Convert Any Links to Clickable Links

すべてのリンクをクリックできるリンクにかえる。

// ==UserScript==
// @name         Convert Any Links to Clickable Links
// @namespace    kdroidwin.hatenablog.com
// @version      2.8
// @description  すべてのリンクをクリックできるリンクにかえる。
// @author       Kdroidwin
// @match        *://*/*
// @exclude      *://github.com/*
// @exclude      *://chat.openai.com/*
// @exclude      *://blog.hatena.ne.jp/*
// @exclude      *://w.atwiki.jp/*
// @grant        none
// @license      GPL-3.0
// ==/UserScript==

(function() {
    'use strict';

    const urlPattern = /\b(?:h?ttps?:\/\/[^\s<>"]+|(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(?:\/[^\s<>"]*)?)\b/g;

    function convertTextToLinks(node) {
        if (node.nodeType !== 3 || !urlPattern.test(node.nodeValue)) return;

        const parent = node.parentNode;
        if (parent.tagName === 'A' || parent.matches('input, textarea, [contenteditable]')) return;

        const frag = document.createDocumentFragment();
        let lastIndex = 0;
        node.nodeValue.replace(urlPattern, (match, offset) => {
            frag.appendChild(document.createTextNode(node.nodeValue.slice(lastIndex, offset)));

            const a = document.createElement('a');
            a.href = match.startsWith('ttp') ? 'h' + match : match.includes('://') ? match : 'https://' + match;
            a.textContent = match;
            a.target = '_blank';
            frag.appendChild(a);

            lastIndex = offset + match.length;
        });

        frag.appendChild(document.createTextNode(node.nodeValue.slice(lastIndex)));
        parent.replaceChild(frag, node);
    }

    function debounce(func, delay) {
        let timer;
        return (...args) => {
            clearTimeout(timer);
            timer = setTimeout(() => func(...args), delay);
        };
    }

    const observer = new MutationObserver(debounce(mutations => {
        for (const mutation of mutations) {
            if (mutation.type === 'childList') {
                for (const node of mutation.addedNodes) {
                    if (node.nodeType === 1) {
                        for (const textNode of node.childNodes) {
                            convertTextToLinks(textNode);
                        }
                    }
                }
            } else if (mutation.type === 'characterData') {
                convertTextToLinks(mutation.target);
            }
        }
    }, 500));

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

    document.querySelectorAll('*').forEach(el => {
        for (const node of el.childNodes) {
            convertTextToLinks(node);
        }
    });
})();