YouTube Transcript Extractor

Extracts and copies YouTube video transcripts to clipboard

您需要先安裝使用者腳本管理器擴展,如 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 Extractor
// @version 2.0.1
// @license MIT
// @description Extracts and copies YouTube video transcripts to clipboard
// @match https://www.youtube.com/watch*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// @run-at document-end
// @connect api.supadata.ai
// @connect www.youtube-transcript.io
// @namespace https://greasyfork.org/users/1458847
// ==/UserScript==

'use strict';

const BUTTON_ID = 'yt-transcript-btn';
const DROPDOWN_ID = 'yt-transcript-dropdown';
const MODAL_ID = 'yt-transcript-modal';

const PROVIDERS = {
  supadata: {
    key: 'transcript_token_supadata',
    label: 'Supadata',
    url: 'https://supadata.ai',
    limit: '100/month',
  },
  ytio: {
    key: 'transcript_token_ytio',
    label: 'youtube-transcript.io',
    url: 'https://youtube-transcript.io',
    limit: '25/month',
  },
};

/** Cache: { [videoId]: { [langCode]: transcriptText } } */
const transcriptCache = {};

// ---------------------------------------------------------------------------
// DOM helper
// ---------------------------------------------------------------------------

function createElement(tag, props = {}, ...children) {
  const element = document.createElement(tag);
  const { dataset, style, ...restProps } = props || {};

  Object.assign(element, restProps);

  if (typeof style === 'string') {
    element.style.cssText = style;
  } else if (typeof style === 'object' && style !== null) {
    Object.assign(element.style, style);
  }

  if (dataset && typeof dataset === 'object') {
    Object.entries(dataset).forEach(([key, value]) => {
      if (value != null) element.dataset[key] = String(value);
    });
  }

  children.forEach((child) => child && element.append(child));
  return element;
}

// ---------------------------------------------------------------------------
// GM menu
// ---------------------------------------------------------------------------

GM_registerMenuCommand('Set API Token', () => openTokenModal());

// ---------------------------------------------------------------------------
// Stylesheet
// ---------------------------------------------------------------------------

document.head.appendChild(
  createElement('style', {
    textContent: `
      @keyframes yt-tr-fadeIn {
        from { opacity: 0; transform: translateY(-5px); }
        to   { opacity: 1; transform: translateY(0); }
      }
      #${MODAL_ID} * { box-sizing: border-box; }
    `,
  })
);

// ---------------------------------------------------------------------------
// Token modal
// ---------------------------------------------------------------------------

