YouTube Transcript Copier

Adds a styled button to copy the YouTube video transcript, with a timestamp toggle

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         YouTube Transcript Copier
// @match        https://www.youtube.com/watch*
// @license      MIT
// @grant        none
// @version      1.0
// @author       Amir Tehrani
// @description  Adds a styled button to copy the YouTube video transcript, with a timestamp toggle
// @namespace    https://greasyfork.org/
// @icon         https://www.google.com/s2/favicons?domain=youtube.com
// ==/UserScript==

(function() {
    'use strict';

    let observer = null;
    let currentURL = window.location.href;
    let insertionAttempts = 0;
    const maxAttempts = 20;
    let retryInterval = null;
    let includeTimestamps = false; // Default: no timestamps
    let copyButton = null;
    let buttonTextNode = null;
    let transcriptPanelTimeout = null; // Timeout for panel loading
    let transcriptButtonTimeout = null; // Timeout for the "Show transcript" button

    function createTranscriptButton() {
        if (document.getElementById('show-transcript-button')) {
            return true;
        }

        copyButton = document.createElement('button');
        copyButton.id = 'show-transcript-button';
        copyButton.classList.add('yt-transcript-button');
        copyButton.setAttribute('aria-label', 'Copy Transcript'); // Accessibility

        buttonTextNode = document.createTextNode('Copy Transcript');
        copyButton.appendChild(buttonTextNode);

        const timestampSpan = document.createElement('span');
        timestampSpan.id = 'timestamp-toggle';
        timestampSpan.textContent = ' (No Time)';
        timestampSpan.style.cssText = `
            font-size: 0.75em;
            margin-left: 6px;
            color: rgba(255, 255, 255, 0.7);
            cursor: pointer;
            user-select: none;
            border: 1px solid rgba(255, 255, 255, 0.3);
            border-radius: 12px;
            padding: 3px 6px;
            display: inline-block;
            vertical-align: middle;
            transition: color 0.2s ease, border-color 0.2s ease, background-color 0.2s ease;
            background-color: rgba(0, 0, 0, 0.1);
        `;

        timestampSpan.addEventListener('mouseover', function() {
            this.style.borderColor = 'rgba(255, 255, 255, 0.9)';
            this.style.backgroundColor = 'rgba(0, 0, 0, 0.2)';
        });
        timestampSpan.addEventListener('mouseout', function() {
            this.style.borderColor = includeTimestamps ? 'white' : 'rgba(255, 255, 255, 0.3)';
            this.style.backgroundColor = includeTimestamps ? 'rgba(0,0,0, 0.4)' : 'rgba(0, 0, 0, 0.1)';
        });

        copyButton.appendChild(timestampSpan);

        timestampSpan.addEventListener('click', function(event) {
            event.stopPropagation(); // Prevent main button click
            includeTimestamps = !includeTimestamps;
            this.textContent = includeTimestamps ? ' (Time)' : ' (No Time)';
            this.style.color = includeTimestamps ? 'white' : 'rgba(255, 255, 255, 0.7)';
            this.style.borderColor = includeTimestamps ? 'white' : 'rgba(255, 255, 255, 0.3)';
            this.style.backgroundColor = includeTimestamps ? 'rgba(0,0,0, 0.4)' : 'rgba(0, 0, 0, 0.1)';
        });

        copyButton.addEventListener('click', handleCopyClick);

        function insertButton() {
            const potentialTargets = [
                '#description #top-level-buttons-computed',
                '#top-row.ytd-watch-metadata',
                '#above-the-fold',
                '#meta-contents',
                '#primary-inner'
            ];

            for (const targetSelector of potentialTargets) {
                const targetElement = document.querySelector(targetSelector);
                if (targetElement) {
                    targetElement.parentNode.insertBefore(copyButton, targetElement.nextSibling);
                    injectStyles();
                    return true;
                }
            }
            return false;
        }

        return insertButton();
    }

    function handleCopyClick() {
        updateButtonText('Copy Transcript');

        const moreActionsButton = document.querySelector('button[aria-label="More actions"]');
        if (moreActionsButton) {
            moreActionsButton.click();
        }

        const buttonIntervalId = setInterval(() => {
            const transcriptButton = document.querySelector('[aria-label="Show transcript"]');
            if (transcriptButton) {
                transcriptButton.click();
                clearInterval(buttonIntervalId);
                clearTimeout(transcriptButtonTimeout);

                const panelIntervalId = setInterval(() => {
                    const transcriptPanel = document.querySelector('ytd-engagement-panel-section-list-renderer[target-id="engagement-panel-searchable-transcript"] #content');
                    if (transcriptPanel && transcriptPanel.querySelector('ytd-transcript-segment-renderer')) {
                        clearInterval(panelIntervalId);
                        clearTimeout(transcriptPanelTimeout);
                        copyTranscriptText(transcriptPanel);
                    }
                }, 100);

                transcriptPanelTimeout = setTimeout(() => {
                    clearInterval(panelIntervalId);
                    console.error("Transcript panel or segments not found after timeout.");
                    if (copyButton) {
                        updateButtonText("Transcript Not Found");
                        copyButton.style.backgroundColor = "rgba(220, 53, 69, 0.8)";
                    }
                }, 15000);
            }
        }, 250);

        transcriptButtonTimeout = setTimeout(() => {
            clearInterval(buttonIntervalId);
            if (copyButton) {
                updateButtonText("Transcript Not Found")
                copyButton.style.backgroundColor = "rgba(220, 53, 69, 0.8)";
            }
            console.error("Transcript button not found after timeout.");
        }, 10000);
    }

    function copyTranscriptText(transcriptPanel) {
        if (!transcriptPanel) {
            console.error("Transcript container not found.");
            updateButtonText("Error");
            return;
        }

        let transcriptText = "";

        if (includeTimestamps) {
            transcriptPanel.querySelectorAll('ytd-transcript-segment-renderer').forEach(line => {
                const timestampElement = line.querySelector('.segment-timestamp');
                const textElement = line.querySelector('.segment-text');
                if (timestampElement && textElement) {
                    transcriptText += timestampElement.textContent.trim() + " " + textElement.textContent.trim() + "\n";
                }
            });
        } else {
            transcriptPanel.querySelectorAll('.segment-text').forEach(segment => {
                transcriptText += segment.textContent.trim() + " ";
            });
        }

        navigator.clipboard.writeText(transcriptText)
            .then(() => {
              updateButtonText("Copied!");
            })
            .catch(err => {
                console.error('Failed to copy transcript:', err);
                if (copyButton) {
                    updateButtonText("Copy Failed");
                    copyButton.style.backgroundColor = "rgba(220, 53, 69, 0.8)";
                }
            });
    }

    function updateButtonText(text) {
        if (copyButton && buttonTextNode) {
            buttonTextNode.textContent = text;
            if (text === "Copied!") {
                copyButton.style.backgroundColor = "rgba(40, 167, 69, 0.9)";
                  setTimeout(() => {
                        buttonTextNode.textContent = 'Copy Transcript';
                         copyButton.style.backgroundColor = 'rgba(0, 123, 255, 0.8)';

                         const timestampToggle = document.getElementById('timestamp-toggle');
                         timestampToggle.textContent = includeTimestamps ? ' (Time)' : ' (No Time)';
                         timestampToggle.style.color = includeTimestamps ? 'white' : 'rgba(255, 255, 255, 0.7)';
                         timestampToggle.style.borderColor = includeTimestamps ? 'white' : 'rgba(255, 255, 255, 0.3)';
                         timestampToggle.style.backgroundColor = includeTimestamps ? 'rgba(0,0,0, 0.4)' : 'rgba(0, 0, 0, 0.1)';

                    }, 1500);
            } else if (text.startsWith("Error") || text === "Copy Failed" || text === "Transcript Not Found") {
                 copyButton.style.backgroundColor = "rgba(220, 53, 69, 0.8)";
            }
        }
    }

    function injectStyles() {
        if (document.getElementById('yt-transcript-button-styles')) return;

        const style = document.createElement('style');
        style.id = 'yt-transcript-button-styles';
        style.textContent = `
            .yt-transcript-button {
                background-color: rgba(0, 123, 255, 0.8);
                border: none;
                color: white;
                padding: 10px 18px;
                text-align: center;
                text-decoration: none;
                display: inline-flex;
                align-items: center;
                font-size: 15px;
                margin: 4px 2px;
                cursor: pointer;
                border-radius: 24px;
                transition: all 0.2s ease;
                box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
                font-family: 'Roboto', sans-serif;
                font-weight: 500;
                position: relative;
                overflow: hidden;
                will-change: transform, box-shadow, background-color;
            }

            .yt-transcript-button:hover {
                background-color: rgba(0, 90, 180, 0.9);
                box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
                transform: translateY(-1px);
            }

            .yt-transcript-button:focus {
                outline: none;
                box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.3);
            }

            .yt-transcript-button:active {
                background-color: rgba(0, 60, 120, 0.9);
                box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
                transform: translateY(1px);
            }

            .yt-transcript-button::before {
                content: '';
                position: absolute;
                top: 0;
                left: 0;
                width: 100%;
                height: 100%;
                background: radial-gradient(circle at 0 0, rgba(255, 255, 255, 0.4) 10%, transparent 10.01%);
                background-size: 0 0;
                opacity: 0;
                pointer-events: none;
                transition: background-size 0.4s ease, opacity 0.4s ease, background-position 0.4s ease;
            }

            .yt-transcript-button:active::before {
                background-size: 200% 200%;
                opacity: 1;
                transition: background-size 0.4s ease, opacity 0.4s ease;
            }
        `;
        document.head.appendChild(style);

        copyButton.addEventListener('mousedown', function(event) {
            const rect = copyButton.getBoundingClientRect();
            const x = event.clientX - rect.left;
            const y = event.clientY - rect.top;

            copyButton.style.setProperty('--ripple-x', x + 'px');
            copyButton.style.setProperty('--ripple-y', y + 'px');
            copyButton.style.background = `radial-gradient(circle at var(--ripple-x) var(--ripple-y), rgba(255, 255, 255, 0.4) 10%, transparent 10.01%)`;
        });

        copyButton.addEventListener('mouseup', function(event){
            copyButton.style.background = null;
        });

        copyButton.addEventListener("mouseleave", function(event) {
            copyButton.style.background = null;
        });
    }

    function attemptButtonCreation() {
        insertionAttempts++;
        if (createTranscriptButton() || insertionAttempts >= maxAttempts) {
            clearInterval(retryInterval);
            retryInterval = null;
            if (insertionAttempts >= maxAttempts) {
                console.error('Could not insert the button after multiple attempts.');
            }
        }
    }

    function setupObserver() {
        if (observer) observer.disconnect();
        observer = new MutationObserver(handleMutations);
        observer.observe(document.body, { childList: true, subtree: true });
    }

    function handleMutations(mutations) {
        if (window.location.href !== currentURL) {
            currentURL = window.location.href;
            resetState();
            startProcess();
        }
    }

    function resetState() {
        if (document.getElementById('show-transcript-button')) {
            document.getElementById('show-transcript-button').remove();
        }
        insertionAttempts = 0;
        if (retryInterval) {
            clearInterval(retryInterval);
            retryInterval = null;
        }
        clearTimeout(transcriptPanelTimeout);
        clearTimeout(transcriptButtonTimeout);
        transcriptPanelTimeout = null;
        transcriptButtonTimeout = null;

        if (observer) {
          observer.disconnect();
          observer = null;
        }

        copyButton = null;
        buttonTextNode = null;
    }

    function startProcess() {
        if (!createTranscriptButton()) {
            retryInterval = setInterval(attemptButtonCreation, 500);
        }
        setupObserver();
    }

    startProcess();
})();