YouTube Transcripts for RAG avatar

YouTube Transcripts for RAG

Pricing

from $5.00 / 1,000 transcripts

Go to Apify Store
YouTube Transcripts for RAG

YouTube Transcripts for RAG

Turn YouTube videos into retrieval-ready text. Per video you get a full plain transcript plus token-bounded chunks (real cl100k tokens, your size and overlap) each carrying start and end timestamps, a deep link to the moment, and a deterministic chunk id for idempotent upserts. No API key, no proxy.

Pricing

from $5.00 / 1,000 transcripts

Rating

0.0

(0)

Developer

Omar Nagy

Omar Nagy

Maintained by Community

Actor stats

0

Bookmarked

1

Total users

0

Monthly active users

11 days ago

Last modified

Share

Turn a list of YouTube videos into retrieval-ready text: one full plain-text transcript per video, plus token-bounded chunks that each carry a start and end timestamp, the video id, a deep link to that exact moment, and a deterministic chunk id you can use as an idempotent upsert key in a vector store.

No API key. No Google credentials. No Google account.

It uses residential egress, on purpose, for one request per video. YouTube refuses caption metadata to datacenter IP addresses including Apify's, so the single request that reads a video's caption list goes out through Apify's RESIDENTIAL proxy group. The caption text itself, which is the bulk of the bytes, is downloaded on the ordinary datacenter connection. Measured on the platform, that costs 44 KB of residential bandwidth per video, and every run reports what it spent. See "Residential egress and bandwidth".

Who it is for

The concrete use case is an AI agent or a RAG pipeline that has to answer questions about video content and cite the moment it came from. You point it at a conference talk, a lecture series, a podcast back catalogue or a competitor's channel, and you get rows that go straight into an embedding call. Because every chunk carries startSeconds and a timestampUrl, the answer your agent produces can link back to the second in the video where the claim was made, which is the difference between a citation a human trusts and a paraphrase they do not.

It also suits:

  • Building a searchable knowledge base out of a team's recorded talks or trainings.
  • Feeding an LLM the actual words of a long video instead of its description.
  • Language and content research where you need to know which caption tracks exist before you commit to a language.

Why the chunking is the point

Most transcript tools hand you a wall of text and leave the hard part to you. This one does the part that decides whether retrieval works:

  • Real tokens, not characters. Chunk size is counted with the cl100k_base BPE encoding, the one used by OpenAI's text-embedding-3 models and GPT-4 class models. Every finished chunk is re-encoded and shrunk until it genuinely fits, so a 400-token chunk is never 438 tokens.
  • Timestamps survive chunking. Chunks are packed out of caption segments, so startSeconds and endSeconds are the real boundaries of the speech inside that chunk.
  • Overlap so sentences are not cut in half. The default 40-token overlap repeats the tail of one chunk at the head of the next, so a claim split across a boundary still retrieves.
  • Deterministic chunk ids. The id is a hash of the video, language, chunk settings, position and text. Re-running the Actor on the same video produces the same ids, so a re-run is an upsert, not a pile of duplicates.
  • An honest language story. You give a fallback chain, and the output tells you which code was actually used, which codes were tried, whether the track was auto-generated, and every caption language the video has.

Modes

ModeWhat it doesCharged
chunks (default)One video record with the full transcript, plus one record per chunktranscript and chunk
transcriptOne video record with the full transcript, no chunkstranscript
languagesLists the caption tracks a video has, downloads none of themfree

Any other value for mode is rejected as a bad request. It is never treated as a default and never silently ignored. Each mode is also a Standby HTTP route of the same name, see below.

Input

