Convert Any links to Clickable Links

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

目前为 2025-03-11 提交的版本。查看 最新版本

// ==UserScript==
// @name         Convert Any links to Clickable Links
// @namespace    kdroidwin.hatenablog.com
// @version      1.7
// @description  URLやドメイン名(先頭の "h" が抜けている場合も含む)をクリック可能なリンクに変換する。ただし、入力中のフォーム領域は除外する。
// @author       K
// @match        *://*/*
// @exclude      *://github.com/*
// @exclude      *://chat.openai.com/*
// @exclude      *://blog.hatena.ne.jp/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    function isEditable(node) {
        if (!node) return false;
        if (node.nodeName === 'INPUT' || node.nodeName === 'TEXTAREA') return true;
        if (node.hasAttribute && node.hasAttribute('contenteditable') && node.getAttribute('contenteditable') !== 'false') {
            return true;
        }
        return false;
    }

    function convertTextToLinks(root = document.body) {
        const pattern = /(h?ttps?:\/\/[^\s]+|(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(?:\/[^\s]*)?)/g;
        const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
            acceptNode: node => {
                let current = node.parentNode;
                while (current) {
                    if (isEditable(current)) return NodeFilter.FILTER_REJECT;
                    current = current.parentNode;
                }
                if (node.parentNode && node.parentNode.tagName !== 'A' && pattern.test(node.nodeValue)) {
                    return NodeFilter.FILTER_ACCEPT;
                }
                return NodeFilter.FILTER_REJECT;
            }
        });

        let nodes = [];
        while (walker.nextNode()) nodes.push(walker.currentNode);

        nodes.forEach(node => {
            const fragment = document.createDocumentFragment();
            const matches = node.nodeValue.match(pattern);
            if (!matches) return;

            let lastIndex = 0;
            matches.forEach(match => {
                const matchIndex = node.nodeValue.indexOf(match, lastIndex);
                if (matchIndex > lastIndex) {
                    fragment.appendChild(document.createTextNode(node.nodeValue.substring(lastIndex, matchIndex)));
                }

                const link = document.createElement('a');
                if (/^ttps:\/\//i.test(match)) {
                    link.href = 'h' + match;
                } else if (/^https?:\/\//i.test(match)) {
                    link.href = match;
                } else {
                    link.href = 'https://' + match;
                }
                link.textContent = match;
                link.target = '_blank';
                fragment.appendChild(link);

                lastIndex = matchIndex + match.length;
            });

            if (lastIndex < node.nodeValue.length) {
                fragment.appendChild(document.createTextNode(node.nodeValue.substring(lastIndex)));
            }

            node.parentNode.replaceChild(fragment, node);
        });
    }

    function debounce(func, wait) {
        let timeout;
        return function(...args) {
            clearTimeout(timeout);
            timeout = setTimeout(() => func.apply(this, args), wait);
        };
    }

    // 初回実行
    convertTextToLinks();

    // DOM の変更を監視(変更があったら 500ms 後に実行)
    const observer = new MutationObserver(debounce(mutations => {
        for (const mutation of mutations) {
            for (const node of mutation.addedNodes) {
                if (node.nodeType === 1) {
                    convertTextToLinks(node);
                }
            }
        }
    }, 500));  // 500ms に変更

    observer.observe(document.body, { childList: true, subtree: true });

})();