之前寫「在看什麼」那頁時,我留了一個伏筆:頁面上那些動畫的資料,是我自己逆向了一個動畫瘋 SDK 撈來的。那篇只一句帶過,說這塊夠寫一整篇。這就是那一篇。

起點很小——我想要頁面上能放我真實的動畫觀看史。電影劇集有 Trakt 可以接,但動畫瘋沒有任何現成整合(台灣限定服務,那些追蹤器都不碰),只能自己動手。真正開始做才發現一件更有趣的事:連 npm 上都沒有人做過動畫瘋的 user-data SDK。

一個沒人做的坑:讀「我看了什麼」#

npm 上跟動畫瘋有關的套件,清一色是下載器(baha-anime-dl 那幾個),沒有一個是「user-facing API client」——沒人封裝 history / watchlist / favorites 這些跟帳號有關的 API。理由也不難猜:巴哈不對外開放 API、不發 SDK、ToS 灰色地帶,想做的人自己寫一寫給自己用,寫完通常不敢開源(怕被巴哈盯上)。

唯一在 npm 上的 bahamut-anime,查下去發現它跟我要做的完全不重疊:它做的是公開資料(搜尋、動畫詳情、單集、彈幕),不碰任何 user data。定位一講就清楚了——他做「動畫長什麼樣」,我做「我看了什麼」。 那條 user-data 的縫,剛好空著。

於是「幫自己的頁面接資料」就順勢長成了「開源一個 SDK」:anigamer

逆向:cookie 有點多,不知道哪顆#

第一步是找 API。登入動畫瘋後打開 devtools,很快就看到一條:

text
GET https://api.gamer.com.tw/anime/v3/history.php?page=1

分頁的觀看紀錄,回傳帶 totalPage(實測 4 頁、每頁 30 筆)。問題是認證:巴哈那堆 cookie 有點多,不知道哪顆才是關鍵。

找法很土炮但有效:在影片頁 animeVideo.php?sn=… 的 devtools 裡,對任何一條 api.gamer.com.tw 的請求按 Copy as cURL,把它整包 cookie 撈出來一顆顆試。最後濾出七顆必要的:

text
BAHAID       你的帳號名
BAHARUNE     JWT 認證 token(約 380 bytes)  ← 關鍵
BAHAENUR     輔助認證
BAHAHASHID   雜湊過的 user id
BAHANICK     暱稱
BAHALV       等級(API 偶爾會交叉檢查)
BAHAFLT      flag token

真正的鑰匙是 ——一顆巴哈用 簽的 JWT。解開它的 payload(userid, username, exp…),看 exp 就知道大約 14 天的壽命。這決定了整個 SDK 的宿命:token 會過期、我簽不了新的,只能用你自己登入後的 cookie 去讀你自己的資料。

SDK 的入口就長這樣,認證只需要把那串 cookie 丟進去:

ts
import { AniGamer } from "anigamer"

const client = new AniGamer({ cookie: process.env.BAHAMUT_COOKIE! })

await client.history({ page: 1 })   // GET /anime/v3/history.php
await client.historyAll()           // 自動分頁 + 去重
await client.validate()             // 七顆 cookie 到齊了嗎? { ok, missing }
await client.jwtStatus()            // 解 BAHARUNE 看還剩幾天,不用連線

封面藏在別的地方(我猜錯的那組 URL)#

history.php 只給你看了哪些、哪一集,沒有封面。第一版我自作聰明,以為封面網址是可以「算」出來的,猜了一個路徑規則:

text
猜的:https://p2.bahamut.com.tw/B/ACG/c/{animeSn 後兩碼}/{animeSn}.JPG

全部 404。 真相是封面藏在 animeRef.php?sn={animeSn} 這個 HTML 頁的 og:image meta tag 裡,而且帶一個無法預測的隨機 hash:

text
https://p2.bahamut.com.tw/B/2KU/71/1293ad110784ede7da06fe5e2d1yjsj5.JPG

那個 …d1yjsj5 是猜不出來的,所以每部動畫都得去 animeRef.php 打一次、抓 og:image(依 anime_sn 去重、請求間隔 400ms 別太衝)。後來又發現一件事:history 回傳的每筆裡,其實就帶了縮圖 raw.cover——於是大部分封面根本不用另外打,直接用清單裡的縮圖,省掉一大票請求(這在 0.2.0 補上)。

TIP

逆向一個沒文件的 API,最容易犯的錯就是「看到規律就以為能推導」。那組 {後兩碼}/{sn}.JPG 看起來多合理啊,結果人家封面走 CDN + 隨機 hash,你怎麼算都算不到。與其猜路徑,不如乖乖去讀它自己吐出來的那個欄位。

