# YouTube Transcript Scraper - Subtitles & Timestamps (`scrapersdelight/youtube-transcript-scraper`) Actor

Get YouTube transcripts with timestamps from any video, playlist, channel or search. Auto-generated (ASR) and human captions, any language, optional machine translation, plus SRT/VTT and full video metadata. No API key, no login.

- **URL**: https://apify.com/scrapersdelight/youtube-transcript-scraper.md
- **Developed by:** [Scrapers Delight](https://apify.com/scrapersdelight) (community)
- **Categories:** Videos, Social media, AI
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$0.80 / 1,000 per video transcript returneds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

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

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
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.
Actors are written with capital "A".

## 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.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## 🎬 YouTube Transcript Scraper

Pull the **transcript of any YouTube video** — as plain text and as timestamped cues — from a video URL, a whole playlist, a channel, or a search query. Auto-generated (ASR) captions and human-written subtitles, in **any language the video publishes**, with optional machine translation into 18+ more.

No API key. No login. No cookies. No YouTube Data API quota.

### What does YouTube Transcript Scraper do?

You give it videos. It gives you back, **one row per video**:

- 📝 **`transcript`** — the whole thing as clean, joined plain text, ready to paste into an LLM
- ⏱️ **`segments`** — every caption cue with `start`, `duration` and `end` in seconds
- 🎞️ **`srt` / `vtt`** — the same transcript rendered as a ready-to-use subtitle file (optional)
- 🌍 **`languageCode` + `isAutoGenerated`** — exactly which track you got, and whether a human wrote it
- 🗂️ **`availableLanguages`** — every other caption track the video offers, so you know what else you could have asked for
- 🔁 **`translateTo`** — have YouTube translate the transcript before it is returned
- 📺 **video metadata** — title, channel, channel ID, duration, views, likes, publish date, YouTube category, description, keywords, thumbnail
- 🚦 **`transcriptStatus`** — `ok`, `no-captions`, `no-transcript-in-requested-language`, `video-unavailable`. **A row is never silently empty.**

### Quick start

```jsonc
{
  "videoUrls": [
    "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
    "https://youtu.be/jNQXAC9IVRw",
    "dQw4w9WgXcQ"
  ]
}
```

Or point it at a whole channel, a playlist and a search at once:

```jsonc
{
  "channelUrls":  ["https://www.youtube.com/@veritasium"],
  "playlistUrls": ["https://www.youtube.com/playlist?list=PLbpi6ZahtOH6Blw3RGYpWkSByi_T7Rygb"],
  "searchQueries": ["noticias en español hoy"],
  "maxVideosPerSource": 25,
  "maxItems": 75
}
```

URL forms accepted: `watch?v=`, `youtu.be/`, `/shorts/`, `/embed/`, `/live/`, `music.youtube.com`, `youtube-nocookie.com`, and bare 11-character video IDs. Channels accept `@handle`, `/channel/UC…`, `/c/`, `/user/` or a bare `UC…` ID. All twelve URL forms are covered by the parser's test cases.

### Two things most transcript scrapers get wrong

**1. Auto-generated captions come back duplicated.** YouTube's ASR json3 stream is a *rolling* caption: every real cue is followed by an `aAppend` redraw event whose only content is a newline. Parsers that ignore the flag emit every line twice. Measured on a real ASR track: **133 caption events, of which 66 were redraws — this actor keeps the 67 real ones.**

**2. A Spanish video comes back blank because you asked for English.** By default `fallbackToAnyLanguage` is on: if the video publishes captions but not in your preferred languages, you get the best track it *does* have, correctly labelled, instead of an empty row. In a 15-video mixed run, that is what turned four Spanish news videos from blanks into full transcripts.

And when a transcript genuinely cannot be produced, the row says so in `transcriptStatus` and `transcriptError` rather than shipping an empty string.

### Output

One row per video. Measured over a **32-video corpus** (music, tutorials, TED talks, Spanish and Korean news, DIY, Shorts, 24/7 livestreams) parsed offline from captured bytes on 2026-09-02: **28 of 32 produced a transcript — 27,678 caption cues and 226,853 words** — and the other 4 genuinely publish no captions at all (three are 24/7 live news streams).

```json
{
  "videoId": "dQw4w9WgXcQ",
  "videoUrl": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
  "title": "Rick Astley - Never Gonna Give You Up (Official Video) (4K Remaster)",
  "channelName": "Rick Astley",
  "channelId": "UCuAXFkgsw1L7xaCfnd5JJOw",
  "channelUrl": "https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw",

  "durationSeconds": 213,
  "viewCount": 1810788783,
  "likeCount": 19368027,
  "publishedAt": "2009-10-24T23:57:33-07:00",
  "uploadedAt": "2009-10-24T23:57:33-07:00",
  "category": "Music",
  "keywords": ["rick astley", "never gonna give you up", "…"],
  "description": "The official video for “Never Gonna Give You Up”…",
  "thumbnailUrl": "https://i.ytimg.com/vi/dQw4w9WgXcQ/sddefault.jpg",
  "isLiveContent": false, "isPrivate": false, "isUnlisted": false, "isFamilySafe": true,

  "transcriptStatus": "ok",
  "language": "English",
  "languageCode": "en",
  "isAutoGenerated": false,
  "isTranslated": false,
  "translatedTo": null,

  "transcript": "[♪♪♪] ♪ We're no strangers to love ♪ ♪ You know the rules and so do I ♪ …",
  "segments": [
    { "start": 1.36,  "duration": 1.68, "end": 3.04,  "text": "[♪♪♪]" },
    { "start": 18.64, "duration": 3.24, "end": 21.88, "text": "♪ We're no strangers to love ♪" }
  ],
  "segmentCount": 61,
  "wordCount": 487,
  "characterCount": 2089,

  "availableLanguages": [
    { "languageCode": "en",     "languageName": "English",                 "kind": "manual" },
    { "languageCode": "en",     "languageName": "English (auto-generated)", "kind": "asr" },
    { "languageCode": "de-DE",  "languageName": "German (Germany)",         "kind": "manual" },
    { "languageCode": "ja",     "languageName": "Japanese",                 "kind": "manual" },
    { "languageCode": "pt-BR",  "languageName": "Portuguese (Brazil)",      "kind": "manual" },
    { "languageCode": "es-419", "languageName": "Spanish (Latin America)",  "kind": "manual" }
  ],
  "availableLanguageCount": 6,
  "translationLanguages": ["ar", "zh-Hant", "nl", "en", "fr", "de", "hi", "id", "…"],

  "scrapedAt": "2026-09-02T04:22:21.784Z"
}
```

#### Measured field fill

Over the same 32-video corpus. "With a transcript" is the 28 rows that produced one.

| Field | All 32 rows | Rows with a transcript |
| --- | --- | --- |
| `videoId`, `videoUrl`, `title`, `channelName`, `channelId`, `channelUrl` | 100% | 100% |
| `durationSeconds`, `viewCount`, `thumbnailUrl` | 100% | 100% |
| `publishedAt`, `uploadedAt`, `category` | 100% | 100% |
| `isLiveContent`, `isPrivate`, `isUnlisted`, `isFamilySafe` | 100% | 100% |
| `transcriptStatus`, `segmentCount`, `wordCount`, `characterCount` | 100% | 100% |
| `likeCount` | 97% | 96% |
| `description` | 91% | 93% |
| `keywords` | 81% | 86% |
| `transcript`, `segments`, `languageCode`, `language`, `isAutoGenerated` | 88% | **100%** |

`likeCount` is missing where the uploader hides it; `keywords` and `description` where the uploader left them empty. Nothing here is estimated — these are counts over real rows.

### Options that matter

- **`languages`** — ordered preference list, e.g. `["en", "es", "pt-BR"]`. `en` also matches `en-US` and `en-GB`. Use `["any"]` to always take the video's own default track.
- **`fallbackToAnyLanguage`** *(on)* — never return blank just because your preferred language is missing.
- **`preferAutoGenerated`** *(off)* — human captions win by default because they are punctuated; switch on for ASR, which is usually more complete on long videos.
- **`translateTo`** — a language code (`es`, `fr`, `de`, `ja`…) to have YouTube translate the transcript. Each row lists its own valid targets in `translationLanguages`.
- **`includeSegments`** *(on)* / **`includeSrt`** / **`includeVtt`** — which representations you want. Turn `includeSegments` off for much smaller rows when you only need the text.
- **`includeVideoMetadata`** *(on)* — publish date, category and like count come from a second, small request; turn it off to shave a request per video.
- **`skipVideosWithoutTranscript`** *(off)* — leave off to see every video and why it failed; switch on to deliver only successes.
- **`maxVideosPerSource`** *(50)* and **`maxItems`** *(200)* — both bounded by default so a 5,000-upload channel cannot run away.

### Pricing

Pay per result. You are charged for rows you actually receive, and never for a video that was blocked.

| Event | Price | When it fires |
| --- | --- | --- |
| `transcript-scraped` | **$0.0008** | One per video row pushed to the dataset |

That is **$0.80 per 1,000 videos** — no run start fee, and cheaper than every other YouTube transcript actor on the Store with real traction (the lane sits between $0.001 and $0.01 per video). Videos are deduplicated across playlists, channels and searches before billing, so the same video is never charged twice in one run.

Worked examples: a 200-video channel ≈ **$0.16**. Ten thousand videos ≈ **$8.00**. A single video ≈ **$0.0008**.

Rows are billed through Apify's gated `pushData` form, so at a spend cap the run stops cleanly and you are never charged for rows you did not receive.

### FAQ

**Does this need a YouTube API key or a Google account?**
No. It talks to YouTube's own public InnerTube endpoint with YouTube's own public web key. No login, no cookies, no OAuth, no Data API quota.

**Does it transcribe videos that have no captions?**
No — and it says so plainly rather than pretending. This actor reads the captions YouTube already publishes (human-written or YouTube's own ASR). If a video has neither, the row comes back with `transcriptStatus: "no-captions"`. Speech-to-text on the audio is a different, much more expensive product.

**Do I get auto-generated captions, or only human ones?**
Both. Human captions are preferred for the same language because they are punctuated and accurate, and YouTube's ASR track is used whenever that is what exists. `isAutoGenerated` tells you which one you got, every time.

**Why does it need a residential proxy?**
Because YouTube blocks datacenter IPs on this endpoint. Measured 2026-09-02 across three videos and seven InnerTube client profiles: through a datacenter pool **every single request** answered `LOGIN_REQUIRED — "Sign in to confirm you're not a bot"` with zero caption tracks; through residential it returned full transcripts on all three. Apify Proxy with the RESIDENTIAL group is the default and needs no configuration from you.

**What happens when a video is blocked?**
It is retried on fresh proxy exits (the wall is per-IP, not per-video), then — if it still will not open — it is **counted and named in the run's status message, not delivered as an empty row and not charged.** A blocked page reported as "no results" is the failure mode this actor exists to avoid.

**Can I get transcripts for a whole channel?**
Yes. Put the channel URL or `@handle` in `channelUrls`. It walks the channel's Videos tab with continuation tokens, bounded by `maxVideosPerSource` (default 50 — raise it for a full back catalogue).

**Can I translate the transcript?**
Yes. Set `translateTo` to a language code and YouTube translates it server-side before it is returned. Each row lists its own available targets in `translationLanguages` (18 for the sample video above).

**Does it work on YouTube Shorts and livestreams?**
Shorts, yes — pass the `/shorts/` URL. Livestreams only once the stream has captions; 24/7 news channels streaming live usually publish none, and come back `no-captions`. Three of the four caption-less videos in our corpus were exactly that.

**How long can a transcript be?**
The longest in our corpus was a 12-hour Python course: **15,121 cues, 92,922 words, a 1.89 MB row.** Everything fits in one dataset item; on the rare video large enough to exceed Apify's 9 MB item limit the timestamped representations are dropped and the plain text kept, flagged with `oversizedRow`.

**Are the timestamps accurate?**
They come straight from YouTube's own cue timings in milliseconds, converted to seconds. `start`, `duration` and `end` are all provided, and the SRT/VTT renderers use the same numbers.

**What if YouTube changes the caption format?**
The parser reads json3 first and falls back to YouTube's srv1 XML shape. Verified on the same video: both produce **61 identical cues** with identical text.

**Is the output stable enough to diff between runs?**
Yes — field names and types do not change per video, and every row carries `scrapedAt`. Counts like `viewCount` and `likeCount` are a snapshot at that moment.

### Notes and honest limits

- **Captions only.** This reads what YouTube publishes. It does not run speech recognition on the audio, so a video with no captions produces no transcript — by design, and reported as such.
- **Residential proxy is required, not optional.** Datacenter IPs get the bot wall on this endpoint. It is the default; leave it alone unless you are supplying your own residential proxy.
- **Private, deleted, age-restricted and members-only videos** return `transcriptStatus: "video-unavailable"` with the reason YouTube gave. This actor has no login and does not attempt one.
- **Translations are YouTube's machine translation**, not a human or an LLM. Quality is whatever YouTube's is.
- **`en` matches `en-US`/`en-GB`, and both an `en` manual and an `en` ASR track can exist** on the same video — `isAutoGenerated` disambiguates.
- **You are responsible for your use of this data.** Review [YouTube's Terms of Service](https://www.youtube.com/t/terms) and respect copyright in the transcripts you collect.

# Actor input Schema

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

YouTube videos to transcribe. Accepts watch URLs, youtu.be short links, /shorts/, /embed/, /live/ and bare 11-character video IDs. Mix any of them freely.

## `playlistUrls` (type: `array`):

Transcribe every video in these playlists. Accepts a playlist URL (https://www.youtube.com/playlist?list=…), a watch URL that carries a `list=` parameter, or a bare playlist ID. Capped per playlist by 'Max videos per source'.

## `channelUrls` (type: `array`):

Transcribe the newest videos from these channels. Accepts @handles, /channel/UC… URLs, /c/ and /user/ vanity URLs, or a bare UC… channel ID. Capped per channel by 'Max videos per source'.

## `searchQueries` (type: `array`):

Search YouTube and transcribe the top video results for each query. Capped per query by 'Max videos per source'.

## `languages` (type: `array`):

Ordered preference list of language codes, e.g. \['en', 'es', 'pt-BR']. The first one the video actually publishes wins; 'en' also matches 'en-US' and 'en-GB'. Use \['any'] to always take the video's own default track.

## `fallbackToAnyLanguage` (type: `boolean`):

If the video has captions but not in your preferred languages, return the best track it does have instead of an empty row. On by default — a Spanish video coming back blank because you asked for English is the most common complaint about transcript scrapers.

## `preferAutoGenerated` (type: `boolean`):

By default human-written captions win over YouTube's automatic speech recognition for the same language, because they are punctuated and accurate. Switch this on to prefer the ASR track instead (it is usually more complete on long videos).

## `translateTo` (type: `string`):

Optional. A language code (e.g. `es`, `fr`, `de`, `ja`) to have YouTube machine-translate the transcript before it is returned. Leave empty to get the original language. The available targets for each video are listed in the row's `translationLanguages` field.

## `includeSegments` (type: `boolean`):

Include the `segments` array — every caption cue with its start, duration and end in seconds. Turn off for a much smaller row when you only want the plain text.

## `includeSrt` (type: `boolean`):

Add an `srt` field containing the transcript rendered as a ready-to-use .srt subtitle file.

## `includeVtt` (type: `boolean`):

Add a `vtt` field containing the transcript rendered as a ready-to-use WebVTT file.

## `includeVideoMetadata` (type: `boolean`):

Fetch the video's publish date, upload date, YouTube category and like count. These live in a different response than the captions, so this adds one small request per video. Title, channel, duration, views, description, keywords and thumbnail come free either way.

## `skipVideosWithoutTranscript` (type: `boolean`):

Leave off (the default) to get a row for every video, with `transcriptStatus` and `transcriptError` explaining any that came back empty — you are never charged for a row you did not ask to skip, but you do see which videos failed and why. Switch on to deliver only videos that actually produced a transcript.

## `maxVideosPerSource` (type: `integer`):

How many videos to take from each playlist, channel or search query. Bounded by default so a channel with 5,000 uploads does not run away. 0 = no per-source limit.

## `maxItems` (type: `integer`):

Hard stop on the total number of videos transcribed in this run, across every source. 0 = no limit.

## `requestConcurrency` (type: `integer`):

How many videos to fetch in parallel. YouTube throttles hard above ~10; the default of 5 is measured as the fast, reliable setting.

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

Defaults to Apify Proxy with the RESIDENTIAL group, and you should leave it there. Measured 2026-09-02: through Apify's DATACENTER pool YouTube answers every request with 'Sign in to confirm you're not a bot' and zero caption tracks; through RESIDENTIAL it returns full transcripts. Datacenter proxies do not work for this endpoint. You can supply your own residential proxy instead.

## Actor input object example

```json
{
  "videoUrls": [
    "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
    "https://youtu.be/jNQXAC9IVRw"
  ],
  "playlistUrls": [],
  "channelUrls": [],
  "searchQueries": [],
  "languages": [
    "en"
  ],
  "fallbackToAnyLanguage": true,
  "preferAutoGenerated": false,
  "translateTo": "",
  "includeSegments": true,
  "includeSrt": false,
  "includeVtt": false,
  "includeVideoMetadata": true,
  "skipVideosWithoutTranscript": false,
  "maxVideosPerSource": 50,
  "maxItems": 2,
  "requestConcurrency": 5,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}
```

# Actor output Schema

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

The dataset of scraped transcripts (one item per video).

# 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",
        "https://youtu.be/jNQXAC9IVRw"
    ],
    "languages": [
        "en"
    ],
    "maxItems": 2
};

// Run the Actor and wait for it to finish
const run = await client.actor("scrapersdelight/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",
        "https://youtu.be/jNQXAC9IVRw",
    ],
    "languages": ["en"],
    "maxItems": 2,
}

# Run the Actor and wait for it to finish
run = client.actor("scrapersdelight/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",
    "https://youtu.be/jNQXAC9IVRw"
  ],
  "languages": [
    "en"
  ],
  "maxItems": 2
}' |
apify call scrapersdelight/youtube-transcript-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,scrapersdelight/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/LgjrqcBhdL8hra7KV/builds/33UwOF4ZjgExAMlt9/openapi.json
