用 Python 凭 IMDb ID 查电影在哪看

发布于 2026年10月15日 · 更新于2026年10月16日

片单里真正要留下的只有 tt0111161 这一格。有 IMDb ID,就不必再搜英文片名,也不必先核对上映年。把它交给作品查询,带上目标市场,当地有没有片源、在哪个平台、会员还是租买,都从这一次返回里读。片名搜索只留给从来没拿到标识的行;某地区选项为空,本身就是答案。

同一中文译名会撞上翻拍、同名剧和不同发行年。把显示名当主键,详情页今天对上 1994 年那部,明天可能换成另一个年份。标识先落地,观看选项后挂上去,片单才不会串台。

流媒体观看平台查询 API 给的是元数据和平台标题页链接,不是视频文件,也不发会员、不帮你翻区。打开本页或复制示例不会发请求。片源会变,文中的 tt0111161 不保证今天任何平台在播。

登录后从控制台复制你有权使用的平台 Key,放到服务端环境变量 OPENDATA_API_KEY。文中的 YOUR_API_KEY 只是占位。Key 走查询串,生产请求放服务端,日志、工单和截图都要脱敏。额度按次计,复制文档不扣点。

tt0111161 才是关联键

get-show 只认一个 id。IMDb 形态是 tt 加数字:本文用 tt0111161,文档里的《泰坦尼克号》示例是 tt0120338。TMDB 电影写成 movie/597,剧集写成 tv/1396,接口自己返回过的作品 ID 也能用。不要传 IMDb 网页 URL,也不要传裸数字——缺前缀的数字无法判断是电影还是剧集。

成功后把 id、imdbId、tmdbId 一起存进片单,下次按标识关联,不要按显示名去重。本地库若已有 tt 号,就直接交给 get-show,不要先搜一遍片名再「碰巧」选中同一条。country=us 只决定读 streamingOptions 的哪一把钥匙,不改变你问的是哪部电影。只有确实要多市场载荷时才省略 country。先用 get-countries 确认小写两位地区码,别假定所有 ISO 代码都覆盖,也别把 USA、US 这种大写写法直接塞进去。get-country 列出某一市场的平台和附加频道,那是能力目录,不是这部片的片源。目录里有 Netflix,不代表 tt0111161 在架。

output_language 目前是 en / es / tr / fr,管文案语言,不管能不能看。中文站点自行翻译界面标签即可,不要承诺返回中文剧情简介。只要作品级信息时给 series_granularity=show;这个操作默认是 episode,剧集会把分季分集一并拉回来,片单卡片用不上那么深。

HTTP 成功后再归组播放链接

安装 requests,密钥只从环境变量读。你真正跑脚本才会耗额度。先看 HTTP:4xx / 5xx 是传输、鉴权或额度问题,请求没跑成。再看信封 {code, message, data}:code 为 200 才是平台业务成功,HTTP 200 不够。Key 无效、额度用尽,多半停在这一层。成功时 data 就是一部作品对象,不是 data.shows,也不是候选数组。排查只留脱敏后的 code 与 message,异常栈里的完整 URL 会把 Key 带出去。

按选项的 type 归组,subscription、free、rent、buy、addon 分开摆。会员链接仍要有对应账号;附加频道可能还要另订。有 price 才展示价格,缺价格不等于免费。不要去请求 link,那是平台标题页,不是可播地址。TMDB 形态同样走这个脚本:把 SHOW_ID 换成 movie/597 即可,信封和归组逻辑不变。

import os
from collections import defaultdict

import requests

BASE = "https://api.opendata-api.com/api/project/streaming-availability"
COUNTRY = "us"
SHOW_ID = "tt0111161"


def get_show(show_id):
    try:
        response = requests.get(
            f"{BASE}/get-show",
            params={
                "key": os.environ["OPENDATA_API_KEY"],
                "id": show_id,
                "country": COUNTRY,
                "output_language": "en",
                "series_granularity": "show",
            },
            timeout=30,
        )
    except requests.RequestException:
        raise RuntimeError("Request failed; inspect redacted diagnostics") from None
    if not 200 <= response.status_code < 300:
        raise RuntimeError(f"HTTP failure: {response.status_code}")
    try:
        envelope = response.json()
    except ValueError:
        raise RuntimeError("Expected a JSON response") from None
    if not isinstance(envelope, dict) or str(envelope.get("code")) != "200":
        raise RuntimeError("Gateway business failure; inspect redacted code/message")
    show = envelope.get("data")
    if not isinstance(show, dict) or not show.get("id"):
        raise RuntimeError("Expected one show object")
    return show


