# YouTube Channel Scraper — All Videos, Transcripts, Comments (`aquixlabs/youtube-channel-scraper`) Actor

Scrape every video from a YouTube channel, playlist or search — long videos, Shorts and live streams. Transcripts, comments and replies. Exact views and RFC-3339 dates. CSV export. No API key, no quota. Failed, filtered and cached rows are free.

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

## Pricing

from $0.50 / 1,000 videos

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 Channel Scraper — All Videos, Transcripts, Comments

**Paste a YouTube channel. Get every video it has published, as a spreadsheet.**
Long videos, Shorts and live streams. No API key, no quota.

### What you get back

| video\_type | title | views | published\_at | duration\_s |
|---|---|---|---|---|
| `video` | Google Pixel 11/Pro/Fold Impressions: It Is What It Is | `3138780` | `2026-08-12T07:00:35-07:00` | `673` |
| `short` | This ZOOM is Insane! | `3567393` | `2026-07-20T13:39:08-07:00` | `66` |
| `stream` | 5 New Phone Updates + Giveaway Update! | `940608` | `2017-11-29T09:15:12-08:00` | `2501` |

Real rows from a real run. **Exact counts, not `3.1M`. Full timestamps, not
`7 days ago`.** Both survive sorting.

***

### What a row actually looks like

One video, with transcripts, timed segments and comments all switched on. Real
output from a real run — long fields trimmed with `…`, nothing else changed.

```json
{
  "_row_type": "video",
  "video_id": "o4SSoURPODY",
  "video_type": "video",
  "title": "Google Pixel 11/Pro/Fold Impressions: It Is What It Is",
  "channel": "Marques Brownlee",
  "channel_id": "UCBJycsmduvYEL83R_U4JriQ",
  "views": "3148445",
  "likes": 84617,
  "published_at": "2026-08-12T07:00:35-07:00",
  "category": "Science & Technology",
  "duration_s": "673",
  "description": "Every year, a new Pixel, and new hopes and dreams... …",
  "keywords": ["Pixel 11", "Pixel 11 Pro", "Pixel 11 Pro Fold", "…"],
  "thumbnail": "https://i.ytimg.com/vi_webp/o4SSoURPODY/maxresdefault.webp",
  "subscriber_count": "21.1M subscribers",
  "is_live": false,
  "is_private": false,
  "is_crawlable": true,
  "transcript": "[music] >> You know, I've been using Pixel phones for a long time. …",
  "transcript_segments": [
    { "start_s": 2.619, "duration_s": 2.741, "text": "[music]" }, "…"
  ],
  "transcript_cue_count": 331,
  "transcript_language": "en",
  "transcript_is_generated": true,
  "transcript_status": "OK",
  "_source_url": "https://www.youtube.com/watch?v=o4SSoURPODY",
  "_cached": false,
  "_error_kind": null
}
```

A comment from the same run — its own row, not a nested field:

```json
{
  "_row_type": "comment",
  "video_id": "o4SSoURPODY",
  "comment_id": "Ugz7VRjqhsV-60jGjwR4AaABAg",
  "comment_text": "Adding a physical RGB LED to the back of the phone just to …",
  "comment_author": "@Mosesplusofficial",
  "comment_author_channel_id": "UCr3n8BfGuDIzhO8ALIxMe4w",
  "comment_author_is_verified": false,
  "comment_published_text": "8 days ago",
  "comment_like_count_text": "13K",
  "comment_reply_count_text": "200",
  "comment_is_reply": false
}
```

***

### Three things that make this different

> ### 🟢 Failures are typed, never blank
>
> Subtitles off returns `NO_CAPTIONS`. A broken fetch returns `FETCH_FAILED`.
> **Different values**, so you always know which happened.

> ### 🟢 Transcripts and comments in the same run
>
> One actor, one pass. Transcript text, timed segments, comments and replies —
> alongside the metadata, not in a separate job.

> ### 🟢 Failed, filtered and cached rows cost nothing
>
> Charged only when a row has data. A deleted video is free. So is one your
> filter removed, or one served from cache.

***

### How do I scrape all videos from a YouTube channel?

