Back when I wrote about [that self-updating "Now Watching" page], I left a thread hanging: the anime data on it comes from an anime-tracking SDK I reverse-engineered myself. That post only mentioned it in one line, saying this piece deserved its own article. This is that article.

The starting point was small — I wanted my page to show my real anime watch history. Movies and shows have Trakt to plug into, but AniGamer (Bahamut's anime service) has no ready-made integration anywhere (a Taiwan-only service; none of the trackers touch it), so I had to do it myself. Only once I actually started did I notice something more interesting: nobody on npm had ever made a user-data SDK for AniGamer.

A Pit Nobody Filled: Reading "What I Watched"#

The AniGamer-related packages on npm are all downloaders (the baha-anime-dl crowd); not one is a "user-facing API client" — nobody wrapped the account-related APIs like history / watchlist / favorites. The reason isn't hard to guess: Bahamut has no public API, ships no SDK, sits in a ToS grey area; whoever wants this writes something for themselves and usually daren't open-source it (afraid of catching Bahamut's eye).

The one package on npm, bahamut-anime, turned out to be completely non-overlapping with what I wanted: it does public data (search, anime details, single episode, danmaku), touching no user data. The positioning became obvious once said out loud — it does "what the anime looks like," I do "what I watched." That user-data seam was sitting empty.

So "wire data into my own page" naturally grew into "open-source an SDK": anigamer.

Reverse-Engineering: Lots of Cookies, No Idea Which#

Step one is finding the API. Logging into AniGamer and opening devtools, one line quickly showed up:

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

Paginated watch history, the response carrying totalPage (measured: 4 pages, 30 entries each). The problem was auth: Bahamut has a lot of cookies, and I had no idea which one was the key.

The method was crude but effective: on a video page animeVideo.php?sn=…, in devtools, hit Copy as cURL on any api.gamer.com.tw request, pull out its whole cookie bundle, and try them one by one. In the end I filtered down to the seven essential ones:

text
BAHAID       your account name
BAHARUNE     JWT auth token (~380 bytes)  ← the key
BAHAENUR     supplementary auth
BAHAHASHID   hashed user id
BAHANICK     nickname
BAHALV       level (the API sometimes cross-checks)
BAHAFLT      flag token

The real key is — a JWT Bahamut signs with . Decode its payload (userid, username, exp…) and the exp tells you it has roughly a 14-day life. This decides the SDK's whole fate: the token expires, I can't sign a new one, so all you can do is use your own logged-in cookie to read your own data.

The SDK's entry point looks like this; auth just needs that cookie string handed in:

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()           // auto-paginate + dedup
await client.validate()             // are all seven cookies present? { ok, missing }
await client.jwtStatus()            // decode BAHARUNE to see days left, no network

The Cover Lives Somewhere Else (the URL I Guessed Wrong)#

history.php only gives you what you watched and which episode — no cover. In the first version I got clever and assumed the cover URL could be "computed," guessing a path pattern:

text
my guess: https://p2.bahamut.com.tw/B/ACG/c/{last 2 digits of animeSn}/{animeSn}.JPG

All 404. The truth is the cover is hidden in the og:image meta tag of the HTML page animeRef.php?sn={animeSn}, and it carries an unpredictable random hash:

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

That …d1yjsj5 is un-guessable, so every anime has to hit animeRef.php once and scrape og:image (deduped by anime_sn, 400 ms between requests, don't hammer it). Later I found another thing: each history entry actually already carries a thumbnail, raw.cover — so most covers don't need a separate request at all; just use the thumbnail from the list, saving a whole pile of requests (added in 0.2.0).

TIP

The easiest mistake when reverse-engineering an undocumented API is "see a pattern and assume you can derive it." That {last2}/{sn}.JPG looked so reasonable, but the cover goes through a CDN with a random hash — no computation gets you there. Rather than guess the path, just read the field it hands you itself.

Every Anime Shows Only One Episode?#

After wiring up the data, the list showed a weird symptom: every anime showed only "one episode." I clearly watched 8 episodes of one show, yet it said 1.

Digging in, the backend sync was storing only the "newest episode" entry, never expanding the nested history[] the SDK provides — each anime entry actually hangs an array underneath, one element per episode, each with its own timestamp. After expanding the whole nested history:

text
DB went from 107 rows → 907 rows (+800 new)
episode counts for all the old shows came out correct

Because each video_sn is a distinct row under the composite key, expanding produced 800 new rows. In passing I hit a related pit: /api/anime/history had a 200-row cap truncating old data (a 32-episode show only showed 8), and raising the cap to 2000 fixed it.

This is the most fun — and most insidious — stretch of the whole thing.

One day I found the sync had died silently for three days — the newest entry stuck three days back, yet monitoring showed all clear. Digging in, it was two traps stacked together.

Trap one: the HTTP-200-hiding-a-401 soft error. After the session died, history.php doesn't return 401 — it returns:

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

The status code is a pretty 200, the 401 buried in the body. My SDK layer only looked at HTTP status, so it read an empty history → the sync reported "success, 0 added." No error, only silence.

Trap two: a fake cookie called deleted that fooled my rotation logic. Bahamut stuffed a into the response:

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

deleted is just PHP's conventional corpse-marker string; the real "delete instruction" is the following and the past expires. But my mergeSetCookies at the time did just one thing — take the name=value before the semicolon and drop everything after:

ts
// anigamer/src/cookies.ts (the version that broke)
const head = line.split(';')[0]      // ← only looks at name=value before the semicolon
const value = head.slice(idx + 1).trim()
// Path / Expires / Max-Age / HttpOnly all dropped
jar[name] = value                    // ← so it stores the literal string "deleted" as the new value

The result: the SDK took "deleted" as BAHARUNE's new value and dutifully stored it in the cookie jar; onCookiesRotated then wrote this emptied jar back to disk at .bahamut-cookie.json, overwriting the still-good cookie in env. From then on validate() failed, and every sync printed cookie missing — skip sync and bailed. The rotation logic fooled itself.

And why didn't the alert fire? Because jwtStatus() got the non-JWT string "deleted", couldn't decode a payload, returned null, and the entire "alert Discord when near expiry" branch was skipped. The system thought everything was fine; it had actually been down for three days.

The fix is two layers:

  • SDK layer: mergeSetCookies now parses Max-Age / expires — if Max-Age <= 0 or already expired, it removes the cookie from the jar rather than storing a "deleted". Then validate() honestly returns { ok: false, missing: ['BAHARUNE'] }, and the sync bails correctly. (Added 8 deletion-case tests.)
  • Application-layer safety net: the backend explicitly checks "sync returned 0 entries" — my account, when healthy, has 900-plus episodes, so 0 almost certainly means the session is dead → push Discord. Plus the SDK later wraps that NO_LOGIN soft error into a typed (0.2.2), so the caller can finally catch it.

CAUTION

The biggest lesson on this line: the most insidious failure is the one that doesn't error. A 200, a string called deleted, and a delete instruction the automation "dutifully obeyed" — three "everything looks normal" pieces stacked together are enough to let a sync with monitoring and alerts die quietly for three days. That defensive line, "0 entries returned = something's wrong," is sometimes more reliable than any status code.

Does the Rotation Even Happen?#

After fixing it I went and verified: is the cookie auto-rotation actually reliable? The conclusion is a bit subtle. Against a guest session with an invalid cookie, Bahamut does return a fresh BAHARUNE (so the rotation "capture" mechanism works); but against a valid logged-in session, I never observed it rotate on its own.

In other words, rotation is opportunistic, not guaranteed. Whether it's "optimistic sliding renewal" or "fixed 14-day death," I couldn't prove. Since I can't bet on it, the design pivoted to a more pragmatic posture: run automatically, shout when it dies. jwtStatus() computes the days left each time; under 3 days, alert Discord, giving me time to manually paste a new cookie and revive it.

"Manually paste a new cookie" sounds simple, but is actually annoying: every time you have to paste a long cookie string into an env file, then docker build and restart. What I was really complaining about wasn't "the session dies," but "every revival means touching env + a rebuild."

The ideal one-click: build a browser extension — on an AniGamer tab, click once → read the cookie → POST to the backend to hot-apply → immediately re-run the sync. No touching env, no rebuild.

Sounds like a ten-minute job. It got stuck on one unreadable cookie and ate a whole evening.

The problem: BAHARUNE is (visible in devtools, 380 bytes, HttpOnly ✓). HttpOnly means page JS and bookmarklets can't read it — only a browser extension with the cookies permission can. But my extension, once installed, only read 5 cookies, all non-HttpOnly (ckWwwTour, __gads, __gpi, PSID_WEB, ckBahamutCsrfToken) — the crucial BAHARUNE just couldn't be grabbed.

What followed was a chain of wrong guesses, each slapped down by a screenshot:

  1. Guess one: InPrivate window cookie partitioning. I thought InPrivate and normal windows had isolated cookie stores and chrome.cookies only reads the current window's partition. — Wrong.
  2. Guess two: the manifest was missing the top-level-domain permission. host_permissions said https://*.gamer.com.tw/*, but BAHARUNE is set on the bare domain .gamer.com.tw, which *. doesn't match at the apex. Fixed it, made collectJar scan all stores, added diagnostics. — Still no.
  3. Guess three: "you're not logged in at all." I read those 5 anonymous cookies as "just browsed, not logged in." — And the screenshot was right there: I clearly was logged in. That diagnosis was wrong too.

The truth was 100% confirmed at the third screenshot: I was indeed logged in in a normal window (DevTools proved BAHARUNE 380 bytes, HttpOnly ✓, right there), but the extension still only got those 5 non-HttpOnly ones. The root cause — on a manually loaded extension, Edge's chrome.cookies simply won't hand over the HttpOnly cookie, even with host permission granted to the hilt. It's a known Edge quirk; no amount of permission-fiddling makes it reliable.

I tried a version with runtime optional permissions that pops a consent dialog on "grab," plus a "Copy as cURL, paste manually" fallback — still couldn't get it. And I really didn't want to paste manually: just fishing that cookie out of a big pile of requests and parsing BAHARUNE is annoying enough. That last escape route, I closed off myself.

So I switched to a method guaranteed to read HttpOnly: . Attach via chrome.debugger to the current tab, call CDP's Storage.getCookies, and read the whole cookie jar directly — this path doesn't eat the host-permission rules and always gets BAHARUNE. The logic is designed so: try chrome.cookies silently first, and only if that misses does it auto-switch to CDP, with nothing to paste manually anymore.

text
try chrome.cookies (silent) ──has BAHARUNE?──► push directly
        │ no
        ▼
chrome.debugger.attach → CDP Storage.getCookies → read whole cookie jar (incl. HttpOnly) → push

This finally worked. But CDP has two operational caveats worth noting:

WARNING

CDP can't be used at the same time as DevTools — that tab's F12 must be closed first, or debugger can't attach (and the animeVideo tab has to be the active tab). While reading, a yellow "this browser is being debugged" banner flashes at the top of the browser, disappearing when done — that's normal. After adding the "debugger" permission, Edge may also disable the extension pending re-consent to the new permission.

By the way, the tabs permission was added then removed — tabs.query doesn't actually need it, and keeping it just adds a scary extra permission warning.

(This extension later grew a content script too, doing live "now watching" detection on the side — but that's more of a front-end-page thing, already covered in the "Now Watching" post, so I won't re-dig it here.)

Why "No Auto-Login" Is Actually a Feature#

Someone will ask: when the cookie dies, can't the SDK just re-login automatically? I looked into it seriously — no, and that's deliberate. Bahamut's login uses Google , which scores in the background — there isn't even a challenge box to force through, so there's no getting around it programmatically. More importantly, the community's auto-login project Bahamut-Automation was taken down by GitHub for ToS violation and moved to GitLab.

That actually nailed down the SDK's positioning: use only your own cookie, read only your own data, never log in for you, never act for you. That line went straight into the README's auth section, as ToS self-defense.

Publishing to npm: A 2FA Adventure#

The SDK itself is zero-runtime-dependency TypeScript: tsup for bundling (ESM+CJS+.d.ts), vitest for tests, Biome as a single tool for lint+format, native fetch, Node 20+. 37 tests total, a tarball of 9 files at 13.9 KB. The name is locked to the bare anigamer (free on npm, and distinct from the downloader crowd like aniGamerPlus). The repo:

Sounds all set, but it got stuck at the publish gate, staging a whole 2FA adventure:

  1. Windows Hello / passkey login suddenly broke: the correct PIN was rejected, the phone passkey refused too, The eventual fix was absurd — a reboot fixed it.

  2. The first 0.1.0 publish threw EOTP:

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

    The root cause: the account's 2FA level was "OTP required for authorization and writes," and that granular token didn't have "Bypass two-factor authentication" checked, so CI couldn't publish. Regenerating a token with Bypass fixed it (luckily 0.1.0 was still empty on npm, so it could be cleanly re-published).

  3. CI took four rounds to go green: pnpm version conflict → vitest peer dep → coverage threshold → publish OTP, one after another.

Finally published with signing (pnpm publish --provenance --access public, triggered by a GitHub Release). The version line is roughly: 0.1.0 MVP → 0.2.0 (added entry.cover thumbnails and duration, fixed that watchTime field, added the auth README) → 0.2.1 (the deletion handling for that deleted cookie).

The Backend Migrated to Rust, So the SDK Went Too#

Later I migrated koimsurai.com's backend from Express + plain JS + sqlite3, via the , to Rust (axum + sqlx). anigamer was one of the hard bones — it had to be rewritten entirely in Rust. The motive is honest: correctness + fun + a consistent stack, not performance.

The Rust port had a few porting pits worth recording:

  • vs fetch: I built reqwest without the json feature, so POSTs use .body(...) not .json(...) — which didn't compile at first.
  • Managing cookies by hand: deliberately not using reqwest's built-in cookie store — because that way there's no way to fire the onCookiesRotated callback on rotation to write new cookies back to disk. So CookieJar uses an IndexMap managed by hand, handling that Max-Age=0 deletion semantics itself.
  • Arc<Mutex> is a reflexive mistake: at first I wanted to wrap the whole AniGamer in Arc<Mutex<…>>, but one sync holds the lock for minutes across .await and would jam the microsecond-level status endpoint; and a parking_lot guard isn't Send. Changed to Arc<AniGamer>, only locking Mutex<CookieJar> internally, all methods &self, dropping the lock before .await; hot-swapping cookies via a set_cookies that briefly locks to swap the jar's contents.

The whole test suite was ported 1:1 from the TS vitest (cookies 20 + jwt 6 + endpoints 3), cargo test all green. Publishing to crates.io hit two more small pits: first a 403 (crates.io's API requires a User-Agent), then blocked by an unverified email — once verified, anigamer v0.1.0 went live. The TS version stays on npm (0.2.1), the READMEs cross-link, and it's not marked deprecated — both versions live on their own.

Final Thoughts#

Looking back, all of this was just so a few anime covers would display correctly on one "Now Watching" page. It ended up digging all the way into an undocumented API, a cookie my own rotation emptied, an unreadable HttpOnly, the CDP debug interface, npm's 2FA, and a Rust rewrite. AniGamer has no public API, but the line always holds: your own data, you read it yourself.

And this thing is doomed to die periodically — the account is family-shared across multiple IPs, itself a red flag for Bahamut's risk control, plus reCAPTCHA makes auto-login unsolvable. So I never fantasized about "zero maintenance" from the start; the goal has always been: run automatically while alive, shout loudly when near death, and paste a cookie to revive in two minutes when it really dies. Knowing how a system will die, and making it die a little louder, is sometimes more practical than making it never die.

參考連結