YouTube Transcript Extractor: Timed Captions
Pricing
Pay per usage
YouTube Transcript Extractor: Timed Captions
Extracts timed captions from YouTube videos through the ANDROID player endpoint behind rotating residential proxies. Returns manual or auto-generated tracks with start times, language, full text and video details, and retries bot checks on a new IP for every video.
Pricing
Pay per usage
Rating
0.0
(0)
Developer
luochengfengqi
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
3 days ago
Last modified
Categories
Share
YouTube Transcript Extractor
Reads the caption track of YouTube videos and returns it as structured data: the full text, the timed segments, the language, whether the track was written by a person or generated by speech recognition, and basic details of the video. Give it video URLs or 11-character video IDs, get one dataset record per video.
The Actor does not use a browser. For every video it calls the same player endpoint that the YouTube Android app uses, picks the caption track that matches your language preference, downloads the timed text and parses it. Requests go through Apify residential proxies, and a request that YouTube answers with a bot check is repeated on a new IP address.
What you get
- Full transcript as one clean text string, plus an optional array of timed segments (start, duration, text).
- The track that was returned: language code, language name and kind (
manualorauto-generated). - The list of all caption tracks the video offers, so you can see which languages exist.
- Video details from the player response: title, channel name and ID, length, view count, keywords and live status.
- A status on every record, so a video without captions, a deleted video or a blocked request is a visible row and not a silent gap.
- A run summary with status counts, attempts, language and track-kind counts, in the key-value store record
SUMMARY.
Only records with status OK count as transcripts. See the pricing section for what a run costs.
How it works
- Every input entry is parsed into a video ID. Accepted are bare IDs and URLs of the forms
watch?v=,youtu.be/,/shorts/,/embed/,/live/andmusic.youtube.com. Playlists, channels and search URLs becomeINVALID_INPUTrecords. Duplicates are skipped and counted. - For each video the Actor sends a POST request to
https://www.youtube.com/youtubei/v1/playeras theANDROIDclient (version 20.10.38) through a residential proxy session. - The response is classified. A playable video with caption tracks continues. A video that does not exist, is private, age-restricted or an unavailable live recording ends as
UNAVAILABLE. A playable video without tracks ends asNO_CAPTIONS. A "Sign in to confirm you are not a bot" answer is treated as a blocked request and retried. - One track is chosen from the list: your languages are tried in order, an exact language code beats a regional variant, and a manual track beats an auto-generated one unless you turn that off.
- The caption URL from the player response is downloaded through the same proxy session and parsed from the YouTube timed text format 3 (XML) into segments. HTML entities are decoded once, line breaks inside a caption are kept as spaces in the joined text, and emoji characters are removed.
- If any step of an attempt fails for a transient reason (bot check, network error, HTTP error, empty caption body, request stalled for more than 22 seconds), the attempt is repeated on a new proxy session after a short pause, up to
maxAttemptstimes. Answers that will not change on retry (unavailable video, no captions, no matching language) are returned immediately. - The record is written to the dataset. Under pay-per-event pricing, a record with status
OKis charged as onetranscript-extractedevent together with the dataset write, and records with any other status are never charged.
Quick start
- Open the Input tab and paste video URLs or IDs into Videos. The prefilled input contains three public videos.
- Keep the proxy configuration on Apify Proxy with the
RESIDENTIALgroup. - Start the Actor. A run with three videos takes a few seconds.
- Open the Output tab. The dataset overview shows one row per video. Open
SUMMARYin the key-value store for the totals.
Input Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
videos | array of strings, required | three sample videos | YouTube video URLs or 11-character IDs. Duplicates are skipped. |
languages | array of strings | ["en"] | Language codes in order of preference, for example en, de, pt-BR. A base code such as es also matches regional tracks such as es-419. An exact code wins over a regional variant. Case-insensitive. |
fallbackToAnyLanguage | boolean | true | If none of the preferred languages exists, return the first allowed track. With false the record becomes NO_CAPTIONS and lists the languages that exist. |
includeAutoGenerated | boolean | true | Allow tracks generated by speech recognition. With false, videos that only have such tracks become NO_CAPTIONS. |
preferManualCaptions | boolean | true | If a manual and an auto-generated track exist for the same language, return the manual one. |
includeSegments | boolean | true | Add the segments array. With false only text and the statistics are returned, which keeps the dataset smaller. |
maxConcurrency | integer, 1 to 20 | 5 | Number of videos processed at the same time. |
maxAttempts | integer, 1 to 5 | 3 | Attempts per video before it is reported as BLOCKED or ERROR. Every attempt uses a new residential IP. |
proxyConfiguration | object | Apify Proxy, group RESIDENTIAL, country US | Residential proxies are required. See the limitations section. |
Example input
{"videos": ["jNQXAC9IVRw","https://youtu.be/dQw4w9WgXcQ","https://www.youtube.com/watch?v=UF8uR6Z6KLc"],"languages": ["de", "en"],"fallbackToAnyLanguage": true,"includeAutoGenerated": true,"includeSegments": true,"maxConcurrency": 5,"maxAttempts": 3,"proxyConfiguration": {"useApifyProxy": true,"apifyProxyGroups": ["RESIDENTIAL"],"apifyProxyCountry": "US"}}
Output Schema
The default dataset holds one record per input entry that is a video, plus one INVALID_INPUT record per entry that is not. Every record has all fields listed below. Fields that do not apply are null (or an empty array for list fields), so the columns stay stable when you export to CSV or Excel.
Field Reference
| Field | Type | Description |
|---|---|---|
input | string | The entry exactly as it was provided. |
videoId | string or null | The 11-character video ID. null for INVALID_INPUT. |
url | string or null | Canonical watch URL built from the ID. |
status | string | OK, NO_CAPTIONS, UNAVAILABLE, BLOCKED, ERROR or INVALID_INPUT. |
message | string or null | Explanation for every status except OK. |
title | string or null | Video title. |
channelId | string or null | Channel ID. |
channelName | string or null | Channel name. |
lengthSeconds | integer or null | Video length in seconds. |
viewCount | integer or null | View count at the time of the request. |
keywords | array of strings | Keywords set by the uploader. |
isLive | boolean or null | true for live streams and live recordings. |
language | string or null | Language code of the returned track, for example en or de-DE. |
languageName | string or null | Display name of the returned track, for example English (auto-generated). |
trackKind | string or null | manual or auto-generated. |
availableLanguages | array of objects | Every caption track the video offers: languageCode, name, trackKind. |
segmentCount | integer or null | Number of caption segments. |
wordCount | integer or null | Number of whitespace-separated words in text. Not meaningful for languages written without spaces. |
transcriptDurationSeconds | number or null | End time of the last segment in seconds. |
text | string or null | The whole transcript as one string. |
segments | array or null | { start, duration, text } per caption segment, times in seconds with millisecond precision. null when includeSegments is false. |
attempts | integer | Attempts used for this video. |
scrapedAt | string or null | ISO 8601 timestamp (UTC) of the record. |
Status values
| Status | Meaning | Counts as a transcript |
|---|---|---|
OK | A caption track was downloaded and parsed. | Yes |
NO_CAPTIONS | The video is playable but has no track that matches your settings. message lists the languages that exist. | No |
UNAVAILABLE | The video does not exist, is private, age-restricted or an unavailable live recording. message carries the reason given by YouTube. | No |
BLOCKED | Every attempt was answered with a bot check. Raise maxAttempts or run again. | No |
ERROR | Every attempt failed with a network error, an HTTP error or an empty caption body. message carries the last error. | No |
INVALID_INPUT | The entry is not a video URL or ID. | No |
Example record
This record was produced by a cloud run on 2026-10-21 (video "Me at the zoo").
{"input": "jNQXAC9IVRw","videoId": "jNQXAC9IVRw","url": "https://www.youtube.com/watch?v=jNQXAC9IVRw","status": "OK","message": null,"title": "Me at the zoo","channelId": "UC4QobU6STFB0P71PMvOGN5A","channelName": "jawed","lengthSeconds": 19,"viewCount": 445555035,"keywords": ["me at the zoo", "jawed karim", "first youtube video"],"isLive": false,"language": "en","languageName": "English","trackKind": "manual","availableLanguages": [{ "languageCode": "en", "name": "English", "trackKind": "manual" },{ "languageCode": "de", "name": "German", "trackKind": "manual" }],"segmentCount": 6,"wordCount": 39,"transcriptDurationSeconds": 18.881,"text": "All right, so here we are, in front of the elephants the cool thing about these guys is that they have really... really really long trunks and that's cool (baaaaaaaaaaahhh!!) and that's pretty much all there is to say","segments": [{ "start": 1.2, "duration": 2.16, "text": "All right, so here we are, in front of the elephants" },{ "start": 5.318, "duration": 2.656, "text": "the cool thing about these guys is that they have really..." },{ "start": 7.974, "duration": 4.642, "text": "really really long trunks" },{ "start": 12.616, "duration": 1.751, "text": "and that's cool" },{ "start": 14.421, "duration": 1.312, "text": "(baaaaaaaaaaahhh!!)" },{ "start": 16.881, "duration": 2, "text": "and that's pretty much all there is to say" }],"attempts": 1,"scrapedAt": "2026-10-21T04:53:35.386Z"}
Example of a record that is not OK
{"input": "nfWlot6h_JM","videoId": "nfWlot6h_JM","status": "NO_CAPTIONS","message": "No caption tracks are available for this video.","language": null,"text": null,"segments": null,"attempts": 1}
Only the fields that matter here are shown. The real record contains all fields from the reference table, with the video details filled in.
Run summary
The key-value store record SUMMARY is written at the end of every run. This is the summary of a real run with 33 videos:
{"startedAt": "2026-10-21T04:55:44.907Z","finishedAt": "2026-10-21T04:56:51.402Z","recordsWritten": 33,"duplicatesSkipped": 0,"statusCounts": { "OK": 31, "NO_CAPTIONS": 2 },"transcriptsReturned": 31,"attemptsTotal": 37,"averageAttemptsPerTranscript": 1.13,"languageCounts": { "en": 30, "ko": 1 },"trackKindCounts": { "manual": 26, "auto-generated": 5 },"segmentsTotal": 2642,"wordsTotal": 19346,"chargeLimitReached": false}
attemptsTotal counts all attempts of the run, including those of records that are not OK. averageAttemptsPerTranscript averages the attempts of OK records only.
Pricing
The Actor has no per-result price at the moment: you pay only the Apify platform usage of your runs, listed below. The Actor is prepared for the pay-per-event model with one event, transcript-extracted, for every record with status OK. If that pricing is switched on, records with any other status stay free and the price is shown on the Actor page before you run it.
Under pay-per-event pricing, if you set a maximum charge for the run, the Actor stops starting new videos once the limit is reached and sets chargeLimitReached in the summary. Videos that were already in flight may not be written to the dataset, because the platform stops accepting data over the limit.
Platform usage per transcript
Platform usage (residential proxy traffic, compute, storage) is billed to the account that runs the Actor while the Actor has no per-result price; under pay-per-event pricing the Actor owner would cover it instead. Measured in cloud runs on 2026-10-21 with 512 MB of memory on a free-plan account, so unit prices on your plan may differ:
| Run | Videos | OK | Platform usage | Per OK transcript |
|---|---|---|---|---|
| Default input, three videos | 3 | 3 | USD 0.00127 | USD 0.00042 |
| Mixed set with a 4.4 hour and a 3.4 hour course | 9 | 7 | USD 0.00478 | USD 0.00068 |
| 33 music videos and talks, concurrency 10 | 33 | 31 | USD 0.01486 | USD 0.00048 |
Residential traffic was about 55 KB per transcript and 92 percent of the cost in the 33-video run. Long videos are not expensive: the whole second run, including the two multi-hour courses (about 2,900 segments each), used about 0.55 MB of residential traffic, because the caption XML is transferred compressed.
The 33-video run includes one proxy connection that stalled for about a minute, which raised its compute share. The Actor now cuts every request after 22 seconds and retries it on a new IP.
Reliability
Measured on 2026-10-21 from the Apify US residential pool, 49 valid video requests in 6 cloud runs:
- 43 returned a transcript, 4 returned
NO_CAPTIONS(2 videos without captions, 2 caused by the settingsincludeAutoGenerated: falseandfallbackToAnyLanguage: falsethat were used in tests), 2 returnedUNAVAILABLE(a non-existent ID and a live stream). - 42 of the 49 videos reached their final answer on the first attempt. The other 7 failed their first attempt and reached a final answer on the second attempt. For 5 of these the logged reason was a bot check (2), a proxy
UPSTREAM504(2) or anECONNRESET(1); the other 2 happened before retry logging was added. No video needed a third attempt, and no video ended asBLOCKEDorERROR. - Without a residential proxy the same endpoint answered most requests with the bot check in earlier tests (1 of 5 videos passed from a cloud datacenter, 1 of 3 from a local connection), so the proxy is part of the design and not an option.
This is a small sample from one day. YouTube changes its anti-bot rules without notice, so check the statusCounts in the summary of your own runs.
Usage via API
Replace YOUR_USERNAME with the account that owns the Actor and YOUR_APIFY_TOKEN with an API token.
cURL
curl -X POST "https://api.apify.com/v2/acts/YOUR_USERNAME~youtube-transcript-extractor/run-sync-get-dataset-items?token=YOUR_APIFY_TOKEN" \-H "Content-Type: application/json" \-d '{"videos": ["jNQXAC9IVRw", "https://youtu.be/dQw4w9WgXcQ"],"languages": ["en"],"includeSegments": false}'
The synchronous endpoint waits for the run and has a time limit of 300 seconds. For large batches start an asynchronous run with POST /v2/acts/YOUR_USERNAME~youtube-transcript-extractor/runs and read the dataset after the run has finished.
Python
from apify_client import ApifyClientclient = ApifyClient("YOUR_APIFY_TOKEN")run = client.actor("YOUR_USERNAME/youtube-transcript-extractor").call(run_input={"videos": ["jNQXAC9IVRw", "https://youtu.be/dQw4w9WgXcQ"],"languages": ["en"],"includeSegments": True,})for item in client.dataset(run["defaultDatasetId"]).iterate_items():if item["status"] == "OK":print(item["videoId"], item["language"], item["trackKind"], item["wordCount"])else:print(item["input"], item["status"], item["message"])
Node.js
import { ApifyClient } from 'apify-client';const client = new ApifyClient({ token: 'YOUR_APIFY_TOKEN' });const run = await client.actor('YOUR_USERNAME/youtube-transcript-extractor').call({videos: ['jNQXAC9IVRw', 'https://youtu.be/dQw4w9WgXcQ'],languages: ['en'],includeSegments: true,});const { items } = await client.dataset(run.defaultDatasetId).listItems();for (const item of items) {if (item.status === 'OK') console.log(item.videoId, item.language, item.trackKind, item.wordCount);else console.log(item.input, item.status, item.message);}
Tips
- To get subtitles for a specific language, put it first in
languagesand setfallbackToAnyLanguagetofalse. Videos without that language then come back asNO_CAPTIONSwith the list of available languages inmessage. - If you only need the text, set
includeSegmentstofalse. The dataset is much smaller and the cost is the same. - To build a clean training or search corpus, set
includeAutoGeneratedtofalseand filter ontrackKind. - For lists of hundreds of videos raise
maxConcurrencyto 10. Residential traffic per transcript does not depend on concurrency. - Retry only the failed videos: filter the dataset for
BLOCKEDandERRORrecords and run the Actor again with theirinputvalues.
Limitations and Notes
- The Actor depends on an undocumented YouTube endpoint and on the
ANDROIDclient identity. YouTube can change or remove it without notice. The client version is fixed insrc/player.js. - Residential proxies are required. In tests without one, most videos were answered with the bot check, while the residential pool returned a final answer for every video within two attempts. Running with
useApifyProxy: falseis possible but most videos come back asBLOCKED. - Only public videos that offer a caption track are supported. Private, age-restricted and removed videos come back as
UNAVAILABLE. A 24/7 live stream was also returned asUNAVAILABLEin a test; members-only videos were not tested. - Translated captions (automatic translation into another language) are not supported. Only the tracks listed in
availableLanguagescan be returned. - Auto-generated captions can contain overlapping segments, because YouTube shows two lines at once.
textis built from the segments in order, so overlapping lines are not merged. - Emoji characters are removed from titles and caption text. Text symbols such as music note signs, the copyright sign and the trademark sign are kept.
wordCountsplits on whitespace, so it is not a reliable length measure for Chinese, Japanese and Thai.viewCountand the other video details are taken from the player response at the time of the request. The player response of this client does not contain the publication date, so the Actor does not return one.- Playlists, channels, search results and comments are out of scope. Pass individual video URLs or IDs.
- Run locally with
apify runonly for testing: accounts without the external proxy access feature cannot use Apify Proxy outside the platform, and a direct connection from a datacenter is usually blocked. - You are responsible for using the data in line with the YouTube Terms of Service and the copyright of the video owners. This Actor is not affiliated with or endorsed by YouTube or Google.
Local development
npm installnpm testapify run
npm test runs the unit tests of the input parser, caption parser, player response classifier, retry logic and the consistency checks between the code defaults and the files in .actor. The tests use real responses that were captured from YouTube as fixtures, and a fake network boundary for the retry logic.