YouTube One-Click Transcript Copier

Adds a YouTube-style control-bar button that copies the current video's transcript.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         YouTube One-Click Transcript Copier
// @namespace    local.youtube.transcript.copy
// @version      7.1.2
// @description  Adds a YouTube-style control-bar button that copies the current video's transcript.
// @license      CC-BY-ND-2.0
// @match        https://www.youtube.com/*
// @match        https://m.youtube.com/*
// @grant        GM_setClipboard
// @grant        GM_xmlhttpRequest
// @grant        unsafeWindow
// @connect      www.youtube.com
// @connect      m.youtube.com
// @connect      youtube.com
// @run-at       document-idle
// ==/UserScript==

(function () {
    'use strict';

    const CONFIG = Object.freeze({
        buttonId: 'yt-transcript-copy-button',
        styleId: 'yt-transcript-copy-style',
        toastId: 'yt-transcript-copy-toast',
        minTranscriptLength: 20,
        resetButtonMs: 1800,
        installIntervalMs: 1000,
        debug: false
    });

    let isWorking = false;
    let installTimer = null;

    init();

    function init() {
        injectStyles();
        installButtonSafely();
        startLowImpactInstaller();

        window.addEventListener('yt-navigate-finish', installButtonSafely);
        window.addEventListener('popstate', installButtonSafely);
    }

    function startLowImpactInstaller() {
        if (installTimer) {
            window.clearInterval(installTimer);
        }

        installTimer = window.setInterval(() => {
            installButtonSafely();
        }, CONFIG.installIntervalMs);
    }

    function installButtonSafely() {
        try {
            installButton();
        } catch (error) {
            debugLog('Button install failed:', error);
        }
    }

    function installButton() {
        if (!isVideoPage()) {
            removeButton();
            return;
        }

        const controls = getControlContainer();
        if (!controls) {
            return;
        }

        let button = document.getElementById(CONFIG.buttonId);
        if (!button) {
            button = createButton();
        }

        const fullscreenButton = controls.querySelector('.ytp-fullscreen-button');

        if (fullscreenButton) {
            if (button.parentElement !== controls || button.nextElementSibling !== fullscreenButton) {
                controls.insertBefore(button, fullscreenButton);
            }
            return;
        }

        if (button.parentElement !== controls) {
            controls.appendChild(button);
        }
    }

    function createButton() {
        const button = document.createElement('button');

        button.id = CONFIG.buttonId;
        button.className = 'ytp-button';
        button.type = 'button';
        button.dataset.state = 'idle';

        setButtonState(button, 'idle');

        button.addEventListener('click', async (event) => {
            event.preventDefault();
            event.stopPropagation();
            await copyCurrentVideoTranscript(button);
        }, true);

        return button;
    }

    function removeButton() {
        const button = document.getElementById(CONFIG.buttonId);
        if (button) {
            button.remove();
        }
    }

    async function copyCurrentVideoTranscript(button) {
        if (isWorking) {
            return;
        }

        isWorking = true;
        setButtonState(button, 'loading');

        try {
            const transcript = await getTranscriptFromYouTubeCaptions();

            if (!isUsableTranscript(transcript)) {
                throw new Error('No usable transcript found.');
            }

            GM_setClipboard(cleanTranscriptForClipboard(transcript), 'text');
            setButtonState(button, 'success');
            showToast('Transcript copied');
        } catch (error) {
            console.error('[Transcript Copier]', error);
            setButtonState(button, 'error');
            showToast(error.message || 'Transcript copy failed');
        } finally {
            window.setTimeout(() => {
                setButtonState(button, 'idle');
                isWorking = false;
            }, CONFIG.resetButtonMs);
        }
    }

    async function getTranscriptFromYouTubeCaptions() {
        const videoId = getCurrentVideoId();

        let playerResponse = null;

        try {
            playerResponse = await getBestAvailablePlayerResponse(videoId);
        } catch (error) {
            debugLog('Caption metadata unavailable, trying transcript panel fallbacks:', error);
        }

        const captionTracks = playerResponse
        ?.captions
        ?.playerCaptionsTracklistRenderer
        ?.captionTracks;
        const tracks = sortCaptionTracks(captionTracks);
        const formats = [null, 'json3', 'srv3', 'srv2', 'srv1', 'vtt'];

        for (const track of tracks) {
            for (const format of formats) {
                const transcriptUrl = buildTimedTextUrl(track.baseUrl, format);

                try {
                    const body = await requestTextWithFallback(transcriptUrl);
                    const transcript = parseTranscriptBody(body, format);

                    if (isUsableTranscript(transcript)) {
                        return transcript;
                    }
                } catch (error) {
                    debugLog('Caption attempt failed:', error);
                }
            }
        }

        const transcriptFromPanelApi = await getTranscriptViaPanelApi(videoId);
        if (isUsableTranscript(transcriptFromPanelApi)) {
            return transcriptFromPanelApi;
        }

        const transcriptFromDomPanel = await getTranscriptFromDomPanel();
        if (isUsableTranscript(transcriptFromDomPanel)) {
            return transcriptFromDomPanel;
        }

        throw new Error('No usable caption transcript found.');
    }

    async function getBestAvailablePlayerResponse(videoId) {
        const candidates = [
            () => getPlayerResponse(videoId),
            () => getInnertubePlayerResponse(videoId),
            () => getPlayerResponseFromWatchPage(videoId)
        ];

        for (const candidate of candidates) {
            try {
                const response = await candidate();
                const tracks = response
                ?.captions
                ?.playerCaptionsTracklistRenderer
                ?.captionTracks;

                if (isPlayerResponseForVideo(response, videoId) && Array.isArray(tracks) && tracks.length > 0) {
                    return response;
                }
            } catch (error) {
                debugLog('Player response candidate failed:', error);
            }
        }

        throw new Error('This video has no exposed caption track.');
    }

    async function getPlayerResponse(videoId) {
        const localResponse = readLocalPlayerResponse(videoId);
        if (localResponse) {
            return localResponse;
        }

        return getPlayerResponseFromWatchPage(videoId);
    }

    async function getPlayerResponseFromWatchPage(videoId) {
        const watchUrl = `https://www.youtube.com/watch?v=${encodeURIComponent(videoId)}&hl=en&persist_hl=1`;

        const watchPage = await gmRequest({
            method: 'GET',
            url: watchUrl,
            headers: {
                Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
            }
        });

        const html = watchPage.responseText || '';
        const extracted = extractPlayerResponseFromText(html);

        if (isPlayerResponseForVideo(extracted, videoId)) {
            return extracted;
        }

        throw new Error('Unable to read YouTube player response.');
    }

    async function getInnertubePlayerResponse(videoId) {
        const apiKey = unsafeWindow?.ytcfg?.get?.('INNERTUBE_API_KEY') || extractInnertubeApiKeyFromDocument();
        if (!apiKey) {
            throw new Error('No INNERTUBE_API_KEY found.');
        }

        const clientName = unsafeWindow?.ytcfg?.get?.('INNERTUBE_CLIENT_NAME') || '1';
        const clientVersion = unsafeWindow?.ytcfg?.get?.('INNERTUBE_CLIENT_VERSION') || '2.20260101.00.00';
        const hl = (navigator.language || 'en').split('-')[0] || 'en';

        const payload = {
            context: {
                client: {
                    clientName: 'WEB',
                    clientVersion,
                    hl
                }
            },
            videoId
        };

        const response = await gmRequest({
            method: 'POST',
            url: `https://www.youtube.com/youtubei/v1/player?key=${encodeURIComponent(apiKey)}&prettyPrint=false`,
            headers: {
                'Content-Type': 'application/json',
                Accept: 'application/json',
                'X-Youtube-Client-Name': String(clientName),
                'X-Youtube-Client-Version': String(clientVersion)
            },
            data: JSON.stringify(payload)
        });

        const body = response.responseText || '';
        if (!body) {
            throw new Error('Empty YouTube player API response.');
        }

        const parsed = JSON.parse(body);
        if (!isPlayerResponseForVideo(parsed, videoId)) {
            throw new Error('YouTube player API returned data for a different video.');
        }

        return parsed;
    }

    function readLocalPlayerResponse(videoId) {
        const directResponse = unsafeWindow?.ytInitialPlayerResponse;
        if (isPlayerResponseForVideo(directResponse, videoId)) {
            return directResponse;
        }

        const playerElement = document.querySelector('#movie_player');
        const playerResponseFromElement = playerElement?.getPlayerResponse?.();

        if (isPlayerResponseForVideo(playerResponseFromElement, videoId)) {
            return playerResponseFromElement;
        }

        const configResponse = unsafeWindow?.ytplayer?.config?.args?.player_response;

        if (configResponse) {
            try {
                const parsed = typeof configResponse === 'string'
                    ? JSON.parse(configResponse)
                : configResponse;

                if (isPlayerResponseForVideo(parsed, videoId)) {
                    return parsed;
                }
            } catch (error) {
                debugLog('Failed to parse ytplayer config response:', error);
            }
        }

        for (const script of Array.from(document.scripts)) {
            const extracted = extractPlayerResponseFromText(script.textContent || '');
            if (isPlayerResponseForVideo(extracted, videoId)) {
                return extracted;
            }
        }

        return null;
    }

    function isPlayerResponseForVideo(response, videoId) {
        if (!response || typeof response !== 'object') {
            return false;
        }

        const responseVideoId = response?.videoDetails?.videoId || response?.currentVideoEndpoint?.watchEndpoint?.videoId;
        return responseVideoId === videoId;
    }

    function extractPlayerResponseFromText(text) {
        const assignedJson = extractAssignedJson(text, 'ytInitialPlayerResponse');

        if (assignedJson) {
            try {
                return JSON.parse(assignedJson);
            } catch (error) {
                debugLog('Failed to parse ytInitialPlayerResponse:', error);
            }
        }

        const playerResponseMatch = text.match(/"player_response":"((?:\\.|[^"\\])*)"/);

        if (playerResponseMatch?.[1]) {
            try {
                return JSON.parse(unescapeJsonString(playerResponseMatch[1]));
            } catch (error) {
                debugLog('Failed to parse embedded player_response:', error);
            }
        }

        return null;
    }

    async function getTranscriptViaPanelApi(videoId) {
        try {
            const initialData = await getBestAvailableInitialData(videoId);
            const params = extractTranscriptParamsFromInitialData(initialData);
            if (!params) {
                return '';
            }

            const apiKey = unsafeWindow?.ytcfg?.get?.('INNERTUBE_API_KEY') || extractInnertubeApiKeyFromDocument();
            if (!apiKey) {
                return '';
            }

            const clientVersion = unsafeWindow?.ytcfg?.get?.('INNERTUBE_CLIENT_VERSION') || '2.20260101.00.00';
            const hl = (navigator.language || 'en').split('-')[0] || 'en';
            const payload = {
                context: {
                    client: {
                        clientName: 'WEB',
                        clientVersion,
                        hl
                    }
                },
                params
            };

            const response = await gmRequest({
                method: 'POST',
                url: `https://www.youtube.com/youtubei/v1/get_transcript?key=${encodeURIComponent(apiKey)}&prettyPrint=false`,
                headers: {
                    'Content-Type': 'application/json',
                    Accept: 'application/json',
                    'X-Youtube-Client-Name': '1',
                    'X-Youtube-Client-Version': String(clientVersion)
                },
                data: JSON.stringify(payload)
            });

            const body = response.responseText || '';
            if (!body) {
                return '';
            }

            return parseTranscriptFromGetTranscriptResponse(JSON.parse(body));
        } catch (error) {
            debugLog('Transcript panel API fallback failed:', error);
            return '';
        }
    }

    async function getBestAvailableInitialData(videoId) {
        const local = readLocalInitialData(videoId);
        if (local) {
            return local;
        }

        const watchUrl = `https://www.youtube.com/watch?v=${encodeURIComponent(videoId)}&hl=en&persist_hl=1`;
        const watchPage = await gmRequest({
            method: 'GET',
            url: watchUrl,
            headers: {
                Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
            }
        });

        const html = watchPage.responseText || '';
        return extractInitialDataFromText(html);
    }

    function readLocalInitialData(videoId) {
        const direct = unsafeWindow?.ytInitialData;
        if (isInitialDataForVideo(direct, videoId)) {
            return direct;
        }

        for (const script of Array.from(document.scripts)) {
            const extracted = extractInitialDataFromText(script.textContent || '');
            if (isInitialDataForVideo(extracted, videoId)) {
                return extracted;
            }
        }

        return null;
    }

    function extractInitialDataFromText(text) {
        const assignedJson = extractAssignedJson(text, 'ytInitialData');
        if (!assignedJson) {
            return null;
        }

        try {
            return JSON.parse(assignedJson);
        } catch (error) {
            debugLog('Failed to parse ytInitialData:', error);
            return null;
        }
    }

    function extractTranscriptParamsFromInitialData(initialData) {
        return findFirstDeepValue(initialData, value => {
            const params = value?.getTranscriptEndpoint?.params;
            return typeof params === 'string' && params ? params : '';
        });
    }

    function isInitialDataForVideo(initialData, videoId) {
        return Boolean(findFirstDeepValue(initialData, value => {
            const endpointVideoId = value?.watchEndpoint?.videoId;
            const playerVideoId = value?.videoId;

            return endpointVideoId === videoId || playerVideoId === videoId ? '1' : '';
        }));
    }

    function parseTranscriptFromGetTranscriptResponse(data) {
        const lines = [];

        walkDeep(data, value => {
            const segment = value?.transcriptSegmentRenderer;
            if (!segment) {
                return;
            }

            const text = getTextFromRuns(segment?.snippet);
            const cleaned = String(text || '').replace(/\s+/g, ' ').trim();
            if (cleaned && !/^\d{1,2}:\d{2}(?::\d{2})?$/.test(cleaned)) {
                lines.push(cleaned);
            }
        });

        return normalizeTranscript(lines.join('\n'));
    }

    async function getTranscriptFromDomPanel() {
        try {
            await openTranscriptPanelIfPossible();
            const transcript = await waitForDomTranscript(7000);
            if (isUsableTranscript(transcript)) {
                return transcript;
            }
        } catch (error) {
            debugLog('DOM transcript fallback failed:', error);
        }

        return '';
    }

    async function openTranscriptPanelIfPossible() {
        const existing = document.querySelector('ytd-transcript-segment-renderer');
        if (existing) {
            return;
        }

        if (await clickVisibleTranscriptControl()) {
            return;
        }

        await expandDescriptionIfPossible();

        if (await clickVisibleTranscriptControl()) {
            return;
        }

        const menuButton = findFirstVisibleElement([
            'ytd-menu-renderer yt-button-shape button[aria-label*="More actions"]',
            '#top-level-buttons-computed ytd-button-renderer button[aria-label*="More actions"]',
            '#actions ytd-menu-renderer button[aria-label*="More actions"]',
            'button[aria-label="More actions"]'
        ]);

        if (menuButton) {
            menuButton.click();
            await sleep(250);

            const transcriptItem = findFirstVisibleElement([
                'ytd-menu-service-item-renderer',
                'tp-yt-paper-item'
            ], element => /transcript/i.test((element.textContent || '').trim()));

            if (transcriptItem) {
                transcriptItem.click();
                await sleep(350);
            }
        }
    }

    async function clickVisibleTranscriptControl() {
        const transcriptButton = findFirstVisibleElement([
            'button',
            'tp-yt-paper-button',
            'ytd-button-renderer',
            'yt-button-shape'
        ], element => {
            if (element.id === CONFIG.buttonId || element.closest?.(`#${CONFIG.buttonId}`)) {
                return false;
            }

            const label = [
                element.getAttribute?.('aria-label') || '',
                element.getAttribute?.('title') || '',
                element.textContent || ''
            ].join(' ');

            return /(?:show\s+)?transcript/i.test(label);
        });

        if (!transcriptButton) {
            return false;
        }

        const clickable = transcriptButton.closest?.('button, tp-yt-paper-button, ytd-button-renderer, yt-button-shape') || transcriptButton;
        clickable.click();
        await sleep(900);
        return Boolean(getTranscriptPanelContainer());
    }

    async function expandDescriptionIfPossible() {
        const expandButton = findFirstVisibleElement([
            '#description-inline-expander #expand',
            'ytd-text-inline-expander #expand',
            '#description button[aria-label*="more" i]',
            '#description tp-yt-paper-button[aria-label*="more" i]',
            'tp-yt-paper-button#expand',
            'button#expand'
        ]);

        if (!expandButton) {
            return;
        }

        expandButton.click();
        await sleep(350);
    }

    async function waitForDomTranscript(timeoutMs) {
        const start = Date.now();

        while (Date.now() - start < timeoutMs) {
            const transcript = readDomTranscript();
            if (isUsableTranscript(transcript)) {
                return transcript;
            }

            await sleep(150);
        }

        return '';
    }

    function readDomTranscript() {
        const segmentSelectors = [
            'ytd-transcript-segment-renderer yt-formatted-string.segment-text',
            'ytd-transcript-segment-renderer #segment-text',
            'ytd-transcript-segment-renderer .segment-text',
            'ytd-transcript-segment-renderer [class*="segment-text"]',
            '[target-id="engagement-panel-searchable-transcript"] ytd-transcript-segment-renderer',
            '[target-id="engagement-panel-searchable-transcript"] [class*="segment-text"]'
        ];

        for (const selector of segmentSelectors) {
            const lines = Array.from(document.querySelectorAll(selector))
                .map(node => normalizeDomTranscriptLine(node.textContent))
                .filter(Boolean)
                .filter(line => !isTimestampOnly(line));

            const transcript = normalizeTranscript(lines.join('\n'));
            if (isUsableTranscript(transcript)) {
                return transcript;
            }
        }

        const panel = getTranscriptPanelContainer();
        if (!panel) {
            return '';
        }

        const lines = Array.from(panel.querySelectorAll('yt-formatted-string, span, div'))
            .map(node => normalizeDomTranscriptLine(node.textContent))
            .filter(Boolean)
            .filter(line => !isTimestampOnly(line))
            .filter(line => !/^(?:transcript|search|show transcript|hide transcript|close)$/i.test(line));

        return normalizeTranscript(dedupeConsecutiveLines(lines).join('\n'));
    }

    function getTranscriptPanelContainer() {
        return document.querySelector('[target-id="engagement-panel-searchable-transcript"]')
            || document.querySelector('ytd-engagement-panel-section-list-renderer ytd-transcript-renderer')
            || document.querySelector('ytd-transcript-renderer')
            || document.querySelector('ytd-transcript-search-panel-renderer');
    }

    function normalizeDomTranscriptLine(text) {
        return String(text || '').replace(/\s+/g, ' ').trim();
    }

    function isTimestampOnly(text) {
        return /^\d{1,2}:\d{2}(?::\d{2})?$/.test(String(text || '').trim());
    }

    function dedupeConsecutiveLines(lines) {
        const deduped = [];

        for (const line of lines) {
            if (line !== deduped[deduped.length - 1]) {
                deduped.push(line);
            }
        }

        return deduped;
    }

    function findFirstVisibleElement(selectors, predicate) {
        for (const selector of selectors) {
            const nodes = Array.from(document.querySelectorAll(selector));
            for (const node of nodes) {
                if (!isVisible(node)) {
                    continue;
                }

                if (predicate && !predicate(node)) {
                    continue;
                }

                return node;
            }
        }

        return null;
    }

    function findFirstDeepValue(root, predicate) {
        let result = '';

        walkDeep(root, value => {
            if (result) {
                return;
            }

            result = predicate(value) || '';
        });

        return result;
    }

    function walkDeep(root, visitor) {
        const stack = [root];
        const seen = new Set();

        while (stack.length > 0) {
            const value = stack.pop();
            if (!value || typeof value !== 'object' || seen.has(value)) {
                continue;
            }

            seen.add(value);
            visitor(value);

            if (Array.isArray(value)) {
                for (let index = value.length - 1; index >= 0; index -= 1) {
                    stack.push(value[index]);
                }
                continue;
            }

            for (const key of Object.keys(value)) {
                stack.push(value[key]);
            }
        }
    }

    function getTextFromRuns(textObject) {
        if (!textObject) {
            return '';
        }

        if (typeof textObject.simpleText === 'string') {
            return textObject.simpleText;
        }

        if (Array.isArray(textObject.runs)) {
            return textObject.runs.map(run => run?.text || '').join('');
        }

        return '';
    }

    function isVisible(element) {
        if (!element || !element.isConnected) {
            return false;
        }

        const rect = element.getBoundingClientRect();
        if (rect.width <= 0 || rect.height <= 0) {
            return false;
        }

        const style = window.getComputedStyle(element);
        return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
    }

    function sleep(ms) {
        return new Promise(resolve => window.setTimeout(resolve, ms));
    }

    function extractInnertubeApiKeyFromDocument() {
        const text = `${document.documentElement?.innerHTML || ''}`;
        const match = text.match(/"INNERTUBE_API_KEY":"([^"]+)"/);
        return match?.[1] || '';
    }

    function extractAssignedJson(text, variableName) {
        const variableIndex = text.indexOf(variableName);
        if (variableIndex === -1) {
            return '';
        }

        const firstBraceIndex = text.indexOf('{', variableIndex);
        if (firstBraceIndex === -1) {
            return '';
        }

        let depth = 0;
        let inString = false;
        let escaped = false;

        for (let index = firstBraceIndex; index < text.length; index += 1) {
            const char = text[index];

            if (escaped) {
                escaped = false;
                continue;
            }

            if (char === '\\') {
                escaped = true;
                continue;
            }

            if (char === '"') {
                inString = !inString;
                continue;
            }

            if (inString) {
                continue;
            }

            if (char === '{') {
                depth += 1;
            } else if (char === '}') {
                depth -= 1;

                if (depth === 0) {
                    return text.slice(firstBraceIndex, index + 1);
                }
            }
        }

        return '';
    }

    function sortCaptionTracks(tracks) {
        if (!Array.isArray(tracks)) {
            return [];
        }

        const preferredLanguage = (navigator.language || 'en').split('-')[0];

        return tracks.filter(track => track?.baseUrl).sort((a, b) => {
            return scoreTrack(b, preferredLanguage) - scoreTrack(a, preferredLanguage);
        });
    }

    function scoreTrack(track, preferredLanguage) {
        let score = 0;

        if (track.languageCode === preferredLanguage) {
            score += 100;
        }

        if (track.languageCode === 'en') {
            score += 90;
        }

        if (track.kind !== 'asr') {
            score += 20;
        }

        if (track.isTranslatable) {
            score += 5;
        }

        return score;
    }

    function buildTimedTextUrl(baseUrl, format) {
        const url = new URL(baseUrl);

        if (format) {
            url.searchParams.set('fmt', format);
        } else {
            url.searchParams.delete('fmt');
        }

        return url.toString();
    }

    async function requestTextWithFallback(url) {
        try {
            const response = await fetch(url, {
                method: 'GET',
                credentials: 'include'
            });

            if (response.ok) {
                return await response.text();
            }
        } catch (error) {
            debugLog('Native fetch failed, falling back to GM request:', error);
        }

        const response = await gmRequest({
            method: 'GET',
            url,
            headers: {
                Accept: 'text/plain,application/xml,text/xml,application/json,*/*'
            }
        });

        return response.responseText || '';
    }

    function parseTranscriptBody(body, format) {
        const trimmed = String(body || '').trim();

        if (!trimmed) {
            return '';
        }

        if (format === 'json3' || trimmed.startsWith('{')) {
            try {
                return parseJson3Transcript(trimmed);
            } catch (error) {
                debugLog('JSON3 parse failed:', error);
            }
        }

        if (format === 'vtt' || trimmed.startsWith('WEBVTT')) {
            return parseVttTranscript(trimmed);
        }

        return parseXmlTranscript(trimmed);
    }

    function parseJson3Transcript(jsonText) {
        const data = JSON.parse(jsonText);
        const lines = [];

        for (const event of data.events || []) {
            const text = (event.segs || [])
            .map(segment => segment.utf8 || '')
            .join('')
            .replace(/\s+/g, ' ')
            .trim();

            if (text) {
                lines.push(text);
            }
        }

        return normalizeTranscript(lines.join('\n'));
    }

    function parseXmlTranscript(xmlText) {
        const lines = [];
        const patterns = [
            /<text\b[^>]*>([\s\S]*?)<\/text>/g,
            /<p\b[^>]*>([\s\S]*?)<\/p>/g,
            /<s\b[^>]*>([\s\S]*?)<\/s>/g
        ];

        for (const pattern of patterns) {
            let match;

            while ((match = pattern.exec(xmlText)) !== null) {
                const withoutTags = match[1].replace(/<[^>]+>/g, '');
                const decodedText = decodeXmlEntities(withoutTags)
                .replace(/\s+/g, ' ')
                .trim();

                if (decodedText) {
                    lines.push(decodedText);
                }
            }

            if (lines.length > 0) {
                break;
            }
        }

        return normalizeTranscript(lines.join('\n'));
    }

    function parseVttTranscript(vttText) {
        const seen = new Set();

        const lines = String(vttText || '')
        .split('\n')
        .map(line => line.trim())
        .filter(line => {
            if (!line) {
                return false;
            }

            if (line === 'WEBVTT') {
                return false;
            }

            if (/^\d+$/.test(line)) {
                return false;
            }

            if (/^\d{2}:\d{2}:\d{2}\.\d{3}\s+-->\s+\d{2}:\d{2}:\d{2}\.\d{3}/.test(line)) {
                return false;
            }

            if (/^Kind:|^Language:|^NOTE/.test(line)) {
                return false;
            }

            return true;
        })
        .map(line => line.replace(/<[^>]+>/g, ''))
        .map(decodeXmlEntities)
        .map(line => line.replace(/\s+/g, ' ').trim())
        .filter(Boolean)
        .filter(line => {
            const key = line.toLowerCase();

            if (seen.has(key)) {
                return false;
            }

            seen.add(key);
            return true;
        });

        return normalizeTranscript(lines.join('\n'));
    }

    function decodeXmlEntities(value) {
        return String(value || '')
            .replace(/&#(\d+);/g, (_match, code) => String.fromCodePoint(Number(code)))
            .replace(/&#x([a-fA-F0-9]+);/g, (_match, code) => String.fromCodePoint(parseInt(code, 16)))
            .replace(/&quot;/g, '"')
            .replace(/&apos;/g, "'")
            .replace(/&#39;/g, "'")
            .replace(/&amp;/g, '&')
            .replace(/&lt;/g, '<')
            .replace(/&gt;/g, '>')
            .replace(/&nbsp;/g, ' ');
    }

    function unescapeJsonString(value) {
        return value
            .replace(/\\"/g, '"')
            .replace(/\\\\/g, '\\')
            .replace(/\\u0026/g, '&')
            .replace(/\\\//g, '/');
    }

    function getCurrentVideoId() {
        const url = new URL(location.href);

        const watchId = url.searchParams.get('v');
        if (watchId && /^[a-zA-Z0-9_-]{11}$/.test(watchId)) {
            return watchId;
        }

        const pathMatch = url.pathname.match(/\/(?:shorts|embed|live|v)\/([a-zA-Z0-9_-]{11})/);
        if (pathMatch) {
            return pathMatch[1];
        }

        throw new Error('No YouTube video ID found.');
    }

    function isVideoPage() {
        return location.href.includes('/watch') || location.href.includes('/shorts/');
    }

    function getControlContainer() {
        const selectors = [
            '.ytp-right-controls',
            '.ytp-chrome-controls .ytp-right-controls',
            '.html5-video-player .ytp-right-controls'
        ];

        for (const selector of selectors) {
            const candidates = Array.from(document.querySelectorAll(selector));
            if (candidates.length > 0) {
                return candidates[candidates.length - 1];
            }
        }

        return null;
    }

    function normalizeTranscript(text) {
        return String(text || '')
            .replace(/\r/g, '')
            .replace(/\u00a0/g, ' ')
            .replace(/[ \t]+\n/g, '\n')
            .replace(/\n{3,}/g, '\n\n')
            .replace(/[ \t]{2,}/g, ' ')
            .trim();
    }

    function isUsableTranscript(text) {
        return typeof text === 'string' && text.trim().length >= CONFIG.minTranscriptLength;
    }

    function gmRequest(options) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: options.method,
                url: options.url,
                headers: options.headers || {},
                data: options.data,
                timeout: 30000,
                onload: response => {
                    if (response.status >= 200 && response.status < 400) {
                        resolve(response);
                    } else {
                        reject(new Error(`Request failed: HTTP ${response.status}`));
                    }
                },
                ontimeout: () => reject(new Error('Request timed out.')),
                onerror: () => reject(new Error('Request failed.'))
            });
        });
    }

    function setButtonState(button, state) {
        button.dataset.state = state;

        while (button.firstChild) {
            button.removeChild(button.firstChild);
        }

        button.appendChild(createIconSvg(state));

        const labels = {
            idle: 'Copy transcript',
            loading: 'Copying transcript',
            success: 'Transcript copied',
            error: 'Transcript copy failed'
        };

        const label = labels[state] || labels.idle;

        button.title = label;
        button.setAttribute('aria-label', label);
        button.setAttribute('data-title-no-tooltip', label);
    }

    function createIconSvg(state) {
        const svg = createSvgElement('svg', {
            height: '100%',
            width: '100%',
            viewBox: '0 0 36 36',
            'aria-hidden': 'true',
            focusable: 'false'
        });

        if (state === 'loading') {
            svg.appendChild(createSvgElement('path', {
                class: 'yt-transcript-spinner',
                fill: '#fff',
                d: 'M18 8a10 10 0 1 0 9.7 12.4h-2.8A7.3 7.3 0 1 1 18 10.7V8z'
            }));

            return svg;
        }

        if (state === 'success') {
            appendShadowedPath(svg, 'M14.8 23.2 9.7 18.1l-1.9 1.9 7 7L29 12.8l-1.9-1.9z');
            return svg;
        }

        if (state === 'error') {
            appendShadowedPath(svg, 'M18 7a11 11 0 1 0 0 22A11 11 0 0 0 18 7zm-1.3 5h2.6v8h-2.6zm0 10h2.6v2.6h-2.6z');
            return svg;
        }

        appendShadowedPath(svg, 'M10 8h12.4L27 12.6V28H10V8zm11 2.8V14h3.2L21 10.8zM13 17h10v2H13v-2zm0 4h10v2H13v-2zm0-8h6v2h-6v-2z');
        return svg;
    }

    function appendShadowedPath(svg, pathData) {
        svg.appendChild(createSvgElement('path', {
            class: 'ytp-svg-shadow',
            fill: '#000',
            'fill-opacity': '0.35',
            d: pathData
        }));

        svg.appendChild(createSvgElement('path', {
            fill: '#fff',
            d: pathData
        }));
    }

    function createSvgElement(tagName, attributes) {
        const element = document.createElementNS('http://www.w3.org/2000/svg', tagName);

        for (const [name, value] of Object.entries(attributes)) {
            element.setAttribute(name, String(value));
        }

        return element;
    }

    function showToast(message) {
        const existing = document.getElementById(CONFIG.toastId);
        if (existing) {
            existing.remove();
        }

        const toast = document.createElement('div');
        toast.id = CONFIG.toastId;
        toast.textContent = message;

        document.body.appendChild(toast);

        window.setTimeout(() => {
            toast.remove();
        }, CONFIG.resetButtonMs);
    }

    function injectStyles() {
        if (document.getElementById(CONFIG.styleId)) {
            return;
        }

        const style = document.createElement('style');
        style.id = CONFIG.styleId;
        style.textContent = `
            #${CONFIG.buttonId}.ytp-button {
                display: inline-block !important;
                width: 48px !important;
                height: 100% !important;
                min-width: 48px !important;
                padding: 0 !important;
                margin: 0 !important;
                border: 0 !important;
                background: transparent !important;
                color: #ffffff !important;
                opacity: 0.9 !important;
                cursor: pointer !important;
                vertical-align: top !important;
                position: relative !important;
                z-index: 10 !important;
                pointer-events: auto !important;
            }

            #${CONFIG.buttonId}.ytp-button:hover {
                opacity: 1 !important;
            }

            #${CONFIG.buttonId}.ytp-button svg {
                display: block !important;
                width: 100% !important;
                height: 100% !important;
                pointer-events: none !important;
            }

            .yt-transcript-spinner {
                transform-origin: 18px 18px;
                animation: ytTranscriptSpin 0.85s linear infinite;
            }

            @keyframes ytTranscriptSpin {
                from {
                    transform: rotate(0deg);
                }

                to {
                    transform: rotate(360deg);
                }
            }

            #${CONFIG.toastId} {
                position: fixed !important;
                right: 16px !important;
                bottom: 72px !important;
                z-index: 2147483647 !important;
                padding: 10px 12px !important;
                border-radius: 8px !important;
                background: rgba(0, 0, 0, 0.88) !important;
                color: #ffffff !important;
                font: 13px Arial, sans-serif !important;
                pointer-events: none !important;
            }
        `;

        document.documentElement.appendChild(style);
    }

    function cleanTranscriptForClipboard(transcript) {
        return normalizeTranscript(
            removeRepeatedCaptionPhrases(
                removeCaptionNoise(
                    decodeHtmlEntities(transcript)
                )
            )
        );
    }

    function decodeHtmlEntities(text) {
        return String(text || '')
            .replace(/&#(\d+);/g, (_match, code) => String.fromCodePoint(Number(code)))
            .replace(/&#x([a-fA-F0-9]+);/g, (_match, code) => String.fromCodePoint(parseInt(code, 16)))
            .replace(/&quot;/g, '"')
            .replace(/&apos;/g, "'")
            .replace(/&#39;/g, "'")
            .replace(/&amp;/g, '&')
            .replace(/&lt;/g, '<')
            .replace(/&gt;/g, '>')
            .replace(/&nbsp;/g, ' ');
    }

    function removeCaptionNoise(text) {
        return String(text || '')
            .replace(/^\s*>>\s*/gm, '')
            .replace(/\[(?:music|laughter|laughs|applause|crying|sighs?|inaudible|silence)\]/gi, '')
            .replace(/\(\s*(?:music|laughter|laughs|applause|crying|sighs?|inaudible|silence)\s*\)/gi, '')
            .replace(/\[ __ \]/g, '[expletive]')
            .replace(/\s+([,.!?])/g, '$1')
            .replace(/([,.!?]){2,}/g, '$1')
            .replace(/[ \t]{2,}/g, ' ')
            .replace(/\n[ \t]+/g, '\n')
            .trim();
    }

    function removeRepeatedCaptionPhrases(text) {
        return String(text || '')
            .split('\n')
            .map(removeImmediateRepeatedWords)
            .map(removeImmediateRepeatedShortPhrases)
            .join('\n');
    }

    function removeImmediateRepeatedWords(line) {
        let cleaned = String(line || '');

        for (let index = 0; index < 3; index += 1) {
            cleaned = cleaned.replace(/\b(\w+)(?:\s+\1\b)+/gi, '$1');
        }

        return cleaned;
    }

    function removeImmediateRepeatedShortPhrases(line) {
        let cleaned = String(line || '');

        for (let index = 0; index < 3; index += 1) {
            cleaned = cleaned.replace(
                /\b((?:[\w'"-]+[,.!?]?\s+){1,4})(?:\1)+/gi,
                '$1'
            );
        }

        return cleaned
            .replace(/[ \t]{2,}/g, ' ')
            .trim();
    }

    function debugLog(...args) {
        if (CONFIG.debug) {
            console.debug('[Transcript Copier]', ...args);
        }

    }
})();