```json
{ "inputs": ["https://www.youtube.com/@mkbhd"], "maxResultsPerSource": 500 }
```

`@handle`, a channel URL, or a `/channel/UC…` ID all work.

**You get all three tabs.** Long videos, Shorts and live streams sit in separate
tabs that do not overlap — one channel measured 30, 48 and 5. `video_type` says
which is which, and the tabs are interleaved so asking for 30 returns a mix.
`channelTabs` narrows it.

Channel rows also carry `subscriber_count` and `channel_description`, free —
same request that lists the videos.

### What happens when something fails?

**You get a typed reason in the row, never an empty cell.**

| `_error_kind` | means |
|---|---|
| `REJECTED_INPUT` | not a recognised channel, playlist, video or search |
| `RESOLVE_FAILED` | the listing could not be read |
| `NO_VIDEOS` | it was read and contained none |
| `FETCH_FAILED` | the video request failed |
| `NO_DATA` | it loaded but carried no video details |

**Blocked requests retry automatically on a fresh IP.** In a 36-video run that
turned 3 blocks into 0 failures.

### How do I get YouTube transcripts in bulk?

```json
{ "inputs": ["https://www.youtube.com/@veritasium"],
  "includeTranscript": true, "transcriptLanguages": ["en", "es"] }
```

Each row gains the transcript as text, its language, whether it was
auto-generated, and how many timed cues it has.

**Timed cues are a separate switch: `includeTranscriptSegments`.** Turn it on
and each row also carries `transcript_segments` — one entry per cue with
`start_s`, `duration_s` and `text` — for citing a moment in the video. It costs
nothing extra, because the cues arrive in the same request as the text. It is
off by default because every export format flattens them: a 1,000-cue video
becomes `transcript_segments/0/text` … `/999/text`, and a spreadsheet of a few
videos turns into thousands of columns. `transcript_cue_count` ships either way.

`transcriptLanguages` is a preference order and **human-written subtitles beat
auto-generated ones.** Subtitles off returns `NO_CAPTIONS`, free.

`includeLikes` adds a like count from one small extra request, not charged
separately.

### How do I export a YouTube playlist to CSV?

```json
{ "inputs": ["https://www.youtube.com/playlist?list=PLbpi6ZahtOH6Blw3RGYpWkSByi_T7Rygb"],
  "maxResultsPerSource": 100 }
```

Then hit **Export** — CSV, JSON, Excel, or an API endpoint. Playlists return 100
videos per request, so multiples of 100 go furthest.

### How do I get YouTube comments with replies?

```json
{ "inputs": ["https://www.youtube.com/@mkbhd"], "includeComments": true,
  "maxCommentsPerVideo": 100, "maxRepliesPerComment": 5 }
```

**Comments arrive as their own rows, not extra columns** — `_row_type` is
`video` or `comment`. Folding 100 comments into a video row would export as 600
unusable columns.

Replies are rows too (`comment_is_reply: true`), capped because each comment
with replies costs an extra request.

### How do I search YouTube and export the results?

```json
{ "inputs": ["drone review", "sourdough starter"] }
```

One quirk: a bare 11-character word is read as a video ID, because that is
exactly how long a YouTube ID is — `helicopters` is eleven characters. Write
`search:helicopters` to force a search.

### Can I filter, or work in another language?

| input | effect |
|---|---|
| `publishedAfter` / `publishedBefore` | `YYYY-MM-DD`, inclusive |
| `durationMinSeconds` | set `61` to exclude Shorts |
| `durationMaxSeconds` | upper bound in seconds |
| `language` | `en`, `de`, `ja`, `es` — text YouTube generates |
| `region` | `US`, `DE`, `GB` — how results are ranked |

Filters run after each video is read, so they are exact — **and filtered-out
videos are never charged.**

`subscriber_count` follows the language as YouTube wrote it: `21.1M subscribers`,
`21,1 Mio. Abonnenten`, `チャンネル登録者数 2110万人`. Titles, descriptions and
`category` are unaffected.

### What people use this for

#### 📈 Track a competitor's channel every week

1. Run with the competitor's channel and `maxResultsPerSource: 50`,
   `maxAgeHours: 0` so view counts are fresh.
