Convert Any links to Clickable Links

URLやドメイン名(先頭の "h" が抜けている場合も含む)をクリック可能なリンクに変換する。ただし、入力中のフォーム領域は除外する。

当前为 2025-03-11 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Convert Any links to Clickable Links
  3. // @namespace kdroidwin.hatenablog.com
  4. // @version 1.5
  5. // @description URLやドメイン名(先頭の "h" が抜けている場合も含む)をクリック可能なリンクに変換する。ただし、入力中のフォーム領域は除外する。
  6. // @author K
  7. // @match *://*/*
  8. // @exclude *://github.com/*
  9. // @exclude *://chat.openai.com/*
  10. // @exclude *://blog.hatena.ne.jp/*
  11. // @grant none
  12. // @license MIT
  13. // ==/UserScript==
  14.  
  15. (function() {
  16. 'use strict';
  17.  
  18. function isEditable(node) {
  19. if (!node) return false;
  20. if (node.nodeName === 'INPUT' || node.nodeName === 'TEXTAREA') return true;
  21. if (node.hasAttribute && node.hasAttribute('contenteditable') && node.getAttribute('contenteditable') !== 'false') {
  22. return true;
  23. }
  24. return false;
  25. }
  26.  
  27. function convertTextToLinks(root = document.body) {
  28. const pattern = /(h?ttps?:\/\/[^\s]+|(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,})/g;
  29. const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
  30. acceptNode: node => {
  31. let current = node.parentNode;
  32. while (current) {
  33. if (isEditable(current)) return NodeFilter.FILTER_REJECT;
  34. current = current.parentNode;
  35. }
  36. if (node.parentNode && node.parentNode.tagName !== 'A' && pattern.test(node.nodeValue)) {
  37. return NodeFilter.FILTER_ACCEPT;
  38. }
  39. return NodeFilter.FILTER_REJECT;
  40. }
  41. });
  42.  
  43. let nodes = [];
  44. while (walker.nextNode()) nodes.push(walker.currentNode);
  45.  
  46. nodes.forEach(node => {
  47. const fragment = document.createDocumentFragment();
  48. const parts = node.nodeValue.split(pattern);
  49. parts.forEach(part => {
  50. const text = part.trim();
  51. if (/^ttps:\/\//i.test(text)) {
  52. const link = document.createElement('a');
  53. link.href = 'h' + text;
  54. link.textContent = text;
  55. link.target = '_blank';
  56. fragment.appendChild(link);
  57. } else if (/^https?:\/\//i.test(text)) {
  58. const link = document.createElement('a');
  59. link.href = text;
  60. link.textContent = text;
  61. link.target = '_blank';
  62. fragment.appendChild(link);
  63. } else if (/(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}/.test(text)) {
  64. const link = document.createElement('a');
  65. link.href = 'https://' + text;
  66. link.textContent = text;
  67. link.target = '_blank';
  68. fragment.appendChild(link);
  69. } else {
  70. fragment.appendChild(document.createTextNode(part));
  71. }
  72. });
  73. node.parentNode.replaceChild(fragment, node);
  74. });
  75. }
  76.  
  77. function debounce(func, wait) {
  78. let timeout;
  79. return function(...args) {
  80. clearTimeout(timeout);
  81. timeout = setTimeout(() => func.apply(this, args), wait);
  82. };
  83. }
  84.  
  85. // 初回実行
  86. convertTextToLinks();
  87.  
  88. // DOM の変更を監視(変更があったら 300ms 後に実行)
  89. const observer = new MutationObserver(debounce(mutations => {
  90. for (const mutation of mutations) {
  91. for (const node of mutation.addedNodes) {
  92. if (node.nodeType === 1) {
  93. convertTextToLinks(node);
  94. }
  95. }
  96. }
  97. }, 300));
  98.  
  99. observer.observe(document.body, { childList: true, subtree: true });
  100.  
  101. })();