Greasy Fork is available in English.

YouTube Transcript Open + Copy

自动打开 YouTube 转写文稿,并提供一键复制按钮

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

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.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         YouTube Transcript Open + Copy
// @namespace    local
// @version      1.9.0
// @description  自动打开 YouTube 转写文稿,并提供一键复制按钮
// @match        https://www.youtube.com/watch*
// @run-at       document-idle
// @grant        GM_setClipboard
// @license MIT
// @noframes
// ==/UserScript==

(() => {
  'use strict';
  if (window.top !== window.self) return;

  const COPY_BTN_ID = 'tm-copy-transcript-btn';
  const KEEP_TIMESTAMPS = true;
  const AUTO_SCROLL_PANEL = true;
  const DEBUG = false;

  let lastUrl = location.href;
  let openedForUrl = false;

  const sleep = ms => new Promise(r => setTimeout(r, ms));
  const log = (...args) => DEBUG && console.debug('[YT Transcript]', ...args);

  const clean = s => (s || '').replace(/\u00A0/g, ' ').replace(/\s+/g, ' ').trim();
  const textOf = el => clean(
    `${el?.innerText || ''} ${el?.textContent || ''} ${el?.getAttribute?.('aria-label') || ''} ${el?.getAttribute?.('title') || ''}`
  ).toLowerCase();

  const isVisible = el => {
    if (!el) return false;
    const s = getComputedStyle(el);
    return s.display !== 'none' && s.visibility !== 'hidden' && el.offsetParent !== null;
  };

  const inPlayer = el => !!el?.closest?.(
    '#movie_player, .html5-video-player, .ytp-chrome-controls, .ytp-popup, .ytp-overflow-panel'
  );

  const isExpandBtn = el => !!el?.closest?.('ytd-text-inline-expander') || el?.id === 'expand';

  const q = (sel, root = document) => root.querySelector(sel);
  const qa = (sel, root = document) => [...root.querySelectorAll(sel)];

  const firstVisible = (selectors, root = document) => {
    for (const sel of selectors) {
      const el = q(sel, root);
      if (el && isVisible(el) && !inPlayer(el)) return el;
    }
    return null;
  };

  const firstByText = (selectors, patterns, root = document, extraFilter = () => true) => {
    for (const el of qa(selectors, root)) {
      if (!isVisible(el) || inPlayer(el) || !extraFilter(el)) continue;
      const s = textOf(el);
      if (patterns.some(p => s.includes(p))) return el;
    }
    return null;
  };

  const transcriptPanel = () => firstVisible([
    'ytd-engagement-panel-section-list-renderer[target-id="PAmodern_transcript_view"]',
    'ytd-engagement-panel-section-list-renderer[target-id*="PAmodern_transcript"]',
    'ytd-engagement-panel-section-list-renderer[target-id*="searchable-transcript"]',
    'ytd-engagement-panel-section-list-renderer[target-id*="transcript"]',
    'ytd-transcript-search-panel-renderer'
  ]);

  const findExpandButton = () => firstVisible([
    'ytd-watch-metadata ytd-text-inline-expander tp-yt-paper-button#expand',
    'ytd-watch-metadata ytd-text-inline-expander #expand',
    'ytd-text-inline-expander tp-yt-paper-button#expand',
    'ytd-text-inline-expander #expand'
  ]);

  const findDirectTranscriptButton = () =>
    firstVisible([
      'ytd-watch-metadata button[aria-label="内容转文字"]',
      'ytd-watch-metadata button[aria-label="內容轉文字"]',
      'ytd-watch-metadata button[aria-label="Show transcript"]',
      'ytd-watch-metadata button[aria-label="显示文稿"]',
      'ytd-watch-metadata button[aria-label="顯示文稿"]',
      'button[aria-label="内容转文字"]',
      'button[aria-label="內容轉文字"]',
      'button[aria-label="Show transcript"]',
      'button[aria-label="显示文稿"]',
      'button[aria-label="顯示文稿"]'
    ]) || firstByText(
      'ytd-watch-metadata button, ytd-watch-metadata [role="button"], ytd-watch-metadata yt-button-shape, ytd-watch-metadata yt-button-view-model',
      ['内容转文字', '內容轉文字', 'show transcript', '显示文稿', '顯示文稿', '查看文稿', '转写文稿'],
      document,
      el => !isExpandBtn(el)
    );

  const findMoreActionsButton = () => {
    const roots = [
      q('ytd-watch-metadata #top-level-buttons-computed'),
      q('ytd-watch-metadata #actions-inner'),
      q('ytd-watch-metadata ytd-menu-renderer'),
      q('ytd-watch-metadata')
    ].filter(Boolean);

    for (const root of roots) {
      const el = firstByText(
        'button,[role="button"],tp-yt-paper-button,yt-button-shape,yt-button-view-model',
        ['more actions', '更多操作'],
        root,
        btn => btn.closest('ytd-watch-metadata') && !isExpandBtn(btn)
      );
      if (el) return el.closest('button,[role="button"],tp-yt-paper-button,yt-button-shape,yt-button-view-model') || el;
    }
    return null;
  };

  const visibleMenus = () =>
    qa('tp-yt-paper-listbox,[role="menu"],ytd-menu-popup-renderer,tp-yt-iron-dropdown')
      .filter(el => isVisible(el) && !inPlayer(el));

  const findTranscriptEntry = () => {
    const selectors = 'ytd-menu-service-item-renderer,tp-yt-paper-item,[role="menuitem"],button,[role="button"],yt-formatted-string';
    const exact = ['show transcript', '显示文稿', '顯示文稿', '查看文稿', '转写文稿', '内容转文字', '內容轉文字'];
    const fallback = ['transcript', '文稿'];

    for (const menu of visibleMenus()) {
      const el = firstByText(selectors, exact, menu);
      if (el) return el;
    }
    for (const menu of visibleMenus()) {
      const el = firstByText(selectors, fallback, menu);
      if (el) return el;
    }
    return null;
  };

  async function expandDescriptionIfNeeded() {
    const btn = findExpandButton();
    if (!btn) return false;
    const s = textOf(btn);
    if (!s.includes('更多') && !s.includes('more')) return false;
    log('expand description', btn);
    btn.click();
    await sleep(400);
    return true;
  }

  async function clickDirectTranscriptButton() {
    const btn = findDirectTranscriptButton();
    if (!btn) return false;
    log('click direct transcript', btn);
    btn.click();
    await sleep(700);
    return !!transcriptPanel();
  }

  async function openTranscript() {
    if (transcriptPanel()) return true;
    if (await clickDirectTranscriptButton()) return true;
    await expandDescriptionIfNeeded();
    if (await clickDirectTranscriptButton()) return true;

    const moreBtn = findMoreActionsButton();
    if (!moreBtn) return false;
    if (!['more actions', '更多操作'].some(x => textOf(moreBtn).includes(x))) return false;

    log('click more actions', moreBtn);
    moreBtn.click();

    for (let i = 0; i < 12; i++) {
      await sleep(250);
      const item = findTranscriptEntry();
      if (!item) continue;
      const clickable = item.closest('[role="menuitem"],tp-yt-paper-item,button,[role="button"],ytd-menu-service-item-renderer') || item;
      log('click transcript entry', clickable);
      clickable.click();
      await sleep(700);
      if (transcriptPanel()) return true;
    }
    return !!transcriptPanel();
  }

  const getScroller = root =>
    [
      q('yt-section-list-renderer', root),
      q('.ytSectionListRendererContents', root),
      q('#content', root),
      q('#contents', root),
      q('#segments-container', root),
      q('#body', root),
      root
    ].find(el => el && el.scrollHeight > el.clientHeight + 20) || root;

  async function scrollPanelFully(panel) {
    if (!panel) return;
    const scroller = getScroller(panel);
    let stable = 0, lastH = -1, lastCount = -1;

    for (let i = 0; i < 50; i++) {
      const count = qa('transcript-segment-view-model.ytwTranscriptSegmentViewModelHost,ytd-transcript-segment-renderer', panel).length;
      scroller.scrollTop = scroller.scrollHeight;
      await sleep(250);

      const h = scroller.scrollHeight;
      stable = (h === lastH && count === lastCount) ? stable + 1 : 0;
      lastH = h;
      lastCount = count;
      if (stable >= 4) break;
    }
    scroller.scrollTop = 0;
  }

  const extractTimestamp = raw => (clean(raw).match(/\b(\d{1,2}:\d{2}(?::\d{2})?)\b/) || [])[1] || '';

  const normalizeLine = line => clean(line)
    .replace(/^\[\d{1,2}:\d{2}(?::\d{2})?\]\s*/, '')
    .replace(/^\d{1,2}:\d{2}(?::\d{2})?\s*/, '')
    .trim();

  const isNoise = s => {
    s = clean(s).toLowerCase();
    return !s || new Set([
      'transcript', 'show transcript', 'search in transcript', 'toggle timestamps',
      'hide timestamps', 'show timestamps', 'more', 'more actions',
      '内容转文字', '內容轉文字', '显示文稿', '顯示文稿', '查看文稿', '文稿',
      '文字记录', '转写文稿', '搜索转写内容', '搜索文稿', '在文稿中搜索', '搜尋文稿',
      '与视频时间同步', '隐藏时间戳', '顯示時間戳', '关闭'
    ]).has(s);
  };

  function dedupeLines(lines) {
    const seen = new Set();
    const out = [];
    for (const line of lines) {
      const key = normalizeLine(line);
      if (!key || isNoise(key) || seen.has(key)) continue;
      seen.add(key);
      out.push(line);
    }
    return out;
  }

  function getTranscriptLines() {
    const panel = transcriptPanel();
    if (!panel) return [];

    const modern = qa('transcript-segment-view-model.ytwTranscriptSegmentViewModelHost', panel);
    if (modern.length) {
      return dedupeLines(modern.map(seg => {
        const time = extractTimestamp(q('.ytwTranscriptSegmentViewModelTimestamp', seg)?.textContent || '');
        const text = clean(q('span.ytAttributedStringHost[role="text"]', seg)?.textContent || '');
        if (!text || isNoise(text)) return null;
        return KEEP_TIMESTAMPS && time ? `[${time}] ${text}` : text;
      }).filter(Boolean));
    }

    const legacy = qa('ytd-transcript-segment-renderer', panel);
    if (legacy.length) {
      return dedupeLines(legacy.map(seg => {
        const time = extractTimestamp(
          q('.segment-timestamp', seg)?.textContent ||
          q('#timestamp', seg)?.textContent ||
          q('yt-formatted-string.segment-timestamp', seg)?.textContent || ''
        );
        const text = clean(
          q('.segment-text', seg)?.textContent ||
          q('#segment-text', seg)?.textContent ||
          q('yt-formatted-string.segment-text', seg)?.textContent || ''
        );
        if (!text || isNoise(text)) return null;
        return KEEP_TIMESTAMPS && time ? `[${time}] ${text}` : text;
      }).filter(Boolean));
    }

    return [];
  }

  async function waitTranscriptLines(timeout = 10000) {
    const start = Date.now();
    while (Date.now() - start < timeout) {
      const lines = getTranscriptLines();
      if (lines.length) return lines;
      await sleep(300);
    }
    return [];
  }

  async function copyTranscript() {
    const btn = q(`#${COPY_BTN_ID}`);
    if (btn) {
      btn.textContent = '复制中...';
      btn.disabled = true;
    }

    let panel = transcriptPanel();
    if (!panel) {
      await openTranscript();
      panel = transcriptPanel();
    }

    if (panel && AUTO_SCROLL_PANEL) {
      await scrollPanelFully(panel);
      await sleep(500);
    }

    let lines = getTranscriptLines();
    if (!lines.length) lines = await waitTranscriptLines();

    if (!lines.length) {
      if (btn) {
        btn.textContent = '未找到文稿';
        setTimeout(() => {
          btn.textContent = '复制文稿';
          btn.disabled = false;
        }, 1200);
      }
      return;
    }

    const text = lines.join('\n');
    if (typeof GM_setClipboard === 'function') {
      GM_setClipboard(text, 'text');
    } else {
      await navigator.clipboard.writeText(text);
    }

    if (btn) {
      btn.textContent = '已复制';
      setTimeout(() => {
        btn.textContent = '复制文稿';
        btn.disabled = false;
      }, 1200);
    }
  }

  function ensureCopyButton() {
    let btn = q(`#${COPY_BTN_ID}`);
    if (btn) return btn;

    btn = document.createElement('button');
    btn.id = COPY_BTN_ID;
    btn.textContent = '复制文稿';
    Object.assign(btn.style, {
      position: 'fixed',
      right: '16px',
      bottom: '16px',
      zIndex: '999999',
      padding: '10px 14px',
      border: 'none',
      borderRadius: '999px',
      background: '#0f0f0f',
      color: '#fff',
      fontSize: '14px',
      cursor: 'pointer',
      boxShadow: '0 2px 8px rgba(0,0,0,.25)'
    });
    btn.addEventListener('click', copyTranscript);
    document.body.appendChild(btn);
    return btn;
  }

  async function runOpenFlow() {
    if (!location.href.includes('/watch') || openedForUrl) return;
    openedForUrl = true;
    ensureCopyButton();
    await sleep(1200);
    if (!transcriptPanel()) await openTranscript();
  }

  function resetAndRun() {
    if (location.href !== lastUrl) {
      lastUrl = location.href;
      openedForUrl = false;
    }
    ensureCopyButton();
    runOpenFlow();
  }

  window.addEventListener('yt-navigate-finish', resetAndRun);
  window.addEventListener('load', resetAndRun);
  resetAndRun();
})();