每部動畫都只有一集?#

接上資料後,清單出現一個怪象:每部動畫都只顯示「一集」。婚姻劇毒明明看了 8 集,卻寫 1 集。

挖下去發現,後端同步時只存了「最新一集」那一筆,完全沒展開 SDK 給的 nested history[]——每筆動畫底下其實掛著一個陣列,一集一個元素、各自帶自己的時間戳。把 nested history 整個展開後:

text
DB 從 107 列 → 907 列(+800 new)
婚姻劇毒 8 集、女僕小姐 9 集、Dr.STONE 等老番集數全部正確

因為每個 video_sn 是複合主鍵下的獨立一列,展開就多出 800 列。順手還踩到一個相關的坑:/api/anime/history 原本有個 200 列的上限把老資料截掉了(Dr.STONE 有 32 集只看到 8 集),把上限拉到 2000 才完整。

這是整條線最精彩、也最陰的一段。

某天我發現同步靜默死了三天——最新一筆停在三天前,監控卻一切正常。挖下去,是兩個陷阱疊在一起。

第一個陷阱:HTTP 200 藏 401 的軟錯誤。 session 死掉後,history.php 不是回你 401,而是回:

json
HTTP 200  {"error":{"code":401,"message":"尚未登入","status":"NO_LOGIN"}}

狀態碼是漂亮的 200,401 藏在 body 裡。我的 SDK 那層只看 HTTP status,於是讀到一個空的 history → 同步回報「成功,0 新增」。沒有錯誤,只有沉默。

第二個陷阱:一顆叫 deleted 的假 cookie,把我的 rotate 邏輯騙了。 巴哈在回應裡塞了一個 :

text
Set-Cookie: BAHARUNE=deleted; expires=Thu, 01-Jan-1970 00:00:01 GMT; Max-Age=0; path=/

deleted 只是 PHP 慣例的死屍標記字串,真正的「刪除指令」是後面的 跟過去的 expires。但我的 mergeSetCookies 當時只做了一件事——取分號前面的 name=value,後面的屬性全丟:

ts
// anigamer/src/cookies.ts(出事的版本)
const head = line.split(';')[0]      // ← 只看分號前的 name=value
const value = head.slice(idx + 1).trim()
// Path / Expires / Max-Age / HttpOnly 全部被丟掉
jar[name] = value                    // ← 於是把字面字串 "deleted" 當成新值存進去

結果就是:SDK 把 "deleted" 當成 BAHARUNE 的新值,乖乖存進 cookie jar;onCookiesRotated 又把這個被掏空的 jar 寫回磁碟的 .bahamut-cookie.json,蓋掉了 env 裡原本還好的 cookie。從此 validate() 過不了,每次同步都印 cookie missing — skip sync 直接跳過。rotate 邏輯被自己騙了。

而告警為什麼沒響?因為 jwtStatus() 拿到 "deleted" 這個非 JWT 的字串,解不出 payload、回 null,整個「快過期就 Discord 告警」的分支被完全跳過。系統以為一切正常,實際已經斷了三天。

修法是兩層:

  • SDK 層:mergeSetCookies 改成會解析 Max-Age / expires——只要 Max-Age <= 0 或已過期,就把這顆 cookie從 jar 移除,而不是存一個 "deleted"。這樣 validate() 才會誠實回 { ok: false, missing: ['BAHARUNE'] },同步才會正確早退。(補了 8 個刪除情境的測試。)
  • 應用層安全網:後端額外檢查「同步回 0 筆」——我的帳號健康時有 900 多集,回 0 幾乎篤定 session 死了,直接推 Discord。加上 SDK 後來把那個 NO_LOGIN 軟錯誤包成 typed 的 (0.2.2),呼叫端終於攔得到。

CAUTION

這條線最大的教訓:最陰險的失敗,是不報錯的失敗。 一個 200、一個叫 deleted 的字串、一個被自動化「乖乖照做」的刪除指令,三個「看起來都正常」疊在一起,就能讓一套有監控、有告警的同步,靜靜地死三天。防禦式的那句「回 0 筆 = 出事」有時候比任何 status code 都可靠。

那 rotate 到底會不會發生?#

修完之後我認真去驗:cookie 的自動輪替(rotate)到底靠不靠得住?結論有點微妙。對一個訪客 session 打無效 cookie,巴哈確實會回一顆新的 BAHARUNE(所以 rotate 的「捕捉」機制是有效的);但對一個有效登入的 session,我從沒觀察到它主動 rotate。

