起點其實跟「觀影」沒什麼關係。我想要的,是一頁像 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 卻沒給你圖,別急著借隔壁那張。寧可留白,也不要張冠李戴——一張配錯的海報,比一個空位還難看。