전에 [자동 갱신되는 「지금 뭐 보는 중」 페이지] 이야기를 쓸 때 복선을 하나 남겼습니다: 페이지의 그 애니 데이터는 제가 직접 리버싱한 애니 시청 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으로 올려서야 완전해졌습니다.

이게 이 라인에서 가장 재미있고, 가장 음험한 대목입니다.

어느 날 동기화가 조용히 사흘 죽어 있는 걸 발견했어요—최신 한 건이 사흘 전에 멈춰 있는데 모니터링은 온통 정상. 파 보니 함정 두 개가 겹쳐 있었습니다.

함정 1: 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건 추가」로 보고. 에러는 없고 침묵만.

함정 2: 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를 건드리고 재빌드하는」 것.

이상적인 원클릭: 브라우저 확장을 만들어, AniGamer 탭에서 한 번 클릭 → cookie 읽기 → 백엔드로 POST해 핫 적용 → 즉시 동기화 재실행. env도 안 건드리고 재빌드도 없음.

10분짜리 일 같죠. 결과는 못 읽는 cookie 하나에 걸려 저녁을 통째로 녹였습니다.

문제는: BAHARUNE(devtools에서 보임, 380 bytes, HttpOnly ✓). HttpOnly는 페이지 JS도 bookmarklet도 못 읽고, cookies 권한을 가진 브라우저 확장만 읽을 수 있다는 뜻. 그런데 제 확장은 깔아도 5개 cookie만 읽고, 전부 non-HttpOnly(ckWwwTour, __gads, __gpi, PSID_WEB, ckBahamutCsrfToken)—가장 핵심인 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 ✓가 거기 있음을 증명), 그런데도 확장은 여전히 non-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가 자동 로그인을 못 풀게 하죠. 그래서 처음부터 「무유지보수」를 꿈꾼 적 없고, 목표는 내내: 살아 있는 동안 자동으로 돌고, 죽어갈 땐 크게 외치고, 진짜 죽으면 2분 만에 cookie 붙여 살린다. 시스템이 어떻게 죽을지 알고, 게다가 좀 시끄럽게 죽게 하는 게, 영영 안 죽게 하는 것보다 실용적일 때가 있습니다.

參考連結