🔑 關鍵洞察
✦ AI·GEN這篇文章記錄了作者為個人網站打造一頁「在看什麼」即時觀影展示頁的完整過程,以及沿路的層層踩坑。因為觀看紀錄散在 Netflix、Disney+、HBO、動畫瘋等一堆平台、而多半又用 app 或電視看,作者先花了一輪功夫在選型——評估過 Trakt、Letterboxd、MyAnimeList、AniList、Plex、各家 GDPR 匯出等十來個工具,最後收斂成「動畫走自造 SDK、影視走 Trakt、Netflix CSV 當歷史底、連結全 TMDb」。由於市面上沒有任何 user-facing 的動畫瘋 API client,作者索性逆向巴哈、開源了一個名為 anigamer 的 npm SDK(另有 Rust 版),並做了一個一鍵推送 cookie 的瀏覽器擴充。資料到齊後,真正反覆翻車的卻是「海報」:同一個借錯來源的 fallback 坑先後以兩種面貌出現——靜態列表裡有 tmdb_id 卻沒海報的電影被 enrich 條件漏掉而破圖,即時卡則因 Trakt 只給 id 不給圖、前端 fallback 到上一部動畫瘋番的海報而張冠李戴。文章順著發現順序拆解海報借錯番、抓到縮圖、直式被裁、進度條長度算錯、日期全變 1970、看完不同步等坑,收束到一套「有 id 就即時補圖、拿不到就顯式 null、絕不借別人的圖」的對稱修法。
起點其實跟「觀影」沒什麼關係。我想要的,是一頁像 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 CSV | 95 | 78 部 / 693 集 | 0 | 0 |
| Trakt | 0 | 3 部 / 151 集 | 151(全壞) | — |
| Letterboxd | 1(Napoleon) | 0 | 0 | 1 |
拿破崙確實來自 Letterboxd——但它有 tmdb_id(753342),只是 poster_url 是 null。而我的 enrich 只補「tmdb_id IS NULL」的那些,於是它看都沒看拿破崙一眼就跳過去了。破圖不是來源壞,是我補圖的條件寫太窄。修法:放寬成「有 tmdb_id、但 poster_url 是 null」也要補(直接拿 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 的海報是這樣挑的:
// Watch.tsx — cover 是 null 時,fallback 到 now?.poster
poster: liveNow.cover ?? now?.posternow 是動畫聚合出來的「最近一部」,也就是史萊姆。於是 cover 一 null,正在看的電影就順手借了上一部番的臉。
IMPORTANT
這跟前面那個破圖的拿破崙,是同一個坑的兩張臉。一個是「有 id 沒圖時,補圖的條件寫太窄」(靜態列表);一個是「沒圖時,fallback 的目標選錯」(即時卡)。根都是同一句——當 TMDb / Trakt 給了你 id 卻沒給你圖,那條 fallback 鏈沒設計好,圖就會從隔壁借。
修法後端、前端各補一刀。後端:poll 到 Trakt 正在看時,用它給的 tmdbId 去 TMDb 抓海報當 cover(重用已快取的 detail,拿不到就 null)。前端:hero 海報改成 liveNow.cover ?? null,不再 fallback 到 now:
// 防呆:正在看的海報只能是它自己的,拿不到就空著,絕不借別人的
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:
-- 少了 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 卻沒給你圖,別急著借隔壁那張。寧可留白,也不要張冠李戴——一張配錯的海報,比一個空位還難看。
還沒有留言
✨ 成為第一個留言的人吧