# YouTube Transcript Scraper - Full Text + Timestamps for LLMs (`lanternlab318/youtube-transcripts`) Actor

YouTube transcripts from video, channel or playlist links: full plain text ready for an LLM, timed segments, caption language, plus title, channel, publish date, duration, views and every caption language. No login, no cookies. Videos without captions or not public are marked, not charged.

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

## Pricing

$2.99 / 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?

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 — Full Text + Timestamps, Ready for an LLM

A Lantern Lab Actor. Give it YouTube video links — or a channel or playlist link — and get each video's **public captions** back: the **full plain text** you can paste straight into an LLM, the **timed segments** (`start`, `end`, `text`) you can link to, the **caption language** and whether the track is human-written or auto-generated, plus the video's **title**, **channel**, **publish date**, **duration**, **view count** and **every caption language it offers**. No YouTube login, no cookies, no account of your own.

**Price: USD 2.99 per 1,000 transcript rows. No run-start fee.** Videos without captions, blocked videos, duplicates and the run summary are free — you pay for transcripts, not for attempts.

Input:

```json
{
  "videoUrls": [
    "https://www.youtube.com/watch?v=LKrwhjOI8Nc",
    "https://www.youtube.com/@3blue1brown"
  ],
  "maxItems": 10,
  "language": "en",
  "includeSegments": true
}
```

Output — one row per video (`recordType: "transcript"`), plus one free `RUN_SUMMARY` row at the end. Sample from a real run, transcript shortened:

```json
{
  "recordType": "transcript",
  "platform": "youtube",
  "videoInput": "https://www.youtube.com/watch?v=LKrwhjOI8Nc",
  "videoId": "LKrwhjOI8Nc",
  "url": "https://www.youtube.com/watch?v=LKrwhjOI8Nc",
  "title": "There's never been a better time to go into math",
  "authorName": "3Blue1Brown",
  "channelName": "3Blue1Brown",
  "channelId": "UCYO_jab_esuFRV4b17AJtAw",
  "channelUrl": "https://www.youtube.com/channel/UCYO_jab_esuFRV4b17AJtAw",
  "publishedAt": "2026-10-16T09:15:20-07:00",
  "durationSec": 2444,
  "viewCount": 2453196,
  "language": "en",
  "languageName": "English",
  "isAutoGenerated": false,
  "transcriptSource": "platform_caption",
  "asrModel": null,
  "text": "Well, let's talk about it.\nIt feels like the whole math world is on fire…",
  "wordCount": 6271,
  "segments": [
    { "start": 0, "end": 1.432, "text": "Well, let's talk about it." },
    { "start": 1.432, "end": 3.873, "text": "It feels like the whole math world is on fire" }
  ],
  "segmentCount": 884,
  "availableCaptionLanguages": [
    { "languageCode": "ar", "languageName": "Arabic", "isAutoGenerated": false },
    { "languageCode": "en", "languageName": "English", "isAutoGenerated": false },
    { "languageCode": "en", "languageName": "English (auto-generated)", "isAutoGenerated": true }
  ],
  "partial": false,
  "partialReason": null,
  "statusText": null,
  "egress": "residential",
  "collectedAt": "2026-10-24T10:18:31.204Z"
}
```

Limits:

