以前 [自動更新される「いま何を見てる」ページ] のことを書いたとき、伏線を一つ残していました:ページ上のアニメのデータは、私が自分で逆解析したアニメ視聴の SDK から取ってきたもの、と。あの記事では一言触れただけで、この部分は一本まるごと書けると言った。これがその一本です。

起点は小さい——ページに自分の本物のアニメ視聴履歴を出したかった。映画やドラマには Trakt という繋ぎ先があるが、AniGamer(バハムートのアニメ配信)には既製の統合がどこにもない(台湾限定のサービスで、あの手のトラッカーは誰も触らない)、だから自分でやるしかない。実際に始めて初めて、もっと面白いことに気づいた:npm 上にすら、AniGamer の user-data SDK を作った人が一人もいない。

誰もやらなかった坑:「自分が何を見たか」を読む#

npm 上の AniGamer 関連パッケージは、揃いも揃ってダウンローダー(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 探し。AniGamer にログインして 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 は何を・何話見たかを返すだけで、カバー画像はない。第一版で私は小賢しく、カバーの URL は「計算」できると思い込み、パスの規則を推測した:

text
推測:https://p2.bahamut.com.tw/B/ACG/c/{animeSn の下 2 桁}/{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 を逆解析するとき一番やりがちな過ちは「規則を見たら推導できると思い込む」こと。あの {下 2 桁}/{sn}.JPG はいかにも筋が通って見えるが、実際はカバーが CDN + ランダム hash を通るので、どう計算しても辿り着けない。パスを推測するより、素直に向こうが吐く欄位を読むほうがいい。

各アニメが一話しか出ない?#

データを繋いだ後、一覧に奇妙な症状が出た:各アニメが「一話」しか表示されない。8 話見たはずのアニメが、1 話と書かれている。

掘ると、バックエンドの同期が**「最新の一話」の一件だけ保存**していて、SDK が渡す nested history[] をまったく展開していなかった——各アニメの下には実は配列がぶら下がっていて、一話一要素、各自の時間戳を持つ。nested history を丸ごと展開した後:

text
DB が 107 行 → 907 行(+800 new)
古い作品の話数もすべて正しくなった

video_sn は複合主キー下の独立した一行なので、展開すると 800 行増える。ついでに関連する坑も踏んだ:/api/anime/history には元々 200 行の上限があって古いデータを切り捨てていた(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 層:mergeSetCookiesMax-Age / expires を解析するように変えた——Max-Age <= 0 か失効済みなら、その cookie を jar から削除し、"deleted" を保存しない。こうして validate() は正直に { ok: false, missing: ['BAHARUNE'] } を返し、同期は正しく早退する。(削除ケースのテストを 8 個追加。)
  • アプリ層の安全網:バックエンドが「同期が 0 件を返す」を追加チェック——私のアカウントは健康なら 900 話超あるので、0 はほぼ確実に session 死亡 → Discord へ。加えて SDK は後にあの NO_LOGIN ソフトエラーを型付きの (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 する」ことだった。

理想の一クリックは: のブラウザ拡張を作り、AniGamer のタブで一度クリック → cookie を読む → バックエンドへ POST して熱適用 → 即座に同期を再実行。env も触らず、再 build もしない。

十分の仕事に聞こえる。結果、読めない cookie 一つに引っかかって、一晩溶かした。

問題は:BAHARUNE(devtools で見える、380 bytes、HttpOnly ✓)。HttpOnly は、ページの JS も bookmarklet も読めず、cookies 権限を持つブラウザ拡張だけが読める、という意味。だが私の拡張は入れても、5 つの cookie しか読めず、しかも全部非 HttpOnly(ckWwwTour__gads__gpiPSID_WEBckBahamutCsrfToken)——一番肝心の BAHARUNE がどうしても取れない。

続いたのは連続の読み違い、一枚ずつ截图にほほを張られた:

  1. 一つ目の推測:InPrivate ウィンドウの cookie パーティション。 InPrivate と通常ウィンドウの cookie store が隔離されていて、chrome.cookies は現在ウィンドウの区画しか読まないと思った。——外れ。
  2. 二つ目の推測:manifest にトップレベルドメイン権限が足りない。 host_permissionshttps://*.gamer.com.tw/* だが、BAHARUNE は裸ドメイン .gamer.com.tw に設定されていて、*. は apex に一致しない。足して、collectJar に全 store を走査させ、診断を追加。——それでも駄目。
  3. 三つ目の推測:「そもそもログインしてない。」 あの 5 つの匿名 cookie を「見ただけでログインしてない」と読んだ。——だが截图が目の前にある:私は確かにログインしていた。この診断も外れ。

真相は三枚目の截图で 100% 確定:私は確かに通常ウィンドウでログインしていた(DevTools が BAHARUNE 380 bytes、HttpOnly ✓ がそこにあると証明)、なのに拡張はやはり非 HttpOnly の 5 つしか取れない。根因は——Edge は「手動読み込み」の拡張に対して、chrome.cookies が HttpOnly の cookie をどうしても吐かない、host 権限を目一杯与えても同じ。これは Edge の既知の癖で、いくら権限を調整しても信頼できない。

権限を runtime optional に変えて「取得」時に許可ダイアログを出す版、さらに「Copy as cURL 手動貼り」の逃げ道も作った——それでも取れない。しかも私は手で貼りたくなかった:大量のリクエストの中からあの cookie をすくい出し、BAHARUNE を解析するだけで十分面倒。最後のあの逃げ道も、自分でふさいだ。

そこで確実に HttpOnly を読める方法に切り替えた:chrome.debugger で現在のタブに attach し、CDP の Storage.getCookies を呼んで、cookie 区全体を直接読む——この道は host 権限のあの縛りを食わず、必ず BAHARUNE を取れる。ロジックはこう設計した:まず無声で chrome.cookies を試し、取れなければ自動で CDP に切り替える、全過程でもう何も手で貼らない。

text
まず chrome.cookies(無声) ──BAHARUNE ある?──► そのまま push
        │ なし
        ▼
chrome.debugger.attach → CDP Storage.getCookies → cookie 区全体(HttpOnly 含む)を読む → push

これでやっと成った。だが CDP には注意すべき操作制限が二つ:

WARNING

CDP は DevTools と同時には使えない——そのタブの F12 を先に閉じないと debugger が attach できない(しかも animeVideo タブがアクティブなタブである必要がある)。読み取り中はブラウザ上部に黄色い「このブラウザをデバッグしています」のバーが点滅し、終わると消える、正常だ。「デバッグ」権限を足した後、Edge が拡張を停止して新権限への再同意を求めることもある。

ついでに、tabs 権限は一度足して外した——tabs.query は実は要らず、残すと怖い権限警告が一つ増えるだけ。

(この拡張は後で content script も一つ増やし、ついでにリアルタイムの「いま見てる」検出をした——ただそれはフロントのページ側の話で、「いま何を見てる」の記事で既に書いたので、ここでは掘り返さない。)

なぜ「自動ログインできない」ことが特色なのか#

こう聞く人もいるだろう:cookie が死んだら、SDK は自動で再ログインできないのか?真面目に調べた——できない、そしてそれは意図的だ。 バハムートのログインは Google を使い、背景でスコアを付ける方式で、強行突破できる認証枠すらない、プログラムでは回避できない;さらに重要なのは、コミュニティの自動ログインプロジェクト Bahamut-AutomationGitHub に 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 はまだ npm 上で空だったので、綺麗に再公開できた)。

  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 は .json(...) でなく .body(...) を使う——最初コンパイルが通らなかった。
  • 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 の熱交換は jar の中身を短いロックで入れ替える set_cookies に。

テスト一式は TS の vitest から 1:1 で移植(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 の書き直しまで掘り当てた。AniGamer は API を開いていないが、あの一句は常に成り立つ:あなた自身のデータは、あなた自身が読む。

そしてこの仕組みは周期的に死ぬ運命にある——アカウントを家族と共用し、複数の IP を跨ぐこと自体がバハムート風控の赤旗、加えて reCAPTCHA が自動ログインを解けなくする。だから最初から「ゼロ保守」など夢見ていない、目標はずっと:生きている間は自動で走り、死にそうなら大声で叫び、本当に死んだら二分で cookie を貼って復活する。 システムがどう死ぬかを知り、しかも少し騒がしく死なせるほうが、永遠に死なせないより実務的なことがある。

參考連結