Skip to content

Repository files navigation

YouTube Transcript Scraper

YouTube Transcript Scraper

YouTube Transcript Scraper for extracting video transcripts, subtitles and closed captions with timestamps, in any published language, from YouTube.com. This repo has a free YouTube transcript web scraping script you can run right now, and a YouTube transcript API that returns timestamped segments and clean plain text for any watch URL.

This is the transcript endpoint on its own. The YouTube Scraper repo covers the rest of the surface: video, search, channels, playlists, comments and Shorts.

Last updated: 2026-07-20. Working against YouTube.com as of July 2026, and re-verified whenever YouTube changes their markup.

Every JSON block on this page was captured from the live API on 2026-07-20. The 3Blue1Brown transcript sample's segments array is trimmed for length (a transcript is copyrighted speech), and each block says exactly what was cut; the scalar fields and the segment_count/word_count/char_count totals are the real full-transcript values, verbatim. Samples are committed in youtube_transcript_scraper_api_data/. Every code example calls the actual API and is runnable from youtube_transcript_scraper_api_codes/.

pip install requests
export CHOCODATA_API_KEY="your_key"     # free: 1,000 requests, one-time, no card
python youtube_transcript_scraper_api_codes/transcript.py

Those three lines return this, live from YouTube.com:

{
  "language_name": "English",
  "is_generated": false,
  "segment_count": 286,
  "word_count": 3357,
  "available_languages": ["ar", "bn", "zh", "...", "fr", "de", "es", "..."],
  "segments": [{ "text": "This is a 3.", "start": 4.22, "duration": 1.18 }]
}

That is a human-written English transcript of a 3Blue1Brown lecture, with 31 published caption tracks. Here is the full field set for one video:

Retrieved YouTube transcript data

That is the whole point of this repo. The rest of this page is the reference: the parameters, the real response, and the fields worth knowing about before you build on them.


Contents


Free YouTube Transcript Scraper

YouTube publishes closed-caption tracks for most videos, and you can read them without a headless browser or the YouTube Data API. It takes two public hops: read the watch page for the InnerTube API key, then ask the ANDROID InnerTube /player endpoint for the caption track list. No key, no cost:

python free_scraper/youtube_transcript_free_scraper.py "https://www.youtube.com/watch?v=aircAruvnKk"
python free_scraper/youtube_transcript_free_scraper.py aircAruvnKk --lang fr

Source: free_scraper/youtube_transcript_free_scraper.py. It parses both timedtext shapes YouTube serves (<text start dur> in seconds and <p t d> in milliseconds), HTML-unescapes the caption text, and joins the segments into plain text.

After running the command, your terminal should look something like this:

Free YouTube transcript scraper parsing a real video

It works. For aircAruvnKk it returns the same 286 segments and 3,357 words the API does, and --lang fr returns the 288-segment French track. youtube-transcript-api uses the same InnerTube hop and returns the same segments, and yt-dlp fetches subtitles too. So the free path is real for a single video, and the interesting question is what happens next, which is the subject of the following section.

Avoid getting blocked when scraping YouTube transcripts

Getting a hard block is not the failure mode on caption data. Everything below returns HTTP 200. The failures are quieter, and all of them are measured against YouTube on 2026-07-20.

First, the caption URL on the desktop watch page is a trap. The obvious free approach, and what most copy-paste gists do, is to read ytInitialPlayerResponse.captions off the watch page and fetch captionTracks[0].baseUrl. That URL now carries &exp=xpe and is PoToken-gated: fetching it returns HTTP 200 with a zero-byte body. Measured on three videos (aircAruvnKk, zSgiXGELjbc, dQw4w9WgXcQ), the watch page loaded at 1.2 to 1.5 MB and listed 6 to 31 caption tracks every time, and every one of those baseUrls returned 0 bytes. A scraper built on the watch-page caption URL gets an empty transcript and a 200, not an error. The ANDROID InnerTube /player caption URL is signed and not gated, which is why the free script here and youtube-transcript-api both use it.

Second, "no transcript" is not one thing. A video can be reachable and still have no usable captions for five distinct reasons, and the API tells them apart instead of returning an empty string. When there is no transcript the response is ok with transcript_available: false and a reason:

{ "video_id": "LXb3EKWsInQ", "transcript_available": false, "reason": "transcripts_disabled" }

The reasons are transcripts_disabled, none_found, members_only, age_restricted and video_unavailable. Coding against reason is the difference between "skip this video, captions are off" and "retry, the fetch failed".

Third, available_languages is not the same as translated text. The array lists every caption track the uploader or YouTube published. On aircAruvnKk that is 31 entries including a human French and Spanish track, so lang=fr returns real French. But most videos carry one auto-generated track, and asking for a language that is not in the list falls back to the first track rather than translating. Read available_languages before you assume lang=de will give you German.

Here is the full picture, and what each item costs you:

