From eab3fd08b8efe838b1f96c0517ab230a0de732ef Mon Sep 17 00:00:00 2001 From: olilovedani Date: Sun, 13 Apr 2025 15:01:09 +0000 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20youtube=5Fmass=5Funsubscriber.js?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- youtube_mass_unsubscriber.js | 442 +++++++++++++++++------------------ 1 file changed, 211 insertions(+), 231 deletions(-) diff --git a/youtube_mass_unsubscriber.js b/youtube_mass_unsubscriber.js index 62077bf..e5ef716 100644 --- a/youtube_mass_unsubscriber.js +++ b/youtube_mass_unsubscriber.js @@ -1,112 +1,110 @@ /** - * @file youtube_mass_unsubscriber.js - * @description Скрипт для браузерной консоли для массовой отписки от YouTube каналов. - * @version 4.0 (Использует SAPISIDHASH для аутентификации) + * @file youtube_unsubscriber.js + * @description Скрипт для автоматической отписки от YouTube каналов через консоль браузера. + * Использует предоставленный пользователем контекст сессии и генерирует + * заголовок Authorization: SAPISIDHASH для аутентификации запросов. + * + * @version 4.0 + * @date 2025-04-13 * * @warning ИСПОЛЬЗУЙТЕ НА СВОЙ СТРАХ И РИСК! - * Этот скрипт автоматизирует действия в вашем аккаунте YouTube. - * - Это может нарушать Условия использования YouTube. - * - Использование может привести к временным или постоянным ограничениям вашего аккаунта. - * - YouTube может изменить структуру сайта или API в любой момент, что сломает скрипт. - * - Разработчик не несет ответственности за любые последствия использования этого скрипта. - * - Рекомендуется использовать с большой задержкой между запросами. + * Массовые автоматические действия могут нарушать Условия Использования YouTube + * и привести к временным ограничениям аккаунта или необходимости проходить CAPTCHA. + * Скрипт может перестать работать после обновлений YouTube. + * Разработчик не несет ответственности за любые последствия использования скрипта. */ -// Обертка в асинхронную самовызывающуюся функцию (IIFE) для изоляции области видимости -(async function massUnsubscribeWithSapisidHash() { - console.log("%cЗапуск скрипта YouTube Mass Unsubscriber v4 (SAPISIDHASH)...", "color: blue; font-weight: bold;"); - alert("ВНИМАНИЕ:\nСкрипт для массовой отписки будет запущен.\nПожалуйста, НЕ закрывайте эту вкладку до завершения.\nИспользуйте на свой страх и риск."); +async function massUnsubscribeWithSapisidHash() { + console.log("Запуск скрипта v4 (SAPISIDHASH)..."); - // --- НАСТРОЙКИ СКРИПТА --- - - /** - * @const {number} DELAY_BETWEEN_REQUESTS_MS - * Задержка между отправкой запросов на отписку в миллисекундах. - * Увеличение этого значения снижает риск блокировки со стороны YouTube. - * Рекомендуется значение не менее 2000-3000 (2-3 секунды). - */ + // --- Конфигурация --- + // Задержка между отправкой запросов на отписку (в миллисекундах). + // Рекомендуется не ставить слишком низкое значение (>= 2000) во избежание блокировок. const DELAY_BETWEEN_REQUESTS_MS = 2500; - - /** - * @const {string} UNSUBSCRIBE_ENDPOINT - * Полный URL конечной точки API YouTube для отписки. - * Взят из анализа сетевых запросов при ручной отписке. - */ + // Полный URL эндпоинта YouTube API для отписки. const UNSUBSCRIBE_ENDPOINT = "https://www.youtube.com/youtubei/v1/subscription/unsubscribe"; + // -------------------- - // --------------------------- - - // --- ПРЕДОСТАВЛЕННЫЙ КОНТЕКСТ СЕССИИ --- - /** - * @const {object} providedContextData - * Объект контекста, который был предоставлен пользователем. - * @important ВНИМАНИЕ: Этот контекст специфичен для конкретной сессии и пользователя - * и, скорее всего, БУДЕТ НЕАКТУАЛЕН для других пользователей или - * даже для того же пользователя через некоторое время. - * Для универсального использования скрипта эту часть НУЖНО ЗАМЕНИТЬ - * на динамическое получение контекста со страницы (см. закомментированные - * альтернативы или предыдущие версии скриптов). - * Оставлено здесь согласно запросу пользователя. - */ - const providedContextData = { /* ... (Полный объект context, как в предыдущем ответе) ... */ - "client": { "hl": "en", "gl": "ES", /* ... остальные поля client ... */ "clientVersion": "2.20250410.10.00" /* ... */ }, - "user": { "lockedSafetyMode": false }, - "request": { "useSsl": true, "internalExperimentFlags": [], "consistencyTokenJars": [] }, - "clientScreenNonce": "a7tpQk6WH5CEA35M", // Может быть одноразовым! - "clickTracking": { "clickTrackingParams": "CPNEEPBbIhMIz5TkmJ7VjAMVyDsGAB1TlwDpMhFzdWJzLWNoYW5uZWwtbGlzdA==" }, - "adSignalsInfo": { /* ... */ } + // --- Предоставленный пользователем контекст --- + // ВАЖНО: Этот объект 'context' был предоставлен пользователем для конкретной сессии. + // Он может устареть. Если скрипт выдает ошибки аутентификации (кроме 401, связанных с SAPISIDHASH), + // возможно, потребуется обновить этот объект, скопировав актуальный 'context' + // из сетевого запроса на странице YouTube (например, при загрузке страницы подписок). + const providedContextData = { + "client": { + "hl": "en", "gl": "ES", "remoteHost": "90.162.8.71", "deviceMake": "Apple", "deviceModel": "", + "visitorData": "CgtzVGRScUVaRjR4USjdmO-_BjInCgJFUxIhEh0SGwsMDg8QERITFBUWFxgZGhscHR4fICEiIyQlJiBH", // Может устареть + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:138.0) Gecko/20100101 Firefox/138.0,gzip(gfe)", + "clientName": "WEB", "clientVersion": "2.20250410.10.00", // Может устареть + "osName": "Macintosh", "osVersion": "10.15", + "originalUrl": "https://www.youtube.com/feed/channels", "screenPixelDensity": 2, "platform": "DESKTOP", + "clientFormFactor": "UNKNOWN_FORM_FACTOR", + "configInfo": { /* ... (данные configInfo, могут устареть) ... */ + "appInstallData": "CN2Y778GELjkzhwQppqwBRCU_rAFEOGCgBMQt4bPHBC9tq4FEMSBzxwQ6-j-EhDz1s4cEPDizhwQiIewBRDevM4cEParsAUQ4M2xBRCJp84cEODg_xIQ0-GvBRCI468FEKn_zhwQy9GxBRCZmLEFEJ2msAUQ4eywBRCJ6K4FEIeszhwQro__EhCj784cELnZzhwQpv3OHBDM_c4cEMnmsAUQ3rjOHBDjg7giEIuCgBMQmvTOHBD8ss4cEImwzhwQ29rOHBC-irAFEN_czhwQntvOHBC9mbAFEJmNsQUQndCwBRDk5_8SEOLUrgUQ26-vBRD6hc8cELfq_hIQjcywBRCz6c4cEJ75zhwQ7N3OHBDt3s4cELvZzhwQ8OzOHBCRjP8SEMn3rwUQ-KuxBRCBzc4cEIeSzhwQzdGxBRCD7s4cENfBsQUQzN-uBRDh5c4cENe-zhwQuuHOHBDy_v8SEOXlzhwqMENBTVNIaFVkb0wyd0ROSGtCcFNDRXVmaTVndVA5QTd2LXdiNTdBUEozQVVkQnc9PQ%3D%3D", + "coldConfigData": "CN2Y778GEO-6rQUQvbauBRDi1K4FEL6KsAUQndCwBRDP0rAFEOP4sAUQpL6xBRDSv7EFENfBsQUQktSxBRCHks4cEJKxzhwQuLHOHBD8ss4cEN64zhwQ177OHBDz1s4cEN_czhwQkeDOHBC64c4cEMrizhwQ4eXOHBDl5c4cEKPmzhwQqujOHBCz6c4cEOHrzhwQr-zOHBDw7M4cEJPvzhwQo-_OHBDH784cEKr1zhwQnvnOHBDO-c4cEID8zhwQtvzOHBCm_c4cEMz9zhwQqf_OHBCmgM8cEMOBzxwQxIHPHBChgs8cEJeDzxwQnYPPHBD6hc8cELeGzxwQ0YbPHBCliM8cENKIzxwaMkFPakZveDFyREZiNTBWMndFR0ZRZGxPSHBLZzh2dHRKbjV6S1QzbXgxMWJUQl9OQ3VRIjJBT2pGb3gwc3RMZFV4MWpwQmItSW0xNTlycnRkeDVXWjhFNjlqZnNnYmFLY1k1QWFuZyqEAUNBTVNYUTBndU4yM0F0NFV6ZzJYSDZncXRRUzlGZjBEdXNlYkVLRVFRdDR1LVFPX0Jkb0I3UUtCQXVJQUZUV1pzYmNmaGFRRm1yc0dfMW00Z0FJRTVRU3Ryd2JqRWFnVjMxdUs0QWFvQzRoWTNkMEdCYkVvbkh0OTVKQUcyeWZhVmc9PQ%3D%3D", + "coldHashData": "CN2Y778GEhM3MjI4Mzg3MTk4NDUzNTg0NDAwGN2Y778GMjJBT2pGb3gxckRGYjUwVjJ3RUdGUWRsT0hwS2c4dnR0Sm41ektUM214MTFiVEJfTkN1UToyQU9qRm94MHN0TGRVeDFqcEJiLUltMTU5cnJ0ZHg1V1o4RTY5amZzZ2JhS2NZNUFhbmdChAFDQU1TWFEwZ3VOMjNBdDRVemcyWEg2Z3F0UVM5RmYwRHVzZWJFS0VRUXQ0dS1RT19CZG9CN1FLQkF1SUFGVFdac2JjZmhhUUZtcnNHXzFtNGdBSUU1UVN0cndiakVhZ1YzMXVLNEFhb0M0aFkzZDBHQmJFb25IdDk1SkFHMnlmYVZnPT0%3D", + "hotHashData": "CN2Y778GEhM4MzY3MjA3NjYxMzA1MzUyNzg1GN2Y778GKJTk_BIopdD9Eijamf4SKMjK_hIorsz-Eii36v4SKMCD_xIokYz_Eiiuj_8SKM7H_xIoiPz_EiiL_v8SKPL-_xIox4CAEyj9gIATKIuCgBMo4YKAEyiyg4ATKMGDgBMyMkFPakZveDFyREZiNTBWMndFR0ZRZGxPSHBLZzh2dHRKbjV6S1QzbXgxMWJUQl9OQ3VROjJBT2pGb3gwc3RMZFV4MWpwQmItSW0xNTlycnRkeDVXWjhFNjlqZnNnYmFLY1k1QWFuZ0I0Q0FNU0lnMEtvdGY2RmE3QkJ2azNraDd5Q2hVVzNjX0NETWFuN1F1RHh3N2RqUUtsd0FVPQ%3D%3D" + }, + "screenDensityFloat": 2, "userInterfaceTheme": "USER_INTERFACE_THEME_LIGHT", "timeZone": "Atlantic/Canary", "browserName": "Firefox", "browserVersion": "138.0", + "acceptHeader": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "deviceExperimentId": "ChxOelE1TWpnd056RXdOakl3TnpVek1qYzNNZz09EN2Y778GGN2Y778G", + "rolloutToken": "CKXstZGOyovPzAEQo5fI9e-GjAMYs9H865HTjAM%3D", "screenWidthPoints": 2407, "screenHeightPoints": 816, "utcOffsetMinutes": 60, + "mainAppWebInfo": {"graftUrl": "https://www.youtube.com/feed/channels", "pwaInstallabilityStatus": "PWA_INSTALLABILITY_STATUS_UNKNOWN", "webDisplayMode": "WEB_DISPLAY_MODE_BROWSER", "isWebNativeShareAvailable": false} + }, + "user": {"lockedSafetyMode": false}, + "request": {"useSsl": true, "internalExperimentFlags": [], "consistencyTokenJars": []}, + "clientScreenNonce": "a7tpQk6WH5CEA35M", // Может устареть + "clickTracking": {"clickTrackingParams": "CPNEEPBbIhMIz5TkmJ7VjAMVyDsGAB1TlwDpMhFzdWJzLWNoYW5uZWwtbGlzdA=="}, // Может устареть + "adSignalsInfo": {"params": [{ "key": "dt", "value": "1744555102337" }, { "key": "flash", "value": "0" }, { "key": "frm", "value": "0" }, { "key": "u_tz", "value": "60" }, { "key": "u_his", "value": "4" }, { "key": "u_h", "value": "1440" }, { "key": "u_w", "value": "2560" }, { "key": "u_ah", "value": "1330" }, { "key": "u_aw", "value": "2560" }, { "key": "u_cd", "value": "30" }, { "key": "bc", "value": "31" }, { "key": "bih", "value": "816" }, { "key": "biw", "value": "2407" }, { "key": "brdim", "value": "6,27,6,27,2560,25,2407,1305,2407,816" }, { "key": "vis", "value": "1" }, { "key": "wgl", "value": "true" }, { "key": "ca_type", "value": "image" }] + } }; - // --------------------------------------- + // ------------------------------------------------------------- - - // --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ --- + // --- Вспомогательные функции --- /** - * Читает значение указанной куки из `document.cookie`. + * Извлекает значение куки по её имени. * @param {string} name - Имя искомой куки. - * @returns {string | null} Значение куки или null, если куки не найдена. + * @returns {string|null} Значение куки или null, если куки не найдена. */ function getCookie(name) { - const value = `; ${document.cookie}`; // Добавляем ';' в начало для упрощения поиска - const parts = value.split(`; ${name}=`); // Разделяем по "; имя=" + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); if (parts.length === 2) { - // Если нашли, берем вторую часть, и отсекаем все после следующей ';' + // Извлекаем значение и убираем возможную ';' в конце return parts.pop().split(';').shift(); } - // console.debug(`Куки "${name}" не найдена.`); // Отладочное сообщение - return null; // Куки не найдена + return null; } /** - * Асинхронно вычисляет SHA-1 хеш для переданной строки. - * Использует встроенный браузерный API `SubtleCrypto`. + * Асинхронно вычисляет SHA-1 хеш строки с использованием SubtleCrypto API. * @param {string} str - Строка для хеширования. - * @returns {Promise} Промис, который разрешается в строку с HEX-представлением SHA-1 хеша, или null в случае ошибки. + * @returns {Promise} Промис, разрешающийся строкой с HEX-представлением хеша, или null в случае ошибки. */ async function sha1(str) { try { - // Кодируем строку в ArrayBuffer (массив байт) + // Преобразуем строку в ArrayBuffer const buffer = new TextEncoder().encode(str); - // Вычисляем хеш с помощью SubtleCrypto API + // Вычисляем хеш const hashBuffer = await window.crypto.subtle.digest('SHA-1', buffer); - // Преобразуем ArrayBuffer с хешем в массив байт + // Преобразуем ArrayBuffer в массив байт const hashArray = Array.from(new Uint8Array(hashBuffer)); - // Преобразуем каждый байт в его HEX-представление (2 символа, с дополнением нуля) и соединяем в строку + // Преобразуем каждый байт в HEX-строку (с дополнением нулями) и соединяем const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); return hashHex; } catch (error) { - console.error("Ошибка при вычислении SHA-1 хеша:", error); - return null; // Возвращаем null при ошибке + console.error("Ошибка при вычислении SHA-1:", error); + return null; } } /** - * Асинхронно генерирует заголовок авторизации `Authorization: SAPISIDHASH ...`. - * Требует наличия куки `SAPISID` в браузере. - * @returns {Promise} Промис, который разрешается в строку заголовка или null, если генерация не удалась. + * Асинхронно генерирует заголовок Authorization: SAPISIDHASH ... + * Требует наличия куки 'SAPISID' в браузере. + * @returns {Promise} Промис, разрешающийся строкой заголовка, или null в случае ошибки. */ async function generateSapisidHashHeader() { - // Получаем текущее время в СЕКУНДАХ с начала эпохи Unix + // Получаем текущее время в секундах (Unix Timestamp) const timestamp = Math.floor(Date.now() / 1000); // Получаем значение куки SAPISID const sapisid = getCookie('SAPISID'); @@ -115,300 +113,277 @@ // Проверяем, найдена ли куки SAPISID if (!sapisid) { - console.error("КРИТИЧЕСКАЯ ОШИБКА: Куки 'SAPISID' не найдена!"); - console.error("Это необходимо для аутентификации запросов."); - console.error("Возможные причины: вы не авторизованы в YouTube, куки заблокированы расширением или настройками браузера."); - alert("Критическая ошибка: Куки SAPISID не найдена.\nСкрипт не может продолжить работу.\nУбедитесь, что вы авторизованы в YouTube."); - return null; // Не можем сгенерировать заголовок + console.error("Критическая ошибка: Не удалось найти куки 'SAPISID'. Убедитесь, что вы авторизованы и куки не заблокированы."); + // alert можно вызывать из основной логики, чтобы не спамить + return null; // Возвращаем null, сигнализируя об ошибке } - // Формируем строку для хеширования: timestamp + пробел + sapisid + пробел + origin + // Формируем строку для хеширования по стандартному формату YouTube const dataToHash = `${timestamp} ${sapisid} ${origin}`; - // Вычисляем SHA-1 хеш этой строки + // Вычисляем SHA-1 хеш const hash = await sha1(dataToHash); - // Проверяем, удалось ли вычислить хеш + // Проверяем, успешно ли вычислен хеш if (!hash) { - console.error("Критическая ошибка: Не удалось вычислить SHA-1 хеш для заголовка авторизации."); - return null; // Не можем сгенерировать заголовок + console.error("Ошибка: Не удалось вычислить SHA-1 хеш для заголовка авторизации."); + return null; } // Формируем финальную строку заголовка const authHeader = `SAPISIDHASH ${timestamp}_${hash}`; - // console.debug("Сгенерирован заголовок Authorization:", authHeader); // Полезно для отладки + // console.log("Сгенерирован заголовок:", authHeader); // Раскомментировать для отладки return authHeader; } /** - * Получает API ключ YouTube (INNERTUBE_API_KEY) из глобальных переменных страницы. - * Пробует найти его в `ytcfg.data_` или `yt.config_`. - * @returns {string | null} API ключ или null, если не найден. + * Получает API-ключ YouTube (INNERTUBE_API_KEY) из глобальных объектов страницы. + * @returns {string|null} API-ключ или null, если не найден. */ function getApiKey() { try { + // Пробуем найти ключ в стандартных местах (могут меняться со временем) const ytcfg = window.ytcfg; - // Проверяем стандартное расположение if (ytcfg && ytcfg.data_ && ytcfg.data_.INNERTUBE_API_KEY) { - // console.debug("API ключ найден в ytcfg.data_"); - return ytcfg.data_.INNERTUBE_API_KEY; + console.debug("API ключ найден в ytcfg.data_"); + return ytcfg.data_.INNERTUBE_API_KEY; } - // Проверяем альтернативное расположение (на случай изменений) if (window.yt && window.yt.config_ && window.yt.config_.INNERTUBE_API_KEY) { - // console.debug("API ключ найден в yt.config_"); + console.debug("API ключ найден в yt.config_"); return window.yt.config_.INNERTUBE_API_KEY; } - // Если нигде не найден, выбрасываем ошибку - throw new Error("Не удалось найти INNERTUBE_API_KEY ни в одном из известных мест."); + // Если не нашли, выбрасываем ошибку + throw new Error("API ключ (INNERTUBE_API_KEY) не найден ни в ytcfg, ни в yt.config_"); } catch (e) { console.error("Ошибка при получении API ключа:", e); - alert("Ошибка: Не удалось получить API ключ YouTube со страницы.\nСкрипт не может продолжить."); return null; } } /** * Извлекает список ID каналов со страницы управления подписками. - * Пытается найти данные в объекте `window.ytInitialData`, пробуя несколько известных путей. - * Если не находит в `ytInitialData`, пробует (менее надежно) извлечь из DOM. - * @returns {string[]} Массив уникальных ID каналов (формата UC...). Пустой массив, если ничего не найдено. + * Пытается найти данные в объекте window.ytInitialData (предпочтительно) + * или сканирует DOM в качестве запасного варианта. + * @returns {string[]} Массив уникальных ID каналов (формата UC...). */ function getChannelIdsFromPageData() { - console.log("Ищем ID каналов на странице..."); + console.log("Извлечение ID каналов..."); try { - // Проверяем наличие основного объекта данных YouTube + // Проверяем наличие основного объекта данных страницы if (!window.ytInitialData) { - throw new Error("Объект window.ytInitialData не найден. Вы точно на странице управления подписками?"); + throw new Error("Объект window.ytInitialData не найден. Убедитесь, что скрипт запущен на странице управления подписками YouTube."); } let channels = []; // Массив для хранения найденных ID - // Пути в объекте ytInitialData, где обычно находятся списки каналов (могут меняться!) + // Возможные пути к списку каналов в структуре ytInitialData (могут меняться!) + // Проверяем как для вида "Сетка", так и для вида "Список" const pathsToTry = [ - 'contents.twoColumnBrowseResultsRenderer.tabs[1].tabRenderer.content.sectionListRenderer.contents[0].gridRenderer.items', // Вид "Сетка" - 'contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].itemSectionRenderer.contents[0].shelfRenderer.content.expandedShelfContentsRenderer.items', // Вид "Список" (старый?) - 'contents.twoColumnBrowseResultsRenderer.tabs[1].tabRenderer.content.sectionListRenderer.contents[0].itemSectionRenderer.contents' // Вид "Список" (новый?) - // Добавляйте сюда другие пути, если структура изменится + 'contents.twoColumnBrowseResultsRenderer.tabs[1].tabRenderer.content.sectionListRenderer.contents[0].gridRenderer.items', // Grid view + 'contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].itemSectionRenderer.contents[0].shelfRenderer.content.expandedShelfContentsRenderer.items', // List view (old structure?) + 'contents.twoColumnBrowseResultsRenderer.tabs[1].tabRenderer.content.sectionListRenderer.contents[0].itemSectionRenderer.contents' // List view (alternative structure?) ]; let items = null; // Переменная для хранения найденного массива элементов каналов - - // Пробуем найти элементы по каждому известному пути + // Пробуем найти данные по каждому известному пути for (const path of pathsToTry) { try { - // Безопасная навигация по вложенным свойствам объекта + // Безопасно проходим по вложенным свойствам объекта let current = window.ytInitialData; path.split('.').forEach(part => { - // Обработка индексов массива вида 'prop[0]' - const arrayIndex = part.match(/\[(\d+)\]$/); + const arrayIndex = part.match(/\[(\d+)\]$/); // Проверка на индекс массива [n] if (arrayIndex) { const key = part.substring(0, arrayIndex.index); const index = parseInt(arrayIndex[1], 10); current = current?.[key]?.[index]; // Безопасный доступ к элементу массива } else { - current = current?.[part]; // Безопасный доступ к свойству объекта + current = current?.[part]; // Безопасный доступ к свойству } - // Если на каком-то этапе путь оборвался (свойство не найдено), current станет undefined - if (current === undefined) throw new Error(`Свойство ${part} не найдено по пути ${path}`); }); - - // Если мы дошли до конца пути и получили массив элементов, сохраняем его + // Если нашли непустой массив, сохраняем его и выходим из цикла if (Array.isArray(current) && current.length > 0) { - console.log(`Найдены элементы каналов по пути в ytInitialData: ${path}`); + console.debug(`Найдены элементы каналов по пути: ${path}`); items = current; - break; // Прекращаем поиск, так как нашли + break; } - } catch (e) { - // console.debug(`Путь ${path} не найден или не содержит элементов:`, e.message); - /* Игнорируем ошибку для данного пути и пробуем следующий */ - } + } catch (e) { /* Игнорируем ошибку для данного пути, пробуем следующий */ } } - // Если не нашли каналы в ytInitialData, пробуем найти через DOM (менее надежно) + // Если не нашли каналы в ytInitialData, пробуем запасной метод - сканирование DOM if (!items || items.length === 0) { - console.warn("Не удалось найти данные о каналах в window.ytInitialData. Попытка найти в DOM (менее надежно)..."); - const renderers = document.querySelectorAll('ytd-channel-renderer'); // Находим все блоки каналов + console.warn("Не удалось найти каналы в ytInitialData. Попытка сканирования DOM (менее надежно)..."); + const renderers = document.querySelectorAll('ytd-channel-renderer'); // Ищем все элементы каналов if (renderers.length > 0) { - console.log(`Найдено ${renderers.length} элементов ytd-channel-renderer в DOM.`); renderers.forEach(renderer => { - // Ищем ссылку на канал внутри блока + // Ищем ссылку на канал внутри элемента const linkElement = renderer.querySelector('a#main-link'); const href = linkElement?.getAttribute('href'); - // Извлекаем ID, если ссылка содержит '/channel/' + // Извлекаем ID, если ссылка имеет формат /channel/UC... if (href && href.includes('/channel/')) { - const channelId = href.split('/channel/')[1].split('/')[0]; // Отсекаем возможные '/videos' и т.д. - if (channelId && channelId.startsWith('UC') && !channels.includes(channelId)) { + const channelId = href.split('/channel/')[1]; + if (channelId && !channels.includes(channelId)) { // Добавляем только уникальные ID channels.push(channelId); } } - // Можно добавить обработку ссылок вида /@handle, если это необходимо, - // но ID из них извлечь сложнее без дополнительных данных. }); if (channels.length > 0) { - console.log(`Извлечено ${channels.length} ID каналов из DOM.`); - // Возвращаем только уникальные ID + console.log(`Извлечено ${channels.length} ID из DOM.`); + // Возвращаем уникальные ID, найденные в DOM return channels.filter((id, index, self) => self.indexOf(id) === index); } } - // Если ничего не нашли ни там, ни там - console.error("Не найдено ID каналов ни в ytInitialData, ни в DOM. Структура страницы могла измениться."); - alert("Ошибка: Не удалось найти каналы на странице.\nВозможно, структура сайта YouTube изменилась или вы не на той странице."); + // Если не нашли ни в данных, ни в DOM + console.error("Не удалось найти ID каналов на странице."); return []; // Возвращаем пустой массив } - // Извлекаем ID каналов из найденных элементов ytInitialData + // Если нашли каналы в ytInitialData, извлекаем ID из них items.forEach(item => { - // ID может быть в разных местах в зависимости от типа рендерера (grid, list, shelf) + // ID может находиться в разных местах в зависимости от типа рендерера (grid/list) const channelId = item?.gridChannelRenderer?.channelId || item?.channelRenderer?.channelId || item?.channelRenderer?.navigationEndpoint?.browseEndpoint?.browseId || item?.gridChannelRenderer?.navigationEndpoint?.browseEndpoint?.browseId; - if (channelId && channelId.startsWith('UC')) { // Добавляем только валидные ID + if (channelId) { channels.push(channelId); } }); - // Убираем дубликаты, если они вдруг появились - const uniqueChannels = channels.filter((id, index, self) => self.indexOf(id) === index); - console.log(`Найдено ${uniqueChannels.length} уникальных ID каналов.`); - return uniqueChannels; + console.log(`Найдено ${channels.length} ID каналов в ytInitialData.`); + // Возвращаем только уникальные ID + return channels.filter((id, index, self) => self.indexOf(id) === index); } catch (e) { console.error("Критическая ошибка при получении ID каналов:", e); - alert("Критическая ошибка при поиске каналов. См. консоль для деталей."); - return []; // Возвращаем пустой массив в случае критической ошибки + return []; // Возвращаем пустой массив в случае любой ошибки } } /** - * Асинхронно отправляет POST-запрос на отписку для одного канала. - * Генерирует заголовок авторизации перед отправкой. + * Асинхронно отправляет POST-запрос на отписку от указанного канала. + * Включает генерацию и добавление заголовка Authorization: SAPISIDHASH. * @param {string} channelId - ID канала для отписки. - * @param {string} apiKey - Действующий API ключ YouTube (INNERTUBE_API_KEY). - * @param {object} context - Объект контекста сессии (INNERTUBE_CONTEXT). - * @returns {Promise} Промис, который разрешается в `true` при успешной отправке запроса (статус 2xx и нет ошибок в JSON), иначе `false`. + * @param {string} apiKey - Актуальный INNERTUBE_API_KEY. + * @param {object} context - Объект контекста запроса (предоставленный пользователем). + * @returns {Promise} Промис, разрешающийся true в случае успеха запроса, false в случае ошибки. */ async function sendUnsubscribeRequest(channelId, apiKey, context) { - // Формируем полный URL запроса с API ключом + // Формируем полный URL запроса с API-ключом const apiUrl = `${UNSUBSCRIBE_ENDPOINT}?key=${apiKey}`; - // Тело запроса: ID канала и контекст сессии + // Тело запроса: ID канала и контекст const payload = { channelIds: [channelId], - context: context // Используем переданный (в данном случае, жестко заданный) контекст + context: context }; - // --- Генерируем заголовок авторизации ПЕРЕД каждым запросом --- + // Генерируем заголовок авторизации ПЕРЕД отправкой запроса const authorizationHeader = await generateSapisidHashHeader(); - // Если заголовок сгенерировать не удалось (например, нет куки SAPISID), прекращаем попытку + // Если заголовок сгенерировать не удалось (например, нет куки SAPISID), прерываем операцию if (!authorizationHeader) { - console.error(`[${channelId}] Не удалось сгенерировать Authorization header. Запрос не будет отправлен.`); - return false; // Возвращаем неудачу + console.error(`Не удалось сгенерировать заголовок Authorization для канала ${channelId}. Запрос не будет отправлен.`); + return false; // Сигнализируем об ошибке } - // --------------------------------------------------------------- - console.log(`[${channelId}] Попытка отписки... Отправка запроса на ${apiUrl}`); + console.log(`Отправка запроса на отписку от ${channelId} с Authorization header...`); try { - // Отправляем асинхронный POST-запрос с помощью fetch API + // Отправляем POST-запрос с помощью fetch API const response = await fetch(apiUrl, { method: 'POST', headers: { - // Стандартные заголовки для JSON POST запросов - 'Content-Type': 'application/json', - // Заголовок, указывающий источник запроса (часто требуется API YouTube) - 'X-Origin': window.location.origin, - // ---- САМОЕ ВАЖНОЕ: Заголовок Авторизации ---- - 'Authorization': authorizationHeader - // --------------------------------------------- + 'Content-Type': 'application/json', // Указываем тип тела запроса + 'X-Origin': window.location.origin, // Указываем источник (часто требуется API YouTube) + 'Authorization': authorizationHeader // Добавляем сгенерированный заголовок авторизации }, - body: JSON.stringify(payload), // Тело запроса в формате JSON + body: JSON.stringify(payload), // Преобразуем тело запроса в JSON-строку credentials: 'include' // Явно указываем браузеру включать куки в запрос }); - // --- Анализируем ответ сервера --- - // Проверяем статус HTTP ответа. Коды 2xx считаются успешными. + // --- Анализ ответа --- + // Проверяем HTTP-статус ответа. !response.ok означает статус не в диапазоне 200-299. if (!response.ok) { - // Если статус не 2xx (например, 401 Unauthorized, 400 Bad Request, 500 Server Error) let errorDetails = `Статус: ${response.status} ${response.statusText}`; try { - // Пытаемся прочитать тело ответа как JSON, там может быть больше деталей об ошибке + // Пытаемся прочитать тело ответа как JSON, если сервер вернул детали ошибки const errorJson = await response.json(); - console.error(`[${channelId}] Ошибка HTTP ${response.status} при отписке. Ответ сервера:`, errorJson); + console.error(`HTTP Ошибка отписки от ${channelId}.`, errorJson); errorDetails = JSON.stringify(errorJson); } catch(e) { // Если тело ответа не JSON или пустое - console.error(`[${channelId}] Ошибка HTTP ${response.status} ${response.statusText}. Тело ответа не JSON.`); + console.error(`HTTP Ошибка отписки от ${channelId}: ${response.status} ${response.statusText}. Не удалось прочитать тело ответа.`); } - // Возвращаем неудачу + // Возвращаем false, т.к. запрос не был успешным return false; } - // Если статус ответа OK (2xx), читаем тело ответа как JSON + // Если HTTP статус OK (2xx), читаем тело ответа как JSON const responseData = await response.json(); - // Иногда YouTube возвращает статус 200 OK, но сообщает об ошибке внутри JSON ответа + // Дополнительная проверка: иногда API возвращает статус 200 OK, но содержит ошибки в теле ответа if (responseData.responseContext && responseData.errors) { - console.error(`[${channelId}] Ошибка API (внутри JSON ответа) при отписке:`, responseData.errors); - return false; // Считаем это неудачей + console.error(`Ошибка API (в JSON ответе) при отписке от ${channelId}:`, responseData.errors); + return false; // Считаем это ошибкой } - // Если дошли сюда, статус OK и в JSON нет явных ошибок - console.log(`%c[${channelId}] Запрос на отписку успешно отправлен (HTTP ${response.status}).`, "color: green;"); - // console.debug(`[${channelId}] Ответ сервера:`, responseData); // Для детальной отладки - return true; // Успех! + // Если все проверки пройдены, считаем запрос успешным + console.log(`Запрос на отписку от ${channelId} успешно обработан сервером.`); + return true; } catch (error) { - // Ловим ошибки сети (нет соединения), CORS, проблемы с DNS и т.д. - console.error(`[${channelId}] КРИТИЧЕСКАЯ ОШИБКА СЕТИ/FETCH при отписке:`, error); - return false; // Неудача + // Ловим ошибки сети, CORS, проблемы с соединением и т.д. + console.error(`Критическая ошибка сети/fetch при отписке от ${channelId}:`, error); + return false; // Возвращаем false при сетевых ошибках } } - // --- ОСНОВНОЙ ПРОЦЕСС ВЫПОЛНЕНИЯ --- + // --- Основной процесс выполнения скрипта --- - // 1. Получаем API ключ со страницы + // 1. Получаем необходимые данные: API ключ и проверяем контекст const apiKey = getApiKey(); - // Получаем контекст (в данной версии - из жестко заданной переменной) - const context = providedContextData; + const context = providedContextData; // Используем предоставленный контекст - // Проверяем, получены ли ключ и контекст - if (!apiKey || !context || !context.client) { - console.error("Критическая ошибка: Не удалось получить API ключ или контекст невалиден. Остановка скрипта."); - // Дополнительные сообщения об ошибках выводятся внутри getApiKey() - return; // Прекращаем выполнение + // Проверяем, что удалось получить ключ и контекст валиден + if (!apiKey) { + console.error("Не удалось получить API ключ. Выполнение скрипта прервано."); + alert("Ошибка: Не удалось получить API ключ. Проверьте консоль."); + return; // Прерываем выполнение } - console.log("API ключ получен. Используется предоставленный контекст и генерация SAPISIDHASH."); + if (!context || !context.client) { // Базовая проверка валидности контекста + console.error("Предоставленный объект контекста невалиден или пуст. Выполнение скрипта прервано."); + alert("Ошибка: Предоставленный контекст невалиден. Проверьте код скрипта."); + return; // Прерываем выполнение + } + console.log("API ключ получен. Используется предоставленный контекст и генерация SAPISIDHASH."); - // 2. Получаем список ID каналов с текущей страницы + // 2. Получаем список ID каналов для отписки с текущей страницы const channelIds = getChannelIdsFromPageData(); // Проверяем, найдены ли каналы if (!channelIds || channelIds.length === 0) { console.log("На текущей странице не найдено каналов для отписки."); - // Дополнительные сообщения выводятся внутри getChannelIdsFromPageData() - return; // Прекращаем выполнение + alert("На этой странице не найдено каналов для отписки. Убедитесь, что вы на странице управления подписками."); + return; // Прерываем выполнение, если каналов нет } - console.log(`Найдено ${channelIds.length} каналов для обработки. Начинаем процесс отписки...`); - alert(`Найдено ${channelIds.length} каналов.\nНачинаем отписку с задержкой ${DELAY_BETWEEN_REQUESTS_MS / 1000} сек между запросами.\nНе закрывайте вкладку!`); + // 3. Запускаем цикл отписки по найденным каналам + console.log(`Начинаем отписку от ${channelIds.length} каналов с задержкой ${DELAY_BETWEEN_REQUESTS_MS / 1000} сек.`); + alert(`Сейчас начнется отписка от ${channelIds.length} каналов, найденных на этой странице.\n\nВАЖНО: Не закрывайте эту вкладку до завершения работы скрипта!\nПрогресс будет отображаться в консоли (F12).`); - // 3. Инициализируем счетчики для итогового отчета - let successCount = 0; - let failCount = 0; - let authHeaderFailedGlobally = false; // Флаг, что генерация заголовка не удалась (чтобы остановить цикл) + let successCount = 0; // Счетчик успешных отписок + let failCount = 0; // Счетчик неудачных отписок/пропусков + let authHeaderFailed = false; // Флаг для отслеживания ошибки генерации заголовка авторизации - // 4. Запускаем цикл отписки по всем найденным каналам + // Проходим по каждому ID канала в массиве for (let i = 0; i < channelIds.length; i++) { - // Если на предыдущей итерации произошла ошибка генерации заголовка, прерываем цикл - if (authHeaderFailedGlobally) { - console.warn("Работа скрипта прервана из-за ошибки генерации заголовка авторизации."); - failCount = channelIds.length - i; // Считаем все оставшиеся каналы неудачными + // Если на предыдущем шаге возникла ошибка с генерацией заголовка, прекращаем цикл + if (authHeaderFailed) { + console.warn("Пропускаем оставшиеся каналы из-за предыдущей ошибки генерации заголовка авторизации."); + failCount += (channelIds.length - i); // Считаем все оставшиеся как неудачные break; // Выходим из цикла } - const channelId = channelIds[i]; - console.log(`--- [${i + 1}/${channelIds.length}] Обработка канала: ${channelId} ---`); + const channelId = channelIds[i]; // Получаем ID текущего канала + console.log(`--- Обработка канала ${i + 1} из ${channelIds.length} (ID: ${channelId}) ---`); - // Вызываем асинхронную функцию отписки для текущего канала - // Она сама внутри генерирует заголовок авторизации + // Вызываем асинхронную функцию отписки и ждем её результата const success = await sendUnsubscribeRequest(channelId, apiKey, context); // Обновляем счетчики в зависимости от результата @@ -416,30 +391,35 @@ successCount++; } else { failCount++; - // Дополнительная проверка: если ошибка произошла И куки SAPISID пропала, - // устанавливаем флаг для остановки цикла на следующей итерации. + // Дополнительная проверка: если отписка не удалась, проверяем, не связана ли ошибка + // с отсутствием куки SAPISID (например, если пользователь вышел из аккаунта в другой вкладке). + // Это простая эвристика, ошибка может быть и другой. if (!getCookie('SAPISID')) { - console.error("ОШИБКА: Куки SAPISID больше не доступна! Возможно, сессия истекла или куки удалены."); - authHeaderFailedGlobally = true; // Устанавливаем флаг для остановки - alert("Критическая ошибка: Куки SAPISID не найдена.\nСкрипт будет остановлен.\nВозможно, вам нужно перезайти в аккаунт YouTube."); - // Не выходим из цикла немедленно, даем завершить текущую итерацию (failCount уже увеличен) + console.error("Критическая ошибка: Куки 'SAPISID' больше не доступна. Возможно, сессия истекла или вы вышли из аккаунта. Прекращаем работу скрипта."); + authHeaderFailed = true; // Устанавливаем флаг, чтобы остановить цикл на следующей итерации + alert("Ошибка: Потеряны куки аутентификации (SAPISID). Скрипт остановлен. Пожалуйста, обновите страницу и попробуйте снова, убедившись, что вы авторизованы."); + // Немедленный выход из цикла не делаем, даем завершиться текущей итерации логгирования } } - // 5. Делаем паузу перед обработкой следующего канала (если это не последний канал и не было фатальной ошибки) - if (!authHeaderFailedGlobally && i < channelIds.length - 1) { + // Делаем паузу перед обработкой следующего канала, + // только если не было фатальной ошибки и это не последний канал в списке. + if (!authHeaderFailed && i < channelIds.length - 1) { console.log(`Пауза ${DELAY_BETWEEN_REQUESTS_MS / 1000} сек...`); - // Ожидаем указанное время + // Ожидаем указанное количество миллисекунд await new Promise(resolve => setTimeout(resolve, DELAY_BETWEEN_REQUESTS_MS)); } - } // Конец цикла for + } - // 6. Выводим итоговый отчет - console.log("--- ЗАВЕРШЕНИЕ РАБОТЫ СКРИПТА ---"); - console.log(`ИТОГО:`); - console.log(` Успешно отправлено запросов: ${successCount}`); - console.log(` Неудачных запросов / Пропущено: ${failCount}`); - console.log("------------------------------------"); - alert(`Работа скрипта завершена!\n\nУспешно: ${successCount}\nНеудачно/Пропущено: ${failCount}\n\nОбновите страницу (F5), чтобы увидеть изменения.`); + // 4. Завершение работы скрипта и вывод статистики + console.log("--- Завершение работы скрипта ---"); + console.log(`Успешно отправлено запросов: ${successCount}`); + console.log(`Неудачных запросов / пропущено: ${failCount}`); + // Выводим финальное сообщение пользователю + if (!authHeaderFailed) { // Не показываем финальный alert, если была ошибка авторизации (уже показали другой) + alert(`Работа скрипта завершена!\n\nУспешно отписано (запросов отправлено): ${successCount}\nНеудачно / пропущено: ${failCount}\n\nПожалуйста, обновите страницу (F5), чтобы увидеть изменения.`); + } +} -})(); // Конец самовызывающейся функции \ No newline at end of file +// Запускаем основную асинхронную функцию +massUnsubscribeWithSapisidHash(); \ No newline at end of file