# YouTube Transcript Scraper - video, channel and playlist (`marekhartmann/youtube-transcript-scraper`) Actor

Get transcripts from YouTube videos, whole channels and playlists. Every video you ask for gets a row - with the transcript, or with the exact reason there is none. Rows without a transcript are never charged.

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

## Pricing

from $3.00 / 1,000 transcripts

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

Get transcripts from YouTube videos, whole channels and playlists.

**Every video you ask for gets a row.** Either with the transcript, or with the
exact reason there is none. Rows without a transcript are never charged.

### Why another YouTube transcript scraper

Because of a failure mode we measured ourselves.

The YouTube watch page still lists a video's caption tracks, but the signed URL
it hands out returns **HTTP 200 with a zero-length body**. An Actor that sees
"captions exist", downloads them, and never checks the length will report
success and save an empty dataset. You pay for the run and get nothing, with a
green tick in the console.

This Actor checks the length. If the body is empty, the run **fails loudly**
instead of charging you for nothing.

That failure mode is not hypothetical. Across the YouTube transcript Actors in
the Store you can find reviews like *"worked less than half the time"*,
*"did not give response to the video"* and *"this scraper did not scrape the
channels I put in"* sitting next to high success rates. We do not claim to know
what any particular competitor does internally — we only know what the endpoint
does, because we measured it, and what it costs you when nobody checks.

### What you get per video

| Field | What it is |
|---|---|
| `transcript` | The transcript, in the format you chose — or `null` |
| `errorCode` / `error` | Why there is no transcript, per video |
| `language`, `isAutoGenerated` | Which track was actually used |
| `availableLanguages` | What the video *does* have, when your language is missing |
| `title`, `channelName`, `viewCount` | Video metadata |
| `channelId` / `playlistId` | Which channel or playlist the row came from |
| `segmentCount`, `wordCount`, `durationSeconds` | Size of the transcript |

### Input

Anything that looks like a YouTube link is accepted — including a **bare video
ID** and a **single video URL**, which some competing Actors refuse outright:

```
https://www.youtube.com/watch?v=dQw4w9WgXcQ
https://youtu.be/jNQXAC9IVRw
https://www.youtube.com/shorts/dQw4w9WgXcQ
dQw4w9WgXcQ
https://www.youtube.com/@YouTube
https://www.youtube.com/playlist?list=PL...
```

Options: transcript **language**, whether to include **auto-generated**
subtitles, output **format** (`plain`, `timestamps`, `srt`), and a cap on how
many videos are taken per channel.

### Output formats

- **plain** — one continuous block of text, ready for an LLM
- **timestamps** — `[00:01:23] line of text`, one line per caption
- **srt** — a valid subtitle file you can use as-is

### What a failure means here

The run either gives you data, or tells you why it could not. There is no third
option where you pay for silence.

| `errorCode` in the row | What it means | Charged? |
|---|---|---|
| `NO_TRANSCRIPT` | The video has no subtitles at all | no |
| `LANGUAGE_NOT_AVAILABLE` | Not in your language — the row lists the ones that exist | no |
| `VIDEO_NOT_FOUND` | The ID is well-formed but the video is gone | no |
| `VIDEO_PRIVATE` | Private, or members-only | no |
| `AGE_RESTRICTED` | Needs a signed-in account | no |
| `VIDEO_UNAVAILABLE_HERE` | Blocked in the proxy's country — try another one | no |
| `NOT_YET_AVAILABLE` | A premiere or a scheduled stream that has not aired | no |
| `VIDEO_UNAVAILABLE` | Unplayable for a reason YouTube words in a way we don't recognise | no |
| `CHANNEL_NOT_FOUND` | The channel or playlist does not exist | no |
| `BAD_TARGET` | We could not read that input as a YouTube link | no |
| `BUDGET_REACHED` | Your `maxTotalChargeUsd` ran out before this one | no |
| `LIST_TRUNCATED` | YouTube stopped handing us the rest of a long list — the list is **not** complete | no |
| `CHARGE_UNCONFIRMED` | The Apify platform answered a charge call in a shape we don't recognise, so we stopped. Your limit was **not** reached — don't raise it | no |

