Three Python functions that scrape YouTube video metadata, a channel's uploads and a full timed transcript, using the Chocodata API. Every snippet below was executed on 27 July 2026 and the output blocks are copied from that run.
Here is what the finished script prints:
NASA 15,000,000 subscribers 6,100 videos more=True
pulled 30 rows from page 1
newest: Moon Base: June 2026 Update (199,184 views)
199,192 views | 8,156 likes | 65s | Science & Technology
transcript: 13 segments, 94 words, 1 languages
[Dramatic music] [Jared Isaacman] People are looking up again, believi
wrote youtube_channel_videos.csv
TL;DR
- The video endpoint returns 26 top-level fields. There is no
datawrapper, sor.json()["title"]is correct andr.json()["data"]raisesKeyError.- A channel request returns 30 videos per page plus
has_moreand a 2,372-character continuation token.- Transcripts come back as timestamped segments. The Rick Astley video gives 61 segments, 487 words, 2,089 characters.
comment_countisNoneandkeywordsis[]on plenty of videos. Both crash naive code.
Why is it hard to scrape YouTube?
YouTube ships the watch page as a shell and builds the visible values in the browser from a JSON blob, so a plain requests.get plus an HTML parser returns markup with no title, no view count and no captions. Channel uploads compound it by paginating with continuation tokens instead of page numbers, and transcripts are not in the page at all. The part that eats the most time is the transcript, because captions are served from a separate call whose parameters are signed and short lived.
Prerequisites
To scrape YouTube with this code you need three things.
1. A free Chocodata API key. Sign up and copy it from the dashboard. Free tier, no card.
2. Python 3.9+ and requests. Tested on Python 3.13.7 with requests 2.34.2 in July 2026.
pip install requests
3. A target URL. The watch URL for videos and transcripts, the channel URL for uploads.
Scrape one video's metadata
To scrape a YouTube video, send a single GET with the watch URL and read the fields off the top level of the response.
1. Send the request and check the status
import requests
BASE = "https://api.chocodata.com/api/v1"
API_KEY = "YOUR_API_KEY"
r = requests.get(f"{BASE}/youtube/video",
params={"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"api_key": API_KEY}, timeout=30)
r.raise_for_status()
v = r.json()
print(r.status_code, len(v), "fields")
print(v["video_id"], v["title"])
Output:
200 26 fields
dQw4w9WgXcQ Rick Astley - Never Gonna Give You Up (Official Video) (4K Remaster)
2. Map the fields you actually need
Pick the fields explicitly rather than storing the whole payload. The counts are real integers, not display strings.
row = {k: v[k] for k in ("video_id", "title", "channel_name", "view_count",
"like_count", "duration_seconds", "category",
"publish_date")}
print(row["view_count"], row["like_count"], row["duration_seconds"])
print(row["channel_name"], "|", row["category"], "|", row["publish_date"][:10])
Output:
1797072750 19279452 213
Rick Astley | Music | 2009-10-24
3. Handle the null and empty fields
comment_count came back None on this video and keywords is an empty list on many others, so coalesce before doing arithmetic or indexing.
print("comment_count:", repr(v["comment_count"]))
print("keywords:", len(v["keywords"]), "| thumbnails:", len(v["thumbnails"]),
"| related:", v["related_count"])
engagement = (v["like_count"] or 0) + (v["comment_count"] or 0)
print("engagement:", engagement)
Output:
comment_count: None
keywords: 27 | thumbnails: 5 | related: 12
engagement: 19279452
Pull a channel's video list
To scrape a YouTube channel's uploads, use the channel parameter, which accepts the channel URL as its value.
1. Request the channel
r = requests.get(f"{BASE}/youtube/channel",
params={"channel": "https://www.youtube.com/@NASA",
"api_key": API_KEY}, timeout=60)
r.raise_for_status()
ch = r.json()
print(ch["channel_name"], ch["channel_id"],
ch["subscriber_count"], ch["video_count"])
Output:
NASA UCLA_DiR1FfKNvjuUpBHmylQ 15000000 6100
2. Flatten the videos list into rows
ch["videos"] is a list of dicts with position, id, title, url, thumbnail, channel, views and published. Note that views is a display string and published is relative, so keep id as your key.
rows = [{"position": x["position"], "video_id": x["id"], "title": x["title"],
"views": x["views"], "published": x["published"], "url": x["url"]}
for x in ch["videos"]]
print(len(rows), "rows")
for x in rows[:3]:
print(x["position"], x["video_id"], x["views"], "|", x["title"][:44])
Output:
30 rows
1 aRSBZN2UF0Q 199,184 views | Moon Base: June 2026 Update
2 75-H-i9ctkE 39,288 views | Independence Day Wishes from the Space Stati
3 84id2dzpwU0 45,248 views | Astronauts on the Space Station Celebrate th
3. Follow has_more for the next page
Pagination is a token, not an offset, so check has_more and carry next_page_token forward instead of incrementing a number.
print("videos_count:", ch["videos_count"], "| has_more:", ch["has_more"],
"| token chars:", len(ch["next_page_token"]))
if ch["has_more"]:
r = requests.get(f"{BASE}/youtube/channel",
params={"page_token": ch["next_page_token"],
"api_key": API_KEY}, timeout=60)
r.raise_for_status()
nxt = r.json()
print("page", nxt["page"], "->", nxt["videos_count"], "more videos")
Output:
videos_count: 30 | has_more: True | token chars: 2372
page 2 -> 30 more videos
Positions restart at 1 on each page, so build your key from video_id and not from position.
Extract a video transcript
To scrape a YouTube transcript, send the watch URL to the transcript endpoint and read the segments list.
1. Fetch the transcript for a video URL
r = requests.get(f"{BASE}/youtube/transcript",
params={"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"api_key": API_KEY}, timeout=60)
r.raise_for_status()
tr = r.json()
if not tr["transcript_available"]:
raise SystemExit("no captions for this video")
print(tr["source"], tr["language"], tr["segment_count"],
tr["word_count"], tr["char_count"])
Output:
native en 61 487 2089
source is native when the captions were authored rather than machine generated, which is_generated also reports as False.
2. Filter segments by timestamp
Every segment carries start and duration in seconds, so slicing a time window is a list comprehension.
window = [s for s in tr["segments"] if 40 <= s["start"] < 60]
print(len(window), "segments between 40s and 60s")
for s in window[:4]:
print(f"{s['start']:>6.2f} +{s['duration']:.2f} {s['text']}")
Output:
7 segments between 40s and 60s
40.52 +2.40 ♪ Gotta make you understand ♪
43.00 +2.12 ♪ Never gonna give you up ♪
45.20 +1.88 ♪ Never gonna let you down ♪
47.32 +3.80 ♪ Never gonna run around and desert you ♪
3. Join the segments into one text block
For search or summarisation you want the flat text, which is a join away.
text = " ".join(s["text"] for s in tr["segments"])
print(len(text), "chars |", len(text.split()), "words")
print(text[:70])
Output:
2089 chars | 487 words
[♪♪♪] ♪ We're no strangers to love ♪ ♪ You know the rules and so
The part that breaks
Three failures, all hit while writing this, none of them in the network layer.
Assuming a data wrapper. The fields are at the top level. Reaching for ["data"] is the single most common mistake porting from other APIs:
Traceback (most recent call last):
File "scrape_youtube.py", line 12, in <module>
title = r.json()["data"]["title"]
~~~~~~~~^^^^^^^^
KeyError: 'data'
comment_count is None, not 0. Summing it straight is a TypeError that only fires on the videos where it is missing:
TypeError: unsupported operand type(s) for +: 'int' and 'NoneType'
keywords is empty on many videos. The Rick Astley video has 27, the NASA clip has zero, and indexing the empty one raises:
IndexError: list index out of range
One more that is easy to miss: available_languages can repeat a code. The TEDx talk lists en twice inside 28 entries, so wrap it in sorted(set(...)) before you count languages.
Full script
"""YouTube scraper: video metadata, channel uploads, transcripts.
Tested: Python 3.13.7, requests 2.34.2, 27 July 2026."""
import csv
import sys
import time
import requests
sys.stdout.reconfigure(encoding="utf-8")
BASE = "https://api.chocodata.com/api/v1"
API_KEY = "YOUR_API_KEY"
def call(job, timeout=60, tries=3, **params):
"""One GET against the API. Fields come back at the top level, no wrapper.
Retried, because a long-running batch should not die on one slow response.
"""
params["api_key"] = API_KEY
for attempt in range(tries):
try:
r = requests.get(f"{BASE}/{job}", params=params, timeout=timeout)
if r.ok:
return r.json()
except requests.exceptions.RequestException:
if attempt == tries - 1:
raise
time.sleep(2)
r.raise_for_status()
def get_video(url):
v = call("youtube/video", url=url)
return {
"video_id": v["video_id"],
"title": v["title"],
"channel": v["channel_name"],
"views": v["view_count"],
"likes": v["like_count"],
"comments": v["comment_count"] or 0, # null on many videos
"seconds": v["duration_seconds"],
"category": v["category"],
"published": v["publish_date"][:10],
"keywords": len(v["keywords"]), # empty list on many videos
}
def get_channel_videos(channel_url, limit=30):
c = call("youtube/channel", channel=channel_url)
rows = [{"position": v["position"], "video_id": v["id"], "title": v["title"],
"views": v["views"], "published": v["published"], "url": v["url"]}
for v in c["videos"][:limit]]
return {"name": c["channel_name"], "channel_id": c["channel_id"],
"subscribers": c["subscriber_count"], "total_videos": c["video_count"],
"has_more": c["has_more"]}, rows
def get_transcript(url):
t = call("youtube/transcript", url=url)
if not t["transcript_available"]:
return None
return {
"video_id": t["video_id"],
"language": t["language"],
"generated": t["is_generated"],
"segments": t["segment_count"],
"words": t["word_count"],
"languages": sorted(set(t["available_languages"])), # duplicates appear
"text": " ".join(s["text"] for s in t["segments"]),
}
def to_csv(rows, path):
with open(path, "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
w.writeheader()
w.writerows(rows)
return path
if __name__ == "__main__":
meta, rows = get_channel_videos("https://www.youtube.com/@NASA")
print(f"{meta['name']} {meta['subscribers']:,} subscribers "
f"{meta['total_videos']:,} videos more={meta['has_more']}")
print(f"pulled {len(rows)} rows from page 1")
newest = rows[0]
print(f"newest: {newest['title']} ({newest['views']})")
video = get_video(newest["url"])
print(f" {video['views']:,} views | {video['likes']:,} likes | "
f"{video['seconds']}s | {video['category']}")
tr = get_transcript(newest["url"])
if tr:
print(f" transcript: {tr['segments']} segments, {tr['words']} words, "
f"{len(tr['languages'])} languages")
print(f" {tr['text'][:70]}")
to_csv(rows, "youtube_channel_videos.csv")
print("wrote youtube_channel_videos.csv")
The chain is the useful part: one channel call produces 30 URLs, and each URL feeds both the video and transcript endpoints without any ID extraction in between. The CSV it wrote has a header plus 30 rows.
Summary
Three endpoints cover the YouTube jobs most pipelines need, and all three take the URL you already have: the video endpoint returns 26 top-level fields with integer view and like counts, the channel endpoint returns 30 uploads per page with a continuation token for the rest, and the transcript endpoint returns timestamped segments plus a language list. The thing to carry away is that the failures are in the data shape rather than the request, so coalesce comment_count, guard keywords, and never reach for a data wrapper that does not exist. Python is used throughout here, though any language that can send a GET works the same way.
FAQ
Is it legal to scrape YouTube?
Public video metadata and captions sit in a different position from private or logged-in data, so stay on publicly visible pages and check YouTube's terms for your specific use.
How many videos does one channel request return?
Thirty per page, with has_more and a next_page_token telling you whether to request another page.
Why is my view count a string instead of a number?
You are reading view_count_text or the channel list's views field, both of which are display strings, while view_count on the video endpoint is a real integer.










Top comments (0)