YouTube Transcript Extractor

Extracts and copies YouTube video transcripts to clipboard

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey, Greasemonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला 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);