2. **Schedule it weekly** in the Apify Console.
3. Join runs on `video_id` and diff `views` to get per-video growth, and
   `published_at` to get posting cadence.

Set `maxAgeHours: 0` here — the cache is a time window, and stale view counts
would flatten exactly the trend you are measuring.

#### 🤖 Build an LLM corpus from a channel's transcripts

1. Run with the channel, `includeTranscript: true`, `maxResultsPerSource: 500`.
2. Export as JSON and use `transcript` for plain text. Add
   `includeTranscriptSegments: true` when you need timestamps to cite back to a
   moment in the video — it is free, but it makes the rows much wider.
3. Filter on `transcript_status == "OK"` — `NO_CAPTIONS` means that video never
   had subtitles, and it cost you nothing.

`transcript_is_generated` tells you whether a human wrote it. Auto-generated
captions carry more errors, which matters if you are grounding an answer on
them.

#### 💬 Find what an audience actually asks about

1. Run with the channel, `includeComments: true`, `maxCommentsPerVideo: 100`,
   and `maxRepliesPerComment: 5` to catch the threads under the popular ones.
2. Filter rows to `_row_type == "comment"`.
3. Group by `video_id`, sort by `comment_like_count_text`, read the top of each.

Useful for support-content planning, sponsorship research, and finding the
question a whole audience keeps asking that nobody has answered yet.

### What fields do I get?

| field | example |
|---|---|
| `video_id` · `video_type` | `dQw4w9WgXcQ` · `video`, `short`, `stream` |
| `title` · `channel` · `channel_id` | `Rick Astley - Never Gonna Give You Up` · `Rick Astley` · `UCuAXFkgsw1L7xaCfnd5JJOw` |
| `views` · `likes` | `1806183998` · `136841` |
| `published_at` · `duration_s` | `2009-10-24T23:57:33-07:00` · `213` |
| `category` · `keywords` · `thumbnail` · `description` | `Music` · array · highest-res URL · full text |
| `subscriber_count` · `channel_description` | from channel inputs |
| `is_live` · `is_private` · `is_crawlable` | booleans |
| every row | `_row_type` `_input` `_source_url` `_site` `_cached` `_cached_at` `_error_kind` `_error` |
| with transcripts | `transcript` `transcript_cue_count` `transcript_language` `transcript_is_generated` `transcript_status` |
| with `includeTranscriptSegments` | `transcript_segments` — one entry per cue, `start_s` `duration_s` `text` |
| comment rows | `comment_id` `comment_text` `comment_author` `comment_author_channel_id` `comment_author_is_verified` `comment_author_is_creator` `comment_published_text` `comment_like_count_text` `comment_reply_count_text` `comment_is_reply` |

Three comment fields end in `_text` because YouTube publishes them rounded and
relative — `13K`, `7 days ago` — and they pass through as given. **`13K` is not
expanded to `13000`**: it could be 13,499, and inventing digits hands you a wrong
number that looks precise.

**Not included:** no sort order, no video or audio downloads, no total comment
count per video.

### Does it re-fetch videos I already pulled?

**Any video fetched in the last 24 hours comes back from stored state — and is
not charged.** Marked `_cached: true` with `_cached_at`.

**This is a time window, not change detection.** It checks how long ago a video
was read, not whether it changed — so set `maxAgeHours` to `0` when sorting on
`views` or measuring growth. The window is per actor: yesterday's run counts for
today's.

**Cached rows still follow this run's settings.** Turn transcripts on and a
cached video gets its transcript fetched and charged; turn them off and the row
carries no transcript fields at all, whatever an earlier run stored. The cache
never adds a field you did not ask for, and never withholds one you did.

### What does it cost?

| you get | you pay |
|---|---|
| a video row | **$0.50** / 1,000 |
| a transcript | **$10.00** / 1,000 |
| a comment or reply | **$0.20** / 1,000 |
| a failed, filtered or cached row | **nothing** |

Transcripts and comments are **billed separately** — each is a separate request
to YouTube, not an extra field.