- **Public captions only.** The caption tracks YouTube publishes on a public video page: human-written and auto-generated. No login, no cookies, no members-only, private, age-restricted or deleted videos — those get a row saying exactly that. No video or audio files and no comments.
- **A video with no captions says so.** `partialReason: "no_captions"` is a different row from `"blocked"`, and neither is ever reported as an empty transcript. See [Partial is not empty](#partial-is-not-empty).
- **This build reads platform captions; it does not transcribe audio.** `transcriptSource` is always `platform_caption` and `asrModel` is always `null`. YouTube auto-generates captions for nearly every public video, so this covers almost everything.
- **Your limits stop collection and appear in the row**: `maxItems` per channel or playlist, the `maxUpstreamRequests` request ceiling and the maximum charge per run (see [Pricing](#pricing)).

### What it does

- **Gives you text an LLM can read.** `text` is the joined plain transcript with one caption line per line — no markup, no timestamps in the way — and `wordCount` tells you what it will cost you in tokens before you send it.
- **Keeps the timings.** `segments[]` carries `start`, `end` and `text` in seconds, so you can quote a passage and link to the second it is spoken at. Turn `includeSegments` off and you get the text and the word count only, which keeps the dataset small.
- **Takes a channel or a playlist, not just a video.** A `youtube.com/@handle` or `/channel/UC…` link collects that channel's most recent `maxItems` uploads; a `playlist?list=…` link collects the playlist in order. Every row still carries the entry it came from in `videoInput`.
- **Picks the caption track the way a human would.** With no `language`, it takes the track the video itself defaults to — the language it is actually in, which is not the same as the first entry in YouTube's own list (that list is sorted by language name, so an English video with community translations starts with Arabic) — and prefers a human-written track over the auto-generated one. With `language`, only that language is accepted — a regional variant matches its base language (`en` accepts `en-GB`) — and a video that has captions but not in that language is reported with `availableCaptionLanguages` rather than silently given a different language.
- **Keeps the provenance separate.** The source ID (`videoId`), the platform's own publish time (`publishedAt`, exactly as YouTube reports it) and our collection time (`collectedAt`) are three different fields.
- **Deduplicates the run.** A video that appears twice — directly and through a channel, or twice in the list — is collected and charged once; the repeat gets a free row saying so.
- **Caps itself.** `maxUpstreamRequests` is a hard ceiling on requests to YouTube for the whole run, derived from your input when you leave it empty, so a run is never unbounded.
- **Charges transcript rows only.** Videos without captions, blocked videos, unavailable videos, duplicates, unreadable input lines and the run summary are all free.

### What it does not do

- No speech-to-text: it returns the captions YouTube publishes, not a transcription of the audio.
- No video, audio or thumbnail downloads, and no comments or channel statistics.
- No login and no cookies, so nothing behind a sign-in: members-only, private, age-restricted and deleted videos are reported, not collected.
- No search: you give it videos, channels or playlists, not keywords.
- No translation: `language` picks one of the tracks YouTube already publishes for that video.

### Input

| Field | Type | Default | What it does |
| --- | --- | --- | --- |
| `videoUrls` | string\[] | **required** | One video, channel or playlist per line. Videos: `watch?v=…`, `youtu.be/…`, `/shorts/…`, `/embed/…`, `/live/…` or a bare 11-character ID. Channels: `/@handle`, `/channel/UC…`, `/c/…`, `/user/…`. Playlists: `?list=…`. A link that carries both a video and a playlist is treated as the video. |
| `maxItems` | integer | `10` | How many of a channel's or playlist's most recent videos to take, per channel or playlist line. Maximum 500. Ignored for single videos. |
| `language` | string | empty | Caption language to return, e.g. `en`, `zh-Hans`, `ru`. Empty takes each video's own default track. |
| `includeSegments` | boolean | `true` | Include `segments[]` with per-line `start`, `end` and `text`. |
| `maxUpstreamRequests` | integer | derived | Hard ceiling on requests to YouTube for the whole run. |
| `proxyConfiguration` | object | Apify Proxy | Proxy for the caption downloads and channel pages. The caption-list call always uses a residential IP, because YouTube answers datacenter IPs with a bot check. |

### Output

One row per video, plus a final `RUN_SUMMARY` row (also stored under the `RUN_SUMMARY` key).

| Field | What it is |
| --- | --- |
| `platform` | Always `youtube`. |
| `videoId`, `url` | The source ID YouTube uses, and the canonical watch URL. |
| `title` | The video's public title. |
| `authorName`, `channelName`, `channelId`, `channelUrl` | The channel's public display name (twice, under the shared and the YouTube field name) and its ID. |
| `publishedAt` | The platform's own publish time, as YouTube reports it. |
| `durationSec`, `viewCount` | Video length in seconds, and the public view count. |
| `language`, `languageName`, `isAutoGenerated` | The caption track that was returned. |
| `transcriptSource` | `platform_caption` for every row this build returns. |
| `asrModel` | Always `null` in this build; set only when a transcript comes from speech recognition. |
| `text`, `wordCount` | The joined plain transcript, and its word count. |
| `segments[]` | `{ start, end, text }` in seconds, when `includeSegments` is on. |
| `segmentCount` | Number of caption lines, even when `segments[]` is left out. |
| `availableCaptionLanguages[]` | Every caption track the video offers, so you can re-run for another language. |
| `partial`, `partialReason`, `statusText` | Whether this row is incomplete, the machine-readable reason, and a sentence explaining it. |
| `egress` | Which egress produced the caption list. |
| `collectedAt` | Our collection time, distinct from `publishedAt`. |

#### Partial is not empty

A row is only a transcript when it carries one. Everything else is `partial: true` with a reason, so "no captions" is never confused with "we were refused":

| `partialReason` | What happened | Charged |
| --- | --- | --- |
| `no_captions` | The video is public and playable but publishes no caption track. | No |
| `language_not_available` | It has captions, but not in the language you asked for. See `availableCaptionLanguages`. | No |
| `blocked` | YouTube refused the request with its bot check, twice, from two different IPs. **Not** a video without captions. | No |
| `unavailable` | Deleted, private, members-only or age-restricted: not publicly playable. | No |
| `empty_transcript` | A caption track was listed but came back empty. | No |
| `invalid_input` | The line is not a YouTube video, channel or playlist link. | No |
| `listing_failed` / `listing_empty` | A channel or playlist page could not be read, or listed no video. | No |
| `duplicate_input` | The video already appeared in this run; it was collected and charged once. | No |
| `upstream_request_cap` | `maxUpstreamRequests` was reached before this video. | No |
| `charge_limit_reached` | The maximum charge you set for the run was reached before this video. | No |
| `upstream_error` | A request for this video failed. | No |

`RUN_SUMMARY` totals the run: rows written and charged, transcripts collected, `partialBy` counts per reason, requests sent per endpoint and per egress, bytes per egress, what each channel or playlist expanded into, and why collection stopped if it did.

### Limits and boundaries

- Public content only: the captions and metadata a public YouTube video page carries. No login, no cookies, no account of anyone's.
- Personal data is kept to the minimum: the channel's public display name and ID. No viewer, commenter or uploader personal details.
- Collection is honest about what it did not get. An incomplete run is marked `partial` with the reason; it is never reported as zero results.

### Pricing

Pay per event: **USD 2.99 per 1,000 `result` events**, where one event is one row that carries a transcript. No run-start fee, and no charge for rows that carry no transcript — `no_captions`, `blocked`, `unavailable`, `duplicate_input`, `invalid_input`, the cap and charge-limit rows, and the run summary are all free.

Set a maximum charge per run in the Apify run options and the Actor stops before exceeding it: the videos it did not reach are written with `partialReason: "charge_limit_reached"` so you know exactly where it stopped.

# Actor input Schema

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

One entry per line. A video: a watch link (youtube.com/watch?v=...), a short link (youtu.be/...), a Shorts, embed or live link, or a bare 11-character video ID. A channel (youtube.com/@handle or /channel/UC...) or a playlist (youtube.com/playlist?list=...) takes its most recent videos, up to "Videos per channel or playlist". Every video produces exactly one row, in the order given. A line that is not a YouTube video, channel or playlist is reported as such and is not charged.

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

How many of a channel's or playlist's most recent videos to take, for each channel or playlist line in the list above. Ignored for single video links.

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

Language code of the caption track to return, for example "en", "zh-Hans" or "es". A regional variant matches its base language, so "en" also accepts "en-GB". Leave empty to take each video's own default track, preferring a human-written track over the auto-generated one. A video that has captions but not in this language is reported with its available languages and is not charged.

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

On: every row carries segments\[] with a start time, an end time and the text of each caption line, so you can link to a timestamp. Off: only the joined plain text and the word count, which keeps the dataset small when you just want to feed the transcript to an LLM.

## `maxUpstreamRequests` (type: `integer`):

Hard ceiling on the HTTP requests this run sends to YouTube: caption-list calls, metadata calls, caption downloads, channel pages and retries together. At the ceiling collection stops - the videos not reached are written with partialReason "upstream_request_cap" and the run then ends normally. Leave empty and the Actor works a ceiling out from the input, so a run is never unbounded.

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

The proxy used for the caption downloads and the channel listing pages. The caption-list call always goes through a residential IP, because YouTube answers datacenter IPs with its "confirm you are not a bot" check, and that part is not affected by this setting.

## Actor input object example

```json
{
  "videoUrls": [
    "https://www.youtube.com/watch?v=LKrwhjOI8Nc",
    "https://www.youtube.com/@3blue1brown"
  ],
  "maxItems": 10,
  "includeSegments": true,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

## `results` (type: `string`):

No description

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

No description

# 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=LKrwhjOI8Nc",
        "https://www.youtube.com/@3blue1brown"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("lanternlab318/youtube-transcripts").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=LKrwhjOI8Nc",
        "https://www.youtube.com/@3blue1brown",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("lanternlab318/youtube-transcripts").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=LKrwhjOI8Nc",
    "https://www.youtube.com/@3blue1brown"
  ]
}' |
apify call lanternlab318/youtube-transcripts --silent --output-dataset

```

## MCP server setup

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

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/H3n5CfEifBrU2Pd36/builds/mX3no2k5gi9uZe19I/openapi.json
