起点其实跟「观影」没什么关系。我想要的,是一页像 Innei 那样的碎念微网志——看完一部什么,随手丢个连结、更新一句。但我跟他有个关键的岔路:他是手动维护、我懒。他那种「看完再去碎念一下、只放连结」的节奏,我学不来也不想学——我要的是它自己更新上去。

于是「手动碎念」慢慢长成了另一个东西:一页会自动去问「我最近在看什么」的页面。我把它叫做「在看什么」。

听起来只是串接。结果整条路上真正把我绊倒的是两件事:一,动画的资料源根本得自己造;二,一件小到不行的事——海报,同一张可以用五种不同的方式配错人。

十个平台,一份清单#

在写任何一行 code 之前,得先决定一件事:用什么追踪。这比想像中难,因为我看片的习惯很杂——动画在动画疯,电影影集散在 Netflix、Disney+、HBO Max,偶尔 YouTube、Bilibili;而且我多半用 app 或电视看,很少开浏览器。

我把能想到的工具全摊开评估了一轮:

  • TMDb:metadata 之王(海报、年份、类型、多语标题),Innei 用的也是它。但它只回答「这部长什么样」,不回答「我看了什么」。所以它注定只能当 metadata 层,不能当追踪源。
  • Trakt:电影、影集、即时都吃,是最像「观影版 Spotify」的东西,选它。但它有个残酷的前提——它只看得到浏览器里的 web 播放,app 跟电视一律抓不到。粗估下来,它对我的涵盖率只有两三成。
  • Letterboxd:UI 比 Trakt 顺、社群感强、有官方 CSV。我一度真的采用了——然后发现它跟 Trakt 几乎完全重叠(只是它只收电影),留着纯粹多余。
  • 动画呢? Trakt 对动画支援很差。我看过 MyAnimeList、AniList(它的 API 确实比 MAL 干净),但它们都要我自己手动打卡;而动画疯是台湾的地区性服务,永远不会有人帮它接 Trakt。既然如此,不如自己逆向一个全自动的,连打卡都免。
  • 老片、历史纪录怎么补? 这里卡最死。Netflix 佛心,有官方 CSV 可以汇出(我一次倒进来 95 部电影、78 部影集);但 Disney+ 跟 HBO 都没有任何汇出介面——我认真研究过发 GDPR 资料请求,结论是对台湾使用者没有法律强制力、还得等好几周;想爬「继续收看」又不稳、还可能被 ban 帐号。最后认了:那两个平台的历史直接放弃,只能从装上 Trakt 的那天起、往后慢慢累积再手动补几笔。
  • 一堆更漂亮的解也都因为「我没有」而出局:Plex 自架的 scrobble 整合最完美,但我没 Plex;桌面版的 trakt-scrobbler 只接 VLC / MPV,不吃浏览器串流。

过程里,AI 丢过我一句很有用的 reality check:你真的有在用 Trakt、AniList、Letterboxd 吗?如果答案是「没有,但我会开始用」——那大概率半年后你还是不会用。这句话后来一语成谶,Letterboxd 真的被我砍了。

于是尘埃落定,分工变得很清楚:

  • 动画 → 动画疯(自造 SDK,全自动)
  • 电影 + 影集 → Trakt(往后的新观看 + 即时),加上 Netflix 那份 CSV 当冻结的历史底
  • 所有连结 → 一律 TMDb(不再把访客导去我私人的动画疯纪录页)
  • Letterboxd、Disney+ / HBO 的历史 → 放生

把选好的来源画成资料流,大概长这样:

NOTE

三家凑在一起,刚好每一家都缺一角:动画疯给图不给 tmdb_id、Trakt 给 id 不给图、TMDb 给直式海报但我要横式 banner。后面几乎每一个坑,都是在补「某一家没给的那一角」——尤其是海报。

自己逆向一个动画疯 SDK#

电影影集有 Trakt 这套生态,动画没有。我的动画全在巴哈动画疯,而 npm 上翻遍了——现存的全是下载器,没有一个是给程式抓「使用者看了什么」的 API client。既然没人做,那就自己逆向一个、顺手开源:一个叫 anigamer 的 TypeScript SDK。

(后来后端整个迁去 Rust,又照着补了一个 Rust 版,测试套件跟 TS 版一比一对齐。)

配套还做了一个浏览器扩充「Cookie Pusher」:

登入动画疯后点一下,它就把 cookie 推到后端、热更新同步——因为我实在不想每次都手动把一长串 cookie 贴进环境变数、再 docker build、再重启。它同时兼差即时侦测,后面 scrobbler 那段会再登场。

这条线的坑不少,而且都很有「巴哈特色」。它对死掉的 session 回一个状态码 200、body 里却塞着 401 的软错误,害我的同步安静坏了三天,监控还以为一切正常。而那颗关键的 BAHARUNE cookie 是 ,扩充一开始怎么读都只读到五颗非 HttpOnly 的——

——最后是靠浏览器的侦错介面(CDP)才绕过去。资料本身也踩过雷:一开始每一部动画的集数都只显示「1 集」、时间戳还全挤在同一刻,追下去发现是 SDK 跟后端两层都只接了「最新一集」,把整串 nested history 展开后,资料库从一百多列长到九百多列。

