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

30 KiB
Raw Blame History

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
  • _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:

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

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.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.

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)

  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 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 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).