🔑 关键洞察
✦ AI·GEN为了让部落格那页「在看什么」能显示我真实的动画疯观看纪录,我一路从逆向巴哈的 history API,做成一个开源的 npm SDK `anigamer`。这篇记录整条坑:七颗 cookie 里哪颗才是关键、封面藏在 og:image 的随机 hash、每部动画只显示一集的 nested history bug,以及最精彩的——巴哈用「HTTP 200 藏 401」的软错误加上一颗 `BAHARUNE=deleted` 的假 cookie,把我自己的 rotate 逻辑骗到把帐号掏空、静默死了三天。后半是为了热换 cookie 做的一键浏览器扩充,如何跟读不到的 HttpOnly cookie 缠斗、最后用 CDP 侦错介面破关,再到发上 npm 的 2FA 大冒险与后端迁 Rust 后的 crates.io 版。
之前写「在看什么」那页时,我留了一个伏笔:页面上那些动画的资料,是我自己逆向了一个动画疯 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,很快就看到一条:
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 捞出来一颗颗试。最后滤出七颗必要的:
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 丢进去:
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 只给你看了哪些、哪一集,没有封面。第一版我自作聪明,以为封面网址是可以「算」出来的,猜了一个路径规则:
猜的:https://p2.bahamut.com.tw/B/ACG/c/{animeSn 后两码}/{animeSn}.JPG全部 404。 真相是封面藏在 animeRef.php?sn={animeSn} 这个 HTML 页的 og:image meta tag 里,而且带一个无法预测的随机 hash:
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 整个展开后:
DB 从 107 列 → 907 列(+800 new)
婚姻剧毒 8 集、女仆小姐 9 集、Dr.STONE 等老番集数全部正确因为每个 video_sn 是复合主键下的独立一列,展开就多出 800 列。顺手还踩到一个相关的坑:/api/anime/history 原本有个 200 列的上限把老资料截掉了(Dr.STONE 有 32 集只看到 8 集),把上限拉到 2000 才完整。
被自己 rotate 掏空的 cookie#
这是整条线最精彩、也最阴的一段。
某天我发现同步静默死了三天——最新一笔停在三天前,监控却一切正常。挖下去,是两个陷阱叠在一起。
第一个陷阱:HTTP 200 藏 401 的软错误。 session 死掉后,history.php 不是回你 401,而是回:
HTTP 200 {"error":{"code":401,"message":"尚未登入","status":"NO_LOGIN"}}状态码是漂亮的 200,401 藏在 body 里。我的 SDK 那层只看 HTTP status,于是读到一个空的 history → 同步回报「成功,0 新增」。没有错误,只有沉默。
第二个陷阱:一颗叫 deleted 的假 cookie,把我的 rotate 逻辑骗了。 巴哈在回应里塞了一个 :
Set-Cookie: BAHARUNE=deleted; expires=Thu, 01-Jan-1970 00:00:01 GMT; Max-Age=0; path=/deleted 只是 PHP 惯例的死尸标记字串,真正的「删除指令」是后面的 跟过去的 expires。但我的 mergeSetCookies 当时只做了一件事——取分号前面的 name=value,后面的属性全丢:
// 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 扩充:读不到的 HttpOnly#
「手动贴张新 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、__gpi、PSID_WEB、ckBahamutCsrfToken)——最关键的 BAHARUNE 就是抓不到。
接下来是连环猜错、被截图一张张打脸:
- 第一猜:无痕视窗的 cookie 分区。 以为是无痕跟一般视窗的 cookie store 隔离,
chrome.cookies只读当前视窗那区。——错。 - 第二猜:manifest 少了顶层网域权限。
host_permissions写的是https://*.gamer.com.tw/*,但BAHARUNE是设在裸网域.gamer.com.tw上的,*.匹配不到 apex。补上、让collectJar扫所有 store、加诊断。——还是不行。 - 第三猜:「根本没登入。」 把那 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,整个过程不用再手动贴任何东西。
先试 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 大冒险:
-
Windows Hello / passkey 登入突然坏掉:PIN 打对了被拒、手机 passkey 也不认,。最后的解法荒谬到不行——重开机就好了。
-
第一次
0.1.0发布喷EOTP:textnpm 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当时还是空的,可以干净重发)。 -
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 我没开
jsonfeature,所以 POST 得用.body(...)而不是.json(...),一开始编不过。 - 手动管 cookie:故意不用 reqwest 内建的 cookie store——因为那样就没办法在 rotate 时触发
onCookiesRotatedcallback 把新 cookie 写回磁碟。所以CookieJar用IndexMap自己管、自己处理那套Max-Age=0的删除语意。 Arc<Mutex>是反射性的错:一开始想把整个AniGamer包成Arc<Mutex<…>>,但一次同步会跨.await持锁好几分钟,会把微秒级的statusendpoint 卡死;而且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 复活。 知道一个系统会怎么死、并且让它死得吵一点,有时候比让它永远不死更实际。
- anigamer · TypeScript / npmGitHubnpm
- anigamer · Rust / crates.ioGitHubcrates.io
- 延伸阅读:打造一页会自己更新的「在看什么」koimsurai.com/blog/44
还没有留言
✨ 成为第一个留言的人吧