MediaWiki:Gadget-blamefinder.js

Материал из Циклопедии
Перейти к навигации Перейти к поиску

Замечание: Возможно, после публикации вам придётся очистить кэш своего браузера, чтобы увидеть изменения.

  • Firefox / Safari: Удерживая клавишу Shift, нажмите на панели инструментов Обновить либо нажмите Ctrl+F5 или Ctrl+R (⌘+R на Mac)
  • Google Chrome: Нажмите Ctrl+Shift+R (⌘+Shift+R на Mac)
  • Internet Explorer / Edge: Удерживая Ctrl, нажмите Обновить либо нажмите Ctrl+F5
  • Opera: Нажмите Ctrl+F5.
/**
 * Gadget: Blame Finder
 * Бета-версия 1.0
 * Назначение: поиск автора любого текста в статье для удобства администраторов
 * Сделан под API для cyclowiki.org, в других вики возможно нужна локализация
 * Рабочей базой служил движок Mediawiki 1.39.1
 * Автор: Урахара
 * Лицензия: CC BY-SA 4.0
 * Использование:
  1. Нажимаете кнопку «Кто автор?» в правом нижнем углу
  2. Выделяете любой текст на странице (минимум 20 символов)
  3. Кликайте по появившейся в центре кнопке таймера (20 секунд на всё)
  4. Гаджет анализирует последние 500 правок страницы
  5. Находит первое появление этого текста в истории
  6. Показывает:
   · Имя автора
   · Дату правки
   · Комментарий к правке
   · Ссылки на версию и diff
 * Что может:
  · Искать авторов фрагментов текста в статьях
  · Расчитан на работу в пространстве статей и форума
  · Поддерживает мышь и сенсорный экран
  · Показывает панель логов с отладкой
 */

