youtube_mass_unsubscriber/youtube_mass_unsubscriber.js

425 lines
32 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @file youtube_unsubscriber.js
* @description Скрипт для автоматической отписки от YouTube каналов через консоль браузера.
* Использует предоставленный пользователем контекст сессии и генерирует
* заголовок Authorization: SAPISIDHASH для аутентификации запросов.
*
* @version 4.0
* @date 2025-04-13
*
* @warning ИСПОЛЬЗУЙТЕ НА СВОЙ СТРАХ И РИСК!
* Массовые автоматические действия могут нарушать Условия Использования YouTube
* и привести к временным ограничениям аккаунта или необходимости проходить CAPTCHA.
* Скрипт может перестать работать после обновлений YouTube.
* Разработчик не несет ответственности за любые последствия использования скрипта.
*/
async function massUnsubscribeWithSapisidHash() {
console.log("Запуск скрипта v4 (SAPISIDHASH)...");
// --- Конфигурация ---
// Задержка между отправкой запросов на отписку (в миллисекундах).
// Рекомендуется не ставить слишком низкое значение (>= 2000) во избежание блокировок.
const DELAY_BETWEEN_REQUESTS_MS = 2500;
// Полный URL эндпоинта YouTube API для отписки.
const UNSUBSCRIBE_ENDPOINT = "https://www.youtube.com/youtubei/v1/subscription/unsubscribe";
// --------------------
// --- Предоставленный пользователем контекст ---
// ВАЖНО: Этот объект '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" }]
}
};
// -------------------------------------------------------------
// --- Вспомогательные функции ---
/**
* Извлекает значение куки по её имени.
* @param {string} name - Имя искомой куки.
* @returns {string|null} Значение куки или null, если куки не найдена.
*/
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) {
// Извлекаем значение и убираем возможную ';' в конце
return parts.pop().split(';').shift();
}
return null;
}
/**
* Асинхронно вычисляет SHA-1 хеш строки с использованием SubtleCrypto API.
* @param {string} str - Строка для хеширования.
* @returns {Promise<string|null>} Промис, разрешающийся строкой с HEX-представлением хеша, или null в случае ошибки.
*/
async function sha1(str) {
try {
// Преобразуем строку в ArrayBuffer
const buffer = new TextEncoder().encode(str);
// Вычисляем хеш
const hashBuffer = await window.crypto.subtle.digest('SHA-1', buffer);
// Преобразуем ArrayBuffer в массив байт
const hashArray = Array.from(new Uint8Array(hashBuffer));
// Преобразуем каждый байт в HEX-строку (с дополнением нулями) и соединяем
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
return hashHex;
} catch (error) {
console.error("Ошибка при вычислении SHA-1:", error);
return null;
}
}
/**
* Асинхронно генерирует заголовок Authorization: SAPISIDHASH ...
* Требует наличия куки 'SAPISID' в браузере.
* @returns {Promise<string|null>} Промис, разрешающийся строкой заголовка, или null в случае ошибки.
*/
async function generateSapisidHashHeader() {
// Получаем текущее время в секундах (Unix Timestamp)
const timestamp = Math.floor(Date.now() / 1000);
// Получаем значение куки SAPISID
const sapisid = getCookie('SAPISID');
// Получаем origin текущей страницы (например, "www.youtube.com")
const origin = window.location.origin;
// Проверяем, найдена ли куки SAPISID
if (!sapisid) {
console.error("Критическая ошибка: Не удалось найти куки 'SAPISID'. Убедитесь, что вы авторизованы и куки не заблокированы.");
// alert можно вызывать из основной логики, чтобы не спамить
return null; // Возвращаем null, сигнализируя об ошибке
}
// Формируем строку для хеширования по стандартному формату YouTube
const dataToHash = `${timestamp} ${sapisid} ${origin}`;
// Вычисляем SHA-1 хеш
const hash = await sha1(dataToHash);
// Проверяем, успешно ли вычислен хеш
if (!hash) {
console.error("Ошибка: Не удалось вычислить SHA-1 хеш для заголовка авторизации.");
return null;
}
// Формируем финальную строку заголовка
const authHeader = `SAPISIDHASH ${timestamp}_${hash}`;
// console.log("Сгенерирован заголовок:", authHeader); // Раскомментировать для отладки
return authHeader;
}
/**
* Получает 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;
}
if (window.yt && window.yt.config_ && window.yt.config_.INNERTUBE_API_KEY) {
console.debug("API ключ найден в yt.config_");
return window.yt.config_.INNERTUBE_API_KEY;
}
// Если не нашли, выбрасываем ошибку
throw new Error("API ключ (INNERTUBE_API_KEY) не найден ни в ytcfg, ни в yt.config_");
} catch (e) {
console.error("Ошибка при получении API ключа:", e);
return null;
}
}
/**
* Извлекает список ID каналов со страницы управления подписками.
* Пытается найти данные в объекте window.ytInitialData (предпочтительно)
* или сканирует DOM в качестве запасного варианта.
* @returns {string[]} Массив уникальных ID каналов (формата UC...).
*/
function getChannelIdsFromPageData() {
console.log("Извлечение ID каналов...");
try {
// Проверяем наличие основного объекта данных страницы
if (!window.ytInitialData) {
throw new Error("Объект window.ytInitialData не найден. Убедитесь, что скрипт запущен на странице управления подписками YouTube.");
}
let channels = []; // Массив для хранения найденных ID
// Возможные пути к списку каналов в структуре ytInitialData (могут меняться!)
// Проверяем как для вида "Сетка", так и для вида "Список"
const pathsToTry = [
'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 => {
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]; // Безопасный доступ к свойству
}
});
// Если нашли непустой массив, сохраняем его и выходим из цикла
if (Array.isArray(current) && current.length > 0) {
console.debug(`Найдены элементы каналов по пути: ${path}`);
items = current;
break;
}
} catch (e) { /* Игнорируем ошибку для данного пути, пробуем следующий */ }
}
// Если не нашли каналы в ytInitialData, пробуем запасной метод - сканирование DOM
if (!items || items.length === 0) {
console.warn("Не удалось найти каналы в ytInitialData. Попытка сканирования DOM (менее надежно)...");
const renderers = document.querySelectorAll('ytd-channel-renderer'); // Ищем все элементы каналов
if (renderers.length > 0) {
renderers.forEach(renderer => {
// Ищем ссылку на канал внутри элемента
const linkElement = renderer.querySelector('a#main-link');
const href = linkElement?.getAttribute('href');
// Извлекаем ID, если ссылка имеет формат /channel/UC...
if (href && href.includes('/channel/')) {
const channelId = href.split('/channel/')[1];
if (channelId && !channels.includes(channelId)) { // Добавляем только уникальные ID
channels.push(channelId);
}
}
});
if (channels.length > 0) {
console.log(`Извлечено ${channels.length} ID из DOM.`);
// Возвращаем уникальные ID, найденные в DOM
return channels.filter((id, index, self) => self.indexOf(id) === index);
}
}
// Если не нашли ни в данных, ни в DOM
console.error("Не удалось найти ID каналов на странице.");
return []; // Возвращаем пустой массив
}
// Если нашли каналы в ytInitialData, извлекаем ID из них
items.forEach(item => {
// ID может находиться в разных местах в зависимости от типа рендерера (grid/list)
const channelId = item?.gridChannelRenderer?.channelId
|| item?.channelRenderer?.channelId
|| item?.channelRenderer?.navigationEndpoint?.browseEndpoint?.browseId
|| item?.gridChannelRenderer?.navigationEndpoint?.browseEndpoint?.browseId;
if (channelId) {
channels.push(channelId);
}
});
console.log(`Найдено ${channels.length} ID каналов в ytInitialData.`);
// Возвращаем только уникальные ID
return channels.filter((id, index, self) => self.indexOf(id) === index);
} catch (e) {
console.error("Критическая ошибка при получении ID каналов:", e);
return []; // Возвращаем пустой массив в случае любой ошибки
}
}
/**
* Асинхронно отправляет POST-запрос на отписку от указанного канала.
* Включает генерацию и добавление заголовка Authorization: SAPISIDHASH.
* @param {string} channelId - ID канала для отписки.
* @param {string} apiKey - Актуальный INNERTUBE_API_KEY.
* @param {object} context - Объект контекста запроса (предоставленный пользователем).
* @returns {Promise<boolean>} Промис, разрешающийся true в случае успеха запроса, false в случае ошибки.
*/
async function sendUnsubscribeRequest(channelId, apiKey, context) {
// Формируем полный URL запроса с API-ключом
const apiUrl = `${UNSUBSCRIBE_ENDPOINT}?key=${apiKey}`;
// Тело запроса: ID канала и контекст
const payload = {
channelIds: [channelId],
context: context
};
// Генерируем заголовок авторизации ПЕРЕД отправкой запроса
const authorizationHeader = await generateSapisidHashHeader();
// Если заголовок сгенерировать не удалось (например, нет куки SAPISID), прерываем операцию
if (!authorizationHeader) {
console.error(`Не удалось сгенерировать заголовок Authorization для канала ${channelId}. Запрос не будет отправлен.`);
return false; // Сигнализируем об ошибке
}
console.log(`Отправка запроса на отписку от ${channelId} с Authorization header...`);
try {
// Отправляем POST-запрос с помощью fetch API
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json', // Указываем тип тела запроса
'X-Origin': window.location.origin, // Указываем источник (часто требуется API YouTube)
'Authorization': authorizationHeader // Добавляем сгенерированный заголовок авторизации
},
body: JSON.stringify(payload), // Преобразуем тело запроса в JSON-строку
credentials: 'include' // Явно указываем браузеру включать куки в запрос
});
// --- Анализ ответа ---
// Проверяем HTTP-статус ответа. !response.ok означает статус не в диапазоне 200-299.
if (!response.ok) {
let errorDetails = `Статус: ${response.status} ${response.statusText}`;
try {
// Пытаемся прочитать тело ответа как JSON, если сервер вернул детали ошибки
const errorJson = await response.json();
console.error(`HTTP Ошибка отписки от ${channelId}.`, errorJson);
errorDetails = JSON.stringify(errorJson);
} catch(e) {
// Если тело ответа не JSON или пустое
console.error(`HTTP Ошибка отписки от ${channelId}: ${response.status} ${response.statusText}. Не удалось прочитать тело ответа.`);
}
// Возвращаем false, т.к. запрос не был успешным
return false;
}
// Если HTTP статус OK (2xx), читаем тело ответа как JSON
const responseData = await response.json();
// Дополнительная проверка: иногда API возвращает статус 200 OK, но содержит ошибки в теле ответа
if (responseData.responseContext && responseData.errors) {
console.error(`Ошибка API (в JSON ответе) при отписке от ${channelId}:`, responseData.errors);
return false; // Считаем это ошибкой
}
// Если все проверки пройдены, считаем запрос успешным
console.log(`Запрос на отписку от ${channelId} успешно обработан сервером.`);
return true;
} catch (error) {
// Ловим ошибки сети, CORS, проблемы с соединением и т.д.
console.error(`Критическая ошибка сети/fetch при отписке от ${channelId}:`, error);
return false; // Возвращаем false при сетевых ошибках
}
}
// --- Основной процесс выполнения скрипта ---
// 1. Получаем необходимые данные: API ключ и проверяем контекст
const apiKey = getApiKey();
const context = providedContextData; // Используем предоставленный контекст
// Проверяем, что удалось получить ключ и контекст валиден
if (!apiKey) {
console.error("Не удалось получить API ключ. Выполнение скрипта прервано.");
alert("Ошибка: Не удалось получить API ключ. Проверьте консоль.");
return; // Прерываем выполнение
}
if (!context || !context.client) { // Базовая проверка валидности контекста
console.error("Предоставленный объект контекста невалиден или пуст. Выполнение скрипта прервано.");
alert("Ошибка: Предоставленный контекст невалиден. Проверьте код скрипта.");
return; // Прерываем выполнение
}
console.log("API ключ получен. Используется предоставленный контекст и генерация SAPISIDHASH.");
// 2. Получаем список ID каналов для отписки с текущей страницы
const channelIds = getChannelIdsFromPageData();
// Проверяем, найдены ли каналы
if (!channelIds || channelIds.length === 0) {
console.log("На текущей странице не найдено каналов для отписки.");
alert("На этой странице не найдено каналов для отписки. Убедитесь, что вы на странице управления подписками.");
return; // Прерываем выполнение, если каналов нет
}
// 3. Запускаем цикл отписки по найденным каналам
console.log(`Начинаем отписку от ${channelIds.length} каналов с задержкой ${DELAY_BETWEEN_REQUESTS_MS / 1000} сек.`);
alert(`Сейчас начнется отписка от ${channelIds.length} каналов, найденных на этой странице.\n\nВАЖНО: Не закрывайте эту вкладку до завершения работы скрипта!\nПрогресс будет отображаться в консоли (F12).`);
let successCount = 0; // Счетчик успешных отписок
let failCount = 0; // Счетчик неудачных отписок/пропусков
let authHeaderFailed = false; // Флаг для отслеживания ошибки генерации заголовка авторизации
// Проходим по каждому ID канала в массиве
for (let i = 0; i < channelIds.length; i++) {
// Если на предыдущем шаге возникла ошибка с генерацией заголовка, прекращаем цикл
if (authHeaderFailed) {
console.warn("Пропускаем оставшиеся каналы из-за предыдущей ошибки генерации заголовка авторизации.");
failCount += (channelIds.length - i); // Считаем все оставшиеся как неудачные
break; // Выходим из цикла
}
const channelId = channelIds[i]; // Получаем ID текущего канала
console.log(`--- Обработка канала ${i + 1} из ${channelIds.length} (ID: ${channelId}) ---`);
// Вызываем асинхронную функцию отписки и ждем её результата
const success = await sendUnsubscribeRequest(channelId, apiKey, context);
// Обновляем счетчики в зависимости от результата
if (success) {
successCount++;
} else {
failCount++;
// Дополнительная проверка: если отписка не удалась, проверяем, не связана ли ошибка
// с отсутствием куки SAPISID (например, если пользователь вышел из аккаунта в другой вкладке).
// Это простая эвристика, ошибка может быть и другой.
if (!getCookie('SAPISID')) {
console.error("Критическая ошибка: Куки 'SAPISID' больше не доступна. Возможно, сессия истекла или вы вышли из аккаунта. Прекращаем работу скрипта.");
authHeaderFailed = true; // Устанавливаем флаг, чтобы остановить цикл на следующей итерации
alert("Ошибка: Потеряны куки аутентификации (SAPISID). Скрипт остановлен. Пожалуйста, обновите страницу и попробуйте снова, убедившись, что вы авторизованы.");
// Немедленный выход из цикла не делаем, даем завершиться текущей итерации логгирования
}
}
// Делаем паузу перед обработкой следующего канала,
// только если не было фатальной ошибки и это не последний канал в списке.
if (!authHeaderFailed && i < channelIds.length - 1) {
console.log(`Пауза ${DELAY_BETWEEN_REQUESTS_MS / 1000} сек...`);
// Ожидаем указанное количество миллисекунд
await new Promise(resolve => setTimeout(resolve, DELAY_BETWEEN_REQUESTS_MS));
}
}
// 4. Завершение работы скрипта и вывод статистики
console.log("--- Завершение работы скрипта ---");
console.log(`Успешно отправлено запросов: ${successCount}`);
console.log(`Неудачных запросов / пропущено: ${failCount}`);
// Выводим финальное сообщение пользователю
if (!authHeaderFailed) { // Не показываем финальный alert, если была ошибка авторизации (уже показали другой)
alert(`Работа скрипта завершена!\n\nУспешно отписано (запросов отправлено): ${successCount}\nНеудачно / пропущено: ${failCount}\n\nПожалуйста, обновите страницу (F5), чтобы увидеть изменения.`);
}
}
// Запускаем основную асинхронную функцию
massUnsubscribeWithSapisidHash();