Telegram Scraper (Menu Commands v2.3.2 - Auto Start & Random Channels)

Scrapes Telegram, sends to n8n. GUI settings, auto-start, random channels. See GitHub for full instructions.

目前為 2025-06-05 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Telegram Scraper (Menu Commands v2.3.2 - Auto Start & Random Channels)
// @name:ru      Telegram Scraper (Команды меню v2.3.2 - Автозапуск и Случайные каналы)
// @namespace    http://tampermonkey.net/
// @version      2.3.2
// @description  Scrapes Telegram, sends to n8n. GUI settings, auto-start, random channels. See GitHub for full instructions.
// @description:ru Собирает сообщения из Telegram, отправляет в n8n. GUI настроек, автозапуск, случайный порядок каналов. Полная инструкция на GitHub.
// @author       Igor Lebedev (Adapted by Gemini Pro)
// @license      MIT
// @homepageURL  https://github.com/LebedevIV/telegram-web-scraper
// @supportURL   https://github.com/LebedevIV/telegram-web-scraper/issues
// @match        https://web.telegram.org/k/*
// @match        https://web.telegram.org/a/*
// @match        https://web.telegram.org/z/*
// @require      https://openuserjs.org/src/libs/sizzle/GM_config.js
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_info
// @run-at       document-idle
// ==/UserScript==

/*
    ENGLISH COMMENTS:
    This script is designed to scrape messages from specified Telegram channels
    when viewed in a web browser (web.telegram.org). It extracts relevant data
    (title, text, link, publication date, source, message ID) and sends it to
    a configured n8n webhook.

    Key Features:
    - Scrapes single currently viewed channel or all predefined channels.
    - Uses GM_config library for a graphical user interface for settings.
    - Handles message age limits to avoid scraping very old messages.
    - Navigates between channels in multi-channel mode.
    - Includes randomized delays to mimic human behavior.
    - Provides Tampermonkey menu commands for control.
    - Scheduled auto-start for multi-channel scraping (runs when the Telegram Web page is open at the specified time).
    - Option to randomize the order of channels for multi-channel scraping.

    РУССКИЕ КОММЕНТАРИИ:
    Этот скрипт предназначен для сбора сообщений из указанных Telegram-каналов
    при их просмотре в веб-браузере (web.telegram.org). Он извлекает релевантные данные
    (заголовок, текст, ссылку, дату публикации, источник, ID сообщения) и отправляет их
    на настроенный веб-хук n8n.

    Ключевые особенности:
    - Сбор данных с одного текущего канала или со всех предустановленных каналов.
    - Использует библиотеку GM_config для графического интерфейса настроек.
    - Учитывает максимальный возраст сообщений, чтобы не собирать слишком старые.
    - Осуществляет навигацию между каналами в многоканальном режиме.
    - Включает рандомизированные задержки для имитации человеческого поведения.
    - Предоставляет команды управления через меню Tampermonkey.
    - Автоматический запуск сбора со всех каналов по расписанию (срабатывает, если страница Telegram Web открыта в указанное время).
    - Опция случайного порядка сбора каналов при многоканальном сборе.
*/