(function() {
    'use strict';

    // ===== ЗАЩИТА ОТ CLICKJACKING =====
    if (window.self !== window.top) {
        console.log('BlameFinder: не запускаемся в iframe по соображениям безопасности');
        return;
    }

    // ===== ЭКРАНИРОВАНИЕ =====
    function escapeHtml(text) {
        if (text === undefined || text === null) return '';
        return String(text)
            .replace(/&/g, '&')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;')
            .replace(/'/g, '&#039;')
            .replace(/\//g, '&#x2F;');
    }

    function escapeForLog(text) {
        if (text === undefined || text === null) return '';
        return String(text).replace(/[<>"']/g, function(match) {
            if (match === '<') return '&lt;';
            if (match === '>') return '&gt;';
            if (match === '"') return '&quot;';
            if (match === "'") return '&#39;';
            return match;
        });
    }

    // ===== СОЗДАНИЕ ЭЛЕМЕНТОВ =====
    function createSafeElement(tag, options) {
        var element = document.createElement(tag);
        
        if (!options) return element;
        
        if (options.className) element.className = options.className;
        if (options.id) element.id = options.id;
        if (options.text) element.textContent = options.text;
        
        // Безопасная установка HTML только для проверенного контента
        if (options.html) {
            // Создаем временный div, устанавливаем textContent для экранирования
            var tempDiv = document.createElement('div');
            tempDiv.textContent = options.html;
            element.innerHTML = tempDiv.innerHTML;
        }
        
        if (options.css) {
            Object.keys(options.css).forEach(function(prop) {
                element.style[prop] = options.css[prop];
            });
        }
        
        if (options.attrs) {
            Object.keys(options.attrs).forEach(function(key) {
                // Валидация URL для href и src
                if (key === 'href' || key === 'src') {
                    var value = options.attrs[key];
                    if (value && typeof value === 'string') {
                        // Разрешаем только относительные или безопасные протоколы
                        if (value.startsWith('/') || 
                            value.startsWith('https://') || 
                            value.startsWith('http://') && value.includes('wikimedia.org') ||
                            value.startsWith('#')) {
                            element.setAttribute(key, value);
                        }
                    }
                } else {
                    element.setAttribute(key, options.attrs[key]);
                }
            });
        }
        
        return element;
    }

    // ===== СОЗДАНИЕ ССЫЛКИ =====
    function createSafeLink(url, text, title) {
        // Валидация URL
        var safeUrl = '';
        if (url && typeof url === 'string') {
            // Разрешаем только относительные URL или URL с доверенными доменами
            if (url.startsWith('/') || url.startsWith('#') || 
                url.includes('cyclowiki.org') || url.includes('wikipedia.org')) {
                safeUrl = url;
            } else {
                // Если URL не прошел валидацию, создаем ссылку на #
                safeUrl = '#';
            }
        }
        
        var link = createSafeElement('a', {
            text: text,
            attrs: {
                href: safeUrl,
                target: '_blank',
                rel: 'noopener noreferrer',
                title: title ? escapeHtml(title) : escapeHtml(text)
            }
        });
        
        return link;
    }

    // ===== ВАЛИДАЦИЯ TITLE =====
    function validateTitle(title) {
        if (!title || typeof title !== 'string') return null;
        
        // Удаляем управляющие символы и потенциально опасные
        var cleaned = title.replace(/[\x00-\x1F\x7F<>"']/g, '').trim();
        
        // Проверяем, что после очистки осталось что-то значимое
        return cleaned.length > 0 ? cleaned : null;
    }

    // ===== ПОЛУЧАЕМ ДАННЫЕ О СТРАНИЦЕ =====
    function getPageInfo() {
        var title = null;
        var namespace = 0;
        var isSpecial = false;
        
        // Безопасное получение pathname
        var pathname = window.location.pathname || '';
        var search = window.location.search || '';
        
        if (pathname.includes('Special:') || pathname.includes('Служебная:') ||
            pathname.includes('special:') || pathname.includes('служебная:')) {
            isSpecial = true;
        }
        
        try {
            var urlParams = new URLSearchParams(search);
            title = urlParams.get('title');
            
            if (title) {
                title = validateTitle(title);
                if (title && title.includes(':')) {
                    var parts = title.split(':');
                    var prefix = parts[0].toLowerCase();
                    var excludedNamespaces = [
                        'обсуждение', 'talk', 'участник', 'user', 'user_talk',
                        'обсуждение_участника', 'файл', 'file', 'file_talk',
                        'обсуждение_файла', 'mediawiki', 'mediawiki_talk',
                        'обсуждение_mediawiki', 'шаблон', 'template',
                        'template_talk', 'обсуждение_шаблона', 'справка',
                        'help', 'help_talk', 'обсуждение_справки', 'категория',
                        'category', 'category_talk', 'обсуждение_категории',
                        'портал', 'portal', 'portal_talk', 'обсуждение_портала',
                        'special', 'служебная'
                    ];
                    if (excludedNamespaces.includes(prefix)) {
                        namespace = 1;
                    }
                }
            } else {
                if (pathname.includes('/wiki/')) {
                    var pathTitle = pathname.split('/wiki/')[1];
                    if (pathTitle) {
                        try {
                            pathTitle = decodeURIComponent(pathTitle);
                        } catch (e) {
                            pathTitle = pathTitle;
                        }
                        
                        if (pathTitle.includes(':')) {
                            var pathPrefix = pathTitle.split(':')[0].toLowerCase();
                            var excludedNamespaces = [
                                'обсуждение', 'talk', 'участник', 'user',
                                'файл', 'file', 'mediawiki', 'шаблон',
                                'template', 'справка', 'help', 'категория',
                                'category', 'портал', 'portal', 'special',
                                'служебная'
                            ];
                            if (excludedNamespaces.includes(pathPrefix)) {
                                namespace = 1;
                            }
                        }
                        title = validateTitle(pathTitle);
                    }
                } else {
                    var h1 = document.querySelector('h1.firstHeading, .firstHeading, #firstHeading');
                    if (h1 && h1.textContent) {
                        title = validateTitle(h1.textContent.trim());
                    }
                }
            }
        } catch (e) {
            console.log('BlameFinder: ошибка получения информации о странице', e);
        }
        
        return {
            title: title || 'Main_Page',
            isArticle: namespace === 0 && !isSpecial
        };
    }

    // ===== РЕАЛИЗАЦИЯ API С ПРОВЕРКОЙ ORIGIN =====
    function cycApiRequest(params, callback, errorCallback) {
        var apiUrl = '/w/api.php';
        params.format = 'json';
        params.origin = '*';
        
        // Валидация параметров
        var safeParams = {};
        Object.keys(params).forEach(function(key) {
            var value = params[key];
            if (typeof value === 'string') {
                // Удаляем потенциально опасные символы из параметров
                safeParams[key] = value.replace(/[<>"']/g, '');
            } else {
                safeParams[key] = value;
            }
        });
        
        var queryString = Object.keys(safeParams).map(function(key) {
            return encodeURIComponent(key) + '=' + encodeURIComponent(safeParams[key]);
        }).join('&');
        
        var url = apiUrl + '?' + queryString;
        var xhr = new XMLHttpRequest();
        var timeoutId = null;
        var completed = false;
        
        timeoutId = setTimeout(function() {
            if (!completed) {
                completed = true;
                xhr.abort();
                addLog('Таймаут API: сервер не отвечает', 'error');
                if (errorCallback) errorCallback('timeout');
            }
        }, 30000);
        
        xhr.open('GET', url, true);
        xhr.setRequestHeader('Api-User-Agent', 'BlameFinder/1.2');
        
        xhr.onreadystatechange = function() {
            if (completed) return;
            
            if (xhr.readyState === 4) {
                if (xhr.status === 200) {
                    // Проверяем Content-Type
                    var contentType = xhr.getResponseHeader('Content-Type') || '';
                    if (!contentType.includes('application/json') && !contentType.includes('text/javascript')) {
                        clearTimeout(timeoutId);
                        completed = true;
                        addLog('Неверный Content-Type ответа API', 'error');
                        if (errorCallback) errorCallback('invalid_content_type');
                        return;
                    }
                    
                    try {
                        var data = JSON.parse(xhr.responseText);
                        if (!data || typeof data !== 'object') {
                            throw new Error('Invalid response format');
                        }
                        clearTimeout(timeoutId);
                        completed = true;
                        callback(data);
                    } catch (e) {
                        clearTimeout(timeoutId);
                        completed = true;
                        addLog('Ошибка парсинга ответа API', 'error');
                        if (errorCallback) errorCallback('parse_error');
                    }
                } else {
                    clearTimeout(timeoutId);
                    completed = true;
                    addLog('Ошибка HTTP: ' + xhr.status, 'error');
                    if (errorCallback) errorCallback('http_' + xhr.status);
                }
            }
        };
        
        xhr.onerror = function() {
            if (!completed) {
                clearTimeout(timeoutId);
                completed = true;
                addLog('Ошибка сети при запросе к API', 'error');
                if (errorCallback) errorCallback('network_error');
            }
        };
        
        xhr.send();
    }

    // ===== ПАНЕЛЬ ЛОГОВ =====
    var logPanel = null;
    var logEntries = [];
    
    function createLogPanel() {
        if (logPanel) return;
        
        logPanel = createSafeElement('div', {
            id: 'blame-log-panel',
            css: {
                position: 'fixed',
                bottom: '20px',
                left: '20px',
                width: '400px',
                maxHeight: '300px',
                overflowY: 'auto',
                background: 'rgba(0,0,0,0.9)',
                color: '#0f0',
                fontFamily: 'monospace',
                fontSize: '12px',
                padding: '10px',
                borderRadius: '5px',
                zIndex: '9999',
                border: '2px solid #36c',
                boxShadow: '0 0 20px rgba(0,0,0,0.5)',
                display: 'none'
            }
        });
        
        var header = createSafeElement('div', {
            css: {
                display: 'flex',
                justifyContent: 'space-between',
                marginBottom: '10px',
                paddingBottom: '5px',
                borderBottom: '2px solid #36c'
            }
        });
        
        var titleSpan = createSafeElement('span', {
            text: 'Логи BlameFinder',
            css: {
                color: '#36c',
                fontWeight: 'bold'
            }
        });
        
        var buttonContainer = createSafeElement('div');
        
        var clearButton = createSafeElement('button', {
            id: 'blame-clear',
            text: 'Очистить',
            css: {
                background: 'none',
                border: '1px solid #36c',
                color: '#36c',
                marginRight: '5px',
                cursor: 'pointer',
                padding: '5px'
            }
        });
        
        var toggleButton = createSafeElement('button', {
            id: 'blame-toggle',
            text: 'Скрыть',
            css: {
                background: 'none',
                border: '1px solid #36c',
                color: '#36c',
                cursor: 'pointer',
                padding: '5px'
            }
        });
        
        buttonContainer.appendChild(clearButton);
        buttonContainer.appendChild(toggleButton);
        header.appendChild(titleSpan);
        header.appendChild(buttonContainer);
        
        var content = createSafeElement('div', {
            id: 'blame-log-content',
            css: {
                wordBreak: 'break-word'
            }
        });
        
        logPanel.appendChild(header);
        logPanel.appendChild(content);
        document.body.appendChild(logPanel);
        
        clearButton.onclick = function(e) {
            e.stopPropagation();
            logEntries = [];
            updateLogDisplay();
        };
        
        var logVisible = true;
        toggleButton.onclick = function(e) {
            e.stopPropagation();
            logVisible = !logVisible;
            logPanel.style.display = logVisible ? 'block' : 'none';
            toggleButton.textContent = logVisible ? 'Скрыть' : 'Показать';
        };
        
        logPanel.style.display = 'block';
    }
    
    function addLog(message, type) {
        var timestamp = new Date().toLocaleTimeString('ru');
        var color = '#0f0';
        if (type === 'error') color = '#f00';
        if (type === 'warning') color = '#ff0';
        if (type === 'success') color = '#0f0';
        
        // Экранируем сообщение для логов
        var safeMessage = escapeForLog(String(message));
        
        logEntries.unshift({
            time: timestamp,
            text: safeMessage,
            color: color
        });
        
        if (logEntries.length > 30) logEntries.pop();
        updateLogDisplay();
    }
    
    function updateLogDisplay() {
        var content = document.getElementById('blame-log-content');
        if (!content) return;
        
        // Безопасная очистка
        while (content.firstChild) {
            content.removeChild(content.firstChild);
        }
        
        logEntries.forEach(function(entry) {
            var entryDiv = createSafeElement('div', {
                css: {
                    color: entry.color,
                    marginBottom: '3px'
                }
            });
            
            // Используем textContent для безопасности
            entryDiv.textContent = '[' + entry.time + '] ' + entry.text;
            content.appendChild(entryDiv);
        });
    }

    // ===== КНОПКА =====
    function addButton() {
        addLog('Инициализация BlameFinder для Циклопедии', 'info');
        
        var button = createSafeElement('div', {
            id: 'blame-finder-button',
            text: 'Кто автор?',
            css: {
                position: 'fixed',
                bottom: '20px',
                right: '20px',
                background: '#36c',
                color: 'white',
                padding: '10px 20px',
                borderRadius: '5px',
                cursor: 'pointer',
                zIndex: '9999',
                fontFamily: 'sans-serif',
                fontSize: '14px',
                boxShadow: '0 2px 10px rgba(0,0,0,0.3)'
            }
        });
        
        button.onmouseover = function() {
            this.style.background = '#447ff5';
        };
        
        button.onmouseout = function() {
            this.style.background = '#36c';
        };
        
        button.addEventListener('click', function(e) {
            e.preventDefault();
            activateSelectionMode();
        });
        
        document.body.appendChild(button);
        addLog('Кнопка добавлена в правый нижний угол', 'success');
        createLogPanel();
    }

    // ===== ПЕРЕМЕННЫЕ РЕЖИМА ВЫДЕЛЕНИЯ =====
    var selectionActive = false;
    var selectionTimer = null;
    var selectionHandlers = {};

    // ===== РЕЖИМ ВЫДЕЛЕНИЯ =====
    function activateSelectionMode() {
        if (selectionActive) {
            addLog('Режим выделения уже активен', 'warning');
            return;
        }
        
        selectionActive = true;
        addLog('Режим выделения активирован', 'info');
        addLog('Выделите текст (мышью или касанием)', 'info');
        
        // Создаем тултип безопасно, без innerHTML
        var tooltip = createSafeElement('div', {
            id: 'blame-tooltip',
            css: {
                position: 'fixed',
                top: '50%',
                left: '50%',
                transform: 'translate(-50%, -50%)',
                background: '#36c',
                color: 'white',
                padding: '20px 40px',
                borderRadius: '10px',
                zIndex: '10000',
                fontFamily: 'sans-serif',
                fontSize: '18px',
                textAlign: 'center',
                boxShadow: '0 0 30px rgba(0,0,0,0.5)'
            }
        });
        
        // Создаём элементы безопасно
        var mainText = document.createTextNode('Выделите текст');
        tooltip.appendChild(mainText);
        
        var br = document.createElement('br');
        tooltip.appendChild(br);
        
        var small = createSafeElement('small', {
            css: {
                color: '#ff0'
            }
        });
        
        var smallText = document.createTextNode('осталось ');
        small.appendChild(smallText);
        
        var timerSpan = createSafeElement('span', {
            id: 'blame-timer',
            text: '20',
            css: {
                color: '#ff0'
            }
        });
        small.appendChild(timerSpan);
        
        var secondsText = document.createTextNode(' секунд');
        small.appendChild(secondsText);
        
        tooltip.appendChild(small);
        document.body.appendChild(tooltip);
        
        var seconds = 20;
        var timer = setInterval(function() {
            seconds--;
            var timerSpan = document.getElementById('blame-timer');
            if (timerSpan) timerSpan.textContent = seconds;
        }, 1000);
        
        function mouseHandler() {
            if (!selectionActive) return;
            handleSelection();
        }
        
        function touchHandler() {
            if (!selectionActive) return;
            setTimeout(handleSelection, 100);
        }
        
        function handleSelection() {
            if (!selectionActive) return;
            
            var selection = window.getSelection();
            var selectedText = selection.toString().trim();
            
            if (selectedText.length >= 20) {
                var logText = 'Выделен текст (' + selectedText.length + ' символов): "' + 
                             selectedText.substring(0, 50) + 
                             (selectedText.length > 50 ? '..."' : '"');
                addLog(logText, 'success');
                deactivateSelectionMode();
                findAuthor(selectedText);
            } else if (selectedText.length > 0) {
                addLog('Слишком короткий текст (' + selectedText.length + ' символов, нужно минимум 20)', 'warning');
            }
        }
        
        function deactivateSelectionMode() {
            if (!selectionActive) return;
            
            selectionActive = false;
            
            document.removeEventListener('mouseup', mouseHandler);
            document.removeEventListener('touchend', touchHandler);
            
            if (timer) {
                clearInterval(timer);
                timer = null;
            }
            
            var tip = document.getElementById('blame-tooltip');
            if (tip && tip.parentNode) {
                tip.parentNode.removeChild(tip);
            }
            
            addLog('Режим выделения отключён', 'info');
        }
        
        selectionHandlers = {
            mouse: mouseHandler,
            touch: touchHandler,
            deactivate: deactivateSelectionMode
        };
        
        document.addEventListener('mouseup', mouseHandler);
        document.addEventListener('touchend', touchHandler);
        
        var timeoutId = setTimeout(function() {
            if (selectionActive) {
                addLog('Время вышло', 'warning');
                deactivateSelectionMode();
            }
        }, 20000);
        
        selectionTimer = timeoutId;
    }

    // ===== ПОИСК АВТОРА =====
    function findAuthor(text) {
        var pageInfo = getPageInfo();
        var title = pageInfo.title;
        
        addLog('Начинаем поиск для страницы: ' + escapeForLog(title), 'info');
        
        var logText = 'Ищем текст: "' + text.substring(0, 100) + 
                     (text.length > 100 ? '..."' : '"');
        addLog(logText, 'info');
        
        var loadingDiv = showLoading('Анализирую историю правок...');
        addLog('Запрашиваем историю правок через API', 'info');
        
        var globalTimeout = setTimeout(function() {
            hideLoading(loadingDiv);
            addLog('Глобальный таймаут: сервер не отвечает', 'error');
        }, 30000);
        
        getRevisions(title, function(revisions) {
            clearTimeout(globalTimeout);
            addLog('Получено ревизий: ' + revisions.length, 'info');
            
            if (revisions.length === 0) {
                addLog('Не удалось загрузить историю правок', 'error');
                hideLoading(loadingDiv);
                return;
            }
            
            addLog('Начинаем поиск первого появления текста...', 'info');
            
            findTextAppearance(revisions, text, title, function(result) {
                hideLoading(loadingDiv);
                
                if (result) {
                    addLog('Текст найден! Автор: ' + escapeForLog(result.rev.user), 'success');
                    showResult(result);
                } else {
                    addLog('Текст не найден в истории', 'error');
                }
            });
        }, function(error) {
            clearTimeout(globalTimeout);
            hideLoading(loadingDiv);
            addLog('Ошибка при запросе к API: ' + error, 'error');
        });
    }
    
    function getRevisions(title, callback, errorCallback) {
        var revisions = [];
        var continueParam = {};
        var totalFetched = 0;
        var maxRevisions = 500;
        
        function fetchRevisions() {
            var params = {
                action: 'query',
                prop: 'revisions',
                titles: title,
                rvprop: 'ids|timestamp|user|comment|content',
                rvlimit: 50,
                rvdir: 'older',
                rvslots: 'main',
                formatversion: 2
            };
            
            if (continueParam.rvcontinue) {
                // Валидация continue параметра
                if (typeof continueParam.rvcontinue === 'string') {
                    params.rvcontinue = continueParam.rvcontinue.replace(/[<>"']/g, '');
                }
            }
            
            cycApiRequest(params, function(data) {
                if (data.error) {
                    addLog('Ошибка API: ' + escapeForLog(data.error.info), 'error');
                    if (errorCallback) errorCallback(data.error.info);
                    return;
                }
                
                if (!data.query || !data.query.pages || !data.query.pages[0]) {
                    addLog('Странный ответ от API', 'error');
                    if (errorCallback) errorCallback('invalid_response');
                    return;
                }
                
                var page = data.query.pages[0];
                
                if (page.missing) {
                    addLog('Страница не найдена в API', 'error');
                    if (errorCallback) errorCallback('page_missing');
                    return;
                }
                
                if (page.revisions && page.revisions.length > 0) {
                    page.revisions.forEach(function(rev) {
                        // Валидация данных ревизии
                        revisions.push({
                            revid: rev.revid || 0,
                            timestamp: rev.timestamp || '',
                            user: (rev.user && typeof rev.user === 'string') ? rev.user : 'Аноним',
                            comment: (rev.comment && typeof rev.comment === 'string') ? rev.comment : '—',
                            content: (rev.slots && rev.slots.main && rev.slots.main.content) ? 
                                    rev.slots.main.content : ''
                        });
                    });
                    
                    totalFetched += page.revisions.length;
                    addLog('Загружено ревизий: ' + totalFetched + '/' + maxRevisions, 'info');
                } else {
                    addLog('У страницы нет истории правок', 'warning');
                    callback(revisions);
                    return;
                }
                
                if (data.continue && totalFetched < maxRevisions) {
                    continueParam = {
                        rvcontinue: data.continue.rvcontinue
                    };
                    fetchRevisions();
                } else {
                    if (totalFetched >= maxRevisions) {
                        addLog('Достигнут лимит загрузки (' + maxRevisions + ' ревизий)', 'warning');
                    }
                    callback(revisions);
                }
            }, errorCallback);
        }
        
        fetchRevisions();
    }
    
    function findTextAppearance(revisions, text, title, callback) {
        var oldestFirst = revisions.slice().reverse();
        addLog('Анализируем ' + oldestFirst.length + ' ревизий от старых к новым', 'info');
        
        var foundRev = null;
        var previousContent = null;
        var checkedCount = 0;
        
        for (var i = 0; i < oldestFirst.length; i++) {
            var rev = oldestFirst[i];
            var content = rev.content || '';
            checkedCount++;
            
            if (checkedCount % 50 === 0) {
                addLog('Проверено ' + checkedCount + '/' + oldestFirst.length + ' ревизий...', 'info');
            }
            
            if (content.indexOf(text) !== -1) {
                addLog('Текст найден в ревизии от ' + rev.timestamp + ' (id: ' + rev.revid + ')', 'info');
                
                if (i > 0) {
                    previousContent = oldestFirst[i - 1].content || '';
                    if (previousContent.indexOf(text) !== -1) {
                        addLog('Текст был и в предыдущей ревизии, продолжаем поиск...', 'info');
                        continue;
                    } else {
                        foundRev = rev;
                        addLog('Найдено первое появление! Ревизия: ' + rev.revid, 'success');
                        break;
                    }
                } else {
                    foundRev = rev;
                    addLog('Текст есть в самой первой ревизии страницы', 'info');
                    break;
                }
            }
        }
        
        if (!foundRev) {
            addLog('Текст не найден ни в одной ревизии', 'error');
            callback(null);
            return;
        }
        
        if (previousContent !== null) {
            addLog('Выполняю дополнительную проверку...', 'info');
            
            var appearsInNew = (foundRev.content || '').indexOf(text) !== -1;
            var appearsInOld = (previousContent || '').indexOf(text) !== -1;
            
            if (appearsInNew && !appearsInOld) {
                addLog('Проверка пройдена: текст появился именно в этой правке', 'success');
                callback({ rev: foundRev, title: title });
            } else {
                addLog('Проверка не пройдена: текст изменялся позже', 'warning');
                
                var index = -1;
                for (var j = 0; j < revisions.length; j++) {
                    if (revisions[j].revid === foundRev.revid) {
                        index = j;
                        break;
                    }
                }
                
                if (index !== -1) {
                    revisions.splice(index, 1);
                    addLog('Перезапускаю поиск...', 'info');
                    findTextAppearance(revisions, text, title, callback);
                } else {
                    callback(null);
                }
            }
        } else {
            callback({ rev: foundRev, title: title });
        }
    }

    // ===== БЕЗОПАСНОЕ ОТОБРАЖЕНИЕ РЕЗУЛЬТАТА =====
    function showResult(result) {
        var rev = result.rev;
        var title = result.title;
        
        var date = new Date(rev.timestamp);
        var formattedDate = date.toLocaleString('ru', {
            year: 'numeric',
            month: 'long',
            day: 'numeric',
            hour: '2-digit',
            minute: '2-digit'
        });
        
        addLog('Результат: автор ' + escapeForLog(rev.user) + ', дата ' + formattedDate, 'success');
        
        // Экранирование пользовательских данных
        var safeUser = escapeHtml(rev.user);
        var safeComment = escapeHtml(rev.comment);
        
        // Создаем overlay
        var overlay = createSafeElement('div', {
            id: 'blame-overlay',
            css: {
                position: 'fixed',
                top: '0',
                left: '0',
                right: '0',
                bottom: '0',
                background: 'rgba(0,0,0,0.5)',
                zIndex: '9999'
            }
        });
        
        // Создаём модальное окно
        var modal = createSafeElement('div', {
            id: 'blame-result-modal',
            css: {
                position: 'fixed',
                top: '50%',
                left: '50%',
                transform: 'translate(-50%, -50%)',
                background: 'white',
                border: '2px solid #a2a9b1',
                borderRadius: '8px',
                padding: '20px',
                zIndex: '10000',
                maxWidth: '500px',
                boxShadow: '0 5px 20px rgba(0,0,0,0.3)',
                fontFamily: 'sans-serif'
            }
        });
        
        // Создаём заголовок
        var titleElement = createSafeElement('h3', {
            text: 'Найден автор текста',
            css: {
                marginTop: '0',
                color: '#36c'
            }
        });
        modal.appendChild(titleElement);
        
        // Создаём параграф с участником
        var userP = createSafeElement('p');
        var userStrong = createSafeElement('strong', { text: 'Участник:' });
        userP.appendChild(userStrong);
        userP.appendChild(document.createTextNode(' ' + safeUser));
        modal.appendChild(userP);
        
        // Создаём параграф с датой
        var dateP = createSafeElement('p');
        var dateStrong = createSafeElement('strong', { text: 'Дата правки:' });
        dateP.appendChild(dateStrong);
        dateP.appendChild(document.createTextNode(' ' + formattedDate));
        modal.appendChild(dateP);
        
        // Создаём параграф с комментарием
        var commentP = createSafeElement('p');
        var commentStrong = createSafeElement('strong', { text: 'Комментарий:' });
        commentP.appendChild(commentStrong);
        commentP.appendChild(document.createTextNode(' "' + safeComment + '"'));
        modal.appendChild(commentP);
        
        // Создаём параграф со ссылкой на версию
        var versionP = createSafeElement('p');
        var versionStrong = createSafeElement('strong', { text: 'Версия:' });
        versionP.appendChild(versionStrong);
        versionP.appendChild(document.createTextNode(' '));
        
        var versionUrl = '/w/index.php?title=' + encodeURIComponent(title) + '&oldid=' + rev.revid;
        var versionLink = createSafeLink(versionUrl, 'посмотреть эту версию');
        versionP.appendChild(versionLink);
        modal.appendChild(versionP);
        
        // Создаём параграф со ссылкой на дифф
        var diffP = createSafeElement('p');
        var diffStrong = createSafeElement('strong', { text: 'Дифф:' });
        diffP.appendChild(diffStrong);
        diffP.appendChild(document.createTextNode(' '));
        
        var diffUrl = '/w/index.php?title=' + encodeURIComponent(title) + '&diff=prev&oldid=' + rev.revid;
        var diffLink = createSafeLink(diffUrl, 'посмотреть изменения');
        diffP.appendChild(diffLink);
        modal.appendChild(diffP);
        
        // Разделитель
        var hr = createSafeElement('hr', {
            css: {
                border: 'none',
                borderTop: '1px solid #eaecf0'
            }
        });
        modal.appendChild(hr);
        
        // Кнопка закрытия
        var closeButton = createSafeElement('button', {
            id: 'blame-close',
            text: 'Закрыть',
            css: {
                background: '#36c',
                color: 'white',
                border: 'none',
                padding: '8px 16px',
                borderRadius: '4px',
                cursor: 'pointer'
            }
        });
        modal.appendChild(closeButton);
        
        document.body.appendChild(overlay);
        document.body.appendChild(modal);
        
        function closeModal() {
            var m = document.getElementById('blame-result-modal');
            var o = document.getElementById('blame-overlay');
            
            if (m && m.parentNode) {
                m.parentNode.removeChild(m);
            }
            if (o && o.parentNode) {
                o.parentNode.removeChild(o);
            }
        }
        
        closeButton.onclick = closeModal;
        overlay.onclick = closeModal;
    }
    
    function showLoading(message) {
        var div = createSafeElement('div', {
            id: 'blame-loading',
            text: message || 'Загрузка...',
            css: {
                position: 'fixed',
                top: '20px',
                right: '20px',
                background: '#f8f9fa',
                border: '1px solid #a2a9b1',
                padding: '10px 20px',
                borderRadius: '4px',
                zIndex: '10000',
                fontFamily: 'sans-serif',
                boxShadow: '0 2px 10px rgba(0,0,0,0.1)'
            }
        });
        
        document.body.appendChild(div);
        return div;
    }
    
    function hideLoading(div) {
        if (div && div.parentNode) {
            div.parentNode.removeChild(div);
        }
    }

    // ===== ЗАПУСК =====
    var pageInfo = getPageInfo();
    
    if (pageInfo.isArticle) {
        if (document.readyState === 'complete') {
            addButton();
        } else {
            window.addEventListener('load', addButton);
        }
    } else {
        console.log('BlameFinder: не основное пространство имён, гаджет не активирован');
    }
})();