Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Netflix API

netflix-api

checks license

This repo is a field by field manual for one page type, netflix.com/title/<id>, written for the Netflix API on ScrapingBee. Each section gives one extract_rules entry, the real markup it targets and what it returned. The second half turns those rules into a catalog dataset: titles, genres with ids, moods, cast, audio and subtitle languages, seasons and every episode, joined to Netflix's weekly Top 10 views.

All of it runs on the logged out title pages Netflix serves to anyone, at 1 credit per title. No account, no player, no video. Captured on 2026-10-29 with country_code=us.

Getting the page for 1 credit

mode=auto on /title/80057281 settled on 5 credits (JavaScript rendering) and the reply came from netflix.com/br-en/title/80057281, because the proxy exit was in Brazil and Netflix answered with a 301. Two changes fixed both:

Parameters on /title/80057281 spb-cost Resolved URL Markup present
mode=auto 5 /br-en/title/80057281 yes, plus JSON-LD
render_js=false 1 /hr-en/title/80057281 yes
render_js=false, country_code=us 1 /title/80057281 yes
render_js=true, country_code=us, on /title/81757047 5 /title/81757047 yes, plus JSON-LD
render_js=false, country_code=us, on /title/82676945 1 /title/82676945 404, still billed

The title page is server rendered. JavaScript adds one thing, a JSON-LD block, and the next sections show why you do not need it.

country_code matters beyond the price. Netflix's catalog differs by country, the page language follows the exit, and a title that is not offered where your request lands can come back as a 404 (which costs the credit) or as a page flagged not streamable.

The rule set

examples/netflix_rules.json holds every rule below in one object, so a single request returns them all. Netflix's class names, such as default-ltr-iqcdef-cache-1xty6x8, come out of a CSS-in-JS build and are not something to anchor on. Every rule here hangs off a data-uia attribute, a tag name or a form field name.

To test a rule before relying on it, examples/rules_probe.py sends just the named rules:

python examples/rules_probe.py 80057281 facts details season_names

title

"title": "[data-uia=\"metadata\"] h2"

The name appears twice. An <h1> sits in a <span> just before <main>; the rule takes the <h2> in the metadata block instead, because it sits next to the facts list that follows:

<div data-uia="metadata" class="default-ltr-iqcdef-cache-ppqefh er6ovcj0"><div class="..."><h2 class="hcw-ltr-iqcdef-cache-mdgd9q e8zmz550">Stranger Things</h2>

facts

"facts": {"selector": "[data-uia=\"metadata\"] ul li", "type": "list"}

The same block continues with an unlabelled list:

<ul class="default-ltr-iqcdef-cache-1xty6x8 epf12f61"><li class="...">2025</li><li class="...">5 Seasons</li><li class="...">⁨TV-14⁩</li><li class="...">Sci-Fi</li></ul>

Returned for a show and for a film:

["2025", "5 Seasons", "\u2068TV-14\u2069", "Sci-Fi"]
["2026", "\u2068R\u2069", "Thriller"]

Two things to handle. The rating is wrapped in Unicode isolate characters, U+2068 and U+2069, invisible on screen and fatal to an equality check, so strip them. And the list has no labels and no fixed length: shows have a season count, films do not, and in our captures films did not show a running time here at all. Do not read it by index. The embedded JSON below has the same values in named fields.

synopsis

"synopsis": "[data-uia=\"title-info-synopsis-talent\"] > span"
<div data-uia="title-info-synopsis-talent" class="default-ltr-iqcdef-cache-1akt4ld e1bri7zl1"><span class="hcw-ltr-iqcdef-cache-ztmha3 e8zmz550">When a young boy vanishes, a small town uncovers a mystery involving secret experiments, terrifying supernatural forces and one strange little girl.</span>

The > keeps the rule off the starring and creator spans nested further down the same div.

starring and creators

"starring": "[data-uia=\"info-starring\"] span + span",
"creators": "[data-uia=\"info-creators\"] span + span"
<div data-uia="info-starring" class="default-ltr-iqcdef-cache-eywhmi e16alc8a2"><span class="hcw-ltr-iqcdef-cache-1nq5aku e8zmz550">Starring:</span> <span class="hcw-ltr-iqcdef-cache-k6t7e8 e8zmz550">Winona Ryder, David Harbour, Millie Bobby Brown</span></div>

