YouTube Transcript Extractor

Extracts and copies YouTube video transcripts to clipboard

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

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

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

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

이 스크립트를 설치하려면 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);