之前写「在看什么」那页时,我留了一个伏笔:页面上那些动画的资料,是我自己逆向了一个动画疯 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 复活。 知道一个系统会怎么死、并且让它死得吵一点,有时候比让它永远不死更实际。

參考連結