Send everything in one run, not one run each: fixed per-run cost is ~86% of a
single-video run and ~6% at a hundred. Requests run 8 at a time, one per second
each; `concurrency` adjusts that between 1 and 16.

### Will it quietly break?

Every run emits a schema fingerprint — which fields resolved, which came back
empty, the row count — compared against a stored baseline. A field that starts
returning empty is caught as drift, not discovered months later.

**Scrapers rarely break loudly. They thin out.**

### Terms

You are responsible for your use of the data you collect, including under
YouTube's Terms of Service and any applicable data protection law.

***

### More from aquixlabs

Other actors from this account will be listed here.

# Actor input Schema

## `inputs` (type: `array`):

One per line, mixed freely. Channel: youtube.com/@handle, @handle, or a /channel/UC... URL — returns that channel's videos, newest first. Playlist: a playlist URL or a bare list ID. Video: an 11-character video ID, a watch URL, youtu.be, /shorts/ or /embed/. Search: any words, e.g. drone review. Note: a bare 11-character word is read as a video ID (so is 'helicopters') — write search:helicopters to force a search.

## `maxResultsPerSource` (type: `integer`):

How many videos to take from each channel, playlist or search term. Videos passed by ID ignore this. This is the main cost control: 10 channels at 100 each is 1,000 videos. Playlists return 100 IDs per request, so values that are multiples of 100 are the cheapest per video.

## `channelTabs` (type: `array`):

A channel keeps long videos, Shorts and live streams in three separate tabs that do not overlap — measured on one channel: 30 videos, 48 Shorts, 5 streams. All three are read by default so "every video from the channel" means every video. Each tab costs one extra listing request; drop the ones you do not want. Every row says which tab it came from in video\_type.

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

Keep only videos published on or after this date. Leave empty for no limit. Filtering happens after each video is read, so videos that are filtered out are never charged to you.

## `publishedBefore` (type: `string`):

Keep only videos published on or before this date. Leave empty for no limit.

## `durationMinSeconds` (type: `integer`):

Keep only videos at least this long. 0 means no minimum. Use 61 to exclude Shorts.

## `durationMaxSeconds` (type: `integer`):

Keep only videos no longer than this. 0 means no maximum.

## `includeLikes` (type: `boolean`):

Off by default. Adds a like count to every row from a small extra request (about 1,392 bytes). Not charged separately.

## `includeTranscript` (type: `boolean`):

Off by default. Adds the full transcript text, its language, whether it was auto-generated, and the number of timed cues. Getting subtitles needs a different and larger request per video, so it is charged as its own event and only when a transcript is actually returned. Videos with subtitles disabled come back with transcript\_status = NO\_CAPTIONS and cost you nothing.

## `transcriptLanguages` (type: `array`):

In order of preference, e.g. en, es, de. The first available match wins. If none match, the video's own first track is used — human-written subtitles before auto-generated ones. Only used when transcripts are on.

## `includeTranscriptSegments` (type: `boolean`):

Off by default, and only used when transcripts are on. Adds transcript\_segments: one entry per subtitle cue with start\_s, duration\_s and text — the same words as transcript, split by timestamp. Turn it on when you need to cite a moment in the video. Leave it off for text: a single video can carry 1,000 cues, every export format flattens them into transcript\_segments/0/text … /999/text, and a spreadsheet of a few videos becomes thousands of columns. It costs nothing extra either way — the cues arrive in the same request as the text — and transcript\_cue\_count tells you how many there would be.

## `includeComments` (type: `boolean`):

Off by default. Adds comments as EXTRA ROWS, not extra columns — each comment is its own row with \_row\_type = comment, which is what you want in a CSV. Video rows are unchanged and keep \_row\_type = video. Charged per comment returned. Videos with comments turned off cost you nothing.

## `maxCommentsPerVideo` (type: `integer`):

Top-level comments come 20 per request, so 20, 40 or 100 are natural stopping points. Only used when comments are on.

## `maxRepliesPerComment` (type: `integer`):

0 = top-level comments only. Replies need one extra request per comment that has any, so this is capped per comment rather than priced per reply — it stops 100 comments quietly becoming 100 extra requests. Replies arrive as their own rows with comment\_is\_reply = true.

