Purpose
Given an IMDb title URL, IMDb title ID (tt...), or free-form title reference (movie / TV show / TV episode / mini-series / short / documentary), return the current IMDb rating, total vote count, rating distribution (votes per 1-10 bucket when shown), and the core title metadata: primary title, original title (when different), title type (movie / tvSeries / tvEpisode / tvMiniSeries / short / documentary / videoGame), release year (or year range for series), MPAA / TV certification, runtime in minutes, genres, Metascore (when present), top-billed cast with role names, directors, writers, primary poster URL, short + long plot summary, language(s), country/countries of origin, and the canonical IMDb URL. For TV episodes additionally return parent series ID + title and season/episode numbers. Read-only — never click Rate, Add to Watchlist, Sign In, or any mutation control.
When to Use
- "What's the IMDb rating of {movie/show}?"
- Bulk enrichment of a watchlist / spreadsheet of titles — pass a free-form name or a known
tt-ID per row. - Comparing the user-rating + Metascore + distribution shape across a candidate set.
- Resolving an ambiguous free-form title to a canonical
tt-ID before scraping any other IMDb subpage. - Pulling the JSON-LD
aggregateRatingfor any IMDb title type, including TV episodes (/title/tt.../episodes/).
Workflow
The optimal flow is two-staged:
- Resolve free-form input →
tt-ID via IMDb's public-but-undocumented suggestion API (no auth, no anti-bot, no proxy). This is the same JSON the IMDb search-bar typeahead uses. Always use this first unless the caller already passed att-ID or a/title/tt.../URL. - Fetch the canonical title page
https://www.imdb.com/title/{ttId}/and extract from its static HTML — primarily the<script type="application/ld+json">block and the<script id="__NEXT_DATA__" type="application/json">blob. The title page is protected by AWS WAF (AwsWafIntegration token challenge) which returns a 202 with a ~2 KB JS-challenge body to any raw HTTP client (including abrowserless_functionin-pagefetch, even through a residential proxy). Drive it withbrowserless_agentandproxy: { proxy: "residential" }. The WAF clears automatically when JS executes in the real browser.
1. Resolve free-form input → tt-ID (skip if you already have the ID)
The suggestion API is rooted at https://v3.sg.media-imdb.com/suggestion/{firstChar}/{slug}.json. The {firstChar} path component is ignored server-side — any of h, t, or the actual first character of {slug} returns the same response. Build {slug} from the user query by replacing spaces with _ and lowercasing:
SLUG=$(echo "$query" | tr '[:upper:]' '[:lower:]' | tr ' ' '_' | sed 's/[^a-z0-9_]//g')
# v3.sg.media-imdb.com is WAF-exempt JSON — a plain HTTPS GET from any client works:
GET "https://v3.sg.media-imdb.com/suggestion/t/${SLUG}.json"The suggestion host is WAF-exempt, so this is a plain HTTPS GET (any client, no proxy, no browser). Under restricted egress, route it via browserless_function that page.goto('https://v3.sg.media-imdb.com/') then page.evaluates a same-origin fetch of the /suggestion/...json path.
Response shape — d[] is an ordered list of matches:
{"d":[
{"id":"tt0111161","l":"The Shawshank Redemption","q":"feature","qid":"movie",
"rank":78,"s":"Tim Robbins, Morgan Freeman","y":1994,
"i":{"imageUrl":"https://m.media-amazon.com/...","height":1800,"width":1200}},
...
]}Key fields:
id— thett-prefixed title ID. This is your handoff to step 2.l— title.q— human-readable type ("feature","TV series","TV mini-series","TV episode","TV short","short","TV movie","video","podcastSeries","videoGame").qid— machine type (movie,tvSeries,tvMiniSeries,tvEpisode,tvShort,short,tvMovie,video,podcastSeries,videoGame).y— year (a single integer). Series additionally carryyras a"YYYY-YYYY"range string (open-ended ongoing series have"YYYY-").rank— IMDb popularity rank (lower = more popular). Do not confuse with the user rating —rankis MOVIEmeter-style popularity, NOT the 0.0-10.0 user score. The user rating is not exposed via the suggestion API at all.s— short top-cast string (comma-separated names, no roles).i— poster image URL + native dimensions.
Disambiguation heuristics (run in order until a single best match is left):
- If the input includes a 4-digit year (e.g.
"the matrix 1999"), filterd[]to entries wherey === year. - If the input includes a type hint (
"TV","series","movie","episode","documentary"), filterd[]by matchingqid. - If multiple candidates remain, pick the lowest
rank(most popular). Ifrankis missing on a candidate, treat asInfinity. - If the top two candidates have very close
rankvalues (within 10× of each other) and the query is ambiguous, emit asuccess: false, reason: "ambiguous_name"result with the top 3-5 candidates rather than guessing.
For TV episodes: the suggestion API surfaces well-known episodes (e.g. "breaking bad ozymandias" → tt2301451) but tends to under-rank lesser-known episode pages. If the query says "season N finale" / "S5E14" / etc. and the suggestion API returns the parent series instead of the episode, fall back to resolving the series first, then navigating to /title/{seriesId}/episodes/?season={N} and reading the episode-list page (or jumping to /title/{episodeId}/).
2. Fetch the title page and extract the rating + metadata
Drive the title page in one browserless_agent call with proxy: { proxy: "residential" }. Batch the whole nav → settle → extract flow inside that single call's commands array — it saves round-trips and avoids accidentally dropping the proxy between calls (there is no separate session-release step):
{
"proxy": { "proxy": "residential" },
"commands": [
{
"method": "goto",
"params": {
"url": "https://www.imdb.com/title/${ttId}/",
"waitUntil": "load",
"timeout": 45000
}
},
{ "method": "waitForTimeout", "params": { "time": 1500 } },
{
"method": "evaluate",
"params": {
"content": "(()=>{const ld=document.querySelector('script[type=\"application/ld+json\"]')?.textContent||null;const nd=document.getElementById('__NEXT_DATA__')?.textContent||null;return JSON.stringify({ld,nd});})()"
}
}
]
}waitUntil: "load"ongoto(nevernetworkidle— it hangs on IMDb's SPA); thewaitForTimeout 1500lets lazy hydration settle.- Extracting the two
<script>payloads with an in-pageevaluate(above) is preferred over shipping raw HTML — it stays well under the result-size cap. If you'd rather parse HTML host-side, use{ "method": "html", "params": { "selector": "body" } }instead and run the regexes below on the returned string. - A residential proxy is required — without it the AWS WAF challenge stalls (a raw HTTP fetch just gets the 202 challenge) and IMDb IP-rate-limits a bare session within a few requests. Driving the page in a real browser (
browserless_agent) with a residential proxy clears the WAF automatically (JS executes) and renders the title page normally (no captcha, no login wall).
2a. Extract <script type="application/ld+json">
The first application/ld+json block on every IMDb title page is a schema.org Movie / TVSeries / TVEpisode object that contains everything you need for the headline rating + most metadata:
{
"@context": "https://schema.org",
"@type": "Movie",
"url": "https://www.imdb.com/title/tt0111161/",
"name": "The Shawshank Redemption",
"alternateName": "Cadena perpetua",
"image": "https://m.media-amazon.com/images/M/MV5B...jpg",
"datePublished": "1994-10-14",
"contentRating": "R",
"duration": "PT2H22M",
"genre": ["Drama"],
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": 9.3,
"ratingCount": 3050000,
"bestRating": 10,
"worstRating": 1
},
"actor": [{"@type":"Person","url":"...","name":"Tim Robbins"}, ...],
"director":[{"@type":"Person","url":"...","name":"Frank Darabont"}],
"creator": [{"@type":"Organization","url":"..."}, {"@type":"Person","url":"...","name":"Stephen King"}],
"description": "Over the course of several years, two convicts form a friendship..."
}Parse it with a hardened regex (NOT JSON.parse on raw HTML; the block may contain HTML-entity-escaped characters in description):
const m = html.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/);
const ld = JSON.parse(m[1]);Field mapping (ld → output JSON):
| Output field | LD-JSON source |
|---|---|
titleId | parse from ld.url (/title/(tt\d+)/) |
title | ld.name |
originalTitle | ld.alternateName if present and !== ld.name, else null |
titleType | derive from ld["@type"] (Movie → movie, TVSeries → tvSeries, TVEpisode → tvEpisode, TVMiniSeries → tvMiniSeries, Short → short, VideoGame → videoGame); fall back to __NEXT_DATA__ (see 2b) when @type is generic. |
year | year part of ld.datePublished (or __NEXT_DATA__.releaseYear.year for safety). |
yearRange | series only — from __NEXT_DATA__ (2b). |
certification | ld.contentRating |
runtimeMinutes | parse ISO-8601 ld.duration (PT2H22M → 142). Some shorts use PT15M; some series use PT45M as per-episode runtime. |
genres | ld.genre (string → wrap in array) |
imdbRating | ld.aggregateRating.ratingValue |
voteCount | ld.aggregateRating.ratingCount |
actors | ld.actor[].name (typically top 5; IMDb truncates here — for the full top-billed list use __NEXT_DATA__, see 2b) |
directors | ld.director[].name (object or array — normalize to array) |
writers | ld.creator[] filtered to @type === "Person" |
posterUrl | ld.image |
shortPlot | ld.description (HTML-entity-decode after parse) |
canonicalUrl | ld.url |
aggregateRating may be absent when a title has fewer than 5 user votes (unrated). Handle missing-gracefully: emit imdbRating: null, voteCount: 0 rather than throwing.
2b. Extract <script id="__NEXT_DATA__" type="application/json">
The LD-JSON block is insufficient for some required fields:
- Rating distribution per 1-10 bucket (not in LD-JSON at all).
- Metascore (not in LD-JSON).
- Full cast list (LD-JSON truncates at ~5).
- Languages (
spokenLanguages). - Countries of origin (
countriesOfOrigin). - TV-episode parent-series ID + season/episode numbers.
- TV-series year range (
endYear).
All of these live in the Next.js page-data blob:
const nm = html.match(
/<script id="__NEXT_DATA__" type="application\/json">([\s\S]*?)<\/script>/,
);
const nd = JSON.parse(nm[1]);
const title = nd.props.pageProps.mainColumnData; // root for most title fields
const above = nd.props.pageProps.aboveTheFoldData; // root for rating + summaryUseful paths inside mainColumnData / aboveTheFoldData (paths stable across iters; field names match IMDb's internal GraphQL schema):
aboveTheFoldData.ratingsSummary.aggregateRating— sameratingValue(decimal).aboveTheFoldData.ratingsSummary.voteCount— same total as LD-JSON.mainColumnData.ratingsSummary.histogram.histogramValues— rating distribution, an array of 10 objects{rating: 10, voteCount: N}from rating 10 down to rating 1. The order is descending — always sort or map byratingrather than relying on positional index.aboveTheFoldData.metacritic.metascore.score— Metascore (ornullwhen no Metascore).mainColumnData.cast.edges[]— full cast; each edge hasnode.name.nameText.text(actor name),node.characters[].name(role names),node.attributes[].text("voice", "uncredited", etc.).mainColumnData.principalCredits[]— director/writer/creator grouped by role (category.id === "director" | "writer" | "creator").mainColumnData.spokenLanguages.spokenLanguages[].text— languages.mainColumnData.countriesOfOrigin.countries[].text— countries.mainColumnData.plot.plotText.plainText— short plot (same as LD-JSONdescription).mainColumnData.outline.plotText.plainText— outline (oftennull).- For series:
mainColumnData.releaseYear.year+mainColumnData.releaseYear.endYear(endYearnullfor ongoing series). - For episodes:
mainColumnData.series.series.id(parent seriestt-ID),mainColumnData.series.series.titleText.text(parent series title),mainColumnData.series.episodeNumber.seasonNumber,mainColumnData.series.episodeNumber.episodeNumber.
For the FULL plot summary (the multi-paragraph "Storyline" block), the __NEXT_DATA__ blob carries it at mainColumnData.summaries.edges[0].node.plotText.plaintext (or null if only a synopsis exists). When you need a longer plot than ld.description, prefer this path.
3. Session lifecycle
No session-release step is needed — there is nothing to release. The session is not torn down when the call returns, though: it persists across calls, keyed by the proxy config (repeat the same proxy to reconnect to the same warmed, WAF-cleared session; dropping or changing it lands you in a different, blank session). Batching the goto → settle → extract sequence inside a single call's commands array saves round-trips and avoids accidentally dropping that config.
Browser fallback (no API shortcut needed for rating data)
There is no public API surface that returns the IMDb user-rating value. The suggestion API in step 1 is purely a name-resolver. The title-page HTML is the only path to the rating + distribution + Metascore. Don't waste cycles chasing caching.graphql.imdb.com or api.graphql.imdb.com — verified blocked / 500 to anonymous clients (see Site-Specific Gotchas).
Site-Specific Gotchas
- AWS WAF (AwsWafIntegration) on every
www.imdb.com/title/*HTML request from non-browser clients. A raw HTTP fetch — including abrowserless_functionin-pagefetch, with or without a residential proxy — returns HTTP 202 and a ~2 KB body containing anawswaf.com/challenge.jstoken-acquisition handshake, not the real page. The challenge clears only when JS executes, so the title page must be driven withbrowserless_agent(a real browser session). Verified acrosshttps://www.imdb.com/title/tt0111161/,/title/.../episodes/,/find/,/_next/data/...,/sitemap.xml,/_json/...— every WAF-protected path returns the same 1991-byte challenge. - A raw HTTP fetch is NOT a viable surface for IMDb title pages. A plain HTTPS GET (including
browserless_function's in-pagefetch) gets the 202 WAF challenge; you must drive the page withbrowserless_agent. Raw fetch is fine only for the suggestion API (v3.sg.media-imdb.com) androbots.txt— both are WAF-exempt. Allwww.imdb.compaths the future agent cares about are WAF-protected. - Use a residential proxy on the
browserless_agentcall. Setproxy: { proxy: "residential" }. Bare sessions get WAF-challenged or IP-rate-limited after a handful of requests. Driving the page in the real browser clears the challenge automatically (JS executes); the residential proxy rotates the source IP to avoid the rate-limit ban that triggers around request 10-20 from the same datacenter IP. - IMDb's robots.txt blocks AI crawlers. Lines
User-agent: anthropic-ai / Claude-Web / GPTBot / CCbot / Google-Extended → Disallow: /are present inhttps://www.imdb.com/robots.txt. The skill must drive a real browser (with a non-bot UA), not curl-fetch with an AI-bot UA.browserless_agentuses a real Chrome UA and clears this. - Suggestion-API
{firstChar}path component is decorative.https://v3.sg.media-imdb.com/suggestion/h/the_matrix.jsonand/suggestion/t/the_matrix.jsonand/suggestion/0/the_matrix.jsonall return identical JSON. The IMDb search-bar typeahead conventionally sends the first character of the query; the server doesn't care. - Suggestion-API
rankis MOVIEmeter popularity, NOT user rating. A common trap. The user rating (aggregateRating.ratingValue) is not in the suggestion JSON at all — only the title-page HTML carries it. aggregateRatingis missing from the LD-JSON block when a title has fewer than ~5 user votes (typical for obscure shorts, unreleased titles, video-game expansions). Treat asimdbRating: null, voteCount: 0rather than failing.- Rating distribution lives ONLY in
__NEXT_DATA__, not in the LD-JSON block. The path ismainColumnData.ratingsSummary.histogram.histogramValuesand the array is sorted descending byrating(10 → 1). Always map byratingfield; do not assume index 0 == 10. - LD-JSON
actorarray is truncated (typically 5 entries). For the full top-billed cast, parse__NEXT_DATA__.props.pageProps.mainColumnData.cast.edges[]. - Runtime in LD-JSON is ISO-8601, not minutes.
PT2H22M→ 142,PT45M→ 45. For series, this is the per-episode runtime, not total — note that in the output if the title type istvSeries/tvMiniSeries. datePublishedfor series is the series premiere date, not the year range. For ayearRangefield on series, read__NEXT_DATA__.mainColumnData.releaseYear.year(start) and.endYear(null for ongoing).- Episode pages are also title pages. A TV-episode
tt-ID has its own/title/tt.../page with the same LD-JSON +__NEXT_DATA__structure. To get parent-series context, readmainColumnData.series.series.id/.titleText.textandmainColumnData.series.episodeNumber.seasonNumber/.episodeNumber. - IMDbPro is a different surface (
pro.imdb.com). It loads without the WAF challenge but exposes MOVIEmeter / production-contact data, not the public user-rating. Don't use it for rating lookup. - IMDb GraphQL is a trap for anonymous clients. Both
caching.graphql.imdb.comandapi.graphql.imdb.comreturn 301 → 500 (or block) without a session-cookied request from a logged-in page context. Don't try to bypass the title-page HTML this way. - Bulk-data alternative for offline use. IMDb publishes daily TSVs at
https://datasets.imdbws.com/(title.basics.tsv.gz,title.ratings.tsv.gz— only rating + numVotes, no distribution). Useful for batch enrichment of millions oftt-IDs; not appropriate for "what's the rating right now" lookups (24-hour staleness) or for distribution / Metascore (not in the dataset). - Read-only — never click Rate, Add to Watchlist, Sign in, or any star-rating bucket. Those mutate user state and require an authenticated user.
- Original-title detection.
ld.alternateNameis the original-language title for foreign-language films (e.g."Cadena perpetua"fortt0111161's Spanish release). It is also populated for some English-language films with regional retitles, so comparealternateName !== namebefore treating it as "original title". - Free-form queries with city/country names don't get rerouted the way OpenTable's term-parser reroutes them — the IMDb suggestion API is purely textual. Safe to pass
"Joe's Shanghai"as a movie title without disambiguation tricks. - The rating data requires a real browser session. The suggestion API (step 1) is a plain WAF-exempt HTTPS GET that works from any client, but the title page — the only source of the rating/distribution/Metascore — is WAF-walled and must be driven with
browserless_agent. An environment that can only make plain HTTP requests can resolve thett-ID but cannot get the rating; make surebrowserless_agentis available before running.
Expected Output
Single, consistent shape — variants by title type are reflected in titleType and the optional seriesContext block.
Movie
{
"success": true,
"titleId": "tt0111161",
"title": "The Shawshank Redemption",
"originalTitle": null,
"titleType": "movie",
"year": 1994,
"yearRange": null,
"certification": "R",
"runtimeMinutes": 142,
"genres": ["Drama"],
"imdbRating": 9.3,
"voteCount": 3050000,
"ratingDistribution": [
{ "rating": 10, "voteCount": 1830000 },
{ "rating": 9, "voteCount": 580000 },
{ "rating": 8, "voteCount": 320000 },
{ "rating": 7, "voteCount": 150000 },
{ "rating": 6, "voteCount": 70000 },
{ "rating": 5, "voteCount": 38000 },
{ "rating": 4, "voteCount": 18000 },
{ "rating": 3, "voteCount": 12000 },
{ "rating": 2, "voteCount": 8000 },
{ "rating": 1, "voteCount": 25000 }
],
"metascore": 82,
"cast": [
{ "name": "Tim Robbins", "role": "Andy Dufresne" },
{ "name": "Morgan Freeman", "role": "Ellis Boyd 'Red' Redding" },
{ "name": "Bob Gunton", "role": "Warden Norton" },
{ "name": "William Sadler", "role": "Heywood" },
{ "name": "Clancy Brown", "role": "Captain Hadley" }
],
"directors": ["Frank Darabont"],
"writers": ["Stephen King", "Frank Darabont"],
"posterUrl": "https://m.media-amazon.com/images/M/MV5BMDAyY2FhYjctNDc5OS00MDNlLThiMGUtY2UxYWVkNGY2ZjljXkEyXkFqcGc@._V1_.jpg",
"shortPlot": "Over the course of several years, two convicts form a friendship, seeking consolation and, eventually, redemption through basic compassion.",
"fullPlot": "Chronicles the experiences of a formerly successful banker as a prisoner...",
"languages": ["English"],
"countries": ["United States"],
"canonicalUrl": "https://www.imdb.com/title/tt0111161/",
"seriesContext": null
}TV Series
{
"success": true,
"titleId": "tt11280740",
"title": "Severance",
"originalTitle": null,
"titleType": "tvSeries",
"year": 2022,
"yearRange": "2022-",
"certification": "TV-MA",
"runtimeMinutes": 60,
"genres": ["Drama", "Mystery", "Sci-Fi", "Thriller"],
"imdbRating": 8.7,
"voteCount": 450000,
"ratingDistribution": [ {"rating": 10, "voteCount": 0}, ... ],
"metascore": 87,
"cast": [ {"name": "Adam Scott", "role": "Mark Scout"}, ... ],
"directors": [],
"writers": ["Dan Erickson"],
"posterUrl": "https://...",
"shortPlot": "...",
"fullPlot": "...",
"languages": ["English"],
"countries": ["United States"],
"canonicalUrl": "https://www.imdb.com/title/tt11280740/",
"seriesContext": null
}TV Episode
{
"success": true,
"titleId": "tt2301451",
"title": "Ozymandias",
"originalTitle": null,
"titleType": "tvEpisode",
"year": 2013,
"yearRange": null,
"certification": "TV-MA",
"runtimeMinutes": 48,
"genres": ["Crime", "Drama", "Thriller"],
"imdbRating": 10.0,
"voteCount": 250000,
"ratingDistribution": [ ... ],
"metascore": null,
"cast": [ ... ],
"directors": ["Rian Johnson"],
"writers": ["Vince Gilligan", "Moira Walley-Beckett"],
"posterUrl": "https://...",
"shortPlot": "...",
"fullPlot": "...",
"languages": ["English"],
"countries": ["United States"],
"canonicalUrl": "https://www.imdb.com/title/tt2301451/",
"seriesContext": {
"seriesId": "tt0903747",
"seriesTitle": "Breaking Bad",
"seasonNumber": 5,
"episodeNumber": 14
}
}Failure shapes
// Unrated (fewer than ~5 user votes — aggregateRating missing from LD-JSON)
{
"success": true,
"titleId": "tt99999999",
"title": "Some Obscure Short",
"titleType": "short",
"imdbRating": null,
"voteCount": 0,
"ratingDistribution": [],
"metascore": null,
...
}
// Free-form input could not be confidently resolved to a single tt-ID
{
"success": false,
"reason": "ambiguous_name",
"query": "severance",
"candidates": [
{"titleId": "tt11280740", "title": "Severance", "year": 2022, "titleType": "tvSeries", "rank": 150},
{"titleId": "tt0464196", "title": "Severance", "year": 2006, "titleType": "movie", "rank": 8508}
]
}
// Free-form input returned zero matches from the suggestion API
{
"success": false,
"reason": "title_not_found",
"query": "ksjdhfksjdhfksjdhf"
}
// WAF challenge could not be cleared (rare with a residential-proxy browserless_agent session; document and retry on a fresh session)
{
"success": false,
"reason": "anti_bot_block",
"titleId": "tt0111161",
"detail": "AWS WAF AwsWafIntegration challenge did not clear after 3 attempts"
}