# YouTube Transcript Scraper: Captions in Any Language (`subrosa/youtube-transcript-scraper`) Actor

Get YouTube transcripts in the language you asked for, with one row per video and a clear status for every failure. Matches regional tracks (pt to pt-BR), flags machine translation, and outputs JSON, text, SRT, VTT or LLM-ready text. You pay only for transcripts retrieved.

- **URL**: https://apify.com/subrosa/youtube-transcript-scraper.md
- **Developed by:** [Subrosa Systems Rosa](https://apify.com/subrosa) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

Pay per usage

This Actor is paid per platform usage. The Actor is free to use, and you only pay for the Apify platform usage, which gets cheaper the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-usage

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## YouTube Transcript Scraper

Pull the transcript out of any YouTube video that has captions, in the language you actually asked for, and get a straight answer about every video that doesn't work.

Feed it one URL or ten thousand. You get back **one row per video, always**, so a 500-item input produces a 500-row dataset you can diff against your input list. No silent drops.

***

### Why this one

Three things go wrong with YouTube transcript scrapers. This Actor is built around fixing all three.

**1. "It returned zero data."**

The usual approach scrapes the YouTube watch page for caption URLs. Those URLs still *look* valid, but as of late 2026 they return **HTTP 200 with an empty body**. A scraper that trusts the status code reports success and writes you an empty dataset.

This Actor requests captions through YouTube's mobile player API instead, which still serves real caption data, and falls back to a second client if the first one is throttled. When a body does come back empty, it is reported as `empty_transcript` with `retryable: true`, never as "this video has no captions".

**2. "I asked for Portuguese and got English."**

YouTube labels caption tracks with regional codes: `pt-BR`, `es-419`, `zh-Hans`. A scraper comparing those against your `"pt"` finds no match, quietly falls back to the first track in the list, and hands you English while reporting success.

This Actor matches `pt` to `pt-BR`, `es` to `es-419`, handles `pt_BR` and `PT-br`, prefers human-written captions over auto-generated ones, and then **tells you what it did** in four fields:

| Field | Meaning |
|---|---|
| `languageRequested` | what you asked for |
| `languageReturned` | what you actually got |
| `languageMatch` | `exact`, `base`, `translated`, or `default` |
| `translated` | `true` if this is machine translation, not real captions |

If no native track exists, you can let it fall back to YouTube's machine translation (clearly flagged) or refuse and get a clean `language_unavailable` status listing the languages that *do* exist.

**3. "Something failed and I can't tell what."**

Every failed video comes back with a machine-readable `status`, a plain-English `message`, and a `retryable` flag so your pipeline knows whether to try again or move on:

| Status | Retry? | Means |
|---|---|---|
| `no_captions` | no | Video loaded fine; captions were never published |
| `video_private` | no | Private video |
| `age_restricted` | no | Needs a signed-in session |
| `video_not_found` | no | Deleted or never existed |
| `language_unavailable` | no | Requested language absent, translation disabled |
| `invalid_input` | no | Not a resolvable YouTube URL or id |
| `login_required` | no | YouTube demanded a signed-in session. Usually means a datacenter IP; switch the proxy to residential |
| `rate_limited` | **yes** | YouTube throttled the request. Use a proxy |
| `empty_transcript` | **yes** | HTTP 200 with no content: throttling, not absence |
| `fetch_failed` | **yes** | Network or timeout |

"No captions exist" and "we got throttled" are different problems with different fixes. Most scrapers report both as the same empty row.

***

### Input

Paste YouTube links. Watch URLs, `youtu.be` short links, Shorts, embeds, live URLs, `youtube-nocookie`, or bare 11-character video ids all work, with or without extra query parameters.

```json
{
  "videoUrls": [
    "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
    "https://youtu.be/jNQXAC9IVRw?t=42",
    "https://www.youtube.com/shorts/abc123XYZ99",
    "kJQP7kiw5Fk"
  ],
  "language": "pt",
  "format": "json",
  "allowTranslation": true,
  "maxConcurrency": 5,
  "proxyConfiguration": { "useApifyProxy": true, "apifyProxyGroups": ["RESIDENTIAL"] }
}
```

| Option | Default | Notes |
|---|---|---|
| `videoUrls` | (required) | URLs or bare video ids |
| `language` | video default | BCP-47: `en`, `pt`, `pt-BR`, `es-419`… |
| `preferManual` | `true` | Human captions over auto-generated |
| `allowTranslation` | `true` | Machine-translate if no native track |
| `format` | `json` | `json`, `text`, `srt`, `vtt`, `llm` |
| `includeMetadata` | `true` | Title, channel, duration, views |
| `maxConcurrency` | `5` | 1–20 |
| `requestTimeoutSecs` | `20` | Per request |
| `proxyConfiguration` | Apify Proxy, residential | Leave on residential. See the note below |

***

### Output formats

`format` changes only the `transcript` field. Everything else stays identical.

**`json`**: timestamped cues, for search, alignment, or citation:

```json
[
  { "start": 1.36, "duration": 1.68, "text": "Never gonna give you up" },
  { "start": 3.04, "duration": 1.5,  "text": "Never gonna let you down" }
]
```

**`text`**: one clean paragraph, entities decoded, formatting tags stripped.

**`srt`** / **`vtt`**: ready-to-use subtitle files.

**`llm`**: paragraph-chunked with coarse time anchors, so a model can cite back to a moment in the video:

```
[00:00:01] Never gonna give you up Never gonna let you down …

[00:01:00] …
```

***

### Example output row

```json
{
  "input": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
  "videoId": "dQw4w9WgXcQ",
  "videoUrl": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
  "status": "ok",
  "success": true,
  "title": "Rick Astley - Never Gonna Give You Up",
  "channelName": "Rick Astley",
  "durationSeconds": 213,
  "viewCount": 1600000000,
  "languageRequested": "pt",
  "languageReturned": "pt-BR",
  "languageMatch": "base",
  "translated": false,
  "autoGenerated": false,
  "languageNote": "No exact \"pt\" track; used closest regional variant \"pt-BR\".",
  "availableLanguages": [
    { "languageCode": "en", "name": "English", "autoGenerated": false },
    { "languageCode": "pt-BR", "name": "Portuguese (Brazil)", "autoGenerated": false }
  ],
  "format": "json",
  "cueCount": 61,
  "characterCount": 1834,
  "durationCoveredSeconds": 207.42,
  "transcript": [ { "start": 1.36, "duration": 1.68, "text": "…" } ],
  "error": null
}
```

And a failure, in the same dataset, in the same shape:

```json
{
  "input": "https://www.youtube.com/watch?v=aqz-KE-bpKQ",
  "videoId": "aqz-KE-bpKQ",
  "status": "no_captions",
  "success": false,
  "title": "Big Buck Bunny",
  "transcript": null,
  "error": {
    "status": "no_captions",
    "message": "This video has no caption tracks. The video itself loaded correctly. Captions were never published for it.",
    "retryable": false
  }
}
```

***

### Pricing

**$4 per 1,000 transcripts retrieved**, plus a half-cent start fee per run.

You pay only for videos that come back with a transcript. A video with no captions, a private video, or one YouTube throttled is still written to the dataset with its status, and costs nothing. Set a maximum cost on the run and the Actor stops cleanly when it is reached.

***

### Notes on running it

- **Keep the proxy on residential.** This is the default and it matters more than it looks. YouTube answers datacenter IP addresses with a sign-in wall, so a datacenter proxy returns `login_required` on videos that are perfectly public. Residential addresses behave normally. If you switch proxy groups and suddenly see `login_required` everywhere, that is the cause.
- **Use a proxy for anything over ~50 videos.** YouTube rate-limits by IP. Without one, a long run will start returning `empty_transcript` partway through. Apify Proxy is enabled by default.
- **Machine translation is throttled harder than native tracks.** If you set `language` to something the video doesn't have, expect a lower success rate than for native captions. Those rows come back as `empty_transcript` with a message saying so, not as a false "no captions".
- **Live streams** have no completed caption track until the broadcast ends.
- **Age-restricted and private videos** cannot be read without a signed-in session. They are reported as such rather than failing vaguely.

### Retrying only what's worth retrying

```js
const { items } = await client.dataset(datasetId).listItems();
const worthRetrying = items
    .filter((r) => r.error?.retryable)
    .map((r) => r.videoUrl);
```

That's the whole point of the typed statuses: you never re-run a video that has no captions, and you never give up on one that was merely throttled.

***

### Legal and usage

This Actor reads publicly available caption data for videos you supply. It does not bypass authentication, does not access private or age-restricted content, and does not download video or audio. You are responsible for ensuring your use of the retrieved text complies with YouTube's Terms of Service and with the copyright applicable to the content you transcribe.

Questions or a video that behaves strangely? Open an issue on the Actor page. Issues get read and fixed.

# Actor input Schema

## `videoUrls` (type: `array`):

YouTube URLs or bare 11-character video ids. Accepts watch, youtu.be, shorts, embed, live and youtube-nocookie links. You get exactly one dataset row per item, whether it succeeds or fails.

## `startUrls` (type: `array`):

Same thing as the field above, in Apify's request-list format. Useful when this Actor is called from another Actor or from a webhook.

## `language` (type: `string`):

BCP-47 language code, e.g. "en", "pt", "pt-BR", "es-419". Asking for "pt" correctly matches YouTube's "pt-BR" track. Leave empty to take the video's default track. The output always tells you which language you actually got and how.

## `preferManual` (type: `boolean`):

When a video has both human-written and auto-generated captions in the same language, take the human ones. Auto-generated captions are noticeably noisier.

## `allowTranslation` (type: `boolean`):

If the requested language has no caption track, fall back to YouTube's machine translation. The output marks these rows with translated: true so you never mistake them for native captions. Turn this off to get a clean language_unavailable status instead.

## `format` (type: `string`):

json keeps the timestamped cue array. text is one clean paragraph. srt and vtt are subtitle files. llm is paragraph-chunked text with time anchors, sized for feeding into a language model.

## `includeMetadata` (type: `boolean`):

Add title, channel, duration, view count and live status to each row.

## `maxConcurrency` (type: `integer`):

How many videos to fetch at once. Raise it for speed, lower it if you are running without a proxy and hitting rate limits.

## `requestTimeoutSecs` (type: `integer`):

Per-request timeout before the Actor gives up and retries.

## `proxyConfiguration` (type: `object`):

Leave this on residential. YouTube answers datacenter IP addresses with a sign-in wall, so a datacenter proxy returns login_required for most videos even though nothing is wrong with the request. Residential addresses behave normally. Without any proxy at all, YouTube starts returning empty caption bodies partway through a large run.

## Actor input object example

```json
{
  "videoUrls": [
    "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
    "https://youtu.be/jNQXAC9IVRw",
    "kJQP7kiw5Fk"
  ],
  "language": "pt",
  "preferManual": true,
  "allowTranslation": true,
  "format": "json",
  "includeMetadata": true,
  "maxConcurrency": 5,
  "requestTimeoutSecs": 20,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}
```

# Actor output Schema

## `transcripts` (type: `string`):

Every requested video, successes and failures alike, each carrying a status and the language actually returned.

## `runSummary` (type: `string`):

Counts broken down by outcome status, so you can see at a glance how many videos failed and why.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "videoUrls": [
        "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("subrosa/youtube-transcript-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "videoUrls": ["https://www.youtube.com/watch?v=dQw4w9WgXcQ"] }

# Run the Actor and wait for it to finish
run = client.actor("subrosa/youtube-transcript-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "videoUrls": [
    "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
  ]
}' |
apify call subrosa/youtube-transcript-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,subrosa/youtube-transcript-scraper"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/t6yoffK7EwzVWuVHi/builds/2pTgHfXFGFdWkyKtC/openapi.json
