Skip to content

ChocoData-com/youtube-video-scraper

Repository files navigation

YouTube Video Scraper

YouTube Video Scraper

YouTube Video Scraper for extracting titles, view counts, likes, descriptions, keywords and related videos from YouTube.com. This repo has a free YouTube video web scraping script you can run right now, and a YouTube video data API that returns 26 structured fields for a watch URL.

This is the video endpoint on its own. The YouTube Scraper repo covers the rest of the surface: search, channels, playlists, transcripts, comments and Shorts.

Last updated: 2026-07-20. Working against YouTube.com as of July 2026, and re-verified whenever YouTube changes their markup.

Every JSON block on this page was captured from the live API on 2026-07-20. Long arrays are trimmed and each block says exactly what was cut; the fields shown are verbatim. Full uncut samples are committed in youtube_video_scraper_api_data/. Every code example calls the actual API and is runnable from youtube_video_scraper_api_codes/.

pip install requests
export CHOCODATA_API_KEY="your_key"     # free: 1,000 requests, one-time, no card
python youtube_video_scraper_api_codes/video.py

Those three lines return this, live from YouTube.com:

{
  "title": "COSTA RICA IN 4K 60fps HDR (ULTRA HD)",
  "view_count": 331795268,
  "like_count": 1107575,
  "duration_seconds": 314,
  "category": "Film & Animation",
  "publish_date": "2018-06-12T19:49:29-07:00",
  "channel_name": "Jacob + Katie Schwarz",
  "related_count": 12
}

That is 8 of the 26 fields. Here are all of them, for one video:

Retrieved YouTube video data

That is the whole point of this repo. The rest of this page is the reference: the parameters, the real response, and the fields worth knowing about before you build on them.


Contents


Free YouTube Video Scraper

YouTube server-renders the watch page's player state into a JavaScript object called ytInitialPlayerResponse, so you can extract structured video data without a headless browser or JavaScript rendering. No key, no cost:

python free_scraper/youtube_video_free_scraper.py "https://www.youtube.com/watch?v=LXb3EKWsInQ"

Source: free_scraper/youtube_video_free_scraper.py. It finds the ytInitialPlayerResponse object, brace-counts to its matching close, and reads videoDetails plus the playerMicroformatRenderer that sits beside it.

After running the command, your terminal should look something like this:

Free YouTube video scraper parsing a real watch page

It works. A plain requests.get to a watch page returns HTTP 200 with the full player state embedded: 12 of 12 consecutive attempts succeeded in a measured run on 2026-07-20, 4 seconds apart, at 1,141,342 to 1,214,927 bytes each. That gets you 12 fields: id, title, channel name and id, view count, like count, duration, publish date, category, live flag, keywords and the full description.

It is not perfectly reliable. One earlier attempt in the same session came back without videoDetails and the next one worked, so treat a miss as something to retry rather than as a permanent wall. The 12 responses ranged over roughly 74 KB in size, so do not treat a byte count as a stable assertion about the page.

What goes wrong on YouTube video pages is a different problem, and it is the subject of the next section.

Avoid getting blocked when scraping YouTube videos

Getting blocked is not the interesting failure mode on a watch page. These are, and all of them are measured against YouTube on 2026-07-20.

First, related[] is not a stable graph. Three calls for the same video id, seconds apart, returned 35 distinct related video ids across 12 rows each, with zero appearing in all three. Even position 1 changed on every call. If your plan was "read the related list once and build a recommendation graph", that plan does not survive contact with YouTube: you need repeated samples and aggregation, not one call.

Second, the counters do not refresh per request. The same three calls returned an identical view_count of 331,795,268 and an identical like_count of 1,107,575 every time. These are exact integers on YouTube's own refresh cycle, not live meters, so polling faster than the video actually gains engagement buys you the same number repeatedly. The two counters are also served independently: on a busier video (dQw4w9WgXcQ, three consecutive calls the same day) the like count moved by exactly one on each call while the view count did not move at all. Do not assume a static view_count means the scrape failed.

