YouTube Shorts Scraper for extracting titles, view counts, likes, channels, durations and related videos from YouTube.com. This repo has a free YouTube Shorts web scraping script you can run right now, and a YouTube Shorts data API that returns 27 structured fields for one Short.
This is the Shorts endpoint on its own. The YouTube Scraper repo covers the rest of the surface: search, channels, playlists, transcripts, comments and full videos.
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, nulls included. Full uncut samples are committed in youtube_shorts_scraper_api_data/. Every code example calls the actual API and is runnable from youtube_shorts_scraper_api_codes/.
pip install requests
export CHOCODATA_API_KEY="your_key" # free: 1,000 requests, one-time, no card
python youtube_shorts_scraper_api_codes/shorts.pyThose three lines return this, live from YouTube.com (8 of the 27 fields; counts captured 2026-07-20):
{
"video_id": "NShimEnXxNg",
"title": "How to make good espresso ☕️",
"view_count": 25588560,
"like_count": 1216618,
"duration_seconds": 47,
"is_short": true,
"channel_name": "Tanner Colson",
"related_count": 12
}Here are all 27 fields, for one Short:
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.
- Free YouTube Shorts Scraper
- Avoid getting blocked when scraping YouTube Shorts
- YouTube Shorts Scraper API reference
- Enrich a list of YouTube Shorts URLs with titles, views and likes
- Measured latency
- License
A Short is a vertical video, so youtube.com/shorts/<id> resolves to a normal watch page and YouTube server-renders its player state into a JavaScript object called ytInitialPlayerResponse. You can extract structured data from it without a headless browser or JavaScript rendering. No key, no cost:
python free_scraper/youtube_shorts_free_scraper.py "https://www.youtube.com/shorts/NShimEnXxNg"Source: free_scraper/youtube_shorts_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:
It works. A plain requests.get to the watch page returns HTTP 200 with the full player state embedded, at roughly 1.1 MB per page. That gets you 11 fields: id, title, channel name and id, view count, like count, duration, publish date, category, live flag, keywords and the description.
Parse first. Anti-bot and consent strings (captcha, consent.youtube) appear in the JavaScript of perfectly good pages, so a scraper that greps the body for them before parsing reports "blocked" on a page full of data and can never report success. Only the genuine absence of videoDetails is a failure, and a miss is something to retry rather than a permanent wall.
What goes wrong on a Short is a different problem, and it is the subject of the next section.
Getting blocked is not the interesting failure mode on a Short. These are, and all of them are measured against YouTube on 2026-07-20 across 12 Shorts.
First, is_short is a flag the endpoint sets, not a fact it detected. The /youtube/shorts endpoint fetches the watch page and stamps is_short: true on the result. It was true on 12 of 12 Shorts sampled, but it would be true for any video id you passed, Short or not, because nothing on the page is inspected to confirm it. The field that actually separates a Short from a long video is duration_seconds: every genuine Short sampled ran 10 to 60 seconds. Filter on the duration, not on is_short.
Second, the counters drift, so timestamp them. NShimEnXxNg read 25,588,560 views on the capture for this page, and the free-scraper run beside it, taken minutes earlier, read 25,588,551. Three rapid API calls in a row returned the same integer each time, so the number is stable within a request burst and moves on YouTube's own refresh cycle rather than per call. A fresh, still-viral Short moves far faster than a settled one, so a view count is only true as of the moment you took it.
Third, related[] is the watch-next column, not a topical graph. It returned 11 to 12 rows across the Shorts sampled, the rows are long-form videos rather than other Shorts, and it is regenerated per request. If your plan was "read the related list once", you need repeated samples and aggregation instead.
Here is the full picture, and what each item costs you:
| What bites you | Why | What it costs you |
|---|---|---|
| A Short is served as a ~1.1 MB watch page | The player state is embedded in a page built to render a video, not to serve data. | Bandwidth and parse time scale with pages you throw away, not with data you keep. |
comment_count was null on 12 of 12 Shorts |
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 Short we tried, and quietly breaks any report that assumes otherwise. |
keywords and description came back empty on the Shorts captured |
Both are uploader-controlled. keywords was [] and description was "" on both Shorts captured in full. |
You cannot assume either field is populated on a Short, so a text pipeline built on them will mostly come back empty. |
channel_handle is a URL, not a handle |
It is microformat.ownerProfileUrl passed through, so it arrives as http://www.youtube.com/@tannercolsoncoffee (note the http). |
Assume it is @tannercolsoncoffee and your string concatenation produces a dead link. |
| 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 Short, same day:
Both reach the Short. The difference is what you carry: 1.1 MB of HTML and a brace-counting parser that has to keep matching YouTube's page shape, versus about 6 KB of JSON with the fields already named and the related list already walked.
The managed option, and the one this repo is built around: the Chocodata YouTube Shorts Scraper API. One GET request per Short, 27 fields of YouTube Shorts data extraction at scale, a ~99% success rate, and no proxy management. It takes a /shorts/ URL, a watch URL, or the bare 11-char id, and returns the same rich object a full watch page does with an added is_short flag. Free for the first 1,000 requests.
Below is the YouTube Shorts Scraper API reference to get you started: authentication, the error bodies, the rate limits, and the endpoint itself.
curl "https://api.chocodata.com/api/v1/youtube/shorts?api_key=YOUR_KEY&url=https://www.youtube.com/shorts/NShimEnXxNg"import requests
r = requests.get(
"https://api.chocodata.com/api/v1/youtube/shorts",
params={"api_key": "YOUR_KEY", "url": "https://www.youtube.com/shorts/NShimEnXxNg"},
timeout=90,
)
s = r.json()
print(s["title"], s["view_count"], s["is_short"])
# How to make good espresso ☕️ 25588560 TrueAfter running the command, your terminal should look something like this:
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/shorts?api_key=YOUR_KEY&url=...
A free key is 1,000 requests, one time, with no card. Get one at chocodata.com.
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 id lands here too, and so does a non-YouTube URL, not a 404. |
no | Check the id, then retry once after a few seconds. |
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.shorts requires `video_id` or `url`", "path": ["video_id"]}]}A bad key, verbatim:
{"error": {"code": "INVALID_API_KEY", "message": "Api key not recognised."}}Both captured bodies are in errors.json. Auth and billing errors nest under error.code (uppercase); the 400 is flat with a lowercase error string. The 502 is transient: branch on the status and retry rather than parsing its body.
The scripts in this repo turn each of these statuses (400, 401, 402, 429, 502) into an actionable message, so a typo'd key does not hand you a stack trace:
Build the retry in. A 502 on this endpoint is retryable and uncharged, and it does happen. enrich_url_list.py retries once after 8 seconds before it gives up on a Short, and that is the pattern to copy.
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 to 500+ |
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 6.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(ref):
r = requests.get("https://api.chocodata.com/api/v1/youtube/shorts",
params={"api_key": KEY, "url": ref}, timeout=90)
return r.json() if r.status_code == 200 else None
with ThreadPoolExecutor(max_workers=8) as pool:
shorts = [s for s in pool.map(one, urls) if s]The full record for one Short: exact view and like counts, duration, upload dates, channel, thumbnails, and a related list. A Short is a watch page, so the object matches the video endpoint's shape with is_short added.
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
video_id |
string | one of video_id/url |
- | The 11-char watch id (e.g. NShimEnXxNg), or any Short/video URL. Note the name: video_id, not id. |
url |
string (URL) | one of video_id/url |
- | Parsed for the 11-char id. /shorts/, watch?v=, youtu.be/, /embed/ and /live/ forms all resolve. |
api_key |
string | yes | - | Your key. Query parameter, not a header. |
curl "https://api.chocodata.com/api/v1/youtube/shorts?api_key=YOUR_KEY&video_id=NShimEnXxNg"Real response. thumbnails cut to 1 of 5 and related to 1 of 12; every one of the 27 fields is present and verbatim, nulls and empty strings included (full sample):
{
"video_id": "NShimEnXxNg",
"url": "https://www.youtube.com/watch?v=NShimEnXxNg",
"type": "video",
"title": "How to make good espresso ☕️",
"description": "",
"view_count": 25588560,
"view_count_text": "25588560",
"like_count": 1216618,
"comment_count": null,
"duration_seconds": 47,
"keywords": [],
"category": "People & Blogs",
"is_live": false,
"is_family_safe": true,
"is_private": false,
"allow_ratings": true,
"publish_date": "2022-10-19T14:20:51-07:00",
"upload_date": "2022-10-19T14:20:51-07:00",
"channel_id": "UCwK9pBa_8dRMhPVyveEzyug",
"channel_name": "Tanner Colson",
"channel_handle": "http://www.youtube.com/@tannercolsoncoffee",
"channel_url": "https://www.youtube.com/channel/UCwK9pBa_8dRMhPVyveEzyug",
"thumbnail": "https://i.ytimg.com/vi/NShimEnXxNg/hq720_2.jpg?sqp=-oaymwE2CK4FEIIDSEbyq4qpAygIARUAAIhCGABwAcABBvABAfgBzgWAAoAKigIMCAAQARhyIEcoNDAP&rs=AOn4CLB2Zlu_oWyVsjYxawdxQBNOT8owqw",
"thumbnails": [
{
"url": "https://i.ytimg.com/vi/NShimEnXxNg/2.jpg?sqp=-oaymwEmCHgQWvKriqkDHBgA8AEB-AHOBYACgAqKAgwIABABGHIgRyg0MA8=&rs=AOn4CLBaKBInBKm2-fW7ADFRe3gdr_tsCw",
"width": 120,
"height": 90
}
],
"related": [
{
"position": 1,
"id": "qFl0k5e_Tio",
"title": "Watery or Bitter Espresso? Dial In Your Barista Express Like This",
"url": "https://www.youtube.com/watch?v=qFl0k5e_Tio",
"thumbnail": "https://i.ytimg.com/vi/qFl0k5e_Tio/hq720.jpg?sqp=-oaymwEcCK4FEIIDSEbyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBeXbys5kr3Ld8WRH-1mNHmoxcGeA",
"channel": "Golden Brown Coffee",
"views": "1.1M views",
"published": null
}
],
"related_count": 12,
"is_short": true
}view_count is the field most people come for, and it is the one place you get an exact integer instead of the "25M views" string the page shows. view_count_text carries whatever the page gave, which on this Short is the same digits.
Notes on the rest, each measured across the 12 Shorts sampled on 2026-07-20:
is_shortis alwaystruehere, because the endpoint sets it, not because the video was inspected. Readduration_secondsto tell a real Short (10 to 60s across the sample) from a long video someone passed to this endpoint.comment_countwasnullon all 12. YouTube loads the count over a separate request after the initial HTML, so it is not on the page the parser reads.keywordsanddescriptioncame back empty on both Shorts captured in full (NShimEnXxNghere andwuPagqHCeRo), which is why both come back[]and""rather than absent. Both are uploader-controlled, so other Shorts can carry them.related[]returns 11 to 12 rows, is the watch-next column rather than other Shorts, and is regenerated per request. Rowviewsis a display string ("1.1M views") and rowpublishedwasnullon every related row captured.channel_handleis a URL, not a handle (http://www.youtube.com/@tannercolsoncoffee), passed through frommicroformat.ownerProfileUrl.publish_dateandupload_datewere identical on both Shorts captured in full. They come from different microformat keys and can diverge on re-uploaded videos.thumbnailsis 5 sizes, smallest first, from 120x90 up.thumbnailon its own is the single largest preview URL.
A second committed sample, shorts_by_id.json, is a different Short fetched with video_id instead of url, so both parameter forms are demonstrated:
{
"video_id": "wuPagqHCeRo",
"title": "How to make ESPRESSO (4 steps)",
"view_count": 13623196,
"like_count": 252490,
"duration_seconds": 10,
"category": "Comedy",
"publish_date": "2025-05-25T08:30:35-07:00",
"channel_name": "Lionfield",
"is_short": true
}(9 of the 27 fields shown, verbatim.)
Runnable: youtube_shorts_scraper_api_codes/shorts.py
The job most people arrive with: a column of Shorts links from a brief or a competitor sweep, and a need for one row per Short with the numbers filled in.
export CHOCODATA_API_KEY="your_key"
python youtube_shorts_scraper_api_codes/enrich_url_list.pyIt reads urls.txt if present, one URL or id per line, and otherwise runs the four sample Shorts. 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:
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_shorts_scraper_api_codes/enrich_url_list.py
Ten consecutive calls to /youtube/shorts for the same Short id, ~3 seconds apart, on 2026-07-20. All ten returned 200 and are timed below:
| Metric | Value |
|---|---|
| calls attempted | 10 |
200 responses |
10 |
| min | 1,864 ms |
| median | 3,808 ms |
| max | 6,381 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. Every measurement is committed in latency.json.
MIT. See LICENSE.







