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.8
  5. // @description URLやドメイン名(先頭の "h" が抜けている場合も含む)をクリック可能なリンクに変換する。ただし、入力中のフォーム領域は除外する。
  6. // @author Kdroidwin
  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. // 修正したURLパターン
  29. const pattern = /(h?ttps?:\/\/[^\s]+|(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(?:\/[^\s]*)?)/g;
  30. const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
  31. acceptNode: node => {
  32. let current = node.parentNode;
  33. while (current) {
  34. if (isEditable(current)) return NodeFilter.FILTER_REJECT;
  35. current = current.parentNode;
  36. }
  37. if (node.parentNode && node.parentNode.tagName !== 'A' && pattern.test(node.nodeValue)) {
  38. return NodeFilter.FILTER_ACCEPT;
  39. }
  40. return NodeFilter.FILTER_REJECT;
  41. }
  42. });
  43.  
  44. let nodes = [];
  45. while (walker.nextNode()) nodes.push(walker.currentNode);
  46.  
  47. nodes.forEach(node => {
  48. const fragment = document.createDocumentFragment();
  49. const matches = node.nodeValue.match(pattern);
  50. if (!matches) return;
  51.  
  52. let lastIndex = 0;
  53. matches.forEach(match => {
  54. const matchIndex = node.nodeValue.indexOf(match, lastIndex);
  55. if (matchIndex > lastIndex) {
  56. fragment.appendChild(document.createTextNode(node.nodeValue.substring(lastIndex, matchIndex)));
  57. }
  58.  
  59. const link = document.createElement('a');
  60. if (/^ttps:\/\//i.test(match)) {
  61. link.href = 'h' + match;
  62. } else if (/^https?:\/\//i.test(match)) {
  63. link.href = match;
  64. } else {
  65. link.href = 'https://' + match;
  66. }
  67. link.textContent = match;
  68. link.target = '_blank';
  69. fragment.appendChild(link);
  70.  
  71. lastIndex = matchIndex + match.length;
  72. });
  73.  
  74. if (lastIndex < node.nodeValue.length) {
  75. fragment.appendChild(document.createTextNode(node.nodeValue.substring(lastIndex)));
  76. }
  77.  
  78. node.parentNode.replaceChild(fragment, node);
  79. });
  80. }
  81.  
  82. function debounce(func, wait) {
  83. let timeout;
  84. return function(...args) {
  85. clearTimeout(timeout);
  86. timeout = setTimeout(() => func.apply(this, args), wait);
  87. };
  88. }
  89.  
  90. // 初回実行
  91. convertTextToLinks();
  92.  
  93. // DOM の変更を監視(変更があったら 500ms 後に実行)
  94. const observer = new MutationObserver(debounce(mutations => {
  95. for (const mutation of mutations) {
  96. for (const node of mutation.addedNodes) {
  97. if (node.nodeType === 1) {
  98. convertTextToLinks(node);
  99. }
  100. }
  101. }
  102. }, 500));
  103.  
  104. observer.observe(document.body, { childList: true, subtree: true });
  105.  
  106. })();