Last active
July 29, 2026 19:06
-
-
Save meoyawn/58cb06909c9eacb2202c85ea12542c12 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bun | |
| import { $ } from "bun"; | |
| interface CaptionFormat { | |
| ext?: string; | |
| url?: string; | |
| name?: string; | |
| } | |
| interface CaptionCandidate { | |
| language: string; | |
| kind: "subtitles" | "automatic_captions"; | |
| format: CaptionFormat; | |
| original: boolean; | |
| score: number; | |
| } | |
| interface TranscriptSegment { | |
| startTimeMs?: number; | |
| text: string; | |
| } | |
| function usage(): never { | |
| console.error("Usage: scripts/youtube-transcript.ts <youtube-url>"); | |
| console.error(" scripts/youtube-transcript.ts --help"); | |
| process.exit(2); | |
| } | |
| function printHelp(): never { | |
| console.log(`Usage: | |
| scripts/youtube-transcript.ts <youtube-url> | |
| scripts/youtube-transcript.ts --help | |
| Prints the video's transcript with timestamps as plain text to stdout. | |
| Arguments: | |
| <youtube-url> YouTube video URL. Must use http or https. | |
| Options: | |
| -h, --help Print this help. | |
| `); | |
| process.exit(0); | |
| } | |
| function fail(message: string): never { | |
| console.error(message); | |
| process.exit(1); | |
| } | |
| function parseArgs(): string { | |
| const args = Bun.argv.slice(2); | |
| if (args.length === 1 && (args[0] === "--help" || args[0] === "-h")) { | |
| printHelp(); | |
| } | |
| if (args.length !== 1) { | |
| usage(); | |
| } | |
| const url = args[0]; | |
| if (url === undefined) { | |
| usage(); | |
| } | |
| try { | |
| const parsed = new URL(url); | |
| if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { | |
| fail(`URL must use http or https: ${url}`); | |
| } | |
| } catch { | |
| fail(`Invalid URL: ${url}`); | |
| } | |
| return url; | |
| } | |
| function isRecord(value: unknown): value is Record<string, unknown> { | |
| return typeof value === "object" && value !== null && !Array.isArray(value); | |
| } | |
| function getString(value: unknown): string | null { | |
| return typeof value === "string" ? value : null; | |
| } | |
| function getCaptionMap(value: unknown): Record<string, CaptionFormat[]> { | |
| if (!isRecord(value)) { | |
| return {}; | |
| } | |
| const captions: Record<string, CaptionFormat[]> = {}; | |
| for (const [language, formats] of Object.entries(value)) { | |
| if (!Array.isArray(formats)) { | |
| continue; | |
| } | |
| const parsedFormats: CaptionFormat[] = []; | |
| for (const format of formats) { | |
| if (!isRecord(format)) { | |
| continue; | |
| } | |
| const url = getString(format["url"]); | |
| if (url === null) { | |
| continue; | |
| } | |
| parsedFormats.push({ | |
| ext: getString(format["ext"]) ?? undefined, | |
| url, | |
| name: getString(format["name"]) ?? undefined, | |
| }); | |
| } | |
| if (parsedFormats.length > 0) { | |
| captions[language] = parsedFormats; | |
| } | |
| } | |
| return captions; | |
| } | |
| function hasTranslatedLanguage(format: CaptionFormat): boolean { | |
| if (format.url === undefined) { | |
| return false; | |
| } | |
| try { | |
| return new URL(format.url).searchParams.has("tlang"); | |
| } catch { | |
| return false; | |
| } | |
| } | |
| function hasAsrKind(format: CaptionFormat): boolean { | |
| if (format.url === undefined) { | |
| return false; | |
| } | |
| try { | |
| return new URL(format.url).searchParams.get("kind") === "asr"; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| function baseLanguage(language: string | null): string | null { | |
| if (language === null || language.length === 0) { | |
| return null; | |
| } | |
| return language.replace(/-orig$/, "").split("-")[0]?.toLowerCase() ?? null; | |
| } | |
| function bestFormatScore(format: CaptionFormat): number { | |
| switch (format.ext) { | |
| case "json3": | |
| return 40; | |
| case "vtt": | |
| return 30; | |
| case "srt": | |
| return 20; | |
| case "ttml": | |
| return 10; | |
| default: | |
| return 0; | |
| } | |
| } | |
| function bestFormat(formats: CaptionFormat[]): CaptionFormat | undefined { | |
| return [...formats].sort((left, right) => bestFormatScore(right) - bestFormatScore(left))[0]; | |
| } | |
| function buildCandidates(info: Record<string, unknown>): CaptionCandidate[] { | |
| const preferredBaseLanguage = baseLanguage(getString(info["language"])); | |
| const candidates: CaptionCandidate[] = []; | |
| for (const [kind, captionMap] of [ | |
| ["subtitles", getCaptionMap(info["subtitles"])], | |
| ["automatic_captions", getCaptionMap(info["automatic_captions"])], | |
| ] as const) { | |
| for (const [language, formats] of Object.entries(captionMap)) { | |
| const format = bestFormat(formats); | |
| if (format === undefined) { | |
| continue; | |
| } | |
| const languageBase = baseLanguage(language); | |
| const original = !hasTranslatedLanguage(format) && (kind === "subtitles" || hasAsrKind(format)); | |
| const languageScore = preferredBaseLanguage !== null && languageBase === preferredBaseLanguage ? 100000 : 0; | |
| const originalScore = original ? 10000 : 0; | |
| const kindScore = kind === "subtitles" ? 1000 : 0; | |
| const englishFallbackScore = languageBase === "en" ? 25 : 0; | |
| candidates.push({ | |
| language, | |
| kind, | |
| format, | |
| original, | |
| score: languageScore + originalScore + kindScore + englishFallbackScore + bestFormatScore(format), | |
| }); | |
| } | |
| } | |
| return candidates.sort((left, right) => right.score - left.score); | |
| } | |
| function decodeEntities(text: string): string { | |
| return text | |
| .replaceAll("&", "&") | |
| .replaceAll(">", ">") | |
| .replaceAll("<", "<") | |
| .replaceAll(""", '"') | |
| .replaceAll("'", "'") | |
| .replaceAll("'", "'"); | |
| } | |
| function cleanText(text: string): string { | |
| return decodeEntities(text.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ")).trim(); | |
| } | |
| function getNumber(value: unknown): number | null { | |
| return typeof value === "number" && Number.isFinite(value) ? value : null; | |
| } | |
| function parseTimestamp(timestamp: string): number | null { | |
| const match = /^(?:(\d{1,2}):)?(\d{2}):(\d{2})[,.](\d{3})$/.exec(timestamp); | |
| if (match === null) { | |
| return null; | |
| } | |
| const hours = Number(match[1] ?? 0); | |
| const minutes = Number(match[2]); | |
| const seconds = Number(match[3]); | |
| const milliseconds = Number(match[4]); | |
| return ((hours * 60 * 60 + minutes * 60 + seconds) * 1000) + milliseconds; | |
| } | |
| function formatTimestamp(startTimeMs: number | undefined): string { | |
| if (startTimeMs === undefined) { | |
| return "[--:--]"; | |
| } | |
| const totalSeconds = Math.floor(startTimeMs / 1000); | |
| const hours = Math.floor(totalSeconds / 3600); | |
| const minutes = Math.floor((totalSeconds % 3600) / 60); | |
| const seconds = totalSeconds % 60; | |
| const time = `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; | |
| return hours === 0 ? `[${time}]` : `[${String(hours).padStart(2, "0")}:${time}]`; | |
| } | |
| function parseJson3(raw: string): TranscriptSegment[] { | |
| const parsed: unknown = JSON.parse(raw); | |
| if (!isRecord(parsed) || !Array.isArray(parsed["events"])) { | |
| return []; | |
| } | |
| const segments: TranscriptSegment[] = []; | |
| for (const event of parsed["events"]) { | |
| if (!isRecord(event) || !Array.isArray(event["segs"])) { | |
| continue; | |
| } | |
| let text = ""; | |
| for (const segment of event["segs"]) { | |
| if (isRecord(segment) && typeof segment["utf8"] === "string") { | |
| text += segment["utf8"]; | |
| } | |
| } | |
| const cleaned = cleanText(text); | |
| if (cleaned.length > 0) { | |
| const startTimeMs = getNumber(event["tStartMs"]); | |
| segments.push({ ...(startTimeMs === null ? {} : { startTimeMs }), text: cleaned }); | |
| } | |
| } | |
| return segments; | |
| } | |
| function parseTimedText(raw: string): TranscriptSegment[] { | |
| const segments: TranscriptSegment[] = []; | |
| let textLines: string[] = []; | |
| let startTimeMs: number | undefined; | |
| function flush(): void { | |
| const text = cleanText(textLines.join(" ")); | |
| textLines = []; | |
| if (text.length === 0 || segments.at(-1)?.text === text) { | |
| return; | |
| } | |
| segments.push({ ...(startTimeMs === undefined ? {} : { startTimeMs }), text }); | |
| startTimeMs = undefined; | |
| } | |
| for (const line of raw.split(/\r?\n/)) { | |
| const trimmed = line.trim(); | |
| if (trimmed.length === 0) { | |
| flush(); | |
| continue; | |
| } | |
| if (trimmed === "WEBVTT" || trimmed.startsWith("Kind:") || trimmed.startsWith("Language:")) { | |
| continue; | |
| } | |
| if (/^\d+$/.test(trimmed)) { | |
| continue; | |
| } | |
| const timing = /^(\d{1,2}:\d{2}(?::\d{2})?[,.]\d{3})\s+-->\s+\d{1,2}:\d{2}(?::\d{2})?[,.]\d{3}/.exec(trimmed); | |
| if (timing !== null) { | |
| flush(); | |
| startTimeMs = parseTimestamp(timing[1] ?? ""); | |
| continue; | |
| } | |
| textLines.push(trimmed); | |
| } | |
| flush(); | |
| return segments; | |
| } | |
| function parsePlainText(raw: string): TranscriptSegment[] { | |
| const text = cleanText(raw); | |
| return text.length === 0 ? [] : [{ text }]; | |
| } | |
| function parseTranscript(raw: string, format: CaptionFormat): TranscriptSegment[] { | |
| if (format.ext === "json3") { | |
| return parseJson3(raw); | |
| } | |
| if (format.ext === "vtt" || format.ext === "srt" || format.ext === "ttml") { | |
| return parseTimedText(raw); | |
| } | |
| return parsePlainText(raw); | |
| } | |
| function commandErrorMessage(error: unknown, fallback: string): string { | |
| if (isRecord(error)) { | |
| const stderr = "stderr" in error ? String(error.stderr).trim() : ""; | |
| const stdout = "stdout" in error ? String(error.stdout).trim() : ""; | |
| const detail = stderr || stdout; | |
| if (detail.length > 0) { | |
| return `${fallback}:\n${detail}`; | |
| } | |
| } | |
| return error instanceof Error ? error.message : fallback; | |
| } | |
| async function readYtDlpJson(url: string): Promise<Record<string, unknown>> { | |
| try { | |
| const raw = await $`yt-dlp --skip-download --dump-single-json --no-warnings --no-playlist ${url}`.text(); | |
| const parsed: unknown = JSON.parse(raw); | |
| if (!isRecord(parsed)) { | |
| fail("yt-dlp returned non-object JSON"); | |
| } | |
| return parsed; | |
| } catch (error) { | |
| fail(commandErrorMessage(error, "yt-dlp failed")); | |
| } | |
| } | |
| async function main(): Promise<void> { | |
| const url = parseArgs(); | |
| const info = await readYtDlpJson(url); | |
| const candidate = buildCandidates(info)[0]; | |
| if (candidate === undefined || candidate.format.url === undefined) { | |
| fail("No transcript found"); | |
| } | |
| const response = await fetch(candidate.format.url); | |
| if (!response.ok) { | |
| fail(`Transcript download failed: HTTP ${response.status} ${response.statusText}`); | |
| } | |
| const segments = parseTranscript(await response.text(), candidate.format); | |
| if (segments.length === 0) { | |
| fail("Transcript parsed to zero text segments"); | |
| } | |
| process.stdout.write(`${segments.map((segment) => `${formatTimestamp(segment.startTimeMs)} ${segment.text}`).join("\n")}\n`); | |
| } | |
| if (import.meta.main) { | |
| await main(); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment