我把豆瓣“看过”做成了自己的影视宇宙

把豆瓣公开“看过”记录整理成静态影视资料库:从 sec.douban.com 反爬、资料补全与海报缓存,到自动加载和电影口味星图。

我想做的不是另一个影视站,也不是把豆瓣换个皮搬到自己的博客上。

初心很简单:我已经在豆瓣留下了一份很长的“看过”记录,里面有评分、有标记日期,也有一些当时的短评。与其让它们只躺在一个平台里,不如把这份数据整理到自己的站点上,做成一个更好看、更容易翻阅,也更适合继续玩下去的聚合体。

最后做出来的东西比一开始多了一点:304 部作品的海报墙、筛选和自动加载、资料抽屉,以及一个可以拖拽和缩放的电影口味星图。

这篇文章不准备讲“我喜欢哪些电影”,主要讲这套东西在技术上是怎么长出来的。

第一版设想:直接从豆瓣拿全套数据

最开始的思路其实很直:

豆瓣“看过”列表
      +
豆瓣每一部作品详情页
      ↓
标题、评分、日期、类型、演员、简介、海报
      ↓
博客里的影视资料页

看起来合理,但很快就撞上了一个现实问题。

公开“看过”列表本身能访问,能拿到作品 ID、片名、评分、标记日期和一段很长的摘要;但一旦继续逐部访问详情页,请求就会跳到 sec.douban.com。这不是普通的 404 或接口报错,而是豆瓣的安全验证域名。

如果这时继续加快请求、换 UA、重试详情页,本质上就是在和反爬策略对抗。这样的方案既不稳定,也不值得长期维护。

所以第一版方案被推翻了:不依赖豆瓣详情页。

这反而成为后面整个架构最重要的约束。

曲线救国:把“一个来源”拆成几种可信来源

不访问详情页,不意味着什么都拿不到。

后来把数据拆成了几层,每层只做自己最擅长的事:

豆瓣公开“看过”列表(list 模式)
  └── 个人评分、标记日期、公开短评、作品 ID、列表摘要

豆瓣公开“看过”墙(grid 模式)
  └── 海报地址

TMDB / Wikipedia / Wikidata
  └── 年份、类型、地区、片长、简介、外部资料链接

博客本地缓存
  └── JSON 数据与 WebP 海报

这样一来,豆瓣只承担“我的观看记录”这一层,不再承担所有影视资料;外部资料来源可以替换,页面最终也不依赖任何第三方实时响应。

这不是非要从一个站点把所有字段扒全,而是先确认每个字段真正应该由谁提供。

数据同步:先验证,后写入

同步脚本在 scripts/library_sync.py,输出的核心文件是:

data/library/movies.json

Hugo 页面只读这个 JSON。原始抓取数据和同步元信息另外保存,方便复盘。

同步器一开始做的不是写文件,而是把公开列表完整读进内存,再逐步做校验:

  • 分页是否正常;
  • 豆瓣 ID 是否重复;
  • 抓取数量是否突然大幅下降;
  • 是否意外拿到了安全验证页;
  • 最后一页之后是否只是正常的空分页。

这次全量同步里,豆瓣页面标题显示 311 条,但实际公开分页能读取到的是 304 条。这个差额没有被硬补成 311,也没有因此判定整次同步失败。它可能来自删除条目、不可公开条目,或者豆瓣页面本身没有返回的记录。

真正重要的是变化幅度。少 7 条可以接受;如果原来有 304 条,下一次只抓到 20 条,那就必须拒绝覆盖旧数据。

通过校验后,脚本才用临时文件原子替换 movies.json。中途任意一步失败,线上仍然保留上一份完整数据。

抓取 → 解析 → 校验 → 补全 → 写临时文件 → 原子替换

一个误判:页面里有登录链接,不等于被拦截

同步器曾经有过一个很蠢的判断:只要 HTML 里出现 passport/login,就认为请求被跳转到登录页面。

问题是,正常的豆瓣页面页头本来就可能带登录链接。于是正常响应也被误杀了。

后来判断改成两层:

  1. 最终响应 URL 是否真的跳到 sec.douban.com 或登录域名;
  2. 页面正文是否出现验证码等明确的安全验证内容。

这类问题挺有代表性。爬取里最难的往往不是正则或 CSS 选择器,而是判断“这个 HTTP 200 到底是不是我想要的页面”。

海报:公开地址不代表脚本能下载

豆瓣公开海报墙能给出每部作品的海报地址。最初我以为拿到 URL 就结束了,结果脚本下载图片时,很多请求都返回:

418 I'm a Teapot

原因是豆瓣图片 CDN 有防盗链。浏览器从豆瓣页面加载图片没问题,但脚本直接下载时没有来源信息。

最后给图片请求补上豆瓣页面的 Referer 和常规浏览器 UA,下载才稳定下来。随后统一用 Pillow 转成两份本地 WebP:

