# YouTube Transcript Scraper with Chapters & Heatmap (`sz022/youtube-transcript-chapters`) Actor

Get YouTube transcripts from videos, channels, playlists or searches: timed segments, plain text, SRT or VTT in your preferred language, every caption and dubbed audio track, chapters, most-replayed moments and LLM-ready chunks with timestamp links. No API key.

- **URL**: https://apify.com/sz022/youtube-transcript-chapters.md
- **Developed by:** [CHEN-SHU-CHEN](https://apify.com/sz022) (community)
- **Stats:** 3 total users, 2 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $4.00 / 1,000 transcript extracteds

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?

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

### What is YouTube Transcript Scraper with Chapters & Heatmap?

You want what was said in a video, not the video: to search it, quote it, summarise it, or feed it to an LLM. YouTube shows the transcript one panel at a time and has no export button.

Paste **video links, channels, playlists or search queries** and get **one row per video** with the transcript as timed segments, plain text, **SRT** or **VTT**, in the language you ask for. Each row also lists **every caption track and dubbed audio track** the video has, the title, description, channel, publish date, views, likes and category, and, if you turn it on, the video's **chapters with the transcript of each one** and YouTube's **"Most replayed"** moments with what is said there.

For LLM work, it can cut each transcript into **chunks by chapter or by length**, each with a start time and a `&t=` link that opens the video at that moment.

No API key and no login. Because it runs on Apify, you can **schedule** it to pick up a channel's new videos every week, call it through the **API**, and export as **JSON, CSV or Excel**.

### How to get YouTube transcripts

1. Open the **Input** tab and paste links into **Videos, channels or playlists**, one per line: `https://www.youtube.com/watch?v=...`, `youtu.be/...`, `/shorts/...`, `@handle`, `/channel/UC...` or `/playlist?list=...`. Or type a search into **Search queries**.
2. Set **Preferred languages**, for example `en` then `es`. If none exists, the video's own language is returned instead, and the row says so.
3. For channels, playlists and searches, set **Videos per channel, playlist or search**. To read only a channel's new videos, set **Only videos published after** to `7 days` and schedule the run weekly.
4. Click **Start**. A video usually takes one to three seconds; five are read at a time.
5. Open the **Output** tab or export the dataset. Videos without a transcript are in the table too, with a `status` and the reason.

### How much does it cost to scrape YouTube transcripts?

You pay per video, and only for what was delivered.

| Event | Charged when | Price | Per 1,000 videos |
|---|---|---|---|
| `transcript-extracted` | The transcript and metadata of one video were returned (`status: ok`) | $0.004 | $4.00 |
| `tracks-listed` | In **caption track list only** mode: the track list and metadata of one video were returned | $0.001 | $1.00 |
| `chapters-heatmap` | With **Chapters, heatmap and channel extras** on, on top of the row, only when that data was actually returned | $0.001 | $1.00 |
| `video-unavailable` | YouTube answered, but there is no transcript to give: `no_captions`, `language_unavailable`, `not_found`, `private`, `age_restricted`, `live_now`, `upcoming`, `unplayable`, `login_required`. The row still carries whatever metadata YouTube showed | $0.0005 | $0.50 |

Never charged: `blocked` (YouTube kept refusing the request), `date_unknown`, `parse_error`, `invalid_input`, `no_videos` (a channel, playlist or search that turned up nothing) and `not_processed` (never reached because the run stopped). Rows for a channel, playlist or search as a whole, rather than for one of its videos, are always free.

Proxy traffic is included. Apify's free plan ($5 a month) covers about 1,250 transcripts.

#### How do I keep a run from costing more than I expect?

Set a maximum charge when you start the run. Before reading each video, the Actor checks that what is left of it covers the most that video could cost (a transcript, plus the extras if they are on), counting the videos already being read, and stops if not. Each row is then written and charged in one step by Apify's own SDK, which writes it only if the limit still covers it, so you never pay for a row you did not get. The extras are charged only after their row is written, once per video. Videos the run did not reach come back `not_processed`. If Apify cannot record a charge, the run stops the same way rather than keep working unbilled.

The run also stops reading 45 seconds before its timeout, including in the middle of retries and channel listings, and marks what is left `not_processed`.

### Input

| Field | Default | Notes |
|---|---|---|
| `urls` | | Videos, channels and playlists, mixed. A watch link that also names a playlist (`watch?v=...&list=...`) is read as the one video. A bare `@handle` needs its `@` |
| `searchQueries` | | Each query returns the top videos for it |
| `maxVideosPerSource` | 20 | Per channel, playlist and search, up to 5,000 |
| `publishedAfter` | | `2026-01-31`, or `7 days`, `2 weeks`, `3 months`. Filters what channels, playlists and searches turn up; video links you list yourself are always read |
| `channelContentType` | `videos` | `videos`, `shorts`, `streams` (past live streams) or `all` uploads, newest first |
| `languages` | `["en"]` | In order of preference. `en` also takes `en-US` or `en-GB`. Empty: the video's default track |
| `fallbackToAnyLanguage` | true | Off: a video without any of your languages comes back `language_unavailable` with the tracks it does have |
| `preferManualCaptions` | true | Within one language, captions uploaded by the creator beat YouTube's auto-generated ones |
| `mode` | `transcript` | `tracksOnly` returns the track lists and metadata, without downloading captions |
| `outputFormats` | `segments`, `text` | Add `srt` and `vtt` for subtitle files. Formats you leave out are `null` in the row |
| `includeWordTimestamps` | false | Start and end of every word. Auto-generated captions only; uploaded captions have no word timings |
| `includeChaptersAndHeatmap` | false | One more request per video, charged as `chapters-heatmap` |
| `llmChunking` | `none` | `chapters` or `length`. `chunkMaxCharacters` (4,000) caps each chunk; a caption line is never split |
| `useResidentialFallback` | true | See "Why is a video blocked?" below |
| `abortAfterConsecutiveFailures` | 10 | Stops the run when YouTube refuses everything. Videos without captions do not count |

```json
{
  "urls": [
    "https://www.youtube.com/watch?v=aircAruvnKk",
    "@3blue1brown"
  ],
  "maxVideosPerSource": 10,
  "publishedAfter": "30 days",
  "languages": ["en"],
  "outputFormats": ["segments", "text", "srt"],
  "includeChaptersAndHeatmap": true,
  "llmChunking": "chapters"
}
```

### Output

One row per video, plus one row for each input line that turned up no video.

| Field | Notes |
|---|---|
| `status`, `error`, `notes` | See the statuses below. `notes` explains anything left out or substituted, such as a fallback language |
| `videoId`, `url`, `title`, `description`, `keywords` | |
| `channelName`, `channelHandle`, `channelId`, `channelUrl` | |
| `publishedAt`, `uploadedAt` | ISO 8601, UTC, to the second |
| `lengthSeconds`, `viewCount`, `likeCount` | Exact. `null` on a stream that is live now, or when YouTube hid the figure |
| `category`, `isShort`, `isLive`, `isLiveContent`, `isUpcoming`, `isUnlisted`, `isFamilySafe`, `isPrivate`, `allowRatings`, `availableCountries` | `isLiveContent` is true for recordings of past streams |
| `liveStartedAt`, `liveEndedAt` | For streams |
| `thumbnailUrl`, `thumbnails` | Largest first in `thumbnailUrl`; every size in `thumbnails` |
| `language`, `languageName`, `isAutoGenerated` | The track returned |
| `languageMatch` | `preferred` (one of your languages) or `fallback` (the video's own default, because none of yours exists) |
| `segments` | `[{start, end, duration, text}]`, seconds. With word timestamps, each segment also has `words: [{start, end, text}]` |
| `text`, `srt`, `vtt` | The whole transcript as one string, or as subtitle files |
| `segmentCount`, `wordCount`, `characterCount` | |
| `isMusicOnly` | True when the captions hold nothing but sound tags such as `[Music]` or `♪`: nobody speaks |
| `captionTracks` | Every track: `languageCode`, `name`, `kind` (`manual` or `asr`), `isAutoGenerated`, `isTranslatable`, `vssId`, `trackName` |
| `captionTrackCount`, `hasManualCaptions`, `hasAutoCaptions` | |
| `spokenLanguage` | The language of YouTube's auto-generated track, which is the language YouTube heard. `null` when there is none |
| `audioTracks`, `hasDubbedAudio` | Dubbed audio tracks: `id`, `languageCode`, `name`, `isDefault`, `isOriginal` |
| `chapters`, `chapterSource` | `[{title, start, end, startTimestamp, url, text}]`, where `text` is the transcript of that chapter. `chapterSource`: `description` (timestamps in the description, read with YouTube's own rules, free), or with the extras on `youtube_description` / `youtube_auto` (chapters YouTube generated itself) |
| `llmChunks` | `[{index, chapterTitle, start, end, startTimestamp, url, characterCount, text}]` |
| `heatmap` | With the extras: `[{start, end, intensity}]`, YouTube's replay intensity from 0 to 1 over about 100 slices of the video |
| `mostReplayed` | With the extras: YouTube's "Most replayed" ranges, `[{start, end, peakAt, label, url, transcriptExcerpt}]`. When YouTube marks none, the hottest heatmap slice, with `source: "heatmap_peak"` |
| `channelIsVerified`, `channelIsVerifiedArtist` | With the extras |
| `subscriberCount`, `subscriberCountText`, `commentCount`, `commentCountText` | With the extras. **Rounded by YouTube** (`8.7M subscribers`, `9.4K`); the number is parsed from the text, and the text is kept as shown |
| `extrasIncluded` | Whether the extras were returned, and so charged |
| `playabilityReason` | YouTube's own message when it would not play the video |
| `fetchedVia` | `datacenter`, `residential` or `direct` |
| `source`, `sourceType`, `inputIndex` | The input line the video came from (`video`, `channel`, `playlist`, `search`) and its position |
| `scrapedAt` | |

Unknown values are `null`, never `0`. Caption text is decoded: you get `It's`, never `It&#39;s`.

Example row from a run on 2026-10-18, shortened:

```json
{
  "status": "ok",
  "videoId": "aircAruvnKk",
  "title": "But what is a neural network? | Deep learning chapter 1",
  "channelName": "3Blue1Brown",
  "channelHandle": "@3blue1brown",
  "publishedAt": "2017-10-05T15:11:25.000Z",
  "lengthSeconds": 1120,
  "viewCount": 24860971,
  "likeCount": 566677,
  "category": "Education",
  "language": "en",
  "isAutoGenerated": false,
  "languageMatch": "preferred",
  "segmentCount": 286,
  "wordCount": 3357,
  "text": "This is a 3. It's sloppily written and rendered at an extremely low resolution of 28x28 pixels…",
  "segments": [{"start": 4.22, "end": 5.4, "duration": 1.18, "text": "This is a 3."}],
  "captionTrackCount": 31,
  "spokenLanguage": "en",
  "hasDubbedAudio": true,
  "chapterSource": "youtube_description",
  "chapters": [{"title": "Introduction example", "start": 0.0, "end": 67.0, "startTimestamp": "0:00",
                "url": "https://www.youtube.com/watch?v=aircAruvnKk&t=0s", "text": "This is a 3. It's sloppily…"}],
  "mostReplayed": [{"start": 504.0, "end": 582.4, "peakAt": 515.2, "label": "Most replayed",
                    "url": "https://www.youtube.com/watch?v=aircAruvnKk&t=504s",
                    "transcriptExcerpt": "determine the activations in the next. The goal is to have some mechanism…"}],
  "channelIsVerified": true,
  "subscriberCount": 8700000,
  "subscriberCountText": "8.7M subscribers",
  "commentCount": 9400,
  "commentCountText": "9.4K"
}
```

#### Statuses

- `ok` — transcript returned.
- `tracks_listed` — track list returned (track list only mode).
- `no_captions` — the video has no captions at all, neither uploaded nor auto-generated. Common for music without lyrics, very new videos (YouTube adds auto-captions some hours after upload) and channels that turned them off.
- `language_unavailable` — none of your languages, with fallback off. `error` lists the languages there are.
- `not_found`, `private`, `age_restricted`, `live_now`, `upcoming`, `unplayable`, `login_required` — YouTube's own answer about the video; `playabilityReason` has its message. `live_now`: captions of a stream exist only once it has ended.
- `blocked` — YouTube kept answering with a bot check or a rate limit, or the caption file could not be downloaded. Not charged; try again later.
- `parse_error` — YouTube answered in a shape the Actor does not recognise. Not charged; please report it.
- `date_unknown` — **Only videos published after** is set, and the video's exact publish date could not be read (YouTube's WEB player request failed). The video is not read and not charged, rather than charged when it might be older than asked. Run again later.
- `no_videos` — a channel, playlist or search that turned up no video (or none newer than `publishedAfter`).
- `invalid_input` — the line is not a YouTube video, channel or playlist.
- `not_processed` — never reached: the run hit its maximum charge, its timeout, or a failure streak.

The rows without a transcript are also listed in `FAILED_VIDEOS` in the key-value store.

### Why is a video blocked?

YouTube asks some datacenter IP addresses to "Sign in to confirm you're not a bot". Measured on Apify in October 2026: 10 of 50 videos got an answer on the first datacenter IP, 44 of 50 within six retries on fresh IPs, and 5 of 5 on residential IPs.

The same happens, less often, to the request that carries the publish date, category and likes. So each of the two requests is retried on fresh datacenter IPs first, up to seven attempts, and then, with `useResidentialFallback` on (the default), once or twice through a residential IP. Every request asks YouTube for only the fields it reads, so a residential retry costs a few kilobytes, and it is included in the price. A video that is still refused comes back `blocked` and is not charged. If many videos in a run are blocked, YouTube is limiting this proxy pool for now: run again later.

### What it cannot do

- **Translate.** YouTube's own translated captions answered with HTTP 429 (too many requests) in testing, so they are not offered. Every caption track the video has in its own right is listed and can be chosen. Use `isTranslatable` to see which ones YouTube could translate, and translate the text yourself.
- **Transcribe videos without captions.** No speech-to-text here. Those videos come back `no_captions`, with their metadata, at the low `video-unavailable` price.
- **Captions of a stream while it is live**, members-only videos, private videos, and age-restricted videos (YouTube asks for a signed-in adult).
- **Exact subscriber and comment counts.** YouTube rounds them for everyone but the channel owner; its official API rounds subscribers too.
- **Comment text.**

### FAQ

#### Is it legal to scrape YouTube transcripts?

The Actor reads only what any visitor can see on a public video without signing in, and uses no account. Captions are the creator's work: check copyright and YouTube's terms before republishing them. This is not legal advice.

#### Why did I get the auto-generated transcript when the video has uploaded captions?

Uploaded captions win only within the same language. If you ask for `es` and the video has uploaded English and auto-generated Spanish, you get the Spanish. Put the languages in the order you want them, or turn off fallback.

#### How do I get only new videos from a channel?

Put the channel in `urls`, set `publishedAfter` to `7 days`, and schedule the run every week. Channels list newest first, so the Actor stops paging at the first video that is certainly older, and checks every video it keeps against its exact publish date before reading the transcript. Older videos are left out and not charged.

#### Where do the chapters come from?

Without the extras, from the timestamps in the description, read with YouTube's rules: the first is `0:00`, there are at least three, in ascending order, each at least ten seconds long. With the extras on, from YouTube's own chapter list, which also includes chapters YouTube generated itself for videos whose description has none.

#### What happens if Apify restarts the run?

Every row is recorded in the key-value store right after it is written. A restart lists the channels and playlists again, skips the videos already written, and reads again the ones that were still in progress, so a written row is neither repeated nor charged twice. When Apify announces a migration, the Actor stops writing at once and leaves the videos in progress to the restart.

#### Something broke or looks wrong

Open an issue on the **Issues** tab with the video link and the run link. YouTube changes its internal API without notice; when a response stops making sense, rows come back `parse_error` instead of empty text, so the problem shows.

# Actor input Schema

## `urls` (type: `array`):

One per line, mixed freely. Videos: https://www.youtube.com/watch?v=..., youtu.be/..., /shorts/... or a bare 11-character video ID. Channels: https://www.youtube.com/@handle, /channel/UC..., or @handle. Playlists: https://www.youtube.com/playlist?list=... or a bare playlist ID. Channels and playlists expand into their newest videos, up to the limit below.

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

YouTube searches, one per line. Each returns the top videos for the query, up to the limit below.

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

How many videos to take from each channel, playlist or search query. Single video links are always read.

## `publishedAfter` (type: `string`):

Optional. A date (2026-01-31) or a period back from now (7 days, 2 weeks, 3 months). Use it on a schedule to read only a channel's new videos. Applies to channels, playlists and searches; video links you list yourself are always read.

## `channelContentType` (type: `string`):

Which of a channel's uploads to list, newest first.

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

Caption languages in order of preference, as codes: en, es, pt-BR, ja. "en" also takes en-US or en-GB. Leave empty to take the video's own default track.

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

When none of the preferred languages exist, return the video's default track instead (the row says so in languageMatch and notes). Off: such videos come back as language_unavailable, with the list of tracks they do have.

## `preferManualCaptions` (type: `boolean`):

Within one language, take uploaded captions over YouTube's auto-generated ones. Off: take auto-generated first.

## `mode` (type: `string`):

Full transcripts, or only the list of caption and audio tracks each video has (cheaper, no caption download).

## `outputFormats` (type: `array`):

segments: timed lines (start, end, text). text: one plain string. srt and vtt: subtitle files as text.

## `includeWordTimestamps` (type: `boolean`):

Add the start and end of every word to each segment. Only YouTube's auto-generated captions carry word timings.

## `includeChaptersAndHeatmap` (type: `boolean`):

One more request per video: YouTube's chapter list (including chapters it generated itself), the 'Most replayed' heatmap with the transcript of the most replayed part, the verified badge, and the subscriber and comment counts. Charged as chapters-heatmap on top of the row. Chapters written in the description are returned without it, free.

## `llmChunking` (type: `string`):

Split each transcript into chunks with a start time and a deep link (\&t=) to that moment. By chapter uses the video's chapters and splits long ones; videos without chapters are chunked by length.

## `chunkMaxCharacters` (type: `integer`):

A chunk ends before it would exceed this. A caption line is never split.

## `useResidentialFallback` (type: `boolean`):

YouTube asks some datacenter IPs to 'confirm you're not a bot'. Each video is first retried on fresh datacenter IPs; with this on, a video that still gets the check is tried once or twice through a residential IP. Included in the price. Off: such videos come back as blocked and are not charged.

## `abortAfterConsecutiveFailures` (type: `integer`):

Ends the run early when YouTube stops answering. Videos without captions, private or deleted videos do not count. 0 never stops early.

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

Apify's datacenter proxy by default. Every request asks YouTube for only the fields it reads, so a video takes about 10 to 30 KB.

## Actor input object example

```json
{
  "urls": [
    "https://www.youtube.com/watch?v=aircAruvnKk",
    "https://youtu.be/jNQXAC9IVRw"
  ],
  "maxVideosPerSource": 20,
  "channelContentType": "videos",
  "languages": [
    "en"
  ],
  "fallbackToAnyLanguage": true,
  "preferManualCaptions": true,
  "mode": "transcript",
  "outputFormats": [
    "segments",
    "text"
  ],
  "includeWordTimestamps": false,
  "includeChaptersAndHeatmap": false,
  "llmChunking": "none",
  "chunkMaxCharacters": 4000,
  "useResidentialFallback": true,
  "abortAfterConsecutiveFailures": 10,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

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

Transcript (segments, text, SRT or VTT), chosen language, every caption and audio track, video and channel metadata, chapters, and optional heatmap and LLM chunks.

## `failedVideos` (type: `string`):

The subset of rows that returned no transcript, with the reason for each. Also present in the dataset itself.

# 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 = {
    "urls": [
        "https://www.youtube.com/watch?v=aircAruvnKk",
        "https://youtu.be/jNQXAC9IVRw"
    ],
    "proxyConfiguration": {
        "useApifyProxy": true
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("sz022/youtube-transcript-chapters").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 = {
    "urls": [
        "https://www.youtube.com/watch?v=aircAruvnKk",
        "https://youtu.be/jNQXAC9IVRw",
    ],
    "proxyConfiguration": { "useApifyProxy": True },
}

# Run the Actor and wait for it to finish
run = client.actor("sz022/youtube-transcript-chapters").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 '{
  "urls": [
    "https://www.youtube.com/watch?v=aircAruvnKk",
    "https://youtu.be/jNQXAC9IVRw"
  ],
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}' |
apify call sz022/youtube-transcript-chapters --silent --output-dataset

```

## MCP server setup

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

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/EByaqGL6zn8WjTRai/builds/Y8UAzcrADMR7LfZvk/openapi.json