也就是說,rotate 是機會性的、不保證的。是「樂觀滑動續期」還是「固定 14 天必死」,我沒能證出來。既然賭不了,設計就轉向一個更務實的姿態:自動跑、死了大聲叫。 jwtStatus() 每次算還剩幾天,少於 3 天就 Discord 告警,讓我有時間手動貼張新 cookie 復活。

「手動貼張新 cookie」聽起來簡單,實際很煩:每次都要把一長串 cookie 貼到 env 檔、再 docker build 重啟。我真正在抱怨的其實不是「session 會死」,而是「每次復活都要碰 env + 重 build」。

理想的一鍵是:做一個 瀏覽器擴充,在動畫瘋分頁點一下 → 讀出 cookie → POST 到後台熱套用 → 立刻重跑同步。不用碰 env、不用重 build。

聽起來十分鐘的事。結果卡在一顆讀不到的 cookie 上,搞了整個晚上。

問題是:BAHARUNE(devtools 裡看得到,380 bytes、HttpOnly ✓)。HttpOnly 的意思是:網頁 JS、bookmarklet 都讀不到它,只有擁有 cookies 權限的瀏覽器擴充能讀。但我的擴充裝上去,卻只讀到 5 顆 cookie,而且全是非 HttpOnly 的(ckWwwTour__gads__gpiPSID_WEBckBahamutCsrfToken)——最關鍵的 BAHARUNE 就是抓不到。

接下來是連環猜錯、被截圖一張張打臉:

  1. 第一猜:無痕視窗的 cookie 分區。 以為是無痕跟一般視窗的 cookie store 隔離,chrome.cookies 只讀當前視窗那區。——錯。
  2. 第二猜:manifest 少了頂層網域權限。 host_permissions 寫的是 https://*.gamer.com.tw/*,但 BAHARUNE 是設在裸網域 .gamer.com.tw 上的,*. 匹配不到 apex。補上、讓 collectJar 掃所有 store、加診斷。——還是不行。
  3. 第三猜:「根本沒登入。」 把那 5 顆匿名 cookie 讀成「只是逛過沒登入」。——結果截圖擺在眼前:我明明登入了。這句診斷也猜錯。

真相在第三張截圖 100% 確定:我確實在一般視窗登入了(DevTools 證明 BAHARUNE 380 bytes、HttpOnly ✓ 就在那),但擴充還是只拿到那 5 顆非 HttpOnly 的。根因是——Edge 對「手動載入」的擴充,chrome.cookies 就是不吐 HttpOnly 的 cookie,就算 host 權限給好給滿也一樣。這是 Edge 的已知毛病,再怎麼喬權限都不可靠。

試過一版把權限改成 runtime optional、點「抓取」時才跳允許對話框,還做了「Copy as cURL 手動貼」的退路——都還是拿不到。而我根本不想手動貼:光是要在一大串 request 裡撈出那段 cookie、再解析出 BAHARUNE,就夠煩了。最後那條退路,只好自己把它堵死。

於是換一個保證讀得到 HttpOnly 的方法:。用 chrome.debugger 附加到當前分頁,呼叫 CDP 的 Storage.getCookies,直接讀整個 cookie 區——這條路不吃 host 權限那套,一定拿得到 BAHARUNE。邏輯設計成:先無聲試 chrome.cookies,抓不到才自動切 CDP,整個過程不用再手動貼任何東西。

text
先試 chrome.cookies(無聲) ──有 BAHARUNE?──► 直接推
        │ 沒有
        ▼
chrome.debugger.attach → CDP Storage.getCookies → 讀到整個 cookie 區(含 HttpOnly)→ 推

這條終於成了。但 CDP 有兩個要注意的操作限制:

WARNING

CDP 不能跟 DevTools 同時用——那個分頁的 F12 必須先關掉,否則 debugger 附加不上去(而且那個 animeVideo 分頁要是當前作用中的分頁)。讀取時瀏覽器頂端會閃一條黃色「正在偵錯此瀏覽器」的橫幅,讀完消失,是正常的。加上「偵錯」權限後,Edge 也可能把擴充停用、要你重新同意新權限。

順帶一提,tabs 權限一度被加上又拿掉——tabs.query 其實不需要它,留著只會多一個嚇人的權限警告。

(這個擴充後來還多掛了一個 content script,順手做即時「正在看」偵測——不過那比較是前端頁面那頁的事,「在看什麼」那篇已經寫過,這裡就不重挖。)

為什麼「不能自動登入」反而是特色#

有人會問:那 cookie 死掉,SDK 能不能自動重登?我認真查過——不行,而且這是刻意的。 巴哈登入用 Google ,是背景打分數、連個可以硬闖的驗證框都沒有,程式繞不過去;更關鍵的是,社群裡做自動化登入的 Bahamut-Automation 專案被 GitHub 以違反 ToS 下架、搬去了 GitLab

