The short answer: for an unattended Node.js pipeline, one production-oriented route to YouTube transcripts is the official apify-client SDK calling the YouTube Transcript Scraper — batch up to 50 video IDs per call, get time-coded JSON back, and handle failures as data instead of exceptions ($3.00 per 1,000 delivered transcripts; a video that can't deliver returns a typed success: false row and costs $0). For notebooks and low-volume scripts from a residential IP, the free Python library remains the right tool — the trade-offs are the same ones covered in the Python guide, and JavaScript's open-source options inherit the same datacenter IP blocks.
Setup: one package, one token
npm install apify-clientThe token comes from Apify Console → Settings → API tokens. The free tier works, and billing runs per delivered result on your existing account — no YouTube Data API key, and none of your Google quota is involved.
Fetch a batch, branch on success
import { ApifyClient } from "apify-client";
const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
const run = await client
.actor("apihq/youtube-transcript-scraper")
.call({
videoIds: ["jNQXAC9IVRw", "dQw4w9WgXcQ", "00000000000"],
language: "en",
metadata: true,
});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
const transcripts = [];
const misses = [];
for (const item of items) {
if (item.success) {
transcripts.push(item); // billed: $0.003
} else {
misses.push(item); // free, machine-readable
console.warn(item.video_id, "->", item.code);
// e.g. "00000000000 -> PLAYABILITY"
}
}
console.log(`${transcripts.length} transcripts, ${misses.length} misses`);The part that matters for anything unattended: the bad ID in that batch does not throw. It arrives as a row with a stable code (NO_CAPTIONS, PLAYABILITY, DEADLINE_EXCEEDED — the full set is in the error-code registry) and a request_id for support. Your catch block is reserved for real infrastructure failures, not for content that happens to lack captions.
From segments to plain text
Each transcript arrives as time-coded segments. For search indexes, embeddings, or summaries you usually want one string:
const docs = transcripts.map((t) => ({
videoId: t.video_id,
title: t.metadata?.title,
text: t.transcript.map((s) => s.text).join(" "),
}));Scaling up: a whole channel
The transcript actor takes IDs, so for a channel you chain the YouTube Channel Scraper ($0.50 per 1,000 videos) in front and feed the IDs through in batches of 50:
const listing = await client
.actor("apihq/youtube-channel-scraper")
.call({ channelUrl: "@mkbhd", maxResults: 200 });
const { items: videos } = await client
.dataset(listing.defaultDatasetId)
.listItems();
const ids = videos.filter((v) => v.success).map((v) => v.video_id);
const corpus = [];
for (let i = 0; i < ids.length; i += 50) {
const batch = ids.slice(i, i + 50);
const r = await client
.actor("apihq/youtube-transcript-scraper")
.call({ videoIds: batch, language: "en" });
const { items } = await client.dataset(r.defaultDatasetId).listItems();
corpus.push(...items.filter((t) => t.success));
}
console.log(`Corpus: ${corpus.length} documents`);Cost check for a 200-video channel where 180 videos have captions: $0.10 for the listing plus $0.54 for the transcripts — about $0.64 for a channel-sized corpus, with the 20 captionless videos costing nothing.
When not to use this
- A few hundred videos from your laptop: the free Python library from a residential IP costs nothing — use it and skip the SDK. (Deployed to a datacenter it hits YouTube's IP blocks — that failure mode explained.)
- Videos with no captions at all: nothing caption-based can help; you'd need speech-to-text on the audio.
- Private, members-only, or age-gated videos are out of reach for every method that doesn't own the video.