What bites you Why What it costs you
The watch-page caption URL returns an empty 200 captionTracks[0].baseUrl carries &exp=xpe and is PoToken-gated. A scraper that reads it silently produces empty transcripts and never errors.
Captions are auto-generated on most videos is_generated: true / source: "asr" means machine transcription, with its punctuation and homophone mistakes. Text quality varies by video; you cannot assume human-edited accuracy.
Segment timing comes in two XML shapes Older tracks use <text start dur> in seconds; newer ones use <p t d> in milliseconds. A parser that handles one shape returns nothing on the other.
lang falls back rather than translating Asking for a code not in available_languages returns the first track, not a translation. You get a transcript in the wrong language and a 200, unless you check the list.
The timedtext endpoint is per-IP rate limited Fetching many caption URLs from one address gets throttled. Bulk transcript work needs IP rotation you would otherwise build and maintain.

Google's own official route does not fill this gap. The YouTube Data API captions.download method is documented, verbatim, as:

"This method is requires the user to have permission to edit the video."

"A call to this method has a quota cost of 200 units."

That is a tool for a channel downloading captions off its own videos at 200 quota units a call, not a way to read the transcript of an arbitrary public video. Reading someone else's public captions is exactly what the free script above and the API below do.

So the two paths, side by side, same video, same day:

Free YouTube transcript scraper vs Chocodata API, measured

Both return the same 286 segments. The free path is a real deliverable for one video. The difference is the two hops, the PoToken trap, the two XML shapes and the per-IP throttle, all of which you own on the free path and none of which you touch on the API.


Using the Chocodata YouTube Transcript Scraper API

The managed option, and the one this repo is built around: the Chocodata YouTube Transcript Scraper API. One GET request per video, the caption track selected and fetched for you, segments plus plain text returned as JSON, a ~99% success rate, and no proxy management. It picks a human track over an auto-generated one in your requested language when both exist, and normalises the two timedtext shapes into one segment shape with start and duration. Free for the first 1,000 requests.


YouTube Transcript Scraper API reference

Below is the YouTube Transcript Scraper API reference to get you started: authentication, the error bodies, the rate limits, and the endpoint itself.

Quickstart

curl "https://api.chocodata.com/api/v1/youtube/transcript?api_key=YOUR_KEY&video_id=aircAruvnKk"
import requests

r = requests.get(
    "https://api.chocodata.com/api/v1/youtube/transcript",
    params={"api_key": "YOUR_KEY", "video_id": "aircAruvnKk", "format": "both"},
    timeout=90,
)
t = r.json()
print(t["language_name"], t["segment_count"], t["word_count"])
# English 286 3357

After running the command, your terminal should look something like this:

Running the YouTube Transcript Scraper API

Authentication

Pass your key as the api_key query parameter. There is no header form and no OAuth step.

https://api.chocodata.com/api/v1/youtube/transcript?api_key=YOUR_KEY&video_id=...

A free key is 1,000 requests, one time, with no card. Get one at chocodata.com.

Errors

Nothing below is billed: you are only charged on a 2xx. A reachable video with no captions is a 200 with transcript_available: false, not an error, so it does not cost a credit either.

Status error code Meaning Billed What to do
400 invalid_params Neither video_id nor url was supplied. The body names the missing param and its path. no Fix the query string.
401 INVALID_API_KEY Key missing, unrecognised, or revoked. no Check api_key. Get one at chocodata.com.
402 INSUFFICIENT_CREDITS Balance exhausted. no Top up or upgrade.
429 RATE_LIMITED Over 120 requests/60s, or over your plan's concurrency. no Back off and retry.
502 target_unreachable YouTube did not return the caption data for this request. A dead or unparseable video id lands here too. no Retry once after a few seconds.

A bad key, verbatim:

curl "https://api.chocodata.com/api/v1/youtube/transcript?api_key=totally_invalid_key&video_id=aircAruvnKk"
{"error": {"code": "INVALID_API_KEY", "message": "Api key not recognised."}}

A missing required param, verbatim. Note that it names the real parameter, which is the fastest way to find out the endpoint takes video_id and not id:

{"error": "invalid_params", "issues": [{"code": "custom", "message": "youtube.transcript requires `video_id` or `url`", "path": ["video_id"]}]}

Two response shapes exist: auth and billing errors nest under error.code (uppercase), while parameter and scrape-layer errors are flat with a lowercase error string.

The scripts in this repo turn each of these errors into an actionable message, so a typo'd key does not hand you a stack trace:

YouTube Transcript Scraper API error handling

Rate limits and concurrency

Two separate limits apply, and they are enforced independently.

Limit Value
Requests per key 120 per 60 seconds (sliding window)
Concurrent requests, Free 10
Concurrent requests, Vibe 30
Concurrent requests, Pro 50
Concurrent requests, Custom 100

Exceed either and you get 429, not a queue. Every call is a synchronous GET: there is no webhook, callback, or async job to poll. The examples use timeout=90 because the slowest successful call measured for the table below took 14.4s and you want headroom.

Fan out with a thread pool, sized to stay inside both limits at once:

from concurrent.futures import ThreadPoolExecutor
import requests

def one(video_id):
    r = requests.get("https://api.chocodata.com/api/v1/youtube/transcript",
                     params={"api_key": KEY, "video_id": video_id, "format": "text"}, timeout=90)
    return r.json() if r.status_code == 200 else None

