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
This commit is contained in:
tamakyi
2026-10-27 11:50:44 +08:00
parent f9bedde19e
commit 97825f020d
19 changed files with 3723 additions and 189 deletions
+589
View File
@@ -0,0 +1,589 @@
# 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).
+600
View File
@@ -0,0 +1,600 @@
# AcFun 广场 / 动态 (moment feed) — reverse-engineered for a Node.js `fetch` plugin
All probes were executed live on **2026-10-27 (Asia/Shanghai)** against `acfun.cn` hosts
(`www.acfun.cn`, `m.acfun.cn`, `api-ipv6.acfunchina.com`, `kuaishouzt.com`) using a desktop Chrome
User-Agent and `Referer: https://www.acfun.cn/` (or `https://m.acfun.cn/` for mobile hosts).
No AcFun account was used. Every claim below is marked **[VERIFIED]** (called live, real
status/JSON/payload pasted) or **[UNVERIFIED]** (contract read out of the site's own JS, or a write
that could only be probed up to its auth error). **No write was ever executed.**
Probe scripts live in `%TEMP%\acfun-moment-probe\` (`p1…p33*.mjs`) and are not part of the repo.
---
## 0. Verdict first (read this if nothing else)
| Question | Answer |
|---|---|
| Is there an official **web** 广场/动态 feed? | **No.** `https://www.acfun.cn/moment/` → `302 → /404`. The www nav contains no 动态/广场 entry at all. `/member/feeds` exists but `302`s anonymous visitors to the login page. There is **no moment bundle** in the www app (40 bundles / 50 `/rest/` paths scanned; only one internal bundle mentions `moment`). |
| Does a **usable moment read API** exist? | **Yes — one.** `GET /rest/pc-direct/moment/detail?momentId={id}` returns a full moment object **anonymously**. Moment comments work anonymously too (`comment/listByFloor` with `sourceType=4`). |
| Is there a public "square"/recommend **list** API? | **Not found.** ~560 guessed paths across `/rest/pc-direct/*`, `/rest/mobile-direct/*`, `/rest/app/*`, `/rest/zt/*` and five hosts. The only moment *lists* are (a) **login-scoped** following feeds (`feed/followFeedV2`, `feed/followDougaFeed`, `feed/webPush`) and (b) the **mobile-web topic page HTML**, which server-renders a fixed 15 items with no pagination. |
| Can a plugin post a 动态? | **No.** Publish is app-only: `POST https://api-ipv6.acfunchina.com/rest/app/moment/add` → `401 {"result":-401,"error_msg":"token value error"}`. Requires the signed `acfun.midground.api_st` token *(see §5)*, i.e. an AcFun **app** session, not a web cookie. |
| What is the real 2026 surface? | **社区圈 / 话题 (`communityCircle`)** on mobile web: `https://m.acfun.cn/communityCircle/{tagId}` is alive with freshly-dated content (observed `2026-10-13`), a topic index (e.g. 教育 = `38关注 | 10458内容`), and moment ids in a **new 7-digit space** (e.g. `5093831`). |
| Does the community build its own web 广场? | Yes — "[【油猴脚本】网页端动态广场!](https://www.acfun.cn/a/ac48817170)" (2026-09-01) plus Greasy Fork scripts `579890` / `584032`. That is direct evidence there is an API but **no official web UI**. (Greasy Fork was unreachable from this network, so their exact endpoint list could not be read; the endpoints below were derived from AcFun's own bundles and verified live.) |
**Bottom line:** a plugin can be a **moment reader + comment reader** today, with no cookie at all,
plus a cookie-gated "following feed" reader. Every write except maybe like/banana/comment is
effectively impossible.
---
## 1. Hosts and URL prefixes (the one thing that trips everyone up)
| Purpose | Host / prefix |
|---|---|
| PC web JSON BFF | `https://www.acfun.cn/rest/pc-direct/...` |
| PC web **SSR-internal** BFF | `https://www.acfun.cn/rest/pc-node/...` — **[VERIFIED]** every call → `502`; not publicly routed |
| Mobile web JSON BFF | `https://m.acfun.cn/rest/mobile-direct/...` — **[VERIFIED]** `502` on `www.acfun.cn`, JSON on `m.acfun.cn` (**host-bound**) |
| Mobile web pages | `https://m.acfun.cn/communityCircle/...` |
| App API | `https://api-ipv6.acfunchina.com/rest/app/...` |
| App API (IPv6-less siblings) | `https://api.acfun.cn` → `502` **[VERIFIED]**; `app.acfun.cn` / `ias.acfun.cn` / `zt.acfun.cn` → DNS/connect failure |
| Like / interact service | `https://kuaishouzt.com/rest/zt/...`, `https://api.kuaishouzt.com/rest/zt/...` |
| Auth token service | `https://id.app.acfun.cn/rest/web/token/get` |
| Captcha / QR | `https://www.acfun.cn/rest/c/infra/captcha/{get,verify}`, `/rest/c/infra/ks/qr/*` (from bundles) |
**[VERIFIED]** The PC bundle states the prefix rule verbatim — this is why `pc-node` exists:
```js
function p(){return "server"===e.env.VUE_ENV||a.a.prototype.$isServer ? "/rest/pc-node" : "/rest/pc-direct"}
```
A Node plugin is **not** the AcFun SSR server, so it must use `/rest/pc-direct` (or `/rest/mobile-direct`
on `m.acfun.cn`, or `/rest/app` on the app host). `/rest/pc-node/*` is unusable from outside.
**Recommended headers** (what the mobile pages answer best to — see §3.4 for the `Accept` quirk):
```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/ (or https://m.acfun.cn/ for mobile hosts)
Accept: */*
Content-Type: application/x-www-form-urlencoded; charset=UTF-8 # for POSTs
```
No `sign`/`__`/`md5` parameter is needed on any read verified here. Writes need the midground token (§5).
---
## 2. Feature map — what actually exists in 2026
### 2.1 Page routes (all fetched live)
| URL | Result | Verdict |
|---|---|---|
| `https://www.acfun.cn/moment/` | `302 → /404` (52-byte body) | **dead** |
| `https://www.acfun.cn/moment` | `302 → /404` | **dead** |
| `https://www.acfun.cn/moment/am:1` | `302 → /404` | **dead** (the `/moment/am:momentId` route string still sits in an old bundle) |
| `https://www.acfun.cn/moment/square` | `302 → /404` | **dead** |
| `https://www.acfun.cn/square`, `/community`, `/discovery`, `/dynamic`, `/feed`, `/am`, `/index/moment`, `/square/` | `502` + 18 366-byte "出错啦!" page | **nonexistent** (`502` is AcFun's unknown-route response on www) |
| `https://www.acfun.cn/member/feeds`, `/member/` | `302 //www.acfun.cn?returnUrl=https%3A%2F%2Fwww.acfun.cn%2Fmember` | exists, **login-gated** (个人动态中心) |
| `https://www.acfun.cn/u/1` | `200`, 73 523 B | user space — tabs are 投稿/收藏/关注/粉丝; **no 动态 tab**, zero occurrences of `moment`/`动态` |
| `https://www.acfun.cn/` | `200`, 155 119 B, 8 bigPipe pagelets (`pagelet_player_info, pagelet_navigation, pagelet_live, pagelet_spring_festival, pagelet_monkey_recommend, pagelet_top_area, pagelet_banner, pagelet_list_banana`) | nav = 首页 / 直播 / 番剧 / video channels / 排行榜 — **no 广场, no 动态** |
| `https://m.acfun.cn/` | `302 → http://www.acfun.cn` | mobile home redirects to www |
| `https://m.acfun.cn/communityCircle` | `200` (31–40 KB shell) | 社区圈 index, **client-rendered** (no tags in HTML) |
| `https://m.acfun.cn/communityCircle/square` | `200` shell | 话题广场, **client-rendered, no tags in HTML** |
| `https://m.acfun.cn/communityCircle/tops`, `/tag` | `200` shell | same |
| `https://m.acfun.cn/communityCircle/{tagId}` | `200`, **144 KB, 15 moment items SSR'd** | **the working 广场 read surface** (§3.4) |
| `https://m.acfun.cn/communityCircle/moment/{momentId}` | `200`, `<title>Acfun 动态详情</title>`, contains `.moment-text`, `.moment-img-box`, `#comment` | **canonical human-readable moment page** |
| `https://m.acfun.cn/moment/200` | 404 page (HTTP 200) | wrong path |
### 2.2 JS bundle evidence (www, 40 bundles / 2.7 MB scanned)
Only **one** www bundle mentions `moment`: `https://ali-imgs.acfun.cn/kos/nlav10360/static/js/app.11fc9e51.js`
(the personal-center/header SPA). It contains the complete moment *store*, the feed *list* actions, and
the interaction *actions* — but **no publish action and no moment page component**. Extracted facts:
```js
// moment store normalisation — THE key mapping
n.resourceType = 10; // moments are resourceType 10
n.resourceId = n.momentId;
// moment detail
GetMomentDetail: function(e,t){
return a.a.get(Object(a.b)(e.rootState, Object(a.e)() + "/moment/detail?momentId=" + t), …)
.then(function(t){ … if(0===n.result) e.commit(f.CHANGE_MOMENT_DETAIL,{moment:n.moment, repostSource:n.repostSource}) })
}
// following feed list
GetFeedsFlow: function(e,t){ var n=t.pcursor||0, o=t.pageSize||20, c=t.resourceTypes||0;
… a.a.get(prefix + "/feed/followFeedV2?pcursor="+n+"&count="+o+"&resourceTypes="+c) …
// resourceTypes === 0 -> ChangeFeedsFlow (all), else -> ChangeFeedsArticleFlow (articles only)
}
// like / unlike
var h = "like"===u ? "/rest/zt/interact/add" : "/rest/zt/interact/delete";
a.a.post("//"+ztDomain+h, qs.stringify({
objectId:n, objectType:o, userId:d,
"acfun.midground.api_st":s, kpn:"ACFUN_APP", kpf:"PC_WEB", subBiz:"mainApp", interactType:1
}), {headers:{"Content-Type":"application/x-www-form-urlencoded"}})
// success is checked as: 1 === t.data.result
// banana on a moment
FeedThrowBanana: function(e,t){ var n=t.resourceId,r=t.resourceType,o=t.count;
a.a.post(prefix + "/banana/throwBanana", qs.stringify({resourceId:n, resourceType:r, count:o}), …)
// success is checked as: 0 === n.data.result
}
// other endpoints present in the same bundle
"/feed/followFeedV2?pcursor=0&count=5&resourceTypes=0"
"/feed/followDougaFeed?pcursor="
"/feed/webPush?count=50&pcursor=0"
"/feed/favorite/bangumi?count=10"
```
Routes in that bundle: `/member/feeds`, `/member/feeds/fans`, `/member/feeds/following`,
`/moment/am:momentId` (legacy string), `/moments/...` none. No publish/edit/delete anywhere.
---
## 3. Read contracts (each one verified live)
### 3.1 Moment detail — **the one reliable read** **[VERIFIED]**
```
GET https://www.acfun.cn/rest/pc-direct/moment/detail?momentId={momentId}
```
- Method/params: `GET`, single query param `momentId` (numeric). **Anonymous works.** No cookie needed.
- Also served identically by `https://api-ipv6.acfunchina.com/rest/app/moment/detail?momentId=…` and
`https://m.acfun.cn/rest/mobile-direct/moment/detail?momentId=…` **[VERIFIED]** (all three → `result:0`).
Live, trimmed (`momentId=5093831`, current-era content):
```json
{"result":0,"host-name":"hb2-acfun-kce-node24.aliyun","requestId":"eac1ac4e8c4e4fbd8d82a662927e7cc2",
"moment":{
"momentId":"5093831","momentType":2,"createTime":"2026-09-23",
"text":"#教育##父亲##贷款#","replaceUbbText":"#教育##父亲##贷款#",
"user":{"id":"23240229","name":"红格男(RSM)",
"headUrl":"https://tx-free-imgs.acfun.cn/newUpload/23240229_cfb0100bc9a64c0aa96d8…"},
"imgs":[{"w":1080,"h":2554,"type":4,"originUrl":"https://tx-free-imgs.acfun.cn/newUpload/23240229_44bf454add3…"}],
"likeCount":13,"commentCount":6,"bananaCount":5,"shareCount":0,
"isLike":false,"isThrowBanana":false,"visibleForFans":false,"originResourceType":0,
"tagList":[{"tagId":40887,"tagName":"贷款"},{"tagId":488,"tagName":"教育"},{"tagId":1968,"tagName":"父亲"}],
"shareUrl":"https://m.acfun.cn/communityCircle/moment/5093831"}}
```
Top-level keys: `result, host-name, requestId, moment` — plus `repostSource` **only when the moment is a
repost** (then counts/author live under `repostSource`, per the store mutation).
#### Moment id space — two eras **[VERIFIED]**
`moment/detail` distinguishes "not found" reasons, which lets you map the id space:
| `momentId` | `result` | `error_msg` |
|---|---|---|
| `1`, `100` | `140005` | `动态不存在或者已被删除` (in-range, deleted) |
| `10000`, `1000000`, `10000000`, `100000000`, `48910450`, `48896959` | `140002` | `内容不存在` (out of range) |
| `200`, `202`, `300`, `5093831`, `5093817` | **`0`** | real moments |
Binary search pinned the boundary: ids **≤ 7525** are "in range" (`140005`), ids **≥ 7526** are
`140002`. That range is the **legacy 2019 动态 system**: every live id found in `1..1200` is dated
`2019-05-23 … 2019-06-14`, user set is the 2019 launch crowd (七里香004, soso15, ACer第一次耳机 …),
`momentType` 1/2, text like "樱花" / "搞笑图片". The **current** system uses **7-digit ids** (`5093831`
→ dated `2026-09-23`, tag list with 话题 ids) and is reachable **only** by knowing the id (from a topic
page) — there is no enumerable list here.
So: `getMoment(id)` works for both eras, but ids cannot be discovered by the API alone.
### 3.2 Moment comments — **[VERIFIED]**
```
GET https://www.acfun.cn/rest/pc-direct/comment/listByFloor
?sourceId={momentId}&sourceType=4&page=1&supportZtEmot=true
```
**`sourceType = 4` identifies a moment.** Proof from a live matrix on `sourceId=202` (a moment whose
`commentCount` is 2):
| `sourceType` | HTTP | payload |
|---|---|---|
| **4** | 200 | `{"result":0,"curPage":1,"isUp":false,"sourceType":4,"totalPage":1,"totalCount":2, "commentsMap":{…},"commentIds":[…],"pageSize":50,…}` — **2 real comments** |
| 3 | 200 | `{"result":0,"sourceType":3,"totalPage":0,"totalCount":0,"commentsMap":{}}` (video type — empty) |
| 2 | 200 | `{"result":0,"sourceType":2,"totalPage":0,"totalCount":0}` (valid but unrelated) |
| 10 | 200 | `{"result":21,"error_msg":"参数格式错误,请仔细阅读API文档。"}` (invalid) |
Comment object keys **[VERIFIED]**:
`imageInfo, userHeadImgInfo, avatarFrameImgInfo, imageUrl, isDelete, quoteId, nameRed, nameColor, headUrl,
verifiedTypes, deviceModel, isSignedUpCollege, avatarFrame, avatarImage, sourceId, cid, isUpDelete, floor,
postDate, sourceType, userName, userId, isUp, verified, content, timestamp`
(e.g. `{"userName":"soso15","userId":"13360169","content":"666666"}`).
Note the **numbering split**, this is the classic trap: comments use `sourceType 4`, while
like/banana use `resourceType 10` (§4). They are different enums for the same object.
`/rest/mobile-direct/comment/list` also exists on `m.acfun.cn` (`result:21` without params → route alive)
but its param names (`resourceId`/`resourceType`) were rejected in probing — **[UNVERIFIED]**; use the
`pc-direct` `listByFloor` form above, which is fully verified.
### 3.3 Following feeds (the "动态流") — route **[VERIFIED]**, data login-scoped
```
GET https://www.acfun.cn/rest/pc-direct/feed/followFeedV2?pcursor=0&count=20&resourceTypes=0
GET https://www.acfun.cn/rest/pc-direct/feed/followDougaFeed?pcursor=0&count=20
GET https://www.acfun.cn/rest/pc-direct/feed/webPush?count=50&pcursor=0
GET https://www.acfun.cn/rest/pc-direct/feed/favorite/bangumi?count=10
```
Anonymous live results — **note these are `200`, not auth errors**, because the routes are
guest-tolerant and simply have nothing to show a guest with `followingCount: 0`:
```json
// followFeedV2
{"result":0,"feedList":[],"pullCount":0,"userInfo":{"followingCount":0},
"requestId":"ea7f66cd511f4926bf9e25c55435415a","followTags":[],"pcursor":"no_more","ups":[],"upsPos":0}
// followDougaFeed
{"result":0,"feedList":[],"userInfo":{"followingCount":0},"followUpers":[],"pcursor":"no_more","pageSize":10,"favBangumis":[]}
// webPush
{"result":0,"feedList":[],"followUpers":[],"pcursor":"no_more"}
// favorite/bangumi -> AUTH ERROR
{"result":-401,"error_msg":"user not login"}
```
So `followFeedV2` is the **right endpoint for "关注动态"** and it *does* carry moments in `feedList`
(the store action names them `feeds`, and the item schema is the same `resourceType 10` object), but
its content is entirely a function of the logged-in user's follows. **It is useless anonymously and
it is not a 广场.**
Params: `pcursor` (opaque cursor; `"no_more"` = end), `count` (default 20 in JS), `resourceTypes`
(`0` = all → `ChangeFeedsFlow`; non-zero → article flow, per the bundle's ternary).
The app API mirrors these **[VERIFIED]**: `/rest/app/feed/followFeed` → `200`
`{"result":0,"feedList":[],"pageNo":1,"totalPage":0,"pageSize":10}`, `/rest/app/feed/followFeedV2`
→ `200` same shape, `/rest/app/feed/followDougaFeed` → `result:11`.
### 3.4 Topic page (社区圈) — the only public "广场" read, **HTML only** **[VERIFIED]**
```
GET https://m.acfun.cn/communityCircle/{tagId}
```
Live (`tagId=488`, 教育): `200`, **144 110 bytes**, title `Acfun【教育】话题,快来参与`,
topic header `教育` + `38关注 | 10458内容`, and **15 server-rendered moment items**, newest dated
`2026-10-13` — i.e. this subsystem is genuinely in use today.
Item markup (stable, scrape-able):
```html
<li class="resources-item ks-1px-b type-1"
moment-id="" resource-type="1" resource-id="48910450" data-repost="false">
<div class="resources-user">…<p class="user-name">c137牛奶娘</p><p class="release-time">2026-10-13</p>…</div>
<div class="resources-article"><p class="article-title">…</p><p class="article-body">…</p></div>
<div class="resources-tag pics-0"><a class="tag-item" href="/communityCircle/488">#教育</a>…</div>
<div class="resources-base">…<span class="base-text">228</span>…</div>
</li>
```
Observed `resource-type` values and their `moment-id`:
| `resource-type` | `moment-id` | meaning |
|---|---|---|
| `1` | `""` (empty) | 图文动态/article-style post; only `resource-id` (e.g. `48910450`) — **not** a moment id |
| `2` | `""` | video-style post; only `resource-id` |
| `3` | **`5093831`, `5093817`** | **real moment** — this is how you harvest current moment ids |
⚠️ **The `Accept` header flips the renderer** — this cost me a lot of time, so it is worth stating
precisely. Same URL, two outcomes:
| Request headers | Length | Items |
|---|---|---|
| default `Accept` (or none, or `Referer` www vs m — irrelevant) | 144 110 | **15** ✅ |
| `Accept: text/html,application/xhtml+xml…` | 31 628 | **0** (JS shell) |
| `User-Agent: …Googlebot…` | 41 722 | 15 (different shell) |
So a Node `fetch` must send **default/`*/*` Accept** to get the SSR'd list, and must **not** pretend to be a
browser navigation.
⚠️ **No pagination.** `?page=2`, `?page=3`, `?sort=reply`, `?sort=post` all return the *identical* 15
items (byte-identical length, same first `resource-id=48910450`). The `热门排序 / 回复时间 / 发帖时间`
filter and the infinite scroll are client-side-only and their data source is **not** reachable:
`/communityCircle/square`, `/tops`, `/tag`, `/communityCircle` are all empty shells with **zero** tag
entries or `/rest/` URLs in the HTML.
### 3.5 `feed/hot` — **[VERIFIED]** but it is **videos, not moments**
```
GET https://www.acfun.cn/rest/pc-direct/feed/hot (also /rest/mobile-direct/feed/hot, /rest/app/feed/hot)
```
`200` `{"result":0,"feeds":[…], "host-name":…, "requestId":…}` — 9–14 items, **every one `type: 2`**
(douga video), with `dougaId`/`videoId`/`caption`/`commentCount`/`bananaCount` etc. It is the hot
**video** module. `count`, `resourceTypes`, `type`, `pcursor` are **all ignored** (same 9–14 items each
time, no `pcursor` in the response). Do not mistake it for a 广场 moment feed — but it is a fine
anonymous "hot feed" and the item schema is the same feed-item envelope that moments use.
---
## 4. Interaction endpoints (contracts from the site's own JS; writes only probed to their auth error)
> I have no account, so each write below was attempted **unauthenticated only** to prove the route and
> observe the auth tier. **No like/banana/comment/follow/post was ever actually performed.**
### 4.1 Like / unlike a moment **[UNVERIFIED — route + auth tier verified]**
```
POST https://kuaishouzt.com/rest/zt/interact/add # like
POST https://kuaishouzt.com/rest/zt/interact/delete # unlike
Content-Type: application/x-www-form-urlencoded
objectId={momentId}&objectType=10&userId={authorUserId}
&acfun.midground.api_st={token}&kpn=ACFUN_APP&kpf=PC_WEB&subBiz=mainApp&interactType=1
```
- `objectType` = the moment's `resourceType` = **10** (from the store's `n.resourceType=10`).
- Success is `{"result":1}` (note: **1**, not 0 — this endpoint's envelope differs from `pc-direct`).
- Auth tier: **signed midground token**. **[VERIFIED]** unauthenticated call →
`401 {"result":-401,"error_msg":"token value error","error_id":""}`.
(Note `/rest/zt/interact/delete` is the *unlike* route — it is not "delete a moment".)
### 4.2 投蕉 (banana) on a moment **[UNVERIFIED — route + auth tier verified]**
```
POST https://www.acfun.cn/rest/pc-direct/banana/throwBanana
Content-Type: application/x-www-form-urlencoded
resourceId={momentId}&resourceType=10&count={1..5}
```
**[VERIFIED]** unauthenticated → `401 {"result":-401,"error_msg":"user not login"}` (same for
`resourceType=1`), i.e. route exists, **plain session cookie** tier. Success would be `result:0`.
### 4.3 Comment / reply on a moment **[UNVERIFIED]**
```
POST https://www.acfun.cn/rest/pc-direct/comment/add
objectId={momentId}&content={ubb text}&replyToCommentId={0|commentId}
```
Inferred from the verified comment *list* pairing (`sourceId`+`sourceType=4`) and the existing
video-comment contract in [`docs/acfun-api.md`](acfun-api.md) (§3), which documents `comment/add` taking
`objectId`. Whether a moment post also needs `sourceType=4` is **[UNVERIFIED]** — the mobile comment
widget constructs itself with `initCommentWidget({resourceId: momentId, resourceType: 4, …})`
(from `pages/momentDetail/index.*.js`), so send `sourceType=4` as well to be safe.
Auth tier: **plain session cookie** (`user not login`). Mobile equivalent that exists but whose params I
could not pin down: `POST https://m.acfun.cn/rest/mobile-direct/comment/add` → `result:11` **[VERIFIED route]**.
### 4.4 Follow the moment author **[UNVERIFIED]**
```
POST https://www.acfun.cn/rest/pc-direct/relation/follow # {targetUid} / {herdId}
```
(`/rest/pc-direct/relation/follow` and `/rest/pc-direct/relation/getGroups` are both present in the www
bundles.) Cookie tier.
### 4.5 Vote (投票动态) **[UNVERIFIED — route + auth tier verified]**
```
POST https://api-ipv6.acfunchina.com/rest/app/moment/vote
```
**[VERIFIED]** `401 {"result":-401,"error_msg":"token value error"}` → app token tier. The moment store
has an `UpdateFeedVote` mutation and the mobile page reads `vote__radio` DOM, so 投票 moments exist as
`momentType` variants; the web cannot drive them.
### 4.6 Repost / 转发 **[UNVERIFIED — no route found]**
The store has `updateFeedRepostFollow` / `updateFeedRepostFavorite` mutations and renders
`repostSource`, so reposts exist and **can be read** (a repost's payload has a `repostSource` sibling).
But **no repost/forward endpoint was found**: `/rest/pc-direct/moment/{repost,forward,share}` → `404`,
and `/rest/app/moment/repost` → `404`. Most likely reposting = `moment/add` with an origin field
(app-only). Treat as impossible for a plugin.
### 4.7 Publish / delete a 动态 **[UNVERIFIED — app-only, token-gated]**
```
POST https://api-ipv6.acfunchina.com/rest/app/moment/add # publish
POST https://api-ipv6.acfunchina.com/rest/app/moment/delete # delete
```
**[VERIFIED]** `POST moment/add` (any body: `content=test`, `momentId=1`, or empty) →
`401 {"result":-401,"error_msg":"token value error"}` — the handler ran, so the route is real; the
`GET` form returns `result:11` (`网络错误,请稍后再试`). `moment/delete` returns `result:11` on GET
(route alive). **Every** `/rest/pc-direct/moment/{add,create,publish,post,delete,remove,repost,forward,
share,like,unlike,vote,update,edit}` returns **HTTP 404 on www** — there is no web publish path at all,
which is exactly why the community wrote a userscript instead of using the site.
Image upload for a moment would go through `/rest/pc-direct/image/upload/getToken` +
`getUrlAfterUpload` (present in bundles) — **[UNVERIFIED]**, and pointless without a publish route.
---
## 5. Auth tiers, signing, captcha
| Tier | What it needs | Evidence seen |
|---|---|---|
| **Anonymous** | nothing | `moment/detail`, `comment/listByFloor`, `feed/followFeedV2`, `feed/hot`, `m.acfun.cn/communityCircle/*` all `200` |
| **Session cookie** | `auth_key` / `_did` / `csrfToken` cookies from a real login | `401 {"result":-401,"error_msg":"user not login"}` for `feed/favorite/bangumi`, `banana/throwBanana` |
| **Signed midground token** | `acfun.midground.api_st`, obtained from `https://id.app.acfun.cn/rest/web/token/get` with a **logged-in app/web session**; sent as a form field | `401 {"result":-401,"error_msg":"token value error"}` for `kuaishouzt/rest/zt/interact/add`, `/rest/app/moment/add`, `/rest/app/moment/delete`, `/rest/app/moment/vote` |
- **No HMAC/md5 request signing** was observed on any read, nor on `interact/add` (it sends plain form
fields plus the token). The "signature" is effectively just the midground token.
- **Anonymous visit cookies** are only `csrfToken` and `_did` — no session.
- **Captcha / identity**: `/rest/c/infra/captcha/{get,verify}` and `/rest/c/infra/ks/qr/{start,scanResult,
acceptResult}` exist for login/QR. Both the desktop and mobile pages load
`@ks/identity-verification` (`static/@ks/identity-verification.81c79f9a249d01283c29.js`), so publishing
may also require 实名/手机号 identity verification **on top of** the token — **[UNVERIFIED]**, but the
SDK's presence on the very pages that would post is a strong hint.
- Observed `result` codes relevant here: `0` ok · `1` ok for `interact/add` · `11` handler ran, context/
params rejected · `21` param format error · `-401` auth (cookie or token) · **`140002` 内容不存在** ·
**`140005` 动态不存在或者已被删除** · `129000` kuaishouzt "Server Error." (missing token entirely).
---
## 6. Field maps
### 6.1 Moment object (`moment/detail` → `moment`, and `feedList[]` items when `resourceType`/`type` is a moment)
| Field | Type | Meaning / note |
|---|---|---|
| `momentId` | string | **the id** — string, not number. Used by detail + comments (`sourceId`) |
| `momentType` | number | `1` text-only, `2` image/text, `3` vote/其他 (observed 1/2 live; `3` inferred from `/communityCircle/{tagId}` items whose `moments-type-3` markup carries a vote) |
| `text` | string | raw content, includes `#话题#` markers and newlines |
| `replaceUbbText` | string | same text after UBB processing |
| `createTime` | `YYYY-MM-DD` **string** | day precision only — no time-of-day |
| `user` | object | see 6.2 |
| `imgs[]` | array | `{width,height,size,type,url,expandedUrl,originUrl}`; `type` 1/3/4 observed (gif vs jpg vs png); `originUrl` is the full-size original, `url` is a 224×224 thumb (`imageView2/5/w/224/h/224`) |
| `imgInfos[]` | array | the newer `<ImageInfo>` envelope (`thumbnailImage/smallSharedImage/expandedImage/originImage`, each `{cdnUrls:[{url,freeTrafficCdn}]}`) — prefer `imgs[].originUrl` for a plugin |
| `likeCount` / `commentCount` / `bananaCount` / `shareCount` | number | counters; `bananaCount` = 投蕉 |
| `isLike` / `isThrowBanana` | boolean | always `false` anonymously; `true`/`false` when a cookie is present |
| `visibleForFans` | boolean | 粉丝可见 flag |
| `originResourceType` | number | `0` for a native moment; non-zero when it wraps another resource |
| `tagList[]` | array | `{tagId, tagName}` — the 话题 links; `tagId` feeds `/communityCircle/{tagId}` |
| `shareUrl` | string | `https://m.acfun.cn/communityCircle/moment/{momentId}` — **use this as the canonical URL** |
| `groupId` | string | per-request rendering group, not stable — ignore |
| `voteInfo` | object | present on 投票 moments only (referenced by the `UpdateFeedVote` mutation) |
| `repostSource` | object (sibling of `moment`) | on reposts: a second moment-shaped object with its own `user`/counts; the store reads `moment.repostSource.*` |
### 6.2 Author object (`moment.user`)
`id` (numeric string), `name`, `headUrl`, `headCdnUrls[]`, `userHeadImgInfo`, `avatarFrame`/`avatarFramePcImg`/
`avatarFrameMobileImg`, `signature`, `gender`, `fanCount`/`fanCountValue`, `followingCount`/
`followingCountValue`, `contributeCount`/`contributeCountValue`, `isFollowing`, `followingStatus`,
`isFollowed`, `href`, `action`, `nameColor`, `socialMedal`, `sexTrend`.
The store normalises this to `{userHead, userId, userName, liveId}` — if you cache, keep `id`+`name`.
### 6.3 Feed-item envelope (`feedList[]`, `feed/hot[]`)
`groupId, contentId, videoId, dougaId, sourceId, type, caption, title, description, coverUrl, coverUrls[],
coverCdnUrls[], displayPlayCount, viewCount, displayDanmakuCount, danmakuCount, likeCount, likeCountShow,
commentCount, commentCountRealValue, bananaCount, bananaCountShow, shareCount, stowCount, isLike,
isThrowBanana, isFavorite, isFollowing, createTime, createTimeMillis, contributeTime, playDuration,
durationMillis, videoList[] (part list with id/title/danmakuCount), tagList[], user, channel,
superUbb, status, expTag, videoSizeType, shareUrl, picShareUrl`
For a moment, `type`/`resourceType` is **10** and the moment body sits alongside as `moment` /
`repostSource` (per the store). `feed/hot` items are `type:2` videos.
---
## 7. Dead ends, 404s, and app-only
**[VERIFIED] 404 on `www.acfun.cn`** (route simply not served publicly):
`/rest/pc-direct/moment/{add,create,publish,post,delete,remove,repost,forward,share,like,unlike,vote,
update,edit}` · `/rest/pc-direct/feed/{publish,addMoment,square,recommend,circle,topic,list,getFeed,
feedList,queryFeed,recommendList,recommendMoment,discover,index,home,timeline,circleFeed,topicFeed,
hotMoment,userFeed,userMoment,user,contribute}` · `/rest/pc-direct/moment/{list,getList,userList,hot,
recommend,square}` · `/rest/pc-direct/{topic/*,communityCircle/*,tag/resources,resources/list,resource/list}`.
**[VERIFIED] 404 on `api-ipv6.acfunchina.com`**: `/rest/app/moment/{square,list,recommend,hot,feed,
timeline,publish,create,repost}` · `/rest/app/{square/*,circle/list,community/*,feed/{square,list,
recommend}}`.
**[VERIFIED] 404 on `m.acfun.cn`**: `/rest/mobile-direct/{topic/*,communityCircle/*,tag/*,moment/{list,
square,recommend,hot,add,publish},feed/{square,recommend,list},square/list,resources/list}`.
**502 (wrong host)**: `/rest/pc-node/*` on www; `/rest/mobile-direct/*` on www.
**HTML 200 (not JSON) on kuaishouzt.com**: `/rest/zt/{feed,moment,square}/*` — only
`/rest/zt/interact/{add,delete}` is a real JSON endpoint there.
**App-only / effectively impossible for a web plugin**: publishing a 动态, deleting one, reposting,
voting, and the app's 广场/推荐 timeline. All of these sit behind the midground token on
`api-ipv6.acfunchina.com` and have no web counterpart. There is **no public square/recommend moment
list API** on any host — after ~560 path probes this is a solid negative, not a gap in the search.
---
## 8. Feasibility and the recommended plugin surface
### 8.1 Verdict per capability
| Capability | Feasible? | Basis |
|---|---|---|
| Read one moment by id (text, images, tags, counts, author) | ✅ **reliable, anonymous** | `moment/detail` `result:0` on legacy + 2026 ids |
| Read a moment's comments | ✅ **reliable, anonymous** | `comment/listByFloor?sourceType=4` `totalCount:2` with real comments |
| Read a topic/话题's moments | ⚠️ **possible but HTML-only, first 15, no paging** | `m.acfun.cn/communityCircle/{tagId}` 144 KB SSR |
| Read the 关注动态 stream | ⚠️ **cookie only** | `feed/followFeedV2` `200` but empty for guests |
| Discover moment ids / browse a 广场 | ❌ **no API** | ~560 probes; topic SSR is the only discovery vector |
| Like/unlike | 🔶 **plausible, unverifiable** | route + token tier verified via `401` |
| 投蕉 | 🔶 **plausible, unverifiable** | route + cookie tier verified via `401` |
| Comment/reply | 🔶 **plausible, unverifiable** | list side verified; `comment/add` from existing doc |
| Follow author | 🔶 **plausible, unverifiable** | `/relation/follow` in bundles |
| Repost | ❌ | no route found |
| Publish / delete 动态 | ❌ **impossible for a web plugin** | app-only, midground token |
| Vote | ❌ | app-only, midground token |
### 8.2 Smallest useful implementation
Two read tools, both anonymous, plus one optional cookie-gated tool. Do **not** ship a "square feed" tool.
```
acfun_moment(momentId: string)
GET https://www.acfun.cn/rest/pc-direct/moment/detail?momentId={id}
-> { momentId, url, author{id,name,avatar}, text, createTime, images[], tags[{tagId,name}],
counts{like,comment,banana,share}, isRepost, repostSource? , raw }
Accepts a bare id, "am:5093831", or a https://m.acfun.cn/communityCircle/moment/5093831 URL.
Errors: 140002 -> NOT_FOUND (out of range); 140005 -> DELETED.
acfun_moment_comments(momentId, page=1)
GET https://www.acfun.cn/rest/pc-direct/comment/listByFloor
?sourceId={id}&sourceType=4&page={page}&supportZtEmot=true
-> { totalCount, totalPage, page, comments[{cid, floor, userName, userId, content, postDate, isUp}] }
acfun_topic_moments(tagId, limit<=15) # optional, mark as best-effort
GET https://m.acfun.cn/communityCircle/{tagId} with DEFAULT Accept (no text/html!)
Parse li.resources-item[resource-type=3] -> moment-id ; other types -> resource-id only.
-> { tag{id,name,followers,contents}, items[{momentId?, resourceType, resourceId, user, time, text, stats}] }
First page only; params page/sort do nothing.
acfun_following_feed(cursor) # only when a session cookie is configured
GET .../feed/followFeedV2?pcursor={cursor||0}&count=20&resourceTypes=0
-> { items[], nextCursor } ; nextCursor="no_more" terminates; empty list for anonymous.
```
Mapping to the existing plugin shape in this repo: these are pure `fetch` GETs, so they slot in beside
the existing `acfun_video` / `acfun_comments` tools with no auth plumbing (`lib/provider.js` style).
`acfun_moment` is the one to ship first — it is the only capability that is both anonymous and
complete. `acfun_topic_moments` is the only way to *discover* a moment id, so it earns its place
despite being HTML scraping.
### 8.3 Risk notes
1. **HTML scraping is load-bearing for discovery.** `communityCircle/{tagId}` has no API, no pagination,
and its SSR only appears when `Accept` is not `text/html`. A markup change breaks discovery, not
reads. Isolate the parser and fail soft.
2. **Two id spaces.** Legacy `1…7525` (2019, mostly deleted) and current 7-digit ids. Never
assume contiguity, never enumerate to "browse" — validate the id and surface `140002`/`140005`
distinctly (they mean different things).
3. **`sourceType 4` vs `resourceType 10`.** Comments use 4, like/banana use 10. Mixing them silently
yields empty comment lists (`sourceType 3` → `totalCount:0`) rather than an error.
4. **Host binding.** `/rest/mobile-direct/*` only answers on `m.acfun.cn`; `/rest/pc-node/*` never
answers publicly. Hard-coding the wrong pair gives `502`s that look like outages.
5. **Guest-tolerant ≠ available.** `feed/followFeedV2` returns `200` with an empty list for a guest;
a plugin must not report "no dynamics" when it means "not logged in".
6. **Writes cannot be validated at all here.** `interact/add` success is `result:1` while every other
route uses `result:0` — easy to get backwards. If write tools are ever enabled, require a cookie and
treat any non-`0`/`1` as failure, and never retry blindly.
7. **The feature is app-first and actively mutating.** www dropped `/moment/` entirely; the live
subsystem moved to `m.acfun.cn/communityCircle` and the app. Treat every endpoint here as
*verified on 2026-10-27* and re-probe before relying on it.
---
## 9. How each claim was produced (short reproduction log)
| # | Probe | What it established |
|---|---|---|
| p1 | fetch 7 candidate URLs, `redirect:'manual'` | `/moment/`→302 `/404`; `/square`,`/community`,`/discovery`→502 error page |
| p2 | home page bigPipe frames + keyword scan | 8 pagelets, nav routes, **0** occurrences of moment/square/dynamic |
| p3 | `pagelet_navigation` HTML + `/member/*` | nav = 首页/直播/番剧/channels/排行榜; `/member/feeds`→login redirect |
| p4 | `/u/1`, `/u/2` space pages | no 动态 tab, no moment strings |
| p5 | download 70 www bundles (2.7 MB), regex `/rest/` | 50 rest paths; **only** `app.11fc9e51.js` mentions moment |
| p6–p9 | dissect `app.11fc9e51.js` | prefix fn, `moment/detail`, `followFeedV2`, `banana/throwBanana`, `interact/add`, `resourceType=10` |
| p10 | live probe of 7 read + 2 write endpoints | detail 200, feeds 200-empty, `favorite/bangumi` 401, `throwBanana` 401, `interact/add` 401 token |
| p11 | existence oracle over 533 `/rest/pc-direct/{moment,feed,...}` paths (502 vs JSON) | only `moment/detail` + `feed/hot` are JSON |
| p12 | `feed/hot` shape + param matrix | 9–14 video items, `type:2` only, params ignored |
| p13–p14 | moment-id range scan + binary search | boundary 7525/7526; `momentId=200/300` → `result:0` |
| p15 | fetch + scan ids 1..1200 | all live moments dated 2019-05/06; full field list |
| p16 | `m.acfun.cn/communityCircle*` + app host | `/communityCircle/square` 200; `/moment/detail` on app host 200 |
| p17–p20 | mobile pagelets + 37 mobile bundles | pages client-rendered; comment widget uses `resourceType 4`, `mobile-direct` paths |
| p21 | moment variety + comment `sourceType` matrix | `sourceType=4` → 2 real comments; 3/2 empty; 10 → result 21 |
| p22–p23 | publish/repost oracle on www + m + app | www 404 all; `POST /rest/app/moment/add` → 401 token; `vote` → 401 |
| p24–p26 | topic pages | `/communityCircle/488` SSR 15 items dated 2026-10-13; tag 教育 38关注/10458内容 |
| p27–p29 | paging/sort/headers | `?page`/`?sort` no-ops; `Accept: text/html` kills the SSR list; `*/*` restores it |
| p30 | moment page SSR | `m.acfun.cn/communityCircle/moment/{id}` → `Acfun 动态详情` with `.moment-text` |
| p31–p32 | kuaishouzt + final list hunt | zt only has `interact/*`; no public/user moment list anywhere |
| p33 | clean evidence capture | the JSON/comment/tag snippets pasted above |
File diff suppressed because one or more lines are too long
+55
View File
@@ -0,0 +1,55 @@
# Client panel: why a restart is required
The AcFun settings page (`client.js`) is a **static Client module**: the Web
carrier turns each package's `dsh.client` declaration into a browser bundle and
serves it under `/plugins`. Two facts discovered while installing this bundle
into a live session matter for anyone iterating on the panel.
## 1. The bundle is composed from the Loader, not from the filesystem
`dsh-client-modules` scans the *live Host Loader entries* for packages declaring
`dsh.client` and composes `window.__DSH_BOOT__` from them. A package that is
installed while the page is already open does not appear in that graph until the
composition is re-run — which happens on Host restart, not on a page refresh.
Evidence gathered from the running Host (see `docs/acfun-panel-diagnosis.md`
for the raw output): `ctx.clientModules.graph()` listed the ~75 shipped client
modules after installing this bundle, and `ctx.clientModules.clientPath(
'dsh-plugin-acfun')` returned `null`. The Host half was fully active at that
moment (all twelve tools, `/acfun-panel/state`, QR issuance), so this is a
client-composition fact, not a plugin bug.
## 2. Package metadata is cached per Loader specifier until restart
The same package declares `dsh.client`, adds `client.js`, and exports `./client`.
Those three edits are read when the package is first scanned. In this session the
first install happened **before** `client.js` existed, so the cached metadata for
`dsh-plugin-acfun` had no client declaration; later reinstalls kept the cached
record.
## What this means in practice
- Installing this bundle for the first time in a fresh Harness is enough: the
next boot composes the panel automatically.
- Upgrading a bundle that already declared `dsh.client` only needs a page reload
after the Host restart.
- When iterating on `client.js`, `plugin_manager action=remove_bundle` +
`install_bundle` reloads the Host half, but the panel needs
`restart the Harness → reload the page` to appear.
## Verifying without a browser
The Host exposes the composition state directly:
```
GET http://127.0.0.1:<port>/acfun-panel/state # client.path
GET http://127.0.0.1:<port>/acfun-panel/diagnose # client.entries
```
`path` is non-null only when the carrier registered the bundle. `/diagnose` lists
every composed client module id, which is the fastest way to tell "the panel is
not composed yet" apart from "the panel crashed while rendering".
Slot registration itself is verifiable without a screenshot:
`cordis_inspect_query` → `Slots.listSubTree` with `root: "settings.section"`
shows the `acfun` occupant once the browser has loaded the module.