這反而把 SDK 的定位釘死了:只用你自己的 cookie、讀你自己的資料,絕不代登入、絕不代操作。 這句話後來直接寫進 README 的認證章節,當作 ToS 上的自保。

發到 npm:一場 2FA 大冒險#

SDK 本體是零 runtime 依賴的 TypeScript:tsup 打包(ESM+CJS+.d.ts)、vitest 測試、Biome 一把刀搞定 lint+format、原生 fetch、Node 20+。整包 37 個測試、tarball 9 個檔 13.9 KB。命名鎖定裸名 anigamer(npm 沒被佔,又跟那堆下載器 aniGamerPlus 區隔開)。倉庫在這:

聽起來一切就緒,結果卡在發布這關,上演了一場 2FA 大冒險:

  1. Windows Hello / passkey 登入突然壞掉:PIN 打對了被拒、手機 passkey 也不認,。最後的解法荒謬到不行——重開機就好了。

  2. 第一次 0.1.0 發布噴 EOTP:

    text
    npm error code EOTP
    This operation requires a one-time password from your authenticator.

    根因是帳號的 2FA 等級是「授權與寫入都要 OTP」,而那顆 granular token 沒勾「Bypass two-factor authentication」,所以 CI 發不了。重生一顆有勾 Bypass 的 token 才過(還好 0.1.0 當時還是空的,可以乾淨重發)。

  3. CI 跑了四輪才綠:pnpm version 衝突 → vitest 的 peer dep → coverage 門檻 → publish OTP,一個接一個。

最後用 簽章發出去(pnpm publish --provenance --access public,由 GitHub Release 觸發)。版本線大概是:0.1.0 MVP → 0.2.0(加 entry.cover 縮圖跟 duration、修那個 watchTime 欄位、補認證 README)→ 0.2.1(那顆 deleted cookie 的刪除處理)。

後端遷 Rust,SDK 也跟著過去#

再後來,我把 koimsurai.com 的後端從 Express + 裸 JS + sqlite3,用 遷去 Rust(axum + sqlx)。anigamer 是其中一根硬骨頭——得整個重寫成 Rust 版。動機很誠實:正確性 + 爽 + 技術棧一致,不是效能。

Rust 版有幾個移植的坑值得記:

  • vs fetch:reqwest 我沒開 json feature,所以 POST 得用 .body(...) 而不是 .json(...),一開始編不過。
  • 手動管 cookie:故意不用 reqwest 內建的 cookie store——因為那樣就沒辦法在 rotate 時觸發 onCookiesRotated callback 把新 cookie 寫回磁碟。所以 CookieJarIndexMap 自己管、自己處理那套 Max-Age=0 的刪除語意。
  • Arc<Mutex> 是反射性的錯:一開始想把整個 AniGamer 包成 Arc<Mutex<…>>,但一次同步會跨 .await 持鎖好幾分鐘,會把微秒級的 status endpoint 卡死;而且 parking_lot 的 guard 不是 Send。改成 Arc<AniGamer>、內部只鎖 Mutex<CookieJar>、所有方法 &self.await 前就放鎖;熱換 cookie 改用一個 set_cookies 短鎖交換 jar 內容。

測試整套 1:1 從 TS 的 vitest 移植過來(cookies 20 + jwt 6 + endpoints 3),cargo test 全綠。發 crates.io 時又踩兩個小坑:第一次 403(crates.io 的 API 要 User-Agent)、接著 email 沒驗證被擋,驗證完 anigamer v0.1.0 才上線。TS 版就留在 npm(0.2.1),README 互相連過去,沒標 deprecated——兩個版本各自活著。

最後想說#

回頭看,這一切只是為了一頁「在看什麼」上,那幾張動畫封面能正確顯示。結果一路挖到逆向沒文件的 API、被自己 rotate 掏空的 cookie、讀不到的 HttpOnly、CDP 偵錯介面、npm 的 2FA、再到 Rust 重寫。動畫瘋沒開 API,但那句話始終成立:你自己的資料,你自己讀。

而且這套東西注定會週期性地死——帳號跟家人共用、跨多個 IP,本身就是巴哈風控的紅旗,加上 reCAPTCHA 讓自動登入無解。所以我從一開始就沒有妄想「零維護」,目標一直是:活著就自動跑、快死就大聲叫、真的死了兩分鐘貼張 cookie 復活。 知道一個系統會怎麼死、並且讓它死得吵一點,有時候比讓它永遠不死更實際。

參考連結