FieldTypeDefaultNotes
videosarray of stringsRequired in every mode. Video URLs or 11-character ids. Watch, youtu.be, Shorts, embed and live links all work. Deduplicated, order preserved.
modechunks, transcript, languageschunksanything else is a 400
languagesarray of strings["en"]fallback chain, tried in order, exact match then prefix (en accepts en-GB). Ignored when mode is languages.
allowAutoGeneratedbooleantrueoff means only human-written tracks count. Ignored when mode is languages.
allowAnyLanguagebooleanfalseon means take the first available track when the chain misses. Ignored when mode is languages.
chunkTokensinteger 50 to 4000400only used when mode is chunks
chunkOverlapTokensinteger 0 to 200040only used when mode is chunks, and must be smaller than chunkTokens or the run fails with a bad-request message
includeFullTranscriptbooleantrueoff drops fullText from the video record. Always on when mode is transcript.
maxVideosinteger 1 to 50050safety cap applied before any request
proxyConfigurationobjectApify Proxy, RESIDENTIAL groupthe lane used for caption discovery. Leave it on residential unless you know your egress is not gated by YouTube.
residentialBudgetMbinteger 1 to 10000200hard ceiling on residential traffic for the run. On reaching it the run stops cleanly, every remaining video gets a budget_exceeded record, and nothing more is charged.
{
"mode": "chunks",
"videos": [
"https://www.youtube.com/watch?v=aircAruvnKk",
"https://www.youtube.com/watch?v=jNQXAC9IVRw"
],
"languages": ["en"],
"chunkTokens": 400,
"chunkOverlapTokens": 40,
"allowAutoGenerated": true,
"includeFullTranscript": true
}

Output

Two record types in one dataset, told apart by type. Two dataset views ship with the Actor, Chunks and Videos, so you can export either shape on its own.

Real output, from a run on 5 September 2026. One video record per video:

{
"type": "video",
"videoId": "aircAruvnKk",
"videoUrl": "https://www.youtube.com/watch?v=aircAruvnKk",
"mode": "chunks",
"languagesRequested": ["en"],
"status": "ok",
"title": "But what is a neural network? | Deep learning chapter 1",
"channel": "3Blue1Brown",
"channelId": "UCYO_jab_esuFRV4b17AJtAw",
"videoDurationSeconds": 1120,
"viewCount": 24124707,
"language": "en",
"isAutoGenerated": false,
"languagesTried": ["en"],
"languageFallbackUsed": false,
"availableLanguages": ["ar", "bn", "zh", "zh-CN", "zh-TW", "cs", "en", "fil", "fr", "de", "el", "iw", "hi", "hu", "it", "ja", "ko", "mr", "fa", "fa-IR", "pl", "pt", "pt-BR", "ro", "ru", "es", "th", "tr", "uk", "ur"],
"autoGeneratedLanguages": ["en"],
"retrievalSource": "residential:player:IOS",
"captionLane": "datacenter",
"captionFormat": "xml",
"segmentCount": 286,
"chunkCount": 11,
"transcriptChars": 18430,
"transcriptTokens": 3817,
"chunkTokens": 400,
"chunkOverlapTokens": 40,
"residentialBytes": 59671,
"datacenterBytes": 14800,
"fullText": "This is a 3. It's sloppily written and rendered at an extremely low resolution ..."
}

retrievalSource names the lane and client that found the caption list, captionLane the lane that downloaded the text, and the two byte counts are what this one video cost.

And one chunk record per chunk:

{
"type": "chunk",
"chunkId": "aircAruvnKk-c9529d79aed76bde",
"videoId": "aircAruvnKk",
"videoUrl": "https://www.youtube.com/watch?v=aircAruvnKk",
"title": "But what is a neural network? | Deep learning chapter 1",
"channel": "3Blue1Brown",
"language": "en",
"isAutoGenerated": false,
"chunkIndex": 1,
"text": "What we're going to do is put together a neural network that can learn to recognize handwritten digits. This is a somewhat classic example for introducing the topic, and I'm happy to stick with the status quo here, because at the end of the two videos I want to point you to a couple good resources ...",
"tokens": 396,
"startSeconds": 100.96,
"endSeconds": 214.16,
"segmentCount": 30,
"timestampUrl": "https://www.youtube.com/watch?v=aircAruvnKk&t=100s"
}