function openTokenModal() {
  document.getElementById(MODAL_ID)?.remove();

  const isDark =
    document.documentElement.hasAttribute('dark') ||
    window.matchMedia('(prefers-color-scheme: dark)').matches;

  const bg = isDark ? '#282828' : '#fff';
  const fg = isDark ? '#fff' : '#0f0f0f';
  const border = isDark ? '#444' : '#ddd';
  const inputBg = isDark ? '#1f1f1f' : '#f9f9f9';
  const hintColor = '#aaa';

  const currentProvider = GM_getValue('transcript_selected_provider', 'supadata');

  function buildProviderTab(id, p) {
    const isSelected = id === currentProvider;
    return createElement('button', {
      dataset: { providerId: id },
      style: `
        flex:1; padding:7px 10px; border-radius:8px; cursor:pointer; font-size:13px;
        font-weight:500; transition:all 0.15s; outline:none;
        border:1px solid ${isSelected ? '#3ea6ff' : border};
        background:${isSelected ? '#3ea6ff22' : 'transparent'};
        color:${isSelected ? '#3ea6ff' : fg};
      `,
      textContent: p.label,
      onclick: () => selectProvider(id),
    });
  }

  const selectorRow = createElement(
    'div',
    { style: 'display:flex; gap:8px; margin-bottom:18px;' },
    ...Object.entries(PROVIDERS).map(([id, p]) => buildProviderTab(id, p))
  );

  function buildTokenInput(providerId) {
    const p = PROVIDERS[providerId];
    return createElement(
      'div',
      { style: 'margin-bottom:18px;' },
      createElement(
        'div',
        { style: 'display:flex; align-items:baseline; gap:8px; margin-bottom:6px;' },
        createElement('span', {
          style: 'font-size:14px; font-weight:500;',
          textContent: p.label,
        }),
        createElement('a', {
          href: p.url,
          target: '_blank',
          style: 'font-size:11px; color:#3ea6ff; text-decoration:none;',
          textContent: p.url,
        }),
        createElement('span', {
          style: `font-size:11px; color:${hintColor}; margin-left:auto;`,
          textContent: p.limit,
        })
      ),
      createElement('input', {
        dataset: { provider: providerId },
        type: 'text',
        placeholder: `Paste your ${p.label} API token`,
        value: GM_getValue(p.key, ''),
        style: `
          width:100%; padding:8px 10px; border:1px solid ${border};
          border-radius:8px; background:${inputBg}; color:${fg};
          font-size:13px; outline:none;
        `,
      })
    );
  }

  const inputArea = createElement('div');
  inputArea.appendChild(buildTokenInput(currentProvider));

  function selectProvider(providerId) {
    GM_setValue('transcript_selected_provider', providerId);

    inputArea.replaceChildren(buildTokenInput(providerId));

    selectorRow.querySelectorAll('button').forEach((tab) => {
      const isSelected = tab.dataset.providerId === providerId;
      tab.style.border = `1px solid ${isSelected ? '#3ea6ff' : border}`;
      tab.style.background = isSelected ? '#3ea6ff22' : 'transparent';
      tab.style.color = isSelected ? '#3ea6ff' : fg;
    });
  }

  const cancelBtn = createElement('button', {
    textContent: 'Cancel',
    style: `
      padding:8px 18px; border:1px solid ${border}; border-radius:8px;
      background:transparent; color:${fg}; cursor:pointer; font-size:13px;
    `,
  });

  const saveBtn = createElement('button', {
    textContent: 'Save',
    style: `
      padding:8px 18px; border:none; border-radius:8px;
      background:#3ea6ff; color:#fff; cursor:pointer; font-size:13px; font-weight:600;
    `,
  });

  const actions = createElement(
    'div',
    { style: 'display:flex; justify-content:flex-end; gap:10px;' },
    cancelBtn,
    saveBtn
  );

  const modal = createElement(
    'div',
    {
      style: `
        background:${bg}; color:${fg}; border:1px solid ${border};
        border-radius:14px; padding:24px 28px; min-width:360px;
        font-family:'Roboto',Arial,sans-serif; box-shadow:0 12px 32px rgba(0,0,0,0.4);
      `,
    },
    createElement('div', {
      style: 'font-size:16px; font-weight:600; margin-bottom:18px;',
      textContent: 'API Token Settings',
    }),
    selectorRow,
    inputArea,
    actions
  );

  const overlay = createElement(
    'div',
    {
      id: MODAL_ID,
      style: `
        position:fixed; inset:0; z-index:999999;
        background:rgba(0,0,0,0.5);
        display:flex; align-items:center; justify-content:center;
      `,
    },
    modal
  );

  cancelBtn.onclick = () => overlay.remove();
  saveBtn.onclick = () => {
    inputArea.querySelectorAll('input[data-provider]').forEach((input) => {
      GM_setValue(PROVIDERS[input.dataset.provider].key, input.value.trim());
    });
    overlay.remove();
  };
  overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });

  document.body.appendChild(overlay);
}

// ---------------------------------------------------------------------------
// Caption list (from ytInitialPlayerResponse)
// ---------------------------------------------------------------------------

function getPlayerResponse() {
  if (typeof unsafeWindow !== 'undefined' && unsafeWindow.ytInitialPlayerResponse) {
    return unsafeWindow.ytInitialPlayerResponse;
  }
  for (const s of document.querySelectorAll('script')) {
    if (s.textContent.includes('ytInitialPlayerResponse =')) {
      try {
        const match = s.textContent.match(/ytInitialPlayerResponse\s*=\s*({.+?});/);
        if (match) return JSON.parse(match[1]);
      } catch (_) {}
    }
  }
  return window.ytInitialPlayerResponse;
}

function getCaptionList() {
  const tracks = getPlayerResponse()?.captions?.playerCaptionsTracklistRenderer?.captionTracks;
  if (!tracks) return [];

  const langMap = new Map();
  tracks.forEach((t) => {
    const code = t.languageCode;
    const label = t.name.simpleText;
    const isAuto = t.kind === 'asr';
    const isDefault = label.includes('Default') || label.includes('기본값');
    if (!langMap.has(code) || isDefault || (!isAuto && langMap.get(code).isAuto)) {
      langMap.set(code, { code, label, isAuto, isDefault });
    }
  });
  return Array.from(langMap.values()).sort((a, b) => b.isDefault - a.isDefault);
}

