# AcFun (acfun.cn / A站) QR-code login (扫码登录) — live-verified contract Reverse-engineered on **2026-10-27 (Asia/Shanghai)** by probing `https://www.acfun.cn/login/`, its webpack bundles, and the QR service at `https://scan.acfun.cn` with Node 24 `fetch` (desktop Chrome UA, UTF-8, `res.headers.getSetCookie()`). **No AcFun account, phone, or scan was used** — every state below was reached anonymously. Each claim is marked **[VERIFIED]** (a live request produced the status/body pasted) or **[UNVERIFIED]** (derived from the site's own JavaScript, or impossible to reach without scanning the code). Probe scripts live in `%TEMP%\ac-qr\*.mjs` and are **not** part of this repo. --- ## TL;DR | Question | Answer | |---|---| | Is web QR login alive in 2026-10? | **Yes — [VERIFIED] live and working.** The modern flow is **not** the old `sdk-pay` `/rest/c/infra/ks/qr/*` paths. | | QR service host | **`https://scan.acfun.cn`** (discovered in `login/widget/login/login.fe91a542c7bccee57336.js`) | | Create | `GET https://scan.acfun.cn/rest/pc-direct/qr/start?type=WEB_LOGIN` → `result:0`, `qrLoginToken`, `qrLoginSignature`, `expireTime:120000`, `imageData` (base64 **PNG**, 125×125) | | Poll | `GET .../qr/scanResult?qrLoginToken=&qrLoginSignature=` — one **long-poll** held until scan or ticket expiry (≤120 s). No polling interval. | | Success exchange | `GET .../qr/acceptResult?qrLoginToken=&qrLoginSignature=` using the **rotated** signature returned by a successful `scanResult` | | Session transport | **Cookies**, not a JSON token: `acceptResult` → `result:0`, page then redirects with no token in the URL → `Set-Cookie` (INFERRED; cannot scan). | | Extra step after scan | Yes: the phone must **authorize** the login ("扫码成功!请在手机上进行授权操作"); `acceptResult` waits for that. No captcha on the QR path. | | Cookies needed *before* starting | **None** — `start`/`scanResult` work with an empty cookie jar (VERIFIED); `_did` is auto-seeded by any `www.acfun.cn` request in any case. | | Anti-bot | None on the QR endpoints: no `Referer`/UA/`X-Requested-With`/`csrfToken` requirement, no captcha, no signature param, 15 rapid `start`s all `result:0`. | | QR lifetime | **120 s absolute from `start`** (proven: a poll opened 60 s late was held only 60.5 s and ended at start+120.5 s). | | Timing trap | A terminal poll returns `100400002 "token expired"`; **re-polling that token is useless** (`result:10 "client error"`). Restart with `start`. | --- ## 0. Where the contract comes from **[VERIFIED]** `https://www.acfun.cn/login/` → HTTP 200, `text/html`, 68,206 bytes. It loads ``` https://ali-imgs.acfun.cn/kos/nlav10360/static/login/widget/login/login.fe91a542c7bccee57336.js ``` whose `created` hook hardcodes the three endpoints (single line, un-minified enough to read): ```js this.url = "https://scan.acfun.cn/rest/pc-direct/qr/start"; this.checkurl = "https://scan.acfun.cn/rest/pc-direct/qr/scanResult"; this.accepturl = "https://scan.acfun.cn/rest/pc-direct/qr/acceptResult"; // createcode(): GET this.url + "?type=WEB_LOGIN", xhrFields:{withCredentials:true}, cache:false // img.src = "data:image/png;base64," + resp.imageData // checkstatus(resp.qrLoginToken, resp.qrLoginSignature) // checkstatus(): GET this.checkurl + "?qrLoginToken=..&qrLoginSignature=..", timeout: 120000 // switch(resp.result){ case 0: ...checkaccept(token, resp.qrLoginSignature) // case 100400002: status-fresh // "二维码已失效" // default: status-fail } // checkaccept(): GET this.accepturl + "?qrLoginToken=..&qrLoginSignature=..", timeout: 120000 // case 0: location.href = returnUrl (whitelisted) or "/" // case 100400002: status-fresh ; default: status-fail + reload in 3 s ``` The state names are corroborated by the login page's own markup/CSS (`status-fresh` = "二维码已失效"/"请点击刷新", `status-success` = "扫码成功!请在手机上进行授权操作", `status-fail` = "登录失败"). This is why `100400002` means **expired**, not "still waiting". The `sdk-pay.*.js` bundle on the same page contains a completely different, generic **Kuaishou Passport** QR API (`/rest/c/infra/ks/qr/start`, `/rest/c/infra/ks/qr/scanResult`, `/rest/c/infra/ks/new/qr/acceptResult`, `/pass/kuaishou/login/qr/callback`). It is parameterized by `baseUrl/sid/qrType/kpn` and is **not** the AcFun web login — see §8.2 for the live 404/307/500 proof. --- ## 1. The flow, step by step All QR calls are plain GETs, JSON in / JSON out, UTF-8, HTTP 200 even for errors. ### Step 0 (optional) — visit the login page / any `www.acfun.cn` page ``` GET https://www.acfun.cn/login/ ``` **[VERIFIED]** HTTP 200 and: ``` set-cookie: csrfToken=LFSE8AMawbKPYyoUMjbkJaf0; path=/ set-cookie: _did=web_970527494F3D75A9; path=/; expires=Wed, 27 Oct 2027 03:25:51 GMT; domain=.acfun.cn ``` - `_did` is device id, 1-year lifetime, `Domain=.acfun.cn` — **any** `www.acfun.cn` response re-seeds it if absent (verified even on 404 responses), so a Node plugin does not need to pre-seed it, but sending it is harmless and more browser-like. - `csrfToken` is host-only for `www.acfun.cn`; it is **not** required by the QR service (all QR probes below were made with an empty jar). - `window.loginConfig` on that page only holds a `domainWhitelist` of `returnUrl` targets and the background image URL — no token/secret. The QR service (`scan.acfun.cn`) is a **different origin**; nothing in the start/scan/accept requests depends on the `www` cookies. ### Step 1 — create the QR code ``` GET https://scan.acfun.cn/rest/pc-direct/qr/start?type=WEB_LOGIN ``` | Param | Required | Value | |---|---|---| | `type` | recommended | **`WEB_LOGIN`** is the only accepted value. Omitted → also works (defaults to WEB_LOGIN). `APP_LOGIN`, `WEB`, `web_login` → `100400000 "type is illegal"`. | | (others) | — | none. No `sid`, no `kpn`, no sign, no cache-buster needed. | Headers actually sent by the site: the jQuery default set plus `withCredentials:true` (i.e. cookies if any). For Node: ```http User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Referer: https://www.acfun.cn/login/ Accept: application/json, text/plain, */* ``` …but **[VERIFIED] none of them are enforced**: `curl/8.5.0` UA, no UA, no Referer and no cookies all returned `result:0` with a fresh QR. **[VERIFIED] Response — HTTP 200, `application/json;charset=UTF-8`:** ```json { "result": 0, "next": "scanResult", "expireTime": 120000, "qrLoginSignature": "314fe07642a8e8a8bbaafdf813f0b967", "imageData": "iVBORw0KGgoAAAANSUhEUgAAAH0AAAB9AQAAAACn+1GIAAACY0lEQVR42s3V…", // 892 base64 chars "qrLoginToken": "1.2824b14669ff2ed2000001a17b64957e5745425f4c4f47494e.f88ab57e30c18e149cfd46ca63c019c2" } ``` | Field | Meaning | |---|---| | `result` | `0` = ok | | `next` | name of the endpoint to call next (`scanResult`) | | `expireTime` | **120000 ms**, absolute lifetime measured from this response | | `qrLoginSignature` | 32-hex signature bound to this token for the *scan* step | | `imageData` | **base64 PNG**, decode → 665 bytes, `\x89PNG…`, IHDR **125×125**. Not a URL, not a content string. Render as `data:image/png;base64,` (the page does exactly this). | | `qrLoginToken` | opaque ticket. Shape `1...`; note the ASCII string `WEB_LOGIN` is hex-encoded inside it (`5745425f4c4f47494e`) — do not parse, store opaquely. | **[VERIFIED] Response headers** (useful for the CORS story): ```http HTTP/1.1 200 OK server: Tengine content-type: application/json;charset=UTF-8 access-control-allow-origin: https://www.acfun.cn access-control-allow-credentials: true x-kslogid: 793071737767276976 keep-alive: timeout=120 ``` `Set-Cookie`: **none** — the QR service is stateless until `acceptResult`. ### Step 2 — wait for the scan (long-poll) ``` GET https://scan.acfun.cn/rest/pc-direct/qr/scanResult?qrLoginToken=&qrLoginSignature= ``` | Param | Required | Notes | |---|---|---| | `qrLoginToken` | yes | from Step 1 | | `qrLoginSignature` | yes | from Step 1 (later: from Step 2's success body) | | (others) | — | none | **[VERIFIED] It is a long-poll, not a short poll.** The request is held open until the phone scans or the ticket expires; the site's own jQuery sets `timeout: 120000`, matching `expireTime`. Measured hold times (no phone involved → always ended in expiry): ``` start → scanResult immediately : 120,489 ms (probe 05) start → scanResult immediately : 120,788 ms (probe 10, first of two concurrent polls) start → idle 60 s → scanResult : held 60,520 ms, returned at start+120,534 ms (probe 14) ``` → **the TTL is absolute from `start`**, not per-request. A late poll does not get a fresh 120 s. **[VERIFIED] Terminal state when nobody scans (HTTP 200):** ```json {"result":100400002,"error_msg":"token expired"} ``` **[VERIFIED] Re-polling that same token (or opening a second concurrent poll on it)** returns immediately (62 ms / 699 ms): ```json {"result":10,"error_msg":"client error"} ``` **So there is exactly one poll per ticket.** `result:10` = "this ticket is not pollable any more (in flight elsewhere, or terminal/consumed)". **[VERIFIED] Error shapes for bad input:** ``` scanResult with no params -> {"result":11,"error_msg":"网络错误,请稍后再试"} scanResult valid token, wrong sig -> {"result":21,"error_msg":"sign error"} ``` **[UNVERIFIED] Success:** on scan the body becomes `{"result":0, …, "qrLoginSignature":""}` (possibly with user info). The rotation is proven by the bundle calling `checkaccept(token, resp.qrLoginSignature)` — i.e. Step 3 must use the signature from **this** response, not the one from Step 1. A `result:0` here means "QR decoded / scanned", not "logged in": the UI shows **"扫码成功!请在手机上进行授权操作"** at this point. ### Step 3 — exchange the scanned ticket for the session ``` GET https://scan.acfun.cn/rest/pc-direct/qr/acceptResult?qrLoginToken=&qrLoginSignature= ``` **[VERIFIED] without scanning, this call always fails fast** (62 ms): ``` start sig, unscanned ticket -> {"result":21,"error_msg":"sign error"} POST acceptResult (same params) -> {"result":21,"error_msg":"sign error"} (GET only) acceptResult, no signature -> {"result":11,"error_msg":"网络错误,请稍后再试"} acceptResult with an expired token -> {"result":21,"error_msg":"sign error"} ``` This is strong evidence that the signature is **step-scoped**: the Step-1 signature is valid for `scanResult` but not for `acceptResult`. It also means "sign error" here does **not** mean the ticket is invalid — it means you called Step 3 too early or with the wrong signature. **[UNVERIFIED — needs a phone]** `result:0` here is the actual login. The page then does `window.location.href = returnUrl || "/"` — **no token is placed in the URL**, and the bundle reads no token from the body, so the authenticated session must arrive as **`Set-Cookie` headers on the `acceptResult` response** (scoped `Domain=.acfun.cn`, otherwise `www.acfun.cn` could not see it). The response is a credentialed cross-origin XHR (`withCredentials:true`, and the server advertises `Access-Control-Allow-Credentials: true`), which only makes sense if cookies are the payload. `result:100400002` here would mean the ticket died while waiting for phone-side authorization; any other code is a hard failure. ### Step 4 — persist and validate Serialize the captured cookie jar into one `Cookie:` header and use it for `www.acfun.cn`. Cheapest validation (documented in [`docs/acfun-api.md`](./acfun-api.md) §6.6): ``` POST https://www.acfun.cn/rest/pc-direct/user/personalInfo -> {"result":0, "info": {"userId": …, …}} # session valid -> {"result":-401,"error_msg":"user not login"} # not valid ``` A confirmed login must at minimum contain an `auth_key` cookie (numeric user id): the site's own front-end decides "logged in" by reading it (`A.Cookie.getCookie("auth_key")` in `common/widget/header/user.*.js` and `core.*.js`). --- ## 2. Response state machine ``` GET /qr/start?type=WEB_LOGIN │ │ result:0 (QR displayed; nothing polled yet = "waiting") ▼ GET /qr/scanResult ← ONE long-poll, held up to 120 s from start │ ├── result:0 ──────────► scanned ("扫码成功!请在手机上进行授权操作") │ │ │ ▼ │ GET /qr/acceptResult (with rotated signature) │ ├── result:0 ─────────► confirmed (Set-Cookie, redirect) │ ├── result:100400002 ─► expired (start again) │ └── other ────────────► failed │ ├── result:100400002 ──► expired ("二维码已失效" / "请点击刷新") → start again │ └── other (10/11/21) ──► failed ("登录失败", page reloads after 3 s) → start again ``` | State | Trigger (observed) | UI (site) | Plugin action | |---|---|---|---| | waiting | request held open | QR image + tip | keep the single poll open | | **scanned** | `scanResult` → `result:0` | "扫码成功!请在手机上进行授权操作" | call `acceptResult` with **new** signature | | **confirmed** | `acceptResult` → `result:0` | redirect to `returnUrl` or `/` | save cookie, mark logged in | | **expired** | `scanResult`/`acceptResult` → `100400002` | "二维码已失效 / 请点击刷新" | call `start` again; never re-poll | | **failed** | any other `result` (`10`, `11`, `21`, `100400000`) | "登录失败", reload after 3 s | surface error, offer restart | | network error / abort | fetch throws | treated as "fresh" (expired) | surface, offer restart | Observed `result` catalogue on this service: | `result` | HTTP | Seen from | Meaning | |---|---|---|---| | `0` | 200 | start (verified), scan/accept (bundle) | ok / scanned / confirmed | | `10` | 200 | scanResult | `client error` — ticket not pollable: in-flight elsewhere, terminal, or consumed | | `11` | 200 | scanResult / acceptResult | `网络错误,请稍后再试` — missing params | | `21` | 200 | scanResult / acceptResult | `sign error` — wrong/expired-step signature | | `100400000` | 200 | start (POST, wrong `type`) | `type is illegal` | | `100400002` | 200 | scanResult (verified), acceptResult (bundle) | `token expired` / QR dead → restart | --- ## 3. Capturing the session cookie in Node Node `fetch` has **no cookie jar** and does not persist `Set-Cookie`. Capture every response with `res.headers.getSetCookie()` (Node ≥ 19.7; Node 24 here) and rebuild the header manually. Do **not** use `headers.get('set-cookie')` — multiple cookies are comma-joined and `Expires=Wed, 27 Oct …` contains commas. ```js const jar = new Map(); function absorb(res) { for (const raw of res.headers.getSetCookie()) { const [pair] = raw.split(';'); const i = pair.indexOf('='); jar.set(pair.slice(0, i).trim(), pair.slice(i + 1).trim()); } } const cookieHeader = () => [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; '); ``` Apply `absorb` to **all three** QR responses (`start`, `scanResult`, `acceptResult`) — the session is expected on `acceptResult`, but absorbing everywhere is free and future-proof. Use `redirect: 'manual'` on `acceptResult` if you want to see a possible redirect's own `Set-Cookie` (`fetch` follows redirects by default and may drop intermediate headers). Expected cookies in a successful jar **[INFERRED — cannot verify without scanning]**: | Cookie | Role | Evidence level | |---|---|---| | `auth_key` | numeric user id; the site's "am I logged in?" check | name **VERIFIED** as read by site JS; set-at-login INFERRED | | `acPasstoken` | main session token | INFERRED (community docs; see [api doc §0.5](./acfun-api.md)) | | `_userid` | numeric user id | INFERRED | | `acPlatform` | `2` = PC web | INFERRED | | `_did` | device id, `Domain=.acfun.cn`, 1 y | **VERIFIED** (anonymous) | | `csrfToken` | host-only `www.acfun.cn` CSRF token | **VERIFIED** (anonymous) | | `ac_username`, `ac_userimg`, `userLevel`, `ac_time`, `checkMobile`, `checkEmail`, `online_status`, `userGroupLevel`, `uuid` | UI/profile hints | names **VERIFIED** as read by site JS (`core.*.js`, `user.*.js`); values INFERRED | Note the ordering trap: `scan.acfun.cn` must be able to *set* `Domain=.acfun.cn` cookies for the `www.acfun.cn` calls later; if `acceptResult` ever returns `result:0` with **no** `Set-Cookie`, treat the login as failed rather than saving an empty jar. Once confirmed, pass `cookieHeader()` verbatim as the plugin's `Cookie` header (this repo's `config.cookie` / `ACFUN_COOKIE`, `lib/providers` `setCookie()`). --- ## 4. CORS, origins, and required headers **[VERIFIED]** ```http access-control-allow-origin: https://www.acfun.cn access-control-allow-credentials: true ``` - The QR host only whitelists `https://www.acfun.cn` as a browser origin, and allows credentials. A browser page on another origin could not read these responses; **Node `fetch` ignores CORS**, so this is a non-issue for the plugin. - `Origin` request header is **not** required (verified: requests with no `Origin` succeed). - `Referer` is **not** required (verified: `start` with no Referer → `result:0`). - `User-Agent` is **not** required (verified: `curl/8.5.0`, `node`, and no UA at all). - `X-Requested-With` is **not** required (the site's own jQuery does not set it for these calls). - `withCredentials`/cookies are **not** required for `start`/`scanResult` (verified with an empty jar end-to-end, including the 120 s expiry path). Send `_did` if you have it, purely to look browser-like. - HTTPS only; no redirect chain observed. Reject any response whose `content-type` is not `application/json` (dead routes on `acfun.cn` answer 404 with an HTML SPA shell, and `scan.acfun.cn/` itself answers 500 HTML). --- ## 5. Anti-bot / rate-limit observations **[VERIFIED]** - No captcha, no `csrfToken`, no `sign`/`__`/`md5` parameter, no device attestation (`safety_id`) anywhere in the QR path. (The `stochastic` + `safety_id` machinery in the login bundle belongs to the **password** branch only.) - 15 consecutive `start` calls with ~0 ms spacing: `result` all `0`, **15 distinct tokens**, no `429`, no throttling. Practical guidance: one `start` per UI action; do not hammer. - **One poll per ticket.** A second simultaneous `scanResult` on the same ticket gets `result:10` immediately (verified), so a buggy client that fires overlapping polls will burn the ticket. - The QR dies **120 s after `start`** regardless of when you polled (verified with a 60 s-delayed poll). - `scan.acfun.cn` root path returns HTTP 500 HTML — only the `/rest/pc-direct/qr/*` routes are meaningful. No evidence of Origin/Referer enforcement or IP binding of the ticket (a ticket created with **no** cookies was polled successfully with **no** cookies). --- ## 6. Fallback paths (and what NOT to use) The web QR flow is **alive in 2026-10** — no fallback is required. Still, for completeness: ### 6.1 Password login (exists, NOT probed here) From the same `login.*.js` bundle (route existence **[VERIFIED from site JS]**, behavior **[UNVERIFIED]** — no account was used, per the task rules): ``` POST https://id.app.acfun.cn/rest/web/login/signin Content-Type: application/x-www-form-urlencoded; withCredentials username=&password=&key=&captcha=<4 chars> # sets a `stochastic` cookie (random btoa, path=/) *before* posting # result:0 -> success (Set-Cookie on id.app.acfun.cn/.acfun.cn); page fills avatar/username # result:100001016 / 100001018 -> captcha required / invalid GET https://id.app.acfun.cn/rest/web/login/captcha # -> {result:0, image, key} ``` Captcha kicks in after >3 failures (`ac_login_error` cookie counter). Do not implement this without user consent; it is a different security class from scanning. ### 6.2 Manual cookie paste (already supported — recommended fallback) `config.cookie` / `ACFUN_COOKIE` (see `README.md` §"Getting the cookie"): log in in a desktop browser → DevTools → Network → any `www.acfun.cn` document/`rest` request → copy the whole `Cookie` request header (contains at least `auth_key`, `acPasstoken`, `_did`, `_userid`, `acPlatform`, `csrfToken`; include everything). Paste as one line. This is the least-bad alternative if a future redeploy breaks the QR contract. ### 6.3 Dead ends — the Kuaishou `/rest/c/infra/ks/qr/*` paths **[VERIFIED] not the AcFun login, do not chase them** (they exist only inside the embedded Kuaishou Passport SDK in `@ac/sdk-pay`, driven by `baseUrl/sid/qrType/kpn`): ``` GET https://passport.kuaishou.com/rest/c/infra/ks/qr/start?sid=acfun -> 307 Location: https://passport.kuaishou.com/pc/account/login GET https://id.app.acfun.cn/rest/c/infra/ks/qr/start?sid=acfun -> 404 (Tomcat HTML) GET https://scan.acfun.cn/rest/c/infra/ks/qr/start?sid=acfun -> 500 (nginx error page) GET https://scan.acfun.cn/ -> 500 ``` `passport.kuaishou.com/pc/identity/qrcode` (from `@ks/identity-verification`) is the **captcha/identity-verification iframe**, not login. `id.app.acfun.cn/rest/web/*` is the password/captcha + midground-token host (see [api doc §7](./acfun-api.md)). --- ## 7. Minimal Node implementation sketch ```js // qr-flow.mjs — verified shape; 120 s TTL is absolute from start() const ORIGIN = 'https://scan.acfun.cn'; const HEADERS = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', 'Accept': 'application/json, text/plain, */*', 'Referer': 'https://www.acfun.cn/login/', }; export class QrFlow { jar = new Map(); token = ''; sig = ''; expiresAt = 0; #absorb(res) { /* getSetCookie() → Map, see §3 */ } #cookie() { return [...this.jar].map(([k, v]) => `${k}=${v}`).join('; '); } #get(path, query, signal) { const u = new URL(ORIGIN + path); for (const [k, v] of Object.entries(query)) u.searchParams.set(k, v); const headers = { ...HEADERS }; if (this.jar.size) headers.Cookie = this.#cookie(); return fetch(u, { headers, signal }); } async start() { const j = await (await this.#get('/rest/pc-direct/qr/start', { type: 'WEB_LOGIN' })).json(); if (j.result !== 0) throw new Error(`QR start failed: ${j.result} ${j.error_msg ?? ''}`); this.token = j.qrLoginToken; this.sig = j.qrLoginSignature; this.expiresAt = Date.now() + (j.expireTime || 120_000); return { qrDataUrl: `data:image/png;base64,${j.imageData}`, // 125×125 PNG expiresAt: this.expiresAt, ticket: this.token, }; } // ONE call; long-polls until scan or ticket expiry. Do not re-call this ticket. async waitForScan(signal) { const t = AbortSignal.timeout(Math.max(5_000, this.expiresAt - Date.now() + 5_000)); const r = await this.#get('/rest/pc-direct/qr/scanResult', { qrLoginToken: this.token, qrLoginSignature: this.sig }, signal ? AbortSignal.any([signal, t]) : t); this.#absorb(r); const j = await r.json(); if (j.result === 0) { if (j.qrLoginSignature) this.sig = j.qrLoginSignature; // rotation (bundle-proven) return 'scanned'; } if (j.result === 100400002) return 'expired'; return `error:${j.result}:${j.error_msg ?? ''}`; // incl. 10 = already consumed } // Call only after 'scanned', with the ROTATED signature. async accept(signal) { const t = AbortSignal.timeout(120_000); const r = await this.#get('/rest/pc-direct/qr/acceptResult', { qrLoginToken: this.token, qrLoginSignature: this.sig }, signal ? AbortSignal.any([signal, t]) : t); this.#absorb(r); // ← session Set-Cookie here const j = await r.json(); if (j.result === 0) return { state: 'confirmed', cookie: this.#cookie() }; if (j.result === 100400002) return { state: 'expired' }; return { state: 'failed', code: j.result, message: j.error_msg }; } } ``` Loop shape for a plugin/UI: `start()` → display `qrDataUrl` → **one** `waitForScan()` holds up to `expireTime`; on `scanned` → `accept()`; on `expired`/`failed`/timeout → surface and let the user press refresh (`start()` again). Never poll `scanResult` again after any terminal code. --- ## 8. Implementation gotchas (contract traps) 1. **`100400002` is terminal.** The server returns it when the 120 s long-poll ends with no scan (`"token expired"`). The site maps it to the *"二维码已失效 / 请点击刷新"* overlay. A client that treats it as "not scanned yet, keep waiting" will re-poll the dead ticket and get `{"result":10,"error_msg":"client error"}` in a tight loop until its own clock expires. *(As of 2026-10-27, `lib/qr-flow.js` in this repo maps `100400002` to `waiting`; live evidence says it is terminal. Keep that mapping only if you re-verify a waiting response — otherwise re-polling spins on `result:10`.)* 2. **Exactly one poll per ticket.** A second concurrent `scanResult` returns `result:10` immediately (verified). Serialize the poll; don't retry the same ticket on network error — `start()` a new one. 3. **Signature rotation.** `acceptResult` rejects the `start` signature with `{"result":21,"error_msg":"sign error"}` even for a fresh, valid ticket. Use the signature from the `scanResult` success body; the bundle does this explicitly. 4. **Absolute TTL.** `expireTime:120000` counts from `start`, not from the poll. A poll opened at t+60 s is held only ~60 s. 5. **GET only.** POST to `start`/`acceptResult` gets `result:21` / `100400000` even with a correct body. 6. **`type` must be `WEB_LOGIN`** (or omitted). Anything else: `100400000 "type is illegal"`. 7. **The QR is a base64 PNG**, not a URL/string: `data:image/png;base64,` + `imageData` (665 bytes, 125×125). No image host involved, nothing to download. 8. **No cookies are needed to start**; don't block login on `_did` seeding. Still forward whatever jar you have, and capture `Set-Cookie` on all three responses. 9. **`acceptResult` body carries no token** — do not look for one; the session is cookies. 10. **Error responses are HTTP 200.** Branch on `result`, not status. Only transport failures, HTML bodies, and 5xx are exceptional. --- ## 9. VERIFIED / UNVERIFIED table | # | Claim | Status | Evidence | |---|---|---|---| | 1 | QR service origin is `https://scan.acfun.cn` | **VERIFIED** | `login.*.js` strings + live 200s | | 2 | `GET /rest/pc-direct/qr/start?type=WEB_LOGIN` returns base64 PNG + token + signature + expireTime | **VERIFIED** | 200 `{"result":0,"next":"scanResult","expireTime":120000,…}` | | 3 | `imageData` is a 125×125 PNG (665 B decoded) | **VERIFIED** | PNG signature + IHDR read | | 4 | `start` needs no cookies, no Referer, no UA, no Origin | **VERIFIED** | 4 header variants → `result:0` | | 5 | `type` only `WEB_LOGIN` (or omitted) | **VERIFIED** | `APP_LOGIN`/`WEB`/`web_login` → `100400000 type is illegal` | | 6 | `start` is GET-only | **VERIFIED** | POST → `100400000 type is illegal` | | 7 | `scanResult` long-polls and expires at 120 s from `start` | **VERIFIED** | holds 120.489 s, 120.788 s; 60 s-late poll held 60.52 s | | 8 | Expiry body is `{"result":100400002,"error_msg":"token expired"}` | **VERIFIED** | probes 05/10/14 | | 9 | Second poll / re-poll → `{"result":10,"error_msg":"client error"}` | **VERIFIED** | probe 10 (699 ms), probe 05 (62 ms) | | 10 | Wrong signature → `{"result":21,"error_msg":"sign error"}` | **VERIFIED** | probes 04/07/10 | | 11 | Missing params → `{"result":11,"error_msg":"网络错误,请稍后再试"}` | **VERIFIED** | probes 07/08 | | 12 | `acceptResult` with unscanned ticket → `21 sign error` (even with start sig) | **VERIFIED** | probe 10 | | 13 | Signature rotates for `acceptResult` | **VERIFIED (bundle)** / body **[UNVERIFIED]** | `checkaccept(t, a.qrLoginSignature)` in `login.*.js` | | 14 | `scanResult` success code is `0` (scanned) | **[UNVERIFIED]** (bundle only) | `switch(a.result){case 0: status-success…}` | | 15 | Session arrives as `Set-Cookie` on `acceptResult`, not JSON | **[UNVERIFIED]** (strong inference) | redirect with no token; `withCredentials`; ACAC:true; no token read by bundle | | 16 | Cookie names `acPasstoken`,`_userid`,`acPlatform` are set at login | **[UNVERIFIED]** | community docs; front-end proves `auth_key`/`ac_username`/`ac_userimg`/… | | 17 | Front-end reads `auth_key`,`ac_username`,`ac_userimg`,`userLevel`,`ac_time`,`checkMobile`,`_did` | **VERIFIED** | `core.*.js`, `user.*.js` | | 18 | Phone-side authorization is a separate step after scan | **VERIFIED (UI+bundle)** | "扫码成功!请在手机上进行授权操作" + separate `acceptResult` | | 19 | No captcha / csrf / signed params in the QR path | **VERIFIED** | all QR probes anonymous, no extra params | | 20 | 15 rapid `start`s: no rate limit, unique tokens | **VERIFIED** | probe 07 | | 21 | `passport.kuaishou.com`/`/rest/c/infra/ks/qr/*` is NOT the AcFun login | **VERIFIED** | 307 / 404 / 500 probes | | 22 | Password login route `id.app.acfun.cn/rest/web/login/signin` exists | **VERIFIED (bundle)** / behavior **[UNVERIFIED]** | login bundle; not called | | 23 | `acceptResult` success body / cookie attribute list | **[UNVERIFIED]** | requires a real phone scan | --- ## 10. Re-verifying later - Login page: — grep the served HTML for `login/widget/login/login.` and fetch that bundle; it holds the three absolute endpoint URLs. If the host/paths change, they change there first. - Sanity check that the flow is alive without scanning: one `start` must return `result:0` with a fresh 892-ish-char `imageData`, and one `scanResult` must still be held for ~120 s before `100400002`. If `start` ever returns `100400000`/`11`/HTML, the contract moved — fall back to cookie paste (§6.2) and update this file. - Probe scripts used for this document: `%TEMP%\ac-qr\01…14-*.mjs` (kept out of the repo).