None of those fail the run — they are properties of the input, and you get a
row saying so. These do fail the run, loudly, because they are **our** problem:

| Situation | What happens |
|---|---|
| YouTube blocks us as a bot | Run **fails** after retrying from other IPs |
| YouTube changes its response format | Run **fails** — we do not guess |
| A signed caption URL returns an empty body | Run **fails** — empty is not a transcript |

If a whole run comes back with nothing but "no subtitles" rows, the Actor asks
a control question first — it fetches a video it knows has subtitles. If that
one fails too, the run fails instead of telling you your videos have no
subtitles.

A run that extracts nothing costs you the start price and nothing else, even
if the dataset has 200 rows explaining why.

One honest caveat: transcripts are charged **as they are delivered**, not at
the end. If a long run is cut short — by us being blocked, or by a format
change — the transcripts you already received stay in the dataset and stay
charged. The run is marked FAILED so you can see it did not finish, and you
keep what you paid for. Re-running skips nothing you already have only if you
pass the same input; there is no cross-run deduplication.

### What this Actor does not do

Written down because you should know before you pay, not after.

- **No cross-run deduplication.** Re-running the same input downloads and
  charges again. Within one run the same video is only ever fetched and
  charged once, including after a migration.
- **No translation.** You get the caption tracks the video actually has. If
  your language is missing, the row tells you which ones exist.
- **Channels are capped per target**, not per run. Ten channels at 100 videos
  each is a thousand transcripts — set `maxVideosPerChannel` or your
  `maxTotalChargeUsd` accordingly.
- **`requestsFromUrl` is not supported.** Paste the links in directly; a
  `requestsFromUrl` entry comes back as an explained input error, not silence.
- **Live streams in progress** have no finished transcript, so they come back
  as `NOT_YET_AVAILABLE`.
- **A run cut short keeps what it delivered.** Transcripts are charged as
  they land, so if we get blocked at video 400 of 500, the first 399 stay in
  the dataset and stay charged, and the run is marked FAILED so you can see
  it did not finish.

### Pricing

Pay per event:

- **0.00005 USD** per run start — charged once per **GB of memory**, so the
  intended 1 GB run start costs exactly this; a 4 GB run start costs 4×
- **3.00 USD per 1 000 transcripts** — one event per transcript actually
  extracted. A row without a transcript is **free**.

To say it the way a reviewer of a competing Actor asked and never got answered:
**1 000 results means 1 000 transcripts.** Not 1 000 videos attempted, not
1 000 API calls.

### Proxy

YouTube blocks datacentre IPs almost immediately, so the default here is a
**residential** proxy — measured, not assumed: through the datacentre pool the
very first request comes back as "Sign in to confirm you are not a bot".

If a run does get blocked mid-way, the Actor swaps to a new proxy session and
retries rather than failing on the first refusal.

### Run report

Every run writes a `RUN_REPORT` record into the key-value store: what was
requested, what each target returned, and how many rows were actually billed.
It is written **as the run goes**, not only at the end, so it exists even if a
long run is cut short. Each transcript row also carries `transportClient` —
which strategy actually answered for that video.

Each target in the report gets an `outcome`:

| `outcome` | What it means |
| --- | --- |
| `OK` | Every video that had a transcript returned one |
| `PARTIAL` | Some transcripts came back, then your `maxTotalChargeUsd` stopped the rest |
| `BUDGET_REACHED` | The budget stopped this target before any transcript came back |
| `CHARGE_UNCONFIRMED` | The platform stopped confirming charges, so this target was not fetched — your limit was **not** reached |
| `DUPLICATE` | Every video here was already downloaded earlier in this same run |
| `MIXED` | Some were already downloaded earlier in this run, the rest have no transcript |
| `NO_TRANSCRIPTS` | None of the videos has a transcript — and the canary proved we can still read |

