🔑 重要なポイント
✦ AI·GENブログの「いま何を見てる」ページに自分の本物のアニメ視聴履歴を出したくて、私は AniGamer(台湾の動画配信、バハムート系)の history API を逆解析し、オープンソースの npm SDK『anigamer』にまとめた。この記事はその落とし穴を一本まるごと:七つの cookie のどれが鍵か、カバー画像が og:image のランダム hash に隠れていた話、各アニメが一話しか出ない nested history のバグ、そして一番の見どころ——バハムートの「HTTP 200 に 401 を隠す」ソフトエラーと `BAHARUNE=deleted` という偽 cookie が、自作の rotate ロジックを騙してアカウントを空にし、三日間静かに死んでいた話。後半は cookie を熱交換するために作った一クリックのブラウザ拡張が、読めない HttpOnly cookie とどう格闘し、最後に CDP デバッグ介面で突破したか、さらに npm への公開での 2FA 大冒険とバックエンドの Rust 移行後の crates.io 版まで。
以前 [自動更新される「いま何を見てる」ページ] のことを書いたとき、伏線を一つ残していました:ページ上のアニメのデータは、私が自分で逆解析したアニメ視聴の 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 を開くと、すぐ一本見えた:
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 は何を・何話見たかを返すだけで、カバー画像はない。第一版で私は小賢しく、カバーの URL は「計算」できると思い込み、パスの規則を推測した:
推測:https://p2.bahamut.com.tw/B/ACG/c/{animeSn の下 2 桁}/{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 を逆解析するとき一番やりがちな過ちは「規則を見たら推導できると思い込む」こと。あの {下 2 桁}/{sn}.JPG はいかにも筋が通って見えるが、実際はカバーが CDN + ランダム hash を通るので、どう計算しても辿り着けない。パスを推測するより、素直に向こうが吐く欄位を読むほうがいい。
各アニメが一話しか出ない?#
データを繋いだ後、一覧に奇妙な症状が出た:各アニメが「一話」しか表示されない。8 話見たはずのアニメが、1 話と書かれている。
掘ると、バックエンドの同期が**「最新の一話」の一件だけ保存**していて、SDK が渡す nested history[] をまったく展開していなかった——各アニメの下には実は配列がぶら下がっていて、一話一要素、各自の時間戳を持つ。nested history を丸ごと展開した後:
DB が 107 行 → 907 行(+800 new)
古い作品の話数もすべて正しくなった各 video_sn は複合主キー下の独立した一行なので、展開すると 800 行増える。ついでに関連する坑も踏んだ:/api/anime/history には元々 200 行の上限があって古いデータを切り捨てていた(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ソフトエラーを型付きの (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 する」ことだった。
理想の一クリックは: のブラウザ拡張を作り、AniGamer のタブで一度クリック → cookie を読む → バックエンドへ POST して熱適用 → 即座に同期を再実行。env も触らず、再 build もしない。
十分の仕事に聞こえる。結果、読めない cookie 一つに引っかかって、一晩溶かした。
問題は:BAHARUNE は だ(devtools で見える、380 bytes、HttpOnly ✓)。HttpOnly は、ページの JS も bookmarklet も読めず、cookies 権限を持つブラウザ拡張だけが読める、という意味。だが私の拡張は入れても、5 つの cookie しか読めず、しかも全部非 HttpOnly(ckWwwTour、__gads、__gpi、PSID_WEB、ckBahamutCsrfToken)——一番肝心の BAHARUNE がどうしても取れない。
続いたのは連続の読み違い、一枚ずつ截图に頬を張られた:
- 一つ目の推測:InPrivate ウィンドウの cookie パーティション。 InPrivate と通常ウィンドウの 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 ✓ がそこにあると証明)、なのに拡張はやはり非 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 に切り替える、全過程でもう何も手で貼らない。
まず 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-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はまだ npm 上で空だったので、綺麗に再公開できた)。 -
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 は.json(...)でなく.body(...)を使う——最初コンパイルが通らなかった。 - 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 の熱交換は 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 を貼って復活する。 システムがどう死ぬかを知り、しかも少し騒がしく死なせるほうが、永遠に死なせないより実務的なことがある。
- anigamer · TypeScript / npmGitHubnpm
- anigamer · Rust / crates.ioGitHubcrates.io
- 関連記事:自動更新される「いま何を見てる」ページkoimsurai.com/blog/44
まだコメントがありません
✨ 最初のコメントを残しませんか