Обновить youtube_mass_unsubscriber.js
This commit is contained in:
parent
2bd97e9c55
commit
eab3fd08b8
@ -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<string | null>} Промис, который разрешается в строку с HEX-представлением SHA-1 хеша, или null в случае ошибки.
|
||||
* @returns {Promise<string|null>} Промис, разрешающийся строкой с 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<string | null>} Промис, который разрешается в строку заголовка или null, если генерация не удалась.
|
||||
* Асинхронно генерирует заголовок Authorization: SAPISIDHASH ...
|
||||
* Требует наличия куки 'SAPISID' в браузере.
|
||||
* @returns {Promise<string|null>} Промис, разрешающийся строкой заголовка, или 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<boolean>} Промис, который разрешается в `true` при успешной отправке запроса (статус 2xx и нет ошибок в JSON), иначе `false`.
|
||||
* @param {string} apiKey - Актуальный INNERTUBE_API_KEY.
|
||||
* @param {object} context - Объект контекста запроса (предоставленный пользователем).
|
||||
* @returns {Promise<boolean>} Промис, разрешающийся 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), чтобы увидеть изменения.`);
|
||||
}
|
||||
}
|
||||
|
||||
})(); // Конец самовызывающейся функции
|
||||
// Запускаем основную асинхронную функцию
|
||||
massUnsubscribeWithSapisidHash();
|
Loading…
Reference in New Issue
Block a user