- 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
30 KiB
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 starts 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):
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
_didis device id, 1-year lifetime,Domain=.acfun.cn— anywww.acfun.cnresponse 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.csrfTokenis host-only forwww.acfun.cn; it is not required by the QR service (all QR probes below were made with an empty jar).window.loginConfigon that page only holds adomainWhitelistofreturnUrltargets 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:
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:
{
"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/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):
{"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):
{"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 §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.
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) |
_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]
access-control-allow-origin: https://www.acfun.cn
access-control-allow-credentials: true
- The QR host only whitelists
https://www.acfun.cnas a browser origin, and allows credentials. A browser page on another origin could not read these responses; Nodefetchignores CORS, so this is a non-issue for the plugin. Originrequest header is not required (verified: requests with noOriginsucceed).Refereris not required (verified:startwith no Referer →result:0).User-Agentis not required (verified:curl/8.5.0,node, and no UA at all).X-Requested-Withis not required (the site's own jQuery does not set it for these calls).withCredentials/cookies are not required forstart/scanResult(verified with an empty jar end-to-end, including the 120 s expiry path). Send_didif you have it, purely to look browser-like.- HTTPS only; no redirect chain observed. Reject any response whose
content-typeis notapplication/json(dead routes onacfun.cnanswer 404 with an HTML SPA shell, andscan.acfun.cn/itself answers 500 HTML).
5. Anti-bot / rate-limit observations
[VERIFIED]
- No captcha, no
csrfToken, nosign/__/md5parameter, no device attestation (safety_id) anywhere in the QR path. (Thestochastic+safety_idmachinery in the login bundle belongs to the password branch only.) - 15 consecutive
startcalls with ~0 ms spacing:resultall0, 15 distinct tokens, no429, no throttling. Practical guidance: onestartper UI action; do not hammer. - One poll per ticket. A second simultaneous
scanResulton the same ticket getsresult:10immediately (verified), so a buggy client that fires overlapping polls will burn the ticket. - The QR dies 120 s after
startregardless of when you polled (verified with a 60 s-delayed poll). scan.acfun.cnroot 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).
7. Minimal Node implementation sketch
// 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)
100400002is 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.jsin this repo maps100400002towaiting; live evidence says it is terminal. Keep that mapping only if you re-verify a waiting response — otherwise re-polling spins onresult:10.)- Exactly one poll per ticket. A second concurrent
scanResultreturnsresult:10immediately (verified). Serialize the poll; don't retry the same ticket on network error —start()a new one. - Signature rotation.
acceptResultrejects thestartsignature with{"result":21,"error_msg":"sign error"}even for a fresh, valid ticket. Use the signature from thescanResultsuccess body; the bundle does this explicitly. - Absolute TTL.
expireTime:120000counts fromstart, not from the poll. A poll opened at t+60 s is held only ~60 s. - GET only. POST to
start/acceptResultgetsresult:21/100400000even with a correct body. typemust beWEB_LOGIN(or omitted). Anything else:100400000 "type is illegal".- 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. - No cookies are needed to start; don't block login on
_didseeding. Still forward whatever jar you have, and captureSet-Cookieon all three responses. acceptResultbody carries no token — do not look for one; the session is cookies.- 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 starts: 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
startmust returnresult:0with a fresh 892-ish-charimageData, and onescanResultmust still be held for ~120 s before100400002. Ifstartever returns100400000/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).