A video whose captions could not be retrieved gets a record too, so nothing disappears silently:

{
"type": "video",
"videoId": "aircAruvnKk",
"status": "error",
"errorType": "captions_unreachable",
"error": "Caption metadata could not be reached from this network. Every Innertube client was refused (playability: LOGIN_REQUIRED, ERROR; watch page HTTP 200). This usually means the request came from a datacenter IP that YouTube gates."
}

errorType values: captions_unreachable (the network was refused, the video may well have captions), no_captions (YouTube says the video is playable and publishes no caption track), captions_disabled, language_not_available, empty_transcript, video_unavailable, rate_limited, fetch_failed. The first two are deliberately separate, because reporting a blocked network as "this video has no captions" would teach an agent something false.

Larger samples are in the examples/ folder of the source.

Use it from an agent (Standby)

Standby keeps the Actor warm and answers over plain HTTP, so an agent calling mid-task pays no container start:

GET {standbyUrl}/chunks?videos=aircAruvnKk&chunkTokens=400&chunkOverlapTokens=40
GET {standbyUrl}/transcript?videos=aircAruvnKk&languages=en
GET {standbyUrl}/languages?videos=aircAruvnKk
GET {standbyUrl}/

videos and languages accept a comma-separated list. Every other input field works as a query parameter with the same name and the same defaults. GET / returns service info and the mode list.

A successful response is:

{ "ok": true, "mode": "chunks", "videos": 1, "transcribed": 1, "failed": 0, "chunks": 1, "count": 2, "items": [ ... ], "tookMs": 1731 }

items holds the same records the dataset would receive, video record first. Errors are explicit and correctly typed:

  • an unknown path is 404 with {"ok": false, "error": "Unknown path \"/reviews\". Available: /chunks, /transcript, /languages."}
  • a bad or missing parameter is 400, for example {"ok": false, "error": "\"videos\" is required: give at least one YouTube video URL or 11-character video id."}
  • only a genuine fault returns 500

Residential egress and bandwidth

YouTube decides whether to serve caption metadata based on the IP address asking. From Apify's datacenter egress every Innertube client answers HTTP 200 with playabilityStatus: LOGIN_REQUIRED, reason "Sign in to confirm you're not a bot", and zero caption tracks. The same request from a residential exit answers OK with the full tracklist. That is a property of YouTube, not of this code.

So the Actor splits its work across two lanes and spends the expensive one as sparingly as it can.

RequestLaneTypical sizeWhy
Innertube player call (the caption tracklist)residential, after a free datacenter attempt28 to 60 KBthe only request YouTube gates
Caption text download (timedtext)datacenter, residential only if refused1.4 to 62 KBa timedtext URL minted by a residential call is honoured from another IP, so the biggest payload stays on the cheap lane
Title and channel fallback (oembed)datacenterunder 1 KBnever gated

Three design choices come out of that, all measured rather than assumed:

  • The client ladder is ordered by what works. Measured across both lanes on 5 September 2026, only the IOS and ANDROID clients ever return caption tracks. ANDROID_VR, TVHTML5_SIMPLY_EMBEDDED_PLAYER, WEB_EMBEDDED_PLAYER, MWEB and WEB never do. Asking those first, as an earlier version did, burned about 30 KB of residential bandwidth per video to learn nothing. They remain at the tail of the ladder only so a future YouTube change has somewhere to land.
  • The datacenter lane is tried first, every time. On a host YouTube does not gate, the whole job runs without touching residential at all and costs nothing in proxy bandwidth. Verified: from an ordinary residential ISP connection all three sample videos are transcribed with residentialBytes: 0.
  • Requests are compressed and counted at the socket. Every request asks for gzip, and the byte figures reported are TCP-level bytesRead plus bytesWritten on a fresh socket, after TLS and after compression. They are what the proxy meters, not an optimistic count of decoded characters.

