# YouTube Comments Scraper - Comment Replies & Threads (`eiv/youtube-comments-scraper`) Actor

YouTube comments scraper that returns every comment AND every reply, with parentCommentId on every reply row so full comment threads reconstruct from one flat dataset. No login, no Google API key, no cookie.

- **URL**: https://apify.com/eiv/youtube-comments-scraper.md
- **Developed by:** [Eimantas V](https://apify.com/eiv) (community)
- **Categories:**
- **Stats:** 2 total users, 1 monthly users, 50.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.50 / 1,000 comment or reply scrapes

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

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 Comments Scraper — Comment Replies & Threads

YouTube comments scraper that returns every comment **and** every reply, with
`parentCommentId` on every reply row so full comment threads reconstruct from
one flat dataset. No login, no Google API key, no cookie, no browser.

Most YouTube comment scrapers give you top-level comments and stop. The replies
are where the argument actually happens — the creator answering a complaint, the
correction that got 400 likes, the thread that turned into a support queue — and
this Actor returns them as first-class rows at the same price, linked to their
parent.

### What you get

One row per comment and per reply. Fill rates below were measured across **325
real rows from 7 videos** sampled from live search, not hand-picked:

| Field | Type | Fill | Notes |
|---|---|---|---|
| `commentId` | string | 100% | Stable YouTube id |
| `parentCommentId` | string | null | 100% of replies | `null` on top-level comments |
| `isReply` | boolean | 100% | |
| `replyLevel` | integer | 100% | `0` top-level, `1` reply |
| `text` | string | 100% | Newlines preserved |
| `authorDisplayName` | string | null | 100% | The `@handle` |
| `authorChannelId` | string | null | 100% | `UC…` |
| `authorChannelUrl` | string | null | 100% | |
| `authorAvatarUrl` | string | null | 100% | |
| `likeCount` | integer | null | 100% | Parsed from the accessibility string |
| `replyCount` | integer | null | 100% of top-level | `null` on replies |
| `publishedTimeText` | string | null | 100% | **Relative only** — see below |
| `authorIsVerified` | boolean | null | 2.5% true | |
| `authorIsChannelOwner` | boolean | null | 0.8% true | |
| `isHeartedByCreator` | boolean | null | 14.2% true | The creator's ❤ |
| `isPinned` | boolean | null | top-level only | Measured on 2 of 6 videos |
| `repliesTruncated` | boolean | null | top-level only | True when the thread had more replies than you asked for |
| `sortBy`, `videoId`, `videoUrl`, `scrapedAt` | | 100% | |

#### Rebuilding threads

Every reply carries its parent's id, so one group-by is enough:

```js
const threads = new Map();
for (const row of rows.filter((r) => r.commentId)) {
    if (row.isReply) (threads.get(row.parentCommentId) ?? []).push(row);
    else threads.set(row.commentId, []);
}
```

Rows also arrive **thread-adjacent** — each parent is immediately followed by
its own replies — so a CSV export is readable without any processing at all.

#### A per-video coverage row

Alongside the comments, each video gets one status row, and it is **never
charged**:

| Field | What it tells you |
|---|---|
| `commentsEnabled` | `false` when comments are turned off — a real answer, not a failure |
| `commentsFetched` / `repliesFetched` | What was written |
| `commentsScanned` | Rows **examined**, which is the work done |
| `pagesFetched` | Requests spent |
| `totalCommentsReported` | **What YouTube itself claims the video has**, so you can see what you missed. It is YouTube's own displayed figure, which is rounded on large videos — a video showing "10M" reports 10000000, not an exact count. Treat it as a scale check, not a precise total |
| `repliesTruncatedThreads` | How many threads had replies you did not take |
| `stoppedOn` | `exhausted`, `maxCommentsPerVideo`, `maxTotalComments`, `zeroNewIds`, … |
| `stoppedOnScanLimit` | `true` when a work budget stopped it, not the corpus |
| `zeroReason` | `comments-disabled`, `no-comments-yet`, or `null` |

This row exists because an empty dataset cannot tell you the difference between
*this video has no comments* and *we only read the first page*. Those are
different answers and you are entitled to know which one you got.

### Input

```json
{
  "videos": [
    "https://www.youtube.com/watch?v=jNQXAC9IVRw",
    "dQw4w9WgXcQ",
    "https://youtu.be/9bZkp7q19f0"
  ],
  "sortBy": "top",
  "includeReplies": true,
  "maxCommentsPerVideo": 100,
  "maxRepliesPerThread": 50,
  "maxTotalComments": 10000
}
```

`watch?v=`, `youtu.be/`, `/shorts/`, `/live/`, `/embed/` and bare 11-character
ids all work, and extra parameters are ignored. **Shorts need no special
handling** — they are ordinary videos to this endpoint. You can also chain from
another Actor's dataset with `sourceDatasetId`.

Comment order is `top` (YouTube's default) or `newest`. That option was verified
to actually change the output rather than merely being accepted: id-diffing Top
against Newest gave 4/10, 0/10 and 1/10 first-page overlap across three videos,
and Newest returns strict recency. Newest costs one extra request per video,
because its token only exists inside the first page's sort menu — and that
discarded page is not billed to you.

### Pricing

| Event | Price | When |
|---|---|---|
| `comment` (primary) | **$0.50–$1.00 per 1,000** | Per comment **or** reply row |
| `video-processed` | $0.00005 | Once per video, including comments-off |
| `apify-actor-start` | $0.00005 | Once per run |

Replies cost the same as comments. Your whole bill is:

```
(videos × $0.00005) + (comment rows + reply rows) × row price + $0.00005
```

Never charged: status rows, error rows of any kind, retried blocked pages, and
the discarded page when using `newest`. Full arithmetic, including the measured
cost basis, is in [docs/PRICING.md](docs/PRICING.md).

### What this does NOT do

Stated plainly, because a listing that overpromises just collects one-star
reviews:

- **No absolute timestamps.** YouTube returns only relative text
  (`"17 hours ago"`). This Actor gives you that string and does **not** convert
  it to a date, because "3 weeks ago" has a multi-day error bar and a converted
  value would look exact while being wrong. If you need real timestamps, the
  official API has them and this does not.
- **No transcripts, captions or subtitles.** That transport was measured RED —
  0 of 20 videos, including 16 that demonstrably have captions. Any Actor
  promising it alongside comments is promising something this endpoint no longer
  serves.
- **No comment search or date filtering.** The endpoint offers neither. Faking
  it by paginating everything and filtering client-side would bill you for
  volume you did not ask for.
- **No channel or playlist expansion.** Pass video URLs. A channel URL is
  rejected with a message telling you so, rather than guessed at.
- **No sentiment scores or AI enrichment.** You get the text; run your own model
  on it if you want that.
- **No reply nesting beyond one level.** YouTube itself does not have it —
  `replyLevel` is only ever 0 or 1.
- **No private, deleted, or held-for-review comments.** Only what a logged-out
  visitor can see.
- **Deep reply pagination is bounded.** The default takes the first 50 replies
  per thread and at most 5 reply pages. When a thread has more, the parent row
  carries `repliesTruncated: true` and the status row counts it — you are told,
  every time.

### Should you use the free official API instead?

Sometimes, yes, and here is the honest test. The YouTube Data API v3 is free,
gives you **absolute timestamps**, and has a default quota of 10,000 units/day.
Comment threads cost 1 unit per call at up to 100 per page, so if you only need
top-level comments, are happy to set up a Google Cloud project, and stay inside
that quota, **use it and do not pay for this**.

This Actor earns its price when: you want replies as well as comments and do not
want to spend a second quota-unit budget on `comments.list` per thread; you do
not want to manage an API key or a Cloud project at all; you want the run to
tell you what it missed; or you are already orchestrating on Apify and want this
to chain onto another Actor's output.

Saying this out loud filters out the buyer who would churn — which is the point.

### How it works, and the honest part

Plain HTTP JSON against YouTube's own InnerTube endpoint
(`POST /youtubei/v1/next`), the one youtube.com calls from your browser. No
browser is launched, which is what makes it roughly two orders of magnitude
cheaper per row than a Playwright crawler.

There is **no API key**, and that is measured rather than assumed: a deliberately
bogus key returned HTTP 200 with 397,262 bytes of full data, and omitting the
key entirely returned 459,946 bytes with a valid continuation token. So there is
no credential here to expire, and nothing bound to any human account.

#### robots.txt

You should know this before you buy, so it is here rather than buried.
`https://www.youtube.com/robots.txt` contains, under `User-agent: *`:

```
Disallow: /api/
Disallow: /comment
Disallow: /results
Disallow: /youtubei/
```

The endpoint this Actor uses, `/youtubei/v1/next`, is covered by that last line.
`/watch` is **not** disallowed, and is the only YouTube path this Actor touches
outside `/youtubei/` — it is used solely to re-read a client version string if
one is ever retired.

This Actor accesses only publicly visible comments, sends no credentials, and
bypasses no authentication or access control. It does not touch private,
deleted, or moderation-held comments. Whether that is acceptable for your
purpose is a decision for you and your legal advisers, and it will depend on
your jurisdiction and what you do with the data. It is not a decision this
README can make for you, and anyone selling you a YouTube comment scraper who
does not mention robots.txt at all is not telling you less than this — they are
just not telling you.

#### Reliability

- **Rate:** measured clean at 174.5 requests/minute with no delay and no rate
  limiting across 159 consecutive requests — the ceiling was never found. The
  default pacing is about a third of that, deliberately.
- **Blocked pages are detected by shape, not status.** A garbage input returns
  HTTP 200 with a normal-looking page and no error object anywhere. The Actor
  keys on the two top-level fields a genuine response always carries, and never
  on the body size — the same stub measured 55,361 bytes one day and 14,538
  another.
- **It fails loudly rather than quietly.** A retired client version answers HTTP
  404 with a ~264-byte body; the Actor rotates versions, then re-reads a live one
  from `/watch`, and if that also fails it errors out rather than reporting an
  empty success. Every video that cannot be scraped gets a typed error row in
  the dataset with a stable `errorClass`.
- **Two independent budgets.** `maxCommentsPerVideo` bounds the output;
  `maxPagesPerVideo` bounds the work. Both are reported. This matters because one
  measured video handed back a valid continuation token on five consecutive
  pages while returning zero comments — the Actor terminates on zero *new* ids,
  never on a token being absent.

#### Proxy

Apify **datacenter** proxy, on by default. Residential is deliberately not
offered at any price: it is billed per gigabyte, it would consume the margin
this Actor is priced on, and the transport was measured not to need it.

# Actor input Schema

## `videos` (type: `array`):

Video URLs or bare 11-character video ids. `watch?v=`, `youtu.be/`, `/shorts/`, `/live/` and `/embed/` links all work, and extra parameters are ignored. Shorts are ordinary videos to this endpoint, so they need no special handling. A channel or playlist URL is rejected with a message rather than guessed at.

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

The same videos, in the shape other Actors and the Apify UI hand over. Merged with the list above and deduplicated by video id.

## `sourceDatasetId` (type: `string`):

Read video URLs out of another run's dataset, for chaining after a channel or search Actor.

## `sourceDatasetField` (type: `string`):

Which field in that dataset holds the video URL or id. Defaults to `url`.

## `sortBy` (type: `string`):

Verified to change the output rather than merely being accepted: id-diffing Top against Newest gave 4/10, 0/10 and 1/10 first-page overlap across three videos, and Newest returned strict recency. Newest costs one extra request per video because its token only exists inside the first page's sort menu, and that discarded page is never billed to you.

## `includeReplies` (type: `boolean`):

Fetch reply threads as well as top-level comments. Every reply row carries `parentCommentId` and `isReply`, so a flat dataset groups back into threads. Replies cost the same per row as comments.

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

Bounds the **output**: top-level comment rows kept per video. Replies are counted separately against the per-thread cap below. YouTube serves 20 comments per page and the page size is not settable.

## `maxRepliesPerThread` (type: `integer`):

Reply pages hold 10. When a thread has more replies than this, the parent row is flagged `repliesTruncated` and the video's status row counts it — an under-delivery is always visible rather than silent. Set 0 to skip replies entirely.

## `maxPagesPerVideo` (type: `integer`):

Bounds the **work**, where the cap above bounds the output. Needed because one measured video handed back a valid continuation token on five consecutive pages while returning zero comments; this Actor stops on zero new ids, and this is the belt-and-braces bound behind that.

## `maxTotalComments` (type: `integer`):

A ceiling across every video, counting comments and replies together. Reached mid-video, the run stops cleanly and the status rows say so.

## `requestDelayMs` (type: `integer`):

Measured clean at 174.5 requests/minute with no delay and no rate limiting across 159 consecutive requests — the ceiling was never found. The default is deliberately about a third of that, because the measurement came from one residential line and your run happens on shared datacenter IPs.

## `maxConcurrency` (type: `integer`):

How many videos are scraped at once. Comments within a video are inherently sequential because each page's token comes from the page before it.

## `proxyConfig` (type: `object`):

Apify datacenter proxy, on by default. Residential is deliberately not used: it is billed per gigabyte and would consume the margin this Actor is priced on, and the transport was measured not to need it.

## Actor input object example

```json
{
  "videos": [
    "https://www.youtube.com/watch?v=jNQXAC9IVRw",
    "dQw4w9WgXcQ",
    "https://youtu.be/9bZkp7q19f0"
  ],
  "sourceDatasetId": "aBcDeFgHiJkLmNoPq",
  "sourceDatasetField": "url",
  "sortBy": "top",
  "includeReplies": true,
  "maxCommentsPerVideo": 100,
  "maxRepliesPerThread": 50,
  "maxPagesPerVideo": 200,
  "maxTotalComments": 10000,
  "requestDelayMs": 1000,
  "maxConcurrency": 4,
  "proxyConfig": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

## `dataset` (type: `string`):

One row per comment and per reply, with parentCommentId linking replies to their parent. Plus one status row per video saying how many pages were read, what YouTube claims the total is, and why the run stopped.

# 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 = {
    "videos": [
        "https://www.youtube.com/watch?v=jNQXAC9IVRw",
        "dQw4w9WgXcQ",
        "https://youtu.be/9bZkp7q19f0"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("eiv/youtube-comments-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 = { "videos": [
        "https://www.youtube.com/watch?v=jNQXAC9IVRw",
        "dQw4w9WgXcQ",
        "https://youtu.be/9bZkp7q19f0",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("eiv/youtube-comments-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 '{
  "videos": [
    "https://www.youtube.com/watch?v=jNQXAC9IVRw",
    "dQw4w9WgXcQ",
    "https://youtu.be/9bZkp7q19f0"
  ]
}' |
apify call eiv/youtube-comments-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,eiv/youtube-comments-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/tGeGP4jEIx2bVSBcL/builds/53S0vZrWzJqriIAbX/openapi.json