with ThreadPoolExecutor(max_workers=8) as pool:
    transcripts = [t for t in pool.map(one, video_ids) if t]

Transcript: segments, timestamps, languages and plain text

The full transcript for one video: timestamped segments, the combined plain text, the language and whether it was auto-generated, and the list of every published caption language.

Param Type Required Default Description
video_id string one of video_id/url - The 11-char watch id (e.g. aircAruvnKk), or any video URL. Note the name: video_id, not id.
url string (URL) one of video_id/url - A full video URL. watch?v=, youtu.be/, /shorts/, /embed/ and /live/ forms are parsed for the id.
lang string no en Preferred caption language code. Prefers a human track, then any track in that code, then the first. Falls back to the first track when the code is not in available_languages (it does not translate).
format string no both both returns segments and text; segments omits text; text omits segments. word_count and char_count are always present.
units string no seconds seconds gives start/duration as decimals; ms gives raw integer milliseconds.
country string no US ISO-3166 alpha-2 egress country hint.
api_key string yes - Your key. Query parameter, not a header.
curl "https://api.chocodata.com/api/v1/youtube/transcript?api_key=YOUR_KEY&video_id=aircAruvnKk&format=both&units=seconds"

Real response. available_languages shown in full (31); segments cut to 3 here (the committed sample has 12), of 286 total; segment_count, word_count and char_count are the real full-transcript totals, and every scalar field is verbatim (committed sample):

{
  "video_id": "aircAruvnKk",
  "url": "https://www.youtube.com/watch?v=aircAruvnKk",
  "transcript_available": true,
  "language": "en",
  "language_name": "English",
  "is_generated": false,
  "source": "native",
  "segment_count": 286,
  "time_unit": "seconds",
  "word_count": 3357,
  "char_count": 18430,
  "available_languages": ["ar", "bn", "zh", "zh-CN", "zh-TW", "cs", "en", "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"],
  "segments": [
    { "text": "This is a 3.", "start": 4.22, "duration": 1.18 },
    { "text": "It's sloppily written and rendered at an extremely low resolution of 28x28 pixels,", "start": 6.06, "duration": 4.653 },
    { "text": "but your brain has no trouble recognizing it as a 3.", "start": 10.713, "duration": 3.007 }
  ]
}

segments is the field most people come for: each row is one caption line with text, start and duration. A few things worth knowing before you build on it, all measured on the videos sampled on 2026-07-20:

  • is_generated / source tell you the caption quality. false / "native" is a human or uploaded track; true / "asr" is YouTube's auto-generated transcription, which carries the usual machine punctuation and homophone errors. aircAruvnKk is native; most videos are asr.
  • segment_count is the row count, word_count and char_count cover the whole transcript. They stay accurate even when you request format=segments or format=text, and even in the trimmed sample above where segments is cut to 12.
  • available_languages can list a code twice ("en" appears twice here: a native and an auto-generated English track). Passing lang prefers the human one.
  • start and duration are seconds by default. Set units=ms for raw integer milliseconds (4.22 becomes 4220), which is often easier for building SRT or seeking.

A second committed sample, transcript_text.json, is a short NASA clip (public-domain government footage) fetched with url and format=text, so the plain-text shape and the URL parameter are both demonstrated. It carries word_count and char_count but no segments array. Note that YouTube captions include [sound cue] and [speaker] tags inline; text cut to its first sentences here, verbatim:

{
  "video_id": "3Yqi3D4b2RQ",
  "transcript_available": true,
  "language": "en",
  "is_generated": false,
  "segment_count": 17,
  "word_count": 75,
  "char_count": 502,
  "text": "[Dramatic music] [Jared Isaacman] Everybody understands what's at stake right now. The difference between success and failure will be measured in months, not years. [Go/No-Go poll] ..."
}

Runnable: youtube_transcript_scraper_api_codes/transcript.py


Fetch transcripts for a list of videos

The job most people arrive with: a list of video URLs (a playlist export, a content audit, a research corpus) and a need for one clean .txt of speech per video, ready to feed a search index or a summariser.

export CHOCODATA_API_KEY="your_key"
python youtube_transcript_scraper_api_codes/transcripts_to_text.py

It reads video_urls.txt if present, one URL or id per line, otherwise runs the three sample videos. It requests format=text, writes one file per video into transcripts/, and reports a captions-disabled video instead of crashing on it:

Fetching transcripts for a list of videos

Source: youtube_transcript_scraper_api_codes/transcripts_to_text.py


Measured latency

Ten consecutive calls to /youtube/transcript for the same video id, 2 seconds apart, on 2026-07-20. All ten returned 200:

Measured latency for the YouTube transcript endpoint

Metric Value
calls attempted 10
200 responses 10
min 1,884 ms
median 3,341 ms
max 14,395 ms

Ten calls is a small sample and the spread is wide (one call ran to 14.4s), which is why the examples set timeout=90 rather than sizing to the median.


License

MIT. See LICENSE.