Docs/Skills and code/src/

src/transcript-fetch.js

Code122 lines5.1 KBsrc/

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(/&lt;/g, "<")
    .replace(/&gt;/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.