Skip to content

Instantly share code, notes, and snippets.

@meoyawn
Last active July 29, 2026 19:06
Show Gist options
  • Select an option

  • Save meoyawn/58cb06909c9eacb2202c85ea12542c12 to your computer and use it in GitHub Desktop.

Select an option

Save meoyawn/58cb06909c9eacb2202c85ea12542c12 to your computer and use it in GitHub Desktop.
#!/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("&amp;", "&")
.replaceAll("&gt;", ">")
.replaceAll("&lt;", "<")
.replaceAll("&quot;", '"')
.replaceAll("&#39;", "'")
.replaceAll("&apos;", "'");
}
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