static/images/library/movies/<douban_id>.webp
static/images/library/movies/<douban_id>-thumb.webp

一份用于详情或高分辨率场景,一份用于海报墙和星图悬停预览。现在页面里的 304 张海报都是本地静态文件。

电影资料怎么补:从模糊片名匹配到精确 ID 关联

个人评分和日期属于豆瓣公开列表;年份、类型、地区、片长和简介则需要额外补全。

有 TMDB 凭据时,同步器优先使用 TMDB。没有凭据时,先根据中英文片名和年份搜索 Wikipedia。

片名搜索有天然风险:重名、续集、译名、剧集季数都可能串片。为此匹配时加入了中文名和原名归一化、年份比对、电影/剧集判断、候选分数差;明显歧义时就拒绝自动匹配。

后来又多加了一层更可靠的兜底:Wikidata 可以通过豆瓣 subject ID 关联实体。片名会变,ID 不会变。

豆瓣 subject ID → Wikidata P4529 → 确定的作品实体、描述与外部链接

这让很多中文剧集、旧片和难以靠英文名搜索的作品有了可靠的外部资料来源。最终策略不是“必须每部都有一大段简介”,而是宁可保留豆瓣列表摘要,也不要因为自动匹配错误,把另一部作品的资料塞进来。

自动化也会错:海报覆盖是必须保留的逃生舱

《欢迎来龙餐馆》给我上了一课。

它的豆瓣默认封面地址返回的其实是另一部《空枪》的海报。页面没有串卡片,subject ID 没有错,错的是来源提供的默认封面。

后来我从这部电影的公开相册里找到了正确的官方竖版海报,并把它写进 poster-overrides.json:

{
  "35811064": {
    "url": "https://img3.doubanio.com/view/photo/m/public/p2935136847.jpg",
    "filename": "35811064-v2"
  }
}

这里的 -v2 不是随便起的。静态资源会被浏览器长时间缓存,直接覆盖同名图片后,已经见过旧图的人仍可能看到缓存。换一个版本化文件名,HTML 的引用也会变,浏览器自然会拉到正确图片。

这也是我现在很认同的一条数据工程原则:自动同步是默认路径,人工覆盖必须是正式能力。

页面架构:静态渲染,前端只做交互

整个文娱板块没有数据库,也没有运行时 API。

movies.json → Hugo 模板 → 静态 HTML 海报卡片 → 浏览器端交互

同步在构建前发生,页面访问时没有第三方依赖,也不会因为豆瓣或外部资料站临时不可用而空白。

/library/ 的全部海报卡片都由 Hugo 预先渲染。JavaScript 不负责取数据,只负责搜索片名、按年份/类型/评分筛选、排序、打开详情抽屉和渐进展示海报。

最开始渐进展示靠“加载更多”按钮。作品变成 304 部后,这个按钮显得有点笨。

现在改成一个不可见 sentinel,IntersectionObserver 观察到用户接近列表底部时,自动放出下一批 40 张海报。它并不请求新数据,只是把已静态渲染、暂时隐藏的卡片显示出来。

电影口味星图:同一份数据的第二种视图

海报墙适合找电影,星图适合看自己的口味。

所以又做了一个独立页面:

/library/universe/

它不去改造原来的海报墙,只在资料馆顶部加一个入口。两个页面读同一份 movies.json,但表达方式不同。

星图里:

  • 每部作品是一颗星;
  • 主类型决定它属于哪个星系;
  • 星点颜色代表类型;
  • 星点大小和光晕代表我的评分;
  • 位置通过豆瓣 ID 计算,同一部作品每次打开都在同一个位置。

星点不是一个个海报 DOM 节点,而是由 Canvas 绘制。这样 304 部作品都在画面里时,页面依然很轻;只有鼠标悬停时才显示海报预览,点击后才打开详情抽屉。

目前支持拖拽、滚轮缩放、片名搜索、类型筛选和移动端触控。

它不是一张严肃的数据科学图表,更像一个可以漫游的私人电影宇宙。它的价值不在于精确计算出“我有百分之几喜欢科幻”,而在于让我第一眼就看到:我的观看记录大概聚集在哪些地方,哪些作品又格外亮。

最后

这次改动从一份豆瓣“看过”记录开始,最后形成了一个小型的数据链路:

公开观看记录
  + 分页校验与原子更新
  + 多来源影视资料补全
  + 本地海报缓存与版本控制
  + 人工异常覆盖
  + Hugo 静态渲染
  + 海报墙与星图两种浏览方式

我最满意的不是多做了一个页面,而是这份记录终于从“某个平台里的列表”,变成了一套可以继续长出来的个人数据。

下一步大概会补导演、演员和制片地区之间的关系。到那时,这张星图也许能真的变成一座更完整的电影星系。

使用 Hugo 构建
主题 Stack 由 Jimmy 设计
参考 iOS 26 液态玻璃风格改造