Each line is a label span and a value span. span + span takes the value. Starring is the top three names only; the full cast is in details. A film has no creators line, so the rule returns "" for /title/81757047.

details

"details": {
  "selector": "[data-uia=\"more-details\"] div:has(> h4)",
  "type": "list",
  "output": {"label": "h4", "value": "h4 + span"}
}

The More Details panel, with classes removed so the shape is visible:

<div><h4>Genres</h4><span>TV Dramas, Sci-Fi Shows, Teen TV Shows, TV Horror</span></div>
<div><h4>This show is ...</h4><span>Ominous, Nostalgic, Horror, Notable Soundtrack, Psychic Power, 1980s, Golden Globe Nominee, Scary, Conspiracy, Teen, Sci-Fi, TV</span></div>
<div><h4>About Stranger Things</h4><p>Go behind the scenes and learn more on <a color="ForegroundSubtle" href="https://tudum.com/stranger-things">Tudum.com</a></p></div>
<div><h4>Audio</h4><span>German - Audio Description, German, English - Audio Description, English [Original], ...</span></div>

The first version of this rule was two flat lists, one for h4 and one for h4 + span, zipped afterwards. It returned 7 labels and 6 values, because the "About" entry has a <p>, not a <span>, and every value after it shifted by one. Selecting each pair's parent with :has(> h4) keeps label and value together, and the "About" row simply comes back with an empty value:

[
  {"label": "Watch offline", "value": "Available to download"},
  {"label": "Genres", "value": "TV Dramas, Sci-Fi Shows, Teen TV Shows, TV Horror"},
  {"label": "This show is ...", "value": "Ominous, Nostalgic, Horror, Notable Soundtrack, ..."},
  {"label": "About Stranger Things", "value": ""},
  {"label": "Audio", "value": "German - Audio Description, German, English - Audio Description, ..."},
  {"label": "Subtitles", "value": "English, Spanish (Latin America), French, Chinese (Simplified), Chinese (Traditional)"},
  {"label": "Cast", "value": "Winona Ryder, David Harbour, Millie Bobby Brown, Finn Wolfhard, ..."}
]

ScrapingBee's extractor accepted :has() without complaint.

season_ids and season_names

"season_ids": {"selector": "select[name=\"seasonSelect\"] option", "type": "list", "output": "@value"},
"season_names": {"selector": "select[name=\"seasonSelect\"] option", "type": "list"}
<select aria-disabled="true" autoComplete="off" id=":R8qlqdd8m:" name="seasonSelect" data-hcw-form-control-element="true"><option value="80077209" selected="">Stranger Things</option><option value="80117715">Stranger Things 2</option><option value="80186799">Stranger Things 3</option><option value="80229865">Stranger Things 4</option><option value="81297917">Stranger Things 5</option></select>

The value is each season's own video id. Note aria-disabled="true": logged out, the selector does not switch seasons. Two lists rather than one list of objects, because an output object addresses children of the matched element and an <option> has none. Our first try, {"id": "@value", "name": "option"}, was rejected with Expected selector, got <EOF at 0>, returned as a 500, and not billed.

episodes

"episodes": {
  "selector": "[data-uia=\"episode-card\"]",
  "type": "list",
  "output": {
    "title": "[data-uia=\"episode-title\"]",
    "runtime": "img + span",
    "synopsis": "[data-uia=\"episode-title\"] + div p"
  }
}
<li data-uia="episode-card"><div><img aria-hidden="true" src="https://occ-0-37-3996.1.nflxso.net/dnm/api/v6/..." alt=""/><span><span><span>49m</span></span></span></div><div><p data-uia="episode-title">1. Chapter One: The Vanishing of Will Byers</p><div><p>On his way home from a friend's house, young Will sees something terrifying. Nearby, a sinister secret lurks in the depths of a government lab.</p></div></div></li>

This is where the HTML stops being enough. Stranger Things has five seasons and 42 episodes, and the page renders 8 cards, all from season 1. The other 34 episodes are not in the markup at all. They are in the embedded JSON.

trailers