If a run is migrated to another machine mid-way, it picks up where it left off
and does not re-download or re-charge what it already delivered.

***

Built by Marek Hartmann. I also build **custom Apify Actors** to order — if you
need a scraper for a source that is not in the Store yet, get in touch.

# Actor input Schema

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

YouTube videos to get transcripts for. Any of these forms works: a full watch URL, a youtu.be short link, a Shorts or embed URL, or the bare 11-character video ID.

## `videoIds` (type: `array`):

Bare 11-character YouTube video IDs, if you already have them. Equivalent to putting them in Video URLs - this field exists so you do not have to build URLs first.

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

Channels to pull transcripts from. Accepts a handle (@handle), a /channel/UC... URL or an older /c/Name URL. Use 'Max videos per channel' to cap how many videos are taken from each one.

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

Playlists to pull transcripts from. Accepts a full playlist URL or a bare playlist ID starting with PL, UU, OL or FL.

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

Two-letter language code of the transcript you want, for example en, sk, de or pt-BR. Leave empty to take whatever language the video offers first. If the video has no track in this language, the run still succeeds and the row lists the languages that ARE available.

## `includeAutoGenerated` (type: `boolean`):

Auto-generated (ASR) subtitles are on by default because most videos have nothing else. Turn this off to accept only subtitles a human uploaded - videos with ASR only will then get a row explaining that, and will not be charged.

## `transcriptFormat` (type: `string`):

plain - one continuous text block. timestamps - one line per caption prefixed with \[HH:MM:SS]. srt - a ready-to-use subtitle file.

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

Adds a segments array with the start time, duration and text of every caption. Useful for building your own format; makes rows considerably larger.

## `maxVideosPerChannel` (type: `integer`):

Upper limit of videos taken from each channel or playlist. Protects you from spending a whole budget on one large channel.

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

YouTube blocks datacentre IPs almost immediately, so a residential proxy is required rather than optional. This is measured, not assumed: with the datacentre default the very first request comes back as "Sign in to confirm you are not a bot". Leave this as it is unless you know your account has a better pool.

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

Generic list of YouTube URLs - videos, channels or playlists mixed together. Useful when the links come from another Actor or an integration. Each entry may be a plain string or an object with a url field.

## Actor input object example

```json
{
  "videoUrls": [
    "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
    "https://youtu.be/jNQXAC9IVRw",
    "dQw4w9WgXcQ"
  ],
  "videoIds": [
    "dQw4w9WgXcQ",
    "jNQXAC9IVRw"
  ],
  "channelUrls": [
    "https://www.youtube.com/@YouTube",
    "@mkbhd"
  ],
  "playlistUrls": [
    "https://www.youtube.com/playlist?list=PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf"
  ],
  "language": "en",
  "includeAutoGenerated": true,
  "transcriptFormat": "plain",
  "includeSegments": false,
  "maxVideosPerChannel": 100,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}
```

# Actor output Schema

## `items` (type: `string`):

One row per video: the transcript plus video metadata, or transcript: null with an error and errorCode explaining why it is missing.

## `runReport` (type: `string`):

Per-target outcome, how many transcripts were extracted, and how many rows were actually billed.

# 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=jNQXAC9IVRw"
    ],
    "proxyConfiguration": {
        "useApifyProxy": true,
        "apifyProxyGroups": [
            "RESIDENTIAL"
        ]
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("marekhartmann/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=jNQXAC9IVRw"],
    "proxyConfiguration": {
        "useApifyProxy": True,
        "apifyProxyGroups": ["RESIDENTIAL"],
    },
}

# Run the Actor and wait for it to finish
run = client.actor("marekhartmann/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=jNQXAC9IVRw"
  ],
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}' |
apify call marekhartmann/youtube-transcript-scraper --silent --output-dataset

```

## MCP server setup

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