(function() {
    'use strict';

    // --- GLOBAL SCRIPT VARIABLES (NOT SETTINGS) ---
    // --- ГЛОБАЛЬНЫЕ ПЕРЕМЕННЫЕ СКРИПТА (НЕ НАСТРОЙКИ) ---
    let isScrapingSingle = false; // Flag: true if single channel scraping is active
                                  // Флаг: true, если активен сбор с одного канала
    let isMultiChannelScrapingActive = false; // Flag: true if multi-channel scraping is active
                                              // Флаг: true, если активен многоканальный сбор
    let currentChannelIndex = 0; // Index for iterating through target channels in multi-channel mode
                                 // Индекс для перебора целевых каналов в многоканальном режиме
    let currentScrapingChannelInfo = null; // Object holding info of the channel currently being scraped
                                           // Объект с информацией о канале, который скрапится в данный момент
    let consecutiveScrollsWithoutNewFound = 0; // Counter for scrolls without finding new messages (to stop early)
                                               // Счетчик прокруток без нахождения новых сообщений (для ранней остановки)
    let autoStartCheckInterval = null; // Interval ID for checking auto-start time
                                       // ID интервала для проверки времени автозапуска
    const LAST_AUTO_SCRAPE_DATE_KEY = 'TeleScraper_lastAutoScrapeDate'; // GM_setValue key for last auto-scrape date (prefixed for uniqueness)
                                                                       // Ключ GM_setValue для даты последнего авто-сбора (с префиксом для уникальности)

    // --- SCRIPT CONSTANTS ---
    // --- КОНСТАНТЫ СКРИПТА ---

    // Original list of target channels. This list is used as the base.
    // Исходный список целевых каналов. Этот список используется как основа.
    const TARGET_CHANNELS_DATA_ORIGINAL = [
        { name: '@e1_news', id: '-1049795479' }, { name: '@RU66RU', id: '-1278627542' },
        { name: '@ekb4tv', id: '-1184077858' }, { name: '@rentv_news', id: '-1310155678' },
        { name: '@TauNewsEkb', id: '-1424016223' }, { name: '@BEZUMEKB', id: '-1739473739' },
        { name: '@zhest_dtp66', id: '-2454557093' }, { name: '@sverdlovskaya_oblasti', id: '-1673288653' },
        { name: '@novosti_ekb66', id: '-1662411694' }
    ];
    // Working copy of channels; this array can be shuffled if randomization is enabled.
    // Рабочая копия списка каналов; этот массив может быть перемешан, если включена рандомизация.
    let currentTargetChannels = [...TARGET_CHANNELS_DATA_ORIGINAL];

    // Settings keys that require a page reload or script restart to take full effect.
    // Ключи настроек, требующие перезагрузки страницы или перезапуска скрипта для полного вступления в силу.
    const SETTINGS_REQUIRING_RELOAD = [
        'N8N_WEBHOOK_URL'
    ];

    // --- HELPER FUNCTIONS ---
    // --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ---

    /**
     * Custom console logger with a script prefix.
     * @param {string} message - The message to log.
     * @param {boolean} [isError=false] - If true, logs as an error.
     *
     * Пользовательский логгер консоли с префиксом скрипта.
     * @param {string} message - Сообщение для лога.
     * @param {boolean} [isError=false] - Если true, логируется как ошибка.
     */
    function consoleLog(message, isError = false) {
        const prefix = "[TeleScraper]";
        if (isError) { console.error(`${prefix} ${message}`); }
        else { console.log(`${prefix} ${message}`); }
    }

    /**
     * Updates status for console, primarily for user feedback during scraping.
     * @param {string} message - The status message.
     * @param {boolean} [isError=false] - If true, logs as an error.
     *
     * Обновляет статус в консоли, в основном для обратной связи с пользователем во время сбора данных.
     * @param {string} message - Статусное сообщение.
     * @param {boolean} [isError=false] - Если true, логируется как ошибка.
     */
    function updateStatusForConsole(message, isError = false) {
        consoleLog(message, isError);
        // In a more complex UI, this could update a status display element on the page.
        // В более сложном пользовательском интерфейсе это могло бы обновлять элемент отображения статуса на странице.
    }

    consoleLog(`v${GM_info.script.version} Script execution started.`);

    // --- GM_CONFIG SETUP ---
    // --- НАСТРОЙКА GM_CONFIG ---

    // Generate a unique ID for GM_config storage based on script version to avoid conflicts between script versions.
    // Генерация уникального ID для хранилища GM_config на основе версии скрипта во избежание конфликтов между версиями скрипта.
    const GM_CONFIG_ID = `TeleScraperConfig_v${GM_info.script.version.replace(/\./g, '_')}`;

    // Define the fields for the GM_config settings GUI.
    // Определение полей для графического интерфейса настроек GM_config.
    let configFields = {
        'N8N_WEBHOOK_URL': {
            'label': 'N8N Webhook URL:',
            'type': 'text',
            'default': 'http://localhost:5678/webhook/telegram-scraped-news',
            'section': ['Основные настройки / Main Settings'], // Section header in GUI / Заголовок секции в GUI
        },
        'MAX_MESSAGE_AGE_HOURS': {
            'label': 'Max message age (hours):',
            'type': 'int',
            'default': 24, // Good for daily auto-runs / Подходит для ежедневных автозапусков
            'min': 1,
            'max': 720 // 30 days / 30 дней
        },
        'BASE_SCRAPE_INTERVAL_MS': {
            'label': 'Base scrape interval (ms) (scroll up frequency):',
            'label:ru': 'Базовый интервал скрапинга (мс) (частота прокрутки вверх):',
            'type': 'int',
            'default': 60000,
            'min': 1000
        },
        'BASE_SCROLL_PAUSE_MS': {
            'label': 'Pause after scroll action (ms):',
            'label:ru': 'Пауза после действия прокрутки (мс):',
            'type': 'int',
            'default': 10000,
            'min': 500
        },
        'BASE_SEND_DELAY_MS': {
            'label': 'Delay before sending each message to n8n (ms):',
            'label:ru': 'Задержка перед отправкой каждого сообщения в n8n (мс):',
            'type': 'int',
            'default': 2500,
            'min': 100
        },
        'CONSECUTIVE_SCROLLS_LIMIT': {
            'label': 'Empty scrolls limit (stops channel if no new messages found after N scrolls):',
            'label:ru': 'Лимит пустых скроллов (останавливает канал, если нет новых сообщений после N прокруток):',
            'type': 'int',
            'default': 5,
            'min': 1
        },
        // Auto Start Section / Секция Автоматического Запуска
        'AUTO_START_ENABLED': {
            'label': 'Enable Automatic Scraping (All Channels):',
            'label:ru': 'Включить автоматический сбор (Все каналы):',
            'type': 'checkbox',
            'default': false,
            'section': ['Автоматический запуск / Automatic Start'],
            'title': 'If checked, the script will attempt to run "Scrape All Listed Channels" daily at the specified time, provided the Telegram Web tab is open. / Если отмечено, скрипт попытается запустить "Собрать со всех каналов" ежедневно в указанное время, если вкладка Telegram Web открыта.'
        },
        'AUTO_START_TIME': {
            'label': 'Scheduled Start Time (HH:MM, 24-hour local time):',
            'label:ru': 'Время запуска по расписанию (ЧЧ:ММ, 24-часовой формат, местное время):',
            'type': 'text', // Using text for HH:MM format, user must ensure correct format / Используется текст для формата ЧЧ:ММ, пользователь должен обеспечить правильный формат
            'default': '10:00',
            'size': 5, // Visual hint for input size / Визуальная подсказка размера поля ввода
            'title': 'Example: 09:30 for 9:30 AM, 22:15 for 10:15 PM'
        },
        // Fine-tuning Section / Секция Тонких Настроек
        'RANDOMIZE_CHANNEL_ORDER': {
            'label': 'Randomize channel order for multi-scrape:',
            'label:ru': 'Случайный порядок каналов при мульти-сборе:',
            'type': 'checkbox',
            'default': true,
            'section': ['Тонкие настройки (паузы и попытки) / Fine-tuning (pauses and attempts)'],
            'title': 'If checked, the order of channels from TARGET_CHANNELS_DATA will be shuffled before each multi-channel scrape. / Если отмечено, порядок каналов из TARGET_CHANNELS_DATA будет перемешан перед каждым многоканальным сбором.'
        },
        'NAVIGATION_INITIATION_PAUSE_MS': { 'label': 'Pause after navigation hash change (ms):', 'type': 'int', 'default': 5000, 'min': 500 },
        'CHANNEL_ACTIVATION_ATTEMPT_PAUSE_MS': { 'label': 'Pause between channel activation attempts (ms):', 'type': 'int', 'default': 1500, 'min': 100 },
        'MAX_CHANNEL_ACTIVATION_ATTEMPTS': { 'label': 'Max channel activation attempts:', 'type': 'int', 'default': 25, 'min': 1 },
        'BASE_SCROLL_ACTION_PAUSE_MS': { 'label': 'Short pause before/after scroll action (ms):', 'type': 'int', 'default': 1000, 'min': 50 },
        'BASE_SCROLL_BOTTOM_PROG_PAUSE_MS': { 'label': 'Pause during programmatic scroll to bottom (ms):', 'type': 'int', 'default': 2000, 'min': 100 },
        'BASE_SCROLL_BOTTOM_CLICK_PAUSE_MS': { 'label': 'Pause after "scroll to bottom" button click (ms):', 'type': 'int', 'default': 2500, 'min': 500 },
        'SCROLL_BOTTOM_PROGRAMMATIC_ITERATIONS': { 'label': 'Programmatic scroll to bottom iterations:', 'type': 'int', 'default': 3, 'min': 1 },
        'MAX_GO_TO_BOTTOM_CLICKS': { 'label': 'Max clicks on "scroll to bottom" button (with badge):', 'type': 'int', 'default': 3, 'min': 0 },
        'RANDOMNESS_FACTOR_MAJOR': { 'label': 'Randomness factor for major pauses (0.0-1.0):', 'type': 'float', 'default': 0.4, 'min': 0, 'max': 1 },
        'RANDOMNESS_FACTOR_MINOR': { 'label': 'Randomness factor for minor pauses (0.0-1.0):', 'type': 'float', 'default': 0.2, 'min': 0, 'max': 1 },
        'USE_FOCUS_IN_SCROLL_UP': { 'label': 'Use focus() during scroll up (experimental):', 'type': 'checkbox', 'default': false }
    };

    // Modify labels to include default values and reload info.
    // Модификация меток для включения значений по умолчанию и информации о перезагрузке.
    for (const key in configFields) {
        if (configFields.hasOwnProperty(key)) {
            let labelSuffix = ` (по умолчанию: ${configFields[key].default})`;
            if (SETTINGS_REQUIRING_RELOAD.includes(key)) {
                labelSuffix += ' [требуется перезагрузка / reload required]';
            }
            configFields[key].label += labelSuffix;
        }
    }

    // Event handlers for GM_config GUI.
    // Обработчики событий для GUI GM_config.
    const configEventHandlers = {
        'open': function(doc) { // 'doc' is the GM_config iframe's document / 'doc' - это документ iframe GM_config
            const urlFieldInputId = `${GM_CONFIG_ID}_field_N8N_WEBHOOK_URL`; // ID for the URL input field / ID для поля ввода URL

            // Styles for the content INSIDE the GM_config iframe.
            // Стили для содержимого ВНУТРИ iframe GM_config.
            const style = doc.createElement('style');
            style.textContent = `
                #${GM_CONFIG_ID}_wrapper { font-family: Arial, sans-serif; }
                #${GM_CONFIG_ID}_header { background-color: #4a4a4a; color: white; padding: 10px; font-size: 1.2em; margin-bottom: 10px; }
                .section_header { background-color: #f0f0f0; padding: 8px; margin-top: 15px; margin-bottom: 5px; border-top: 1px solid #ddd; border-bottom: 1px solid #ddd; font-weight: bold; color: #333; }
                .config_var { margin: 10px 15px; padding: 8px 0; border-bottom: 1px solid #eee; display: flex; flex-direction: column; }
                .config_var label { display: block; margin-bottom: 5px; color: #555; font-size: 0.9em; font-weight: normal; text-align: left; }
                .config_var input { padding: 6px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; margin-left: 0; width: 280px; max-width: 100%; }
                #${urlFieldInputId} { width: 100% !important; min-width: 450px !important; } /* Specific width for URL field / Особая ширина для поля URL */
                .config_var input[type="checkbox"] { width: auto !important; margin-right: auto; align-self: flex-start; }
                #${GM_CONFIG_ID}_buttons_holder { padding: 15px; text-align: right; border-top: 1px solid #ddd; background-color: #f9f9f9; }
                #${GM_CONFIG_ID}_saveBtn, #${GM_CONFIG_ID}_resetBtn, #${GM_CONFIG_ID}_closeBtn { padding: 8px 15px; margin-left: 10px; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; }
                #${GM_CONFIG_ID}_saveBtn { background-color: #4CAF50; color: white; }
                #${GM_CONFIG_ID}_resetBtn { background-color: #f44336; color: white; }
                #${GM_CONFIG_ID}_closeBtn { background-color: #bbb; color: black; }
            `;
            doc.head.appendChild(style);

            const firstInput = doc.querySelector('input[type="text"], input[type="number"], input[type="checkbox"]');
            if (firstInput) {
                firstInput.focus();
            }
        },
        'save': function() {
            consoleLog("Настройки сохранены через GM_config GUI. / Settings saved via GM_config GUI.");
            alert("Настройки сохранены! Некоторые изменения (URL, автозапуск) могут потребовать перезагрузки или вступят в силу при следующей проверке.\n\nSettings saved! Some changes (URL, auto-start) may require a reload or will take effect on the next check.");
            setupAutoStart(); // Re-initialize auto-start check with new settings / Переинициализация проверки автозапуска с новыми настройками
        },
        'reset': function() {
            consoleLog("Настройки сброшены через GM_config GUI. / Settings reset via GM_config GUI.");
            alert("Настройки сброшены к значениям по умолчанию! Пожалуйста, перезагрузите страницу.\n\nSettings have been reset to default! Please reload the page.");
            setupAutoStart(); // Re-evaluate auto-start with default settings / Переоценка автозапуска с настройками по умолчанию
        }
    };

    let gmConfigInitialized = false;
    try {
        if (typeof GM_config !== 'undefined' && typeof GM_info !== 'undefined') {
            GM_config.init({
                'id': GM_CONFIG_ID,
                'title': `Настройки Telegram Scraper v${GM_info.script.version}`,
                'fields': configFields,
                'events': configEventHandlers,
                'frameStyle': { // Styles for the GM_config iframe itself / Стили для самого iframe GM_config
                    width: '1000px',
                    height: '75vh', // Relative to viewport height / Относительно высоты окна просмотра
                    minHeight: '500px',
                    border: '1px solid rgb(0, 0, 0)', // Default GM_config style / Стиль GM_config по умолчанию
                    margin: '0px',
                    maxHeight: '95%', // Limit to 95% of viewport height / Ограничение до 95% высоты окна просмотра
                    maxWidth: '95%',  // Limit to 95% of viewport width / Ограничение до 95% ширины окна просмотра
                    opacity: '1',
                    overflow: 'auto',
                    padding: '0px',
                    position: 'fixed',
                    zIndex: '9999' // Ensure it's on top / Гарантируем, что он поверх всего
                }
            });
            gmConfigInitialized = true;
            consoleLog("GM_config инициализирован. / GM_config initialized.");
        } else {
            if (typeof GM_config === 'undefined') consoleLog("GM_config не определен. Библиотека не загрузилась или есть конфликт. / GM_config is not defined. Library might not have loaded or there's a conflict.", true);
            if (typeof GM_info === 'undefined') consoleLog("GM_info не определен. Не могу получить версию скрипта. / GM_info is not defined. Cannot get script version.", true);
        }
    } catch (e) {
        consoleLog("Ошибка инициализации GM_config: / Error initializing GM_config: " + e, true);
        alert("Ошибка инициализации GM_config. Скрипт может работать некорректно. / Error initializing GM_config. The script might not work correctly.");
    }

    /**
     * Retrieves a configuration value using GM_config, with a fallback to default.
     * Includes checks for GM_config readiness.
     * @param {string} key - The configuration key.
     * @param {*} defaultValue - The default value if the key is not found or GM_config is not ready.
     * @returns {*} The configuration value or the default.
     *
     * Получает значение конфигурации с помощью GM_config, с возвратом к значению по умолчанию.
     * Включает проверки готовности GM_config.
     * @param {string} key - Ключ конфигурации.
     * @param {*} defaultValue - Значение по умолчанию, если ключ не найден или GM_config не готов.
     * @returns {*} Значение конфигурации или значение по умолчанию.
     */
    function getConfigValue(key, defaultValue) {
        if (gmConfigInitialized && typeof GM_config.get === 'function' && (typeof GM_config.isInit === 'undefined' || GM_config.isInit) ) {
            try {
                const val = GM_config.get(key);
                return typeof val !== 'undefined' ? val : defaultValue;
            } catch (e) {
                consoleLog(`Ошибка при вызове GM_config.get('${key}'): ${e}. Используется значение по умолчанию. / Error calling GM_config.get('${key}'): ${e}. Using default value.`, true);
                const field = configFields[key]; // Fallback to hardcoded defaults if GM_config fails post-init
                                                 // Возврат к жестко заданным значениям по умолчанию, если GM_config не срабатывает после init
                return field && typeof field.default !== 'undefined' ? field.default : defaultValue;
            }
        }
        // Fallback if GM_config is not initialized or not ready
        // Фоллбэк, если GM_config не инициализирован или не готов
        const field = configFields[key];
        return field && typeof field.default !== 'undefined' ? field.default : defaultValue;
    }

    /**
     * Returns a randomized interval based on a base interval and a randomness factor.
     * @param {number} baseInterval - The base interval in milliseconds.
     * @param {string} [randomnessFactorKey='RANDOMNESS_FACTOR_MAJOR'] - The key for the randomness factor in settings.
     * @returns {number} The randomized interval in milliseconds.
     *
     * Возвращает рандомизированный интервал на основе базового интервала и коэффициента случайности.
     * @param {number} baseInterval - Базовый интервал в миллисекундах.
     * @param {string} [randomnessFactorKey='RANDOMNESS_FACTOR_MAJOR'] - Ключ для коэффициента случайности в настройках.
     * @returns {number} Рандомизированный интервал в миллисекундах.
     */
    function getRandomizedInterval(baseInterval, randomnessFactorKey = 'RANDOMNESS_FACTOR_MAJOR') {
        const defaultFactor = configFields[randomnessFactorKey] ? configFields[randomnessFactorKey].default : 0.3;
        const factor = getConfigValue(randomnessFactorKey, defaultFactor);
        const delta = baseInterval * factor * (Math.random() - 0.5) * 2;
        return Math.max(50, Math.round(baseInterval + delta));
    }

    // --- AUTO-START LOGIC ---
    // --- ЛОГИКА АВТОЗАПУСКА ---

    /**
     * Checks if auto-scraping should run based on settings and current time.
     * This function is called periodically by an interval timer if auto-start is enabled.
     *
     * Проверяет, должен ли запуститься автоматический сбор на основе настроек и текущего времени.
     * Эта функция вызывается периодически таймером интервала, если автозапуск включен.
     */
    async function checkAndRunAutoScrape() {
        // Ensure GM_config is fully initialized before attempting to read values.
        // Убеждаемся, что GM_config полностью инициализирован перед попыткой чтения значений.
        if (!gmConfigInitialized || (typeof GM_config !== 'undefined' && typeof GM_config.isInit !== 'undefined' && !GM_config.isInit) ) {
            consoleLog("[AutoStart] GM_config еще не готов для проверки автозапуска. / GM_config not yet ready for auto-start check.");
            return;
        }

        if (!getConfigValue('AUTO_START_ENABLED', false)) {
            // consoleLog("[AutoStart] Auto-start is disabled in settings."); // Optional: log if disabled
            return;
        }
        if (isScrapingSingle || isMultiChannelScrapingActive) {
            // consoleLog("[AutoStart] Scraping is already in progress. Auto-start skipped for this interval.");
            return;
        }

        const scheduledTimeStr = getConfigValue('AUTO_START_TIME', '10:00');
        const parts = scheduledTimeStr.split(':');
        if (parts.length !== 2) {
            consoleLog(`[AutoStart] Неверный формат времени автозапуска: ${scheduledTimeStr}. Используйте ЧЧ:ММ. / Invalid auto-start time format: ${scheduledTimeStr}. Use HH:MM.`, true);
            return;
        }
        const scheduledHour = parseInt(parts[0], 10);
        const scheduledMinute = parseInt(parts[1], 10);

        if (isNaN(scheduledHour) || isNaN(scheduledMinute) || scheduledHour < 0 || scheduledHour > 23 || scheduledMinute < 0 || scheduledMinute > 59) {
            consoleLog(`[AutoStart] Неверные значения времени автозапуска: ${scheduledTimeStr}. / Invalid auto-start time values: ${scheduledTimeStr}.`, true);
            return;
        }

        const now = new Date();
        const todayStr = now.toISOString().split('T')[0]; // Format: YYYY-MM-DD / Формат: ГГГГ-ММ-ДД
        const lastRunDate = GM_getValue(LAST_AUTO_SCRAPE_DATE_KEY, null);

        if (lastRunDate === todayStr) {
            // consoleLog(`[AutoStart] Автоматический сбор уже выполнялся сегодня (${todayStr}). / Auto-scrape already ran today (${todayStr}).`);
            return;
        }

        if (now.getHours() === scheduledHour && now.getMinutes() === scheduledMinute) {
            consoleLog(`[AutoStart] Наступило время для автоматического запуска (${scheduledTimeStr})! / Scheduled time (${scheduledTimeStr}) reached for auto-start!`);
            updateStatusForConsole(`Автозапуск в ${scheduledTimeStr}... / Auto-starting at ${scheduledTimeStr}...`);
            GM_setValue(LAST_AUTO_SCRAPE_DATE_KEY, todayStr); // Mark as run for today / Пометить как выполненный сегодня
            await startMultiChannelScrapeMenu(true); // Pass true to indicate it's an auto-run / Передача true, чтобы указать, что это автозапуск
        } else {
            // Optional: Log that it's not time yet, can be verbose.
            // Опционально: Логировать, что время еще не наступило, может быть избыточным.
            // consoleLog(`[AutoStart] Current time ${now.getHours()}:${String(now.getMinutes()).padStart(2,'0')}. Scheduled for ${scheduledTimeStr}. Last run: ${lastRunDate || 'never'}`);
        }
    }

    /**
     * Sets up or clears the interval timer for checking the auto-start time.
     * Called on script load and after settings are saved/reset.
     *
     * Устанавливает или очищает таймер интервала для проверки времени автозапуска.
     * Вызывается при загрузке скрипта и после сохранения/сброса настроек.
     */
    function setupAutoStart() {
        if (autoStartCheckInterval) {
            clearInterval(autoStartCheckInterval);
            autoStartCheckInterval = null;
        }
        // This getConfigValue call is critical and needs GM_config to be ready.
        // Этот вызов getConfigValue критичен и требует, чтобы GM_config был готов.
        if (getConfigValue('AUTO_START_ENABLED', false)) {
            consoleLog("[AutoStart] Автозапуск включен. Проверка времени каждую минуту. / Auto-start enabled. Checking time every minute.");
            checkAndRunAutoScrape(); // Perform an initial check immediately / Немедленная первоначальная проверка
            autoStartCheckInterval = setInterval(checkAndRunAutoScrape, 60000); // Check every 60 seconds / Проверка каждые 60 секунд
        } else {
            consoleLog("[AutoStart] Автозапуск выключен. / Auto-start disabled.");
        }
    }

    // --- CORE SCRAPING FUNCTIONS (Definitions) ---
    // --- ОСНОВНЫЕ ФУНКЦИИ СКРАПИНГА (Определения) ---
    // (isTargetChannelActive, parseTimestampFromBubble, extractDataFromMessageElement, sendToN8N, processCurrentMessages, tryScrollUp, scrollToBottom, scrapingLoopSingleChannel, scrapeSingleChannelProcess)
    // These functions are defined below, after helper and config functions, to ensure all dependencies are met.
    // Эти функции определены ниже, после вспомогательных функций и функций конфигурации, чтобы обеспечить выполнение всех зависимостей.

    function isTargetChannelActive() { /* ... (Implementation as in v2.3.1 - no changes) ... */
        if (!currentScrapingChannelInfo || !currentScrapingChannelInfo.id) { return false; }
        const chatInfoContainer = document.querySelector('#column-center .chat.active .sidebar-header .chat-info');
        if (!chatInfoContainer) { return false; }
        const avatarElement = chatInfoContainer.querySelector('.avatar[data-peer-id]');
        if (avatarElement && avatarElement.dataset && avatarElement.dataset.peerId) {
            const displayedPeerId = avatarElement.dataset.peerId;
            if (displayedPeerId === currentScrapingChannelInfo.id) {
                consoleLog(`[isTargetActive] Channel "${currentScrapingChannelInfo.name}" (ID: ${currentScrapingChannelInfo.id}) IS ACTIVE.`);
                return true;
            }
        }
        return false;
    }
    function parseTimestampFromBubble(bubbleElement) { /* ... (Implementation as in v2.3.1 - no changes) ... */
        if (bubbleElement && bubbleElement.dataset && bubbleElement.dataset.timestamp) {
            return parseInt(bubbleElement.dataset.timestamp, 10) * 1000;
        }
        return null;
    }
    function extractDataFromMessageElement(messageElement) { /* ... (Implementation as in v2.3.1 - no changes) ... */
        const channelNameForSource = currentScrapingChannelInfo ? currentScrapingChannelInfo.name : 'unknown_channel';
        const data = {
            title: '', text: '', link: null, pubDate: null,
            source: `t.me/${channelNameForSource.replace('@','')}`,
            messageId: null, rawHtmlContent: messageElement.innerHTML
        };
        const parentBubble = messageElement.closest('.bubble.channel-post');
        if (!parentBubble) { consoleLog(`[Extractor] Parent bubble not found: ${messageElement.textContent.substring(0,50)}...`, true); return null; }
        data.messageId = parentBubble.dataset.mid;
        if (!data.messageId) { consoleLog(`[Extractor] Message ID not found: ${parentBubble.outerHTML.substring(0,100)}...`, true); return null; }
        const timestamp = parseTimestampFromBubble(parentBubble);
        if (!timestamp) { consoleLog(`[Extractor] Timestamp not parsed for ID ${data.messageId} in ${channelNameForSource}`, true); return null; }
        data.pubDate = new Date(timestamp).toISOString();
        const oldestAllowedDate = new Date();
        oldestAllowedDate.setHours(oldestAllowedDate.getHours() - getConfigValue('MAX_MESSAGE_AGE_HOURS', 24));
        if (new Date(timestamp) < oldestAllowedDate) {
            consoleLog(`[Extractor] Msg ID ${data.messageId} (PubDate: ${data.pubDate}) in ${channelNameForSource} OLDER than ${getConfigValue('MAX_MESSAGE_AGE_HOURS', 24)} hours. STOP_SCROLLING.`);
            return 'STOP_SCROLLING';
        }
        const strongElements = Array.from(messageElement.querySelectorAll('strong'));
        if (strongElements.length > 0) {
            const firstStrong = strongElements.find(s => {
                const anchor = s.closest('a');
                return !anchor || !(anchor.href.includes(`/${channelNameForSource.replace('@','')}`) || anchor.href.includes(`/${channelNameForSource}`));
            });
            if (firstStrong) data.title = firstStrong.innerText.trim();
        }
        let fullText = '';
        const channelNamePartForLinkComparison = channelNameForSource.replace('@','');
        messageElement.childNodes.forEach(node => {
            if (node.nodeType === Node.TEXT_NODE) { fullText += node.textContent; }
            else if (node.nodeType === Node.ELEMENT_NODE) {
                if (node.tagName === 'A' && node.classList.contains('anchor-url')) {
                    fullText += node.innerText;
                    if (!data.link && node.href && node.target === '_blank' && !node.href.startsWith('https://t.me/')) data.link = node.href;
                }
                else if (node.tagName !== 'STRONG' || (data.title && !node.innerText.trim().startsWith(data.title) && !data.title.includes(node.innerText.trim()))) {
                    const isCustomEmoji = node.matches && (node.matches('img.custom-emoji') || node.matches('custom-emoji-element') || node.querySelector('img.custom-emoji'));
                    const isSticker = node.matches && (node.matches('.media-sticker-wrapper') || node.matches('tg-sticker'));
                    const isReactions = node.matches && (node.matches('reactions-element') || node.classList.contains('reactions'));
                    let isChannelSignatureLink = false;
                    if (node.tagName === 'A' && node.href) {
                        const hrefLower = node.href.toLowerCase();
                        if (hrefLower.includes(`t.me/${channelNamePartForLinkComparison.toLowerCase()}`) || hrefLower.includes(`/${channelNamePartForLinkComparison.toLowerCase()}`)) {
                            if (node.innerText.toLowerCase().includes(channelNamePartForLinkComparison.toLowerCase())) isChannelSignatureLink = true;
                        }
                    }
                    if (!isChannelSignatureLink && node.querySelector(`a[href*="/${channelNamePartForLinkComparison}"]`)) {
                        const nestedLink = node.querySelector(`a[href*="/${channelNamePartForLinkComparison}"]`);
                        if (nestedLink.innerText.toLowerCase().includes(channelNamePartForLinkComparison.toLowerCase())) isChannelSignatureLink = true;
                    }
                    if (!isCustomEmoji && !isSticker && !isReactions && !isChannelSignatureLink) fullText += node.innerText || node.textContent;
                }
            }
        });
        data.text = fullText.replace(/\s+/g, ' ').trim();
        if (!data.title && data.text) data.title = data.text.substring(0, 120) + (data.text.length > 120 ? '...' : '');
        if (data.title && data.text.toLowerCase().startsWith(data.title.toLowerCase())) data.text = data.text.substring(data.title.length).trim();
        return data;
    }
    function sendToN8N(payload) { /* ... (Implementation as in v2.3.1 - no changes) ... */
        const n8nWebhookUrl = getConfigValue('N8N_WEBHOOK_URL', '');
        if (!n8nWebhookUrl) { updateStatusForConsole('N8N URL не настроен!', true); return; }
        const channelName = currentScrapingChannelInfo ? currentScrapingChannelInfo.name : 'N/A';
        const channelId = currentScrapingChannelInfo ? currentScrapingChannelInfo.id : 'N/A';
        updateStatusForConsole(`Отправка ID ${payload.messageId} (Канал: ${channelName} [${channelId}], Date: ${payload.pubDate})...`);
        GM_xmlhttpRequest({
            method: "POST", url: n8nWebhookUrl, data: JSON.stringify(payload), headers: { "Content-Type": "application/json" },
            onload: function(response) { updateStatusForConsole(`n8n ответ для ID ${payload.messageId}: ${response.status}`); consoleLog(`[Sender] N8N Response for ID ${payload.messageId}: ${response.status} ${response.responseText.substring(0,100)}`); },
            onerror: function(response) { updateStatusForConsole(`n8n ошибка для ID ${payload.messageId}: ${response.status}`, true); consoleLog(`[Sender] N8N Error for ID ${payload.messageId}: ${response.status} ${response.responseText.substring(0,100)}`, true); }
        });
    }
    async function processCurrentMessages() { /* ... (Implementation as in v2.3.1 - no changes) ... */
        if (!isScrapingSingle && !isMultiChannelScrapingActive) return { foundNew: false, stopScrolling: false };
        if (!currentScrapingChannelInfo) { consoleLog("processCurrentMessages: currentScrapingChannelInfo is not set.", true); return { foundNew: false, stopScrolling: true, error: "Канал не установлен" };}
        if (!isTargetChannelActive()) { updateStatusForConsole(`Канал ${currentScrapingChannelInfo.name} не активен (process).`, true); return { foundNew: false, stopScrolling: true, error: `Канал ${currentScrapingChannelInfo.name} не активен` }; }
        updateStatusForConsole(`Поиск в ${currentScrapingChannelInfo.name}...`);
        const messageElements = document.querySelectorAll('.bubble.channel-post .message span.translatable-message, .bubble.channel-post .text-content');
        let foundNew = false; let stopDueToAge = false;
        for (let i = messageElements.length - 1; i >= 0; i--) {
            if (!isScrapingSingle && !isMultiChannelScrapingActive) break;
            const el = messageElements[i]; const parentBubble = el.closest('.bubble.channel-post'); const msgId = parentBubble ? parentBubble.dataset.mid : null;
            if (msgId) {
                const articleData = extractDataFromMessageElement(el);
                if (articleData === 'STOP_SCROLLING') { stopDueToAge = true; const ts = parentBubble?.dataset.timestamp ? new Date(parseInt(parentBubble.dataset.timestamp,10)*1000).toISOString() : 'N/A'; updateStatusForConsole(`Старые сообщения (ID: ${msgId}, Date: ${ts}). Стоп.`); break; }
                if (articleData && articleData.title && (articleData.text || articleData.link)) {
                    consoleLog(`[Proc] ID ${msgId} (${articleData.pubDate.substring(11,19)}) к отправке.`); sendToN8N(articleData); foundNew = true;
                    await new Promise(r => setTimeout(r, getRandomizedInterval(getConfigValue('BASE_SEND_DELAY_MS', 1000), 'RANDOMNESS_FACTOR_MINOR')));
                } else if (articleData) { consoleLog(`[Proc] ID ${msgId} пропущено (нет данных).`); }
                else { consoleLog(`[Proc] ID ${msgId} ошибка извлечения.`, true); }
            }
        }
        return { foundNew, stopScrolling: stopDueToAge };
    }
    async function tryScrollUp() { /* ... (Implementation as in v2.3.1 - no changes) ... */
        if (!isScrapingSingle && !isMultiChannelScrapingActive) return;
        await new Promise(r => setTimeout(r, getRandomizedInterval(getConfigValue('BASE_SCROLL_ACTION_PAUSE_MS', 300), 'RANDOMNESS_FACTOR_MINOR')));
        updateStatusForConsole('Скролл вверх...');
        const messageBubbles = document.querySelectorAll('.bubbles-inner .bubble.channel-post');
        if (messageBubbles.length > 0) {
            const topBubble = messageBubbles[0]; if (typeof topBubble.tabIndex === 'undefined' || topBubble.tabIndex === -1) topBubble.tabIndex = -1;
            try {
                consoleLog(`Скролл к верхнему ID: ${topBubble.dataset.mid} (scrollIntoView)`); topBubble.scrollIntoView({ behavior: 'auto', block: 'start' });
                await new Promise(r => setTimeout(r, getRandomizedInterval(getConfigValue('BASE_SCROLL_ACTION_PAUSE_MS', 300), 'RANDOMNESS_FACTOR_MINOR')));
                if (getConfigValue('USE_FOCUS_IN_SCROLL_UP', false)) { consoleLog(`Фокус на верхний ID: ${topBubble.dataset.mid}`); topBubble.focus({ preventScroll: true }); }
                await new Promise(r => setTimeout(r, getRandomizedInterval(getConfigValue('BASE_SCROLL_PAUSE_MS', 5000))));
            } catch (e) {
                consoleLog(`Ошибка scrollIntoView/focus: ${e.message}`, true); updateStatusForConsole('Ошибка скролла вверх. Стандартный метод...', true);
                const scrollArea = document.querySelector('div.bubbles-inner')?.parentElement || document.querySelector('.scrollable-y.chat-history-list') || document.querySelector('.bubbles > .scrollable-y');
                if (scrollArea) { scrollArea.scrollTop = 0; scrollArea.dispatchEvent(new WheelEvent('wheel', { deltaY: -1000, bubbles: true, cancelable: true })); await new Promise(r => setTimeout(r, getRandomizedInterval(getConfigValue('BASE_SCROLL_PAUSE_MS', 5000))));}
            }
        } else {
            updateStatusForConsole('Нет сообщений для скролла вверх. Стандартный метод.');
            const scrollArea = document.querySelector('div.bubbles-inner')?.parentElement || document.querySelector('.scrollable-y.chat-history-list') || document.querySelector('.bubbles > .scrollable-y');
            if (scrollArea) { scrollArea.scrollTop = 0; scrollArea.dispatchEvent(new WheelEvent('wheel', { deltaY: -1000, bubbles: true, cancelable: true })); await new Promise(r => setTimeout(r, getRandomizedInterval(getConfigValue('BASE_SCROLL_PAUSE_MS', 5000))));}
            else { updateStatusForConsole('Нет области скролла и нет сообщений.', true); }
        }
    }
    async function scrollToBottom() { /* ... (Implementation as in v2.3.1 - no changes) ... */
        updateStatusForConsole('Прокрутка к последним сообщениям...');
        const scrollableArea = document.querySelector('div.bubbles-inner')?.parentElement || document.querySelector('.scrollable-y.chat-history-list') || document.querySelector('.bubbles > .scrollable-y');
        if (!scrollableArea) { updateStatusForConsole('Ошибка: Не найдена область для прокрутки вниз.', true); return false; }
        let goToBottomButton; let clicksMade = 0; const maxClicks = getConfigValue('MAX_GO_TO_BOTTOM_CLICKS', 3);
        await new Promise(r => setTimeout(r, getRandomizedInterval(getConfigValue('BASE_SCROLL_ACTION_PAUSE_MS', 300), 'RANDOMNESS_FACTOR_MINOR')));
        while (clicksMade < maxClicks) {
            if (!isScrapingSingle && !isMultiChannelScrapingActive && clicksMade > 0) { updateStatusForConsole('Прокрутка вниз прервана.'); return false; }
            goToBottomButton = document.querySelector('.bubbles-go-down.chat-secondary-button:not(.is-hidden):not([style*="display: none"])');
            const badge = goToBottomButton ? goToBottomButton.querySelector('.badge:not(.is-badge-empty)') : null;
            if (goToBottomButton && badge && typeof goToBottomButton.click === 'function') {
                const unreadCountText = badge.textContent; updateStatusForConsole(`Клик по кнопке "вниз" (${unreadCountText || 'несколько'} непрочитанных)...`);
                consoleLog(`[ScrollToBottom] Clicking "go to bottom" button (unread: ${unreadCountText}). Click ${clicksMade + 1}`);
                goToBottomButton.click(); clicksMade++;
                await new Promise(resolve => setTimeout(resolve, getRandomizedInterval(getConfigValue('BASE_SCROLL_BOTTOM_CLICK_PAUSE_MS', 2500))));
            } else { consoleLog('[ScrollToBottom] "Go to bottom" button with counter not found or empty.'); break; }
        }
        updateStatusForConsole('Программная прокрутка вниз...'); let prevScrollHeight = 0; const scrollIterations = getConfigValue('SCROLL_BOTTOM_PROGRAMMATIC_ITERATIONS', 3);
        for (let i = 0; i < scrollIterations; i++) {
            if (!isScrapingSingle && !isMultiChannelScrapingActive) { updateStatusForConsole('Прокрутка вниз прервана.'); return false; }
            prevScrollHeight = scrollableArea.scrollHeight; scrollableArea.scrollTop = scrollableArea.scrollHeight;
            updateStatusForConsole(`Прокрутка вниз... (итерация ${i + 1}/${scrollIterations})`);
            await new Promise(resolve => setTimeout(resolve, getRandomizedInterval(getConfigValue('BASE_SCROLL_BOTTOM_PROG_PAUSE_MS', 700), 'RANDOMNESS_FACTOR_MINOR')));
            if (i > 0 && scrollableArea.scrollHeight - prevScrollHeight < 50) { consoleLog('[ScrollToBottom] Scroll height changed minimally.'); break; }
        }
        const lastMessageGroup = document.querySelector('.bubbles-inner .bubbles-group-last');
        if (lastMessageGroup) {
            consoleLog('[ScrollToBottom] Found .bubbles-group-last, scrolling to it.'); updateStatusForConsole('Точная прокрутка к последней группе...');
            lastMessageGroup.scrollIntoView({ behavior: 'auto', block: 'end' });
            await new Promise(resolve => setTimeout(resolve, getRandomizedInterval(getConfigValue('BASE_SCROLL_BOTTOM_PROG_PAUSE_MS', 700) / 2, 'RANDOMNESS_FACTOR_MINOR')));
        } else { consoleLog('[ScrollToBottom] .bubbles-group-last not found.'); }
        goToBottomButton = document.querySelector('.bubbles-go-down.chat-secondary-button:not(.is-hidden):not([style*="display: none"])');
        if (goToBottomButton && typeof goToBottomButton.click === 'function' && clicksMade < maxClicks) {
            const finalBadge = goToBottomButton.querySelector('.badge:not(.is-badge-empty)');
            if (!finalBadge) { consoleLog('[ScrollToBottom] "Go to bottom" button (no counter) is active, final click.'); updateStatusForConsole('Финальный клик по кнопке "вниз"...'); goToBottomButton.click(); await new Promise(resolve => setTimeout(resolve, getRandomizedInterval(getConfigValue('BASE_SCROLL_BOTTOM_CLICK_PAUSE_MS', 2500) / 2))); }
        }
        updateStatusForConsole('Прокрутка к последним сообщениям завершена.'); return true;
    }
    async function scrapingLoopSingleChannel() { /* ... (Implementation as in v2.3.1 - no changes) ... */
        if (!isScrapingSingle) { consoleLog(`[Loop-${currentScrapingChannelInfo.name}] Остановлен (isScrapingSingle=false).`); return; }
        if (isMultiChannelScrapingActive && !isScrapingSingle) { consoleLog(`[Loop-${currentScrapingChannelInfo.name}] Остановлен (multi active, single false).`); return; }
        await new Promise(r => setTimeout(r, getRandomizedInterval(getConfigValue('BASE_SCROLL_ACTION_PAUSE_MS', 300), 'RANDOMNESS_FACTOR_MINOR')));
        const { foundNew, stopScrolling, error } = await processCurrentMessages();
        if (error) { updateStatusForConsole(error + `. Прерываю для ${currentScrapingChannelInfo.name}.`, true); return; }
        if (stopScrolling) { updateStatusForConsole(`Лимит по дате для ${currentScrapingChannelInfo.name}. Завершаю.`); return; }
        if (foundNew) { consecutiveScrollsWithoutNewFound = 0; }
        else { consecutiveScrollsWithoutNewFound++; consoleLog(`[Loop-${currentScrapingChannelInfo.name}] Ничего нового. Счетчик: ${consecutiveScrollsWithoutNewFound}`);}
        if (consecutiveScrollsWithoutNewFound >= getConfigValue('CONSECUTIVE_SCROLLS_LIMIT', 5)) {
            updateStatusForConsole(`Нет новых сообщений для ${currentScrapingChannelInfo.name} после ${getConfigValue('CONSECUTIVE_SCROLLS_LIMIT', 5)} прокруток. Завершаю.`); return;
        }
        await tryScrollUp();
        if (isScrapingSingle) {
           const baseNextInterval = !foundNew ? getConfigValue('BASE_SCRAPE_INTERVAL_MS', 30000) : getConfigValue('BASE_SCRAPE_INTERVAL_MS', 30000) / 2;
           await new Promise(r => setTimeout(r, getRandomizedInterval(baseNextInterval)));
           if (isScrapingSingle) await scrapingLoopSingleChannel();
        }
    }
    async function scrapeSingleChannelProcess(channelInfoObject) { /* ... (Implementation as in v2.3.1 - no changes) ... */
        if (!channelInfoObject || !channelInfoObject.id || !channelInfoObject.name) { consoleLog("Ошибка: Некорректные данные канала в scrapeSingleChannelProcess", true); return false; }
        if (!isScrapingSingle && !isMultiChannelScrapingActive) { consoleLog(`scrapeSingleChannelProcess для ${channelInfoObject.name} не может быть запущен (флаги).`); return false; }
        currentScrapingChannelInfo = channelInfoObject;
        consoleLog(`--- Начало скрапинга канала: ${currentScrapingChannelInfo.name} (ID: ${currentScrapingChannelInfo.id}) ---`);
        updateStatusForConsole(`Скрапинг: ${currentScrapingChannelInfo.name}`);
        const targetHashForNavigation = `#${currentScrapingChannelInfo.name}`;
        let navigationNeeded = true;
        const chatInfoContainerInitial = document.querySelector('#column-center .chat.active .sidebar-header .chat-info');
        let initialDisplayedPeerId = null;
        if (chatInfoContainerInitial) {
            const avatarElementInitial = chatInfoContainerInitial.querySelector('.avatar[data-peer-id]');
            if (avatarElementInitial) { initialDisplayedPeerId = avatarElementInitial.dataset.peerId; }
        }
        if (initialDisplayedPeerId === currentScrapingChannelInfo.id) { consoleLog(`[Nav] Уже на канале ${currentScrapingChannelInfo.name} (peerId совпадает).`); navigationNeeded = false; }
        else if (window.location.hash.toLowerCase() === targetHashForNavigation.toLowerCase() && initialDisplayedPeerId) { consoleLog(`[Nav] URL hash is ${targetHashForNavigation} or peerId (${initialDisplayedPeerId}) present, but expecting ${currentScrapingChannelInfo.id}. Will wait for peerId activation.`); navigationNeeded = false; }
        if (navigationNeeded) {
            consoleLog(`Перехожу на канал ${targetHashForNavigation}...`); window.location.hash = targetHashForNavigation;
            await new Promise(r => setTimeout(r, getRandomizedInterval(getConfigValue('NAVIGATION_INITIATION_PAUSE_MS', 2500), 'RANDOMNESS_FACTOR_MAJOR')));
        }
        let activationAttempts = 0; const maxActivationAttempts = getConfigValue('MAX_CHANNEL_ACTIVATION_ATTEMPTS', 25);
        consoleLog(`Ожидание активации канала ${currentScrapingChannelInfo.name} (ID: ${currentScrapingChannelInfo.id}) по peer-id...`);
        while (activationAttempts < maxActivationAttempts) {
            if (!isScrapingSingle && !isMultiChannelScrapingActive) { consoleLog("Остановка во время ожидания активации канала."); return false; }
            if (isTargetChannelActive()) break;
            activationAttempts++; updateStatusForConsole(`Ожидание ${currentScrapingChannelInfo.name} (${activationAttempts}/${maxActivationAttempts})`);
            await new Promise(r => setTimeout(r, getRandomizedInterval(getConfigValue('CHANNEL_ACTIVATION_ATTEMPT_PAUSE_MS', 700), 'RANDOMNESS_FACTOR_MINOR')));
        }
        if (!isTargetChannelActive()) { updateStatusForConsole(`Не удалось активировать ${currentScrapingChannelInfo.name} (ID: ${currentScrapingChannelInfo.id}) по peer-id. Пропускаю.`, true); return false; }
        consoleLog(`Канал ${currentScrapingChannelInfo.name} активен. Прокрутка вниз.`);
        const scrolledToBottom = await scrollToBottom();
        if (!scrolledToBottom) { if (isScrapingSingle || isMultiChannelScrapingActive) { updateStatusForConsole(`Ошибка прокрутки вниз для ${currentScrapingChannelInfo.name}.`, true); } return false; }
        if (!isScrapingSingle && !isMultiChannelScrapingActive) { consoleLog("Остановка после прокрутки вниз."); return false;}
        updateStatusForConsole(`Скрапинг вверх для ${currentScrapingChannelInfo.name}...`);
        consecutiveScrollsWithoutNewFound = 0; await scrapingLoopSingleChannel();
        consoleLog(`--- Скрапинг канала ${currentScrapingChannelInfo.name} завершен/остановлен ---`); return true;
    }

    // --- MENU COMMAND HANDLERS ---
    /**
     * Shuffles array in place. ES6 version
     * @param {Array} a items An array containing the items.
     * Перемешивает массив на месте. Версия ES6.
     * @param {Array} a Массив, содержащий элементы.
     */
    function shuffleArray(a) { for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; }

    async function startSingleChannelScrapeMenu() { /* ... (Implementation as in v2.3.1 - no changes) ... */
        consoleLog("Команда 'Scrape Current Channel' вызвана.");
        if (isScrapingSingle || isMultiChannelScrapingActive) { alert("Скрапинг уже запущен."); consoleLog("Скрапинг уже запущен.", true); return; }
        let displayedPeerId = null;
        const chatInfoContainer = document.querySelector('#column-center .chat.active .sidebar-header .chat-info');
        if (chatInfoContainer) { const avatarElement = chatInfoContainer.querySelector('.avatar[data-peer-id]'); if (avatarElement && avatarElement.dataset && avatarElement.dataset.peerId) displayedPeerId = avatarElement.dataset.peerId; }
        let channelInfoToScrape = null;
        if (displayedPeerId) {
            channelInfoToScrape = TARGET_CHANNELS_DATA_ORIGINAL.find(ch => ch.id === displayedPeerId);
            if (channelInfoToScrape) consoleLog(`[startSingle] Канал определен по peer-id: ${channelInfoToScrape.name}`);
            else consoleLog(`[startSingle] Peer-id ${displayedPeerId} не найден в TARGET_CHANNELS_DATA.`);
        } else consoleLog(`[startSingle] Не удалось получить peer-id.`);
        if (!channelInfoToScrape) {
            let hash = window.location.hash.substring(1);
            if (hash) {
                const queryParamIndex = hash.indexOf('?'); if (queryParamIndex !== -1) hash = hash.substring(0, queryParamIndex);
                channelInfoToScrape = TARGET_CHANNELS_DATA_ORIGINAL.find(ch => ch.id === hash);
                if (!channelInfoToScrape) { let nameToCompare = hash; if (!hash.startsWith('@') && isNaN(parseInt(hash))) nameToCompare = '@' + hash; channelInfoToScrape = TARGET_CHANNELS_DATA_ORIGINAL.find(ch => ch.name.toLowerCase() === nameToCompare.toLowerCase()); }
                if (channelInfoToScrape) consoleLog(`[startSingle] Канал определен по hash "${hash}": ${channelInfoToScrape.name}`);
                else consoleLog(`[startSingle] Канал не определен по hash "${hash}".`);
            }
        }
        if (!channelInfoToScrape) { alert("Не удалось определить текущий канал."); consoleLog("Не удалось определить текущий канал.", true); return; }
        isScrapingSingle = true; consoleLog(`--- Начало ОДИНОЧНОЙ сессии для ${channelInfoToScrape.name} ---`);
        alert(`Начинаю скрапинг текущего канала: ${channelInfoToScrape.name}.`);
        await scrapeSingleChannelProcess(channelInfoToScrape);
        isScrapingSingle = false;
        if (!isMultiChannelScrapingActive) { updateStatusForConsole("Скрапинг текущего канала завершен."); consoleLog("--- ОДИНОЧНАЯ сессия скрапинга завершена ---"); alert(`Скрапинг канала ${channelInfoToScrape.name} завершен.`); }
        currentScrapingChannelInfo = null;
    }
    async function startMultiChannelScrapeMenu(isAutoStart = false) { /* ... (Implementation as in v2.3.1 - no changes) ... */
        if (!isAutoStart) {
            consoleLog("Команда 'Scrape All Listed Channels' вызвана.");
            if (isScrapingSingle || isMultiChannelScrapingActive) { alert("Скрапинг уже запущен."); consoleLog("Скрапинг уже запущен.", true); return; }
            if (!confirm(`Начать скрапинг ${TARGET_CHANNELS_DATA_ORIGINAL.length} каналов?`)) { consoleLog("Мульти-скрапинг отменен."); return; }
        } else {
            if (isScrapingSingle || isMultiChannelScrapingActive) { consoleLog("[AutoStart] Скрапинг уже запущен, автозапуск пропущен."); return; }
            consoleLog("[AutoStart] Запуск мульти-скрапинга по расписанию.");
        }
        isMultiChannelScrapingActive = true; currentChannelIndex = 0;
        if (getConfigValue('RANDOMIZE_CHANNEL_ORDER', true)) { consoleLog("Перемешивание порядка каналов..."); currentTargetChannels = shuffleArray([...TARGET_CHANNELS_DATA_ORIGINAL]); }
        else { currentTargetChannels = [...TARGET_CHANNELS_DATA_ORIGINAL]; }
        consoleLog("--- Начало МУЛЬТИ-СКРАПИНГА ---");
        if (!isAutoStart) alert("Начинаю скрапинг всех каналов.");
        while (currentChannelIndex < currentTargetChannels.length && isMultiChannelScrapingActive) {
            isScrapingSingle = true; const channelInfo = currentTargetChannels[currentChannelIndex];
            updateStatusForConsole(`[${currentChannelIndex + 1}/${currentTargetChannels.length}] Запуск для: ${channelInfo.name}`);
            const success = await scrapeSingleChannelProcess(channelInfo);
            isScrapingSingle = false;
            if (!isMultiChannelScrapingActive) { consoleLog("Мульти-скрапинг остановлен."); break; }
            if (!success) consoleLog(`Проблема со скрапингом канала ${channelInfo.name}, пропускаю.`, true);
            currentChannelIndex++;
            if (currentChannelIndex < currentTargetChannels.length && isMultiChannelScrapingActive) {
                const pauseDuration = getRandomizedInterval(getConfigValue('BASE_SCROLL_PAUSE_MS', 5000) * 1.5, 'RANDOMNESS_FACTOR_MAJOR');
                updateStatusForConsole(`Пауза ${Math.round(pauseDuration/1000)}с перед ${currentTargetChannels[currentChannelIndex].name}`);
                await new Promise(r => setTimeout(r, pauseDuration));
            }
        }
        if (isMultiChannelScrapingActive) {
            updateStatusForConsole("Скрапинг ВСЕХ каналов завершен.");
            if (!isAutoStart) alert("Скрапинг всех каналов завершен!");
            else consoleLog("[AutoStart] Автоматический сбор завершен.");
        }
        isMultiChannelScrapingActive = false; isScrapingSingle = false; currentScrapingChannelInfo = null;
    }
    function stopAllScrapingActivitiesMenu() { /* ... (Implementation as in v2.3.1 - no changes) ... */
        consoleLog("Команда 'Stop All Scraping' вызвана.", true);
        isScrapingSingle = false; isMultiChannelScrapingActive = false;
        updateStatusForConsole('Скрапинг остановлен пользователем.'); alert("Все процессы скрапинга остановлены.");
    }
    function toggleAutoStartMenu() { /* ... (Implementation as in v2.3.1 - no changes) ... */
        const currentAutoStart = getConfigValue('AUTO_START_ENABLED', false);
        const newAutoStart = !currentAutoStart;
        GM_config.set('AUTO_START_ENABLED', newAutoStart); GM_config.save();
        alert(`Автозапуск ${newAutoStart ? 'ВКЛЮЧЕН' : 'ВЫКЛЮЧЕН'}.`);
        consoleLog(`Автозапуск ${newAutoStart ? 'ВКЛЮЧЕН' : 'ВЫКЛЮЧЕН'} через меню.`);
        setupAutoStart();
    }

    // --- REGISTER MENU COMMANDS ---
    if (typeof GM_registerMenuCommand === 'function') {
        if (gmConfigInitialized) {
            GM_registerMenuCommand("Scrape Current Channel / Собрать с текущего канала", startSingleChannelScrapeMenu, "C");
            GM_registerMenuCommand("Scrape All Listed Channels / Собрать со всех каналов", () => startMultiChannelScrapeMenu(false), "A");
            GM_registerMenuCommand("Toggle Auto-Start / Вкл/Выкл Автозапуск", toggleAutoStartMenu, "T");
            GM_registerMenuCommand("Stop All Scraping / Остановить всё", stopAllScrapingActivitiesMenu, "S");
            GM_registerMenuCommand("Настройки скрипта... / Script Settings...", () => GM_config.open(), "O");
            consoleLog("Команды меню Tampermonkey зарегистрированы.");
        } else {
            consoleLog("GM_config не был успешно инициализирован.", true); alert("Ошибка: GM_config не инициализирован.");
            GM_registerMenuCommand("Scrape Current Channel / Собрать с текущего канала", startSingleChannelScrapeMenu, "C");
            GM_registerMenuCommand("Scrape All Listed Channels / Собрать со всех каналов",() => startMultiChannelScrapeMenu(false), "A");
            GM_registerMenuCommand("Stop All Scraping / Остановить всё", stopAllScrapingActivitiesMenu, "S");
            consoleLog("Основные команды меню Tampermonkey зарегистрированы.");
        }
    } else {
        consoleLog("GM_registerMenuCommand не доступна.", true); alert("Tampermonkey API GM_registerMenuCommand не доступно.");
    }

    // Initialize auto-start check after a short delay to ensure GM_config is fully ready.
    // Инициализация проверки автозапуска после небольшой задержки, чтобы GM_config был полностью готов.
    if (gmConfigInitialized) {
        setTimeout(() => {
            consoleLog("Первоначальная настройка автозапуска после задержки. / Initial auto-start setup after delay.");
            setupAutoStart();
        }, 1000); // 1 second delay / Задержка в 1 секунду
    }

})();
console.log(`[Telegram Scraper v${GM_info.script.version}] Script IIFE execution completed.`);