The short answer: the YouTube Comments Scraper takes a video URL or bare 11-character ID, returns one JSON record per comment — text, author, reply structure, pinned flag — and pandas turns that into a CSV in three lines. No YouTube Data API key or quota. $0.40 per 1,000 comments; a video with comments turned off returns a free typed row instead of an error. Full script below. (If you need exact integer like counts and have a Google Cloud project, the official commentThreads API is genuinely strong here — our comparison lays out both sides.)
Setup
pip install apify-client pandasThe token comes from Apify Console → Settings → API tokens (free tier works). No Google account or API key.
Video URL → DataFrame → CSV
import os
import pandas as pd
from apify_client import ApifyClient
client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor("apihq/youtube-comments-scraper").call(
run_input={
"videoUrls": ["dQw4w9WgXcQ"], # watch URL, youtu.be, Shorts, or bare ID
"maxComments": 2000,
"sort": "top", # top | newest
"includeReplies": True, # replies count toward maxComments
}
)
items = list(client.dataset(run["defaultDatasetId"]).iterate_items())
comments = [c for c in items if c["success"]] # billed: $0.0004 each
misses = [c for c in items if not c["success"]] # free, typed
for m in misses:
print(m.get("video_url"), "->", m["code"]) # e.g. COMMENTS_DISABLED
df = pd.DataFrame(comments)
if not df.empty:
# like_count is a display string; zero-like comments currently carry
# whitespace instead of an empty value (disclosed actor defect)
df["like_count"] = df["like_count"].fillna("").str.strip()
df.to_csv("youtube_comments.csv", index=False)
print(f"{len(df)} comments -> youtube_comments.csv")Filtering on success first matters: a deleted or private video, a comments-off video, or a bad ID comes back as a typed success: false row (VIDEO_NOT_FOUND, COMMENTS_DISABLED — the registry has all of them), not as an exception — and not on your bill. Batch up to 50 videos in one run by adding more entries to videoUrls; one comments-off video never blocks the others.
How replies land in the DataFrame
With includeReplies: true, reply rows arrive in the same dataset carrying reply_level (0 = top-level, 1+ = reply) and parent_comment_id, so threads reconstruct with a groupby instead of follow-up API calls. Two honest mechanics to know: replies count toward maxComments, so a huge thread can be cut off mid-replies — raise the cap if you want more of the thread — and the split is easy to check:
top_level = df[df["reply_level"] == 0]
replies = df[df["reply_level"] > 0]
# the busiest threads, by the integer reply counter
busiest = top_level.sort_values("reply_count", ascending=False).head(10)Two lines of analysis to start from
# who comments most (author_name is the public display name, often an @handle)
print(df["author_name"].value_counts().head(10))
# pinned comment, if the creator pinned one
pinned = df[df["is_pinned"]]From here the usual moves are sentiment scoring on text, splitting creator-hearted threads out — though see the honest limits below before using that flag — and joining the comment set against video metadata from the Channel Scraper for per-video comparisons. Note that like_countis a display string as YouTube renders it ("264K"), not an integer — sort engagement by the integer reply_count instead, or use the official API when exact like arithmetic is the job.
Cost math
- If the script delivers all 2,000 rows: $0.80 (
maxCommentsis a ceiling — you pay per delivered row, so a smaller comment section costs less). - 10,000 delivered comments across a 50-video batch: $4.00.
- With
maxComments: 100, 30 full daily checks on one video cost at most $1.20 per month. The importable n8n and GitHub Actions templates run this same actor-call pattern, but as shipped they use a manual trigger and 200 comments (n8n sorts newest, Actions sorts top) — switch the trigger to a schedule and setsortandmaxCommentsto make them a daily newest-100 check.
Honest limits
- Display strings, not integers.
like_countcomes as YouTube renders it ("264K") — and on zero-like comments the field is currently a whitespace-only string rather than empty, a disclosed actor defect: normalize with.str.strip()before using it (the script above does).published_timeis relative ("1 year ago"). For exact integers and timestamps, the official commentThreads API is the stronger dataset — compared here. - Do not branch on
is_heartedyet. A 2026-07-22 first-hand run returned it true on all 200 rows, which is implausible; it is tracked as a known limitation in the actor repository and disclosed on the actor page. - Public video comments only: private, deleted, and members-only videos return typed
success: falserows; community-post comments and live chat are out of scope. - Replies share the cap.
maxCommentscounts top-level comments and replies together, so it bounds the run's spend — but a 1,000-reply thread will not arrive complete under a low cap.