Field-tested against www.rottentomatoes.com on 2026-10-23 using plain HTTP (Python urllib) and a Browser Use cloud browser.
robots.txt (checked 2026-10-23) has no rule for /m/, /tv/, /browse/ or /cnapi/browse/ (the paths this skill uses) and disallows /search for all user agents.
Quick summary
- No browser needed for data. Every title page (
/m/<slug>,/tv/<slug>,/tv/<slug>/s05) ships the full scorecard as embedded JSON (<script id="media-scorecard-json">), plus one schema.org JSON-LD block and a server-rendered "Movie Info" list. Plain urllib with its defaultPython-urllib/3.xUser-Agent returned HTTP 200 for all of them. - Browse lists (
/browse/<list>/<filters>) are paginated by the same JSON endpoint the "Load more" button calls:GET /cnapi/browse/<list>/<filters>?after=<endCursor>, 30 items per page, cursor inpageInfo. 5 pages (150 unique titles) of one list in a row worked, ~0.2-1.1 s per call. - Browse items carry title, URL, Tomatometer, Popcornmeter, certified flags and a release-date string. Review counts, rating, genre, dates and cast come from the title page, so a full scrape is "browse, then one request per title".
- The browser is only a fallback. Close the app-promo modal before clicking; do not click the consent banner's "Continue", it reloads the page.
- This skill does not use
/search. Start from browse lists or known/m/and/tv/URLs.
What works
1. Browse 2+ pages, then enrich each title (plain HTTP, no browser)
Runs as one script, inside browser-harness or as plain python3 (standard library only). rt_get fetches with retry, rt_browse pages the list, rt_title parses one title page, rt_cast gets the full cast from /cast-and-crew.
import re, json, time, gzip, email.utils, html as htmllib
import urllib.parse, urllib.request, urllib.error
BASE = "https://www.rottentomatoes.com"
# No User-Agent override: urllib sends its own truthful "Python-urllib/3.x", which got HTTP 200 on every URL below.
HDRS = {"Accept-Language": "en-US,en;q=0.9", "Accept-Encoding": "gzip"}
RETRY_CODES = {429, 500, 502, 503, 504}
MAX_WAIT = 120.0 # longest sleep we accept; a longer Retry-After stops the run instead
TITLE_TYPES = {"Movie", "TVSeries", "TVSeason"}
def _retry_after(v):
"""Retry-After header as seconds: delta-seconds or an HTTP-date. None if absent/unparseable."""
v = (v or "").strip()
if v.isdigit():
return float(v)
try:
return max(0.0, email.utils.parsedate_to_datetime(v).timestamp() - time.time())
except (TypeError, ValueError, IndexError):
return None
def rt_get(url, tries=4, timeout=20):
"""GET -> (final_url, text). Retries 429/5xx and network errors with backoff, then raises.
Any other HTTP error (404 for a bad slug or list) raises at once, and so does a
Retry-After longer than MAX_WAIT. Never sleeps after the last attempt."""
for i in range(tries):
try:
with urllib.request.urlopen(urllib.request.Request(url, headers=HDRS), timeout=timeout) as r:
data = r.read()
if r.headers.get("Content-Encoding") == "gzip":
data = gzip.decompress(data)
return r.geturl(), data.decode()
except urllib.error.HTTPError as e:
if e.code not in RETRY_CODES or i == tries - 1:
raise
wait = _retry_after(e.headers.get("Retry-After"))
except OSError: # URLError, socket timeout, connection reset
if i == tries - 1:
raise
wait = None
if wait is not None and wait > MAX_WAIT:
raise RuntimeError(f"{url}: server asked to wait {wait:.0f}s (> {MAX_WAIT:.0f}s); stopping")
time.sleep(min(MAX_WAIT, wait if wait is not None else 2.0 * 2 ** i))
def _script_json(page, attr):
"""Parse the first <script ...attr...>{JSON object}</script> block, or None."""
m = re.search(r'<script[^>]*%s[^>]*>(.*?)</script>' % attr, page, re.S)
try:
j = json.loads(m.group(1)) if m else None
except ValueError:
return None
return j if isinstance(j, dict) else None
def _title_ld(page):
"""The JSON-LD object describing this title (@type Movie/TVSeries/TVSeason), else {}."""
for m in re.finditer(r'<script[^>]*type="application/ld\+json"[^>]*>(.*?)</script>', page, re.S):
try:
j = json.loads(m.group(1))
except ValueError:
continue
for o in (j if isinstance(j, list) else [j]):
if isinstance(o, dict) and o.get("@type") in TITLE_TYPES:
return o
return {}
def _d(v): return v if isinstance(v, dict) else {}
def _s(v): return (v.strip() or None) if isinstance(v, str) else None
def _b(v): return v if isinstance(v, bool) else None
def _int(v):
if isinstance(v, bool) or not isinstance(v, (int, float, str)):
return None
try:
return int(str(v).replace(",", "").strip())
except ValueError:
return None
def _strs(v):
if isinstance(v, str):
v = [v]
return [x.strip() for x in v if isinstance(x, str) and x.strip()] if isinstance(v, list) else []
def _names(people):
if isinstance(people, dict):
people = [people]
return [p["name"] for p in (people if isinstance(people, list) else [])
if isinstance(p, dict) and isinstance(p.get("name"), str)]
def _text(s):
return htmllib.unescape(re.sub(r"<[^>]+>", "", re.sub(r"\s+", " ", s or ""))).strip()
def rt_title(url):
"""Full record for one /m/<slug>, /tv/<slug> or /tv/<slug>/sNN page.
Raises if the page has neither the scorecard JSON nor a title JSON-LD block."""
final_url, page = rt_get(urllib.parse.urljoin(BASE, url))
ld = _title_ld(page)
sc = _script_json(page, r'id="media-scorecard-json"') or {}
if not ld and not sc:
raise LookupError(f"no title data on {final_url}")
hero = _d((_script_json(page, r'id="media-hero-json"') or {}).get("content"))
info = {} # "Movie Info" / "Series Info" list: label -> [values]
for m in re.finditer(r'data-qa="item-label">(.*?)</rt-text>\s*</dt>\s*<dd[^>]*>(.*?)</dd>', page, re.S):
info[_text(m.group(1))] = [_text(v) for v in re.findall(r'data-qa="item-value"[^>]*>(.*?)</rt-', m.group(2), re.S)]
first = lambda k: (info.get(k) or [None])[0]
crit, aud = _d(sc.get("criticsScore")), _d(sc.get("audienceScore"))
top = _d(_d(sc.get("overlay")).get("criticsTop"))
rating_detail = first("Rating")
return {
"url": final_url, # after redirects
"type": ld.get("@type"), # Movie | TVSeries | TVSeason
"title": _text(_s(hero.get("title")) or _s(ld.get("name"))) or None,
"tomatometer": _int(crit.get("score")), # None until enough reviews
"tomatometer_reviews": _int(crit.get("reviewCount")),
"tomatometer_avg": _s(crit.get("averageRating")), # "8.60" (out of 10)
"certified_fresh": _b(crit.get("certified")),
"top_critics_score": _int(top.get("score")),
"top_critics_reviews": _int(top.get("reviewCount")),
"popcornmeter": _int(aud.get("score")),
"popcornmeter_type": _s(aud.get("scoreType")), # VERIFIED | ALL
"audience_ratings": _s(aud.get("bandedRatingCount")), # "10,000+ Verified Ratings"
"audience_reviews": _int(aud.get("reviewCount")), # absent on TV series pages
"rating": _s(ld.get("contentRating")) or (rating_detail or "").split(" (")[0] or None,
"rating_detail": rating_detail, # "R (Nudity|Language|Some Sexuality)"
"genres": _strs(ld.get("genre")) or info.get("Genre") or [],
"release_date": first("Release Date (Theaters)") or first("Release Date"),
"streaming_date": first("Release Date (Streaming)"),
"first_date": _s(ld.get("dateCreated")), # ISO YYYY-MM-DD
"runtime": first("Runtime"),
"seasons": _int(ld.get("numberOfSeasons")),
"network": first("Network"),
"box_office": first("Box Office (Gross USA)"),
"directors": _names(ld.get("director")),
"cast": _names(ld.get("actor")), # top-billed only, 0-5 names
}
def rt_cast(url):
"""Full cast list from /cast-and-crew: [{name, character}].
Raises if the page has no cast-and-crew cards at all (markup change, or a title with no credits)."""
final_url, page = rt_get(urllib.parse.urljoin(BASE, url).rstrip("/") + "/cast-and-crew")
cards = re.findall(r'<cast-and-crew-card(.*?)</cast-and-crew-card>', page, re.S)
if not cards:
raise LookupError(f"no <cast-and-crew-card> on {final_url}")
out = []
for card in cards:
role = re.search(r'data-role="([^"]*)"', card)
if not role or "cast" not in role.group(1).split(","):
continue
name = re.search(r'slot="title"[^>]*>(.*?)</rt-text>', card, re.S)
char = re.search(r'slot="characters"[^>]*>(.*?)</rt-text>', card, re.S)
out.append({"name": _text(name.group(1)) if name else None,
"character": _text(char.group(1)) if char else None})
return out
def rt_browse(vanity, filters="sort:popular", max_pages=2, delay=1.5):
"""Browse list via the endpoint behind "Load more", 30 items per page.
Returns (rows, complete). complete is False when max_pages stopped it while the API
still said hasNextPage. A failed page raises (after rt_get's retries); it never ends the list."""
base = (f"{BASE}/cnapi/browse/{urllib.parse.quote(vanity, safe='')}/"
f"{urllib.parse.quote(filters, safe=':~,')}")
rows, seen, after = [], set(), None
for _ in range(max_pages):
url = base
if after: # unquote first so a cursor copied as "Mjk%3D" is not double-encoded
url += "?after=" + urllib.parse.quote(urllib.parse.unquote(after), safe="")
_, body = rt_get(url)
d = json.loads(body)
items = _d(d.get("grid")).get("list")
if not isinstance(items, list):
raise LookupError(f"no grid.list in {url}")
for it in items:
if not isinstance(it, dict):
continue
key = _s(it.get("emsId")) or _s(it.get("mediaUrl"))
if key in seen:
continue # duplicate across pages; rows with no id are always kept
if key:
seen.add(key)
c, a = _d(it.get("criticsScore")), _d(it.get("audienceScore"))
media = _s(it.get("mediaUrl"))
rows.append({
"ems_id": _s(it.get("emsId")),
"title": _s(it.get("title")),
"url": urllib.parse.urljoin(BASE, media) if media else None,
"type": _s(it.get("type")),
"tomatometer": _int(c.get("score")),
"certified_fresh": _b(c.get("certified")),
"popcornmeter": _int(a.get("score")),
"release_text": _s(it.get("releaseDateText")), # "Opened Oct 16, 2026", "Streaming Oct 13, 2026"
})
pi = _d(d.get("pageInfo"))
if not pi.get("hasNextPage"):
return rows, True
after = _s(pi.get("endCursor"))
if not after:
raise LookupError(f"hasNextPage is true but no endCursor in {url}")
time.sleep(delay)
return rows, False
# Browse 2 pages (60 titles) of Certified Fresh movies streaming at home
rows, complete = rt_browse("movies_at_home", "critics:certified_fresh~sort:popular", max_pages=2)
print(len(rows), "titles,", len({r["url"] for r in rows}), "unique urls, complete list:", complete)
if not complete:
print("stopped at max_pages=2; the list has more pages")
print(json.dumps(rows[0]) if rows else "no rows")
# Enrich the first 5 with their title pages (review counts, rating, genre, dates, cast)
todo, failed = rows[:5], []
time.sleep(1.5) # rt_browse does not sleep after its last page
for i, r in enumerate(todo):
if not r["url"]:
failed.append((r["title"], "no url"))
continue
try:
d = rt_title(r["url"])
print(json.dumps({k: d[k] for k in ("title", "tomatometer", "tomatometer_reviews", "popcornmeter",
"audience_ratings", "rating", "genres", "release_date", "cast")}))
except urllib.error.HTTPError as e:
if e.code in (403, 429):
raise # blocked, or still rate limited after rt_get's retries: stop the run and back off
failed.append((r["url"], repr(e)[:80])) # e.g. 404 for a removed title
except (OSError, LookupError, ValueError) as e: # network error after retries, no data
failed.append((r["url"], repr(e)[:80]))
finally:
if i < len(todo) - 1:
time.sleep(1.5) # after every attempted request, failed ones included; not after the last
print("enriched", len(todo) - len(failed), "of", len(todo), "| failed:", failed)
# A movie, a TV series and a TV season directly, plus a full cast list (1.5 s before each request)
for u in ["/m/oppenheimer_2023", "/tv/the_bear", "/tv/the_bear/s05"]:
time.sleep(1.5)
print(json.dumps(rt_title(u)))
time.sleep(1.5)
cast = rt_cast("/m/oppenheimer_2023")
print(len(cast), "cast members", cast[:2])Real output (trimmed; people's names, which the code returns as plain strings, replaced with <name>):
60 titles, 60 unique urls, complete list: False
stopped at max_pages=2; the list has more pages
{"ems_id": "5b44daa8-5b70-3a2b-b009-0052edcfc114", "title": "Tenzing", "url": "https://www.rottentomatoes.com/m/tenzing", "type": "Movie", "tomatometer": 98, "certified_fresh": true, "popcornmeter": 91, "release_text": "Streaming Oct 16, 2026"}
{"title": "Tenzing", "tomatometer": 98, "tomatometer_reviews": 58, "popcornmeter": 91, "audience_ratings": "100+ Ratings", "rating": "PG", "genres": ["Drama", "Adventure"], "release_date": "Oct 9, 2026, Limited", "cast": ["<name>", "<name>", "<name>", "<name>", "<name>"]}
{"title": "Buddy", "tomatometer": 88, "tomatometer_reviews": 138, "popcornmeter": 76, "audience_ratings": "1,000+ Verified Ratings", "rating": "R", "genres": ["Horror", "Comedy", "Mystery & Thriller", "Fantasy"], "release_date": "Aug 28, 2026, Wide", "cast": ["<name>", "<name>", "<name>"]}
...
enriched 5 of 5 | failed: []
{"url": "https://www.rottentomatoes.com/m/oppenheimer_2023", "type": "Movie", "title": "Oppenheimer", "tomatometer": 93, "tomatometer_reviews": 510, "tomatometer_avg": "8.60", "certified_fresh": true, "top_critics_score": 95, "top_critics_reviews": 87, "popcornmeter": 91, "popcornmeter_type": "VERIFIED", "audience_ratings": "10,000+ Verified Ratings", "audience_reviews": 6023, "rating": "R", "rating_detail": "R (Nudity|Language|Some Sexuality)", "genres": ["Biography", "History", "Drama"], "release_date": "Jul 21, 2023, Wide", "streaming_date": "Nov 21, 2023", "first_date": "2023-07-21", "runtime": "3h 0m", "seasons": null, "network": null, "box_office": "$330.0M", "directors": ["<name>"], "cast": ["<name>", "<name>", "<name>", "<name>", "<name>"]}
{"url": "https://www.rottentomatoes.com/tv/the_bear", "type": "TVSeries", "title": "The Bear", "tomatometer": 93, "tomatometer_reviews": 469, "tomatometer_avg": "8.30", "certified_fresh": false, "top_critics_score": 93, "top_critics_reviews": 182, "popcornmeter": 79, "popcornmeter_type": "ALL", "audience_ratings": "10,000+ Ratings", "audience_reviews": null, "rating": "TV-MA", "rating_detail": "TV-MA", "genres": ["Comedy", "Drama"], "release_date": "Jun 23, 2022", "streaming_date": null, "first_date": "2022-06-23", "runtime": null, "seasons": 5, "network": "Hulu", "box_office": null, "directors": ["<name>"], "cast": ["<name>", "<name>", "<name>", "<name>"]}
{"url": "https://www.rottentomatoes.com/tv/the_bear/s05", "type": "TVSeason", "title": "Season 5 – The Bear", "tomatometer": 96, "tomatometer_reviews": 83, "tomatometer_avg": "8.10", "certified_fresh": true, "top_critics_score": 97, "top_critics_reviews": 33, "popcornmeter": 85, "popcornmeter_type": "ALL", "audience_ratings": "250+ Ratings", "audience_reviews": 156, "rating": "TV-MA", "rating_detail": "TV-MA", "genres": ["Comedy", "Drama"], "release_date": "Jun 25, 2026", "streaming_date": null, "first_date": "2026-06-25", "runtime": null, "seasons": null, "network": "Hulu", "box_office": null, "directors": ["<name>"], "cast": ["<name>", "<name>", "<name>", "<name>"]}
31 cast members [{'name': '<name>', 'character': '<name>'}, {'name': '<name>', 'character': '<name>'}]The failure paths were checked against a local mock server the same day: a 429 with Retry-After: 1 and a 503 with an HTTP-date Retry-After were each retried once and then succeeded; a 429 with Retry-After: 300 made rt_get raise after 1 request with no sleep; a server that always returned 500 made rt_get raise HTTPError 500 after its tries; a browse page that kept failing made rt_browse raise instead of returning the rows it already had; a row with emsId: null was kept.
List names and filters. The path after /browse/ on the site is exactly what /cnapi/browse/ takes. Observed working: movies_in_theaters/sort:popular, movies_at_home/critics:certified_fresh~sort:popular, movies_coming_soon/sort:popular, tv_series_browse/sort:popular, tv_series_browse/genres:comedy~sort:newest. Multiple filters are joined with ~. To get a filter string, set the filters on the site's browse page and copy the path. An unknown list name returns HTTP 404.
2. Browser fallback (same data from the live DOM, "Load more" pagination)
Use this path when you want the rendered page in a normal browser session; it reads the same public, logged-out pages a visitor sees, at a human pace. It is not a way around a block. A 429 on the HTTP path gets only the bounded retry in rt_get (see Pacing): at most 4 tries, honoring Retry-After up to 120 s, then it raises. A 403, challenge or captcha on either path, or a 429 in the browser, is not retried: stop, record it and back off, and do not switch paths, sessions or networks to get past it. It works in a background tab (new_tab does not foreground Chrome; nothing here calls activate_tab).
import json, time
def is_harness_timeout(e):
"""js() re-raises a slow Runtime.evaluate as RuntimeError("Runtime.evaluate timed out; ...");
cdp() raises a TimeoutError subclass. Anything else (a JS exception) is a real error."""
return isinstance(e, TimeoutError) or (isinstance(e, RuntimeError) and "timed out" in str(e))
def js_retry(expr, tries=3):
"""js() that retries harness timeouts (slow daemon round trip on a busy cloud browser)."""
for i in range(tries):
try:
return js(expr)
except (TimeoutError, RuntimeError) as e:
if not is_harness_timeout(e) or i == tries - 1:
raise
time.sleep(1)
def open_page(url, ready_selector, first=False, timeout=30):
"""Navigate, then wait until location.href is THIS url, its document is complete, and
ready_selector exists. Checking the URL stops us reading the previous page's readyState."""
new_tab(url) if first else goto_url(url)
want = url.split("#")[0].split("?")[0].rstrip("/")
check = ("(() => location.href.split('#')[0].split('?')[0].replace(/\\/$/, '') === %s"
" && document.readyState === 'complete' && !!document.querySelector(%s))()"
% (json.dumps(want), json.dumps(ready_selector)))
deadline = time.time() + timeout
while time.time() < deadline:
try:
if js(check):
return
except (TimeoutError, RuntimeError) as e:
if not is_harness_timeout(e):
raise
time.sleep(0.5)
raise RuntimeError(f"{url} not ready ({ready_selector}): {json.dumps(page_info())}")
def click_button(name):
"""Click the first rendered button whose accessible name equals `name` (case-insensitive)."""
for n in cdp("Accessibility.getFullAXTree")["nodes"]:
if n.get("ignored") or n.get("role", {}).get("value") != "button":
continue
if str(n.get("name", {}).get("value", "")).strip().lower() != name.lower():
continue
bid = n.get("backendDOMNodeId")
try:
cdp("DOM.scrollIntoViewIfNeeded", backendNodeId=bid)
q = cdp("DOM.getBoxModel", backendNodeId=bid)["model"]["content"]
except RuntimeError:
continue # node exists but is not rendered
click_at_xy(sum(q[0::2]) / 4, sum(q[1::2]) / 4)
return True
return False
PROMO_CLOSE = """(() => { const b = document.querySelector('rt-app-modal-content [data-rtappmanager="btnClose:click"]');
if (!b || !b.checkVisibility()) return null; const r = b.getBoundingClientRect();
return r.width ? [r.x + r.width / 2, r.y + r.height / 2] : null })()"""
def close_app_promo(settle=2.0):
"""Close the app-promo modal if it is showing. True = closed, False = not showing.
Do NOT click the consent banner's "Continue" (id onetrust-accept-btn-handler):
it accepts the terms and reloads the page, wiping anything already read."""
time.sleep(settle) # the modal appears shortly after load, not on every page view
xy = js_retry(PROMO_CLOSE)
if not xy:
return False
click_at_xy(*xy)
for _ in range(10):
time.sleep(0.3)
if not js_retry(PROMO_CLOSE):
return True
raise RuntimeError("app promo did not close")
# Title page: read the embedded JSON from the DOM
open_page("https://www.rottentomatoes.com/m/oppenheimer_2023", "#media-scorecard-json", first=True)
rec = js_retry("""(() => {
const j = el => { try { return el ? JSON.parse(el.textContent) : null } catch (e) { return null } };
const ld = [...document.querySelectorAll('script[type="application/ld+json"]')].map(j)
.find(o => o && ['Movie', 'TVSeries', 'TVSeason'].includes(o['@type'])) || {};
const sc = j(document.getElementById('media-scorecard-json')) || {};
const c = sc.criticsScore || {}, a = sc.audienceScore || {};
return {url: location.href, title: ld.name || null, tomatometer: c.score || null, reviews: c.reviewCount ?? null,
popcornmeter: a.score || null, audience: a.bandedRatingCount || null,
rating: ld.contentRating || null, genres: ld.genre || [], released: ld.dateCreated || null,
cast: (Array.isArray(ld.actor) ? ld.actor : []).map(p => p && p.name).filter(Boolean)};
})()""")
print(json.dumps(rec))
# Browse page: read tiles, press "Load more" at most MAX_CLICKS times
TILE = 'a[data-qa="discovery-media-list-item-caption"]'
TILES = """
return [...document.querySelectorAll('a[data-qa="discovery-media-list-item-caption"]')].map(a => ({
title: a.querySelector('[data-qa="discovery-media-list-item-title"]')?.textContent.trim() || null,
url: a.href || null,
tomatometer: a.querySelector('[slot="criticsScore"]')?.textContent.trim() || null,
popcornmeter: a.querySelector('[slot="audienceScore"]')?.textContent.trim() || null,
date: a.querySelector('[data-qa="discovery-media-list-item-start-date"]')?.textContent.trim() || null}));
"""
def wait_more(before, secs=10):
"""Poll until the tile count grows past `before`; return the latest tiles either way."""
tiles = []
for _ in range(int(secs / 0.5)):
time.sleep(0.5)
tiles = js_retry(TILES) or []
if len(tiles) > before:
break
return tiles
open_page("https://www.rottentomatoes.com/browse/movies_in_theaters/sort:popular", TILE)
print("app promo closed:", close_app_promo())
tiles = js_retry(TILES) or []
print("page 1:", len(tiles), "tiles")
MAX_CLICKS, clicks, end_of_list = 2, 0, False
while clicks < MAX_CLICKS:
before = len(tiles)
if not click_button("Load more"):
end_of_list = True # no button rendered: the list is exhausted
break
tiles = wait_more(before)
if len(tiles) <= before: # the promo can open late and swallow the click: close it, try once more
close_app_promo(settle=0)
if click_button("Load more"):
tiles = wait_more(before)
if len(tiles) <= before:
raise RuntimeError(f"Load more added no tiles ({before} stayed {len(tiles)}): {json.dumps(page_info())}")
clicks += 1
print("after Load more:", len(tiles), "tiles")
time.sleep(1.5)
if not end_of_list:
print(f"stopped at MAX_CLICKS={MAX_CLICKS}; list not exhausted")
seen, rows = set(), []
for t in tiles: # dedupe by url; tiles without a url are kept
if t.get("url") in seen:
continue
if t.get("url"):
seen.add(t["url"])
rows.append(t)
print("rows:", len(rows), "| url now:", js_retry("location.href"))
print(rows[0] if rows else None)Real output from a fresh cloud browser (people's names replaced with <name>):
{"url": "https://www.rottentomatoes.com/m/oppenheimer_2023", "title": "Oppenheimer (2023)", "tomatometer": "93", "reviews": 510, "popcornmeter": "91", "audience": "10,000+ Verified Ratings", "rating": "R", "genres": ["Biography", "History", "Drama"], "released": "2023-07-21", "cast": ["<name>", "<name>", "<name>", "<name>", "<name>"]}
app promo closed: True
page 1: 28 tiles
after Load more: 58 tiles
after Load more: 88 tiles
stopped at MAX_CLICKS=2; list not exhausted
rows: 88 | url now: https://www.rottentomatoes.com/browse/movies_in_theaters/sort:popular?page=3
{'title': 'You Can See Everything', 'url': 'https://www.rottentomatoes.com/m/you_can_see_everything', 'tomatometer': '98%', 'popcornmeter': '97%', 'date': 'Opened Oct 16, 2026'}Fields
rt_title(url) returns exactly these keys (example values from the Oppenheimer / The Bear output above):
| Key | Source | Example / note |
|---|---|---|
| url | final URL after redirects | "https://www.rottentomatoes.com/m/oppenheimer_2023" |
| type | JSON-LD @type | "Movie", "TVSeries", "TVSeason" |
| title | media-hero-json .content.title, else JSON-LD name (which adds the year) | "Oppenheimer" |
| tomatometer | media-scorecard-json .criticsScore.score (a string on the page) | 93; None until enough reviews |
| tomatometer_reviews | .criticsScore.reviewCount | 510 |
| tomatometer_avg | .criticsScore.averageRating | "8.60" (string, out of 10) |
| certified_fresh | .criticsScore.certified | true / false |
| top_critics_score, top_critics_reviews | .overlay.criticsTop.score / .reviewCount | 95, 87 |
| popcornmeter | .audienceScore.score | 91 |
| popcornmeter_type | .audienceScore.scoreType | "VERIFIED" on movies, "ALL" on TV |
| audience_ratings | .audienceScore.bandedRatingCount | "10,000+ Verified Ratings" |
| audience_reviews | .audienceScore.reviewCount | 6023; None on TV series |
| rating | JSON-LD contentRating, else Movie Info "Rating" before ( | "R", "TV-MA" |
| rating_detail | Movie Info "Rating" | "R (Nudity|Language|Some Sexuality)" |
| genres | JSON-LD genre, else Movie Info "Genre" | ["Biography", "History", "Drama"] |
| release_date | Movie Info "Release Date (Theaters)" (movies) or "Release Date" (TV) | "Jul 21, 2023, Wide", "Jun 23, 2022" |
| streaming_date | Movie Info "Release Date (Streaming)" | "Nov 21, 2023"; None on TV |
| first_date | JSON-LD dateCreated | "2023-07-21" |
| runtime | Movie Info "Runtime" | "3h 0m"; None on TV |
| seasons | JSON-LD numberOfSeasons | 5 on a series; None on movies and seasons |
| network | Movie Info "Network" | "Hulu"; None on movies |
| box_office | Movie Info "Box Office (Gross USA)" | "$330.0M" |
| directors | JSON-LD director[].name | 1 name in each example |
| cast | JSON-LD actor[].name | top-billed only: 5, 4 and 4 names above; 0-5 across 12 in-theater titles |
rt_browse(...) returns (rows, complete). Each row has exactly: ems_id (emsId), title, url (absolute, from mediaUrl), type ("Movie"), tomatometer, certified_fresh, popcornmeter, release_text (releaseDateText). complete is True only when the API said hasNextPage: false. Paging uses pageInfo {"endCursor": "Mjk=", "hasNextPage": true}, 30 items per page.
rt_cast(url) returns [{"name", "character"}]: 31 cast entries out of 37 <cast-and-crew-card> elements for Oppenheimer (the other 6 are crew, data-role="all,crew").
The browser block's rec has url, title, tomatometer, reviews, popcornmeter, audience, rating, genres, released, cast (scores stay strings, title is the JSON-LD name with the year). Each tile has title, url, tomatometer, popcornmeter, date (scores as shown, "98%").
Gotchas
- Missing scores are normal. On
movies_coming_soonpage 1, 11 of 30 items had no Tomatometer and 26 no Popcornmeter ("audienceScore": {"scorePercent": ""}). On the title pagescoreis absent andbandedRatingCountreads"0 Verified Ratings". The_inthelper turns these intoNone. - TV vs movie differences. TV series pages label scores "Avg. Tomatometer"/"Avg. Popcornmeter", use
scoreType: "ALL", and have no exactaudienceScore.reviewCount. The date label is "Release Date" (not "Release Date (Theaters)"). TV season pages (/tv/<slug>/s05) have no JSON-LDcontentRating, soratingfalls back to the Movie Info value, and the hero title contains an HTML entity (Season 5 – The Bear), unescaped by_text. - Browse HTML vs JSON page size. The server-rendered browse page shows 28 tiles and a
pageInfoscript withendCursor: "Mjc=", but/cnapi/browse/returns 30 per page (endCursor: "Mjk="). Do not mix cursors between the two.?page=2on the HTML URL returned 56 cumulative tiles, but its JSON-LDItemListstopped at 50 positions, so do not rely on the HTML JSON-LD for long lists. - Cursors decode to a 0-based offset (
MA=== 0,Mjk== 29). Treat them as opaque and passendCursorback. The rawMjk=, the encodedMjk%3D, andquote(unquote(...), safe="")of either all returned HTTP 200 and the same next cursor (NTk=);rt_browseunquotes first so a cursor copied from a URL is not double-encoded. A filter path with a comma (genres:comedy,drama~sort:popular) worked both raw and with the comma as%2C. - Truncation is explicit.
rt_browsereturnscomplete=False(and the example prints it) whenmax_pagesstops it whilehasNextPageis still true. A page that fails after retries raises; it is never treated as the end of the list. The browser block raises if "Load more" adds no tiles after a promo-close retry, and prints when it stops atMAX_CLICKS. - Consent banner: leave it alone. A fresh browser shows a OneTrust notice at the bottom ("This site is now part of Versant. By continuing, you agree to our Terms...") with a "Continue" button (
id="onetrust-accept-btn-handler"). Clicking it accepts the terms and reloads the page:performance.timeOriginchanged anddocument.readyStatewent back toloadingwithin 0.4 s. In a cold run, the tile read right after that click returned 0 tiles and the script exited with no data. With the banner still showing, "Load more" clicks worked (28 -> 58 tiles), so the browser block never clicks it. - App-promo modal blocks clicks. An app-promo modal (
<rt-app-modal-content>, close buttonaria-label="Close",data-rtappmanager="btnClose:click") did not appear on the first page view of a fresh browser but did appear on the second. While it was open,elementFromPointat the "Load more" button returned the modal and the click added nothing (28 tiles stayed 28). Closing it does not reload the page.close_app_promo()clicks that exact element (rt-app-modal-content [data-rtappmanager="btnClose:click"], an<action-icon>) only if it is visible, then confirms it went away; it never clicks some other "Close" button. - Load more updates the URL. Each click appends 30 tiles in place and rewrites the URL to
?page=2,?page=3via history (sametimeOrigin, no reload). - Confirm the page before reading it. Right after
goto_urlfrom the title page to a browse page,location.hrefwas already the new URL butreadyStatewasloading, theninteractive;wait_for_load()alone only checksreadyState, which can still be the old document's.open_page()waits for the requested URL,complete, and a page-specific selector together, and raises withpage_info()instead of reading 0 tiles. Match AX button names case-insensitively. - Browser call timeouts. A slow
cdpround trip raises aTimeoutErrorsubclass, butjsre-raises a timed-outRuntime.evaluateasRuntimeError("Runtime.evaluate timed out; ..."), soexcept TimeoutErroralone misses it.is_harness_timeoutaccepts both;open_pageandjs_retryretry only those and re-raise real JS errors; other code lets them raise. - Region redirects. Bare
https://rottentomatoes.com/redirects tohttps://www.rottentomatoes.com/. Title, browse and/cnapi/URLs returned HTTP 200 with no redirect from both vantage points used, but both were in the US (the site's OneTrust geolocation reported"country":"US"for the cloud browser and for the plain-HTTP network). Behavior from other countries was not tested; checklocation.hrefin the browser, or theurlkeyrt_titlereturns (the final URL after redirects), if you run elsewhere. - Selector drift. The
data-qa="item-label"/item-valueandcast-and-crew-cardmarkup are server-rendered web components. The embedded JSON (media-scorecard-json, JSON-LD) is the more stable source; prefer it when a field exists in both. - Pacing. About 45 HTTP requests at 1-1.5 s spacing returned no 429s or challenges. No published rate limit was found, so keep at least ~1 s between requests.
rt_getretries 429/500/502/503/504 and network errors up to 4 tries, honoringRetry-After(seconds or HTTP-date) up toMAX_WAIT(120 s) or else backing off 2, 4, 8 s, then raises. ARetry-Afterabove 120 s raises at once instead of sleeping, and there is no sleep after the last attempt. Inrt_browsethat raise ends the run; in the enrich loop of block 1 a 403, or a 429 whose retries ran out, ends the run; a title that failed with any other HTTP error (such as a 404) or with network or 5xx errors after its retries is recorded infailed, and the loop goes on to the next title after the same 1.5 s wait as a success. The over-120 s raise (RuntimeError) is not caught and also ends the run. - User-Agent. The HTTP block sends urllib's own truthful
Python-urllib/3.x(no override), which got HTTP 200 on title, browse and/cnapi/URLs. It uses its ownrt_getrather than the harnesshttp_get, becausehttp_getsends a browser-styleMozilla/5.0UA and does not retry.
What does not work
/napi/browse/...(an older endpoint name) returns HTTP 404. Use/cnapi/browse/..../cnapi/browse/<unknown_list>/...returns HTTP 404.- Removed or wrong slugs (
/m/<bad_slug>) return HTTP 404 (urllib.error.HTTPError, not retried); catch and skip. - Full cast is not on the main title page (JSON-LD has at most the top 5 names, sometimes 0); it needs the extra
/cast-and-crewrequest. - Exact audience rating counts are not exposed for TV series, only banded text like
"10,000+ Ratings". - No 403, 429, challenge or captcha was seen on 2026-10-23 on either path. A 429 or 5xx on the HTTP path gets the bounded
rt_getretry above (at most 4 tries,Retry-Afterhonored up to 120 s, then it raises); anything else (a 403, challenge, captcha, or a 429 in the browser) means stop and back off. Neither block changes its client identity, session or network to get past it.