"trailers": {
  "selector": "[data-uia=\"video-card-container\"]",
  "type": "list",
  "output": {
    "kind": "[data-uia=\"playable-type-heading\"]",
    "title": "[data-uia=\"playable-title\"]",
    "runtime": "[data-uia=\"playable-runtime\"]"
  }
}
<button data-uia="video-card-container" class="default-ltr-iqcdef-cache-1h6dp90 epccd7w7">...<p data-uia="playable-runtime" class="...">2m 15s</p>...<p data-uia="playable-type-heading" class="..."><span class="...">TRAILER</span></p><div class="..."><p data-uia="playable-title" class="...">Franchise Trailer: Stranger Things</p></div></button>

Titles and lengths only. kind was TRAILER or TEASER on trailers and empty on clips and featurettes. The embedded JSON also holds a signed .mp4 URL for each of these, which this repo does not read.

similar_ids and similar_names

"similar_ids": {"selector": "[data-uia=\"more-like-this\"] a[data-uia^=\"titleId-\"]", "type": "list", "output": "@data-uia"},
"similar_names": {"selector": "[data-uia=\"more-like-this\"] a[data-uia^=\"titleId-\"]", "type": "list", "output": "@aria-label"}
<a href="https://www.netflix.com/title/81398721" aria-disabled="false" tabindex="0" data-uia="titleId-81398721" aria-label="Go to Stranger Things: Tales From &#x27;85" class="...">

data-uia carries the id, aria-label the name with a "Go to " prefix. The section header read "You Might Also Like" and held 9 links for both test titles. These ids are the cheapest way to grow a crawl frontier.

inline_scripts, the rule that does most of the work

"inline_scripts": {"selector": "script:not([src])", "type": "list", "output": "html"}

output: "html" returns the element's markup instead of its text, which is how a JSON blob inside a <script> comes back through extract_rules. On Gladiator II the rule returned 8 inline scripts, one of them 189,562 characters long:

