Добавить youtube_mass_unsubscriber.js

This commit is contained in:
olilovedani 2025-04-13 14:56:37 +00:00
parent 5c0f57f36e
commit 2bd97e9c55

View File

@ -0,0 +1,445 @@
/**
* @file youtube_mass_unsubscriber.js
* @description Скрипт для браузерной консоли для массовой отписки от YouTube каналов.
* @version 4.0 (Использует SAPISIDHASH для аутентификации)
*
* @warning ИСПОЛЬЗУЙТЕ НА СВОЙ СТРАХ И РИСК!
* Этот скрипт автоматизирует действия в вашем аккаунте YouTube.
* - Это может нарушать Условия использования YouTube.
* - Использование может привести к временным или постоянным ограничениям вашего аккаунта.
* - YouTube может изменить структуру сайта или API в любой момент, что сломает скрипт.
* - Разработчик не несет ответственности за любые последствия использования этого скрипта.
* - Рекомендуется использовать с большой задержкой между запросами.
*/
// Обертка в асинхронную самовызывающуюся функцию (IIFE) для изоляции области видимости
(async function massUnsubscribeWithSapisidHash() {
console.log("%cЗапуск скрипта YouTube Mass Unsubscriber v4 (SAPISIDHASH)...", "color: blue; font-weight: bold;");
alert("ВНИМАНИЕ:\nСкрипт для массовой отписки будет запущен.\nПожалуйста, НЕ закрывайте эту вкладку до завершения.\nИспользуйте на свой страх и риск.");
// --- НАСТРОЙКИ СКРИПТА ---
/**
* @const {number} DELAY_BETWEEN_REQUESTS_MS
* Задержка между отправкой запросов на отписку в миллисекундах.
* Увеличение этого значения снижает риск блокировки со стороны YouTube.
* Рекомендуется значение не менее 2000-3000 (2-3 секунды).
*/
const DELAY_BETWEEN_REQUESTS_MS = 2500;
/**
* @const {string} UNSUBSCRIBE_ENDPOINT
* Полный URL конечной точки API YouTube для отписки.
* Взят из анализа сетевых запросов при ручной отписке.
*/
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": { /* ... */ }
};
// ---------------------------------------
// --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ---
/**
* Читает значение указанной куки из `document.cookie`.
* @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();
}
// console.debug(`Куки "${name}" не найдена.`); // Отладочное сообщение
return null; // Куки не найдена
}
/**
* Асинхронно вычисляет SHA-1 хеш для переданной строки.
* Использует встроенный браузерный API `SubtleCrypto`.
* @param {string} str - Строка для хеширования.
* @returns {Promise<string | null>} Промис, который разрешается в строку с HEX-представлением SHA-1 хеша, или null в случае ошибки.
*/
async function sha1(str) {
try {
// Кодируем строку в ArrayBuffer (массив байт)
const buffer = new TextEncoder().encode(str);
// Вычисляем хеш с помощью SubtleCrypto API
const hashBuffer = await window.crypto.subtle.digest('SHA-1', buffer);
// Преобразуем ArrayBuffer с хешем в массив байт
const hashArray = Array.from(new Uint8Array(hashBuffer));
// Преобразуем каждый байт в его HEX-представление (2 символа, с дополнением нуля) и соединяем в строку
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
return hashHex;
} catch (error) {
console.error("Ошибка при вычислении SHA-1 хеша:", error);
return null; // Возвращаем null при ошибке
}
}
/**
* Асинхронно генерирует заголовок авторизации `Authorization: SAPISIDHASH ...`.
* Требует наличия куки `SAPISID` в браузере.
* @returns {Promise<string | null>} Промис, который разрешается в строку заголовка или null, если генерация не удалась.
*/
async function generateSapisidHashHeader() {
// Получаем текущее время в СЕКУНДАХ с начала эпохи Unix
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' не найдена!");
console.error("Это необходимо для аутентификации запросов.");
console.error("Возможные причины: вы не авторизованы в YouTube, куки заблокированы расширением или настройками браузера.");
alert("Критическая ошибка: Куки SAPISID не найдена.\nСкрипт не может продолжить работу.\nУбедитесь, что вы авторизованы в YouTube.");
return null; // Не можем сгенерировать заголовок
}
// Формируем строку для хеширования: timestamp + пробел + sapisid + пробел + origin
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.debug("Сгенерирован заголовок Authorization:", authHeader); // Полезно для отладки
return authHeader;
}
/**
* Получает API ключ YouTube (INNERTUBE_API_KEY) из глобальных переменных страницы.
* Пробует найти его в `ytcfg.data_` или `yt.config_`.
* @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("Не удалось найти INNERTUBE_API_KEY ни в одном из известных мест.");
} catch (e) {
console.error("Ошибка при получении API ключа:", e);
alert("Ошибка: Не удалось получить API ключ YouTube со страницы.\nСкрипт не может продолжить.");
return null;
}
}
/**
* Извлекает список ID каналов со страницы управления подписками.
* Пытается найти данные в объекте `window.ytInitialData`, пробуя несколько известных путей.
* Если не находит в `ytInitialData`, пробует (менее надежно) извлечь из DOM.
* @returns {string[]} Массив уникальных ID каналов (формата UC...). Пустой массив, если ничего не найдено.
*/
function getChannelIdsFromPageData() {
console.log("Ищем ID каналов на странице...");
try {
// Проверяем наличие основного объекта данных YouTube
if (!window.ytInitialData) {
throw new Error("Объект window.ytInitialData не найден. Вы точно на странице управления подписками?");
}
let channels = []; // Массив для хранения найденных ID
// Пути в объекте 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' // Вид "Список" (новый?)
// Добавляйте сюда другие пути, если структура изменится
];
let items = null; // Переменная для хранения найденного массива элементов каналов
// Пробуем найти элементы по каждому известному пути
for (const path of pathsToTry) {
try {
// Безопасная навигация по вложенным свойствам объекта
let current = window.ytInitialData;
path.split('.').forEach(part => {
// Обработка индексов массива вида 'prop[0]'
const arrayIndex = part.match(/\[(\d+)\]$/);
if (arrayIndex) {
const key = part.substring(0, arrayIndex.index);
const index = parseInt(arrayIndex[1], 10);
current = current?.[key]?.[index]; // Безопасный доступ к элементу массива
} else {
current = current?.[part]; // Безопасный доступ к свойству объекта
}
// Если на каком-то этапе путь оборвался (свойство не найдено), current станет undefined
if (current === undefined) throw new Error(`Свойство ${part} не найдено по пути ${path}`);
});
// Если мы дошли до конца пути и получили массив элементов, сохраняем его
if (Array.isArray(current) && current.length > 0) {
console.log(`Найдены элементы каналов по пути в ytInitialData: ${path}`);
items = current;
break; // Прекращаем поиск, так как нашли
}
} catch (e) {
// console.debug(`Путь ${path} не найден или не содержит элементов:`, e.message);
/* Игнорируем ошибку для данного пути и пробуем следующий */
}
}
// Если не нашли каналы в ytInitialData, пробуем найти через DOM (менее надежно)
if (!items || items.length === 0) {
console.warn("Не удалось найти данные о каналах в window.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/'
if (href && href.includes('/channel/')) {
const channelId = href.split('/channel/')[1].split('/')[0]; // Отсекаем возможные '/videos' и т.д.
if (channelId && channelId.startsWith('UC') && !channels.includes(channelId)) {
channels.push(channelId);
}
}
// Можно добавить обработку ссылок вида /@handle, если это необходимо,
// но ID из них извлечь сложнее без дополнительных данных.
});
if (channels.length > 0) {
console.log(`Извлечено ${channels.length} ID каналов из DOM.`);
// Возвращаем только уникальные ID
return channels.filter((id, index, self) => self.indexOf(id) === index);
}
}
// Если ничего не нашли ни там, ни там
console.error("Не найдено ID каналов ни в ytInitialData, ни в DOM. Структура страницы могла измениться.");
alert("Ошибка: Не удалось найти каналы на странице.\nВозможно, структура сайта YouTube изменилась или вы не на той странице.");
return []; // Возвращаем пустой массив
}
// Извлекаем ID каналов из найденных элементов ytInitialData
items.forEach(item => {
// ID может быть в разных местах в зависимости от типа рендерера (grid, list, shelf)
const channelId = item?.gridChannelRenderer?.channelId
|| item?.channelRenderer?.channelId
|| item?.channelRenderer?.navigationEndpoint?.browseEndpoint?.browseId
|| item?.gridChannelRenderer?.navigationEndpoint?.browseEndpoint?.browseId;
if (channelId && channelId.startsWith('UC')) { // Добавляем только валидные ID
channels.push(channelId);
}
});
// Убираем дубликаты, если они вдруг появились
const uniqueChannels = channels.filter((id, index, self) => self.indexOf(id) === index);
console.log(`Найдено ${uniqueChannels.length} уникальных ID каналов.`);
return uniqueChannels;
} catch (e) {
console.error("Критическая ошибка при получении ID каналов:", e);
alert("Критическая ошибка при поиске каналов. См. консоль для деталей.");
return []; // Возвращаем пустой массив в случае критической ошибки
}
}
/**
* Асинхронно отправляет POST-запрос на отписку для одного канала.
* Генерирует заголовок авторизации перед отправкой.
* @param {string} channelId - ID канала для отписки.
* @param {string} apiKey - Действующий API ключ YouTube (INNERTUBE_API_KEY).
* @param {object} context - Объект контекста сессии (INNERTUBE_CONTEXT).
* @returns {Promise<boolean>} Промис, который разрешается в `true` при успешной отправке запроса (статус 2xx и нет ошибок в JSON), иначе `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(`[${channelId}] Не удалось сгенерировать Authorization header. Запрос не будет отправлен.`);
return false; // Возвращаем неудачу
}
// ---------------------------------------------------------------
console.log(`[${channelId}] Попытка отписки... Отправка запроса на ${apiUrl}`);
try {
// Отправляем асинхронный 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
// ---------------------------------------------
},
body: JSON.stringify(payload), // Тело запроса в формате JSON
credentials: 'include' // Явно указываем браузеру включать куки в запрос
});
// --- Анализируем ответ сервера ---
// Проверяем статус HTTP ответа. Коды 2xx считаются успешными.
if (!response.ok) {
// Если статус не 2xx (например, 401 Unauthorized, 400 Bad Request, 500 Server Error)
let errorDetails = `Статус: ${response.status} ${response.statusText}`;
try {
// Пытаемся прочитать тело ответа как JSON, там может быть больше деталей об ошибке
const errorJson = await response.json();
console.error(`[${channelId}] Ошибка HTTP ${response.status} при отписке. Ответ сервера:`, errorJson);
errorDetails = JSON.stringify(errorJson);
} catch(e) {
// Если тело ответа не JSON или пустое
console.error(`[${channelId}] Ошибка HTTP ${response.status} ${response.statusText}. Тело ответа не JSON.`);
}
// Возвращаем неудачу
return false;
}
// Если статус ответа OK (2xx), читаем тело ответа как JSON
const responseData = await response.json();
// Иногда YouTube возвращает статус 200 OK, но сообщает об ошибке внутри JSON ответа
if (responseData.responseContext && responseData.errors) {
console.error(`[${channelId}] Ошибка API (внутри JSON ответа) при отписке:`, responseData.errors);
return false; // Считаем это неудачей
}
// Если дошли сюда, статус OK и в JSON нет явных ошибок
console.log(`%c[${channelId}] Запрос на отписку успешно отправлен (HTTP ${response.status}).`, "color: green;");
// console.debug(`[${channelId}] Ответ сервера:`, responseData); // Для детальной отладки
return true; // Успех!
} catch (error) {
// Ловим ошибки сети (нет соединения), CORS, проблемы с DNS и т.д.
console.error(`[${channelId}] КРИТИЧЕСКАЯ ОШИБКА СЕТИ/FETCH при отписке:`, error);
return false; // Неудача
}
}
// --- ОСНОВНОЙ ПРОЦЕСС ВЫПОЛНЕНИЯ ---
// 1. Получаем API ключ со страницы
const apiKey = getApiKey();
// Получаем контекст (в данной версии - из жестко заданной переменной)
const context = providedContextData;
// Проверяем, получены ли ключ и контекст
if (!apiKey || !context || !context.client) {
console.error("Критическая ошибка: Не удалось получить API ключ или контекст невалиден. Остановка скрипта.");
// Дополнительные сообщения об ошибках выводятся внутри getApiKey()
return; // Прекращаем выполнение
}
console.log("API ключ получен. Используется предоставленный контекст и генерация SAPISIDHASH.");
// 2. Получаем список ID каналов с текущей страницы
const channelIds = getChannelIdsFromPageData();
// Проверяем, найдены ли каналы
if (!channelIds || channelIds.length === 0) {
console.log("На текущей странице не найдено каналов для отписки.");
// Дополнительные сообщения выводятся внутри getChannelIdsFromPageData()
return; // Прекращаем выполнение
}
console.log(`Найдено ${channelIds.length} каналов для обработки. Начинаем процесс отписки...`);
alert(`Найдено ${channelIds.length} каналов.\nНачинаем отписку с задержкой ${DELAY_BETWEEN_REQUESTS_MS / 1000} сек между запросами.\nНе закрывайте вкладку!`);
// 3. Инициализируем счетчики для итогового отчета
let successCount = 0;
let failCount = 0;
let authHeaderFailedGlobally = false; // Флаг, что генерация заголовка не удалась (чтобы остановить цикл)
// 4. Запускаем цикл отписки по всем найденным каналам
for (let i = 0; i < channelIds.length; i++) {
// Если на предыдущей итерации произошла ошибка генерации заголовка, прерываем цикл
if (authHeaderFailedGlobally) {
console.warn("Работа скрипта прервана из-за ошибки генерации заголовка авторизации.");
failCount = channelIds.length - i; // Считаем все оставшиеся каналы неудачными
break; // Выходим из цикла
}
const channelId = channelIds[i];
console.log(`--- [${i + 1}/${channelIds.length}] Обработка канала: ${channelId} ---`);
// Вызываем асинхронную функцию отписки для текущего канала
// Она сама внутри генерирует заголовок авторизации
const success = await sendUnsubscribeRequest(channelId, apiKey, context);
// Обновляем счетчики в зависимости от результата
if (success) {
successCount++;
} else {
failCount++;
// Дополнительная проверка: если ошибка произошла И куки SAPISID пропала,
// устанавливаем флаг для остановки цикла на следующей итерации.
if (!getCookie('SAPISID')) {
console.error("ОШИБКА: Куки SAPISID больше не доступна! Возможно, сессия истекла или куки удалены.");
authHeaderFailedGlobally = true; // Устанавливаем флаг для остановки
alert("Критическая ошибка: Куки SAPISID не найдена.\nСкрипт будет остановлен.\nВозможно, вам нужно перезайти в аккаунт YouTube.");
// Не выходим из цикла немедленно, даем завершить текущую итерацию (failCount уже увеличен)
}
}
// 5. Делаем паузу перед обработкой следующего канала (если это не последний канал и не было фатальной ошибки)
if (!authHeaderFailedGlobally && 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), чтобы увидеть изменения.`);
})(); // Конец самовызывающейся функции