Greasy Fork is available in English.
自动打开 YouTube 转写文稿,并提供一键复制按钮
// ==UserScript==
// @name YouTube Transcript Open + Copy
// @namespace local
// @version 1.9.0
// @description 自动打开 YouTube 转写文稿,并提供一键复制按钮
// @match https://www.youtube.com/watch*
// @run-at document-idle
// @grant GM_setClipboard
// @license MIT
// @noframes
// ==/UserScript==
(() => {
'use strict';
if (window.top !== window.self) return;
const COPY_BTN_ID = 'tm-copy-transcript-btn';
const KEEP_TIMESTAMPS = true;
const AUTO_SCROLL_PANEL = true;
const DEBUG = false;
let lastUrl = location.href;
let openedForUrl = false;
const sleep = ms => new Promise(r => setTimeout(r, ms));
const log = (...args) => DEBUG && console.debug('[YT Transcript]', ...args);
const clean = s => (s || '').replace(/\u00A0/g, ' ').replace(/\s+/g, ' ').trim();
const textOf = el => clean(
`${el?.innerText || ''} ${el?.textContent || ''} ${el?.getAttribute?.('aria-label') || ''} ${el?.getAttribute?.('title') || ''}`
).toLowerCase();
const isVisible = el => {
if (!el) return false;
const s = getComputedStyle(el);
return s.display !== 'none' && s.visibility !== 'hidden' && el.offsetParent !== null;
};
const inPlayer = el => !!el?.closest?.(
'#movie_player, .html5-video-player, .ytp-chrome-controls, .ytp-popup, .ytp-overflow-panel'
);
const isExpandBtn = el => !!el?.closest?.('ytd-text-inline-expander') || el?.id === 'expand';
const q = (sel, root = document) => root.querySelector(sel);
const qa = (sel, root = document) => [...root.querySelectorAll(sel)];
const firstVisible = (selectors, root = document) => {
for (const sel of selectors) {
const el = q(sel, root);
if (el && isVisible(el) && !inPlayer(el)) return el;
}
return null;
};
const firstByText = (selectors, patterns, root = document, extraFilter = () => true) => {
for (const el of qa(selectors, root)) {
if (!isVisible(el) || inPlayer(el) || !extraFilter(el)) continue;
const s = textOf(el);
if (patterns.some(p => s.includes(p))) return el;
}
return null;
};
const transcriptPanel = () => firstVisible([
'ytd-engagement-panel-section-list-renderer[target-id="PAmodern_transcript_view"]',
'ytd-engagement-panel-section-list-renderer[target-id*="PAmodern_transcript"]',
'ytd-engagement-panel-section-list-renderer[target-id*="searchable-transcript"]',
'ytd-engagement-panel-section-list-renderer[target-id*="transcript"]',
'ytd-transcript-search-panel-renderer'
]);
const findExpandButton = () => firstVisible([
'ytd-watch-metadata ytd-text-inline-expander tp-yt-paper-button#expand',
'ytd-watch-metadata ytd-text-inline-expander #expand',
'ytd-text-inline-expander tp-yt-paper-button#expand',
'ytd-text-inline-expander #expand'
]);
const findDirectTranscriptButton = () =>
firstVisible([
'ytd-watch-metadata button[aria-label="内容转文字"]',
'ytd-watch-metadata button[aria-label="內容轉文字"]',
'ytd-watch-metadata button[aria-label="Show transcript"]',
'ytd-watch-metadata button[aria-label="显示文稿"]',
'ytd-watch-metadata button[aria-label="顯示文稿"]',
'button[aria-label="内容转文字"]',
'button[aria-label="內容轉文字"]',
'button[aria-label="Show transcript"]',
'button[aria-label="显示文稿"]',
'button[aria-label="顯示文稿"]'
]) || firstByText(
'ytd-watch-metadata button, ytd-watch-metadata [role="button"], ytd-watch-metadata yt-button-shape, ytd-watch-metadata yt-button-view-model',
['内容转文字', '內容轉文字', 'show transcript', '显示文稿', '顯示文稿', '查看文稿', '转写文稿'],
document,
el => !isExpandBtn(el)
);
const findMoreActionsButton = () => {
const roots = [
q('ytd-watch-metadata #top-level-buttons-computed'),
q('ytd-watch-metadata #actions-inner'),
q('ytd-watch-metadata ytd-menu-renderer'),
q('ytd-watch-metadata')
].filter(Boolean);
for (const root of roots) {
const el = firstByText(
'button,[role="button"],tp-yt-paper-button,yt-button-shape,yt-button-view-model',
['more actions', '更多操作'],
root,
btn => btn.closest('ytd-watch-metadata') && !isExpandBtn(btn)
);
if (el) return el.closest('button,[role="button"],tp-yt-paper-button,yt-button-shape,yt-button-view-model') || el;
}
return null;
};
const visibleMenus = () =>
qa('tp-yt-paper-listbox,[role="menu"],ytd-menu-popup-renderer,tp-yt-iron-dropdown')
.filter(el => isVisible(el) && !inPlayer(el));
const findTranscriptEntry = () => {
const selectors = 'ytd-menu-service-item-renderer,tp-yt-paper-item,[role="menuitem"],button,[role="button"],yt-formatted-string';
const exact = ['show transcript', '显示文稿', '顯示文稿', '查看文稿', '转写文稿', '内容转文字', '內容轉文字'];
const fallback = ['transcript', '文稿'];
for (const menu of visibleMenus()) {
const el = firstByText(selectors, exact, menu);
if (el) return el;
}
for (const menu of visibleMenus()) {
const el = firstByText(selectors, fallback, menu);
if (el) return el;
}
return null;
};
async function expandDescriptionIfNeeded() {
const btn = findExpandButton();
if (!btn) return false;
const s = textOf(btn);
if (!s.includes('更多') && !s.includes('more')) return false;
log('expand description', btn);
btn.click();
await sleep(400);
return true;
}
async function clickDirectTranscriptButton() {
const btn = findDirectTranscriptButton();
if (!btn) return false;
log('click direct transcript', btn);
btn.click();
await sleep(700);
return !!transcriptPanel();
}
async function openTranscript() {
if (transcriptPanel()) return true;
if (await clickDirectTranscriptButton()) return true;
await expandDescriptionIfNeeded();
if (await clickDirectTranscriptButton()) return true;
const moreBtn = findMoreActionsButton();
if (!moreBtn) return false;
if (!['more actions', '更多操作'].some(x => textOf(moreBtn).includes(x))) return false;
log('click more actions', moreBtn);
moreBtn.click();
for (let i = 0; i < 12; i++) {
await sleep(250);
const item = findTranscriptEntry();
if (!item) continue;
const clickable = item.closest('[role="menuitem"],tp-yt-paper-item,button,[role="button"],ytd-menu-service-item-renderer') || item;
log('click transcript entry', clickable);
clickable.click();
await sleep(700);
if (transcriptPanel()) return true;
}
return !!transcriptPanel();
}
const getScroller = root =>
[
q('yt-section-list-renderer', root),
q('.ytSectionListRendererContents', root),
q('#content', root),
q('#contents', root),
q('#segments-container', root),
q('#body', root),
root
].find(el => el && el.scrollHeight > el.clientHeight + 20) || root;
async function scrollPanelFully(panel) {
if (!panel) return;
const scroller = getScroller(panel);
let stable = 0, lastH = -1, lastCount = -1;
for (let i = 0; i < 50; i++) {
const count = qa('transcript-segment-view-model.ytwTranscriptSegmentViewModelHost,ytd-transcript-segment-renderer', panel).length;
scroller.scrollTop = scroller.scrollHeight;
await sleep(250);
const h = scroller.scrollHeight;
stable = (h === lastH && count === lastCount) ? stable + 1 : 0;
lastH = h;
lastCount = count;
if (stable >= 4) break;
}
scroller.scrollTop = 0;
}
const extractTimestamp = raw => (clean(raw).match(/\b(\d{1,2}:\d{2}(?::\d{2})?)\b/) || [])[1] || '';
const normalizeLine = line => clean(line)
.replace(/^\[\d{1,2}:\d{2}(?::\d{2})?\]\s*/, '')
.replace(/^\d{1,2}:\d{2}(?::\d{2})?\s*/, '')
.trim();
const isNoise = s => {
s = clean(s).toLowerCase();
return !s || new Set([
'transcript', 'show transcript', 'search in transcript', 'toggle timestamps',
'hide timestamps', 'show timestamps', 'more', 'more actions',
'内容转文字', '內容轉文字', '显示文稿', '顯示文稿', '查看文稿', '文稿',
'文字记录', '转写文稿', '搜索转写内容', '搜索文稿', '在文稿中搜索', '搜尋文稿',
'与视频时间同步', '隐藏时间戳', '顯示時間戳', '关闭'
]).has(s);
};
function dedupeLines(lines) {
const seen = new Set();
const out = [];
for (const line of lines) {
const key = normalizeLine(line);
if (!key || isNoise(key) || seen.has(key)) continue;
seen.add(key);
out.push(line);
}
return out;
}
function getTranscriptLines() {
const panel = transcriptPanel();
if (!panel) return [];
const modern = qa('transcript-segment-view-model.ytwTranscriptSegmentViewModelHost', panel);
if (modern.length) {
return dedupeLines(modern.map(seg => {
const time = extractTimestamp(q('.ytwTranscriptSegmentViewModelTimestamp', seg)?.textContent || '');
const text = clean(q('span.ytAttributedStringHost[role="text"]', seg)?.textContent || '');
if (!text || isNoise(text)) return null;
return KEEP_TIMESTAMPS && time ? `[${time}] ${text}` : text;
}).filter(Boolean));
}
const legacy = qa('ytd-transcript-segment-renderer', panel);
if (legacy.length) {
return dedupeLines(legacy.map(seg => {
const time = extractTimestamp(
q('.segment-timestamp', seg)?.textContent ||
q('#timestamp', seg)?.textContent ||
q('yt-formatted-string.segment-timestamp', seg)?.textContent || ''
);
const text = clean(
q('.segment-text', seg)?.textContent ||
q('#segment-text', seg)?.textContent ||
q('yt-formatted-string.segment-text', seg)?.textContent || ''
);
if (!text || isNoise(text)) return null;
return KEEP_TIMESTAMPS && time ? `[${time}] ${text}` : text;
}).filter(Boolean));
}
return [];
}
async function waitTranscriptLines(timeout = 10000) {
const start = Date.now();
while (Date.now() - start < timeout) {
const lines = getTranscriptLines();
if (lines.length) return lines;
await sleep(300);
}
return [];
}
async function copyTranscript() {
const btn = q(`#${COPY_BTN_ID}`);
if (btn) {
btn.textContent = '复制中...';
btn.disabled = true;
}
let panel = transcriptPanel();
if (!panel) {
await openTranscript();
panel = transcriptPanel();
}
if (panel && AUTO_SCROLL_PANEL) {
await scrollPanelFully(panel);
await sleep(500);
}
let lines = getTranscriptLines();
if (!lines.length) lines = await waitTranscriptLines();
if (!lines.length) {
if (btn) {
btn.textContent = '未找到文稿';
setTimeout(() => {
btn.textContent = '复制文稿';
btn.disabled = false;
}, 1200);
}
return;
}
const text = lines.join('\n');
if (typeof GM_setClipboard === 'function') {
GM_setClipboard(text, 'text');
} else {
await navigator.clipboard.writeText(text);
}
if (btn) {
btn.textContent = '已复制';
setTimeout(() => {
btn.textContent = '复制文稿';
btn.disabled = false;
}, 1200);
}
}
function ensureCopyButton() {
let btn = q(`#${COPY_BTN_ID}`);
if (btn) return btn;
btn = document.createElement('button');
btn.id = COPY_BTN_ID;
btn.textContent = '复制文稿';
Object.assign(btn.style, {
position: 'fixed',
right: '16px',
bottom: '16px',
zIndex: '999999',
padding: '10px 14px',
border: 'none',
borderRadius: '999px',
background: '#0f0f0f',
color: '#fff',
fontSize: '14px',
cursor: 'pointer',
boxShadow: '0 2px 8px rgba(0,0,0,.25)'
});
btn.addEventListener('click', copyTranscript);
document.body.appendChild(btn);
return btn;
}
async function runOpenFlow() {
if (!location.href.includes('/watch') || openedForUrl) return;
openedForUrl = true;
ensureCopyButton();
await sleep(1200);
if (!transcriptPanel()) await openTranscript();
}
function resetAndRun() {
if (location.href !== lastUrl) {
lastUrl = location.href;
openedForUrl = false;
}
ensureCopyButton();
runOpenFlow();
}
window.addEventListener('yt-navigate-finish', resetAndRun);
window.addEventListener('load', resetAndRun);
resetAndRun();
})();