Files
jackwener__opencli/clis/youtube/transcript.ts
jakevin 80eef46b4e refactor: monorepo adapter separation (clis/ at root) (#782)
* refactor: move adapters from src/clis/ to root clis/ for monorepo separation

Separates CLI adapters from the core runtime to prepare for independent
adapter distribution via postinstall fetch.

Key changes:
- Move src/clis/ → clis/ (adapters at repo root)
- Change tsconfig rootDir from "src" to "." so tsc compiles both
- Create root-level shim files (registry.ts, errors.ts, etc.) so adapter
  relative imports (../../registry.js) resolve correctly
- Update build-manifest.ts, main.ts paths for new dist/src/ structure
- Expand ensureUserCliCompatShims() to cover all adapter import targets
  (types, utils, logger, launcher, browser/*, download/*, pipeline/*)
- Add scripts/fetch-adapters.js postinstall for ~/.opencli/clis/ sync
- Update vitest.config.ts adapter test paths
- Add package.json files field to exclude adapters from npm package

Official adapter files are unconditionally overwritten on update;
user-created files not in the manifest are preserved.

* fix: add dist/clis/ and cli-manifest.json to npm files, harden fetch-adapters

- Add dist/clis/ and dist/cli-manifest.json to package.json files field
  so built-in adapters and manifest ship with the npm package
- Replace execSync with execFileSync to prevent command injection
- Add version check to skip redundant adapter fetches
- Track tmpRoot explicitly for reliable cleanup

* fix: address review blockers — manifest-based updates, global-only fetch, first-run fallback

1. Manifest-based update strategy:
   - Read old manifest to identify previously-official files
   - Clean up files removed upstream (in old manifest but not new)
   - User-created files (never in any manifest) remain untouched

2. Only run fetch-adapters on global install (npm_config_global=true)
   or explicit OPENCLI_FETCH=1, preventing heavy side effects for
   local/dev installs

3. First-run fallback in discovery.ts:
   - ensureUserAdapters() checks for adapter-manifest.json
   - If missing and ~/.opencli/clis/ is empty, spawns fetch-adapters.js
   - Guarantees adapters are available even with --ignore-scripts

* fix: remove OPENCLI_FETCH env var, use internal _OPENCLI_FIRST_RUN instead

* feat: also support OPENCLI_FETCH=1 for explicit adapter fetch trigger

* simplify: replace git clone with local copy from dist/clis/

Adapters already ship in the npm package (dist/clis/), so there's no
need to clone from GitHub. Copy directly from the installed package:

- Eliminates git, curl, tar dependencies
- No network calls in postinstall
- No timeout/offline issues
- Version always matches the installed CLI
- ~65 lines of clone/download code replaced by one cpSync loop
2026-04-05 01:46:36 +08:00

280 lines
11 KiB
TypeScript

/**
* YouTube transcript — uses InnerTube player API with Android client context.
*
* The Web client's caption URLs require a PoToken (proof of origin) generated
* by BotGuard at runtime. The Android client returns caption URLs that work
* without PoToken — same approach used by youtube-transcript-api (Python).
*
* Modes:
* --mode grouped (default): sentences merged, speaker detection, chapters
* --mode raw: every caption segment as-is with precise timestamps
*/
import { cli, Strategy } from '../../registry.js';
import { parseVideoId, prepareYoutubeApiPage } from './utils.js';
import {
groupTranscriptSegments,
formatGroupedTranscript,
type RawSegment,
type Chapter,
} from './transcript-group.js';
import { CommandExecutionError, EmptyResultError } from '../../errors.js';
cli({
site: 'youtube',
name: 'transcript',
description: 'Get YouTube video transcript/subtitles',
domain: 'www.youtube.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'url', required: true, positional: true, help: 'YouTube video URL or video ID' },
{ name: 'lang', required: false, help: 'Language code (e.g. en, zh-Hans). Omit to auto-select' },
{ name: 'mode', required: false, default: 'grouped', help: 'Output mode: grouped (readable paragraphs) or raw (every segment)' },
],
// columns intentionally omitted — raw and grouped modes return different schemas,
// so we let the renderer auto-detect columns from the data keys.
func: async (page, kwargs) => {
const videoId = parseVideoId(kwargs.url);
await prepareYoutubeApiPage(page);
const lang = kwargs.lang || '';
const mode = kwargs.mode || 'grouped';
// Step 1: Get caption track URL via Android InnerTube API
const captionData = await page.evaluate(`
(async () => {
const cfg = window.ytcfg?.data_ || {};
const apiKey = cfg.INNERTUBE_API_KEY;
if (!apiKey) return { error: 'INNERTUBE_API_KEY not found on page' };
const resp = await fetch('/youtubei/v1/player?key=' + apiKey + '&prettyPrint=false', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
context: { client: { clientName: 'ANDROID', clientVersion: '20.10.38' } },
videoId: ${JSON.stringify(videoId)}
})
});
if (!resp.ok) return { error: 'InnerTube player API returned HTTP ' + resp.status };
const data = await resp.json();
const renderer = data.captions?.playerCaptionsTracklistRenderer;
if (!renderer?.captionTracks?.length) {
return { error: 'No captions available for this video' };
}
const tracks = renderer.captionTracks;
const available = tracks.map(t => t.languageCode + (t.kind === 'asr' ? ' (auto)' : ''));
const langPref = ${JSON.stringify(lang)};
let track = null;
if (langPref) {
track = tracks.find(t => t.languageCode === langPref)
|| tracks.find(t => t.languageCode.startsWith(langPref));
}
if (!track) {
track = tracks.find(t => t.kind !== 'asr') || tracks[0];
}
return {
captionUrl: track.baseUrl,
language: track.languageCode,
kind: track.kind || 'manual',
available,
requestedLang: langPref || null,
langMatched: !!(langPref && track.languageCode === langPref),
langPrefixMatched: !!(langPref && track.languageCode !== langPref && track.languageCode.startsWith(langPref))
};
})()
`);
if (!captionData || typeof captionData === 'string') {
throw new CommandExecutionError(`Failed to get caption info: ${typeof captionData === 'string' ? captionData : 'null response'}`);
}
if (captionData.error) {
throw new CommandExecutionError(`${captionData.error}${captionData.available ? ' (available: ' + captionData.available.join(', ') + ')' : ''}`);
}
// Warn if --lang was specified but not matched
if (captionData.requestedLang && !captionData.langMatched && !captionData.langPrefixMatched) {
console.error(`Warning: --lang "${captionData.requestedLang}" not found. Using "${captionData.language}" instead. Available: ${captionData.available.join(', ')}`);
}
// Step 2: Fetch caption XML and parse segments
const segments: RawSegment[] = await page.evaluate(`
(async () => {
const resp = await fetch(${JSON.stringify(captionData.captionUrl)});
const xml = await resp.text();
if (!xml?.length) {
return { error: 'Caption URL returned empty response' };
}
function getAttr(tag, name) {
const needle = name + '="';
const idx = tag.indexOf(needle);
if (idx === -1) return '';
const valStart = idx + needle.length;
const valEnd = tag.indexOf('"', valStart);
if (valEnd === -1) return '';
return tag.substring(valStart, valEnd);
}
function decodeEntities(s) {
return s
.replaceAll('&', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'");
}
const isFormat3 = xml.includes('<p t="');
const marker = isFormat3 ? '<p ' : '<text ';
const endMarker = isFormat3 ? '</p>' : '</text>';
const results = [];
let pos = 0;
while (true) {
const tagStart = xml.indexOf(marker, pos);
if (tagStart === -1) break;
let contentStart = xml.indexOf('>', tagStart);
if (contentStart === -1) break;
contentStart += 1;
const tagEnd = xml.indexOf(endMarker, contentStart);
if (tagEnd === -1) break;
const attrStr = xml.substring(tagStart + marker.length, contentStart - 1);
const content = xml.substring(contentStart, tagEnd);
let startSec, durSec;
if (isFormat3) {
startSec = (parseFloat(getAttr(attrStr, 't')) || 0) / 1000;
durSec = (parseFloat(getAttr(attrStr, 'd')) || 0) / 1000;
} else {
startSec = parseFloat(getAttr(attrStr, 'start')) || 0;
durSec = parseFloat(getAttr(attrStr, 'dur')) || 0;
}
// Strip inner tags (e.g. <s> in srv3 format) and decode entities
const text = decodeEntities(content.replace(/<[^>]+>/g, '')).split('\\\\n').join(' ').trim();
if (text) {
results.push({ start: startSec, end: startSec + durSec, text });
}
pos = tagEnd + endMarker.length;
}
if (results.length === 0) {
return { error: 'Parsed 0 segments from caption XML' };
}
return results;
})()
`);
if (!Array.isArray(segments)) {
throw new CommandExecutionError((segments as any)?.error || 'Failed to parse caption segments');
}
if (segments.length === 0) {
throw new EmptyResultError('youtube transcript');
}
// Step 3: Fetch chapters (for grouped mode)
let chapters: Chapter[] = [];
if (mode === 'grouped') {
try {
const chapterData = await page.evaluate(`
(async () => {
const cfg = window.ytcfg?.data_ || {};
const apiKey = cfg.INNERTUBE_API_KEY;
if (!apiKey) return [];
const resp = await fetch('/youtubei/v1/next?key=' + apiKey + '&prettyPrint=false', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
context: { client: { clientName: 'WEB', clientVersion: '2.20240101.00.00' } },
videoId: ${JSON.stringify(videoId)}
})
});
if (!resp.ok) return [];
const data = await resp.json();
const chapters = [];
// Try chapterRenderer from player bar
const panels = data.playerOverlays?.playerOverlayRenderer
?.decoratedPlayerBarRenderer?.decoratedPlayerBarRenderer
?.playerBar?.multiMarkersPlayerBarRenderer?.markersMap;
if (Array.isArray(panels)) {
for (const panel of panels) {
const markers = panel.value?.chapters;
if (!Array.isArray(markers)) continue;
for (const marker of markers) {
const ch = marker.chapterRenderer;
if (!ch) continue;
const title = ch.title?.simpleText || '';
const startMs = ch.timeRangeStartMillis;
if (title && typeof startMs === 'number') {
chapters.push({ title, start: startMs / 1000 });
}
}
}
}
if (chapters.length > 0) return chapters;
// Fallback: macroMarkersListItemRenderer from engagement panels
const engPanels = data.engagementPanels;
if (!Array.isArray(engPanels)) return [];
for (const ep of engPanels) {
const content = ep.engagementPanelSectionListRenderer?.content;
const items = content?.macroMarkersListRenderer?.contents;
if (!Array.isArray(items)) continue;
for (const item of items) {
const renderer = item.macroMarkersListItemRenderer;
if (!renderer) continue;
const t = renderer.title?.simpleText || '';
const ts = renderer.timeDescription?.simpleText || '';
if (!t || !ts) continue;
const parts = ts.split(':').map(Number);
let secs = null;
if (parts.length === 3 && parts.every(n => !isNaN(n))) secs = parts[0]*3600 + parts[1]*60 + parts[2];
else if (parts.length === 2 && parts.every(n => !isNaN(n))) secs = parts[0]*60 + parts[1];
if (secs !== null) chapters.push({ title: t, start: secs });
}
}
return chapters;
})()
`);
if (Array.isArray(chapterData)) {
chapters = chapterData;
}
} catch {
// Chapters are optional — proceed without them
}
}
// Step 4: Format output based on mode
if (mode === 'raw') {
// Precise timestamps in seconds with decimals, matching bilibili/subtitle format
return segments.map((seg, i) => ({
index: i + 1,
start: Number(seg.start).toFixed(2) + 's',
end: Number(seg.end).toFixed(2) + 's',
text: seg.text,
}));
}
// Grouped mode: merge sentences, detect speakers, insert chapters
const grouped = groupTranscriptSegments(
segments.map(s => ({ start: s.start, text: s.text })),
);
const { rows } = formatGroupedTranscript(grouped, chapters);
return rows;
},
});