Telegram Scraper (Menu Commands v2.2.15 - GM_config GUI Final Field Widths)

Scrapes messages from Telegram channels and sends them to an n8n webhook. Features a GM_config GUI for settings.

当前为 2025-06-03 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Telegram Scraper (Menu Commands v2.2.15 - GM_config GUI Final Field Widths)
  3. // @name:ru Telegram Scraper (Команды меню v2.2.15 - GM_config GUI Финальная ширина полей)
  4. // @namespace http://tampermonkey.net/
  5. // @version 2.2.15
  6. // @description Scrapes messages from Telegram channels and sends them to an n8n webhook. Features a GM_config GUI for settings.
  7. // @description:ru Собирает сообщения из Telegram-каналов и отправляет их на веб-хук n8n. Имеет графический интерфейс настроек через GM_config.
  8. // @author Igor Lebedev (Adapted by Gemini Pro)
  9. // @license MIT
  10. // @match https://web.telegram.org/k/*
  11. // @match https://web.telegram.org/a/*
  12. // @match https://web.telegram.org/z/*
  13. // @require https://openuserjs.org/src/libs/sizzle/GM_config.js
  14. // @grant GM_xmlhttpRequest
  15. // @grant GM_registerMenuCommand
  16. // @grant GM_getValue
  17. // @grant GM_setValue
  18. // @grant GM_info
  19. // @run-at document-idle
  20. // ==/UserScript==
  21.  
  22. /*
  23. ENGLISH COMMENTS:
  24. This script is designed to scrape messages from specified Telegram channels
  25. when viewed in a web browser (web.telegram.org). It extracts relevant data
  26. (title, text, link, publication date, source, message ID) and sends it to
  27. a configured n8n webhook.
  28.  
  29. Key Features:
  30. - Scrapes single currently viewed channel or all predefined channels.
  31. - Uses GM_config library for a graphical user interface for settings.
  32. - Handles message age limits to avoid scraping very old messages.
  33. - Navigates between channels in multi-channel mode.
  34. - Includes randomized delays to mimic human behavior.
  35. - Provides Tampermonkey menu commands for control.
  36.  
  37. РУССКИЕ КОММЕНТАРИИ:
  38. Этот скрипт предназначен для сбора сообщений из указанных Telegram-каналов
  39. при их просмотре в веб-браузере (web.telegram.org). Он извлекает релевантные данные
  40. (заголовок, текст, ссылку, дату публикации, источник, ID сообщения) и отправляет их
  41. на настроенный веб-хук n8n.
  42.  
  43. Ключевые особенности:
  44. - Сбор данных с одного текущего канала или со всех предустановленных каналов.
  45. - Использует библиотеку GM_config для графического интерфейса настроек.
  46. - Учитывает максимальный возраст сообщений, чтобы не собирать слишком старые.
  47. - Осуществляет навигацию между каналами в многоканальном режиме.
  48. - Включает рандомизированные задержки для имитации человеческого поведения.
  49. - Предоставляет команды управления через меню Tampermonkey.
  50. */
  51.  
  52. (function() {
  53. 'use strict';
  54.  
  55. // --- GLOBAL SCRIPT VARIABLES (NOT SETTINGS) ---
  56. // --- ГЛОБАЛЬНЫЕ ПЕРЕМЕННЫЕ СКРИПТА (НЕ НАСТРОЙКИ) ---
  57. let isScrapingSingle = false; // Flag: true if single channel scraping is active
  58. // Флаг: true, если активен сбор с одного канала
  59. let isMultiChannelScrapingActive = false; // Flag: true if multi-channel scraping is active
  60. // Флаг: true, если активен многоканальный сбор
  61. let currentChannelIndex = 0; // Index for iterating through TARGET_CHANNELS_DATA in multi-channel mode
  62. // Индекс для перебора TARGET_CHANNELS_DATA в многоканальном режиме
  63. let currentScrapingChannelInfo = null; // Object holding info of the channel currently being scraped
  64. // Объект с информацией о канале, который скрапится в данный момент
  65. let consecutiveScrollsWithoutNewFound = 0; // Counter for scrolls without finding new messages (to stop early)
  66. // Счетчик прокруток без нахождения новых сообщений (для ранней остановки)
  67.  
  68. // --- SCRIPT CONSTANTS (NOT USER-CONFIGURABLE VIA GUI) ---
  69. // --- КОНСТАНТЫ СКРИПТА (НЕ НАСТРАИВАЮТСЯ ПОЛЬЗОВАТЕЛЕМ ЧЕРЕЗ GUI) ---
  70.  
  71. // List of target channels with their names (used for navigation hash) and peer IDs (used for verification)
  72. // Список целевых каналов с их именами (используются для хэша навигации) и peer ID (используются для проверки)
  73. const TARGET_CHANNELS_DATA = [
  74. { name: '@e1_news', id: '-1049795479' },
  75. { name: '@RU66RU', id: '-1278627542' },
  76. { name: '@ekb4tv', id: '-1184077858' },
  77. { name: '@rentv_news', id: '-1310155678' },
  78. { name: '@TauNewsEkb', id: '-1424016223' },
  79. { name: '@BEZUMEKB', id: '-1739473739' },
  80. { name: '@zhest_dtp66', id: '-2454557093' },
  81. { name: '@sverdlovskaya_oblasti', id: '-1673288653' },
  82. { name: '@novosti_ekb66', id: '-1662411694' }
  83. ];
  84.  
  85. // Settings keys that require a page reload or script restart to take full effect
  86. // Ключи настроек, требующие перезагрузки страницы или перезапуска скрипта для полного вступления в силу
  87. const SETTINGS_REQUIRING_RELOAD = [
  88. 'N8N_WEBHOOK_URL' // Example: Changing the webhook URL might need a fresh start for connections
  89. // Пример: Изменение URL веб-хука может потребовать нового старта для соединений
  90. ];
  91.  
  92. // --- HELPER FUNCTIONS ---
  93. // --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ---
  94.  
  95. /**
  96. * Custom console logger with a script prefix.
  97. * @param {string} message - The message to log.
  98. * @param {boolean} [isError=false] - If true, logs as an error.
  99. *
  100. * Пользовательский логгер консоли с префиксом скрипта.
  101. * @param {string} message - Сообщение для лога.
  102. * @param {boolean} [isError=false] - Если true, логируется как ошибка.
  103. */
  104. function consoleLog(message, isError = false) {
  105. const prefix = "[TeleScraper]";
  106. if (isError) {
  107. console.error(`${prefix} ${message}`);
  108. } else {
  109. console.log(`${prefix} ${message}`);
  110. }
  111. }
  112. consoleLog(`v${GM_info.script.version} Script execution started.`);
  113.  
  114. // --- GM_CONFIG SETUP ---
  115. // --- НАСТРОЙКА GM_CONFIG ---
  116.  
  117. // Generate a unique ID for GM_config storage based on script version to avoid conflicts
  118. // Генерация уникального ID для хранилища GM_config на основе версии скрипта во избежание конфликтов
  119. const GM_CONFIG_ID = `TeleScraperConfig_v${GM_info.script.version.replace(/\./g, '_')}`;
  120.  
  121. // Define the fields for the GM_config settings GUI
  122. // Определение полей для графического интерфейса настроек GM_config
  123. let configFields = {
  124. 'N8N_WEBHOOK_URL': {
  125. 'label': 'N8N Webhook URL:', // English label
  126. 'label:ru': 'URL веб-хука n8n:', // Russian label (GM_config might not support this directly, but good for comments)
  127. 'type': 'text',
  128. 'default': 'http://localhost:5678/webhook/telegram-scraped-news',
  129. 'section': ['Основные настройки'], // Section header in GUI / Заголовок секции в GUI
  130. },
  131. 'MAX_MESSAGE_AGE_HOURS': {
  132. 'label': 'Max message age (hours):',
  133. 'label:ru': 'Макс. возраст сообщений (часы):',
  134. 'type': 'int',
  135. 'default': 24,
  136. 'min': 1,
  137. 'max': 720 // 30 days / 30 дней
  138. },
  139. 'BASE_SCRAPE_INTERVAL_MS': {
  140. 'label': 'Base scrape interval (ms):',
  141. 'label:ru': 'Базовый интервал скрапинга (мс):',
  142. 'type': 'int',
  143. 'default': 30000,
  144. 'min': 1000
  145. },
  146. 'BASE_SCROLL_PAUSE_MS': {
  147. 'label': 'Pause after scroll (ms):',
  148. 'label:ru': 'Пауза после скролла (мс):',
  149. 'type': 'int',
  150. 'default': 5000,
  151. 'min': 500
  152. },
  153. 'BASE_SEND_DELAY_MS': {
  154. 'label': 'Delay before sending message (ms):',
  155. 'label:ru': 'Задержка перед отправкой сообщения (мс):',
  156. 'type': 'int',
  157. 'default': 1000,
  158. 'min': 100
  159. },
  160. 'CONSECUTIVE_SCROLLS_LIMIT': {
  161. 'label': 'Empty scrolls limit before stop:',
  162. 'label:ru': 'Лимит пустых скроллов до остановки:',
  163. 'type': 'int',
  164. 'default': 5,
  165. 'min': 1
  166. },
  167. 'NAVIGATION_INITIATION_PAUSE_MS': {
  168. 'label': 'Pause after navigation (ms):',
  169. 'label:ru': 'Пауза после навигации (мс):',
  170. 'type': 'int',
  171. 'default': 2500,
  172. 'min': 500,
  173. 'section': ['Тонкие настройки (паузы и попытки)'], // Fine-tuning (pauses and attempts)
  174. },
  175. 'CHANNEL_ACTIVATION_ATTEMPT_PAUSE_MS': {
  176. 'label': 'Pause between channel activation attempts (ms):',
  177. 'label:ru': 'Пауза между попытками активации канала (мс):',
  178. 'type': 'int',
  179. 'default': 700,
  180. 'min': 100
  181. },
  182. 'MAX_CHANNEL_ACTIVATION_ATTEMPTS': {
  183. 'label': 'Max channel activation attempts:',
  184. 'label:ru': 'Макс. попыток активации канала:',
  185. 'type': 'int',
  186. 'default': 25,
  187. 'min': 1
  188. },
  189. 'BASE_SCROLL_ACTION_PAUSE_MS': {
  190. 'label': 'Pause before/after scroll action (ms):',
  191. 'label:ru': 'Пауза перед/после действия скролла (мс):',
  192. 'type': 'int',
  193. 'default': 300,
  194. 'min': 50
  195. },
  196. 'BASE_SCROLL_BOTTOM_PROG_PAUSE_MS': {
  197. 'label': 'Pause during programmatic scroll down (ms):',
  198. 'label:ru': 'Пауза при программном скролле вниз (мс):',
  199. 'type': 'int',
  200. 'default': 700,
  201. 'min': 100
  202. },
  203. 'BASE_SCROLL_BOTTOM_CLICK_PAUSE_MS': {
  204. 'label': 'Pause after "scroll to bottom" button click (ms):',
  205. 'label:ru': 'Пауза после клика по кнопке "вниз" (мс):',
  206. 'type': 'int',
  207. 'default': 2500,
  208. 'min': 500
  209. },
  210. 'SCROLL_BOTTOM_PROGRAMMATIC_ITERATIONS': {
  211. 'label': 'Programmatic scroll down iterations:',
  212. 'label:ru': 'Итераций программного скролла вниз:',
  213. 'type': 'int',
  214. 'default': 3,
  215. 'min': 1
  216. },
  217. 'MAX_GO_TO_BOTTOM_CLICKS': {
  218. 'label': 'Max clicks on "scroll to bottom" button:',
  219. 'label:ru': 'Макс. кликов по кнопке "вниз":',
  220. 'type': 'int',
  221. 'default': 3,
  222. 'min': 0
  223. },
  224. 'RANDOMNESS_FACTOR_MAJOR': {
  225. 'label': 'Randomness factor for major pauses (0.0-1.0):',
  226. 'label:ru': 'Коэф. случайности для основных пауз (0.0-1.0):',
  227. 'type': 'float',
  228. 'default': 0.3,
  229. 'min': 0,
  230. 'max': 1
  231. },
  232. 'RANDOMNESS_FACTOR_MINOR': {
  233. 'label': 'Randomness factor for minor pauses (0.0-1.0):',
  234. 'label:ru': 'Коэф. случайности для малых пауз (0.0-1.0):',
  235. 'type': 'float',
  236. 'default': 0.15,
  237. 'min': 0,
  238. 'max': 1
  239. },
  240. 'USE_FOCUS_IN_SCROLL_UP': {
  241. 'label': 'Use focus() during scroll up:',
  242. 'label:ru': 'Использовать focus() при скролле вверх:',
  243. 'type': 'checkbox',
  244. 'default': false
  245. }
  246. };
  247.  
  248. // Modify labels to include default values and reload info
  249. // Модификация меток для включения значений по умолчанию и информации о перезагрузке
  250. for (const key in configFields) {
  251. if (configFields.hasOwnProperty(key)) {
  252. let labelSuffix = ` (по умолчанию: ${configFields[key].default})`;
  253. if (SETTINGS_REQUIRING_RELOAD.includes(key)) {
  254. labelSuffix += ' [требуется перезагрузка]';
  255. }
  256. configFields[key].label += labelSuffix;
  257. }
  258. }
  259.  
  260. // Event handlers for GM_config GUI
  261. // Обработчики событий для GUI GM_config
  262. const configEventHandlers = {
  263. 'open': function(doc) { // 'doc' is the GM_config iframe's document / 'doc' - это документ iframe GM_config
  264. const urlFieldInputId = `${GM_CONFIG_ID}_field_N8N_WEBHOOK_URL`; // ID for the URL input field / ID для поля ввода URL
  265.  
  266. // Styles for the content INSIDE the GM_config iframe
  267. // Стили для содержимого ВНУТРИ iframe GM_config
  268. const style = doc.createElement('style');
  269. style.textContent = `
  270. #${GM_CONFIG_ID}_wrapper { font-family: Arial, sans-serif; }
  271. #${GM_CONFIG_ID}_header { background-color: #4a4a4a; color: white; padding: 10px; font-size: 1.2em; margin-bottom: 10px; }
  272. .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; }
  273. .config_var { margin: 10px 15px; padding: 8px 0; border-bottom: 1px solid #eee; display: flex; flex-direction: column; }
  274. .config_var label { display: block; margin-bottom: 5px; color: #555; font-size: 0.9em; font-weight: normal; text-align: left; }
  275. .config_var input { padding: 6px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; margin-left: 0; width: 280px; max-width: 100%; }
  276. #${urlFieldInputId} { width: 100% !important; min-width: 450px !important; } /* Specific width for URL field / Особая ширина для поля URL */
  277. .config_var input[type="checkbox"] { width: auto !important; margin-right: auto; align-self: flex-start; }
  278. #${GM_CONFIG_ID}_buttons_holder { padding: 15px; text-align: right; border-top: 1px solid #ddd; background-color: #f9f9f9; }
  279. #${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; }
  280. #${GM_CONFIG_ID}_saveBtn { background-color: #4CAF50; color: white; }
  281. #${GM_CONFIG_ID}_resetBtn { background-color: #f44336; color: white; }
  282. #${GM_CONFIG_ID}_closeBtn { background-color: #bbb; color: black; }
  283. `;
  284. doc.head.appendChild(style);
  285.  
  286. const firstInput = doc.querySelector('input[type="text"], input[type="number"], input[type="checkbox"]');
  287. if (firstInput) {
  288. firstInput.focus();
  289. }
  290. },
  291. 'save': function() {
  292. consoleLog("Настройки сохранены через GM_config GUI. / Settings saved via GM_config GUI.");
  293. alert("Настройки сохранены! Некоторые изменения могут потребовать перезагрузки страницы или перезапуска скрапинга (см. пометки [требуется перезагрузка] у параметров).\n\nSettings saved! Some changes may require a page reload or script restart to take full effect (see [требуется перезагрузка] notes on parameters).");
  294. },
  295. 'reset': function() {
  296. consoleLog("Настройки сброшены через GM_config GUI. / Settings reset via GM_config GUI.");
  297. alert("Настройки сброшены к значениям по умолчанию! Пожалуйста, перезагрузите страницу.\n\nSettings have been reset to default! Please reload the page.");
  298. }
  299. };
  300.  
  301. let gmConfigInitialized = false;
  302. try {
  303. if (typeof GM_config !== 'undefined' && typeof GM_info !== 'undefined') {
  304. GM_config.init({
  305. 'id': GM_CONFIG_ID, // Unique ID for this script's config / Уникальный ID для конфигурации этого скрипта
  306. 'title': `Настройки Telegram Scraper v${GM_info.script.version}`, // Title of the config window / Заголовок окна настроек
  307. 'fields': configFields, // Defined fields / Определенные поля
  308. 'events': configEventHandlers, // Event handlers (open, save, reset) / Обработчики событий (open, save, reset)
  309. 'frameStyle': { // Styles for the GM_config iframe itself / Стили для самого iframe GM_config
  310. width: '1000px',
  311. height: '75vh',
  312. minHeight: '500px',
  313. border: '1px solid rgb(0, 0, 0)',
  314. margin: '0px',
  315. maxHeight: '95%',
  316. maxWidth: '95%', // Limits width to 95% of viewport if 1000px is too wide / Ограничивает ширину до 95% окна просмотра, если 1000px слишком широко
  317. opacity: '1',
  318. overflow: 'auto',
  319. padding: '0px',
  320. position: 'fixed',
  321. zIndex: '9999'
  322. }
  323. });
  324. gmConfigInitialized = true;
  325. consoleLog("GM_config инициализирован. / GM_config initialized.");
  326. } else {
  327. if (typeof GM_config === 'undefined') consoleLog("GM_config не определен. Библиотека не загрузилась или есть конфликт. / GM_config is not defined. Library might not have loaded or there's a conflict.", true);
  328. if (typeof GM_info === 'undefined') consoleLog("GM_info не определен. Не могу получить версию скрипта. / GM_info is not defined. Cannot get script version.", true);
  329. }
  330. } catch (e) {
  331. consoleLog("Ошибка инициализации GM_config: / Error initializing GM_config: " + e, true);
  332. alert("Ошибка инициализации GM_config. Скрипт может работать некорректно. / Error initializing GM_config. The script might not work correctly.");
  333. }
  334.  
  335. /**
  336. * Retrieves a configuration value using GM_config, with a fallback to default.
  337. * @param {string} key - The configuration key.
  338. * @param {*} defaultValue - The default value if the key is not found or GM_config is not ready.
  339. * @returns {*} The configuration value or the default.
  340. *
  341. * Получает значение конфигурации с помощью GM_config, с возвратом к значению по умолчанию.
  342. * @param {string} key - Ключ конфигурации.
  343. * @param {*} defaultValue - Значение по умолчанию, если ключ не найден или GM_config не готов.
  344. * @returns {*} Значение конфигурации или значение по умолчанию.
  345. */
  346. function getConfigValue(key, defaultValue) {
  347. if (gmConfigInitialized && typeof GM_config.get === 'function') {
  348. const val = GM_config.get(key);
  349. // GM_config.get might return undefined if the value isn't set and no default is in its fields,
  350. // or if the type doesn't match. So, check for undefined.
  351. // GM_config.get может вернуть undefined, если значение не установлено и нет значения по умолчанию в его полях,
  352. // или если тип не совпадает. Поэтому проверяем на undefined.
  353. return typeof val !== 'undefined' ? val : defaultValue;
  354. }
  355. // Fallback if GM_config is not initialized
  356. // Фоллбэк, если GM_config не инициализирован
  357. const field = configFields[key];
  358. return field && typeof field.default !== 'undefined' ? field.default : defaultValue;
  359. }
  360.  
  361. /**
  362. * Returns a randomized interval based on a base interval and a randomness factor.
  363. * @param {number} baseInterval - The base interval in milliseconds.
  364. * @param {string} [randomnessFactorKey='RANDOMNESS_FACTOR_MAJOR'] - The key for the randomness factor in settings.
  365. * @returns {number} The randomized interval in milliseconds.
  366. *
  367. * Возвращает рандомизированный интервал на основе базового интервала и коэффициента случайности.
  368. * @param {number} baseInterval - Базовый интервал в миллисекундах.
  369. * @param {string} [randomnessFactorKey='RANDOMNESS_FACTOR_MAJOR'] - Ключ для коэффициента случайности в настройках.
  370. * @returns {number} Рандомизированный интервал в миллисекундах.
  371. */
  372. function getRandomizedInterval(baseInterval, randomnessFactorKey = 'RANDOMNESS_FACTOR_MAJOR') {
  373. const defaultFactor = configFields[randomnessFactorKey] ? configFields[randomnessFactorKey].default : 0.3;
  374. const factor = getConfigValue(randomnessFactorKey, defaultFactor);
  375. const delta = baseInterval * factor * (Math.random() - 0.5) * 2; // Randomness: -factor/2 to +factor/2
  376. // Случайность: от -factor/2 до +factor/2
  377. return Math.max(50, Math.round(baseInterval + delta)); // Ensure interval is at least 50ms / Гарантируем, что интервал не менее 50 мс
  378. }
  379.  
  380. // --- CORE SCRAPING FUNCTIONS ---
  381. // --- ОСНОВНЫЕ ФУНКЦИИ СКРАПИНГА ---
  382.  
  383. /**
  384. * Checks if the currently active chat in the center column matches the target scraping channel.
  385. * It verifies this by comparing the 'data-peer-id' of the avatar in the chat header.
  386. * @returns {boolean} True if the target channel is active, false otherwise.
  387. *
  388. * Проверяет, соответствует ли текущий активный чат в центральной колонке целевому каналу для скрапинга.
  389. * Проверка осуществляется путем сравнения 'data-peer-id' аватара в заголовке чата.
  390. * @returns {boolean} True, если целевой канал активен, иначе false.
  391. */
  392. function isTargetChannelActive() {
  393. if (!currentScrapingChannelInfo || !currentScrapingChannelInfo.id) {
  394. // consoleLog("[isTargetActive] No currentScrapingChannelInfo or ID set.", true);
  395. return false;
  396. }
  397. // Select the chat info container within the currently active chat view
  398. // Выбираем контейнер информации о чате внутри текущего активного представления чата
  399. const chatInfoContainer = document.querySelector('#column-center .chat.active .sidebar-header .chat-info');
  400. if (!chatInfoContainer) {
  401. // consoleLog(`[isTargetActive] Chat info container (.chat.active .sidebar-header .chat-info) not found for "${currentScrapingChannelInfo.name}".`);
  402. return false;
  403. }
  404.  
  405. const avatarElement = chatInfoContainer.querySelector('.avatar[data-peer-id]');
  406. if (avatarElement && avatarElement.dataset && avatarElement.dataset.peerId) {
  407. const displayedPeerId = avatarElement.dataset.peerId;
  408. if (displayedPeerId === currentScrapingChannelInfo.id) {
  409. consoleLog(`[isTargetActive] Channel "${currentScrapingChannelInfo.name}" (ID: ${currentScrapingChannelInfo.id}) IS ACTIVE (peerId ${displayedPeerId} matches).`);
  410. return true;
  411. } else {
  412. // consoleLog(`[isTargetActive] Waiting: Displayed peerId: ${displayedPeerId}, expected: ${currentScrapingChannelInfo.id} for "${currentScrapingChannelInfo.name}"`);
  413. return false;
  414. }
  415. }
  416. // consoleLog(`[isTargetActive] Avatar element with data-peer-id not found within active chat's .chat-info for "${currentScrapingChannelInfo.name}".`);
  417. return false;
  418. }
  419.  
  420. /**
  421. * Parses the timestamp from a message bubble element.
  422. * @param {HTMLElement} bubbleElement - The message bubble HTML element.
  423. * @returns {number|null} The timestamp in milliseconds, or null if not found.
  424. *
  425. * Извлекает временную метку из элемента "пузыря" сообщения.
  426. * @param {HTMLElement} bubbleElement - HTML-элемент "пузыря" сообщения.
  427. * @returns {number|null} Временная метка в миллисекундах или null, если не найдена.
  428. */
  429. function parseTimestampFromBubble(bubbleElement) {
  430. if (bubbleElement && bubbleElement.dataset && bubbleElement.dataset.timestamp) {
  431. return parseInt(bubbleElement.dataset.timestamp, 10) * 1000; // Telegram timestamp is in seconds
  432. // Временная метка Telegram в секундах
  433. }
  434. return null;
  435. }
  436.  
  437. /**
  438. * Extracts structured data from a single message HTML element.
  439. * @param {HTMLElement} messageElement - The HTML element containing the message text (e.g., span.translatable-message).
  440. * @returns {object|string|null} An object with extracted data, 'STOP_SCROLLING' if message is too old, or null on error.
  441. *
  442. * Извлекает структурированные данные из одного HTML-элемента сообщения.
  443. * @param {HTMLElement} messageElement - HTML-элемент, содержащий текст сообщения (например, span.translatable-message).
  444. * @returns {object|string|null} Объект с извлеченными данными, 'STOP_SCROLLING', если сообщение слишком старое, или null в случае ошибки.
  445. */
  446. function extractDataFromMessageElement(messageElement) {
  447. const channelNameForSource = currentScrapingChannelInfo ? currentScrapingChannelInfo.name : 'unknown_channel';
  448. const data = {
  449. title: '',
  450. text: '',
  451. link: null,
  452. pubDate: null,
  453. source: `t.me/${channelNameForSource.replace('@','')}`, // Construct source URL / Формируем URL источника
  454. messageId: null,
  455. rawHtmlContent: messageElement.innerHTML // For debugging or further processing / Для отладки или дальнейшей обработки
  456. };
  457.  
  458. const parentBubble = messageElement.closest('.bubble.channel-post');
  459. if (!parentBubble) {
  460. consoleLog(`[Extractor] Parent bubble (.bubble.channel-post) not found for message: ${messageElement.textContent.substring(0,50)}...`, true);
  461. return null;
  462. }
  463.  
  464. data.messageId = parentBubble.dataset.mid;
  465. if (!data.messageId) {
  466. consoleLog(`[Extractor] Message ID (data-mid) not found on parent bubble: ${parentBubble.outerHTML.substring(0,100)}...`, true);
  467. return null;
  468. }
  469.  
  470. const timestamp = parseTimestampFromBubble(parentBubble);
  471. if (!timestamp) {
  472. consoleLog(`[Extractor] Timestamp could not be parsed for message ID ${data.messageId} in channel ${channelNameForSource}`, true);
  473. return null;
  474. }
  475. data.pubDate = new Date(timestamp).toISOString();
  476.  
  477. // Check if the message is older than the configured limit
  478. // Проверка, не старше ли сообщение установленного лимита
  479. const oldestAllowedDate = new Date();
  480. oldestAllowedDate.setHours(oldestAllowedDate.getHours() - getConfigValue('MAX_MESSAGE_AGE_HOURS', 5));
  481. if (new Date(timestamp) < oldestAllowedDate) {
  482. consoleLog(`[Extractor] Message ID ${data.messageId} (PubDate: ${data.pubDate}) in ${channelNameForSource} is OLDER than ${getConfigValue('MAX_MESSAGE_AGE_HOURS', 5)} hours. Indicating STOP_SCROLLING.`);
  483. return 'STOP_SCROLLING';
  484. }
  485.  
  486. // Attempt to extract title from the first <strong> element not part of a channel signature link
  487. // Попытка извлечь заголовок из первого элемента <strong>, не являющегося частью ссылки-подписи канала
  488. const strongElements = Array.from(messageElement.querySelectorAll('strong'));
  489. if (strongElements.length > 0) {
  490. const firstStrong = strongElements.find(s => {
  491. const anchor = s.closest('a');
  492. // A strong tag is NOT a title if it's inside a link pointing back to the same channel (signature)
  493. // Тег strong НЕ является заголовком, если он находится внутри ссылки, ведущей обратно на тот же канал (подпись)
  494. return !anchor || !(anchor.href.includes(`/${channelNameForSource.replace('@','')}`) || anchor.href.includes(`/${channelNameForSource}`));
  495. });
  496. if (firstStrong) {
  497. data.title = firstStrong.innerText.trim();
  498. }
  499. }
  500.  
  501. // Extract full text content, excluding certain elements like custom emojis, stickers, reactions, and channel signature links
  502. // Извлечение полного текстового содержимого, исключая определенные элементы, такие как кастомные эмодзи, стикеры, реакции и ссылки-подписи канала
  503. let fullText = '';
  504. const channelNamePartForLinkComparison = channelNameForSource.replace('@','');
  505.  
  506. messageElement.childNodes.forEach(node => {
  507. if (node.nodeType === Node.TEXT_NODE) {
  508. fullText += node.textContent;
  509. } else if (node.nodeType === Node.ELEMENT_NODE) {
  510. // If it's an external link, add its text and try to capture the href
  511. // Если это внешняя ссылка, добавляем ее текст и пытаемся захватить href
  512. if (node.tagName === 'A' && node.classList.contains('anchor-url')) {
  513. fullText += node.innerText;
  514. if (!data.link && node.href && node.target === '_blank' && !node.href.startsWith('https://t.me/')) {
  515. data.link = node.href;
  516. }
  517. }
  518. // If it's not a <strong> tag that we already used for the title
  519. // Если это не тег <strong>, который мы уже использовали для заголовка
  520. else if (node.tagName !== 'STRONG' || (data.title && !node.innerText.trim().startsWith(data.title) && !data.title.includes(node.innerText.trim()))) {
  521. const isCustomEmoji = node.matches && (node.matches('img.custom-emoji') || node.matches('custom-emoji-element') || node.querySelector('img.custom-emoji'));
  522. const isSticker = node.matches && (node.matches('.media-sticker-wrapper') || node.matches('tg-sticker'));
  523. const isReactions = node.matches && (node.matches('reactions-element') || node.classList.contains('reactions'));
  524.  
  525. // Check for channel signature links (e.g., "t.me/channelname" or "/channelname" that also contains the channel name as text)
  526. // Проверка на ссылки-подписи канала (например, "t.me/channelname" или "/channelname", которые также содержат имя канала в тексте)
  527. let isChannelSignatureLink = false;
  528. if (node.tagName === 'A' && node.href) {
  529. const hrefLower = node.href.toLowerCase();
  530. if (hrefLower.includes(`t.me/${channelNamePartForLinkComparison.toLowerCase()}`) || hrefLower.includes(`/${channelNamePartForLinkComparison.toLowerCase()}`)) {
  531. if (node.innerText.toLowerCase().includes(channelNamePartForLinkComparison.toLowerCase())) {
  532. isChannelSignatureLink = true;
  533. }
  534. }
  535. }
  536. // Check for nested signature links
  537. // Проверка на вложенные ссылки-подписи
  538. if (!isChannelSignatureLink && node.querySelector(`a[href*="/${channelNamePartForLinkComparison}"]`)) {
  539. const nestedLink = node.querySelector(`a[href*="/${channelNamePartForLinkComparison}"]`);
  540. if (nestedLink.innerText.toLowerCase().includes(channelNamePartForLinkComparison.toLowerCase())) {
  541. isChannelSignatureLink = true;
  542. }
  543. }
  544.  
  545. if (!isCustomEmoji && !isSticker && !isReactions && !isChannelSignatureLink) {
  546. fullText += node.innerText || node.textContent; // Prefer innerText to avoid hidden elements' textContent
  547. // Предпочитаем innerText, чтобы избежать textContent скрытых элементов
  548. }
  549. }
  550. }
  551. });
  552. data.text = fullText.replace(/\s+/g, ' ').trim(); // Normalize whitespace / Нормализация пробелов
  553.  
  554. // If no title was found from <strong>, use the beginning of the text as title
  555. // Если заголовок не был найден из <strong>, используем начало текста как заголовок
  556. if (!data.title && data.text) {
  557. data.title = data.text.substring(0, 120) + (data.text.length > 120 ? '...' : '');
  558. }
  559.  
  560. // If the text starts with the title, remove the title part from the text
  561. // Если текст начинается с заголовка, удаляем часть заголовка из текста
  562. if (data.title && data.text.toLowerCase().startsWith(data.title.toLowerCase())) {
  563. data.text = data.text.substring(data.title.length).trim();
  564. }
  565.  
  566. return data;
  567. }
  568.  
  569. /**
  570. * Sends the scraped data payload to the configured n8n webhook.
  571. * @param {object} payload - The data object to send.
  572. *
  573. * Отправляет собранные данные на настроенный веб-хук n8n.
  574. * @param {object} payload - Объект данных для отправки.
  575. */
  576. function sendToN8N(payload) {
  577. const n8nWebhookUrl = getConfigValue('N8N_WEBHOOK_URL', '');
  578. if (!n8nWebhookUrl) {
  579. updateStatusForConsole('N8N Webhook URL не настроен! / N8N Webhook URL is not configured!', true);
  580. return;
  581. }
  582. const channelName = currentScrapingChannelInfo ? currentScrapingChannelInfo.name : 'N/A';
  583. const channelId = currentScrapingChannelInfo ? currentScrapingChannelInfo.id : 'N/A';
  584.  
  585. updateStatusForConsole(`Отправка ID ${payload.messageId} (Канал: ${channelName} [${channelId}], Date: ${payload.pubDate})... / Sending ID ${payload.messageId} (Channel: ${channelName} [${channelId}], Date: ${payload.pubDate})...`);
  586. GM_xmlhttpRequest({
  587. method: "POST",
  588. url: n8nWebhookUrl,
  589. data: JSON.stringify(payload),
  590. headers: { "Content-Type": "application/json" },
  591. onload: function(response) {
  592. updateStatusForConsole(`n8n ответ для ID ${payload.messageId} (Канал: ${channelName}): ${response.status} / n8n response for ID ${payload.messageId} (Channel: ${channelName}): ${response.status}`);
  593. consoleLog(`[Sender] N8N Response for ID ${payload.messageId}: ${response.status} ${response.responseText.substring(0,100)}`);
  594. },
  595. onerror: function(response) {
  596. updateStatusForConsole(`n8n ошибка для ID ${payload.messageId} (Канал: ${channelName}): ${response.status} / n8n error for ID ${payload.messageId} (Channel: ${channelName}): ${response.status}`, true);
  597. consoleLog(`[Sender] N8N Error for ID ${payload.messageId}: ${response.status} ${response.responseText.substring(0,100)}`, true);
  598. }
  599. });
  600. }
  601.  
  602. /**
  603. * Processes currently visible messages in the active channel.
  604. * Extracts data and sends it to n8n.
  605. * @returns {Promise<object>} An object { foundNew: boolean, stopScrolling: boolean, error?: string }.
  606. *
  607. * Обрабатывает видимые в данный момент сообщения в активном канале.
  608. * Извлекает данные и отправляет их в n8n.
  609. * @returns {Promise<object>} Объект { foundNew: boolean, stopScrolling: boolean, error?: string }.
  610. */
  611. async function processCurrentMessages() {
  612. if (!isScrapingSingle && !isMultiChannelScrapingActive) {
  613. return { foundNew: false, stopScrolling: false };
  614. }
  615. if (!currentScrapingChannelInfo) {
  616. consoleLog("processCurrentMessages: currentScrapingChannelInfo is not set.", true);
  617. return { foundNew: false, stopScrolling: true, error: "Канал не установлен / Channel not set" };
  618. }
  619. if (!isTargetChannelActive()) {
  620. updateStatusForConsole(`Канал ${currentScrapingChannelInfo.name} не активен (process). / Channel ${currentScrapingChannelInfo.name} is not active (process).`, true);
  621. return { foundNew: false, stopScrolling: true, error: `Канал ${currentScrapingChannelInfo.name} не активен / Channel ${currentScrapingChannelInfo.name} is not active` };
  622. }
  623.  
  624. updateStatusForConsole(`Поиск в ${currentScrapingChannelInfo.name}... / Searching in ${currentScrapingChannelInfo.name}...`);
  625. // Query for message text containers
  626. // Запрос контейнеров текста сообщений
  627. const messageElements = document.querySelectorAll('.bubble.channel-post .message span.translatable-message, .bubble.channel-post .text-content');
  628. let foundNew = false;
  629. let stopDueToAge = false;
  630.  
  631. // Iterate backwards to process older messages first if needed, or to find the "stop scrolling" point sooner
  632. // Итерация в обратном порядке для обработки сначала более старых сообщений, если необходимо, или для более быстрого нахождения точки "остановки прокрутки"
  633. for (let i = messageElements.length - 1; i >= 0; i--) {
  634. if (!isScrapingSingle && !isMultiChannelScrapingActive) break; // Stop if scraping is cancelled / Остановка, если сбор отменен
  635.  
  636. const el = messageElements[i];
  637. const parentBubble = el.closest('.bubble.channel-post');
  638. const msgId = parentBubble ? parentBubble.dataset.mid : null;
  639.  
  640. if (msgId) {
  641. const articleData = extractDataFromMessageElement(el);
  642.  
  643. if (articleData === 'STOP_SCROLLING') {
  644. stopDueToAge = true;
  645. const ts = parentBubble?.dataset.timestamp ? new Date(parseInt(parentBubble.dataset.timestamp,10)*1000).toISOString() : 'N/A';
  646. updateStatusForConsole(`Старые сообщения (ID: ${msgId}, Date: ${ts}). Стоп. / Old messages (ID: ${msgId}, Date: ${ts}). Stop.`);
  647. break; // Stop processing further messages on this page / Прекратить обработку дальнейших сообщений на этой странице
  648. }
  649.  
  650. if (articleData && articleData.title && (articleData.text || articleData.link)) {
  651. consoleLog(`[Proc] ID ${msgId} (${articleData.pubDate.substring(11,19)}) к отправке. / to be sent.`);
  652. sendToN8N(articleData);
  653. foundNew = true;
  654. await new Promise(r => setTimeout(r, getRandomizedInterval(getConfigValue('BASE_SEND_DELAY_MS', 1000), 'RANDOMNESS_FACTOR_MINOR')));
  655. } else if (articleData) {
  656. consoleLog(`[Proc] ID ${msgId} пропущено (нет данных). / skipped (no data).`);
  657. } else {
  658. consoleLog(`[Proc] ID ${msgId} ошибка извлечения. / extraction error.`, true);
  659. }
  660. }
  661. }
  662. return { foundNew, stopScrolling: stopDueToAge };
  663. }
  664.  
  665. /**
  666. * Attempts to scroll the message list up to load older messages.
  667. *
  668. * Пытается прокрутить список сообщений вверх для загрузки более старых сообщений.
  669. */
  670. async function tryScrollUp() {
  671. if (!isScrapingSingle && !isMultiChannelScrapingActive) return;
  672. await new Promise(r => setTimeout(r, getRandomizedInterval(getConfigValue('BASE_SCROLL_ACTION_PAUSE_MS', 300), 'RANDOMNESS_FACTOR_MINOR')));
  673. updateStatusForConsole('Скролл вверх... / Scrolling up...');
  674.  
  675. const messageBubbles = document.querySelectorAll('.bubbles-inner .bubble.channel-post');
  676. if (messageBubbles.length > 0) {
  677. const topBubble = messageBubbles[0];
  678. // Ensure the element is focusable for scrollIntoView if needed
  679. // Убеждаемся, что элемент может получить фокус для scrollIntoView, если это необходимо
  680. if (typeof topBubble.tabIndex === 'undefined' || topBubble.tabIndex === -1) {
  681. topBubble.tabIndex = -1;
  682. }
  683. try {
  684. consoleLog(`Скролл к верхнему ID: ${topBubble.dataset.mid} (scrollIntoView) / Scrolling to top ID: ${topBubble.dataset.mid} (scrollIntoView)`);
  685. topBubble.scrollIntoView({ behavior: 'auto', block: 'start' }); // 'auto' is faster than 'smooth' / 'auto' быстрее, чем 'smooth'
  686. await new Promise(r => setTimeout(r, getRandomizedInterval(getConfigValue('BASE_SCROLL_ACTION_PAUSE_MS', 300), 'RANDOMNESS_FACTOR_MINOR')));
  687.  
  688. if (getConfigValue('USE_FOCUS_IN_SCROLL_UP', false)) {
  689. consoleLog(`Фокус на верхний ID: ${topBubble.dataset.mid} / Focusing top ID: ${topBubble.dataset.mid}`);
  690. topBubble.focus({ preventScroll: true }); // Focus without scrolling again / Фокус без повторной прокрутки
  691. }
  692. await new Promise(r => setTimeout(r, getRandomizedInterval(getConfigValue('BASE_SCROLL_PAUSE_MS', 5000))));
  693. } catch (e) {
  694. consoleLog(`Ошибка scrollIntoView/focus: ${e.message} / Error scrollIntoView/focus: ${e.message}`, true);
  695. updateStatusForConsole('Ошибка скролла вверх. Стандартный метод... / Scroll up error. Fallback method...', true);
  696. // Fallback scroll method / Резервный метод прокрутки
  697. const scrollArea = document.querySelector('div.bubbles-inner')?.parentElement || document.querySelector('.scrollable-y.chat-history-list') || document.querySelector('.bubbles > .scrollable-y');
  698. if (scrollArea) {
  699. scrollArea.scrollTop = 0; // Scroll to the very top / Прокрутка в самый верх
  700. scrollArea.dispatchEvent(new WheelEvent('wheel', { deltaY: -1000, bubbles: true, cancelable: true })); // Simulate wheel scroll / Имитация прокрутки колесом
  701. await new Promise(r => setTimeout(r, getRandomizedInterval(getConfigValue('BASE_SCROLL_PAUSE_MS', 5000))));
  702. }
  703. }
  704. } else {
  705. updateStatusForConsole('Нет сообщений для скролла вверх. Стандартный метод. / No messages to scroll up to. Fallback method.');
  706. const scrollArea = document.querySelector('div.bubbles-inner')?.parentElement || document.querySelector('.scrollable-y.chat-history-list') || document.querySelector('.bubbles > .scrollable-y');
  707. if (scrollArea) {
  708. scrollArea.scrollTop = 0;
  709. scrollArea.dispatchEvent(new WheelEvent('wheel', { deltaY: -1000, bubbles: true, cancelable: true }));
  710. await new Promise(r => setTimeout(r, getRandomizedInterval(getConfigValue('BASE_SCROLL_PAUSE_MS', 5000))));
  711. } else {
  712. updateStatusForConsole('Нет области скролла и нет сообщений. / No scrollable area and no messages found.', true);
  713. }
  714. }
  715. }
  716.  
  717. /**
  718. * Scrolls to the bottom of the chat, handling the "Go to bottom" button and programmatic scrolling.
  719. * @returns {Promise<boolean>} True if scrolled to bottom successfully, false otherwise.
  720. *
  721. * Прокручивает чат до конца, обрабатывая кнопку "Перейти к последним сообщениям" и программную прокрутку.
  722. * @returns {Promise<boolean>} True, если прокрутка до конца прошла успешно, иначе false.
  723. */
  724. async function scrollToBottom() {
  725. updateStatusForConsole('Прокрутка к последним сообщениям... / Scrolling to latest messages...');
  726. const scrollableArea = document.querySelector('div.bubbles-inner')?.parentElement || document.querySelector('.scrollable-y.chat-history-list') || document.querySelector('.bubbles > .scrollable-y');
  727. if (!scrollableArea) {
  728. updateStatusForConsole('Ошибка: Не найдена область для прокрутки вниз. / Error: Scrollable area for scrolling down not found.', true);
  729. return false;
  730. }
  731.  
  732. let goToBottomButton;
  733. let clicksMade = 0;
  734. const maxClicks = getConfigValue('MAX_GO_TO_BOTTOM_CLICKS', 3);
  735. await new Promise(r => setTimeout(r, getRandomizedInterval(getConfigValue('BASE_SCROLL_ACTION_PAUSE_MS', 300), 'RANDOMNESS_FACTOR_MINOR')));
  736.  
  737. // Click "Go to bottom" button if it has unread messages badge
  738. // Нажатие кнопки "Перейти к последним сообщениям", если на ней есть значок непрочитанных сообщений
  739. while (clicksMade < maxClicks) {
  740. if (!isScrapingSingle && !isMultiChannelScrapingActive && clicksMade > 0) {
  741. updateStatusForConsole('Прокрутка вниз прервана. / Scroll down interrupted.');
  742. return false;
  743. }
  744. goToBottomButton = document.querySelector('.bubbles-go-down.chat-secondary-button:not(.is-hidden):not([style*="display: none"])');
  745. const badge = goToBottomButton ? goToBottomButton.querySelector('.badge:not(.is-badge-empty)') : null;
  746.  
  747. if (goToBottomButton && badge && typeof goToBottomButton.click === 'function') {
  748. const unreadCountText = badge.textContent;
  749. updateStatusForConsole(`Клик по кнопке "вниз" (${unreadCountText || 'несколько'} непрочитанных)... / Clicking "down" button (${unreadCountText || 'some'} unread)...`);
  750. consoleLog(`[ScrollToBottom] Clicking "go to bottom" button (unread: ${unreadCountText}). Click ${clicksMade + 1}`);
  751. goToBottomButton.click();
  752. clicksMade++;
  753. await new Promise(resolve => setTimeout(resolve, getRandomizedInterval(getConfigValue('BASE_SCROLL_BOTTOM_CLICK_PAUSE_MS', 2500))));
  754. } else {
  755. consoleLog('[ScrollToBottom] "Go to bottom" button with counter not found or empty.');
  756. break;
  757. }
  758. }
  759.  
  760. // Programmatic scroll to bottom
  761. // Программная прокрутка вниз
  762. updateStatusForConsole('Программная прокрутка вниз... / Programmatic scroll down...');
  763. let prevScrollHeight = 0;
  764. const scrollIterations = getConfigValue('SCROLL_BOTTOM_PROGRAMMATIC_ITERATIONS', 3);
  765. for (let i = 0; i < scrollIterations; i++) {
  766. if (!isScrapingSingle && !isMultiChannelScrapingActive) {
  767. updateStatusForConsole('Прокрутка вниз прервана. / Scroll down interrupted.');
  768. return false;
  769. }
  770. prevScrollHeight = scrollableArea.scrollHeight;
  771. scrollableArea.scrollTop = scrollableArea.scrollHeight;
  772. updateStatusForConsole(`Прокрутка вниз... (итерация ${i + 1}/${scrollIterations}) / Scrolling down... (iteration ${i + 1}/${scrollIterations})`);
  773. await new Promise(resolve => setTimeout(resolve, getRandomizedInterval(getConfigValue('BASE_SCROLL_BOTTOM_PROG_PAUSE_MS', 700), 'RANDOMNESS_FACTOR_MINOR')));
  774. // If scroll height didn't change much, we are likely at the bottom
  775. // Если высота прокрутки почти не изменилась, мы, вероятно, внизу
  776. if (i > 0 && scrollableArea.scrollHeight - prevScrollHeight < 50) {
  777. consoleLog('[ScrollToBottom] Scroll height changed minimally, likely at bottom.');
  778. break;
  779. }
  780. }
  781.  
  782. // Scroll to the very last message group for precision
  783. // Прокрутка к самой последней группе сообщений для точности
  784. const lastMessageGroup = document.querySelector('.bubbles-inner .bubbles-group-last');
  785. if (lastMessageGroup) {
  786. consoleLog('[ScrollToBottom] Found .bubbles-group-last, scrolling to it.');
  787. updateStatusForConsole('Точная прокрутка к последней группе... / Precise scroll to last group...');
  788. lastMessageGroup.scrollIntoView({ behavior: 'auto', block: 'end' });
  789. await new Promise(resolve => setTimeout(resolve, getRandomizedInterval(getConfigValue('BASE_SCROLL_BOTTOM_PROG_PAUSE_MS', 700) / 2, 'RANDOMNESS_FACTOR_MINOR')));
  790. } else {
  791. consoleLog('[ScrollToBottom] .bubbles-group-last not found.');
  792. }
  793.  
  794. // Final click on "Go to bottom" if it's still visible (without badge)
  795. // Финальный клик по кнопке "Перейти к последним сообщениям", если она все еще видна (без значка)
  796. goToBottomButton = document.querySelector('.bubbles-go-down.chat-secondary-button:not(.is-hidden):not([style*="display: none"])');
  797. if (goToBottomButton && typeof goToBottomButton.click === 'function' && clicksMade < maxClicks) {
  798. const finalBadge = goToBottomButton.querySelector('.badge:not(.is-badge-empty)');
  799. if (!finalBadge) { // Click if no badge (meaning it's just the arrow) / Клик, если нет значка (значит, это просто стрелка)
  800. consoleLog('[ScrollToBottom] "Go to bottom" button (no counter) is active, final click.');
  801. updateStatusForConsole('Финальный клик по кнопке "вниз"... / Final click on "down" button...');
  802. goToBottomButton.click();
  803. await new Promise(resolve => setTimeout(resolve, getRandomizedInterval(getConfigValue('BASE_SCROLL_BOTTOM_CLICK_PAUSE_MS', 2500) / 2)));
  804. }
  805. }
  806.  
  807. updateStatusForConsole('Прокрутка к последним сообщениям завершена. / Scroll to latest messages completed.');
  808. return true;
  809. }
  810.  
  811. /**
  812. * Main scraping loop for a single channel. Scrolls up, processes messages, and repeats.
  813. *
  814. * Основной цикл сбора данных для одного канала. Прокручивает вверх, обрабатывает сообщения и повторяет.
  815. */
  816. async function scrapingLoopSingleChannel() {
  817. if (!isScrapingSingle) {
  818. consoleLog(`[Loop-${currentScrapingChannelInfo.name}] Остановлен (isScrapingSingle=false). / Stopped (isScrapingSingle=false).`);
  819. return;
  820. }
  821. // This check is important if multi-channel scraping was stopped while this loop was paused for the next iteration.
  822. // Эта проверка важна, если многоканальный сбор был остановлен, пока этот цикл был на паузе перед следующей итерацией.
  823. if (isMultiChannelScrapingActive && !isScrapingSingle) {
  824. consoleLog(`[Loop-${currentScrapingChannelInfo.name}] Остановлен (multi active, single false). / Stopped (multi active, single false).`);
  825. return;
  826. }
  827.  
  828. await new Promise(r => setTimeout(r, getRandomizedInterval(getConfigValue('BASE_SCROLL_ACTION_PAUSE_MS', 300), 'RANDOMNESS_FACTOR_MINOR')));
  829.  
  830. const { foundNew, stopScrolling, error } = await processCurrentMessages();
  831.  
  832. if (error) {
  833. updateStatusForConsole(error + `. Прерываю для ${currentScrapingChannelInfo.name}. / Aborting for ${currentScrapingChannelInfo.name}.`, true);
  834. return; // Stop this channel's loop on error / Остановка цикла этого канала при ошибке
  835. }
  836. if (stopScrolling) {
  837. updateStatusForConsole(`Лимит по дате для ${currentScrapingChannelInfo.name}. Завершаю. / Date limit reached for ${currentScrapingChannelInfo.name}. Finishing.`);
  838. return; // Stop this channel's loop / Остановка цикла этого канала
  839. }
  840.  
  841. if (foundNew) {
  842. consecutiveScrollsWithoutNewFound = 0; // Reset counter if new messages were found / Сброс счетчика, если найдены новые сообщения
  843. } else {
  844. consecutiveScrollsWithoutNewFound++;
  845. consoleLog(`[Loop-${currentScrapingChannelInfo.name}] Ничего нового. Счетчик: ${consecutiveScrollsWithoutNewFound} / Nothing new. Counter: ${consecutiveScrollsWithoutNewFound}`);
  846. }
  847.  
  848. if (consecutiveScrollsWithoutNewFound >= getConfigValue('CONSECUTIVE_SCROLLS_LIMIT', 5)) {
  849. updateStatusForConsole(`Нет новых сообщений для ${currentScrapingChannelInfo.name} после ${getConfigValue('CONSECUTIVE_SCROLLS_LIMIT', 5)} прокруток. Завершаю. / No new messages for ${currentScrapingChannelInfo.name} after ${getConfigValue('CONSECUTIVE_SCROLLS_LIMIT', 5)} scrolls. Finishing.`);
  850. return; // Stop this channel's loop / Остановка цикла этого канала
  851. }
  852.  
  853. await tryScrollUp(); // Scroll up to load more messages / Прокрутка вверх для загрузки большего количества сообщений
  854.  
  855. if (isScrapingSingle) { // Check flag again before scheduling next iteration / Проверка флага снова перед планированием следующей итерации
  856. const baseNextInterval = !foundNew ? getConfigValue('BASE_SCRAPE_INTERVAL_MS', 30000) : getConfigValue('BASE_SCRAPE_INTERVAL_MS', 30000) / 2;
  857. await new Promise(r => setTimeout(r, getRandomizedInterval(baseNextInterval)));
  858. if (isScrapingSingle) await scrapingLoopSingleChannel(); // Recursive call for the loop / Рекурсивный вызов для цикла
  859. }
  860. }
  861.  
  862. /**
  863. * Manages the process of scraping a single channel: navigation, activation check, scrolling, and starting the loop.
  864. * @param {object} channelInfoObject - An object from TARGET_CHANNELS_DATA.
  865. * @returns {Promise<boolean>} True if scraping process was successful (or ran its course), false on critical failure.
  866. *
  867. * Управляет процессом сбора данных с одного канала: навигация, проверка активации, прокрутка и запуск цикла.
  868. * @param {object} channelInfoObject - Объект из TARGET_CHANNELS_DATA.
  869. * @returns {Promise<boolean>} True, если процесс сбора прошел успешно (или завершился), false при критической ошибке.
  870. */
  871. async function scrapeSingleChannelProcess(channelInfoObject) {
  872. if (!channelInfoObject || !channelInfoObject.id || !channelInfoObject.name) {
  873. consoleLog("Ошибка: Некорректные данные канала в scrapeSingleChannelProcess / Error: Invalid channel data in scrapeSingleChannelProcess", true);
  874. return false;
  875. }
  876. // This check ensures that if scraping was stopped globally, this process doesn't start/continue.
  877. // Эта проверка гарантирует, что если сбор был остановлен глобально, этот процесс не начнется/не продолжится.
  878. if (!isScrapingSingle && !isMultiChannelScrapingActive) {
  879. consoleLog(`scrapeSingleChannelProcess для ${channelInfoObject.name} не может быть запущен (флаги). / cannot be started (flags).`);
  880. return false;
  881. }
  882.  
  883. currentScrapingChannelInfo = channelInfoObject; // Set the global current channel / Установка текущего глобального канала
  884.  
  885. consoleLog(`--- Начало скрапинга канала: ${currentScrapingChannelInfo.name} (ID: ${currentScrapingChannelInfo.id}) --- / --- Starting scrape for channel: ${currentScrapingChannelInfo.name} (ID: ${currentScrapingChannelInfo.id}) ---`);
  886. updateStatusForConsole(`Скрапинг: ${currentScrapingChannelInfo.name} / Scraping: ${currentScrapingChannelInfo.name}`);
  887.  
  888. // --- Navigation and Activation ---
  889. // --- Навигация и Активация ---
  890. const targetHashForNavigation = `#${currentScrapingChannelInfo.name}`; // Assumes channel name is usable in hash / Предполагается, что имя канала можно использовать в хэше
  891. let navigationNeeded = true;
  892.  
  893. // Check if already on the target channel by peer ID
  894. // Проверка, находимся ли мы уже на целевом канале по peer ID
  895. const chatInfoContainerInitial = document.querySelector('#column-center .chat.active .sidebar-header .chat-info');
  896. let initialDisplayedPeerId = null;
  897. if (chatInfoContainerInitial) {
  898. const avatarElementInitial = chatInfoContainerInitial.querySelector('.avatar[data-peer-id]');
  899. if (avatarElementInitial) { initialDisplayedPeerId = avatarElementInitial.dataset.peerId; }
  900. }
  901.  
  902. if (initialDisplayedPeerId === currentScrapingChannelInfo.id) {
  903. consoleLog(`[Nav] Уже на канале ${currentScrapingChannelInfo.name} (peerId совпадает). / Already on channel ${currentScrapingChannelInfo.name} (peerId matches).`);
  904. navigationNeeded = false;
  905. } else if (window.location.hash.toLowerCase() === targetHashForNavigation.toLowerCase() && initialDisplayedPeerId) {
  906. // If hash matches but peer ID doesn't (or if peer ID is not yet available but hash is correct),
  907. // still wait for peer ID activation to be sure.
  908. // Если хэш совпадает, а peer ID нет (или если peer ID еще недоступен, но хэш правильный),
  909. // все равно ждем активации по peer ID для уверенности.
  910. consoleLog(`[Nav] URL hash is ${targetHashForNavigation} or peerId (${initialDisplayedPeerId}) present, but expecting ${currentScrapingChannelInfo.id}. Will wait for peerId activation.`);
  911. navigationNeeded = false; // No need to change hash, just wait for activation
  912. // Нет необходимости менять хэш, просто ждем активации
  913. }
  914.  
  915. if (navigationNeeded) {
  916. consoleLog(`Перехожу на канал ${targetHashForNavigation}... / Navigating to channel ${targetHashForNavigation}...`);
  917. window.location.hash = targetHashForNavigation;
  918. await new Promise(r => setTimeout(r, getRandomizedInterval(getConfigValue('NAVIGATION_INITIATION_PAUSE_MS', 2500), 'RANDOMNESS_FACTOR_MAJOR')));
  919. }
  920.  
  921. // Wait for the channel to become active by checking its peer ID
  922. // Ожидание активации канала путем проверки его peer ID
  923. let activationAttempts = 0;
  924. const maxActivationAttempts = getConfigValue('MAX_CHANNEL_ACTIVATION_ATTEMPTS', 25);
  925. consoleLog(`Ожидание активации канала ${currentScrapingChannelInfo.name} (ID: ${currentScrapingChannelInfo.id}) по peer-id... / Waiting for channel ${currentScrapingChannelInfo.name} (ID: ${currentScrapingChannelInfo.id}) to activate by peer-id...`);
  926. while (activationAttempts < maxActivationAttempts) {
  927. if (!isScrapingSingle && !isMultiChannelScrapingActive) { // Check if scraping was stopped during wait / Проверка, не был ли сбор остановлен во время ожидания
  928. consoleLog("Остановка во время ожидания активации канала. / Stopped while waiting for channel activation.");
  929. return false;
  930. }
  931. if (isTargetChannelActive()) {
  932. break; // Channel is active / Канал активен
  933. }
  934. activationAttempts++;
  935. updateStatusForConsole(`Ожидание ${currentScrapingChannelInfo.name} (${activationAttempts}/${maxActivationAttempts}) / Waiting for ${currentScrapingChannelInfo.name} (${activationAttempts}/${maxActivationAttempts})`);
  936. await new Promise(r => setTimeout(r, getRandomizedInterval(getConfigValue('CHANNEL_ACTIVATION_ATTEMPT_PAUSE_MS', 700), 'RANDOMNESS_FACTOR_MINOR')));
  937. }
  938.  
  939. if (!isTargetChannelActive()) {
  940. updateStatusForConsole(`Не удалось активировать ${currentScrapingChannelInfo.name} (ID: ${currentScrapingChannelInfo.id}) по peer-id. Пропускаю. / Failed to activate ${currentScrapingChannelInfo.name} (ID: ${currentScrapingChannelInfo.id}) by peer-id. Skipping.`, true);
  941. return false; // Critical failure for this channel / Критическая ошибка для этого канала
  942. }
  943.  
  944. // --- Scroll to Bottom and Start Scraping Up ---
  945. // --- Прокрутка вниз и начало сбора вверх ---
  946. consoleLog(`Канал ${currentScrapingChannelInfo.name} активен. Прокрутка вниз. / Channel ${currentScrapingChannelInfo.name} is active. Scrolling to bottom.`);
  947. const scrolledToBottom = await scrollToBottom();
  948. if (!scrolledToBottom) {
  949. if (isScrapingSingle || isMultiChannelScrapingActive) { // Only log error if scraping is still meant to be active / Логировать ошибку, только если сбор все еще должен быть активен
  950. updateStatusForConsole(`Ошибка прокрутки вниз для ${currentScrapingChannelInfo.name}. / Error scrolling to bottom for ${currentScrapingChannelInfo.name}.`, true);
  951. }
  952. return false; // Could not prepare channel / Не удалось подготовить канал
  953. }
  954. if (!isScrapingSingle && !isMultiChannelScrapingActive) { // Re-check after potentially long scroll / Повторная проверка после потенциально долгой прокрутки
  955. consoleLog("Остановка после прокрутки вниз. / Stopped after scrolling to bottom.");
  956. return false;
  957. }
  958.  
  959. updateStatusForConsole(`Скрапинг вверх для ${currentScrapingChannelInfo.name}... / Scraping upwards for ${currentScrapingChannelInfo.name}...`);
  960. consecutiveScrollsWithoutNewFound = 0; // Reset for this channel / Сброс для этого канала
  961. await scrapingLoopSingleChannel(); // Start the actual scraping loop / Запуск основного цикла сбора
  962.  
  963. consoleLog(`--- Скрапинг канала ${currentScrapingChannelInfo.name} завершен/остановлен --- / --- Scraping for channel ${currentScrapingChannelInfo.name} finished/stopped ---`);
  964. return true; // Process for this channel completed its course / Процесс для этого канала завершил свой ход
  965. }
  966.  
  967.  
  968. // --- MENU COMMAND HANDLERS ---
  969. // --- ОБРАБОТЧИКИ КОМАНД МЕНЮ ---
  970.  
  971. /**
  972. * Menu command: Scrape the currently open channel.
  973. * Команда меню: Собрать данные с текущего открытого канала.
  974. */
  975. async function startSingleChannelScrapeMenu() {
  976. consoleLog("Команда 'Scrape Current Channel' вызвана. / 'Scrape Current Channel' command called.");
  977. if (isScrapingSingle || isMultiChannelScrapingActive) {
  978. alert("Скрапинг уже запущен. / Scraping is already running.");
  979. consoleLog("Скрапинг уже запущен. / Scraping is already running.", true);
  980. return;
  981. }
  982.  
  983. // Determine current channel based on peer ID in header, then by URL hash
  984. // Определение текущего канала по peer ID в заголовке, затем по URL хэшу
  985. let displayedPeerId = null;
  986. const chatInfoContainer = document.querySelector('#column-center .chat.active .sidebar-header .chat-info');
  987. if (chatInfoContainer) {
  988. const avatarElement = chatInfoContainer.querySelector('.avatar[data-peer-id]');
  989. if (avatarElement && avatarElement.dataset && avatarElement.dataset.peerId) {
  990. displayedPeerId = avatarElement.dataset.peerId;
  991. }
  992. }
  993.  
  994. let channelInfoToScrape = null;
  995. if (displayedPeerId) {
  996. channelInfoToScrape = TARGET_CHANNELS_DATA.find(ch => ch.id === displayedPeerId);
  997. if (channelInfoToScrape) {
  998. consoleLog(`[startSingle] Канал определен по peer-id: ${channelInfoToScrape.name} (${displayedPeerId}) / Channel identified by peer-id: ${channelInfoToScrape.name} (${displayedPeerId})`);
  999. } else {
  1000. consoleLog(`[startSingle] Peer-id ${displayedPeerId} найден, но не соответствует ни одному каналу в TARGET_CHANNELS_DATA. / Peer-id ${displayedPeerId} found, but does not match any channel in TARGET_CHANNELS_DATA.`);
  1001. }
  1002. } else {
  1003. consoleLog(`[startSingle] Не удалось получить peer-id из шапки активного чата. / Could not get peer-id from active chat header.`);
  1004. }
  1005.  
  1006. if (!channelInfoToScrape) { // Fallback to URL hash if peer ID method failed / Резервный вариант - URL хэш, если метод с peer ID не сработал
  1007. let hash = window.location.hash.substring(1); // Remove #
  1008. if (hash) {
  1009. const queryParamIndex = hash.indexOf('?'); // Remove query params if any / Удаление query-параметров, если есть
  1010. if (queryParamIndex !== -1) { hash = hash.substring(0, queryParamIndex); }
  1011.  
  1012. // Try matching by ID first (if hash is a peer ID)
  1013. // Сначала пытаемся сопоставить по ID (если хэш - это peer ID)
  1014. channelInfoToScrape = TARGET_CHANNELS_DATA.find(ch => ch.id === hash);
  1015. if (!channelInfoToScrape) {
  1016. // Then try matching by name (add @ if missing and not a number)
  1017. // Затем пытаемся сопоставить по имени (добавляем @, если отсутствует и не является числом)
  1018. let nameToCompare = hash;
  1019. if (!hash.startsWith('@') && isNaN(parseInt(hash))) { // Avoid adding @ to peer IDs / Избегаем добавления @ к peer ID
  1020. nameToCompare = '@' + hash;
  1021. }
  1022. channelInfoToScrape = TARGET_CHANNELS_DATA.find(ch => ch.name.toLowerCase() === nameToCompare.toLowerCase());
  1023. }
  1024.  
  1025. if (channelInfoToScrape) {
  1026. consoleLog(`[startSingle] Канал определен по hash "${hash}": ${channelInfoToScrape.name} / Channel identified by hash "${hash}": ${channelInfoToScrape.name}`);
  1027. } else {
  1028. consoleLog(`[startSingle] Канал не определен по hash "${hash}". / Channel not identified by hash "${hash}".`);
  1029. }
  1030. }
  1031. }
  1032.  
  1033. if (!channelInfoToScrape) {
  1034. alert("Не удалось определить текущий канал из списка TARGET_CHANNELS_DATA. Откройте один из целевых каналов или убедитесь, что URL корректен.\n\nCould not determine the current channel from TARGET_CHANNELS_DATA. Please open one of the target channels or ensure the URL is correct.");
  1035. consoleLog("Не удалось определить текущий канал из списка TARGET_CHANNELS_DATA. / Could not determine current channel from TARGET_CHANNELS_DATA.", true);
  1036. return;
  1037. }
  1038.  
  1039. isScrapingSingle = true; // Set flag for single channel mode / Установка флага для режима одного канала
  1040. consoleLog(`--- Начало ОДИНОЧНОЙ сессии для ${channelInfoToScrape.name} --- / --- Starting SINGLE session for ${channelInfoToScrape.name} ---`);
  1041. alert(`Начинаю скрапинг текущего канала: ${channelInfoToScrape.name}. Следите за консолью (F12).\n\nStarting to scrape current channel: ${channelInfoToScrape.name}. Check console (F12) for progress.`);
  1042.  
  1043. await scrapeSingleChannelProcess(channelInfoToScrape);
  1044.  
  1045. isScrapingSingle = false; // Clear flag when done / Сброс флага по завершении
  1046. if (!isMultiChannelScrapingActive) { // Only show completion alert if not part of a multi-scrape / Показать alert о завершении, только если это не часть многоканального сбора
  1047. updateStatusForConsole("Скрапинг текущего канала завершен. / Scraping of current channel finished.");
  1048. consoleLog("--- ОДИНОЧНАЯ сессия скрапинга завершена --- / --- SINGLE scraping session finished ---");
  1049. alert(`Скрапинг канала ${channelInfoToScrape.name} завершен. / Scraping of channel ${channelInfoToScrape.name} finished.`);
  1050. }
  1051. currentScrapingChannelInfo = null; // Clear current channel info / Очистка информации о текущем канале
  1052. }
  1053.  
  1054. /**
  1055. * Menu command: Scrape all channels listed in TARGET_CHANNELS_DATA.
  1056. * Команда меню: Собрать данные со всех каналов, перечисленных в TARGET_CHANNELS_DATA.
  1057. */
  1058. async function startMultiChannelScrapeMenu() {
  1059. consoleLog("Команда 'Scrape All Listed Channels' вызвана. / 'Scrape All Listed Channels' command called.");
  1060. if (isScrapingSingle || isMultiChannelScrapingActive) {
  1061. alert("Скрапинг уже запущен. / Scraping is already running.");
  1062. consoleLog("Скрапинг уже запущен. / Scraping is already running.", true);
  1063. return;
  1064. }
  1065. if (!confirm(`Начать скрапинг ${TARGET_CHANNELS_DATA.length} каналов? Это может занять много времени.\n\nStart scraping ${TARGET_CHANNELS_DATA.length} channels? This may take a long time.`)) {
  1066. consoleLog("Мульти-скрапинг отменен пользователем. / Multi-channel scraping cancelled by user.");
  1067. return;
  1068. }
  1069.  
  1070. isMultiChannelScrapingActive = true; // Set flag for multi-channel mode / Установка флага для многоканального режима
  1071. currentChannelIndex = 0; // Reset index / Сброс индекса
  1072. consoleLog("--- Начало МУЛЬТИ-СКРАПИНГА --- / --- Starting MULTI-CHANNEL SCRAPING ---");
  1073. alert("Начинаю скрапинг всех каналов из списка. Следите за консолью (F12).\n\nStarting to scrape all listed channels. Check console (F12) for progress.");
  1074.  
  1075. while (currentChannelIndex < TARGET_CHANNELS_DATA.length && isMultiChannelScrapingActive) {
  1076. isScrapingSingle = true; // Temporarily set for scrapeSingleChannelProcess logic / Временно устанавливается для логики scrapeSingleChannelProcess
  1077. const channelInfo = TARGET_CHANNELS_DATA[currentChannelIndex];
  1078. updateStatusForConsole(`[${currentChannelIndex + 1}/${TARGET_CHANNELS_DATA.length}] Запуск для: ${channelInfo.name} / Starting for: ${channelInfo.name}`);
  1079.  
  1080. const success = await scrapeSingleChannelProcess(channelInfo);
  1081.  
  1082. isScrapingSingle = false; // Clear temporary flag / Сброс временного флага
  1083. if (!isMultiChannelScrapingActive) { // Check if stopped during this channel's processing / Проверка, не был ли остановлен во время обработки этого канала
  1084. consoleLog("Мульти-скрапинг остановлен пользователем во время обработки. / Multi-channel scraping stopped by user during processing.");
  1085. break;
  1086. }
  1087.  
  1088. if (!success) {
  1089. consoleLog(`Проблема со скрапингом канала ${channelInfo.name}, пропускаю. / Problem scraping channel ${channelInfo.name}, skipping.`, true);
  1090. }
  1091.  
  1092. currentChannelIndex++;
  1093. if (currentChannelIndex < TARGET_CHANNELS_DATA.length && isMultiChannelScrapingActive) {
  1094. const pauseDuration = getRandomizedInterval(getConfigValue('BASE_SCROLL_PAUSE_MS', 5000) * 1.5, 'RANDOMNESS_FACTOR_MAJOR');
  1095. updateStatusForConsole(`Пауза ${Math.round(pauseDuration/1000)}с перед ${TARGET_CHANNELS_DATA[currentChannelIndex].name} / Pausing ${Math.round(pauseDuration/1000)}s before ${TARGET_CHANNELS_DATA[currentChannelIndex].name}`);
  1096. await new Promise(r => setTimeout(r, pauseDuration));
  1097. }
  1098. }
  1099.  
  1100. if (isMultiChannelScrapingActive) { // If loop completed naturally / Если цикл завершился естественным образом
  1101. updateStatusForConsole("Скрапинг ВСЕХ каналов завершен. / Scraping of ALL channels finished.");
  1102. alert("Скрапинг всех каналов завершен! / Scraping of all channels finished!");
  1103. }
  1104. isMultiChannelScrapingActive = false;
  1105. isScrapingSingle = false; // Ensure this is also false / Убедимся, что это также false
  1106. currentScrapingChannelInfo = null;
  1107. }
  1108.  
  1109. /**
  1110. * Menu command: Stop all ongoing scraping activities.
  1111. * Команда меню: Остановить все текущие процессы сбора данных.
  1112. */
  1113. function stopAllScrapingActivitiesMenu() {
  1114. consoleLog("Команда 'Stop All Scraping' вызвана. / 'Stop All Scraping' command called.", true);
  1115. isScrapingSingle = false;
  1116. isMultiChannelScrapingActive = false;
  1117. updateStatusForConsole('Скрапинг остановлен пользователем через меню. / Scraping stopped by user via menu.');
  1118. alert("Все процессы скрапинга остановлены. / All scraping processes have been stopped.");
  1119. }
  1120.  
  1121.  
  1122. // --- REGISTER MENU COMMANDS ---
  1123. // --- РЕГИСТРАЦИЯ КОМАНД МЕНЮ ---
  1124. if (typeof GM_registerMenuCommand === 'function') {
  1125. if (gmConfigInitialized) { // Check if GM_config was successfully initialized / Проверка, был ли GM_config успешно инициализирован
  1126. GM_registerMenuCommand("Scrape Current Channel / Собрать с текущего канала", startSingleChannelScrapeMenu, "C");
  1127. GM_registerMenuCommand("Scrape All Listed Channels / Собрать со всех каналов", startMultiChannelScrapeMenu, "A");
  1128. GM_registerMenuCommand("Stop All Scraping / Остановить всё", stopAllScrapingActivitiesMenu, "S");
  1129. GM_registerMenuCommand("Настройки скрипта... / Script Settings...", () => GM_config.open(), "O");
  1130. consoleLog("Команды меню Tampermonkey зарегистрированы (включая GM_config). / Tampermonkey menu commands registered (including GM_config).");
  1131. } else {
  1132. // GM_config not initialized, register only basic commands
  1133. // GM_config не инициализирован, регистрируем только основные команды
  1134. consoleLog("GM_config не был успешно инициализирован. Регистрируются только основные команды. / GM_config was not successfully initialized. Registering only basic commands.", true);
  1135. alert("Ошибка: GM_config не инициализирован. Настройки через GUI не будут доступны. Проверьте консоль.\n\nError: GM_config not initialized. Settings GUI will not be available. Check console.");
  1136. GM_registerMenuCommand("Scrape Current Channel / Собрать с текущего канала", startSingleChannelScrapeMenu, "C");
  1137. GM_registerMenuCommand("Scrape All Listed Channels / Собрать со всех каналов", startMultiChannelScrapeMenu, "A");
  1138. GM_registerMenuCommand("Stop All Scraping / Остановить всё", stopAllScrapingActivitiesMenu, "S");
  1139. consoleLog("Основные команды меню Tampermonkey зарегистрированы (без настроек GM_config). / Basic Tampermonkey menu commands registered (without GM_config settings).");
  1140. }
  1141. } else {
  1142. consoleLog("GM_registerMenuCommand не доступна. Управление через меню невозможно. / GM_registerMenuCommand is not available. Menu control is not possible.", true);
  1143. alert("Tampermonkey API GM_registerMenuCommand не доступно. Скрипт будет работать без UI команд.\n\nTampermonkey API GM_registerMenuCommand is not available. The script will run without UI commands.");
  1144. }
  1145.  
  1146. })(); // End of IIFE / Конец IIFE
  1147. console.log(`[Telegram Scraper v${GM_info.script.version}] Script IIFE execution completed.`);