Универсальные кликабельные номера телефонов на странице (Международные)

Скрипт для замены номеров телефона на кликабельные ссылки, работает с любыми международными номерами на любых страницах.

// ==UserScript==
// @name             Универсальные кликабельные номера телефонов на странице (Международные)
// @name:en          Universal Clickable Phone Numbers (International)
// @namespace        http://tampermonkey.net/
// @version          2.6
// @description      Скрипт для замены номеров телефона на кликабельные ссылки, работает с любыми международными номерами на любых страницах.
// @description:en   Script to replace phone numbers with clickable links, works with any international numbers on any pages.
// @author           Coffee_Feather
// @match            *://*/*
// @grant            none
// @noframes
// @license          MIT
// ==/UserScript==

(function() {
    'use strict';

    const config = {
        phonePatterns: [
            {
                type: 'RU_FULL',
                regex: /(?:\+7|8|7)[\s()-]*\d{3}[\s()-]*\d{3}[\s()-]*\d{2}[\s()-]*\d{2}/g,
                minDigits: 11,
                maxDigits: 11,
                normalize: (match) => {
                    let digits = match.replace(/\D/g, '');
                    digits = digits.startsWith('8') ? '+7' + digits.slice(1) : '+7' + digits.slice(1);
                    return digits;
                },
                disabled: false
            },
            {
                type: 'UA_FULL',
                regex: /(?:\+380|380|\+38|38)(?:[\s()-]*\d){9,10}/g,
                minDigits: 12,
                maxDigits: 12,
                normalize: (match) => {
                    let digits = match.replace(/\D/g, '');
                    // Обработка формата +38XX...
                    if (digits.startsWith('38') && digits.length === 10) {
                        return '+380' + digits.slice(2);
                    }
                    // Обработка формата +380XX...
                    if (digits.startsWith('380')) {
                        return '+' + digits;
                    }
                    // Все остальные случаи считаем невалидными
                    return null;
                },
                disabled: false
            },
            {
                type: 'INTERNATIONAL',
                regex: /\+\d{1,4}(?:[\s()-]*\d){6,14}/g, // Разрешены разделители между цифрами
                minDigits: 7, // Минимальная длина: код страны (1-4) + номер (6+)
                maxDigits: 15, // Максимальная длина: код (4) + номер (11)
                normalize: function(match) {
                    const digits = match.replace(/\D/g, '');
                    const codeLength = digits.match(/^\+\d+/)?.[0].length || 0;
                    // Проверка длины номера (без кода страны)
                    const numberLength = digits.length - codeLength;
                    if (numberLength < 6 || numberLength > 11) return null;
                    return digits;
                },
                disabled: true
            }
        ],
        excludedDomains: [''],
        allowedParents: ['DIV', 'SPAN', 'P', 'TD', 'LABEL'],
        forbiddenParents: ['A', 'SCRIPT', 'STYLE', 'TEXTAREA'],
        forbiddenClasses: ['sidebar', 'sidebar__menu-item', 'compose-button', 'settings'],
        debounceTime: 300
    };

    function isForbidden(node) {
        if (!node) return true;

        if (node.classList && config.forbiddenClasses.some(c => node.classList.contains(c))) {
            return true;
        }

        if (config.forbiddenParents.includes(node.tagName)) {
            return true;
        }

        let parent = node.parentNode;
        while (parent && parent !== document.body) {
            if (parent.classList && config.forbiddenClasses.some(c => parent.classList.contains(c))) {
                return true;
            }
            if (config.forbiddenParents.includes(parent.tagName)) {
                return true;
            }
            parent = parent.parentNode;
        }

        return false;
    }

    function safeReplace(textNode) {
        try {
            const text = textNode.nodeValue;
            const parent = textNode.parentNode;

            if (!parent || !config.allowedParents.includes(parent.tagName) || isForbidden(parent)) return;

            const matches = [];

            config.phonePatterns.forEach(pattern => {
                if (pattern.disabled) return; // Пропускаем отключенные модули
                let match;
                const regex = new RegExp(pattern.regex.source, 'g');
                while ((match = regex.exec(text)) !== null) {
                    const [fullMatch] = match;
                    const digitsOnly = fullMatch.replace(/\D/g, '');

                    // Проверка длины для конкретного паттерна
                    const length = digitsOnly.length;
                    if (length < pattern.minDigits || length > pattern.maxDigits) continue;

                    // Нормализация номера
                    const normalized = pattern.normalize(fullMatch);

                    matches.push({
                        original: fullMatch,
                        index: match.index,
                        normalized,
                        type: pattern.type
                    });
                }
            });

            if (matches.length === 0) return;

            // Сортировка и удаление пересечений
            matches.sort((a, b) => a.index - b.index);
            const filteredMatches = [];
            let lastEnd = -1;

            for (const match of matches) {
                if (match.index > lastEnd) {
                    filteredMatches.push(match);
                    lastEnd = match.index + match.original.length;
                }
            }

            // Создание DOM элементов
            const fragment = document.createDocumentFragment();
            let lastIndex = 0;

            filteredMatches.forEach(({original, index, normalized, type}) => {
                if (index > lastIndex) {
                    fragment.appendChild(document.createTextNode(text.slice(lastIndex, index)));
                }

                const link = document.createElement('a');
                link.href = `tel:${normalized}`;
                link.dataset.phoneType = type;
                link.style.cssText = 'color: inherit; text-decoration: inherit;';
                link.textContent = original;

                fragment.appendChild(link);
                lastIndex = index + original.length;
            });

            if (lastIndex < text.length) {
                fragment.appendChild(document.createTextNode(text.slice(lastIndex)));
            }

            parent.replaceChild(fragment, textNode);
        } catch(e) {
            console.error('Phone replace error:', e);
        }
    }

    function processor() {
        // Проверка исключений для домена и всех поддоменов
        const isExcluded = config.excludedDomains.some(domain => {
            const host = location.hostname.toLowerCase();
            const checkDomain = domain.toLowerCase().replace(/^\./, '');

            // Проверка точного совпадения или поддомена
            return host === checkDomain ||
                host.endsWith(`.${checkDomain}`);
        });

        if (isExcluded) {
            console.log('Домен исключен:', location.hostname);
            return;
        }

        const walker = document.createTreeWalker(
            document.body,
            NodeFilter.SHOW_TEXT,
            {
                acceptNode(node) {
                    if (isForbidden(node.parentElement)) return NodeFilter.FILTER_REJECT;

                    // Фильтруем отключенные паттерны
                    const activePatterns = config.phonePatterns.filter(p => !p.disabled);
                    return activePatterns.some(pattern => {
                        const regex = new RegExp(pattern.regex.source);
                        return regex.test(node.nodeValue);
                    }) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
                }
            }
        );

        const nodes = [];
        while (walker.nextNode()) nodes.push(walker.currentNode);
        nodes.reverse().forEach(safeReplace);
    }

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

    // Инициализация
    if (!config.excludedDomains.includes(location.hostname)) {
        processor();
        const debouncedProcessor = debounce(processor, config.debounceTime);
        new MutationObserver(debouncedProcessor).observe(document.body, {
            childList: true,
            subtree: true,
            characterData: true
        });
    }
})();