YouTube Transcript Open + Copy

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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