## `maxAgeHours` (type: `integer`):

Videos this actor already fetched within this many hours are returned from stored state instead of being fetched again, and are not charged. Cached rows are marked \_cached = true with \_cached\_at, so row age is always visible. Set 0 to force a fresh fetch. View counts move constantly — use 0 when sorting on views or computing growth.

## `resetFingerprintBaseline` (type: `boolean`):

Advanced. Every run compares its column shape against a stored baseline and warns when fields start coming back empty. After an intended change to the output columns the old baseline is permanently wrong and warns forever. Turn this on for one run to replace it. Leave off otherwise — a baseline that resets itself cannot detect anything.

## `concurrency` (type: `integer`):

How many requests are in flight at once. Each worker makes at most one request per second, so this also sets the request rate. Lower it if you see failures; raising it finishes sooner but does not cost less.

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

Language for text YouTube generates — category names, "N subscribers", relative dates. e.g. en, de, es, ja. Titles and descriptions are whatever the creator wrote and do not change.

## `region` (type: `string`):

Two-letter country the results are ranked and filtered for, e.g. US, DE, GB, IN. Mostly affects search ordering and availability.

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

Hard cap across the whole run, applied after channels and playlists are expanded. 0 processes everything.

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

RESIDENTIAL is required and is the default. Measured on this actor: channel and playlist listings work on datacenter IPs, but the per-video request was refused on 20 of 20 with "Sign in to confirm you're not a bot". Responses are small — about 4.4 KB per video plus one listing request per channel or playlist — so bandwidth is roughly $0.0001 per video at $8/GB.

## `site` (type: `string`):

Which site config to use. Only 'youtube' today.

## Actor input object example

```json
{
  "inputs": [
    "https://www.youtube.com/@mkbhd",
    "dQw4w9WgXcQ",
    "drone review"
  ],
  "maxResultsPerSource": 30,
  "channelTabs": [
    "videos",
    "shorts",
    "streams"
  ],
  "durationMinSeconds": 0,
  "durationMaxSeconds": 0,
  "includeLikes": false,
  "includeTranscript": false,
  "transcriptLanguages": [
    "en"
  ],
  "includeTranscriptSegments": false,
  "includeComments": false,
  "maxCommentsPerVideo": 20,
  "maxRepliesPerComment": 0,
  "maxAgeHours": 24,
  "resetFingerprintBaseline": false,
  "concurrency": 8,
  "language": "en",
  "region": "US",
  "maxItems": 0,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  },
  "site": "youtube"
}
```

# Actor output Schema

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

Every row this run produced, videos and comments together. Each row carries \_row\_type ('video' or 'comment'), and failures carry \_error\_kind and \_error rather than being dropped.

## `schemaFingerprint` (type: `string`):

Which fields resolved and which came back empty this run, compared against the stored baseline. This is the drift canary: a field that starts returning empty shows up here rather than being discovered months later.

## `runUrl` (type: `string`):

The run's own page, including the full log with per-mode bandwidth and any retried requests.

# 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 = {
    "inputs": [
        "https://www.youtube.com/@mkbhd",
        "dQw4w9WgXcQ",
        "drone review"
    ],
    "transcriptLanguages": [
        "en"
    ],
    "proxyConfiguration": {
        "useApifyProxy": true,
        "apifyProxyGroups": [
            "RESIDENTIAL"
        ]
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("aquixlabs/youtube-channel-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 = {
    "inputs": [
        "https://www.youtube.com/@mkbhd",
        "dQw4w9WgXcQ",
        "drone review",
    ],
    "transcriptLanguages": ["en"],
    "proxyConfiguration": {
        "useApifyProxy": True,
        "apifyProxyGroups": ["RESIDENTIAL"],
    },
}

# Run the Actor and wait for it to finish
run = client.actor("aquixlabs/youtube-channel-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 '{
  "inputs": [
    "https://www.youtube.com/@mkbhd",
    "dQw4w9WgXcQ",
    "drone review"
  ],
  "transcriptLanguages": [
    "en"
  ],
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}' |
apify call aquixlabs/youtube-channel-scraper --silent --output-dataset

```

## MCP server setup

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