Every video record carries residentialBytes and datacenterBytes, and the run log and status message carry the run totals and the residential bytes per transcript.

If your plan has no residential proxy the Actor does not crash. It runs the datacenter lane, and any video YouTube gates comes back with errorType: "residential_proxy_required" and a message saying so. Turn on the operator flag debugConnectivity to log every attempt with its lane, HTTP status and playability status.

Pay-per-event

Two events, both charged per unit, no start fee. The live rates are always the ones on the Pricing tab.

EventMeaningRate
transcriptone video whose captions were fetched, decoded and normalized$0.005
chunkone chunk emitted$0.00005

What that works out to, from the three sample videos:

VideoLengthChunksCost
Me at the zoo19 seconds1$0.00505
But what is a neural network?18 minutes11$0.00555
Let's build GPT: from scratch1 hour 56 minutes63$0.00815

Rules that keep the bill honest:

  • A failed video costs nothing. Only a video that actually produced a transcript is charged, and every error record is free.
  • mode: "languages" charges nothing at all, because listing a video's caption tracks is free.
  • A run stopped by residentialBudgetMb charges nothing for the videos it did not reach.
  • No start fee, so trying the Actor on one video costs half a cent rather than a minimum.

Limits

  • Captions must already exist. This Actor reads YouTube's caption tracks. It does not transcribe audio, so a video with captions turned off returns an error record for that video, not silence.
  • Auto-generated captions are machine text. No punctuation, no speaker labels, and real recognition errors on accents and jargon. Set allowAutoGenerated: false to refuse them.
  • One bad video does not fail the run. A video that could not be retrieved is written to the dataset with status: "error" and an errorType, and the run continues. Only a run where every video failed is marked failed, and a run stopped by its own bandwidth budget is not: a limit doing its job is a successful run with explicit records, not a failure.
  • The error types are no_captions (playable, but the uploader published no track), language_not_available, empty_transcript, residential_proxy_required (this run's network was gated and no residential lane was available), captions_unreachable (both lanes refused) and budget_exceeded.
  • Rate limiting. Videos are paced with a short pause, and each residential request uses a fresh exit IP, so a gated address is retried from a different one rather than sticking.
  • A single caption segment longer than chunkTokens is emitted as its own oversized chunk rather than being split at an invented boundary. It is rare, and the chunk's real tokens value tells you when it happened.
  • Timestamps come from the caption track, so they are as precise as the captions are, which for auto-generated tracks means roughly phrase-level.

What it does not do

It does not transcribe audio, download video or audio files, read private, unlisted or age-restricted videos, translate captions, diarize speakers, bypass any bot check, or call any paid API. If a video has no caption track, this Actor cannot produce a transcript for it.

FAQ

Which token encoding is used? cl100k_base. That is the encoding behind text-embedding-3-small, text-embedding-3-large and GPT-4 class models. Counts for other tokenizers are close but not identical.

Can I get a transcript in a language the video was not spoken in? Only if the uploader published a caption track in that language. Put the codes you want in languages and the output reports what it found. This Actor does not translate.

What is timestampUrl for? It is a watch link with &t= set to the chunk's start second, so a citation in your agent's answer opens the video at the right moment.

Is the chunk id stable? Yes, for the same video, language, chunkTokens, chunkOverlapTokens, position and text. Change the chunk settings and you get different chunks, so you get different ids, which is the correct behaviour. The id is also identical whichever retrieval layer produced the transcript.

Why did I get captions_unreachable or residential_proxy_required for a video I can see captions on? Your network was refused, not the video. residential_proxy_required means the run had no residential lane to retry through, so add Apify Proxy with the RESIDENTIAL group. Read "Residential egress and bandwidth" above.

How much residential bandwidth will a big run use? About 44 KB per video, so roughly 4.3 MB per 100 videos. residentialBudgetMb caps it per run and defaults to 200 MB, which covers around 4,600 videos.


Built by Omar Nagy. Part of an agent-native data-tool series on Apify.