IMDb Title Rating Lookup
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 non-browser HTTP clients (includingbrowse cloud fetch, even with--proxies). Drive it from a real browser session:browse open --remoteagainst a Browserbase session created with--verified --proxies. The WAF clears automatically when JS executes.
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')
browse cloud fetch "https://v3.sg.media-imdb.com/suggestion/t/${SLUG}.json"
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
SID=$(browse cloud sessions create --keep-alive --verified --proxies | jq -r '.id')
export BROWSE_SESSION="$SID"
browse open "https://www.imdb.com/title/${ttId}/" --remote
browse wait load --remote
browse wait timeout 1500 --remote # let lazy hydration settle
HTML=$(browse get html body --remote)
Both --verified and --proxies are required — without them the AWS WAF challenge stalls in browse cloud fetch and IP-blocks/rate-limits a vanilla Browserbase session within a few requests. With them, the title page renders 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 + summary
Useful 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. Release the session
browse cloud sessions update "$SID" --body '{"status":"REQUEST_RELEASE"}'
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 barebrowse cloud fetch(with or without--proxies) 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 loaded inside 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. browse cloud fetchis NOT a viable surface for IMDb title pages. Use it 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
--verified --proxieson the Browserbase session. Bare sessions get WAF-challenged or IP-rate-limited after a handful of requests.--verifiedclears the challenge automatically;--proxiesrotates 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. Browserbase's verified-browser path uses 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. - DNS reachability matters for the host environment. The skill requires the executing environment to reach
wss://connect.{region}.browserbase.comfor CDP traffic, not justapi.browserbase.com. Sandboxes that allowlist only the API host can runbrowse cloud fetchagainst the suggestion API but cannot drive the title page viabrowse open --remote— and the title page is mandatory for rating data. Verify CDP reachability 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 --verified --proxies; 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"
}