Third, the field names lie about their contents. channel_handle is not a handle, it is a full profile URL (http://www.youtube.com/@JacobKatieSchwarz, note the http). Assume it is @JacobKatieSchwarz and your string concatenation produces a dead link.

Here is the full picture, and what each item costs you:

What bites you Why What it costs you
A watch page is ~1.2 MB The player state is embedded in a page built to render a video player, not to serve data. 1,213,956 bytes fetched to read 12 fields. Bandwidth and parse time scale with pages you throw away, not with data you keep.
related[] is regenerated per request 35 distinct ids across three consecutive calls, none common to all three. One call is noise. Recommendation and discovery work needs many samples.
comment_count was null on 5 of 5 videos sampled The watch page loads its comment count over a separate request after render, so it is not in the initial HTML the parser sees. A field that exists, came back empty on every video we tried, and quietly breaks any report that assumes otherwise.
keywords is uploader-controlled It is the tag list the uploader typed. Across five videos sampled it ranged from 3 to 31 entries. You cannot size a keyword pipeline on one video, and you cannot assume the field is populated.
The JSON path moves ytInitialPlayerResponse and the shapes inside it change with YouTube's releases. Your parser silently returns nothing. Ongoing maintenance, plus alerting smart enough to tell "empty" from "broken".

So the two paths, side by side, same video, same day:

Free YouTube video scraper vs Chocodata API, measured

Both reach the video. The difference is what you carry: 1.2 MB of HTML and a brace-counting parser that has to keep matching YouTube's page shape, versus 7.6 KB of JSON with the fields already named and the related list already walked.


Using the Chocodata YouTube Video Scraper API

The managed option, and the one this repo is built around: the Chocodata YouTube Video Scraper API. One GET request per video URL, 26 fields of YouTube video data extraction at scale, a ~99% success rate, and no proxy management. The related list is collected from the three renderer shapes YouTube currently ships on the watch-next column (compactVideoRenderer, videoWithContextRenderer and lockupViewModel) and normalised into one row shape. Free for the first 1,000 requests.


YouTube Video Scraper API reference

Below is the YouTube Video Scraper API reference to get you started: authentication, the error bodies, the rate limits, and the endpoint itself.

Quickstart

curl "https://api.chocodata.com/api/v1/youtube/video?api_key=YOUR_KEY&url=https://www.youtube.com/watch?v=LXb3EKWsInQ"
import requests

r = requests.get(
    "https://api.chocodata.com/api/v1/youtube/video",
    params={"api_key": "YOUR_KEY", "url": "https://www.youtube.com/watch?v=LXb3EKWsInQ"},
    timeout=90,
)
v = r.json()
print(v["title"], v["view_count"], v["like_count"])
# COSTA RICA IN 4K 60fps HDR (ULTRA HD) 331795268 1107575

After running the command, your terminal should look something like this:

Running the YouTube Video Scraper API

Authentication

Pass your key as the api_key query parameter. There is no header form and no OAuth step.

https://api.chocodata.com/api/v1/youtube/video?api_key=YOUR_KEY&url=...

A free key is 1,000 requests, one time, with no card. Get one at chocodata.com.

Errors

Nothing below is billed: you are only charged on a 2xx.

Status error code Meaning Billed What to do
400 invalid_params Neither video_id nor url was supplied. The body names the missing param and its path. no Fix the query string.
401 INVALID_API_KEY Key missing, unrecognised, or revoked. no Check api_key. Get one at chocodata.com.
402 INSUFFICIENT_CREDITS Balance exhausted. no Top up or upgrade.
429 RATE_LIMITED Over 120 requests/60s, or over your plan's concurrency. no Back off and retry.
502 - YouTube did not return a parseable watch page for this request. A dead or nonexistent video id lands here too, not on a 404. no Check the id, then retry once after a few seconds.

A bad key, verbatim:

curl "https://api.chocodata.com/api/v1/youtube/video?api_key=totally_invalid_key&url=https://www.youtube.com/watch?v=LXb3EKWsInQ"
{"error": {"code": "INVALID_API_KEY", "message": "Api key not recognised."}}

A missing required param, verbatim. Note that it names the real parameter, which is the fastest way to find out the endpoint takes video_id and not id:

{"error": "invalid_params", "issues": [{"code": "custom", "message": "youtube.video requires `video_id` or `url`", "path": ["video_id"]}]}

Two response shapes exist: auth and billing errors nest under error.code (uppercase), while scrape-layer errors are flat with a lowercase error string.

The scripts in this repo map every documented status onto an actionable message, so a typo'd key does not hand you a stack trace:

YouTube Video Scraper API error handling

Build the retry in. A 502 on this endpoint is retryable and uncharged, and it does happen: one call in the latency run below came back 502. enrich_url_list.py retries once after 8 seconds before it gives up on a URL, and that is the pattern to copy.

Rate limits and concurrency

Two separate limits apply, and they are enforced independently.

Limit Value
Requests per key 120 per 60 seconds (sliding window)
Concurrent requests, Free 10
Concurrent requests, Vibe 30
Concurrent requests, Pro 50
Concurrent requests, Custom 100

Exceed either and you get 429, not a queue. Every call is a synchronous GET: there is no webhook, callback, or async job to poll. The examples use timeout=90 because the slowest successful call measured for the table below took 14.4s and you want headroom.

Fan out with a thread pool, sized to stay inside both limits at once:

from concurrent.futures import ThreadPoolExecutor
import requests

def one(url):
    r = requests.get("https://api.chocodata.com/api/v1/youtube/video",
                     params={"api_key": KEY, "url": url}, timeout=90)
    return r.json() if r.status_code == 200 else None

with ThreadPoolExecutor(max_workers=8) as pool:
    videos = [v for v in pool.map(one, urls) if v]

Video: titles, view counts, likes, descriptions and keywords

The full record for one video: exact view and like counts, the complete description, upload dates, channel, thumbnails, and a related list.

Param Type Required Default Description
video_id string one of video_id/url - The 11-char watch id (e.g. LXb3EKWsInQ), or any video URL. Note the name: video_id, not id.
url string (URL) one of video_id/url - Five URL forms are parsed for the 11-char id: watch?v=, youtu.be/, /shorts/, /embed/ and /live/.
api_key string yes - Your key. Query parameter, not a header.
curl "https://api.chocodata.com/api/v1/youtube/video?api_key=YOUR_KEY&video_id=LXb3EKWsInQ"

Real response. description truncated from 1,413 chars where marked, keywords cut to 4 of 19, thumbnails to 1 of 5, related to 1 of 12; every one of the 26 fields is present and verbatim (full sample):

{
  "video_id": "LXb3EKWsInQ",
  "url": "https://www.youtube.com/watch?v=LXb3EKWsInQ",
  "type": "video",
  "title": "COSTA RICA IN 4K 60fps HDR (ULTRA HD)",
  "description": "We've re-mastered and re-uploaded our favorite video in HDR!\n\nCHECK OUT OUR MOST POPULAR VIDEO: ...",
  "view_count": 331795268,
  "view_count_text": "331795268",
  "like_count": 1107575,
  "comment_count": null,
  "duration_seconds": 314,
  "keywords": [
    "4K",
    "4k resolution",
    "60fps",
    "HDR"
  ],
  "category": "Film & Animation",
  "is_live": false,
  "is_family_safe": true,
  "is_private": false,
  "allow_ratings": true,
  "publish_date": "2018-06-12T19:49:29-07:00",
  "upload_date": "2018-06-12T19:49:29-07:00",
  "channel_id": "UCYq-iAOSZBvoUxvfzwKIZWA",
  "channel_name": "Jacob + Katie Schwarz",
  "channel_handle": "http://www.youtube.com/@JacobKatieSchwarz",
  "channel_url": "https://www.youtube.com/channel/UCYq-iAOSZBvoUxvfzwKIZWA",
  "thumbnail": "https://i.ytimg.com/vi/LXb3EKWsInQ/hq720.jpg?sqp=-oaymwEcCK4FEIIDSEbyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLB5GgSUsJLwRUTt-dQ4Dm_gXd2jeQ",
  "thumbnails": [
    {
      "url": "https://i.ytimg.com/vi_webp/LXb3EKWsInQ/default.webp",
      "width": 120,
      "height": 90
    }
  ],
  "related": [
    {
      "position": 1,
      "id": "-sEEUWRxPcY",
      "title": "The Deputy Sheriff Knocked At Dawn With Eviction Papers — My SIL Was Laughing... | Calm Dad Stories.",
      "url": "https://www.youtube.com/watch?v=-sEEUWRxPcY",
      "thumbnail": "https://i.ytimg.com/vi/-sEEUWRxPcY/hq720.jpg?sqp=-oaymwEcCK4FEIIDSEbyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDY2IfM0LSmQZNMH8KelI6rUxuVLA",
      "channel": "Calm Dad Stories",
      "views": "19K views",
      "published": null
    }
  ],
  "related_count": 12
}

view_count is the field most people come for, and it is the one place you get an exact integer instead of the "331M views" string the page shows. view_count_text carries whatever the page gave, which on this video is the same digits.

Six notes on the rest, each measured across the videos sampled on 2026-07-20:

  • comment_count is null. YouTube does not put the comment count in the watch page's initial HTML: it arrives later over a separate request. Null on all 5 videos sampled.
  • related[] returns 12 rows on all 5 videos sampled, each with the 8 keys shown. It is the watch-next column, so the rows have no topical relationship to the video you asked for, row views is a display string ("19K views") rather than an integer, and row published was null on every related row captured.
  • channel_handle is a URL, not a handle. It is microformat.ownerProfileUrl passed through, so it arrives as http://www.youtube.com/@JacobKatieSchwarz.
  • publish_date and upload_date were identical on both committed samples. They come from different microformat keys and can diverge on re-uploaded videos.
  • keywords ranged from 3 to 31 entries across five videos. It is the uploader's own tag list, so treat an empty array as normal rather than as a parse failure.
  • thumbnails is 5 sizes, smallest first, from 120x90 up. thumbnail on its own is the single largest preview URL.

A second committed sample, video_first_youtube_video.json, is the first video uploaded to YouTube, fetched with video_id instead of url so both parameter forms are demonstrated:

{
  "video_id": "jNQXAC9IVRw",
  "title": "Me at the zoo",
  "view_count": 400788518,
  "like_count": 19221070,
  "duration_seconds": 19,
  "category": "Film & Animation",
  "publish_date": "2005-04-23T20:31:52-07:00",
  "channel_name": "jawed",
  "keywords": [
    "me at the zoo",
    "jawed karim",
    "first youtube video"
  ]
}

Runnable: youtube_video_scraper_api_codes/video.py


Enrich a list of video URLs with titles, views and likes

The job most people arrive with: a list of YouTube video URLs from a spreadsheet, a campaign brief or a competitor report, and a need for one row per video with the numbers filled in.

export CHOCODATA_API_KEY="your_key"
python youtube_video_scraper_api_codes/enrich_url_list.py

It reads urls.txt if present, one URL per line, and otherwise runs the four sample URLs. Four workers keep it inside both the concurrency cap and the 120 requests/60s limit, a 502 is retried once after 8 seconds, and the output is sorted by view count:

Enriching a list of YouTube video URLs

The CSV carries video_id, title, channel_name, view_count, like_count, duration_seconds, category, publish_date, url. Point it at a real list and the shape does not change.

Source: youtube_video_scraper_api_codes/enrich_url_list.py


Measured latency

Ten consecutive calls to /youtube/video for the same video id, 3 seconds apart, on 2026-07-20. Nine returned 200 and are timed below; the tenth returned a retryable 502:

Measured latency for the YouTube video endpoint

Metric Value
calls attempted 10
200 responses 9
min 2,335 ms
median 3,420 ms
max 14,393 ms

Ten calls is a small sample and the spread is wide, which is why the examples set timeout=90 rather than sizing to the median, and why both scripts retry a 502 once before giving up.


License

MIT. See LICENSE.