Extracts and copies YouTube video transcripts to clipboard
// ==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);