// ---------------------------------------------------------------------------
// Transcript fetchers
// ---------------------------------------------------------------------------

function cleanTranscript(text) {
  return text.replace(/\r?\n|\r/g, ' ').replace(/\s{2,}/g, ' ').trim();
}

function fetchSupadata(videoId, langCode, token) {
  return new Promise((resolve, reject) => {
    GM_xmlhttpRequest({
      method: 'GET',
      url: `https://api.supadata.ai/v1/transcript?url=https://www.youtube.com/watch?v=${videoId}&lang=${langCode}&text=true&mode=auto`,
      headers: { 'x-api-key': token },
      onload: ({ status, responseText }) => {
        if (status !== 200) { reject(new Error(`Status ${status}`)); return; }
        try { resolve(JSON.parse(responseText).content); }
        catch (_) { reject(new Error('Failed to parse response')); }
      },
      onerror: () => reject(new Error('Network error')),
    });
  });
}

function buildTranscriptText(entries) {
  return entries
    .filter(({ text }) => !text.match(/^\(.*\)$/))
    .map(({ text }) => text.replace(/\n/g, ' ').replace(/- \[.*?\] /g, ''))
    .join(' ');
}

function fetchYtIo(videoId, token) {
  return new Promise((resolve, reject) => {
    GM_xmlhttpRequest({
      method: 'POST',
      url: 'https://www.youtube-transcript.io/api/transcripts',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Basic ${token}`,
      },
      data: JSON.stringify({ ids: [videoId] }),
      onload: ({ status, responseText }) => {
        if (status !== 200) { reject(new Error(`Status ${status}`)); return; }
        try {
          const data = JSON.parse(responseText);
          resolve(buildTranscriptText(data[0].tracks[0].transcript));
        } catch (_) { reject(new Error('Failed to parse response')); }
      },
      onerror: () => reject(new Error('Network error')),
    });
  });
}

async function fetchTranscript(providerId, videoId, langCode) {
  const token = GM_getValue(PROVIDERS[providerId].key, '');
  if (!token) throw new Error(`No API token set for ${PROVIDERS[providerId].label}.\nUse the Tampermonkey menu → "Set API Token".`);

  if (providerId === 'supadata') return fetchSupadata(videoId, langCode, token);
  if (providerId === 'ytio') return fetchYtIo(videoId, token);
}

// ---------------------------------------------------------------------------
// Dropdown (language + provider selector)
// ---------------------------------------------------------------------------

async function handleYtIoClick(btn) {
  const videoId = new URLSearchParams(window.location.search).get('v');
  const cacheKey = '__ytio__';

  const originalText = btn.textContent;
  btn.textContent = 'Loading...';
  btn.disabled = true;

  try {
    let text;
    if (transcriptCache[videoId]?.[cacheKey] !== undefined) {
      text = transcriptCache[videoId][cacheKey];
    } else {
      const token = GM_getValue(PROVIDERS.ytio.key, '');
      if (!token) throw new Error(`No API token set for ${PROVIDERS.ytio.label}.\nUse the Tampermonkey menu → "Set API Token".`);
      const raw = await fetchYtIo(videoId, token);
      text = cleanTranscript(raw);
      if (!transcriptCache[videoId]) transcriptCache[videoId] = {};
      transcriptCache[videoId][cacheKey] = text;
    }
    await navigator.clipboard.writeText(text);
    btn.textContent = 'Copied';
  } catch (err) {
    alert(`Error: ${err.message}`);
    btn.textContent = 'Failed';
  }

  setTimeout(() => {
    btn.textContent = originalText;
    btn.disabled = false;
  }, 2500);
}

function buildDropdownItem({ lang, videoId, isDark, fg, border, hintColor, hoverBg, btn }) {
  const cacheKey = lang.code;
  const isCached = transcriptCache[videoId]?.[cacheKey] !== undefined;
  const displayLabel = lang.label.replace(' - Default', '').replace(' (auto-generated)', '');

  const item = createElement(
    'div',
    {
      style: `
        padding:10px 20px; cursor:pointer;
        border-bottom:1px solid ${isDark ? '#333' : '#f0f0f0'};
        transition:background 0.15s; display:flex; align-items:center; gap:8px;
      `,
    },
    createElement('span', { style: 'flex:1;', textContent: displayLabel }),
    lang.isAuto
      ? createElement('span', { style: `font-size:11px; color:${hintColor};`, textContent: 'auto' })
      : null,
    isCached
      ? createElement('span', {
          style: 'font-size:11px; color:#4caf50;',
          title: 'Cached',
          textContent: '●',
        })
      : null
  );

  item.onmouseover = () => { item.style.backgroundColor = hoverBg; };
  item.onmouseout = () => { item.style.backgroundColor = 'transparent'; };
  item.onclick = async (e) => {
    e.stopPropagation();
    document.getElementById(DROPDOWN_ID)?.remove();

    const originalText = btn.textContent;
    btn.textContent = 'Loading...';
    btn.disabled = true;

    try {
      let text;
      if (transcriptCache[videoId]?.[cacheKey] !== undefined) {
        text = transcriptCache[videoId][cacheKey];
      } else {
        const raw = await fetchTranscript('supadata', videoId, lang.code);
        text = cleanTranscript(raw);
        if (!transcriptCache[videoId]) transcriptCache[videoId] = {};
        transcriptCache[videoId][cacheKey] = text;
      }
      await navigator.clipboard.writeText(text);
      btn.textContent = 'Copied';
    } catch (err) {
      alert(`Error: ${err.message}`);
      btn.textContent = 'Failed';
    }

    setTimeout(() => {
      btn.textContent = originalText;
      btn.disabled = false;
    }, 2500);
  };

  return item;
}

function toggleDropdown(btn) {
  const existing = document.getElementById(DROPDOWN_ID);
  if (existing) { existing.remove(); return; }

  const providerId = GM_getValue('transcript_selected_provider', 'supadata');
  if (providerId === 'ytio') { handleYtIoClick(btn); return; }

  const captionList = getCaptionList();

  const isDark =
    document.documentElement.hasAttribute('dark') ||
    window.matchMedia('(prefers-color-scheme: dark)').matches;
  const bg = isDark ? '#282828' : '#fff';
  const fg = isDark ? '#fff' : '#0f0f0f';
  const border = isDark ? '#444' : '#ddd';
  const hoverBg = isDark ? '#3f3f3f' : '#f2f2f2';
  const hintColor = '#aaa';

  const videoId = new URLSearchParams(window.location.search).get('v');
  const rect = btn.getBoundingClientRect();

  const items =
    captionList.length === 0
      ? [createElement('div', { style: `padding:12px 20px; color:${hintColor};`, textContent: 'No captions available' })]
      : captionList.map((lang) => buildDropdownItem({ lang, videoId, isDark, fg, border, hintColor, hoverBg, btn }));

  const dropdown = createElement(
    'div',
    {
      id: DROPDOWN_ID,
      style: `
        position:fixed; top:${rect.bottom + 8}px; left:${rect.left}px; z-index:99999;
        background:${bg}; color:${fg}; border:1px solid ${border};
        border-radius:12px; box-shadow:0 10px 25px rgba(0,0,0,0.3);
        min-width:200px; max-height:200px; overflow-y:auto;
        padding:8px 0; font-family:"Roboto",Arial,sans-serif; font-size:14px;
        animation:yt-tr-fadeIn 0.15s ease-out;
      `,
    },
    ...items
  );

  document.body.appendChild(dropdown);
}

// ---------------------------------------------------------------------------
// Button injection
// ---------------------------------------------------------------------------

function injectButton() {
  if (document.getElementById(BUTTON_ID)) return;
  const toolbar = document.querySelector('#top-level-buttons-computed');
  if (!toolbar) return;

  const button = createElement('button', {
    id: BUTTON_ID,
    className: [
      'yt-spec-button-shape-next',
      'yt-spec-button-shape-next--tonal',
      'yt-spec-button-shape-next--mono',
      'yt-spec-button-shape-next--size-m',
    ].join(' '),
    style: 'margin-left:8px;',
    textContent: 'Get Transcript',
  });
  button.addEventListener('click', (e) => { e.stopPropagation(); toggleDropdown(button); });

  toolbar.appendChild(button);
}

window.addEventListener('scroll', () => document.getElementById(DROPDOWN_ID)?.remove(), { passive: true });
document.addEventListener('click', () => document.getElementById(DROPDOWN_ID)?.remove());
setInterval(injectButton, 2000);