Docs/Skills and code/src/
src/transcript-fetch.js
Mentioned on: page 12
import { Innertube, Log } from "youtubei.js";
import { fetchTranscript } from "youtube-transcript-plus";
// youtubei.js logs a parser warning for every YouTube node type it doesn't know yet.
Log.setLevel(Log.Level.NONE);
let client = null;
const getClient = async () => (client ??= await Innertube.create());
function decodeEntities(s) {
return String(s ?? "")
.replace(/'|'/g, "'")
.replace(/"/g, '"')
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">");
}
const baseLang = code => String(code ?? "").split(/[-_.]/)[0].toLowerCase();
/**
* Works out which caption track is in the video's original spoken language.
*
* YouTube lists every dubbed audio track's captions alongside the original ones, and the
* order is arbitrary (a Hindi video can list Bangla first), so "first track" is wrong.
* 1. Multi-audio videos label the real track "<Language> original" (or mark it default).
* 2. Otherwise the auto-generated (asr) track is always in the spoken language.
* 3. Otherwise the first track.
*/
export async function detectOriginalCaptionTrack(videoId) {
const yt = await getClient();
const info = await yt.getBasicInfo(videoId);
const tracks = (info.captions?.caption_tracks ?? []).map(t => ({
lang: t.language_code,
kind: t.kind ?? "manual",
name: t.name?.text ?? t.language_code
}));
if (!tracks.length) throw new Error("This video has no caption tracks (captions disabled or none generated).");
let audio = [];
try {
audio = (info.streaming_data?.adaptive_formats ?? []).map(f => f.audio_track).filter(Boolean);
} catch { /* streaming data unavailable; fall through to caption heuristics */ }
const originalAudio = audio.find(a => /original/i.test(a.display_name ?? "")) ?? audio.find(a => a.audio_is_default);
const byLang = lang => tracks.find(t => t.lang === lang) ?? tracks.find(t => baseLang(t.lang) === baseLang(lang));
if (originalAudio) {
const hit = byLang(originalAudio.id);
if (hit) return { ...hit, method: "audio_track_original", audioTrack: originalAudio.display_name, available: tracks };
}
const asr = tracks.find(t => t.kind === "asr");
if (asr) return { ...asr, method: "auto_generated_track", available: tracks };
return { ...tracks[0], method: "first_track", available: tracks };
}
async function viaTranscriptPlus(videoId, lang) {
const items = await fetchTranscript(videoId, { lang });
return items.map(i => ({ start: Number(i.offset), duration: Number(i.duration), text: decodeEntities(i.text) }));
}
async function viaYoutubei(videoId, lang) {
const yt = await getClient();
const info = await yt.getInfo(videoId);
let transcript = await info.getTranscript();
const wanted = transcript.languages?.find(l => l.toLowerCase().startsWith(lang.toLowerCase()));
if (wanted && transcript.selectedLanguage !== wanted) transcript = await transcript.selectLanguage(wanted);
return transcript.transcript.content.body.initial_segments
.filter(s => s.snippet?.text)
.map(s => ({
start: Number(s.start_ms) / 1000,
duration: (Number(s.end_ms) - Number(s.start_ms)) / 1000,
text: decodeEntities(s.snippet.text)
}));
}
/**
* Fetches a video's transcript in its original language. No official API key needed.
* Primary: youtube-transcript-plus. Fallback: youtubei.js.
* @returns {{library, lang, languageName, detection, availableTracks, segments}}
*/
export async function fetchOriginalTranscript(videoId) {
const track = await detectOriginalCaptionTrack(videoId);
const errors = [];
for (const [library, fn] of [["youtube-transcript-plus", viaTranscriptPlus], ["youtubei.js", viaYoutubei]]) {
try {
const segments = await fn(videoId, track.lang);
if (!segments.length) throw new Error("empty transcript");
return {
library,
lang: track.lang,
languageName: track.name,
detection: track.method,
availableTracks: track.available.map(t => `${t.lang}:${t.kind}`),
segments
};
} catch (error) {
errors.push(`${library}: ${error.message}`);
}
}
throw new Error(`Transcript fetch failed for ${videoId} (${track.lang}). ${errors.join(" | ")}`);
}
/** Groups caption segments into ~30s paragraphs, each prefixed with a [mm:ss] timestamp. */
export function formatTranscriptText(segments, paragraphSeconds = 30) {
const stamp = s => {
const total = Math.max(0, Math.floor(s));
const h = Math.floor(total / 3600), m = Math.floor((total % 3600) / 60), sec = total % 60;
const mm = String(m).padStart(2, "0"), ss = String(sec).padStart(2, "0");
return h ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
};
const paragraphs = [];
let current = null;
for (const seg of segments) {
if (!current || seg.start - current.start >= paragraphSeconds) {
current = { start: seg.start, parts: [] };
paragraphs.push(current);
}
current.parts.push(seg.text);
}
return paragraphs.map(p => `[${stamp(p.start)}] ${p.parts.join(" ").replace(/\s+/g, " ").trim()}`).join("\n\n");
}
Shown as it is in the repository at build time (2026-11-03); the home directory appears as <home>. If this page and the file differ, the file is right. Back to the catalogue.