How to get a YouTube transcript in 2026: libraries, yt-dlp, Whisper, hosted APIs and MCP
Getting the transcript of a YouTube video sounds like one function call. In practice the right tool depends on where your code runs, whether the video has captions at all, and whether a person or an AI agent is asking. This is a short guide to the options, with commands you can run today.
It is a companion to awesome-youtube-transcripts, a list we maintain of libraries, hosted APIs, MCP servers, agent skills and speech-to-text tools for YouTube transcripts.
Quick answer
| You want to | Start with |
| --- | --- |
| Fetch captions from a script on your own machine | youtube-transcript-api (Python) or YouTube.js (JavaScript) |
| Download subtitle files from the command line | yt-dlp |
| Transcribe a video that has no captions | faster-whisper or whisper.cpp on the audio |
| Fetch transcripts from a server or cloud function | A hosted API, or a library behind your own proxies |
| Let an AI assistant read YouTube videos | An MCP server or an agent skill |
1. A library, on your own machine
If you run code on a laptop or home connection, an open-source library is the simplest route.
Python: youtube-transcript-api. It reads manual and auto-generated captions, lists the languages a video offers, and can translate. Version 1.x uses an instance:
from youtube_transcript_api import YouTubeTranscriptApi
transcript = YouTubeTranscriptApi().fetch("dQw4w9WgXcQ", languages=["en"])
text = " ".join(snippet.text for snippet in transcript)
print(text[:200])
Each snippet also has start and duration in seconds if you need timestamps.
JavaScript: YouTube.js. A client for YouTube's internal InnerTube API; getTranscript() returns timed segments. There is a transcript example in the repo. If you want one function and nothing else, the youtube-transcript npm package has a single fetchTranscript() call, but it uses an unofficial endpoint and can break when YouTube changes.
2. yt-dlp, for subtitle files
yt-dlp can save captions without downloading the video:
yt-dlp --write-subs --write-auto-subs --sub-langs en --sub-format vtt \
--skip-download -o "%(id)s" "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
That writes dQw4w9WgXcQ.en.vtt. --write-subs takes uploaded captions, --write-auto-subs falls back to auto-generated ones, and the subtitle options section lists every format and language flag. It also handles whole playlists and channels.
3. Videos with no captions: speech-to-text
Some videos have captions turned off, or none were ever generated. Then you need the audio and a speech model:
- faster-whisper: Whisper on CTranslate2, several times faster than the original with lower memory use.
- whisper.cpp: Whisper in C/C++, runs on CPU and Apple Silicon.
- WhisperX: adds word-level timestamps and speaker diarization.
Download the audio with yt-dlp -x --audio-format m4a <url> and pass the file to the model. Expect this to take far longer than reading captions, and to cost GPU time at scale.
4. The cloud problem
Everything above works well from a home connection. Run the same code on AWS, GCP, Azure or most VPS providers and requests often fail with RequestBlocked or IpBlocked, because YouTube blocks many cloud IP ranges. The youtube-transcript-api README has a good explanation and proxy setup.
You have two choices:
1. Keep the library and route it through residential or rotating proxies you manage.
2. Use a hosted API that handles it for you.
Hosted options (all commercial; check each site for current pricing and limits):
- GetYouTubeTranscript: REST API and remote MCP server for transcripts, YouTube search, channels and playlists, with Python and Node SDKs. (Ours; see the disclosure below.)
- SearchAPI: a transcripts endpoint inside a general search API.
- Supadata: transcripts and metadata for YouTube, TikTok, Instagram, X and web pages.
- TranscriptAPI: YouTube transcripts, search, channels and playlists, with an MCP server.
- TranscriptOut: transcript API with agent skills and an MCP server.
- youtube-transcript.io: web tool and API.
A hosted call is one HTTP request. With ours, for example:
curl "https://getyoutubetranscript.com/api/v1/transcript?v=dQw4w9WgXcQ" \
-H "Authorization: Bearer $YOUTUBE_TRANSCRIPT_API_KEY"
When comparing services, test with your own videos: success rate on long videos, videos with only auto-generated captions, and non-English videos matters more than the headline price. We publish an open benchmark you can fork and point at any provider.
5. For AI assistants: MCP servers and agent skills
If the goal is "let Claude, Cursor or ChatGPT read this video", you don't need to write code at all.
MCP servers (Model Context Protocol) give an assistant a transcript tool:
- jkawamoto/mcp-youtube-transcript: runs locally, paginates long videos, supports proxy settings.
- anaisbetts/mcp-youtube: uses a local yt-dlp install.
- tubeagentkit/youtube-mcp: our hosted server (transcripts, search, channels, playlists), remote HTTP with OAuth sign-in or local stdio.
Local servers inherit the cloud problem only if your machine is in a data centre; on a laptop they work fine.
Agent skills are instruction folders that Claude Code, Cursor and Codex load when a task matches. tapestry-skills pulls transcripts as research sources, and youtube-to-ebook turns a channel's videos into an EPUB. The full list has more.
6. Framework integrations
If you already use a framework, there is probably a loader:
- LlamaIndex has a built-in YoutubeTranscriptReader.
- n8n, Haystack and Dify have community nodes and components, listed in the Framework integrations section.
Common errors
- Transcripts disabled: the uploader turned captions off. Only speech-to-text will help.
- No transcript in that language: ask for the languages the video offers first, then pick one.
- RequestBlocked / IpBlocked: you are on a cloud IP; see section 4.
- Works locally, fails in CI: same cause. CI runners are cloud machines.
We keep a longer error reference with what triggers each one.
Disclosure
We build GetYouTubeTranscript, so some tools above are ours. Competing tools are listed on the same terms, and if one is missing or described unfairly, open a PR on awesome-youtube-transcripts.
How to get YouTube transcripts in bulk with Python (no API key, works on cloud servers)
Disclosure: I built the Apify actor used in this post. It is a paid tool (pay per transcript), and I say below where it doesn't fit.
If you have tried to get YouTube transcripts from code, you have probably hit one of these walls:
1. The official YouTube Data API doesn't help. Its captions.download endpoint only works for videos you own. For anyone else's video you get a 403.
2. youtube-transcript-api works on your laptop and then dies on a server. YouTube answers requests from cloud IP ranges (AWS, GCP, Hetzner, most VPSes) with a bot check, so the same code that ran fine locally starts throwing RequestBlocked / IpBlocked in production. Developers on the library's issue tracker discuss exactly this.
3. There is no "whole channel" button. You need to list the channel's videos first, then fetch each transcript, then handle the videos that have no captions.
This post shows a way around all three from Python, in about 20 lines.
The short version: one HTTP call
YouTube Transcript Scraper runs on Apify's infrastructure. It tries a datacenter proxy first and automatically retries through a residential IP when YouTube shows a bot check, so you don't manage proxies yourself. You send video, playlist or channel URLs and get one JSON row per video.
You need a free Apify account and your API token (Console → Settings → Integrations). Then:
import requests
TOKEN = "YOUR_APIFY_TOKEN"
url = ("https://api.apify.com/v2/acts/groupoject~youtube-transcript-scraper"
f"/run-sync-get-dataset-items?token={TOKEN}")
rows = requests.post(url, json={
"urls": ["https://www.youtube.com/watch?v=iG9CE55wbtY"],
"outputFormats": ["text"],
}, timeout=320).json()
for row in rows:
print(row["title"], "-", row["wordCount"], "words")
print(row["text"][:300])
run-sync-get-dataset-items starts the run, waits for it to finish and returns the rows in one response. Apify caps synchronous calls at about 5 minutes, so use it for a handful of videos. For a whole channel, use the client library below.
Bulk: a whole channel or playlist
pip install apify-client, then:
from pathlib import Path
from apify_client import ApifyClient
client = ApifyClient("YOUR_APIFY_TOKEN")
run = client.actor("groupoject/youtube-transcript-scraper").call(run_input={
"urls": [
"@lexfridman", # a channel handle
"https://www.youtube.com/playlist?list=PLxxxxxxxxxxxx", # a playlist
],
"maxVideosPerSource": 50, # newest 50 per channel / playlist
"channelContentTypes": ["videos"], # or add "shorts", "streams"
"languages": ["en"], # preference order, e.g. ["es", "en"]
"outputFormats": ["text", "segments", "srt"],
})
out = Path("transcripts")
out.mkdir(exist_ok=True)
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
(out / f"{item['videoId']}.txt").write_text(item["text"], encoding="utf-8")
(out / f"{item['videoId']}.srt").write_text(item["srt"], encoding="utf-8")
print(item["videoId"], item["languageCode"],
"auto" if item["isAutoGenerated"] else "manual", item["title"])
A few input details worth knowing (these are the real field names from the actor's input schema):
- urls accepts watch?v= links, youtu.be links, Shorts, /live/ URLs, bare 11-character IDs, playlist URLs, channel URLs and @handles, all mixed in one list.
- maxVideosPerSource caps each playlist and each channel content type (newest first, up to 5,000). Single video URLs are not affected.
- captionType is any (default: prefer creator-uploaded, fall back to auto-generated), manual or auto.
- fallbackToAnyLanguage (default true) returns the original-language captions when none of your languages exist. Set it to false to skip those videos instead.
- includeVideoMetadata (default true) adds title, channel, publish date, views, likes, duration, description and tags to each row.
Which videos had no transcript?
Videos without captions are not pushed to the dataset and are not charged. They are listed in the run's key-value store:
store = client.key_value_store(run["defaultKeyValueStoreId"])
missing = (store.get_record("NO_TRANSCRIPT") or {}).get("value") or []
for m in missing:
print(m["videoId"], m["reason"], m.get("availableLanguages"))
reason is NO_CAPTIONS, LANGUAGE_NOT_AVAILABLE or EMPTY_CAPTIONS. Private, deleted or region-blocked videos go to the ERRORS record instead.
Feeding transcripts to an LLM or a RAG pipeline
For summarising, the text field is all you need. For retrieval with citations, use segments: each segment has start, duration and text in seconds, so you can chunk by time and keep a deep link back to the moment in the video.
def chunks(item, seconds=90):
buf, t0 = [], None
for seg in item["transcript"]:
t0 = seg["start"] if t0 is None else t0
buf.append(seg["text"])
if seg["start"] - t0 >= seconds:
yield {"text": " ".join(buf),
"source": f"{item['url']}&t={int(t0)}s",
"title": item["title"]}
buf, t0 = [], None
if buf:
yield {"text": " ".join(buf), "source": f"{item['url']}&t={int(t0)}s",
"title": item["title"]}
Each chunk can go straight into your vector store with source as metadata, so answers can link to the exact timestamp.
No-code: n8n, Make, Zapier
If you'd rather not run Python, the actor works with Apify's integrations. In n8n, add the Apify node, choose Run an Actor and Get Dataset, set the actor to groupoject~youtube-transcript-scraper and paste the JSON input above. Connect a Google Sheets or Notion node after it. In Make and Zapier the Apify app has the same "run actor" and "get dataset items" steps. You can also call it from Claude, Cursor or another MCP client through https://mcp.apify.com?tools=groupoject/youtube-transcript-scraper.
Monitor a channel for new transcripts
Turn on onlyNewVideos and give the monitor a stateKey (any name, e.g. ai-podcasts). Each run then saves only videos it hasn't saved before for that key, so you're never charged twice for the same video. Schedule it daily in Apify (Schedules → Create) and connect a Slack, email or Google Sheets integration to get each new transcript as it lands:
run = client.actor("groupoject/youtube-transcript-scraper").call(run_input={
"urls": ["https://www.youtube.com/@lexfridman"],
"onlyNewVideos": True,
"stateKey": "lex-podcast",
"publishedAfter": "7 days", # optional: ignore older uploads on the first run
})
Brand-new uploads sometimes get auto-captions a few hours late; the actor re-checks those for 24 hours instead of skipping them.
What it costs
$2.50 per 1,000 transcripts on the free plan ($0.0025 per video), with proxy traffic included. Paid Apify plans get a lower per-result rate automatically. Videos with no captions, and failed, private or deleted videos, are not charged. A 100-video playlist where every video has captions costs $0.25. You can set a maximum charge per run, and the actor stops cleanly before it goes over.
Honest limits
- It returns YouTube's own captions. It does not transcribe audio. If a video has no captions at all (music clips, some Shorts), you get nothing for it. For those you need Whisper or a speech-to-text API.
- Auto-generated captions are only as good as YouTube's speech recognition. Names and jargon are often wrong. isAutoGenerated tells you which kind you got.
- Translation (translateTo) is best effort. YouTube rate-limits its auto-translate endpoint heavily. When it refuses, you still get the original-language transcript and translationError explains why.
- No login, by design. Age-restricted, members-only and private videos are reported in ERRORS, not scraped.
- If you need a few videos a day from your laptop, you don't need this. youtube-transcript-api or yt-dlp --write-auto-subs is free and works fine from a home connection. This tool is for when you need volume, channels, or a server.
- Respect copyright. Transcripts are the creators' work. Summarising for research is one thing. Republishing them is another.
Links
- Actor: apify.com
If something breaks on a video that should work, open an issue on the actor's Issues tab with the video ID. I read them daily.
youtube-transcript-api works locally but gets blocked on your server: why, and 3 fixes
You build something with youtube-transcript-api. It works perfectly on your laptop. You deploy it to AWS, Render, Railway, Vercel or a VPS, and every call now fails with this:
youtube_transcript_api._errors.RequestBlocked: Could not retrieve a transcript for the video ...
Sometimes it shows up as IpBlocked instead. Your code didn't change and neither did the video, so what happened?
Why it happens
YouTube blocks most IP addresses that belong to cloud providers. The library's own README says so: requests from AWS, Google Cloud, Azure and similar networks are very likely to be refused. A home IP can get blocked too if it sends a lot of requests in a short time.
So the problem is where your request comes from, not your code. That's also why switching libraries rarely helps. yt-dlp on the same server usually hits the same wall, as a "Sign in to confirm you're not a bot" error.
There are three ways around it.
Fix 1: Rotating residential proxies
This is the fix the library supports itself. Residential proxies send your request through ordinary home connections, which YouTube treats like normal viewers.
from youtube_transcript_api import YouTubeTranscriptApi
from youtube_transcript_api.proxies import WebshareProxyConfig
ytt_api = YouTubeTranscriptApi(
proxy_config=WebshareProxyConfig(
proxy_username="<proxy-username>",
proxy_password="<proxy-password>",
)
)
transcript = ytt_api.fetch("dQw4w9WgXcQ")
print(" ".join(s.text for s in transcript))
Any provider works through GenericProxyConfig:
from youtube_transcript_api.proxies import GenericProxyConfig
ytt_api = YouTubeTranscriptApi(
proxy_config=GenericProxyConfig(
http_url="http://user:pass@proxy.example.com:8000",
https_url="http://user:pass@proxy.example.com:8000",
)
)
Things to know:
- Buy rotating residential proxies. Static or datacenter proxies get banned after a while.
- You pay per GB, and the price varies a lot between providers. Transcripts are small, but YouTube pages around them are not.
- Some requests will still fail, so add retries with backoff.
- Cache every transcript you fetch. Captions rarely change, and a cache hit costs no proxy traffic.
import json, pathlib, time
from youtube_transcript_api import YouTubeTranscriptApi
from youtube_transcript_api._errors import RequestBlocked, IpBlocked
CACHE = pathlib.Path("transcripts")
CACHE.mkdir(exist_ok=True)
def get_transcript(api: YouTubeTranscriptApi, video_id: str, tries: int = 4):
path = CACHE / f"{video_id}.json"
if path.exists():
return json.loads(path.read_text())
for attempt in range(tries):
try:
snippets = [
{"text": s.text, "start": s.start, "duration": s.duration}
for s in api.fetch(video_id)
]
path.write_text(json.dumps(snippets))
return snippets
except (RequestBlocked, IpBlocked):
time.sleep(2 ** attempt) # rotating proxies give a new IP on retry
raise RuntimeError(f"still blocked after {tries} tries: {video_id}")
Good fit: you want full control, you fetch a lot every day, and you're fine maintaining it.
Fix 2: Fetch from a machine that isn't in a data center
If your volume is small, run the fetching part somewhere with a normal home IP: an old laptop, a Raspberry Pi, or your dev machine. Your cloud app puts video IDs in a queue (a database table is enough), and the home worker fetches the transcripts and writes them back.
It's free, but it's another machine to keep running. If you send too many requests, your home IP can get blocked too. Keep it slow, a few requests a minute, and cache everything.
Good fit: side projects, a few dozen videos a day.
Fix 3: Use a hosted transcript API
The third option is to make the IP problem somebody else's. Several hosted APIs do this: Supadata, a few Apify actors, and the one I build, BulkTranscripts. They run the proxies and retries, and your server makes a plain HTTPS call that YouTube never sees.
Disclosure: I build BulkTranscripts, so here's what it looks like:
import requests
r = requests.get(
"https://bulktranscripts.co/api/v1/transcript",
params={"video": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"},
headers={"Authorization": "Bearer bt_ak_YOUR_KEY"},
timeout=60,
)
r.raise_for_status()
data = r.json()
print(data["title"], data["word_count"])
print(data["text"][:500]) # full transcript as one string
print(data["segments"][0]) # {"text": ..., "start": ..., "duration": ...}
Some details, since you'd want them before choosing:
- 30 free credits to try it, no card. After that, packs start at $4.99 for 200 transcripts, and credits don't expire.
- A failed fetch isn't charged, and re-reading a transcript you already fetched is free.
- There are endpoints for a whole channel or playlist (/api/v1/channel/videos, /api/v1/playlist/videos), plus an MCP server if you want Claude or ChatGPT to fetch transcripts directly.
- Docs here.
Whichever hosted API you pick, check three things: what a failed request costs, whether re-fetches are billed, and whether it can do channels and playlists in bulk.
Good fit: you want to ship your actual product and stop thinking about proxies.
Which one to pick
| Your situation | Pick |
|---|---|
| Hobby project, under ~50 videos a day | Fix 2: a home worker |
| High volume, you want control and can maintain it | Fix 1: rotating residential proxies |
| You want it working today and don't want to maintain it | Fix 3: a hosted API |
Whatever you choose, cache transcripts. It's the one thing that makes all three cheaper and more reliable.
If you've found another way around RequestBlocked, I'd like to hear it in the comments.
How to Get Transcripts for a Whole YouTube Channel or Playlist (No API Key)
Getting the transcript of one YouTube video is easy: open the video, click Show transcript, copy. Getting transcripts for every video on a channel, or a 200-video playlist, as clean timed data you can feed into a script or an LLM, is where it gets annoying.
The usual routes need a YouTube Data API key and some glue code, or they work on your laptop and then fail as soon as you run them on a server. This tutorial covers three ways to do it: no code, a few lines of Python, and a do-it-yourself route with the open-source youtube-transcript-api library. For the DIY route I'll show what happened when I ran it from a cloud server.
Disclosure: I built the Apify actor used in options 1 and 2 (YouTube Transcript & Subtitles Scraper). Option 3 doesn't use it.
What you get per video
One row per video. These are real field names and values from a run on 26 Oct 2026 (Steve Jobs' Stanford commencement speech):
| Field | Example |
| --- | --- |
| videoId, url | UF8uR6Z6KLc |
| title | Steve Jobs' 2005 Stanford Commencement Address |
| channelName, channelId | Stanford, UC-EnprmCZ3OXyAoG7vjVNCA |
| durationSeconds, viewCount | 904, 49320612 |
| language, languageName | en, English - English |
| isAutoGenerated | false (a human-made caption track) |
| segmentCount, wordCount | 244, 2298 |
| transcript | [{"start": 22.492, "duration": 7.527, "text": "Thank You. I am honored to be with you today at your commencement"}, …] |
| availableLanguages | every caption track on the video: Arabic, English (manual and auto-generated), Italian, … |
| error, errorReason | null when it worked (see the FAQ for the codes) |
When a video has both a human-made and an auto-generated track in your language, the human-made one wins.
Option 1: No code (Apify Console)
1. Open the actor page and click Try for free. Apify's free plan includes monthly platform credit; the actor itself is pay-per-event (see the Store page for the current price, and videos without captions aren't charged).
2. Paste into Videos, playlists or channels any mix of:
- video URLs (watch?v=, youtu.be/, /shorts/, /live/) or bare 11-character IDs,
- playlist URLs or IDs (PL…),
- channel URLs (/@TED, /channel/UC…).
Playlists and channels are expanded to their videos (channels newest first) up to Max videos.
3. Set Preferred languages (e.g. en, es, pt-BR) and the Transcript format: timed segments, plain text, or both.
4. Click Start and export the results as CSV, JSON or Excel, or send them to Google Sheets.
For a sense of speed: the 30 newest videos of @TED took 16 seconds and returned 29 transcripts. The 30th came back with error: bot_detected (YouTube refused that one request) and wasn't charged.
Option 2: Python
pip install apify-client, then take your API token from Apify Console → Settings → Integrations.
from apify_client import ApifyClient
client = ApifyClient("<YOUR_APIFY_TOKEN>")
run = client.actor("axlymxp/youtube-transcript-scraper").call(run_input={
"videoUrls": [
"https://www.youtube.com/@TED", # a whole channel
"https://www.youtube.com/watch?v=UF8uR6Z6KLc", # a single video
],
"languages": ["en"],
"maxVideos": 50,
"outputFormat": "both", # segments in `transcript`, joined text in `transcriptText`
})
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
if item["error"]:
print("skip", item["videoId"], item["error"])
continue
print(item["title"], "|", item["wordCount"], "words |", "auto" if item["isAutoGenerated"] else "manual")
Save one text file per video
Handy for search indexes, RAG pipelines or just reading:
import pathlib, re
out = pathlib.Path("transcripts")
out.mkdir(exist_ok=True)
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
if item["error"]:
continue
name = re.sub(r"[^\w\- ]", "", item["title"])[:80].strip() or item["videoId"]
(out / f"{name} [{item['videoId']}].txt").write_text(item["transcriptText"], encoding="utf-8")
If you only need text, set "outputFormat": "text": transcript then holds one string instead of segments, which is the most compact input for an LLM.
Example output
One real row (trimmed to three segments; transcriptText, description, keywords and thumbnailUrl removed):
{
"videoId": "UF8uR6Z6KLc",
"url": "https://www.youtube.com/watch?v=UF8uR6Z6KLc",
"title": "Steve Jobs' 2005 Stanford Commencement Address",
"channelName": "Stanford",
"durationSeconds": 904,
"viewCount": 49320612,
"language": "en",
"languageName": "English - English",
"isAutoGenerated": false,
"isTranslated": false,
"segmentCount": 244,
"wordCount": 2298,
"transcript": [
{"start": 7.47, "duration": 2.899, "text": "This program is brought to you by Stanford University."},
{"start": 10.47, "duration": 3.973, "text": "Please visit us at stanford.edu"},
{"start": 22.492, "duration": 7.527, "text": "Thank You. I am honored to be with you today at your commencement"}
],
"error": null
}
Option 3: DIY with youtube-transcript-api
If you'd rather run everything yourself, youtube-transcript-api is the standard open-source library. With version 1.2.4:
from youtube_transcript_api import YouTubeTranscriptApi
api = YouTubeTranscriptApi()
transcript = api.fetch("UF8uR6Z6KLc", languages=["en"])
for s in transcript.snippets[:3]:
print(s.start, s.text)
That works on most home connections. Cloud servers are a different story. I ran exactly this on an Apify cloud server on 26 Oct 2026:
- 5 of 5 videos failed with RequestBlocked ("YouTube is blocking requests from your IP") when sent directly from the server.
- 5 of 5 worked once the same requests went through a residential proxy, which the library supports out of the box:
from youtube_transcript_api.proxies import GenericProxyConfig
api = YouTubeTranscriptApi(proxy_config=GenericProxyConfig(
http_url="http://user:pass@your-residential-proxy:8000",
https_url="http://user:pass@your-residential-proxy:8000",
))
For whole channels you also need the list of video IDs. scrapetube does that without an API key, but watch out: on that same cloud server, scrapetube.get_channel(channel_url="https://www.youtube.com/@TED") returned an empty list with no error. If your channel job reports zero videos, check where it runs before you debug your code.
So the DIY route is free in software but you'll manage proxies, retries, language selection and resuming yourself. That plumbing is what the actor handles: residential proxy by default, manual-over-auto track selection, free rows for videos without captions, and resuming an interrupted run.
Scheduling and integrations
To keep a channel's transcripts up to date, save your input as an Apify task with the channel URL and a modest Max videos (say 20), and schedule it daily or weekly. Send the dataset to Google Sheets, Slack, Make, Zapier or a webhook after each run. An interrupted run resumes where it stopped when you start it again with the same input.
FAQ
Do I need a YouTube API key? No. Neither the actor nor the DIY route uses the YouTube Data API.
What if a video has no captions? It comes back with error: no_captions and isn't charged. Transcripts come from YouTube's own caption tracks (human-made or auto-generated); the actor doesn't do speech-to-text.
Can I get a specific language or a translation? Set Preferred languages in order of preference; en also matches en-US and en-GB. If none exists, Fall back to any available language returns the video's default track (or error: language_not_available if you turn it off). Translate to uses YouTube's own caption translation; YouTube throttles that per IP, so when it refuses, the row keeps the original language and says translationError: rate_limited.
Shorts and live streams? Shorts work like normal videos. On clips without speech, the auto-generated track may hold only tags like [Music] or [Laughter].
Is it legal? These are publicly available captions, fetched without logging in. How you use them is on you: respect YouTube's terms and copyright, especially if you republish transcripts.
Questions or a video that won't work? Open an issue on the actor page and I'll take a look.
Why youtube-transcript-api gets blocked on your server (and 3 ways to fix it)
If you've used youtube-transcript-api, you know the feeling: it works perfectly on your laptop, you deploy it to a server, and every request fails with RequestBlocked or IpBlocked.
I hit this while building a transcript tool, so I measured it instead of guessing. Here's what happened, why, and what you can do about it.
The test
I took 10 popular YouTube videos that have captions (I checked each one from my home connection first) and fetched their transcripts from a cloud server three ways, using youtube-transcript-api 1.2.4:
| How the request went out | Transcripts fetched |
|---|---|
| Directly from the cloud server | 10 / 10 |
| Through datacenter proxies | 9 / 10 |
| Through residential proxies | 10 / 10 |
This is one run with 10 videos in October 2026, so treat it as a snapshot, not a benchmark. The pattern is still clear: YouTube blocks cloud IP ranges outright. Your code isn't broken; your server's IP is.
One more data point: my home IP also got blocked after roughly 20 requests in a single afternoon of testing. So "just run it locally" works for a handful of videos, not for a batch.
Why it happens
The library doesn't use an official API. It loads the video page like a browser would and then fetches the caption track. IP addresses of cloud and hosting providers are easy to recognize, and traffic from them looks like bots, because it usually is. YouTube refuses those requests, and the library surfaces that as RequestBlocked or IpBlocked.
Fix 1: run it from a home connection (small volumes only)
For a few videos, the plain library is all you need:
from youtube_transcript_api import YouTubeTranscriptApi
api = YouTubeTranscriptApi()
transcript = api.fetch("aircAruvnKk", languages=["en"])
text = " ".join(snippet.text for snippet in transcript)
print(text[:200])
Keep the volume low and space out your requests, or your home IP ends up blocked too.
Fix 2: bring your own proxies
The library has proxy support built in. With residential proxies from Webshare:
from youtube_transcript_api import YouTubeTranscriptApi
from youtube_transcript_api.proxies import WebshareProxyConfig
api = YouTubeTranscriptApi(
proxy_config=WebshareProxyConfig(
proxy_username="your-username",
proxy_password="your-password",
)
)
transcript = api.fetch("aircAruvnKk")
Or with any other provider:
from youtube_transcript_api.proxies import GenericProxyConfig
api = YouTubeTranscriptApi(
proxy_config=GenericProxyConfig(
http_url="http://user:pass@proxy.example.com:8000",
https_url="http://user:pass@proxy.example.com:8000",
)
)
Watch the bandwidth bill. Residential proxies are usually priced per GB. In my test, each video moved about 0.4 MB through the proxy (the library downloads the whole watch page, not just the captions). At around $8/GB, that's roughly $3 per 1,000 transcripts if every request goes residential.
The cheaper pattern is what I ended up doing: try a datacenter proxy first, and fall back to residential only when it's blocked. Datacenter got 9 out of 10 through, so only the leftovers pay the residential price. That brought my measured cost down to about $0.50 per 1,000 transcripts.
from youtube_transcript_api import YouTubeTranscriptApi, NoTranscriptFound, TranscriptsDisabled, VideoUnavailable
from youtube_transcript_api.proxies import GenericProxyConfig
ROUTES = [DATACENTER_URL, DATACENTER_URL, RESIDENTIAL_URL, RESIDENTIAL_URL] # your proxy URLs
FINAL = (NoTranscriptFound, TranscriptsDisabled, VideoUnavailable) # another IP won't fix these
def fetch(video_id):
last_error = None
for url in ROUTES:
api = YouTubeTranscriptApi(proxy_config=GenericProxyConfig(http_url=url, https_url=url))
try:
return api.fetch(video_id)
except FINAL:
raise
except Exception as exc: # blocked or flaky: try the next route
last_error = exc
raise last_error
Two details that matter in practice:
- Don't retry errors another IP can't fix. A video with captions disabled fails the same way from every IP; retrying it only burns proxy bandwidth.
- Rotate the IP on every attempt. Most providers give you a new IP per connection or per session; make sure your retries actually change it.
Fix 3: use a hosted scraper
If you don't want to manage proxies at all, there are hosted tools that do this for you. Disclosure: I built one of them, YouTube Transcript Scraper on Apify. It runs the datacenter-then-residential routine above, takes URLs or IDs in bulk (Shorts included), lets you choose languages in order of preference, and returns plain text plus timestamped segments. It charges $3 per 1,000 transcripts delivered; failed videos are free.
Calling it from Python with the official client:
from apify_client import ApifyClient
client = ApifyClient("YOUR_APIFY_TOKEN")
run = client.actor("sturdyscrape/youtube-transcript-scraper").call(
run_input={
"videos": [
"https://www.youtube.com/watch?v=aircAruvnKk",
"https://youtu.be/dQw4w9WgXcQ",
],
"languages": ["en"],
"includeSegments": False,
}
)
for item in client.dataset(run.default_dataset_id).iterate_items():
print(item["videoId"], item["wordCount"], item["text"][:80])
There are other transcript tools on the Apify Store and elsewhere, at different prices; compare them for your volume.
Which one should you pick?
- A handful of videos, once: Fix 1. Free.
- Regular batches and you're comfortable running infrastructure: Fix 2, with the datacenter-first fallback to keep the proxy bill down.
- You just need the text inside a pipeline, an AI agent or a no-code workflow: Fix 3.
Whatever you choose, the main lesson from the test holds: if your transcript code works locally and fails on the server, it's the IP, not the code.
Have you found another way around the blocking? Tell me in the comments; I'm curious what's working for other people.
1 reaction
Mentions
How to get a YouTube transcript in 2026: libraries, yt-dlp, Whisper, hosted APIs and MCP Getting the transcript of a YouTube video sounds like one function call. In practice the right tool depends on where your code runs, whether the video has captions at all, and whether a person or an AI agent is asking. This is a short guide to the options, with commands you can run today. It is a companion to awesome-youtube-transcripts, a list we maintain of libraries, hosted APIs, MCP servers, agent skills and speech-to-text tools for YouTube transcripts. Quick answer | You want to | Start with | | --- | --- | | Fetch captions from a script on your own machine | youtube-transcript-api (Python) or YouTube.js (JavaScript) | | Download subtitle files from the command line | yt-dlp | | Transcribe a video that has no captions | faster-whisper or whisper.cpp on the audio | | Fetch transcripts from a server or cloud function | A hosted API, or a library behind your own proxies | | Let an AI assistant read YouTube videos | An MCP server or an agent skill | 1. A library, on your own machine If you run code on a laptop or home connection, an open-source library is the simplest route. Python: youtube-transcript-api. It reads manual and auto-generated captions, lists the languages a video offers, and can translate. Version 1.x uses an instance: from youtube_transcript_api import YouTubeTranscriptApi transcript = YouTubeTranscriptApi().fetch("dQw4w9WgXcQ", languages=["en"]) text = " ".join(snippet.text for snippet in transcript) print(text[:200]) Each snippet also has start and duration in seconds if you need timestamps. JavaScript: YouTube.js. A client for YouTube's internal InnerTube API; getTranscript() returns timed segments. There is a transcript example in the repo. If you want one function and nothing else, the youtube-transcript npm package has a single fetchTranscript() call, but it uses an unofficial endpoint and can break when YouTube changes. 2. yt-dlp, for subtitle files yt-dlp can save captions without downloading the video: yt-dlp --write-subs --write-auto-subs --sub-langs en --sub-format vtt \ --skip-download -o "%(id)s" "https://www.youtube.com/watch?v=dQw4w9WgXcQ" That writes dQw4w9WgXcQ.en.vtt. --write-subs takes uploaded captions, --write-auto-subs falls back to auto-generated ones, and the subtitle options section lists every format and language flag. It also handles whole playlists and channels. 3. Videos with no captions: speech-to-text Some videos have captions turned off, or none were ever generated. Then you need the audio and a speech model: - faster-whisper: Whisper on CTranslate2, several times faster than the original with lower memory use. - whisper.cpp: Whisper in C/C++, runs on CPU and Apple Silicon. - WhisperX: adds word-level timestamps and speaker diarization. Download the audio with yt-dlp -x --audio-format m4a <url> and pass the file to the model. Expect this to take far longer than reading captions, and to cost GPU time at scale. 4. The cloud problem Everything above works well from a home connection. Run the same code on AWS, GCP, Azure or most VPS providers and requests often fail with RequestBlocked or IpBlocked, because YouTube blocks many cloud IP ranges. The youtube-transcript-api README has a good explanation and proxy setup. You have two choices: 1. Keep the library and route it through residential or rotating proxies you manage. 2. Use a hosted API that handles it for you. Hosted options (all commercial; check each site for current pricing and limits): - GetYouTubeTranscript: REST API and remote MCP server for transcripts, YouTube search, channels and playlists, with Python and Node SDKs. (Ours; see the disclosure below.) - SearchAPI: a transcripts endpoint inside a general search API. - Supadata: transcripts and metadata for YouTube, TikTok, Instagram, X and web pages. - TranscriptAPI: YouTube transcripts, search, channels and playlists, with an MCP server. - TranscriptOut: transcript API with agent skills and an MCP server. - youtube-transcript.io: web tool and API. A hosted call is one HTTP request. With ours, for example: curl "https://getyoutubetranscript.com/api/v1/transcript?v=dQw4w9WgXcQ" \ -H "Authorization: Bearer $YOUTUBE_TRANSCRIPT_API_KEY" When comparing services, test with your own videos: success rate on long videos, videos with only auto-generated captions, and non-English videos matters more than the headline price. We publish an open benchmark you can fork and point at any provider. 5. For AI assistants: MCP servers and agent skills If the goal is "let Claude, Cursor or ChatGPT read this video", you don't need to write code at all. MCP servers (Model Context Protocol) give an assistant a transcript tool: - jkawamoto/mcp-youtube-transcript: runs locally, paginates long videos, supports proxy settings. - anaisbetts/mcp-youtube: uses a local yt-dlp install. - tubeagentkit/youtube-mcp: our hosted server (transcripts, search, channels, playlists), remote HTTP with OAuth sign-in or local stdio. Local servers inherit the cloud problem only if your machine is in a data centre; on a laptop they work fine. Agent skills are instruction folders that Claude Code, Cursor and Codex load when a task matches. tapestry-skills pulls transcripts as research sources, and youtube-to-ebook turns a channel's videos into an EPUB. The full list has more. 6. Framework integrations If you already use a framework, there is probably a loader: - LlamaIndex has a built-in YoutubeTranscriptReader. - n8n, Haystack and Dify have community nodes and components, listed in the Framework integrations section. Common errors - Transcripts disabled: the uploader turned captions off. Only speech-to-text will help. - No transcript in that language: ask for the languages the video offers first, then pick one. - RequestBlocked / IpBlocked: you are on a cloud IP; see section 4. - Works locally, fails in CI: same cause. CI runners are cloud machines. We keep a longer error reference with what triggers each one. Disclosure We build GetYouTubeTranscript, so some tools above are ours. Competing tools are listed on the same terms, and if one is missing or described unfairly, open a PR on awesome-youtube-transcripts.
How to get YouTube transcripts in bulk with Python (no API key, works on cloud servers) Disclosure: I built the Apify actor used in this post. It is a paid tool (pay per transcript), and I say below where it doesn't fit. If you have tried to get YouTube transcripts from code, you have probably hit one of these walls: 1. The official YouTube Data API doesn't help. Its captions.download endpoint only works for videos you own. For anyone else's video you get a 403. 2. youtube-transcript-api works on your laptop and then dies on a server. YouTube answers requests from cloud IP ranges (AWS, GCP, Hetzner, most VPSes) with a bot check, so the same code that ran fine locally starts throwing RequestBlocked / IpBlocked in production. Developers on the library's issue tracker discuss exactly this. 3. There is no "whole channel" button. You need to list the channel's videos first, then fetch each transcript, then handle the videos that have no captions. This post shows a way around all three from Python, in about 20 lines. The short version: one HTTP call YouTube Transcript Scraper runs on Apify's infrastructure. It tries a datacenter proxy first and automatically retries through a residential IP when YouTube shows a bot check, so you don't manage proxies yourself. You send video, playlist or channel URLs and get one JSON row per video. You need a free Apify account and your API token (Console → Settings → Integrations). Then: import requests TOKEN = "YOUR_APIFY_TOKEN" url = ("https://api.apify.com/v2/acts/groupoject~youtube-transcript-scraper" f"/run-sync-get-dataset-items?token={TOKEN}") rows = requests.post(url, json={ "urls": ["https://www.youtube.com/watch?v=iG9CE55wbtY"], "outputFormats": ["text"], }, timeout=320).json() for row in rows: print(row["title"], "-", row["wordCount"], "words") print(row["text"][:300]) run-sync-get-dataset-items starts the run, waits for it to finish and returns the rows in one response. Apify caps synchronous calls at about 5 minutes, so use it for a handful of videos. For a whole channel, use the client library below. Bulk: a whole channel or playlist pip install apify-client, then: from pathlib import Path from apify_client import ApifyClient client = ApifyClient("YOUR_APIFY_TOKEN") run = client.actor("groupoject/youtube-transcript-scraper").call(run_input={ "urls": [ "@lexfridman", # a channel handle "https://www.youtube.com/playlist?list=PLxxxxxxxxxxxx", # a playlist ], "maxVideosPerSource": 50, # newest 50 per channel / playlist "channelContentTypes": ["videos"], # or add "shorts", "streams" "languages": ["en"], # preference order, e.g. ["es", "en"] "outputFormats": ["text", "segments", "srt"], }) out = Path("transcripts") out.mkdir(exist_ok=True) for item in client.dataset(run["defaultDatasetId"]).iterate_items(): (out / f"{item['videoId']}.txt").write_text(item["text"], encoding="utf-8") (out / f"{item['videoId']}.srt").write_text(item["srt"], encoding="utf-8") print(item["videoId"], item["languageCode"], "auto" if item["isAutoGenerated"] else "manual", item["title"]) A few input details worth knowing (these are the real field names from the actor's input schema): - urls accepts watch?v= links, youtu.be links, Shorts, /live/ URLs, bare 11-character IDs, playlist URLs, channel URLs and @handles, all mixed in one list. - maxVideosPerSource caps each playlist and each channel content type (newest first, up to 5,000). Single video URLs are not affected. - captionType is any (default: prefer creator-uploaded, fall back to auto-generated), manual or auto. - fallbackToAnyLanguage (default true) returns the original-language captions when none of your languages exist. Set it to false to skip those videos instead. - includeVideoMetadata (default true) adds title, channel, publish date, views, likes, duration, description and tags to each row. Which videos had no transcript? Videos without captions are not pushed to the dataset and are not charged. They are listed in the run's key-value store: store = client.key_value_store(run["defaultKeyValueStoreId"]) missing = (store.get_record("NO_TRANSCRIPT") or {}).get("value") or [] for m in missing: print(m["videoId"], m["reason"], m.get("availableLanguages")) reason is NO_CAPTIONS, LANGUAGE_NOT_AVAILABLE or EMPTY_CAPTIONS. Private, deleted or region-blocked videos go to the ERRORS record instead. Feeding transcripts to an LLM or a RAG pipeline For summarising, the text field is all you need. For retrieval with citations, use segments: each segment has start, duration and text in seconds, so you can chunk by time and keep a deep link back to the moment in the video. def chunks(item, seconds=90): buf, t0 = [], None for seg in item["transcript"]: t0 = seg["start"] if t0 is None else t0 buf.append(seg["text"]) if seg["start"] - t0 >= seconds: yield {"text": " ".join(buf), "source": f"{item['url']}&t={int(t0)}s", "title": item["title"]} buf, t0 = [], None if buf: yield {"text": " ".join(buf), "source": f"{item['url']}&t={int(t0)}s", "title": item["title"]} Each chunk can go straight into your vector store with source as metadata, so answers can link to the exact timestamp. No-code: n8n, Make, Zapier If you'd rather not run Python, the actor works with Apify's integrations. In n8n, add the Apify node, choose Run an Actor and Get Dataset, set the actor to groupoject~youtube-transcript-scraper and paste the JSON input above. Connect a Google Sheets or Notion node after it. In Make and Zapier the Apify app has the same "run actor" and "get dataset items" steps. You can also call it from Claude, Cursor or another MCP client through https://mcp.apify.com?tools=groupoject/youtube-transcript-scraper. Monitor a channel for new transcripts Turn on onlyNewVideos and give the monitor a stateKey (any name, e.g. ai-podcasts). Each run then saves only videos it hasn't saved before for that key, so you're never charged twice for the same video. Schedule it daily in Apify (Schedules → Create) and connect a Slack, email or Google Sheets integration to get each new transcript as it lands: run = client.actor("groupoject/youtube-transcript-scraper").call(run_input={ "urls": ["https://www.youtube.com/@lexfridman"], "onlyNewVideos": True, "stateKey": "lex-podcast", "publishedAfter": "7 days", # optional: ignore older uploads on the first run }) Brand-new uploads sometimes get auto-captions a few hours late; the actor re-checks those for 24 hours instead of skipping them. What it costs $2.50 per 1,000 transcripts on the free plan ($0.0025 per video), with proxy traffic included. Paid Apify plans get a lower per-result rate automatically. Videos with no captions, and failed, private or deleted videos, are not charged. A 100-video playlist where every video has captions costs $0.25. You can set a maximum charge per run, and the actor stops cleanly before it goes over. Honest limits - It returns YouTube's own captions. It does not transcribe audio. If a video has no captions at all (music clips, some Shorts), you get nothing for it. For those you need Whisper or a speech-to-text API. - Auto-generated captions are only as good as YouTube's speech recognition. Names and jargon are often wrong. isAutoGenerated tells you which kind you got. - Translation (translateTo) is best effort. YouTube rate-limits its auto-translate endpoint heavily. When it refuses, you still get the original-language transcript and translationError explains why. - No login, by design. Age-restricted, members-only and private videos are reported in ERRORS, not scraped. - If you need a few videos a day from your laptop, you don't need this. youtube-transcript-api or yt-dlp --write-auto-subs is free and works fine from a home connection. This tool is for when you need volume, channels, or a server. - Respect copyright. Transcripts are the creators' work. Summarising for research is one thing. Republishing them is another. Links - Actor: apify.com If something breaks on a video that should work, open an issue on the actor's Issues tab with the video ID. I read them daily.
youtube-transcript-api works locally but gets blocked on your server: why, and 3 fixes You build something with youtube-transcript-api. It works perfectly on your laptop. You deploy it to AWS, Render, Railway, Vercel or a VPS, and every call now fails with this: youtube_transcript_api._errors.RequestBlocked: Could not retrieve a transcript for the video ... Sometimes it shows up as IpBlocked instead. Your code didn't change and neither did the video, so what happened? Why it happens YouTube blocks most IP addresses that belong to cloud providers. The library's own README says so: requests from AWS, Google Cloud, Azure and similar networks are very likely to be refused. A home IP can get blocked too if it sends a lot of requests in a short time. So the problem is where your request comes from, not your code. That's also why switching libraries rarely helps. yt-dlp on the same server usually hits the same wall, as a "Sign in to confirm you're not a bot" error. There are three ways around it. Fix 1: Rotating residential proxies This is the fix the library supports itself. Residential proxies send your request through ordinary home connections, which YouTube treats like normal viewers. from youtube_transcript_api import YouTubeTranscriptApi from youtube_transcript_api.proxies import WebshareProxyConfig ytt_api = YouTubeTranscriptApi( proxy_config=WebshareProxyConfig( proxy_username="<proxy-username>", proxy_password="<proxy-password>", ) ) transcript = ytt_api.fetch("dQw4w9WgXcQ") print(" ".join(s.text for s in transcript)) Any provider works through GenericProxyConfig: from youtube_transcript_api.proxies import GenericProxyConfig ytt_api = YouTubeTranscriptApi( proxy_config=GenericProxyConfig( http_url="http://user:pass@proxy.example.com:8000", https_url="http://user:pass@proxy.example.com:8000", ) ) Things to know: - Buy rotating residential proxies. Static or datacenter proxies get banned after a while. - You pay per GB, and the price varies a lot between providers. Transcripts are small, but YouTube pages around them are not. - Some requests will still fail, so add retries with backoff. - Cache every transcript you fetch. Captions rarely change, and a cache hit costs no proxy traffic. import json, pathlib, time from youtube_transcript_api import YouTubeTranscriptApi from youtube_transcript_api._errors import RequestBlocked, IpBlocked CACHE = pathlib.Path("transcripts") CACHE.mkdir(exist_ok=True) def get_transcript(api: YouTubeTranscriptApi, video_id: str, tries: int = 4): path = CACHE / f"{video_id}.json" if path.exists(): return json.loads(path.read_text()) for attempt in range(tries): try: snippets = [ {"text": s.text, "start": s.start, "duration": s.duration} for s in api.fetch(video_id) ] path.write_text(json.dumps(snippets)) return snippets except (RequestBlocked, IpBlocked): time.sleep(2 ** attempt) # rotating proxies give a new IP on retry raise RuntimeError(f"still blocked after {tries} tries: {video_id}") Good fit: you want full control, you fetch a lot every day, and you're fine maintaining it. Fix 2: Fetch from a machine that isn't in a data center If your volume is small, run the fetching part somewhere with a normal home IP: an old laptop, a Raspberry Pi, or your dev machine. Your cloud app puts video IDs in a queue (a database table is enough), and the home worker fetches the transcripts and writes them back. It's free, but it's another machine to keep running. If you send too many requests, your home IP can get blocked too. Keep it slow, a few requests a minute, and cache everything. Good fit: side projects, a few dozen videos a day. Fix 3: Use a hosted transcript API The third option is to make the IP problem somebody else's. Several hosted APIs do this: Supadata, a few Apify actors, and the one I build, BulkTranscripts. They run the proxies and retries, and your server makes a plain HTTPS call that YouTube never sees. Disclosure: I build BulkTranscripts, so here's what it looks like: import requests r = requests.get( "https://bulktranscripts.co/api/v1/transcript", params={"video": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"}, headers={"Authorization": "Bearer bt_ak_YOUR_KEY"}, timeout=60, ) r.raise_for_status() data = r.json() print(data["title"], data["word_count"]) print(data["text"][:500]) # full transcript as one string print(data["segments"][0]) # {"text": ..., "start": ..., "duration": ...} Some details, since you'd want them before choosing: - 30 free credits to try it, no card. After that, packs start at $4.99 for 200 transcripts, and credits don't expire. - A failed fetch isn't charged, and re-reading a transcript you already fetched is free. - There are endpoints for a whole channel or playlist (/api/v1/channel/videos, /api/v1/playlist/videos), plus an MCP server if you want Claude or ChatGPT to fetch transcripts directly. - Docs here. Whichever hosted API you pick, check three things: what a failed request costs, whether re-fetches are billed, and whether it can do channels and playlists in bulk. Good fit: you want to ship your actual product and stop thinking about proxies. Which one to pick | Your situation | Pick | |---|---| | Hobby project, under ~50 videos a day | Fix 2: a home worker | | High volume, you want control and can maintain it | Fix 1: rotating residential proxies | | You want it working today and don't want to maintain it | Fix 3: a hosted API | Whatever you choose, cache transcripts. It's the one thing that makes all three cheaper and more reliable. If you've found another way around RequestBlocked, I'd like to hear it in the comments.
How to Get Transcripts for a Whole YouTube Channel or Playlist (No API Key) Getting the transcript of one YouTube video is easy: open the video, click Show transcript, copy. Getting transcripts for every video on a channel, or a 200-video playlist, as clean timed data you can feed into a script or an LLM, is where it gets annoying. The usual routes need a YouTube Data API key and some glue code, or they work on your laptop and then fail as soon as you run them on a server. This tutorial covers three ways to do it: no code, a few lines of Python, and a do-it-yourself route with the open-source youtube-transcript-api library. For the DIY route I'll show what happened when I ran it from a cloud server. Disclosure: I built the Apify actor used in options 1 and 2 (YouTube Transcript & Subtitles Scraper). Option 3 doesn't use it. What you get per video One row per video. These are real field names and values from a run on 26 Oct 2026 (Steve Jobs' Stanford commencement speech): | Field | Example | | --- | --- | | videoId, url | UF8uR6Z6KLc | | title | Steve Jobs' 2005 Stanford Commencement Address | | channelName, channelId | Stanford, UC-EnprmCZ3OXyAoG7vjVNCA | | durationSeconds, viewCount | 904, 49320612 | | language, languageName | en, English - English | | isAutoGenerated | false (a human-made caption track) | | segmentCount, wordCount | 244, 2298 | | transcript | [{"start": 22.492, "duration": 7.527, "text": "Thank You. I am honored to be with you today at your commencement"}, …] | | availableLanguages | every caption track on the video: Arabic, English (manual and auto-generated), Italian, … | | error, errorReason | null when it worked (see the FAQ for the codes) | When a video has both a human-made and an auto-generated track in your language, the human-made one wins. Option 1: No code (Apify Console) 1. Open the actor page and click Try for free. Apify's free plan includes monthly platform credit; the actor itself is pay-per-event (see the Store page for the current price, and videos without captions aren't charged). 2. Paste into Videos, playlists or channels any mix of: - video URLs (watch?v=, youtu.be/, /shorts/, /live/) or bare 11-character IDs, - playlist URLs or IDs (PL…), - channel URLs (/@TED, /channel/UC…). Playlists and channels are expanded to their videos (channels newest first) up to Max videos. 3. Set Preferred languages (e.g. en, es, pt-BR) and the Transcript format: timed segments, plain text, or both. 4. Click Start and export the results as CSV, JSON or Excel, or send them to Google Sheets. For a sense of speed: the 30 newest videos of @TED took 16 seconds and returned 29 transcripts. The 30th came back with error: bot_detected (YouTube refused that one request) and wasn't charged. Option 2: Python pip install apify-client, then take your API token from Apify Console → Settings → Integrations. from apify_client import ApifyClient client = ApifyClient("<YOUR_APIFY_TOKEN>") run = client.actor("axlymxp/youtube-transcript-scraper").call(run_input={ "videoUrls": [ "https://www.youtube.com/@TED", # a whole channel "https://www.youtube.com/watch?v=UF8uR6Z6KLc", # a single video ], "languages": ["en"], "maxVideos": 50, "outputFormat": "both", # segments in `transcript`, joined text in `transcriptText` }) for item in client.dataset(run["defaultDatasetId"]).iterate_items(): if item["error"]: print("skip", item["videoId"], item["error"]) continue print(item["title"], "|", item["wordCount"], "words |", "auto" if item["isAutoGenerated"] else "manual") Save one text file per video Handy for search indexes, RAG pipelines or just reading: import pathlib, re out = pathlib.Path("transcripts") out.mkdir(exist_ok=True) for item in client.dataset(run["defaultDatasetId"]).iterate_items(): if item["error"]: continue name = re.sub(r"[^\w\- ]", "", item["title"])[:80].strip() or item["videoId"] (out / f"{name} [{item['videoId']}].txt").write_text(item["transcriptText"], encoding="utf-8") If you only need text, set "outputFormat": "text": transcript then holds one string instead of segments, which is the most compact input for an LLM. Example output One real row (trimmed to three segments; transcriptText, description, keywords and thumbnailUrl removed): { "videoId": "UF8uR6Z6KLc", "url": "https://www.youtube.com/watch?v=UF8uR6Z6KLc", "title": "Steve Jobs' 2005 Stanford Commencement Address", "channelName": "Stanford", "durationSeconds": 904, "viewCount": 49320612, "language": "en", "languageName": "English - English", "isAutoGenerated": false, "isTranslated": false, "segmentCount": 244, "wordCount": 2298, "transcript": [ {"start": 7.47, "duration": 2.899, "text": "This program is brought to you by Stanford University."}, {"start": 10.47, "duration": 3.973, "text": "Please visit us at stanford.edu"}, {"start": 22.492, "duration": 7.527, "text": "Thank You. I am honored to be with you today at your commencement"} ], "error": null } Option 3: DIY with youtube-transcript-api If you'd rather run everything yourself, youtube-transcript-api is the standard open-source library. With version 1.2.4: from youtube_transcript_api import YouTubeTranscriptApi api = YouTubeTranscriptApi() transcript = api.fetch("UF8uR6Z6KLc", languages=["en"]) for s in transcript.snippets[:3]: print(s.start, s.text) That works on most home connections. Cloud servers are a different story. I ran exactly this on an Apify cloud server on 26 Oct 2026: - 5 of 5 videos failed with RequestBlocked ("YouTube is blocking requests from your IP") when sent directly from the server. - 5 of 5 worked once the same requests went through a residential proxy, which the library supports out of the box: from youtube_transcript_api.proxies import GenericProxyConfig api = YouTubeTranscriptApi(proxy_config=GenericProxyConfig( http_url="http://user:pass@your-residential-proxy:8000", https_url="http://user:pass@your-residential-proxy:8000", )) For whole channels you also need the list of video IDs. scrapetube does that without an API key, but watch out: on that same cloud server, scrapetube.get_channel(channel_url="https://www.youtube.com/@TED") returned an empty list with no error. If your channel job reports zero videos, check where it runs before you debug your code. So the DIY route is free in software but you'll manage proxies, retries, language selection and resuming yourself. That plumbing is what the actor handles: residential proxy by default, manual-over-auto track selection, free rows for videos without captions, and resuming an interrupted run. Scheduling and integrations To keep a channel's transcripts up to date, save your input as an Apify task with the channel URL and a modest Max videos (say 20), and schedule it daily or weekly. Send the dataset to Google Sheets, Slack, Make, Zapier or a webhook after each run. An interrupted run resumes where it stopped when you start it again with the same input. FAQ Do I need a YouTube API key? No. Neither the actor nor the DIY route uses the YouTube Data API. What if a video has no captions? It comes back with error: no_captions and isn't charged. Transcripts come from YouTube's own caption tracks (human-made or auto-generated); the actor doesn't do speech-to-text. Can I get a specific language or a translation? Set Preferred languages in order of preference; en also matches en-US and en-GB. If none exists, Fall back to any available language returns the video's default track (or error: language_not_available if you turn it off). Translate to uses YouTube's own caption translation; YouTube throttles that per IP, so when it refuses, the row keeps the original language and says translationError: rate_limited. Shorts and live streams? Shorts work like normal videos. On clips without speech, the auto-generated track may hold only tags like [Music] or [Laughter]. Is it legal? These are publicly available captions, fetched without logging in. How you use them is on you: respect YouTube's terms and copyright, especially if you republish transcripts. Questions or a video that won't work? Open an issue on the actor page and I'll take a look.
Why youtube-transcript-api gets blocked on your server (and 3 ways to fix it) If you've used youtube-transcript-api, you know the feeling: it works perfectly on your laptop, you deploy it to a server, and every request fails with RequestBlocked or IpBlocked. I hit this while building a transcript tool, so I measured it instead of guessing. Here's what happened, why, and what you can do about it. The test I took 10 popular YouTube videos that have captions (I checked each one from my home connection first) and fetched their transcripts from a cloud server three ways, using youtube-transcript-api 1.2.4: | How the request went out | Transcripts fetched | |---|---| | Directly from the cloud server | 10 / 10 | | Through datacenter proxies | 9 / 10 | | Through residential proxies | 10 / 10 | This is one run with 10 videos in October 2026, so treat it as a snapshot, not a benchmark. The pattern is still clear: YouTube blocks cloud IP ranges outright. Your code isn't broken; your server's IP is. One more data point: my home IP also got blocked after roughly 20 requests in a single afternoon of testing. So "just run it locally" works for a handful of videos, not for a batch. Why it happens The library doesn't use an official API. It loads the video page like a browser would and then fetches the caption track. IP addresses of cloud and hosting providers are easy to recognize, and traffic from them looks like bots, because it usually is. YouTube refuses those requests, and the library surfaces that as RequestBlocked or IpBlocked. Fix 1: run it from a home connection (small volumes only) For a few videos, the plain library is all you need: from youtube_transcript_api import YouTubeTranscriptApi api = YouTubeTranscriptApi() transcript = api.fetch("aircAruvnKk", languages=["en"]) text = " ".join(snippet.text for snippet in transcript) print(text[:200]) Keep the volume low and space out your requests, or your home IP ends up blocked too. Fix 2: bring your own proxies The library has proxy support built in. With residential proxies from Webshare: from youtube_transcript_api import YouTubeTranscriptApi from youtube_transcript_api.proxies import WebshareProxyConfig api = YouTubeTranscriptApi( proxy_config=WebshareProxyConfig( proxy_username="your-username", proxy_password="your-password", ) ) transcript = api.fetch("aircAruvnKk") Or with any other provider: from youtube_transcript_api.proxies import GenericProxyConfig api = YouTubeTranscriptApi( proxy_config=GenericProxyConfig( http_url="http://user:pass@proxy.example.com:8000", https_url="http://user:pass@proxy.example.com:8000", ) ) Watch the bandwidth bill. Residential proxies are usually priced per GB. In my test, each video moved about 0.4 MB through the proxy (the library downloads the whole watch page, not just the captions). At around $8/GB, that's roughly $3 per 1,000 transcripts if every request goes residential. The cheaper pattern is what I ended up doing: try a datacenter proxy first, and fall back to residential only when it's blocked. Datacenter got 9 out of 10 through, so only the leftovers pay the residential price. That brought my measured cost down to about $0.50 per 1,000 transcripts. from youtube_transcript_api import YouTubeTranscriptApi, NoTranscriptFound, TranscriptsDisabled, VideoUnavailable from youtube_transcript_api.proxies import GenericProxyConfig ROUTES = [DATACENTER_URL, DATACENTER_URL, RESIDENTIAL_URL, RESIDENTIAL_URL] # your proxy URLs FINAL = (NoTranscriptFound, TranscriptsDisabled, VideoUnavailable) # another IP won't fix these def fetch(video_id): last_error = None for url in ROUTES: api = YouTubeTranscriptApi(proxy_config=GenericProxyConfig(http_url=url, https_url=url)) try: return api.fetch(video_id) except FINAL: raise except Exception as exc: # blocked or flaky: try the next route last_error = exc raise last_error Two details that matter in practice: - Don't retry errors another IP can't fix. A video with captions disabled fails the same way from every IP; retrying it only burns proxy bandwidth. - Rotate the IP on every attempt. Most providers give you a new IP per connection or per session; make sure your retries actually change it. Fix 3: use a hosted scraper If you don't want to manage proxies at all, there are hosted tools that do this for you. Disclosure: I built one of them, YouTube Transcript Scraper on Apify. It runs the datacenter-then-residential routine above, takes URLs or IDs in bulk (Shorts included), lets you choose languages in order of preference, and returns plain text plus timestamped segments. It charges $3 per 1,000 transcripts delivered; failed videos are free. Calling it from Python with the official client: from apify_client import ApifyClient client = ApifyClient("YOUR_APIFY_TOKEN") run = client.actor("sturdyscrape/youtube-transcript-scraper").call( run_input={ "videos": [ "https://www.youtube.com/watch?v=aircAruvnKk", "https://youtu.be/dQw4w9WgXcQ", ], "languages": ["en"], "includeSegments": False, } ) for item in client.dataset(run.default_dataset_id).iterate_items(): print(item["videoId"], item["wordCount"], item["text"][:80]) There are other transcript tools on the Apify Store and elsewhere, at different prices; compare them for your volume. Which one should you pick? - A handful of videos, once: Fix 1. Free. - Regular batches and you're comfortable running infrastructure: Fix 2, with the datacenter-first fallback to keep the proxy bill down. - You just need the text inside a pipeline, an AI agent or a no-code workflow: Fix 3. Whatever you choose, the main lesson from the test holds: if your transcript code works locally and fails on the server, it's the IP, not the code. Have you found another way around the blocking? Tell me in the comments; I'm curious what's working for other people.