The short answer: the YouTube Search Scraper takes up to 50 keywords, returns one JSON record per result — video, channel, playlist, or Shorts under type: all, each row carrying its search_query — and pandas turns that into a CSV in three lines. No YouTube Data API key or quota. $0.20 per 1,000 delivered results; a keyword that finds nothing returns a free typed row. Full script below. (The official search.listhas the richer filter surface and no documented fees, but since Google's June 2026 quota revision it lives in a 100-calls-per-day bucket — 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.
Keywords → DataFrame → CSV
import os
import pandas as pd
from apify_client import ApifyClient
client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor("apihq/youtube-search-scraper").call(
run_input={
"queries": ["lofi hip hop", "synthwave", "jazz for studying"],
"type": "video", # video | channel | playlist | all
"maxResults": 200, # ceiling per query, not a guarantee
"sortBy": "relevance",
"uploadDate": "month",
}
)
items = list(client.dataset(run["defaultDatasetId"]).iterate_items())
results = [r for r in items if r["success"]] # billed: $0.0002 each
misses = [r for r in items if not r["success"]] # free, typed
for m in misses:
print(m.get("search_query"), "->", m["code"]) # e.g. NO_RESULTS
df = pd.DataFrame(results)
if not df.empty:
# the same result can surface on two result pages of one query —
# a first-hand 200-row run contained one such duplicate. Every
# successful row carries a canonical url, whatever its type.
df = df.drop_duplicates(subset=["search_query", "url"])
# live results carry a concurrent "watching" count in view_count
# instead of lifetime views (video rows; channel/playlist rows
# don't have this column)
if "view_count" in df.columns:
df["is_live_row"] = df["view_count"].fillna("").str.contains("watching")
df.to_csv("search_results.csv", index=False)
print(f"{len(df)} results -> search_results.csv")Filtering on success first matters: a keyword that returns nothing or an unusable filter value comes back as a typed success: false row (NO_RESULTS, VALIDATION_FAILED — the registry has all of them), not as an exception — and not on your bill. The dedupe line reflects an observed fact, not paranoia: in our published 200-row run, one video surfaced on two result pages and was billed twice — 200 billed rows, 199 unique videos.
One scope note: the script requests type: video, and the analysis below targets video rows. Channel and playlist rows lack duration and view_count (they carry their own fields — subscriber_text on channel rows, playlist_id on playlist rows); Shorts returned under type: all carry video_id and view_count, but no duration. The type-safety guards above are what keep a mixed all export from breaking.
Two lines of analysis to start from
# how deep each keyword actually went (YouTube sets the depth)
print(df["search_query"].value_counts())
# which channels rank across your keywords
print(df.groupby("channel_name")["search_query"].nunique().sort_values(ascending=False).head(10))duration is an integer of seconds on video rows (absent on live streams); view_countis a display string as YouTube renders it — lifetime views ("55M views"), or a concurrent watching count on live rows, which is what the is_live_row mask separates. The most common next step is feeding video_id into the Transcript Scraper or Comments Scraper — there's an importable n8n template that chains search → transcripts in one workflow.
Cost math
- If all three keywords deliver their full 200 rows, as in the script: $0.12 (
maxResultsis a ceiling — you pay per delivered result, and YouTube usually runs out of results in the low hundreds per query). - 50 keywords at up to 200 results each: at most $2.00 per run.
- A weekly rank-track of 10 terms at up to 50 results each: at most $0.10 per sweep — four full weekly sweeps cost at most $0.40; a five-run calendar month costs at most $0.50.
Honest limits
- YouTube sets the search depth, not you. Results stop when relevance runs out — usually in the low hundreds per query.
maxResultsis a ceiling, not a guarantee, and thevideofilter is shallower than an unfilteredallsearch. - Duplicates can occur across result pages. Our first-hand run delivered 200 billed rows containing 199 unique videos — one video appeared twice, one excess duplicate row. Duplicated rows are billed as delivered rows, so keep the
drop_duplicatesline. - Display strings, not integers. Audience counts come as YouTube renders them, with mixed semantics on live rows. For exact integer statistics, the official route is
search.listplusvideos.batchGetStats— compared here, where its filter surface genuinely wins. - Public results only: what a signed-out visitor sees. One row per result with what search exposes — full descriptions, like counts, and tags are out of scope.