Greasy Fork is available in English.

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

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

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Greasemonkey lub Violentmonkey.

You will need to install an extension such as Tampermonkey to install this script.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana będzie instalacja rozszerzenia Tampermonkey lub Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

Aby zainstalować ten skrypt, musisz zainstalować rozszerzenie menedżera skryptów użytkownika.

(Mam już menedżera skryptów użytkownika, pozwól mi to zainstalować!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Musisz zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

(Mam już menedżera stylów użytkownika, pozwól mi to zainstalować!)

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