但这些——逆向的攻防、cookie 的拉锯、那个藏在 200 里的 401、SDK 从零到发布——其实是另一篇的份量,我会单独写一篇深入的。这一页只要知道:动画的资料,是我自己造了一条管线接进来的。

脏掉的历史#

动画这条线收完,换电影影集这边。

破图的拿破仑#

「最近在看」里有一张拿破仑(NAPOLEON)的海报破图。我的第一直觉是怪 Letterboxd,想干脆把来源全砍到只剩 Trakt。但把资料摊开一数,真相跟我印象刚好相反:

来源电影影集坏日期(1970)缺海报
Netflix CSV9578 部 / 693 集00
Trakt03 部 / 151 集151(全坏)
Letterboxd1(Napoleon)001

拿破仑确实来自 Letterboxd——但它tmdb_id(753342),只是 poster_urlnull。而我的 enrich 只补「tmdb_id IS NULL」的那些,于是它看都没看拿破仑一眼就跳过去了。破图不是来源坏,是我补图的条件写太窄。修法:放宽成「有 tmdb_id、但 poster_urlnull」也要补(直接拿 id 抓 detail、不重搜,免得手滑改坏 tmdb_id)。拿破仑的脸当场长回来。

也就是这一刻,我把 Letterboxd 正式收了——前面选型时就嫌它跟 Trakt 重叠,现在它唯一贡献的那部还是破图的元凶。我让它先注解掉、函式留着:Trakt 的网页介面实在难用、手动补片很痛,说不定哪天还想回头。

全是 1970 的日期#

那张表还藏了另一个雷:Breaking Bad、Game of Thrones、Itaewon Class 三部,日期全是 1970-01-01。它们全来自 Trakt,151 笔 watched_at 全坏成 。根因不在程式,在我自己——这些片是好几年前看的,我根本记不得日期,当初在 Trakt 就填了「未知」。Trakt 把「未知」存成了 Unix epoch,一路显示成 1970。

修法:加一个 cleanWatchedDate,同步时把 epoch / 1970 的日期存成 NULL(而不是假装它真是 1970);前端没日期就显示「日期不明」,而且排序一律排在最后。我完全不用回 Trakt 改。

剧场版不是同一部#

有些动画我在 Netflix 跟动画疯都看过,会出现两次。我要的去重规则是「留集数最多的那笔」,而且不能只靠名字——怕误伤名字相近的作品或剧场版。所以去重用 tmdb_id:同 id 保留集数最多;movie 跟 tv 命名空间分开,剧场版(movie id)天生不会跟影集(tv id)撞;没 tmdb_id 的干脆不去重,宁可重复也不误杀。名字这种东西,拿来当主键永远会出事。

正在看,不是最近看#

我要的不是「最后看的」,是「现在正在看」——不管影集、电影还是动画。我站上本来就有一个 Spotify 的 now-playing(没在播就回 204),照那个节奏做:

  • 电影 / 影集:装一个现成的 Universal Trakt Scrobbler(UTS)扩充,把 Netflix、Disney+、HBO 的播放即时 scrobble 到 Trakt,后端再轮询 Trakt 的 /users/{slug}/watching。这条我这端一行都不用改。
  • 动画疯:它不在 Trakt 生态,只能自己来——这就是前面那个 Cookie Pusher 扩充的第二个工作:一段挂在 animeVideo.php 的 content script,侦测 <video> 在播就每 30 秒 heartbeat 一次,暂停或关页就停。纯 push、对动画疯零轮询。

侦测只靠三个稳定的原语:网址上的 ?sn=(video_sn)、<video> 的播放 / 暂停事件、以及 document.title(只当后备)。真正的标题、封面、tmdb_id,我不从易碎的站内 selector 硬刮,而是后端用 video_sn 反查 anime_history 补回来——能不手刻就不手刻。后端一个记忆体状态、90 秒 TTL;前端轮询,有人在播就亮「● 正在看」,没有就诚实退回「最近看完」。

TIP

我当时最担心的是轮询会不会出事。所以规则设成:只有真的有人打开这页才去问 Trakt、最多每 25 秒一次,而且动画疯在播的时候根本不问 Trakt。闲置时是 0 次呼叫,最坏情况大约用掉 Trakt 限额的 1%。

唯一不受我控制的,是那个第三方 UTS 扩充本身有点小脾气:偶尔就是抓不到播放。我实测下来,在 Netflix 上如果是从主页点进去看,有时候得进去之后再手动重整一次那个播放页,才会被 scrobble 到——猜是它换了一层渲染页、扩充没挂上。老实说我没能真的证实根因,只能说:至少这样做,它就乖了。

即时卡的连环出包#

借错番的脸#

架好即时抓取,兴冲冲打开页面。Trakt 确实即时抓到了——文字明明白白写着「正在看 100 METERS,电影」,大图却是我上一部在动画疯看的番(转生史莱姆第四季)。标题对了,脸是别人的。

