Skip to content

Repository files navigation

YouTube Shorts Scraper

YouTube Shorts Scraper

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.py

Those 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:

Retrieved YouTube Shorts 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 Shorts Scraper

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:

Free YouTube Shorts scraper parsing a real watch page

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.

Avoid getting blocked when scraping YouTube Shorts

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:

Free YouTube Shorts scraper vs Chocodata API, measured

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.


Using the Chocodata YouTube Shorts Scraper API

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.


YouTube Shorts Scraper API reference

Below is the YouTube Shorts 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/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 True

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

Running the YouTube Shorts 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/shorts?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 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:

YouTube Shorts Scraper API error handling

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.

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 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]

Shorts: titles, view counts, likes, durations and related videos

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_short is always true here, because the endpoint sets it, not because the video was inspected. Read duration_seconds to tell a real Short (10 to 60s across the sample) from a long video someone passed to this endpoint.
  • comment_count was null on all 12. YouTube loads the count over a separate request after the initial HTML, so it is not on the page the parser reads.
  • keywords and description came back empty on both Shorts captured in full (NShimEnXxNg here and wuPagqHCeRo), 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. Row views is a display string ("1.1M views") and row published was null on every related row captured.
  • channel_handle is a URL, not a handle (http://www.youtube.com/@tannercolsoncoffee), passed through from microformat.ownerProfileUrl.
  • publish_date and upload_date were identical on both Shorts captured in full. They come from different microformat keys and can diverge on re-uploaded videos.
  • thumbnails is 5 sizes, smallest first, from 120x90 up. thumbnail on 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


Enrich a list of YouTube Shorts URLs with titles, views and likes

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.py

It 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:

Enriching a list of YouTube Shorts 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_shorts_scraper_api_codes/enrich_url_list.py


Measured latency

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:

Measured latency for the YouTube Shorts endpoint

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.


License

MIT. See LICENSE.