Greasy Fork is available in English.
Принудительно загружает расшифровку видео на YouTube
// ==UserScript==
// @name YouTube Force Transcript Loader & Debugger (Trusted-Types DOM)
// @namespace http://tampermonkey.net/
// @version 1.9
// @description Принудительно загружает расшифровку видео на YouTube
// @author User
// @match *://www.youtube.com/*
// @grant GM_xmlhttpRequest
// @grant unsafeWindow
// @run-at document-start
// @connect youtube.com
// @connect youtube.com/*
// @connect googlevideo.com
// ==/UserScript==
(function() {
'use strict';
const DEBUG = true;
let globalPoToken = ''; // Хранилище для PO Token подписи
let currentVideoId = '';
let fetchedTranscript = null;
let checkInterval = null;
function log(...args) {
if (DEBUG) {
console.log('[YT-Force-Loader]', ...args);
}
}
// Безопасное декодирование спецсимволов HTML с помощью DOMParser (в обход innerHTML)
function decodeHTML(html) {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
return doc.documentElement.textContent || '';
} catch (e) {
return html;
}
}
function getVideoId() {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get('v');
}
// Рекурсивный сканер объектов памяти на наличие PO-Token
function findPoTokenInObject(obj, visited = new Set()) {
if (!obj || typeof obj !== 'object' || visited.has(obj)) return null;
visited.add(obj);
for (let key in obj) {
try {
const val = obj[key];
// Поиск по имени ключа
if (key.toLowerCase() === 'potoken' || key.toLowerCase() === 'po_token') {
if (typeof val === 'string' && val.length > 15) {
return val;
}
}
// Поиск по значению (форматы сигнатур веб-клиента)
if (typeof val === 'string') {
if (val.startsWith('web.subs') || val.startsWith('web+')) {
return val;
}
} else if (typeof val === 'object') {
const found = findPoTokenInObject(val, visited);
if (found) return found;
}
} catch (e) {}
}
return null;
}
// Сканирование глобальных объектов YouTube
function scanMemoryForPoToken() {
if (globalPoToken) return true; // Токен уже есть
const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
const targets = [win.ytcfg, win.ytplayer, win.ytInitialPlayerResponse];
for (let target of targets) {
if (target) {
const token = findPoTokenInObject(target);
if (token) {
globalPoToken = token;
log('Из памяти YouTube рекурсивно извлечен PO Token:', globalPoToken);
return true;
}
}
}
return false;
}
// Функция обработки перехваченных данных
function handleInterceptedData(text, url) {
log('Получены перехваченные данные сети для:', url);
// Извлекаем PO Token, если он содержится в параметрах перехваченного URL
const potMatch = url.match(/[&?]pot=([^&]+)/);
if (potMatch) {
globalPoToken = potMatch[1];
log('Захвачен рабочий PO Token из запроса:', globalPoToken);
}
handleResponse(text);
}
// ШАГ 1: Сетевой перехватчик в unsafeWindow (Trusted Types Safe)
if (typeof unsafeWindow !== 'undefined') {
log('Запуск безопасного сетевого перехвата в unsafeWindow.');
// Перехват XMLHttpRequest
const OriginalXHR = unsafeWindow.XMLHttpRequest;
unsafeWindow.XMLHttpRequest = function() {
const xhr = new OriginalXHR();
const originalOpen = xhr.open;
let requestUrl = '';
xhr.open = function(method, url, ...args) {
requestUrl = url;
return originalOpen.apply(this, [method, url, ...args]);
};
xhr.addEventListener('readystatechange', function() {
if (xhr.readyState === 4 && xhr.status === 200) {
if (requestUrl.includes('api/timedtext') || requestUrl.includes('get_transcript')) {
const responseText = xhr.responseText;
if (responseText && responseText.trim().length > 0) {
log('XHR timedtext/get_transcript успешно перехвачен.');
handleInterceptedData(responseText, requestUrl);
}
}
}
});
return xhr;
};
unsafeWindow.XMLHttpRequest.prototype = OriginalXHR.prototype;
Object.assign(unsafeWindow.XMLHttpRequest, OriginalXHR);
// Перехват window.fetch
const originalFetch = unsafeWindow.fetch;
unsafeWindow.fetch = async function(input, init) {
const response = await originalFetch.apply(this, [input, init]);
let url = '';
if (typeof input === 'string') {
url = input;
} else if (input && input.url) {
url = input.url;
}
if (url.includes('api/timedtext') || url.includes('get_transcript')) {
try {
const clone = response.clone();
const text = await clone.text();
if (text && text.trim().length > 0) {
log('Fetch timedtext/get_transcript успешно перехвачен.');
handleInterceptedData(text, url);
}
} catch (e) {
log('Ошибка чтения потока перехваченного Fetch:', e);
}
}
return response;
};
}
function onVideoChange() {
const videoId = getVideoId();
if (!videoId || videoId === currentVideoId) return;
currentVideoId = videoId;
log(`Переход на видео ID: ${currentVideoId}. Запускаем сканирование и сборщик.`);
fetchedTranscript = null;
removeCustomPanel();
removeDebugButton();
// Запуск сборщика с циклом ожидания
setTimeout(tryToExtractCaptions, 1500);
}
// Ручное извлечение и загрузка альтернативного GET-запроса субтитров (с циклом ожидания плеера)
function tryToExtractCaptions() {
if (getVideoId() !== currentVideoId) return;
const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
let playerResponse = null;
const player = document.getElementById('movie_player');
if (player && typeof player.getPlayerResponse === 'function') {
playerResponse = player.getPlayerResponse();
}
if (!playerResponse && win.ytInitialPlayerResponse) {
playerResponse = win.ytInitialPlayerResponse;
}
// Если плеер еще не инициализирован, повторяем попытку через 2 секунды
if (!playerResponse) {
log('Плеер YouTube еще не инициализировался. Повторный поиск метаданных...');
setTimeout(tryToExtractCaptions, 2000);
return;
}
// ШАГ 2: Интеллектуальный поиск PO-Token в глубоких метаданных плеера
if (!globalPoToken) {
// А. Проверка в serviceIntegrityDimensions
if (playerResponse?.serviceIntegrityDimensions?.poToken) {
globalPoToken = playerResponse.serviceIntegrityDimensions.poToken;
log('PO Token извлечен напрямую из serviceIntegrityDimensions:', globalPoToken);
}
// Б. Проверка в serviceTrackingParams
if (!globalPoToken && playerResponse?.serviceTrackingParams) {
try {
playerResponse.serviceTrackingParams.forEach(param => {
const potParam = param.params?.find(x => x.key === 'pot');
if (potParam && potParam.value) {
globalPoToken = potParam.value;
log('PO Token извлечен напрямую из параметров трекинга:', globalPoToken);
}
});
} catch(e){}
}
// В. Рекурсивное сканирование объектов памяти
if (!globalPoToken) {
scanMemoryForPoToken();
}
}
const captions = playerResponse?.captions?.playerCaptionsTracklistRenderer;
if (!captions || !captions.captionTracks || captions.captionTracks.length === 0) {
log('Метаданные субтитров еще не прогрузились в плеер. Повторный поиск дорожек...');
setTimeout(tryToExtractCaptions, 2000);
return;
}
log('Метаданные субтитров успешно извлечены из плеера:', captions.captionTracks);
// Ищем русский, затем английский, иначе берем первую дорожку
let selectedTrack = captions.captionTracks.find(t => t.languageCode === 'ru') ||
captions.captionTracks.find(t => t.languageCode === 'en') ||
captions.captionTracks[0];
log(`Выбран язык для принудительной загрузки: ${selectedTrack.name.simpleText} (${selectedTrack.languageCode})`);
let url = selectedTrack.baseUrl + '&fmt=json3';
if (globalPoToken) {
url += `&pot=${globalPoToken}&c=WEB`;
log('Применяем PO Token для авторизации запроса.');
} else {
log('Внимание: Запрос отправляется без PO Token (может вернуться пустое тело 200).');
}
downloadSubtitleTrack(url);
}
function downloadSubtitleTrack(url) {
GM_xmlhttpRequest({
method: 'GET',
url: url,
onload: function(response) {
if (response.status === 200) {
const text = response.responseText || response.response;
if (text && text.trim().length > 0) {
handleResponse(text);
} else {
log('Ручной GET-запрос субтитров вернул пустое тело ответа (требуется PO-Token).');
}
} else {
log(`Сбой ручной загрузки субтитров. Код: ${response.status}`);
}
}
});
}
// Универсальный парсинг
function handleResponse(responseText) {
if (!responseText || typeof responseText !== 'string' || responseText.trim().length === 0) return;
const trimmed = responseText.trim();
let parsedLines = null;
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
parsedLines = parseJSON3(JSON.parse(trimmed));
log('Формат субтитров успешно определен как JSON3.');
} catch (e) {}
}
if (!parsedLines && (trimmed.startsWith('<') || trimmed.includes('<?xml'))) {
try {
parsedLines = parseXML(trimmed);
log('Формат субтитров успешно определен как XML.');
} catch (e) {}
}
if (parsedLines && parsedLines.length > 0) {
fetchedTranscript = parsedLines;
log(`Успешно обработано строк расшифровки: ${fetchedTranscript.length}`);
injectIntoPage();
} else {
log('Не удалось извлечь строки расшифровки из ответа.');
}
}
function parseJSON3(data) {
const events = data.events;
if (!events) return null;
const lines = [];
events.forEach(event => {
if (!event.segs) return;
const text = event.segs.map(s => s.utf8).join('').trim();
if (!text) return;
const startMs = event.tStartMs || 0;
const totalSec = Math.floor(startMs / 1000);
lines.push({
time: `${Math.floor(totalSec / 60)}:${(totalSec % 60).toString().padStart(2, '0')}`,
rawSec: totalSec,
text: text
});
});
return lines;
}
function parseXML(xmlText) {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlText, "text/xml");
const lines = [];
const textNodes = xmlDoc.getElementsByTagName('text');
if (textNodes.length > 0) {
for (let i = 0; i < textNodes.length; i++) {
const node = textNodes[i];
const startSec = parseFloat(node.getAttribute('start')) || 0;
lines.push({
time: `${Math.floor(startSec / 60)}:${Math.floor(startSec % 60).toString().padStart(2, '0')}`,
rawSec: Math.floor(startSec),
text: decodeHTML(node.textContent.trim())
});
}
return lines;
}
return null;
}
function injectIntoPage() {
createCustomPanelButton();
const nativePanel = document.querySelector('ytd-engagement-panel-section-list-renderer[target-id="PAmodern_transcript_view"]');
if (nativePanel && fetchedTranscript) {
replaceNativeContent(nativePanel);
}
}
function replaceNativeContent(panel) {
const contentContainer = panel.querySelector('#content');
if (!contentContainer || !fetchedTranscript) return;
const listDiv = document.createElement('div');
listDiv.id = 'force-transcript-list';
listDiv.style.cssText = 'padding: 15px; max-height: 500px; overflow-y: auto; background: #0f0f0f; color: #fff; font-family: Roboto, Arial; font-size: 14px;';
fetchedTranscript.forEach(line => {
const row = document.createElement('div');
row.style.cssText = 'margin-bottom: 12px; display: flex; align-items: flex-start;';
const timeBtn = document.createElement('span');
timeBtn.innerText = line.time;
timeBtn.style.cssText = 'color: #3ea6ff; font-weight: bold; margin-right: 12px; cursor: pointer; min-width: 45px;';
timeBtn.addEventListener('click', () => {
const video = document.querySelector('video');
if (video) { video.currentTime = line.rawSec; video.play(); }
});
const textSpan = document.createElement('span');
textSpan.innerText = line.text;
row.appendChild(timeBtn);
row.appendChild(textSpan);
listDiv.appendChild(row);
});
// Безопасное очищение без Trusted-Types-ошибок
contentContainer.textContent = '';
contentContainer.appendChild(listDiv);
}
function createCustomPanelButton() {
if (document.getElementById('force-transcript-debug-btn')) return;
const btn = document.createElement('button');
btn.id = 'force-transcript-debug-btn';
btn.innerText = '⚙️ Debug: Показать расшифровку';
btn.style.cssText = 'position: fixed; bottom: 20px; right: 20px; z-index: 9999; background: #ff0000; color: white; border: none; padding: 10px 15px; border-radius: 8px; font-weight: bold; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.5); font-family: sans-serif;';
btn.addEventListener('click', () => {
if (fetchedTranscript) {
showCustomTranscriptPanel();
} else {
alert('Сборщик ожидает прогрузки метаданных или перехвата PO-Token. Если расшифровка пустая, пожалуйста, попробуйте включить субтитры (кнопка CC) на видео один раз.');
}
});
document.body.appendChild(btn);
}
function showCustomTranscriptPanel() {
removeCustomPanel();
const overlay = document.createElement('div');
overlay.id = 'custom-transcript-overlay';
overlay.style.cssText = 'position: fixed; top: 12%; right: 20px; width: 360px; height: 65%; background: rgba(15, 15, 15, 0.96); border: 1px solid #444; border-radius: 12px; z-index: 10000; box-shadow: 0 10px 30px rgba(0,0,0,0.8); color: white; font-family: sans-serif; display: flex; flex-direction: column;';
const header = document.createElement('div');
header.style.cssText = 'padding: 12px 15px; border-bottom: 1px solid #333; display: flex; justify-content: space-between; align-items: center;';
// Безопасное создание заголовка без innerHTML (в обход Trusted Types)
const headerTitle = document.createElement('h3');
headerTitle.style.cssText = 'margin:0; font-size:15px; color:#ff4e4e;';
headerTitle.textContent = 'Debug Panel (Memory Scanner)';
header.appendChild(headerTitle);
const closeBtn = document.createElement('button');
closeBtn.innerText = '✕';
closeBtn.style.cssText = 'background:transparent; border:none; color:white; font-size:18px; cursor:pointer;';
closeBtn.addEventListener('click', removeCustomPanel);
header.appendChild(closeBtn);
const listContainer = document.createElement('div');
listContainer.style.cssText = 'flex: 1; overflow-y: auto; padding: 15px;';
fetchedTranscript.forEach(line => {
const row = document.createElement('div');
row.style.cssText = 'margin-bottom: 10px; font-size: 13px; line-height: 1.4;';
const time = document.createElement('span');
time.innerText = line.time + ' ';
time.style.cssText = 'color: #3ea6ff; font-weight: bold; cursor: pointer; margin-right: 8px;';
time.addEventListener('click', () => {
const video = document.querySelector('video');
if (video) video.currentTime = line.rawSec;
});
const text = document.createElement('span');
text.innerText = line.text;
row.appendChild(time);
row.appendChild(text);
listContainer.appendChild(row);
});
overlay.appendChild(header);
overlay.appendChild(listContainer);
document.body.appendChild(overlay);
}
function removeCustomPanel() {
const el = document.getElementById('custom-transcript-overlay');
if (el) el.remove();
}
function removeDebugButton() {
const el = document.getElementById('force-transcript-debug-btn');
if (el) el.remove();
}
window.addEventListener('yt-navigate-finish', onVideoChange);
window.addEventListener('load', onVideoChange);
if (!checkInterval) {
checkInterval = setInterval(() => {
const vid = getVideoId();
if (vid && vid !== currentVideoId) {
onVideoChange();
}
const nativePanel = document.querySelector('ytd-engagement-panel-section-list-renderer[target-id="PAmodern_transcript_view"]');
if (nativePanel && nativePanel.querySelector('tp-yt-paper-spinner[active]') && fetchedTranscript) {
replaceNativeContent(nativePanel);
}
}, 1500);
}
})();