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:
@@ -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).
|
||||
@@ -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
@@ -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.
|
||||
Reference in New Issue
Block a user