YouTube Force Transcript Loader & Debugger (Trusted-Types DOM)

Принудительно загружает расшифровку видео на YouTube

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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);
    }
})();