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.
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.
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": "[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": {"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": "[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": "[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": {
"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": {"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": {
"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": {
"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": {"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 '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": {"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.
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 NoneTwo 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 --embeddedWith 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.
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),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
export SCRAPINGBEE_API_KEY=...
pip install requests
python examples/netflix_catalog.py --top10 movies --top10 tv \
--csv titles.csv --episodes episodes.csv --jsonl titles.jsonlfailed 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."
- 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 carriedisAvailable: false; the flag is stored asstreamable_here. - A show flagged not streamable loses its episodes. From, on the call where it was flagged, came back with
seasons.edgesempty andtotalCount: 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 andisAvailable: falsewith 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. Treatstreamable_hereas 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.
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.
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.
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.
MIT. See LICENSE.