Media and entertainment

tvmaze.com

TVmaze - Show Search, Episode Lists, TV and Streaming Schedule, Cast (official API + site)

Notes an AI agent reads before it works on tvmaze.com: what to open, what to call, what breaks. Written by browser-harness contributors after real runs, and kept on GitHub under the MIT license.

Give it to your agent

Any coding agent

Start the task with this line. The Markdown version is the same notes without the page around them.

Read https://browser-use.com/skills/tvmaze.md, then <your task>

Browser Harness

Save the files into your workspace and turn domain skills on. The code below calls its helpers, such as http_get and js. What Browser Harness is.

d="${BH_AGENT_WORKSPACE:-$HOME/.config/browser-harness/agent-workspace}/domain-skills/tvmaze"
mkdir -p "$d" && cd "$d"
curl -sLO https://raw.githubusercontent.com/browser-use/browser-harness/main/agent-workspace/domain-skills/tvmaze/scraping.md
export BH_DOMAIN_SKILLS=1

Field-tested against api.tvmaze.com and www.tvmaze.com on 2026-11-05 using a Browser Use cloud browser, logged out. Blocks 1-4 call the official TVmaze API (https://www.tvmaze.com/api) with fetch() from a www.tvmaze.com tab; block 5 reads the public site. About 110 API calls and 25 page loads: every answer was HTTP 200 or a documented 404/422, no 429, no challenge, no login or consent wall.

robots.txt (fetched 2026-11-05): www.tvmaze.com has User-agent: * disallowing only /stats, /offer, /users/*/*/followed and /users/*/*/watched, with no named AI-agent groups; api.tvmaze.com/robots.txt is HTTP 404 (no rules). This skill opens /robots.txt, /search, /shows/<id>/<slug>/episodes and /shows/<id>/<slug>/cast on www and calls the documented API endpoints.

Quick summary

  • Use the API. Public, keyless, CORS enabled on every endpoint (docs). Twelve endpoints cover the task:
    • /search/shows?q= (fuzzy, best first, max 10), /singlesearch/shows?q= (one show or 404), /lookup/shows?imdb=|thetvdb=.
    • /shows/<id>?embed[]=seasons&embed[]=nextepisode&embed[]=previousepisode, /shows/<id>/episodes[?specials=1], /shows/<id>/episodebynumber?season=&number=.
    • /schedule?country=<ISO2>&date=<YYYY-MM-DD> (broadcast/cable) and /schedule/web?date=[&country=] (streaming).
    • /shows/<id>/cast, /episodes/<id>/guestcast, /search/people?q=, /people/<id>/castcredits?embed=show.
  • Rate limit (docs): "at least 20 calls every 10 seconds per IP address", HTTP 429 above that. The blocks run one call at a time with 0.6 s between calls, back off 10 s once on a 429 (the docs say to retry after a small pause), then stop.
  • Caching (docs): API output is cached 60 minutes (cache-control: public, max-age=3600 observed), so site edits take up to an hour to show.
  • License (docs): API data is CC BY-SA; credit TVmaze with a link back.
  • User-Agent: the docs recommend (do not require) a descriptive User-Agent. The blocks keep the browser's own; do not change it.
  • The site works too (block 5): search, episode guide and cast pages are server-rendered HTML and match the API.

What works

Blocks 1-4 share the same helpers: on_tvmaze_tab() opens https://www.tvmaze.com/robots.txt once (a tiny allowed page) and later blocks reuse any www.tvmaze.com tab. api(path, params) runs one fetch() in that tab, polls for the answer (episode lists reach 725 KB, schedules 515 KB), reads big bodies back in 500,000-character slices, and returns parsed JSON. It raises NotFound on 404, backs off once then exits on 429, retries 5xx and network errors twice, and raises on anything else (422 included), so a failure never looks like an empty result.

1. Find a show: fuzzy search, single best match, IMDb / TheTVDB id (browser, official API)

import json, time, urllib.parse
 
QUERIES = ["the office", "breking bad", "zzqx no such show 98712"]   # fuzzy search, typo, no match
SINGLE = ["breaking bad", "top gear"]                                 # best single match (or 404)
LOOKUPS = [{"imdb": "tt0386676"}, {"thetvdb": "81189"}]               # by IMDb / TheTVDB id
 
API = "https://api.tvmaze.com"
PACE = 0.6             # seconds between calls; docs: "at least 20 calls every 10 seconds per IP address"
CHUNK = 500_000        # characters per js() read when pulling a big body out of the tab
 
class NotFound(Exception):
    """TVmaze answered 404: no such show/episode/person, or singlesearch/lookup found nothing."""
 
def on_tvmaze_tab():
    """API calls run as fetch() from a logged-out www.tvmaze.com tab (the docs: every endpoint is CORS enabled)."""
    try:
        if js("location.origin") == "https://www.tvmaze.com":
            return
    except Exception:
        pass
    new_tab("https://www.tvmaze.com/robots.txt")   # tiny allowed page on the site
    wait_for_load()
 
START_JS = """(() => { window.__tm = null;
  fetch(%s, {credentials: 'omit'})
    .then(async r => { const t = await r.text(); window.__tm = {status: r.status, len: t.length, body: t}; })
    .catch(e => { window.__tm = {status: 0, len: 0, body: '', err: String(e)}; });
  return 1; })()"""
 
def api(path, params=None, tries=3):
    """Serial GET of api.tvmaze.com<path>. Parsed JSON; NotFound on 404; one 10 s back-off on 429, then stops;
    retries 5xx/network errors with backoff, then raises (so a failure never looks like an empty result)."""
    url = API + path + ("?" + urllib.parse.urlencode(params, doseq=True) if params else "")
    for attempt in range(tries):
        js(START_JS % json.dumps(url))
        t0 = time.time()
        while not (meta := js("window.__tm && {status: window.__tm.status, len: window.__tm.len, err: window.__tm.err}")):
            if time.time() - t0 > 60:
                raise RuntimeError(f"{url}: no answer in 60 s; stopping")
            time.sleep(0.2)
        body = "".join(js(f"window.__tm.body.slice({i}, {i + CHUNK})") for i in range(0, meta["len"], CHUNK))
        js("window.__tm = null")
        time.sleep(PACE)
        status = meta["status"]
        if status == 200:
            return json.loads(body)
        if status == 404:
            raise NotFound(f"{url}: {body[:120]}")
        if status == 429:
            if attempt == 0:
                time.sleep(10)   # docs: back off a few seconds and retry; once only
                continue
            raise SystemExit(f"HTTP 429 twice on {url}: stop and slow down, do not work around it")
        if (status >= 500 or status == 0) and attempt < tries - 1:
            time.sleep(5 * 2 ** attempt)
            continue
        raise RuntimeError(f"{url}: HTTP {status}: {meta.get('err') or body[:200]}")
 
def channel(show):
    """Broadcast network or streaming web channel, with country when known."""
    ch = show.get("network") or show.get("webChannel") or {}
    country = (ch.get("country") or {}).get("code")
    return f"{ch.get('name', '?')}{' ' + country if country else ''}"
 
def show_row(s):
    return {
        "id": s["id"], "name": s["name"], "channel": channel(s), "type": s["type"], "language": s["language"],
        "status": s["status"], "premiered": s["premiered"], "ended": s["ended"],
        "rating": s["rating"]["average"], "imdb": s["externals"].get("imdb"), "url": s["url"],
    }
 
on_tvmaze_tab()
for q in QUERIES:
    hits = api("/search/shows", {"q": q})                    # list of {score, show}; at most 10, best first
    print(f"{q!r}: {len(hits)} hits")
    for h in hits[:5]:
        r = show_row(h["show"])
        print(f"  {h['score']:.3f}  #{r['id']:<6} {r['name']:<28} {r['channel']:<16} {r['premiered']} -> {r['ended'] or r['status']}  rating {r['rating']}")
for q in SINGLE:
    try:
        r = show_row(api("/singlesearch/shows", {"q": q}))
        print(f"singlesearch {q!r}: #{r['id']} {r['name']} ({r['channel']}, {r['premiered']})")
    except NotFound:
        print(f"singlesearch {q!r}: no match")
for ids in LOOKUPS:
    try:
        r = show_row(api("/lookup/shows", ids))
        print(f"lookup {ids}: #{r['id']} {r['name']} ({r['channel']})")
    except NotFound:
        print(f"lookup {ids}: no match")
print(json.dumps(show_row(api("/search/shows", {"q": QUERIES[0]})[0]["show"]), indent=1))

Output observed on 2026-11-05:

'the office': 10 hits
  1.185  #526    The Office                   NBC US           2005-03-24 -> 2013-05-16  rating 8.5
  1.183  #1292   The Office                   BBC Two GB       2001-07-09 -> 2003-12-27  rating 7.8
  1.169  #69044  The Office                   Prime Video      2024-10-18 -> 2024-10-18  rating 4.9
  1.169  #57704  The Office                   JioHotstar IN    2019-06-28 -> 2019-09-15  rating None
  1.150  #57168  The Office                   CBS US           1995-03-11 -> 1995-04-15  rating None
'breking bad': 9 hits
  0.556  #169    Breaking Bad                 AMC US           2008-01-20 -> 2019-10-11  rating 9.2
  0.527  #27845  Breaking Bad: Original Minisodes AMC US           2009-02-17 -> 2011-07-11  rating 7
  0.460  #6101   Breaking Band                AXS TV US        2016-01-24 -> 2016-02-28  rating None
  0.409  #37136  Breaking the Band            REELZ US         2018-06-10 -> Running  rating None
  0.356  #39967  Bradley Walsh & Son: Breaking Dad ITV1 GB          2019-01-02 -> Running  rating 5.2
'zzqx no such show 98712': 0 hits
singlesearch 'breaking bad': #169 Breaking Bad (AMC US, 2008-01-20)
singlesearch 'top gear': #522 Top Gear (BBC One GB, 2002-10-20)
lookup {'imdb': 'tt0386676'}: #526 The Office (NBC US)
lookup {'thetvdb': '81189'}: #169 Breaking Bad (AMC US)
{
 "id": 526,
 "name": "The Office",
 "channel": "NBC US",
 "type": "Scripted",
 "language": "English",
 "status": "Ended",
 "premiered": "2005-03-24",
 "ended": "2013-05-16",
 "rating": 8.5,
 "imdb": "tt0386676",
 "url": "https://www.tvmaze.com/shows/526/the-office"
}

2. Episode list: seasons, regular episodes, specials, previous and next episode (browser, official API)

import json, time, urllib.parse
from datetime import datetime, timezone
 
SHOWS = [526, 169, 83]          # TVmaze show ids (block 1): The Office (US), Breaking Bad, The Simpsons
BY_NUMBER = (526, 2, 1)         # show id, season, episode -> one episode
 
API = "https://api.tvmaze.com"
PACE = 0.6             # seconds between calls; docs: "at least 20 calls every 10 seconds per IP address"
CHUNK = 500_000        # characters per js() read when pulling a big body out of the tab
 
class NotFound(Exception):
    """TVmaze answered 404: no such show/episode/person, or singlesearch/lookup found nothing."""
 
def on_tvmaze_tab():
    """API calls run as fetch() from a logged-out www.tvmaze.com tab (the docs: every endpoint is CORS enabled)."""
    try:
        if js("location.origin") == "https://www.tvmaze.com":
            return
    except Exception:
        pass
    new_tab("https://www.tvmaze.com/robots.txt")   # tiny allowed page on the site
    wait_for_load()
 
START_JS = """(() => { window.__tm = null;
  fetch(%s, {credentials: 'omit'})
    .then(async r => { const t = await r.text(); window.__tm = {status: r.status, len: t.length, body: t}; })
    .catch(e => { window.__tm = {status: 0, len: 0, body: '', err: String(e)}; });
  return 1; })()"""
 
def api(path, params=None, tries=3):
    """Serial GET of api.tvmaze.com<path>. Parsed JSON; NotFound on 404; one 10 s back-off on 429, then stops;
    retries 5xx/network errors with backoff, then raises (so a failure never looks like an empty result)."""
    url = API + path + ("?" + urllib.parse.urlencode(params, doseq=True) if params else "")
    for attempt in range(tries):
        js(START_JS % json.dumps(url))
        t0 = time.time()
        while not (meta := js("window.__tm && {status: window.__tm.status, len: window.__tm.len, err: window.__tm.err}")):
            if time.time() - t0 > 60:
                raise RuntimeError(f"{url}: no answer in 60 s; stopping")
            time.sleep(0.2)
        body = "".join(js(f"window.__tm.body.slice({i}, {i + CHUNK})") for i in range(0, meta["len"], CHUNK))
        js("window.__tm = null")
        time.sleep(PACE)
        status = meta["status"]
        if status == 200:
            return json.loads(body)
        if status == 404:
            raise NotFound(f"{url}: {body[:120]}")
        if status == 429:
            if attempt == 0:
                time.sleep(10)   # docs: back off a few seconds and retry; once only
                continue
            raise SystemExit(f"HTTP 429 twice on {url}: stop and slow down, do not work around it")
        if (status >= 500 or status == 0) and attempt < tries - 1:
            time.sleep(5 * 2 ** attempt)
            continue
        raise RuntimeError(f"{url}: HTTP {status}: {meta.get('err') or body[:200]}")
 
def ep_row(e):
    return {
        "id": e["id"], "season": e["season"], "number": e["number"], "type": e["type"], "name": e["name"],
        "airdate": e["airdate"], "airstamp": e["airstamp"], "runtime": e["runtime"],
        "rating": e["rating"]["average"], "url": e["url"],
    }
 
on_tvmaze_tab()
now = datetime.now(timezone.utc)
for sid in SHOWS:
    show = api(f"/shows/{sid}", {"embed[]": ["seasons", "nextepisode", "previousepisode"]})
    seasons = show["_embedded"]["seasons"]
    eps = [ep_row(e) for e in api(f"/shows/{sid}/episodes", {"specials": 1})]   # specials=1 adds specials
    regular = [e for e in eps if e["type"] == "regular"]
    aired = [e for e in regular if e["airstamp"] and datetime.fromisoformat(e["airstamp"]) <= now]
    print(f"\n#{sid} {show['name']} ({show['status']}): {len(seasons)} seasons, {len(regular)} regular episodes "
          f"({len(aired)} aired), {len(eps) - len(regular)} specials")
    for s in seasons[:3] + (["..."] if len(seasons) > 4 else []) + seasons[-1:] * (len(seasons) > 3):
        if s == "...":
            print("   ...")
            continue
        n = sum(1 for e in regular if e["season"] == s["number"])
        print(f"   S{s['number']:<4} {n:>3} eps (episodeOrder {s['episodeOrder']})  {s['premiereDate']} -> {s['endDate']}")
    for e in regular[:2] + regular[-1:]:
        print(f"   {e['season']}x{e['number']:<3} {e['airdate']}  {e['name']}  rating {e['rating']}")
    for e in [e for e in eps if e["type"] != "regular"][:2]:
        print(f"   special ({e['type']}) S{e['season']} {e['airdate']}  {e['name']}")
    for key in ("previousepisode", "nextepisode"):
        e = show["_embedded"].get(key)   # absent when there is none (ended show, or nothing scheduled)
        code = e and (f"{e['season']}x{e['number']}" if e["number"] is not None else f"S{e['season']} special")
        print(f"   {key}: " + (f"{code} {e['name']} airstamp {e['airstamp']}" if e else "none"))
 
sid, season, number = BY_NUMBER
e = ep_row(api(f"/shows/{sid}/episodebynumber", {"season": season, "number": number}))
print("\nepisodebynumber:", json.dumps(e, indent=1))
try:
    api(f"/shows/{sid}/episodebynumber", {"season": season, "number": 99})
except NotFound as err:
    print("episodebynumber 2x99:", err)

Output observed on 2026-11-05:

 
#526 The Office (Ended): 9 seasons, 202 regular episodes (202 aired), 1 specials
   S1      6 eps (episodeOrder 6)  2005-03-24 -> 2005-04-26
   S2     22 eps (episodeOrder 22)  2005-09-20 -> 2006-05-11
   S3     25 eps (episodeOrder 25)  2006-09-21 -> 2007-05-17
   ...
   S9     25 eps (episodeOrder 27)  2012-09-20 -> 2013-05-16
   1x1   2005-03-24  Pilot  rating 7.6
   1x2   2005-03-29  Diversity Day  rating 8.1
   9x25  2013-05-16  Finale (2)  rating 9
   special (insignificant_special) S9 2013-05-16  Retrospective
   previousepisode: 9x25 Finale (2) airstamp 2013-05-17T01:30:00+00:00
   nextepisode: none
 
#169 Breaking Bad (Ended): 5 seasons, 62 regular episodes (62 aired), 1 specials
   S1      7 eps (episodeOrder 7)  2008-01-20 -> 2008-03-09
   S2     13 eps (episodeOrder 13)  2009-03-08 -> 2009-05-31
   S3     13 eps (episodeOrder 13)  2010-03-21 -> 2010-06-13
   ...
   S5     16 eps (episodeOrder 16)  2012-07-15 -> 2019-10-11
   1x1   2008-01-20  Pilot  rating 8.3
   1x2   2008-01-27  Cat's in the Bag...  rating 7.9
   5x16  2013-09-29  Felina  rating 8
   special (significant_special) S5 2019-10-11  El Camino: A Breaking Bad Movie
   previousepisode: S5 special El Camino: A Breaking Bad Movie airstamp 2019-10-12T02:00:00+00:00
   nextepisode: none
 
#83 The Simpsons (Running): 38 seasons, 809 regular episodes (806 aired), 3 specials
   S1     13 eps (episodeOrder 13)  1989-12-17 -> 1990-05-13
   S2     22 eps (episodeOrder 22)  1990-10-11 -> 1991-07-11
   S3     24 eps (episodeOrder 24)  1991-09-19 -> 1992-08-27
   ...
   S38     8 eps (episodeOrder None)  2026-09-27 -> 2026-11-22
   1x1   1989-12-17  Simpsons Roasting on an Open Fire  rating 7.9
   1x2   1990-01-14  Bart the Genius  rating 7.5
   38x8   2026-11-22  Curb Your Expectations  rating None
   special (insignificant_special) S21 2010-01-10  The Simpsons 20th Anniversary Special - In 3-D! on Ice!
   special (insignificant_special) S24 2012-10-19  Stand Up to Cancer
   previousepisode: 38x5 Testing 1-2-3 airstamp 2026-11-02T01:00:00+00:00
   nextepisode: 38x6 The Kosmic Kruiser airstamp 2026-11-09T01:30:00+00:00
 
episodebynumber: {
 "id": 47646,
 "season": 2,
 "number": 1,
 "type": "regular",
 "name": "The Dundies",
 "airdate": "2005-09-20",
 "airstamp": "2005-09-21T01:00:00+00:00",
 "runtime": 30,
 "rating": 8.1,
 "url": "https://www.tvmaze.com/episodes/47646/the-office-2x01-the-dundies"
}
episodebynumber 2x99: https://api.tvmaze.com/shows/526/episodebynumber?season=2&number=99: {"name":"Not Found","message":"Unknown episode","code":0,"status":404}

3. Schedule: what airs on TV in one country, and what drops on streaming, for one day (browser, official API)

import json, time, urllib.parse
from collections import Counter, defaultdict
from datetime import date
 
DATE = date.today().isoformat()   # YYYY-MM-DD; other formats are HTTP 422
COUNTRY = "US"                    # ISO 3166-1 alpha-2; the United Kingdom is GB (UK is HTTP 422)
PRIME = ("20:00", "23:00")        # local primetime window for the broadcast listing
 
API = "https://api.tvmaze.com"
PACE = 0.6             # seconds between calls; docs: "at least 20 calls every 10 seconds per IP address"
CHUNK = 500_000        # characters per js() read when pulling a big body out of the tab
 
class NotFound(Exception):
    """TVmaze answered 404: no such show/episode/person, or singlesearch/lookup found nothing."""
 
def on_tvmaze_tab():
    """API calls run as fetch() from a logged-out www.tvmaze.com tab (the docs: every endpoint is CORS enabled)."""
    try:
        if js("location.origin") == "https://www.tvmaze.com":
            return
    except Exception:
        pass
    new_tab("https://www.tvmaze.com/robots.txt")   # tiny allowed page on the site
    wait_for_load()
 
START_JS = """(() => { window.__tm = null;
  fetch(%s, {credentials: 'omit'})
    .then(async r => { const t = await r.text(); window.__tm = {status: r.status, len: t.length, body: t}; })
    .catch(e => { window.__tm = {status: 0, len: 0, body: '', err: String(e)}; });
  return 1; })()"""
 
def api(path, params=None, tries=3):
    """Serial GET of api.tvmaze.com<path>. Parsed JSON; NotFound on 404; one 10 s back-off on 429, then stops;
    retries 5xx/network errors with backoff, then raises (so a failure never looks like an empty result)."""
    url = API + path + ("?" + urllib.parse.urlencode(params, doseq=True) if params else "")
    for attempt in range(tries):
        js(START_JS % json.dumps(url))
        t0 = time.time()
        while not (meta := js("window.__tm && {status: window.__tm.status, len: window.__tm.len, err: window.__tm.err}")):
            if time.time() - t0 > 60:
                raise RuntimeError(f"{url}: no answer in 60 s; stopping")
            time.sleep(0.2)
        body = "".join(js(f"window.__tm.body.slice({i}, {i + CHUNK})") for i in range(0, meta["len"], CHUNK))
        js("window.__tm = null")
        time.sleep(PACE)
        status = meta["status"]
        if status == 200:
            return json.loads(body)
        if status == 404:
            raise NotFound(f"{url}: {body[:120]}")
        if status == 429:
            if attempt == 0:
                time.sleep(10)   # docs: back off a few seconds and retry; once only
                continue
            raise SystemExit(f"HTTP 429 twice on {url}: stop and slow down, do not work around it")
        if (status >= 500 or status == 0) and attempt < tries - 1:
            time.sleep(5 * 2 ** attempt)
            continue
        raise RuntimeError(f"{url}: HTTP {status}: {meta.get('err') or body[:200]}")
 
def channel(show, web=False):
    """Channel name; a show can have both a network and a webChannel, so say which one wins."""
    ch = (show.get("webChannel") or show.get("network")) if web else (show.get("network") or show.get("webChannel"))
    return (ch or {}).get("name", "?")
 
def code(e):
    return f"{e['season']}x{e['number']}" if e["number"] is not None else f"S{e['season']} special"
 
on_tvmaze_tab()
 
# 1. Broadcast/cable schedule for one country and day. The show is under e["show"].
tv = api("/schedule", {"country": COUNTRY, "date": DATE})
print(f"/schedule {COUNTRY} {DATE}: {len(tv)} episodes on {len({channel(e['show']) for e in tv})} channels")
by_ch = defaultdict(list)
for e in tv:
    if PRIME[0] <= e["airtime"] < PRIME[1]:     # airtime is local to the channel's country, airstamp is UTC
        by_ch[channel(e["show"])].append(f"{e['airtime']} {e['show']['name']} {code(e)}")
for ch in sorted(by_ch)[:8]:
    print(f"  {ch:<10} " + " | ".join(by_ch[ch]))
late = [e for e in tv if e["airdate"] != DATE]   # after-midnight airings belong to the previous broadcast day
print(f"  {len(late)} rows carry airdate != {DATE}, e.g.",
      json.dumps({k: late[0][k] for k in ("airdate", "airtime", "airstamp", "runtime", "url")} | {"show": late[0]["show"]["name"]}) if late else "none")
 
# 2. Streaming schedule. The show is under e["_embedded"]["show"].
#    No country = local + global web channels; country=XX = only that country's local channels; country="" = only global ones.
for label, params in [("all", {"date": DATE}), (COUNTRY + " local", {"date": DATE, "country": COUNTRY}),
                      ("global", {"date": DATE, "country": ""})]:
    web = api("/schedule/web", params)
    top = Counter(channel(e["_embedded"]["show"], web=True) for e in web).most_common(5)
    print(f"/schedule/web {label}: {len(web)} episodes; top channels {top}")
    if label == "global":
        for e in [e for e in web if channel(e["_embedded"]["show"], web=True) == "Netflix"][:4]:
            print(f"  Netflix  {e['_embedded']['show']['name']} {code(e)} {e['name']!r} airstamp {e['airstamp']}")
 
try:
    api("/schedule", {"country": "UK", "date": DATE})
except RuntimeError as err:
    print("country=UK:", err)

Output observed on 2026-11-05:

/schedule US 2026-11-05: 80 episodes on 26 channels
  A&E        21:00 After the First 48 13x3 | 22:00 Killer Investigations 2x3
  ABC        20:00 9-1-1 10x4 | 21:00 9-1-1: Nashville 2x4 | 22:00 Grey's Anatomy 23x4
  AMC        21:00 TNA iMPACT! 2026x45
  BYUtv      20:30 Studio C 23x3
  Bravo      20:00 The Real Housewives of Orange County 20x18 | 21:15 Southern Charm S12 special | 21:45 Watch What Happens Live 23x180
  CBS        20:00 Georgie & Mandy's First Marriage 3x5 | 20:31 Eternally Yours 1x5 | 21:00 Elsbeth 4x4 | 22:00 Cupertino 1x5
  FOX        20:00 Hell's Kitchen 25x7 | 21:02 Special Forces: World's Toughest Test 5x7
  HBO        21:00 War 1x6
  2 rows carry airdate != 2026-11-05, e.g. {"airdate": "2026-11-04", "airtime": "00:35", "airstamp": "2026-11-05T05:35:00+00:00", "runtime": 60, "url": "https://www.tvmaze.com/episodes/3798341/late-night-with-seth-meyers-2026-11-04-john-malkovich-gabby-windey", "show": "Late Night with Seth Meyers"}
/schedule/web all: 253 episodes; top channels [('Tencent QQ', 78), ('iQIYI', 78), ('Mango TV', 13), ('Youku', 10), ('YouTube', 7)]
/schedule/web US local: 13 episodes; top channels [('ABC News Live', 4), ('Peacock', 2), ('NBC.com', 2), ('Hulu', 2), ('CBS News', 1)]
/schedule/web global: 106 episodes; top channels [('iQIYI', 78), ('YouTube', 7), ('Netflix', 6), ('HBO Max', 3), ('Prime Video', 2)]
  Netflix  Black Doves 2x1 'Black Door' airstamp 2026-11-05T12:00:00+00:00
  Netflix  Black Doves 2x2 'Put Your Head on my Shoulder' airstamp 2026-11-05T12:00:00+00:00
  Netflix  Black Doves 2x3 'P Is for Poison' airstamp 2026-11-05T12:00:00+00:00
  Netflix  Black Doves 2x4 'The Opportunity to Become Savage' airstamp 2026-11-05T12:00:00+00:00
country=UK: https://api.tvmaze.com/schedule?country=UK&date=2026-11-05: HTTP 422: {"name":"Unprocessable entity","message":"Not a valid ISO country code","code":0,"status":422}

4. Cast: main cast, voice cast, one episode's guest cast, one person's credits (browser, official API)

import json, time, urllib.parse
from collections import Counter
 
SHOW = 526          # The Office (US); show ids come from block 1
VOICE_SHOW = 83     # The Simpsons: a voice cast
EPISODE = 47640     # The Office 1x01 "Pilot" (episode ids come from block 2)
PERSON_QUERY = "rainn wilson"
TOP = 10
 
API = "https://api.tvmaze.com"
PACE = 0.6             # seconds between calls; docs: "at least 20 calls every 10 seconds per IP address"
CHUNK = 500_000        # characters per js() read when pulling a big body out of the tab
 
class NotFound(Exception):
    """TVmaze answered 404: no such show/episode/person, or singlesearch/lookup found nothing."""
 
def on_tvmaze_tab():
    """API calls run as fetch() from a logged-out www.tvmaze.com tab (the docs: every endpoint is CORS enabled)."""
    try:
        if js("location.origin") == "https://www.tvmaze.com":
            return
    except Exception:
        pass
    new_tab("https://www.tvmaze.com/robots.txt")   # tiny allowed page on the site
    wait_for_load()
 
START_JS = """(() => { window.__tm = null;
  fetch(%s, {credentials: 'omit'})
    .then(async r => { const t = await r.text(); window.__tm = {status: r.status, len: t.length, body: t}; })
    .catch(e => { window.__tm = {status: 0, len: 0, body: '', err: String(e)}; });
  return 1; })()"""
 
def api(path, params=None, tries=3):
    """Serial GET of api.tvmaze.com<path>. Parsed JSON; NotFound on 404; one 10 s back-off on 429, then stops;
    retries 5xx/network errors with backoff, then raises (so a failure never looks like an empty result)."""
    url = API + path + ("?" + urllib.parse.urlencode(params, doseq=True) if params else "")
    for attempt in range(tries):
        js(START_JS % json.dumps(url))
        t0 = time.time()
        while not (meta := js("window.__tm && {status: window.__tm.status, len: window.__tm.len, err: window.__tm.err}")):
            if time.time() - t0 > 60:
                raise RuntimeError(f"{url}: no answer in 60 s; stopping")
            time.sleep(0.2)
        body = "".join(js(f"window.__tm.body.slice({i}, {i + CHUNK})") for i in range(0, meta["len"], CHUNK))
        js("window.__tm = null")
        time.sleep(PACE)
        status = meta["status"]
        if status == 200:
            return json.loads(body)
        if status == 404:
            raise NotFound(f"{url}: {body[:120]}")
        if status == 429:
            if attempt == 0:
                time.sleep(10)   # docs: back off a few seconds and retry; once only
                continue
            raise SystemExit(f"HTTP 429 twice on {url}: stop and slow down, do not work around it")
        if (status >= 500 or status == 0) and attempt < tries - 1:
            time.sleep(5 * 2 ** attempt)
            continue
        raise RuntimeError(f"{url}: HTTP {status}: {meta.get('err') or body[:200]}")
 
def cast_row(c):
    p, ch = c["person"], c["character"]
    return {"person": p["name"], "person_id": p["id"], "character": ch["name"], "character_id": ch["id"],
            "self": c["self"], "voice": c["voice"], "country": (p.get("country") or {}).get("code"),
            "birthday": p["birthday"], "person_url": p["url"]}
 
on_tvmaze_tab()
 
# 1. Main cast, ordered by the character's number of appearances (docs).
cast = [cast_row(c) for c in api(f"/shows/{SHOW}/cast")]
print(f"show {SHOW} main cast: {len(cast)}")
for r in cast[:TOP]:
    print(f"  {r['person']:<22} as {r['character']}")
print(json.dumps(cast[0], indent=1))
 
# 2. Animated show: voice=True rows, and one person can play many characters.
vc = [cast_row(c) for c in api(f"/shows/{VOICE_SHOW}/cast")]
per_person = Counter(r["person"] for r in vc)
print(f"\nshow {VOICE_SHOW} cast rows: {len(vc)} ({sum(r['voice'] for r in vc)} voice, {sum(r['self'] for r in vc)} self), "
      f"{len(per_person)} people; most characters: {per_person.most_common(3)}")
 
# 3. Guest cast of one episode (main cast who appear can be listed here too).
guests = [cast_row(c) for c in api(f"/episodes/{EPISODE}/guestcast")]
print(f"\nepisode {EPISODE} guest cast: {len(guests)}: " + ", ".join(f"{g['person']} ({g['character']})" for g in guests[:6]))
 
# 4. A person's show credits: people search, then /people/<id>/castcredits with the show embedded.
hits = api("/search/people", {"q": PERSON_QUERY})
pid, name = hits[0]["person"]["id"], hits[0]["person"]["name"]
credits = api(f"/people/{pid}/castcredits", {"embed": "show"})
print(f"\n{name} (#{pid}, score {hits[0]['score']:.2f}): {len(credits)} show credits")
for c in credits[:6]:
    s = c["_embedded"]["show"]
    print(f"  {s['name']:<32} {s['premiered']}  as {c['_links']['character']['name']}{'  (self)' if c['self'] else ''}{'  (voice)' if c['voice'] else ''}")

Output observed on 2026-11-05:

show 526 main cast: 25
  Rainn Wilson           as Dwight Kurt Schrute
  John Krasinski         as Jim Halpert
  Leslie David Baker     as Stanley Hudson
  Phyllis Smith          as Phyllis Lapin-Vance
  Jenna Fischer          as Pam Beesly
  Brian Baumgartner      as Kevin Malone
  Angela Kinsey          as Angela Martin
  Oscar Nuñez            as Oscar Martinez
  Kate Flannery          as Meredith Palmer
  Creed Bratton          as Creed Bratton
{
 "person": "Rainn Wilson",
 "person_id": 19396,
 "character": "Dwight Kurt Schrute",
 "character_id": 85556,
 "self": false,
 "voice": false,
 "country": "US",
 "birthday": "1966-01-20",
 "person_url": "https://www.tvmaze.com/people/19396/rainn-wilson"
}
 
show 83 cast rows: 1420 (1419 voice, 1 self), 6 people; most characters: [('Dan Castellaneta', 455), ('Hank Azaria', 422), ('Harry Shearer', 353)]
 
episode 47640 guest cast: 11: Melora Hardin (Jan Levinson), David Denman (Roy Anderson), Leslie David Baker (Stanley Hudson), Phyllis Smith (Phyllis Lapin-Vance), Brian Baumgartner (Kevin Malone), Angela Kinsey (Angela Martin)
 
Rainn Wilson (#19396, score 1.00): 8 show credits
  The Office                       2005-03-24  as Dwight Kurt Schrute
  Backstrom                        2015-01-22  as Det. Lt. Everett Backstrom
  Independent Spirit Awards        1986-03-22  as Host  (self)
  Star Trek: Short Treks           2018-10-04  as Harry Mudd
  Utopia                           2020-09-25  as Dr. Michael Stearns
  We Are the Champions             2020-11-17  as Narrator  (self)  (voice)

5. Same data from the site: search results, episode guide, cast page (browser, HTML)

import json, time, urllib.parse
 
QUERY = "the office"
SHOW_URL = "https://www.tvmaze.com/shows/526/the-office"   # canonical show URL (API field `url`)
 
def open_page(url):
    """Logged-out page load on www.tvmaze.com; stops if the page is not what was asked for."""
    if not js("location.origin") == "https://www.tvmaze.com":
        new_tab(url)
    else:
        goto_url(url)
    wait_for_load()
    title = js("document.title")
    if not js("location.href").startswith("https://www.tvmaze.com/") or "TVmaze" not in title:
        raise SystemExit(f"unexpected page at {js('location.href')}: {title!r}; stop (challenge or block)")
    time.sleep(1)   # one page at a time
 
SEARCH_JS = """[...document.querySelectorAll('a[href^="/shows/"]')].filter(a => a.innerText.trim())
  .map(a => ({name: a.innerText.trim(), href: a.href,
              line: (a.closest('.cell, article, div').parentElement.innerText || '').split('\\n').map(s => s.trim()).filter(Boolean).slice(0, 3)}))"""
 
EPISODES_JS = """[...document.querySelectorAll('section.season')].map(sec => {
  const h = sec.previousElementSibling;
  return {season: h ? h.innerText.trim() : null,
          rows: [...sec.querySelectorAll('article.episode-row')].map(r => {
            const c = r.querySelectorAll(':scope > div');
            const a = r.querySelector('a[href^="/episodes/"]');
            return {number: c[0].innerText.trim(), date: c[1].innerText.trim(), name: a ? a.innerText.trim() : c[2].innerText.trim(),
                    score: (c[3] && c[3].querySelector('a span') || {}).innerText || null, href: a && a.href};
          })};
})"""
 
CAST_JS = """[...document.querySelectorAll('h2 a[href^="/people/"]')].map(a => {
  const cell = a.closest('.cell');
  const ch = cell.querySelector('a[href^="/characters/"]');
  return {person: a.innerText.trim(), character: ch ? ch.innerText.trim() : cell.innerText.split('\\n').slice(1).join(' ').trim(), person_url: a.href};
})"""
 
open_page("https://www.tvmaze.com/search?" + urllib.parse.urlencode({"q": QUERY}))
hits = js(SEARCH_JS)
print(f"site search {QUERY!r}: {len(hits)} shows on page 1")
for h in hits[:4]:
    print("  ", h["href"], "|", " / ".join(h["line"]))
 
open_page(SHOW_URL + "/episodes")
seasons = js(EPISODES_JS)
print(f"\nepisode guide: {len(seasons)} sections, {sum(len(s['rows']) for s in seasons)} rows")
for s in seasons[-2:]:   # page lists the newest season first; the last section is Specials when there are any
    print(f"  {s['season']}: {len(s['rows'])} rows; first {json.dumps(s['rows'][-1] if s['season'] != 'Specials' else s['rows'][0])}")
 
open_page(SHOW_URL + "/cast")
cast = js(CAST_JS)
print(f"\ncast page: {len(cast)} people")
for c in cast[:5]:
    print(f"  {c['person']:<20} as {c['character']}")

Output observed on 2026-11-05:

site search 'the office': 10 shows on page 1
   https://www.tvmaze.com/shows/526/the-office | The Office (NBC , 2005 - 2013) / Previous episode: Finale (2) / Starring: Rainn Wilson John Krasinski Leslie David Baker
   https://www.tvmaze.com/shows/1292/the-office | The Office (BBC Two , 2001 - 2003) / Previous episode: Christmas Special: Part 2 / Starring: Ricky Gervais Martin Freeman Mackenzie Crook
   https://www.tvmaze.com/shows/69044/the-office | The Office (Prime Video, 2024 - 2024) / Previous episode: The Sales Report / Starring: Felicity Ward Edith Poor Steen Raskopoulos
   https://www.tvmaze.com/shows/57704/the-office | The Office (JioHotstar , 2019 - 2019) / Previous episode: Founder's Day / Starring: Mukul Chadda Gopal Dutt Sayandeep Sengupta
 
episode guide: 10 sections, 203 rows
  Season 1: 6 rows; first {"number": "1", "date": "Mar 24, 2005", "name": "Pilot", "score": "7.6", "href": "https://www.tvmaze.com/episodes/47640/the-office-1x01-pilot"}
  Specials: 1 rows; first {"number": "S", "date": "May 16, 2013", "name": "Retrospective", "score": "6", "href": "https://www.tvmaze.com/episodes/47839/the-office-s09-special-retrospective"}
 
cast page: 25 people
  Rainn Wilson         as Dwight Kurt Schrute
  John Krasinski       as Jim Halpert
  Leslie David Baker   as Stanley Hudson
  Phyllis Smith        as Phyllis Lapin-Vance
  Jenna Fischer        as Pam Beesly

URL patterns (all observed)

URLWhat it is
api.tvmaze.com/search/shows?q=<q>up to 10 {score, show}, best first; page= is ignored
api.tvmaze.com/singlesearch/shows?q=<q>[&embed=episodes]one show, or 404 with body null
api.tvmaze.com/lookup/shows?imdb=<tt id> / ?thetvdb=<id>one show by external id
api.tvmaze.com/shows/<id>?embed[]=seasons&embed[]=nextepisode&embed[]=previousepisodeshow plus embeds under _embedded; embed[]=episodes, embed[]=cast also work
api.tvmaze.com/shows/<id>/episodes[?specials=1]all episodes in airing order; specials only with specials=1
api.tvmaze.com/shows/<id>/seasonsseasons with episodeOrder, premiereDate, endDate
api.tvmaze.com/shows/<id>/episodebynumber?season=<s>&number=<n>one episode, or 404 Unknown episode
api.tvmaze.com/schedule?country=<ISO2>&date=<YYYY-MM-DD>broadcast/cable day; defaults US and today
api.tvmaze.com/schedule/web?date=<d>[&country=<ISO2>]streaming day; no country = all, country=XX = local, country= = global only
api.tvmaze.com/shows/<id>/castmain cast, ordered by appearances
api.tvmaze.com/episodes/<id>/guestcastguest cast of one episode
api.tvmaze.com/search/people?q=<q> / people/<id>/castcredits?embed=showperson search, show credits
www.tvmaze.com/search?q=<q>[&page=N]site search, 10 shows per page, paginated
www.tvmaze.com/shows/<id>/<slug>/episodes / /castepisode guide (newest season first, Specials last) / cast

Fields

ObjectKeys this task needs
Showid, name, type, language, genres, status (Running, Ended, In Development, ...), premiered, ended, runtime, schedule.time / .days, rating.average, network or webChannel (name, country.code, country.timezone), externals.imdb / .thetvdb / .tvrage, image.medium / .original, summary (HTML), url, _links.previousepisode / .nextepisode
Episodeid, season, number (null for specials), type (regular, significant_special, insignificant_special), name, airdate, airtime (local), airstamp (UTC ISO), runtime, rating.average, summary (HTML or null), url
Seasonid, number, name, episodeOrder (can be null), premiereDate, endDate, network, webChannel
Cast itemperson (id, name, country, birthday, deathday, gender, url), character (id, name, url), self, voice
Cast creditself, voice, _links.show, _links.character.name, _embedded.show with embed=show
Schedule rowan Episode plus the show: show on /schedule, _embedded.show on /schedule/web

Gotchas

  • API search stops at 10. q=the and q=the&page=2 returned the same 10 ids. For more matches use a more specific query, /lookup/shows by IMDb/TheTVDB id, or the site's paginated /search?q=...&page=N (block 5).
  • Same-name shows. the office returned five shows named The Office (NBC, BBC Two, Prime Video, JioHotstar, CBS). singlesearch picks one, and the docs say it is undefined which one wins when names are identical (top gear gave BBC One #522). Pick by network/webChannel, premiered or external id.
  • ended and season endDate count specials. Breaking Bad shows ended 2019-10-11 and season 5 endDate 2019-10-11 (El Camino, a significant_special); the last regular episode, Felina 5x16, aired 2013-09-29. previousepisode can be a special with number: null.
  • Episode lists include announced future episodes. The Simpsons: 809 regular rows, 806 aired, 38x8 dated 2026-11-22. Filter on airstamp (block 2).
  • episodeOrder is not the row count. The Office season 9 says 27 with 25 regular rows; the current Simpsons season has null.
  • Schedule show key differs: e["show"] on /schedule, e["_embedded"]["show"] on /schedule/web.
  • Schedule day = broadcast day. /schedule?country=US&date=2026-11-05 held 2 rows dated 2026-11-04 (Late Night with Seth Meyers, airtime 00:35, airstamp 2026-11-05T05:35Z). airtime is local to the channel's country, airstamp is UTC; streaming rows can have airtime: "".
  • Country codes and dates are strict: country=UK is HTTP 422 Not a valid ISO country code (use GB); a date like 11/05/2026 is HTTP 422 Not a valid ISO date.
  • Netflix is a global web channel: it is missing from /schedule/web?country=US (13 rows) and present with no country (253 rows) or country= (106 rows). A show can have both network and webChannel; block 3 prefers webChannel for streaming rows.
  • The site's /schedule page is a primetime grid (20:00-22:30 columns for the chosen country); /schedule in the API is the whole day (80 US rows).
  • Cast is main cast only; guests are per episode, and main cast can show up there too (Leslie David Baker and Phyllis Smith are in the Pilot's guest cast). Animated shows list every character: The Simpsons has 1,420 cast rows from 6 people (Dan Castellaneta 455), voice: true.
  • Ratings: the API gives rating.average only; vote counts appear only on the site (episode guide rating dropdown, e.g. (16 votes)).
  • Summaries are HTML (<p>...</p>) or null.
  • 404 bodies differ: a singlesearch no-match is a 404 with body null; other misses are JSON {"name":"Not Found","message":"Unknown episode",...}.
  • CORS: a cross-origin fetch() exposes only cache-control, content-type and sometimes last-modified; no rate-limit headers are readable, so pace yourself. Open api.tvmaze.com/robots.txt as a page; a cross-origin fetch() of it failed with TypeError: Failed to fetch.
  • On a repeated 429 or a challenge page, stop. Do not retry with a different user agent, session or IP.

What does not work

  • More than 10 API search results: no paging parameter (see Gotchas).
  • Follow, watched, voting and user lists: need an account (js-needlogin on the site) and /users/*/*/followed and /users/*/*/watched are disallowed by robots.txt; not attempted.
  • /stats and /offer: disallowed by robots.txt; not used.
  • Calling faster than the documented limit: 429; not attempted beyond the paced calls above.

scraping.md · updated Nov 5, 2026 · 5,027 words

Sites change. These notes describe what worked when they were last updated, not a guarantee they still do. If something broke, the fix is a pull request to browser-harness.