又是那家「给 id 不给图」的 Trakt。/api/watch/now 的 Trakt 分支把 cover 直接写死 null,而前端 hero 的海报是这样挑的:

ts
// Watch.tsx — cover 是 null 时,fallback 到 now?.poster
poster: liveNow.cover ?? now?.poster

now动画聚合出来的「最近一部」,也就是史莱姆。于是 covernull,正在看的电影就顺手借了上一部番的脸。

IMPORTANT

这跟前面那个破图的拿破仑,是同一个坑的两张脸。一个是「有 id 没图时,补图的条件写太窄」(静态列表);一个是「没图时,fallback 的目标选错」(即时卡)。根都是同一句——当 TMDb / Trakt 给了你 id 却没给你图,那条 fallback 链没设计好,图就会从隔壁借。

修法后端、前端各补一刀。后端:poll 到 Trakt 正在看时,用它给的 tmdbId 去 TMDb 抓海报当 cover(重用已快取的 detail,拿不到就 null)。前端:hero 海报改成 liveNow.cover ?? null,不再 fallback 到 now:

ts
// 防呆:正在看的海报只能是它自己的,拿不到就空着,绝不借别人的
poster: liveNow.cover ?? null

又糊,又被裁#

海报总算是它自己了。但接下来,它换着花样坏给我看。

先是糊——大图一看就是缩图被放大的。我补图时抓的是 w342(342px),塞进大 banner 被放大快三倍。改抓 original,naturalWidth 从 342 变 1433,清晰了。

清晰了,换被裁——原图明明是直式,放上去只剩中间一条。TMDb 的海报是直式(1433×2048),我塞进横式 banner(880×587),用 一裁,只剩中间。

修法:让 TMDb detail 多吐一个 URL——那是 16:9 的横式剧照,正在看的大图优先用它、没有才退回海报。同一张图,先借错番、再糊、再裁,连修三轮才安分。

算不准的进度条#

进度条一开始根本不会跑。我加了 endsAt、让 client 端插值把它推动起来;结果又不对——我刚看完 100 METERS,进度条却没跑满,明明看到片尾了,它显示得像才 30%。

真正的鬼,是 Trakt 的 started_at / expires_at 会跳。同一部 100 METERS,duration 一下算成 68 分钟、一下 30 分钟(播放器重新 scrobble)。用一个会跳的起点去算进度,当然看到片尾却以为刚开始。片长不该问 Trakt,起点也不该用会跳的 started_at

修法:让 TMDb detail 多吐 runtime_min,用真实片长算(100 METERS = 106 分钟,稳);锚点改用 expires_at 反推;前端插值改成「以后端快照的进度为锚,加上流逝时间 delta」,免疫时钟偏差。实测速率 1.57×10⁻² %/秒,刚好等于 100 ÷(106×60),完全吻合。

WARNING

这里我没能 100% 修好,也不想装作修好了:绝对进度仍然会被 Trakt 那个会跳的 started_at 影响——因为资料里根本没有「真实播放到第几秒」这个东西。我能做的是把速率算对,再靠每 30 秒轮询自我校正。scrobbler 给你的是「在看什么」,从来不是「看到哪一秒」。

消失的海报#

最后一个症状最基本:图不见了,而且顶端的「最近看完」还挂着上一部动画。刚看完的 100 METERS 进了清单,却只剩一个场记板占位。

两个根因叠在一起。

一,Trakt 历史同步的那句 INSERT 根本没存 poster_url:

sql
-- 少了 poster_url,难怪 100 METERS 进了清单却没脸
INSERT OR IGNORE INTO film_history (title, watched_date, source, tmdb_id, release_year) ...

二,hero 的「最近看完」永远用动画聚合(= 史莱姆),不是跨类型真正最近的那一笔。修法:清单对缺图的 Trakt 电影,用 tmdb_id 即时从 TMDb 补图;hero 改算「跨类型最近的一笔」(正在看 / 最近电影 / 最近影集,取最新)。

还有一个「太慢」的坑:看完关掉播放器后,Trakt 那边显示看完了,我的网站却没同步——因为历史同步的 worker 每 6 小时才跑一次。修法是侦测「正在看 → 没在看」这个转换(= 看完关掉)时,立刻补跑一次历史同步(idempotent),大约 90 秒内就更新到位。

对不上的形状#

把整件事收束起来,那个反复出现的坑其实很单纯:三家 API,各给我一半,而所有的痛都发生在「把缺的那一半接起来」的接缝上。动画疯连个 API client 都没有,我得自己逆向;它给了整串历史我只接一格;Trakt 给了 id 没给图,我就让它去借隔壁的脸;TMDb 给了直式海报,我硬塞进横框。每一个 bug,拆到底都是同一句话——来源给你的形状,跟你要的形状,对不上。

海报那条 fallback 链,最后我立了一条规矩:有 id 就即时去 TMDb 补图,拿不到就显式 null、老实留白,绝不去借别人的图。破图的拿破仑、张冠李戴的即时卡,是同一条规矩的两种写法。

当一个来源给了你 id 却没给你图,别急着借隔壁那张。宁可留白,也不要张冠李戴——一张配错的海报,比一个空位还难看。