Files
dsh-acfun/docs/acfun-login.md
T
tamakyi 97825f020d feat: QR login, AcFun settings panel, and 动态 (moment) tools
- scan.acfun.cn QR flow (start -> long-poll scanResult -> acceptResult) with
  rotated-signature handling, background poller, durable session in the Host
  credential service, and cookie handoff to the provider
- Client half: Settings -> AcFun page (status, QR code, poll, logout) over a
  same-origin /acfun-panel JSON API with cross-origin rejection
- acfun_moment / acfun_moment_comments / acfun_topic read tools, moment_like /
  moment_unlike / moment_banana actions, and comment posting on moments
- acfun_account status tool; docs for the QR contract, the 广场/动态 research
  verdict, and the client-composition restart requirement
2026-10-27 11:50:44 +08:00

590 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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,<imageData>` (the page does exactly this). |
| `qrLoginToken` | opaque ticket. Shape `1.<hex>.<hex>.<hex>`; 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=<token>&qrLoginSignature=<sig>
```
| 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":"<rotated>"}`
(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=<token>&qrLoginSignature=<rotated sig from Step 2>
```
**[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=<phone/email>&password=<pw>&key=<captchaKey>&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: <https://www.acfun.cn/login/> — 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).