<script>window.netflix = window.netflix || {}; netflix.reactContext = {"title":"Netflix","clPageName":"nonmemberTitle", ...

That is the whole page state, in the same 1 credit reply as the visible fields.

Embedded JSON

netflix.reactContext is a JavaScript object literal, not JSON. It writes characters as \x2F and \x40, which json.loads rejects (Invalid \escape: line 1 column 330). One substitution fixes it:

def react_context(scripts):
    for script in scripts or []:
        m = re.search(r"netflix\.reactContext = (\{.*?\});\s*</script>", script, re.S)
        if m:
            text = re.sub(r"\\x([0-9A-Fa-f]{2})", r"\\u00\1", m.group(1))
            return json.loads(text)
    return None

Two paths inside it carry title data.

models.nmTitleGQL.data, short and stable:

{
  "copy": {"title": "Animals", "synopsis": "When their son is kidnapped, an LA mayoral candidate and his wife scramble to raise the ransom while making choices that could tear their world apart."},
  "genreInfo": {"coreGenre": {"name": [{"name": "Thrillers"}, {"name": "Dramas"}]}},
  "metaData": {"isIndexable": true, "isAvailable": true, "isOriginal": true, "maturityDetails": {"value": 100},
               "seasonCount": false, "topLevelVideoId": 81757047, "type": "Movie", "videoId": 81757047},
  "persons": {"actors": [{"name": "Ben Affleck"}, {"name": "Kerry Washington"}, "..."], "creators": [], "directors": [{"name": "Ben Affleck"}]}
}

models.graphqlClcs.data[<query>].clcsHookV2.result.screen.componentTree.nodes[0].data.videos[0], long and complete. The key of graphqlClcs.data is the serialised query itself, a string that starts {"query":"clcsHookV2","variables":{"locale":"en-US","flowName":"loggedOutDetailPage",..., so take the first value instead of naming it. Trimmed:

{
  "__typename": "Movie",
  "title": "Animals",
  "videoId": 81757047,
  "contentAdvisory": {"certificationValue": "R"},
  "latestYear": 2026,
  "availabilityStartTime": "2026-10-09T07:00:00.000Z",
  "isAvailable": true,
  "isAvailableForDownload": true,
  "genres": {"edges": [
    {"node": {"genreId": 5763, "title": "Drama Movies"}},
    {"node": {"genreId": 8933, "title": "Thriller Movies"}},
    {"node": {"genreId": 3947, "title": "Social Issue Dramas"}}]},
  "tags": [{"displayName": "Dark", "id": 100037, "isDisplayable": true}, {"displayName": "Suspenseful", "id": 100055, "isDisplayable": true}],
  "actors": {"edges": [{"node": {"name": "Ben Affleck", "personId": 20000016}}, {"node": {"name": "Kerry Washington", "personId": 20009926}}]},
  "mediaTracks": {"audioTracks": [{"language": "German - Audio Description"}, "..."], "subtitles": [{"language": "English"}, "..."]},
  "similarVideos": [{"__typename": "Show", "videoId": 81662954, "title": "HIS & HERS"}, {"__typename": "Movie", "videoId": 81449034, "title": "Nowhere"}],
  "tudumTitle": {"slug": "/animals"}
}

Genre ids, mood tag ids, person ids and typed similar titles: none of these are in the visible markup. For a show the same object adds numSeasonsLabel and seasons, and seasons is complete:

{"numberLabelV2": "Season 1", "title": "Stranger Things", "totalDisplayRuntimeInSec": 24636, "videoId": 80077209,
 "episodes": {"edges": [{"node": {"videoId": 80077368, "number": 1, "runtimeSec": 2976,
   "title": "Chapter One: The Vanishing of Will Byers",
   "shortSynopsis": "On his way home from a friend's house, young Will sees something terrifying. ..."}}]}}

All 5 seasons, 8, 9, 8, 9 and 8 episodes, every pageInfo.hasNextPage false. Note runtimeSec: 2976 against the card's 49m: the page truncates, so keep seconds.

The same clcsHookV2 node also contains the signup plans grid with prices and the signed trailer URLs. Neither is catalog metadata and the parser skips both.

To map these paths on any title without reading the dump yourself:

python examples/rules_probe.py 81757047 --embedded

JSON-LD, only after rendering

With render_js=true (5 credits) a <script type="application/ld+json" data-rh="true"> appears, injected by the client. It was absent from every render_js=false capture. From /title/81757047, trimmed:

{
  "@context": "http://schema.org",
  "@type": "Movie",
  "url": "https://www.netflix.com/title/81757047",
  "contentRating": "R",
  "name": "Animals",
  "genre": "Thrillers",
  "dateCreated": "2026-10-9",
  "trailer": {"@type": "VideoObject", "name": "Teaser: Animals", "duration": "PT1M4S", "uploadDate": "2026-08-12T13:00:00.000Z"},
  "actors": [{"@type": "Person", "name": "Ben Affleck"}, {"@type": "Person", "name": "Kerry Washington"}],
  "directors": [{"@type": "Person", "name": "Ben Affleck"}],
  "creators": []
}

A show's block (Stranger Things, from the Brazilian capture) is @type: "TVSeries" with numberOfSeasons: 5 and startDate: "2025-1-1". Everything in it is also in reactContext with more precision (dateCreated is not zero padded, genre is a single string), so JSON-LD is not worth five times the price here.

Building a catalog dataset

examples/netflix_catalog.py puts the rules and the decoder together. One request per title:

def _extract(self, url, rules):
    params = {
        "url": url,
        "render_js": "false",
        "country_code": self.country,
        "extract_rules": json.dumps(rules),
    }
    for attempt in range(self.retries):
        r = self.s.get(API, params=params, timeout=120)
        if r.status_code != 500:
            break
        time.sleep(2 * (attempt + 1))
    r.raise_for_status()
    ...

A 500 from ScrapingBee is not billed, so it is retried. A 404 is billed and not retried, since it is Netflix's answer, not a transport failure.

Each title becomes a Title dataclass, preferring embedded JSON and falling back to the visible markup when a key is missing:

genres=_edges(video.get("genres"), "title") or split(details.get("Genres")),
genre_ids=_edges(video.get("genres"), "genreId"),
moods=[t["displayName"] for t in video.get("tags", []) if t.get("isDisplayable")]
or split(details.get("This show is ...") or details.get("This movie is ...")),
cast=split(details.get("Cast")) or [p["name"] for p in (gql.get("persons") or {}).get("fullCast", [])],
directors=_edges(video.get("directors")) or [p["name"] for p in (gql.get("persons") or {}).get("directors", [])],
...
streamable_here=video.get("isAvailable", meta.get("isAvailable")),
has_embedded=bool(video),

Seeding from the Netflix Top 10

Title ids have to come from somewhere. Netflix's Tudum Top 10 page (/tudum/top10 for films, /tudum/top10/tv for shows) is server rendered and costs 1 credit. The table has views and hours but no ids:

<td data-uia="top10-table-row-title"><span>01</span><img src="https://dnm.nflximg.net/api/v6/..." alt="" aria-hidden="true"/><button>Animals</button></td><td data-uia="top10-table-row-weeks">3</td><td data-uia="top10-table-row-views">10,700,000</td><td data-uia="top10-table-row-runtime">1:48</td><td data-uia="top10-table-row-hours">19,300,000</td>

The ids are on the artwork cards, in rank order:

<li data-entity-id="top10-4hB3vzDf6ZuX1ToDt7bAL9-81757047"><div data-uia="top10-card" data-sel="top10-card" data-id="top10-4hB3vzDf6ZuX1ToDt7bAL9-81757047" ...>

We checked the pairing by the logo alt on each card: card 1 is Animals, card 2 is Hot Wheels Let's Race, card 3 is Armored, matching the table. The Top 10 table also gives a film's runtime (1:48 for Animals), which that title page's facts list did not show.

examples/top10.sh does the seeding step with cURL alone:

$ ./examples/top10.sh
Movies | English, week 10/19/26 - 10/25/26
01   81757047   10,700,000 views   3 wk   1:48  Animals
02   82063750    5,900,000 views   1 wk   0:47  Hot Wheels Let's Race: Attack of the T-Rex
03   70113008    4,200,000 views   1 wk   1:28  Armored
...
07   82676945    3,000,000 views   1 wk   1:29  Primate
...
10   60022702    2,800,000 views   3 wk   1:23  Ice Age

A full run

export SCRAPINGBEE_API_KEY=...
pip install requests
python examples/netflix_catalog.py --top10 movies --top10 tv \
    --csv titles.csv --episodes episodes.csv --jsonl titles.jsonl
failed 82676945: HTTP 404
81757047  Movie Animals (2026, R), 3 genres, 8 cast  top10 #1 10,700,000 views
82063750  Movie Hot Wheels Let's Race: Attack of the T-Rex (2026, TV-Y7), 3 genres, 0 cast  top10 #2 5,900,000 views
70113008  Movie Armored (2009, PG-13), not streamable in US, 1 genres, 10 cast  top10 #3 4,200,000 views
80096067  Movie Ice Age 5: Collision Course (2016, PG), not streamable in US, 3 genres, 10 cast  top10 #4 3,500,000 views
70107121  Movie Ice Age: Dawn of the Dinosaurs (2009, PG), 3 genres, 8 cast  top10 #5 3,300,000 views
81564308  Movie Blackout (2022, TV-MA), not streamable in US, 1 genres, 7 cast  top10 #6 3,200,000 views
82023496  Movie Doing Life (2026, TV-MA), 3 genres, 10 cast  top10 #8 3,000,000 views
81902148  Movie Gladiator II (2024, R), not streamable in US, 3 genres, 8 cast  top10 #9 3,000,000 views
60022702  Movie Ice Age (2002, PG), not streamable in US, 3 genres, 6 cast  top10 #10 2,800,000 views
81652678  Show  Below (2026, TV-MA), 1 seasons / 6 episodes, 3 genres, 9 cast  top10 #1 7,700,000 views
82052145  Show  Nobody Wants This (2026, TV-MA), 3 seasons / 30 episodes, 2 genres, 9 cast  top10 #2 6,200,000 views
81958126  Show  The Diplomat (2026, TV-MA), 4 seasons / 30 episodes, 3 genres, 10 cast  top10 #3 5,500,000 views
81780624  Show  TYSON (2026, TV-MA), 1 seasons / 4 episodes, 4 genres, 1 cast  top10 #4 3,800,000 views
81922540  Show  Love Is Blind (2026, TV-MA), 0 seasons / 0 episodes, not streamable in US, 3 genres, 2 cast  top10 #5 3,700,000 views
81611855  Show  East of Eden (2026, TV-MA), 1 seasons / 7 episodes, 4 genres, 9 cast  top10 #6 3,600,000 views
81446289  Show  From (2024, TV-MA), 0 seasons / 0 episodes, not streamable in US, 2 genres, 10 cast  top10 #7 2,900,000 views
81930790  Show  The New Stanford Prison Experiment (2026, TV-MA), 0 seasons / 0 episodes, not streamable in US, 1 genres, 0 cast  top10 #8 2,600,000 views
82095363  Show  Raw (2026, TV-PG), 0 seasons / 0 episodes, not streamable in US, 3 genres, 0 cast  top10 #9 2,500,000 views
83100912  Show  Spike (2024, TV-Y), 1 seasons / 9 episodes, 2 genres, 0 cast  top10 #10 1,800,000 views
{"titles": 19, "failed": 1, "credits": 22}

22 credits: 2 Top 10 lists and 20 title requests, the 404 included, for 19 titles and 86 episode rows. episodes.csv starts:

title_id,season,number,episode_id,name,runtime_s,synopsis
81652678,Limited Series,1,81943170,Come Home Year,2855,Calvin Penney welcomes his family home for a long-awaited reunion as an unexpected arrival makes waves throughout the coastal town of Snooks Arm.
81652678,Limited Series,2,81943171,Crown and Anchor,2222,"Calvin teams up with marine researcher Dr. Fonda Howander to investigate strange events, while his son Wade tries to make sense of unsettling dreams."

What the run told us about the Netflix dataset

  • The Top 10 is global, the catalog is not. The films list's country selector read "Global". Primate (82676945) is number 7 on it and returned a 404 from a US exit. Other titles loaded but carried isAvailable: false; the flag is stored as streamable_here.
  • A show flagged not streamable loses its episodes. From, on the call where it was flagged, came back with seasons.edges empty and totalCount: 0; Love Is Blind and Raw looked the same in both runs.
  • The same title answers differently on different calls. We ran the full job twice, minutes apart, both with country_code=us. From (81446289) came back with 3 seasons and 30 episodes in the first run and isAvailable: false with no seasons in the second. Gladiator II flipped to not streamable, Ice Age: Dawn of the Dinosaurs flipped the other way, and the episode total went from 116 rows to 86. Treat streamable_here as an observation from one request, and refetch any show that returns zero seasons before trusting it.
  • Some titles have no cast at all. Hot Wheels Let's Race, Raw, Spike and The New Stanford Prison Experiment came back with no cast in either the markup or the JSON.

Scope

Public title pages and the public Top 10 only. The code never signs in, never opens /watch/ links, never fetches video or images, and ignores the plans and prices block on the page. Profiles, viewing history, ratings you gave and anything else behind a Netflix login are out of reach, and ScrapingBee's terms rule out scraping behind a login in any case. Netflix's own terms of use apply to its pages; read them before you run this at volume or republish the output.

Related ScrapingBee pages

Reference: extract_rules syntax, data extraction feature, AI extraction for when a selector is not worth maintaining, country_code and geotargeting, pricing (1,000 free credits to start).

Film and video data elsewhere: IMDb scraper API, movie API, YouTube movie results API, YouTube video scraper API, Twitch API.

FAQ

How do I scrape Netflix titles without an account? Request netflix.com/title/<id> through ScrapingBee with render_js=false and a country_code. The logged out page carries name, year, rating, synopsis, genres, cast, languages and the full episode list, for 1 credit.

Where can I get a Netflix dataset of genres with their ids? From reactContext: every title's genres.edges[].node has genreId and title, for example 5763 Drama Movies. Run the catalog script over a list of ids and the genre_ids column builds the mapping as you go.

Why does my Netflix scraper only find the first season's episodes? Because the page only renders season 1's cards and the season selector is disabled when logged out. Read seasons.edges in the embedded JSON instead; it held all 42 Stranger Things episodes.

Does the Netflix scraper need JavaScript rendering? No. Rendering adds a JSON-LD block that repeats data already in the page at a fifth of the cost.

Why did a title id from the Top 10 return 404? The Top 10 is global and your request has a country. A title missing from that country's catalog returned 404 (billed) or loaded with isAvailable: false. Try another country_code.

License

MIT. See LICENSE.