Skip to content

Build1 publisher3 min readPublished

YouTube refuses caption scrapers with a well-formed empty 200

Seven variations of the old ytInitialPlayerResponse scrape returned zero bytes from a clean residential IP on 2026-09-21. Getting text back now costs either a maintained library or a headless browser per request.

The Engineer · Build desk

What happened

  • The YouTube Data API's captions.download only works on videos you own, so third-party tools read the caption track URL out of the watch page's ytInitialPlayerResponse instead.
  • Seven variations of that scrape returned HTTP 200 with a zero-byte body on 2026-09-21 from a clean residential IP, with no error status anywhere in the exchange.
  • The missing credential is a PO Token, or Proof of Origin, generated by YouTube's BotGuard JavaScript, and a request without one gets a well-formed nothing back.
  • The maintained Python client youtube-transcript-api already handles the token and tracks YouTube's changes, listing tracks with api.list() and pulling segments with api.fetch().
  • A test on 2026-09-22 through residential proxies, three retries per video on fresh exit IPs, fetched transcripts for four of four videos and translations for none of them.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • exposure A pipeline that keys on status codes logs a success for every refusal, so nothing in the alerting tells you which day your transcript coverage went to zero.
  • constraint Clearing the token gate leaves a rate limit at roughly 30 fetches from one address, so the cost line for a transcript feature includes residential proxy egress on top of a pip dependency.
  • decision Anyone shipping a translated-subtitles claim now has to fetch one per advertised language before publishing the number, because YouTube's language listing is not evidence of delivery.
  • capability Operators get a clean diagnostic: a session that returns transcripts but no translations points at YouTube's translate path, so rotating more proxies is wasted effort.

The refusal happens at the caption fetch, not at the watch page. You can still read `ytInitialPlayerResponse` and pull a `captionTracks[].baseUrl` out of it [2]. The URL resolves and the request completes. The body has nothing in it [3]. The dev.to post that measured this names the missing ingredient as a PO Token, or Proof of Origin, generated by YouTube's BotGuard JavaScript, and describes the zero-byte 200 as a refusal [4] [5].

"Two days of my own attempts were worth less than pip install," the post's author wrote [8]. The alternative is to run BotGuard yourself in a headless browser and mint tokens, which the post reports works and costs one browser process per request [6]. That bill grows with traffic. Take the library instead and you are depending on someone else's release cadence [7].

Either path leaves the address problem untouched. After about 30 transcript fetches from a home connection, youtube-transcript-api started raising `IpBlocked`, which the post identifies as ordinary rate limiting and not the token wall [16]. The same post's language test ran 39 long-form videos [10]. At one fetch per video, a single home IP crosses the 30-fetch mark with nine videos still to go [19].

Both headline results come from one operator on two consecutive days. The scrape test was seven variations on 2026-09-21 from a clean residential IP [3]. The translation test was four videos on 2026-09-22 through residential proxies, three retries apiece, each retry on a fresh exit IP [11]. Rotating the exit on retry rules out one poisoned address. Four videos is too small a sample to separate YouTube's auto-translate path from the luck of the draw. For the zero to transfer to your workload, the path is what has to be failing.

Two of the post's defensive checks are worth copying whatever route you take to the bytes. Naive SRT timecode math turns 59.9999 seconds into `00:00:60,000`, and some players reject the entire file over that one cue [13]. Rounding to milliseconds before splitting fixes it, and the listed boundary tests are 0.9995, 59.9999, 3599.9999, 86399.9999 and a negative [14]. The second is a length assert on batched self-translation: when the endpoint returns a different number of results than you sent, a silent `zip()` shifts every subtitle after that point by one cue, and the file, in the post's words, "still looks fine until someone watches it" [15].

Preferring a language instead of requiring one is the cheap fix. Most tools ask YouTube for English and give up, so a Korean video with a perfectly good Korean transcript comes back empty and you cannot tell whether it had captions at all [9]. Preferring took that 39-video mixed English/Korean sample from a pile of empty rows to 39 transcripts [10]. The egress side stays expensive: a residential exit per request, and a new exit on every retry [18]. Datacenter addresses are worse, with a fresh cloud container usually blocked on the first call [17].

What to watch

  • Whether Transcript.translate() starts returning bytes again, or the track listing stops advertising translation languages it will not serve.
  • Whether the empty-200 refusal spreads from the caption fetch to the track listing call, which would also break language detection.
  • A larger auto-translate sample than four videos, run from datacenter and residential egress on the same day.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories