- 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
590 lines
30 KiB
Markdown
590 lines
30 KiB
Markdown
# 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).
|