Adds a YouTube-style control-bar button that copies the current video's transcript.
// ==UserScript==
// @name YouTube One-Click Transcript Copier
// @namespace local.youtube.transcript.copy
// @version 7.1.2
// @description Adds a YouTube-style control-bar button that copies the current video's transcript.
// @license CC-BY-ND-2.0
// @match https://www.youtube.com/*
// @match https://m.youtube.com/*
// @grant GM_setClipboard
// @grant GM_xmlhttpRequest
// @grant unsafeWindow
// @connect www.youtube.com
// @connect m.youtube.com
// @connect youtube.com
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
const CONFIG = Object.freeze({
buttonId: 'yt-transcript-copy-button',
styleId: 'yt-transcript-copy-style',
toastId: 'yt-transcript-copy-toast',
minTranscriptLength: 20,
resetButtonMs: 1800,
installIntervalMs: 1000,
debug: false
});
let isWorking = false;
let installTimer = null;
init();
function init() {
injectStyles();
installButtonSafely();
startLowImpactInstaller();
window.addEventListener('yt-navigate-finish', installButtonSafely);
window.addEventListener('popstate', installButtonSafely);
}
function startLowImpactInstaller() {
if (installTimer) {
window.clearInterval(installTimer);
}
installTimer = window.setInterval(() => {
installButtonSafely();
}, CONFIG.installIntervalMs);
}
function installButtonSafely() {
try {
installButton();
} catch (error) {
debugLog('Button install failed:', error);
}
}
function installButton() {
if (!isVideoPage()) {
removeButton();
return;
}
const controls = getControlContainer();
if (!controls) {
return;
}
let button = document.getElementById(CONFIG.buttonId);
if (!button) {
button = createButton();
}
const fullscreenButton = controls.querySelector('.ytp-fullscreen-button');
if (fullscreenButton) {
if (button.parentElement !== controls || button.nextElementSibling !== fullscreenButton) {
controls.insertBefore(button, fullscreenButton);
}
return;
}
if (button.parentElement !== controls) {
controls.appendChild(button);
}
}
function createButton() {
const button = document.createElement('button');
button.id = CONFIG.buttonId;
button.className = 'ytp-button';
button.type = 'button';
button.dataset.state = 'idle';
setButtonState(button, 'idle');
button.addEventListener('click', async (event) => {
event.preventDefault();
event.stopPropagation();
await copyCurrentVideoTranscript(button);
}, true);
return button;
}
function removeButton() {
const button = document.getElementById(CONFIG.buttonId);
if (button) {
button.remove();
}
}
async function copyCurrentVideoTranscript(button) {
if (isWorking) {
return;
}
isWorking = true;
setButtonState(button, 'loading');
try {
const transcript = await getTranscriptFromYouTubeCaptions();
if (!isUsableTranscript(transcript)) {
throw new Error('No usable transcript found.');
}
GM_setClipboard(cleanTranscriptForClipboard(transcript), 'text');
setButtonState(button, 'success');
showToast('Transcript copied');
} catch (error) {
console.error('[Transcript Copier]', error);
setButtonState(button, 'error');
showToast(error.message || 'Transcript copy failed');
} finally {
window.setTimeout(() => {
setButtonState(button, 'idle');
isWorking = false;
}, CONFIG.resetButtonMs);
}
}
async function getTranscriptFromYouTubeCaptions() {
const videoId = getCurrentVideoId();
let playerResponse = null;
try {
playerResponse = await getBestAvailablePlayerResponse(videoId);
} catch (error) {
debugLog('Caption metadata unavailable, trying transcript panel fallbacks:', error);
}
const captionTracks = playerResponse
?.captions
?.playerCaptionsTracklistRenderer
?.captionTracks;
const tracks = sortCaptionTracks(captionTracks);
const formats = [null, 'json3', 'srv3', 'srv2', 'srv1', 'vtt'];
for (const track of tracks) {
for (const format of formats) {
const transcriptUrl = buildTimedTextUrl(track.baseUrl, format);
try {
const body = await requestTextWithFallback(transcriptUrl);
const transcript = parseTranscriptBody(body, format);
if (isUsableTranscript(transcript)) {
return transcript;
}
} catch (error) {
debugLog('Caption attempt failed:', error);
}
}
}
const transcriptFromPanelApi = await getTranscriptViaPanelApi(videoId);
if (isUsableTranscript(transcriptFromPanelApi)) {
return transcriptFromPanelApi;
}
const transcriptFromDomPanel = await getTranscriptFromDomPanel();
if (isUsableTranscript(transcriptFromDomPanel)) {
return transcriptFromDomPanel;
}
throw new Error('No usable caption transcript found.');
}
async function getBestAvailablePlayerResponse(videoId) {
const candidates = [
() => getPlayerResponse(videoId),
() => getInnertubePlayerResponse(videoId),
() => getPlayerResponseFromWatchPage(videoId)
];
for (const candidate of candidates) {
try {
const response = await candidate();
const tracks = response
?.captions
?.playerCaptionsTracklistRenderer
?.captionTracks;
if (isPlayerResponseForVideo(response, videoId) && Array.isArray(tracks) && tracks.length > 0) {
return response;
}
} catch (error) {
debugLog('Player response candidate failed:', error);
}
}
throw new Error('This video has no exposed caption track.');
}
async function getPlayerResponse(videoId) {
const localResponse = readLocalPlayerResponse(videoId);
if (localResponse) {
return localResponse;
}
return getPlayerResponseFromWatchPage(videoId);
}
async function getPlayerResponseFromWatchPage(videoId) {
const watchUrl = `https://www.youtube.com/watch?v=${encodeURIComponent(videoId)}&hl=en&persist_hl=1`;
const watchPage = await gmRequest({
method: 'GET',
url: watchUrl,
headers: {
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
}
});
const html = watchPage.responseText || '';
const extracted = extractPlayerResponseFromText(html);
if (isPlayerResponseForVideo(extracted, videoId)) {
return extracted;
}
throw new Error('Unable to read YouTube player response.');
}
async function getInnertubePlayerResponse(videoId) {
const apiKey = unsafeWindow?.ytcfg?.get?.('INNERTUBE_API_KEY') || extractInnertubeApiKeyFromDocument();
if (!apiKey) {
throw new Error('No INNERTUBE_API_KEY found.');
}
const clientName = unsafeWindow?.ytcfg?.get?.('INNERTUBE_CLIENT_NAME') || '1';
const clientVersion = unsafeWindow?.ytcfg?.get?.('INNERTUBE_CLIENT_VERSION') || '2.20260101.00.00';
const hl = (navigator.language || 'en').split('-')[0] || 'en';
const payload = {
context: {
client: {
clientName: 'WEB',
clientVersion,
hl
}
},
videoId
};
const response = await gmRequest({
method: 'POST',
url: `https://www.youtube.com/youtubei/v1/player?key=${encodeURIComponent(apiKey)}&prettyPrint=false`,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Youtube-Client-Name': String(clientName),
'X-Youtube-Client-Version': String(clientVersion)
},
data: JSON.stringify(payload)
});
const body = response.responseText || '';
if (!body) {
throw new Error('Empty YouTube player API response.');
}
const parsed = JSON.parse(body);
if (!isPlayerResponseForVideo(parsed, videoId)) {
throw new Error('YouTube player API returned data for a different video.');
}
return parsed;
}
function readLocalPlayerResponse(videoId) {
const directResponse = unsafeWindow?.ytInitialPlayerResponse;
if (isPlayerResponseForVideo(directResponse, videoId)) {
return directResponse;
}
const playerElement = document.querySelector('#movie_player');
const playerResponseFromElement = playerElement?.getPlayerResponse?.();
if (isPlayerResponseForVideo(playerResponseFromElement, videoId)) {
return playerResponseFromElement;
}
const configResponse = unsafeWindow?.ytplayer?.config?.args?.player_response;
if (configResponse) {
try {
const parsed = typeof configResponse === 'string'
? JSON.parse(configResponse)
: configResponse;
if (isPlayerResponseForVideo(parsed, videoId)) {
return parsed;
}
} catch (error) {
debugLog('Failed to parse ytplayer config response:', error);
}
}
for (const script of Array.from(document.scripts)) {
const extracted = extractPlayerResponseFromText(script.textContent || '');
if (isPlayerResponseForVideo(extracted, videoId)) {
return extracted;
}
}
return null;
}
function isPlayerResponseForVideo(response, videoId) {
if (!response || typeof response !== 'object') {
return false;
}
const responseVideoId = response?.videoDetails?.videoId || response?.currentVideoEndpoint?.watchEndpoint?.videoId;
return responseVideoId === videoId;
}
function extractPlayerResponseFromText(text) {
const assignedJson = extractAssignedJson(text, 'ytInitialPlayerResponse');
if (assignedJson) {
try {
return JSON.parse(assignedJson);
} catch (error) {
debugLog('Failed to parse ytInitialPlayerResponse:', error);
}
}
const playerResponseMatch = text.match(/"player_response":"((?:\\.|[^"\\])*)"/);
if (playerResponseMatch?.[1]) {
try {
return JSON.parse(unescapeJsonString(playerResponseMatch[1]));
} catch (error) {
debugLog('Failed to parse embedded player_response:', error);
}
}
return null;
}
async function getTranscriptViaPanelApi(videoId) {
try {
const initialData = await getBestAvailableInitialData(videoId);
const params = extractTranscriptParamsFromInitialData(initialData);
if (!params) {
return '';
}
const apiKey = unsafeWindow?.ytcfg?.get?.('INNERTUBE_API_KEY') || extractInnertubeApiKeyFromDocument();
if (!apiKey) {
return '';
}
const clientVersion = unsafeWindow?.ytcfg?.get?.('INNERTUBE_CLIENT_VERSION') || '2.20260101.00.00';
const hl = (navigator.language || 'en').split('-')[0] || 'en';
const payload = {
context: {
client: {
clientName: 'WEB',
clientVersion,
hl
}
},
params
};
const response = await gmRequest({
method: 'POST',
url: `https://www.youtube.com/youtubei/v1/get_transcript?key=${encodeURIComponent(apiKey)}&prettyPrint=false`,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Youtube-Client-Name': '1',
'X-Youtube-Client-Version': String(clientVersion)
},
data: JSON.stringify(payload)
});
const body = response.responseText || '';
if (!body) {
return '';
}
return parseTranscriptFromGetTranscriptResponse(JSON.parse(body));
} catch (error) {
debugLog('Transcript panel API fallback failed:', error);
return '';
}
}
async function getBestAvailableInitialData(videoId) {
const local = readLocalInitialData(videoId);
if (local) {
return local;
}
const watchUrl = `https://www.youtube.com/watch?v=${encodeURIComponent(videoId)}&hl=en&persist_hl=1`;
const watchPage = await gmRequest({
method: 'GET',
url: watchUrl,
headers: {
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
}
});
const html = watchPage.responseText || '';
return extractInitialDataFromText(html);
}
function readLocalInitialData(videoId) {
const direct = unsafeWindow?.ytInitialData;
if (isInitialDataForVideo(direct, videoId)) {
return direct;
}
for (const script of Array.from(document.scripts)) {
const extracted = extractInitialDataFromText(script.textContent || '');
if (isInitialDataForVideo(extracted, videoId)) {
return extracted;
}
}
return null;
}
function extractInitialDataFromText(text) {
const assignedJson = extractAssignedJson(text, 'ytInitialData');
if (!assignedJson) {
return null;
}
try {
return JSON.parse(assignedJson);
} catch (error) {
debugLog('Failed to parse ytInitialData:', error);
return null;
}
}
function extractTranscriptParamsFromInitialData(initialData) {
return findFirstDeepValue(initialData, value => {
const params = value?.getTranscriptEndpoint?.params;
return typeof params === 'string' && params ? params : '';
});
}
function isInitialDataForVideo(initialData, videoId) {
return Boolean(findFirstDeepValue(initialData, value => {
const endpointVideoId = value?.watchEndpoint?.videoId;
const playerVideoId = value?.videoId;
return endpointVideoId === videoId || playerVideoId === videoId ? '1' : '';
}));
}
function parseTranscriptFromGetTranscriptResponse(data) {
const lines = [];
walkDeep(data, value => {
const segment = value?.transcriptSegmentRenderer;
if (!segment) {
return;
}
const text = getTextFromRuns(segment?.snippet);
const cleaned = String(text || '').replace(/\s+/g, ' ').trim();
if (cleaned && !/^\d{1,2}:\d{2}(?::\d{2})?$/.test(cleaned)) {
lines.push(cleaned);
}
});
return normalizeTranscript(lines.join('\n'));
}
async function getTranscriptFromDomPanel() {
try {
await openTranscriptPanelIfPossible();
const transcript = await waitForDomTranscript(7000);
if (isUsableTranscript(transcript)) {
return transcript;
}
} catch (error) {
debugLog('DOM transcript fallback failed:', error);
}
return '';
}
async function openTranscriptPanelIfPossible() {
const existing = document.querySelector('ytd-transcript-segment-renderer');
if (existing) {
return;
}
if (await clickVisibleTranscriptControl()) {
return;
}
await expandDescriptionIfPossible();
if (await clickVisibleTranscriptControl()) {
return;
}
const menuButton = findFirstVisibleElement([
'ytd-menu-renderer yt-button-shape button[aria-label*="More actions"]',
'#top-level-buttons-computed ytd-button-renderer button[aria-label*="More actions"]',
'#actions ytd-menu-renderer button[aria-label*="More actions"]',
'button[aria-label="More actions"]'
]);
if (menuButton) {
menuButton.click();
await sleep(250);
const transcriptItem = findFirstVisibleElement([
'ytd-menu-service-item-renderer',
'tp-yt-paper-item'
], element => /transcript/i.test((element.textContent || '').trim()));
if (transcriptItem) {
transcriptItem.click();
await sleep(350);
}
}
}
async function clickVisibleTranscriptControl() {
const transcriptButton = findFirstVisibleElement([
'button',
'tp-yt-paper-button',
'ytd-button-renderer',
'yt-button-shape'
], element => {
if (element.id === CONFIG.buttonId || element.closest?.(`#${CONFIG.buttonId}`)) {
return false;
}
const label = [
element.getAttribute?.('aria-label') || '',
element.getAttribute?.('title') || '',
element.textContent || ''
].join(' ');
return /(?:show\s+)?transcript/i.test(label);
});
if (!transcriptButton) {
return false;
}
const clickable = transcriptButton.closest?.('button, tp-yt-paper-button, ytd-button-renderer, yt-button-shape') || transcriptButton;
clickable.click();
await sleep(900);
return Boolean(getTranscriptPanelContainer());
}
async function expandDescriptionIfPossible() {
const expandButton = findFirstVisibleElement([
'#description-inline-expander #expand',
'ytd-text-inline-expander #expand',
'#description button[aria-label*="more" i]',
'#description tp-yt-paper-button[aria-label*="more" i]',
'tp-yt-paper-button#expand',
'button#expand'
]);
if (!expandButton) {
return;
}
expandButton.click();
await sleep(350);
}
async function waitForDomTranscript(timeoutMs) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const transcript = readDomTranscript();
if (isUsableTranscript(transcript)) {
return transcript;
}
await sleep(150);
}
return '';
}
function readDomTranscript() {
const segmentSelectors = [
'ytd-transcript-segment-renderer yt-formatted-string.segment-text',
'ytd-transcript-segment-renderer #segment-text',
'ytd-transcript-segment-renderer .segment-text',
'ytd-transcript-segment-renderer [class*="segment-text"]',
'[target-id="engagement-panel-searchable-transcript"] ytd-transcript-segment-renderer',
'[target-id="engagement-panel-searchable-transcript"] [class*="segment-text"]'
];
for (const selector of segmentSelectors) {
const lines = Array.from(document.querySelectorAll(selector))
.map(node => normalizeDomTranscriptLine(node.textContent))
.filter(Boolean)
.filter(line => !isTimestampOnly(line));
const transcript = normalizeTranscript(lines.join('\n'));
if (isUsableTranscript(transcript)) {
return transcript;
}
}
const panel = getTranscriptPanelContainer();
if (!panel) {
return '';
}
const lines = Array.from(panel.querySelectorAll('yt-formatted-string, span, div'))
.map(node => normalizeDomTranscriptLine(node.textContent))
.filter(Boolean)
.filter(line => !isTimestampOnly(line))
.filter(line => !/^(?:transcript|search|show transcript|hide transcript|close)$/i.test(line));
return normalizeTranscript(dedupeConsecutiveLines(lines).join('\n'));
}
function getTranscriptPanelContainer() {
return document.querySelector('[target-id="engagement-panel-searchable-transcript"]')
|| document.querySelector('ytd-engagement-panel-section-list-renderer ytd-transcript-renderer')
|| document.querySelector('ytd-transcript-renderer')
|| document.querySelector('ytd-transcript-search-panel-renderer');
}
function normalizeDomTranscriptLine(text) {
return String(text || '').replace(/\s+/g, ' ').trim();
}
function isTimestampOnly(text) {
return /^\d{1,2}:\d{2}(?::\d{2})?$/.test(String(text || '').trim());
}
function dedupeConsecutiveLines(lines) {
const deduped = [];
for (const line of lines) {
if (line !== deduped[deduped.length - 1]) {
deduped.push(line);
}
}
return deduped;
}
function findFirstVisibleElement(selectors, predicate) {
for (const selector of selectors) {
const nodes = Array.from(document.querySelectorAll(selector));
for (const node of nodes) {
if (!isVisible(node)) {
continue;
}
if (predicate && !predicate(node)) {
continue;
}
return node;
}
}
return null;
}
function findFirstDeepValue(root, predicate) {
let result = '';
walkDeep(root, value => {
if (result) {
return;
}
result = predicate(value) || '';
});
return result;
}
function walkDeep(root, visitor) {
const stack = [root];
const seen = new Set();
while (stack.length > 0) {
const value = stack.pop();
if (!value || typeof value !== 'object' || seen.has(value)) {
continue;
}
seen.add(value);
visitor(value);
if (Array.isArray(value)) {
for (let index = value.length - 1; index >= 0; index -= 1) {
stack.push(value[index]);
}
continue;
}
for (const key of Object.keys(value)) {
stack.push(value[key]);
}
}
}
function getTextFromRuns(textObject) {
if (!textObject) {
return '';
}
if (typeof textObject.simpleText === 'string') {
return textObject.simpleText;
}
if (Array.isArray(textObject.runs)) {
return textObject.runs.map(run => run?.text || '').join('');
}
return '';
}
function isVisible(element) {
if (!element || !element.isConnected) {
return false;
}
const rect = element.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) {
return false;
}
const style = window.getComputedStyle(element);
return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
}
function sleep(ms) {
return new Promise(resolve => window.setTimeout(resolve, ms));
}
function extractInnertubeApiKeyFromDocument() {
const text = `${document.documentElement?.innerHTML || ''}`;
const match = text.match(/"INNERTUBE_API_KEY":"([^"]+)"/);
return match?.[1] || '';
}
function extractAssignedJson(text, variableName) {
const variableIndex = text.indexOf(variableName);
if (variableIndex === -1) {
return '';
}
const firstBraceIndex = text.indexOf('{', variableIndex);
if (firstBraceIndex === -1) {
return '';
}
let depth = 0;
let inString = false;
let escaped = false;
for (let index = firstBraceIndex; index < text.length; index += 1) {
const char = text[index];
if (escaped) {
escaped = false;
continue;
}
if (char === '\\') {
escaped = true;
continue;
}
if (char === '"') {
inString = !inString;
continue;
}
if (inString) {
continue;
}
if (char === '{') {
depth += 1;
} else if (char === '}') {
depth -= 1;
if (depth === 0) {
return text.slice(firstBraceIndex, index + 1);
}
}
}
return '';
}
function sortCaptionTracks(tracks) {
if (!Array.isArray(tracks)) {
return [];
}
const preferredLanguage = (navigator.language || 'en').split('-')[0];
return tracks.filter(track => track?.baseUrl).sort((a, b) => {
return scoreTrack(b, preferredLanguage) - scoreTrack(a, preferredLanguage);
});
}
function scoreTrack(track, preferredLanguage) {
let score = 0;
if (track.languageCode === preferredLanguage) {
score += 100;
}
if (track.languageCode === 'en') {
score += 90;
}
if (track.kind !== 'asr') {
score += 20;
}
if (track.isTranslatable) {
score += 5;
}
return score;
}
function buildTimedTextUrl(baseUrl, format) {
const url = new URL(baseUrl);
if (format) {
url.searchParams.set('fmt', format);
} else {
url.searchParams.delete('fmt');
}
return url.toString();
}
async function requestTextWithFallback(url) {
try {
const response = await fetch(url, {
method: 'GET',
credentials: 'include'
});
if (response.ok) {
return await response.text();
}
} catch (error) {
debugLog('Native fetch failed, falling back to GM request:', error);
}
const response = await gmRequest({
method: 'GET',
url,
headers: {
Accept: 'text/plain,application/xml,text/xml,application/json,*/*'
}
});
return response.responseText || '';
}
function parseTranscriptBody(body, format) {
const trimmed = String(body || '').trim();
if (!trimmed) {
return '';
}
if (format === 'json3' || trimmed.startsWith('{')) {
try {
return parseJson3Transcript(trimmed);
} catch (error) {
debugLog('JSON3 parse failed:', error);
}
}
if (format === 'vtt' || trimmed.startsWith('WEBVTT')) {
return parseVttTranscript(trimmed);
}
return parseXmlTranscript(trimmed);
}
function parseJson3Transcript(jsonText) {
const data = JSON.parse(jsonText);
const lines = [];
for (const event of data.events || []) {
const text = (event.segs || [])
.map(segment => segment.utf8 || '')
.join('')
.replace(/\s+/g, ' ')
.trim();
if (text) {
lines.push(text);
}
}
return normalizeTranscript(lines.join('\n'));
}
function parseXmlTranscript(xmlText) {
const lines = [];
const patterns = [
/<text\b[^>]*>([\s\S]*?)<\/text>/g,
/<p\b[^>]*>([\s\S]*?)<\/p>/g,
/<s\b[^>]*>([\s\S]*?)<\/s>/g
];
for (const pattern of patterns) {
let match;
while ((match = pattern.exec(xmlText)) !== null) {
const withoutTags = match[1].replace(/<[^>]+>/g, '');
const decodedText = decodeXmlEntities(withoutTags)
.replace(/\s+/g, ' ')
.trim();
if (decodedText) {
lines.push(decodedText);
}
}
if (lines.length > 0) {
break;
}
}
return normalizeTranscript(lines.join('\n'));
}
function parseVttTranscript(vttText) {
const seen = new Set();
const lines = String(vttText || '')
.split('\n')
.map(line => line.trim())
.filter(line => {
if (!line) {
return false;
}
if (line === 'WEBVTT') {
return false;
}
if (/^\d+$/.test(line)) {
return false;
}
if (/^\d{2}:\d{2}:\d{2}\.\d{3}\s+-->\s+\d{2}:\d{2}:\d{2}\.\d{3}/.test(line)) {
return false;
}
if (/^Kind:|^Language:|^NOTE/.test(line)) {
return false;
}
return true;
})
.map(line => line.replace(/<[^>]+>/g, ''))
.map(decodeXmlEntities)
.map(line => line.replace(/\s+/g, ' ').trim())
.filter(Boolean)
.filter(line => {
const key = line.toLowerCase();
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
return normalizeTranscript(lines.join('\n'));
}
function decodeXmlEntities(value) {
return String(value || '')
.replace(/&#(\d+);/g, (_match, code) => String.fromCodePoint(Number(code)))
.replace(/&#x([a-fA-F0-9]+);/g, (_match, code) => String.fromCodePoint(parseInt(code, 16)))
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/'/g, "'")
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/ /g, ' ');
}
function unescapeJsonString(value) {
return value
.replace(/\\"/g, '"')
.replace(/\\\\/g, '\\')
.replace(/\\u0026/g, '&')
.replace(/\\\//g, '/');
}
function getCurrentVideoId() {
const url = new URL(location.href);
const watchId = url.searchParams.get('v');
if (watchId && /^[a-zA-Z0-9_-]{11}$/.test(watchId)) {
return watchId;
}
const pathMatch = url.pathname.match(/\/(?:shorts|embed|live|v)\/([a-zA-Z0-9_-]{11})/);
if (pathMatch) {
return pathMatch[1];
}
throw new Error('No YouTube video ID found.');
}
function isVideoPage() {
return location.href.includes('/watch') || location.href.includes('/shorts/');
}
function getControlContainer() {
const selectors = [
'.ytp-right-controls',
'.ytp-chrome-controls .ytp-right-controls',
'.html5-video-player .ytp-right-controls'
];
for (const selector of selectors) {
const candidates = Array.from(document.querySelectorAll(selector));
if (candidates.length > 0) {
return candidates[candidates.length - 1];
}
}
return null;
}
function normalizeTranscript(text) {
return String(text || '')
.replace(/\r/g, '')
.replace(/\u00a0/g, ' ')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.replace(/[ \t]{2,}/g, ' ')
.trim();
}
function isUsableTranscript(text) {
return typeof text === 'string' && text.trim().length >= CONFIG.minTranscriptLength;
}
function gmRequest(options) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: options.method,
url: options.url,
headers: options.headers || {},
data: options.data,
timeout: 30000,
onload: response => {
if (response.status >= 200 && response.status < 400) {
resolve(response);
} else {
reject(new Error(`Request failed: HTTP ${response.status}`));
}
},
ontimeout: () => reject(new Error('Request timed out.')),
onerror: () => reject(new Error('Request failed.'))
});
});
}
function setButtonState(button, state) {
button.dataset.state = state;
while (button.firstChild) {
button.removeChild(button.firstChild);
}
button.appendChild(createIconSvg(state));
const labels = {
idle: 'Copy transcript',
loading: 'Copying transcript',
success: 'Transcript copied',
error: 'Transcript copy failed'
};
const label = labels[state] || labels.idle;
button.title = label;
button.setAttribute('aria-label', label);
button.setAttribute('data-title-no-tooltip', label);
}
function createIconSvg(state) {
const svg = createSvgElement('svg', {
height: '100%',
width: '100%',
viewBox: '0 0 36 36',
'aria-hidden': 'true',
focusable: 'false'
});
if (state === 'loading') {
svg.appendChild(createSvgElement('path', {
class: 'yt-transcript-spinner',
fill: '#fff',
d: 'M18 8a10 10 0 1 0 9.7 12.4h-2.8A7.3 7.3 0 1 1 18 10.7V8z'
}));
return svg;
}
if (state === 'success') {
appendShadowedPath(svg, 'M14.8 23.2 9.7 18.1l-1.9 1.9 7 7L29 12.8l-1.9-1.9z');
return svg;
}
if (state === 'error') {
appendShadowedPath(svg, 'M18 7a11 11 0 1 0 0 22A11 11 0 0 0 18 7zm-1.3 5h2.6v8h-2.6zm0 10h2.6v2.6h-2.6z');
return svg;
}
appendShadowedPath(svg, 'M10 8h12.4L27 12.6V28H10V8zm11 2.8V14h3.2L21 10.8zM13 17h10v2H13v-2zm0 4h10v2H13v-2zm0-8h6v2h-6v-2z');
return svg;
}
function appendShadowedPath(svg, pathData) {
svg.appendChild(createSvgElement('path', {
class: 'ytp-svg-shadow',
fill: '#000',
'fill-opacity': '0.35',
d: pathData
}));
svg.appendChild(createSvgElement('path', {
fill: '#fff',
d: pathData
}));
}
function createSvgElement(tagName, attributes) {
const element = document.createElementNS('http://www.w3.org/2000/svg', tagName);
for (const [name, value] of Object.entries(attributes)) {
element.setAttribute(name, String(value));
}
return element;
}
function showToast(message) {
const existing = document.getElementById(CONFIG.toastId);
if (existing) {
existing.remove();
}
const toast = document.createElement('div');
toast.id = CONFIG.toastId;
toast.textContent = message;
document.body.appendChild(toast);
window.setTimeout(() => {
toast.remove();
}, CONFIG.resetButtonMs);
}
function injectStyles() {
if (document.getElementById(CONFIG.styleId)) {
return;
}
const style = document.createElement('style');
style.id = CONFIG.styleId;
style.textContent = `
#${CONFIG.buttonId}.ytp-button {
display: inline-block !important;
width: 48px !important;
height: 100% !important;
min-width: 48px !important;
padding: 0 !important;
margin: 0 !important;
border: 0 !important;
background: transparent !important;
color: #ffffff !important;
opacity: 0.9 !important;
cursor: pointer !important;
vertical-align: top !important;
position: relative !important;
z-index: 10 !important;
pointer-events: auto !important;
}
#${CONFIG.buttonId}.ytp-button:hover {
opacity: 1 !important;
}
#${CONFIG.buttonId}.ytp-button svg {
display: block !important;
width: 100% !important;
height: 100% !important;
pointer-events: none !important;
}
.yt-transcript-spinner {
transform-origin: 18px 18px;
animation: ytTranscriptSpin 0.85s linear infinite;
}
@keyframes ytTranscriptSpin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
#${CONFIG.toastId} {
position: fixed !important;
right: 16px !important;
bottom: 72px !important;
z-index: 2147483647 !important;
padding: 10px 12px !important;
border-radius: 8px !important;
background: rgba(0, 0, 0, 0.88) !important;
color: #ffffff !important;
font: 13px Arial, sans-serif !important;
pointer-events: none !important;
}
`;
document.documentElement.appendChild(style);
}
function cleanTranscriptForClipboard(transcript) {
return normalizeTranscript(
removeRepeatedCaptionPhrases(
removeCaptionNoise(
decodeHtmlEntities(transcript)
)
)
);
}
function decodeHtmlEntities(text) {
return String(text || '')
.replace(/&#(\d+);/g, (_match, code) => String.fromCodePoint(Number(code)))
.replace(/&#x([a-fA-F0-9]+);/g, (_match, code) => String.fromCodePoint(parseInt(code, 16)))
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/'/g, "'")
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/ /g, ' ');
}
function removeCaptionNoise(text) {
return String(text || '')
.replace(/^\s*>>\s*/gm, '')
.replace(/\[(?:music|laughter|laughs|applause|crying|sighs?|inaudible|silence)\]/gi, '')
.replace(/\(\s*(?:music|laughter|laughs|applause|crying|sighs?|inaudible|silence)\s*\)/gi, '')
.replace(/\[ __ \]/g, '[expletive]')
.replace(/\s+([,.!?])/g, '$1')
.replace(/([,.!?]){2,}/g, '$1')
.replace(/[ \t]{2,}/g, ' ')
.replace(/\n[ \t]+/g, '\n')
.trim();
}
function removeRepeatedCaptionPhrases(text) {
return String(text || '')
.split('\n')
.map(removeImmediateRepeatedWords)
.map(removeImmediateRepeatedShortPhrases)
.join('\n');
}
function removeImmediateRepeatedWords(line) {
let cleaned = String(line || '');
for (let index = 0; index < 3; index += 1) {
cleaned = cleaned.replace(/\b(\w+)(?:\s+\1\b)+/gi, '$1');
}
return cleaned;
}
function removeImmediateRepeatedShortPhrases(line) {
let cleaned = String(line || '');
for (let index = 0; index < 3; index += 1) {
cleaned = cleaned.replace(
/\b((?:[\w'"-]+[,.!?]?\s+){1,4})(?:\1)+/gi,
'$1'
);
}
return cleaned
.replace(/[ \t]{2,}/g, ' ')
.trim();
}
function debugLog(...args) {
if (CONFIG.debug) {
console.debug('[Transcript Copier]', ...args);
}
}
})();