def group_options(show):
    markets = show.get("streamingOptions") or {}
    if not isinstance(markets, dict):
        raise RuntimeError("Expected country-keyed streamingOptions")
    options = markets.get(COUNTRY) or []
    if not isinstance(options, list):
        raise RuntimeError("Expected a viewing-option array")
    groups = defaultdict(list)
    for option in options:
        if not isinstance(option, dict):
            raise RuntimeError("Unexpected viewing-option entry")
        service = option.get("service") or {}
        groups[option.get("type") or "unclassified"].append(
            {
                "service": service.get("name") or service.get("id"),
                "link": option.get("link"),
                "price": option.get("price"),
                "addon": option.get("addon"),
            }
        )
    return options, groups


show = get_show(SHOW_ID)
print(show.get("title"), show.get("releaseYear"), show.get("imdbId"))

options, groups = group_options(show)
if not options:
    print("No reported viewing options for this country.")
for access_type, entries in sorted(groups.items()):
    print(access_type)
    for entry in entries:
        print(" ", entry)

作品上的 overview、genres、cast、rating、runtime、imageSet,以及选项上的音轨、字幕、画质、附加频道、日期,缺了就缺着,不要补默认值。

没有 id 才用片名搜索

导入清单里只有「Titanic」这种名字时,才走 search-shows-by-title。必填 title 和 country,可加 show_type=movie。data 直接是数组,没有 shows 包裹,也没有游标,不能拿片库搜索那套 hasMore / nextCursor 去翻页。用 releaseYear、showType、cast、imdbId、tmdbId 核对后再收下,不要取第一条。搜到作品仍可能当地没有片源——身份和可看是两件事。对不上的名字交给人工选,不要在脚本里猜。

候选项确认后,把 IMDb ID 写回片单,之后一律 get-show。每次打开详情页都重搜显示名,翻拍和同名剧就会串台,额度也会被同一批片名反复烧掉。

search-shows-by-filters 是按市场浏览(必填 country,可选 catalogs=netflix)。筛选命中的是「这个市场里符合条件的作品」,不是给已有 id 补链接。get-changes 是在文档窗口内轮询上下架,用变化记录里的 showId 去对 shows 映射。那是发现和差分,造不出关联键。片单充实不走这两条。

某地区没有片源也是答案

streamingOptions 缺失、没有 us 这一键、或数组为空,都是查完了:该市场没有报告中的片源。不是传输失败,不是影片被删,更不是全球下架。标题、年份、海报仍可展示,空状态要诚实。美国区没有,不能据此写「全球下架」;换一个已确认的地区码再查,才是另一个市场的答案。网关 code 非 200、正文不是 JSON、data 结构坏了,才走错误分支,别渲染成「没在播」,也不要按空结果去换 Key 重试。

get-country 的平台标记只说明这个市场能卖什么形态,不证明这部 id 在架。目录里的 homePage 不能当标题页链接,也不要按平台名拼观看地址。链接是跳转入口,访问仍受地区、会员和租买条件限制。

这次查询不授予播放权、不绕过会员、不解锁地区,也不是任何流媒体的官方片库。按平台筛选时,命中作品的选项里仍可能出现其他服务,页面上展示实际返回的列表即可。后续若做片库浏览,分类 ID 从 get-genres 取,不要提交自己翻译的标签。变化轮询不是推送,也不是无限历史库,窗口外的上架记录要靠你自己存快照。

Key 不要写进播放器前端、公开笔记本或分享出去的命令历史,查询串会泄露。空选项不要当故障重试,HTTP 200 也不等于「在 Netflix 上」。额度按次计,片单充实应对已存 ID 调 get-show,不要为了同一批片子去扫片库。参数见作品查询和项目页,单次费用见定价。