diff --git a/README.md b/README.md
index 713de9e..986fcfe 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,10 @@
# dsh-plugin-acfun
DeepSeek Harness plugin for **AcFun (A站, acfun.cn)**: search videos, read video
-metadata, comment threads, danmaku (弹幕), the day/week ranking and playable
-stream URLs, and — only when you opt in — interact with your own account (like,
-tip bananas, favorite, follow, post comments).
+metadata, comment threads, danmaku (弹幕), the day/week ranking, playable stream
+URLs and 动态 (moments); interact with your own account (like, tip bananas,
+favorite, follow, comment — including moments); and sign in by **scanning a QR
+code in the Harness Web UI** (Settings → AcFun).
This is the AcFun counterpart of the community Bilibili tool plugins. Reads are
anonymous and work out of the box; every mutating call is gated behind
@@ -20,11 +21,15 @@ never silently gives the model write access to an account.
| `acfun_danmaku` | read | One batch of a video's 弹幕, ordered by in-video position, with cursor paging. |
| `acfun_rank` | read | Day or week ranking, all channels or one `channelId`. |
| `acfun_playurl` | read | m3u8 quality ladder (1080P+ / 1080P / 720P / 540P / 360P). |
-| `acfun_interact` | write | `like`, `unlike`, `banana`, `favorite`, `unfavorite`, `follow`, `unfollow`. |
-| `acfun_post_comment` | write | Post a top-level comment or a reply. |
+| `acfun_moment` | read | One 动态 by id: text, topics, images, author, counts, canonical share URL. |
+| `acfun_moment_comments` | read | One page of a 动态's comment thread (moments have their own comment enum). |
+| `acfun_topic` | read | 社区圈 topic page (best effort, 15 newest items) — the only public way to discover current moment ids. |
+| `acfun_interact` | write | Video: `like`, `unlike`, `banana`, `favorite`, `unfavorite`, `follow`, `unfollow`. Moment: `moment_like`, `moment_unlike`, `moment_banana`. |
+| `acfun_post_comment` | write | Post a top-level comment or reply on a video (`acId`) or a 动态 (`momentId`). |
+| `acfun_account` | read | Login state, credential source, whether interactions are enabled, and the next sign-in step. |
A model-facing skill (`skills/plugin-tool-acfun/SKILL.md`) teaches the tool
-sequence and the write-safety rules.
+sequence, the write-safety rules, and the 动态 limitations.
## Install
@@ -34,12 +39,42 @@ Install the workspace directory as a profile bundle:
plugin_manager action=install_bundle target=C:\path\to\dsh-acfun
```
-The bundle inserts one Loader row (`id: acfun`) with the config below. A local
-directory is linked, so edits to the plugin need a Harness restart to load a
-fresh module generation.
+The bundle inserts two Loader rows: `acfun` (the tools, panel API and QR login)
+and `acfun-skill-filesystem` (which exposes this package's `skills/` directory
+to the agent). A local directory is linked, so edits to the plugin need a
+Harness restart to load a fresh module generation.
To remove it, use `plugin_manager action=remove_bundle target=dsh-plugin-acfun`.
+## QR login and the Web UI panel
+
+The bundle ships a Client half (`client.js`) that adds a **Settings → AcFun**
+page. It shows the current login state, the credential source and whether
+interaction tools are enabled, renders the QR code, and polls until the phone
+confirms.
+
+Flow (all Host-side, `lib/qr-flow.js`):
+
+1. `GET https://scan.acfun.cn/rest/pc-direct/qr/start?type=WEB_LOGIN` returns a
+ base64 PNG plus `qrLoginToken`, `qrLoginSignature` and `expireTime` (120 s).
+2. `scanResult` is a single long-poll held by AcFun until the phone scans or the
+ code expires. On scan it returns a **rotated signature**.
+3. `acceptResult` with the rotated signature issues the session cookies; the
+ plugin captures `Set-Cookie`, persists the cookie through the Host credential
+ service (`acfun-session` grant record), and points the provider at it.
+
+The panel talks to the Host over `/acfun-panel/*` (same-origin JSON, cross-origin
+requests rejected). Scanning a QR code is the only step a plugin cannot verify
+by itself, so the confirm branch is exercised only by a real phone scan.
+
+**Restart requirement:** a Client half added to a package that the Host already
+scanned needs one Harness restart, then a page reload, before
+**Settings → AcFun** appears. The graph is composed from the live Loader entry
+set at boot, so a bundle installed into a running page is not in it yet. Detect
+it with `GET /acfun-panel/state` (`client.path` must be non-null) or
+`GET /acfun-panel/diagnose`; details and evidence are in
+[`docs/client-panel.md`](docs/client-panel.md).
+
## Configuration
The row config lives in `cordis.patch.yml`; every key is optional.
@@ -52,13 +87,16 @@ The row config lives in `cordis.patch.yml`; every key is optional.
| `requestTimeoutMs` | `30000` | Per-HTTP-request timeout. |
| `timeoutMs` | `45000` | Cooperative per-tool-call budget (write calls use at least 60 s). |
+A cookie from the panel's QR login is stored as a credential record and wins
+over `cookie`/`ACFUN_COOKIE`; signing out in the panel falls back to the config.
+
Example with the environment variable:
```powershell
$env:ACFUN_COOKIE = 'auth_key=...; acPasstoken=...; _did=...'
```
-### Getting the cookie
+### Getting the cookie without the panel
1. Sign in to in a desktop browser.
2. Open DevTools → Network, reload any acfun.cn page, and copy the complete
@@ -85,15 +123,21 @@ plugin never logs it.
Verified live on 2026-10-27 against acfun.cn (anonymous):
- All six read tools — `acfun_search` (including `sort: "views"`), `acfun_video`,
- `acfun_comments`, `acfun_danmaku`, `acfun_rank`, `acfun_playurl` — return real
- API data and pass output-schema validation plus rendering
+ `acfun_comments`, `acfun_danmaku`, `acfun_rank`, `acfun_playurl`,
+ `acfun_moment`, `acfun_moment_comments`, `acfun_topic` — return real API data
+ and pass output-schema validation plus rendering
(`node scripts/live-check.mjs`).
-- All eight tools register through a mock Cordis context and the write guard
- fails closed (`node scripts/self-check.mjs`).
+- QR login issuance and the waiting/abort path were verified live against
+ `scan.acfun.cn` (a real ticket and PNG are returned; an unscanned ticket holds
+ the long-poll until the 120 s expiry, then reports "expired").
+- All twelve tools register through a mock Cordis context, the panel routes are
+ served on a local HTTP server, and the write guard fails closed
+ (`node scripts/self-check.mjs`).
- Write routes exist and reject anonymous requests with `{"result":-401}`. The
- authenticated happy path was **not** exercised here because no AcFun
- account/cookie was available. Treat first use as a live test: check the result
- in the AcFun UI instead of retrying blindly.
+ authenticated happy path — including the final QR accept step and moment
+ writes — was **not** exercised here because no AcFun account was available.
+ Treat first use as a live test: check the result in the AcFun UI instead of
+ retrying blindly.
## Known limitations
@@ -102,6 +146,15 @@ Verified live on 2026-10-27 against acfun.cn (anonymous):
only ordering control AcFun honours.
- Bangumi (番剧) and article pages are not supported — `acfun_video` reports
`UNSUPPORTED_RESOURCE` instead of guessing.
+- **No 广场 feed API exists.** After ~560 path probes no public square/recommend
+ moment list was found on any host; moment ids can only be discovered through
+ `acfun_topic` (server-rendered, 15 newest items, no pagination) or a share
+ link. The only moment *lists* are login-scoped following feeds.
+- Publishing, deleting, reposting and voting on a 动态 are app-only behind the
+ signed midground token; the web plugin cannot do them.
+- Moment like/banana/comment contracts are derived from the site's own store and
+ verified only up to their auth error (`401`), so treat moment writes as
+ experimental.
- Danmaku paging beyond the first batch depends on AcFun's opaque `pcursor`;
`acfun_danmaku` passes it through as `cursor`/`nextCursor`.
- Play URLs are time-limited signed m3u8 links; re-run `acfun_playurl` when one
@@ -112,17 +165,19 @@ Verified live on 2026-10-27 against acfun.cn (anonymous):
## Development
```
-node scripts/self-check.mjs # offline: exports, tool schemas, write guard
-node scripts/live-check.mjs # online: real reads + schema validation + render
+node scripts/self-check.mjs # offline: exports, tool schemas, QR state machine, panel HTTP
+node scripts/live-check.mjs # online: real reads, QR issuance, schema validation + render
```
-No build step, no runtime dependencies. Everything under `lib/` is plain ESM
-that imports only Node built-ins/globals. The bundle intentionally exports no
-`Config` schema: a workspace-linked bundle cannot resolve `@deepseek-ai/*`
-packages, so `lib/config.js` validates the row config and falls back to defaults
-with warnings instead of using schemastery.
+No build step, no runtime dependencies. Everything under `lib/` and `client.js`
+is plain ESM/JavaScript that imports only Node built-ins/globals. The bundle
+intentionally exports no `Config` schema: a workspace-linked bundle cannot
+resolve `@deepseek-ai/*` packages, so `lib/config.js` validates the row config
+and falls back to defaults with warnings instead of using schemastery.
-See `docs/acfun-api.md` for the endpoint notes this plugin is based on.
+Endpoint notes: `docs/acfun-api.md` (videos/comments/danmaku/rank),
+`docs/acfun-login.md` (QR login), `docs/acfun-moment.md` (广场/动态 research and
+verdict).
## License
diff --git a/README.zh.md b/README.zh.md
index 2024f3b..d261d45 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -1,28 +1,29 @@
# dsh-plugin-acfun
给 **DeepSeek Harness** 用的 **AcFun(A站)** 插件:搜索视频、读取视频详情、
-评论、弹幕、日/周排行榜和可播放的 m3u8 地址;只有在你主动开启后,才能用自己
-的账号进行互动(点赞、投蕉、收藏、关注、发评论)。
-
-这是社区 B 站工具插件在 A 站上的对应实现。读取类工具默认匿名可用;所有写操作
-必须同时满足 `allowWriteActions: true` **和** AcFun 登录 Cookie,因此装好插件
-并不会悄悄把账号的写权限交给模型。
+评论、弹幕、日/周排行榜、播放地址和**动态(moment)**;可在
+**“设置 → AcFun”里扫码登录**;开启写操作后可用自己的账号点赞、投蕉、收藏、
+关注、评论(含动态)。
## 工具
| 工具 | 类型 | 作用 |
| --- | --- | --- |
-| `acfun_search` | 读 | 关键词搜索视频;`sort: "views"` 按播放量排序(A 站只返回固定第一页,最多 30 条)。 |
+| `acfun_search` | 读 | 关键词搜索视频;`sort: "views"` 按播放量(A 站只返回固定第一页 30 条)。 |
| `acfun_video` | 读 | 单个视频完整信息:UP 主、分区、标签、播放/点赞/收藏/投蕉/评论/弹幕数、发布时间、封面。 |
| `acfun_comments` | 读 | 视频评论(按楼层序)的一页。 |
| `acfun_danmaku` | 读 | 视频弹幕一批,按视频内时间排序,支持 `cursor` 翻页。 |
| `acfun_rank` | 读 | 日榜 / 周榜,`channelId: 0` 为全站。 |
| `acfun_playurl` | 读 | m3u8 清晰度列表(1080P+ / 1080P / 720P / 540P / 360P)。 |
-| `acfun_interact` | 写 | `like`、`unlike`、`banana`(投蕉)、`favorite`、`unfavorite`、`follow`、`unfollow`。 |
-| `acfun_post_comment` | 写 | 发表评论或回复某条评论。 |
+| `acfun_moment` | 读 | 按 id 读取一条动态:正文、话题、图片、作者、计数、官方分享链接。 |
+| `acfun_moment_comments` | 读 | 动态评论区一页(动态使用独立的评论枚举)。 |
+| `acfun_topic` | 读 | 社区圈话题页(尽力解析,最多 15 条)——目前唯一可公开发现动态 id 的入口。 |
+| `acfun_interact` | 写 | 视频:`like`、`unlike`、`banana`、`favorite`、`unfavorite`、`follow`、`unfollow`;动态:`moment_like`、`moment_unlike`、`moment_banana`。 |
+| `acfun_post_comment` | 写 | 给视频(`acId`)或动态(`momentId`)发表评论或回复。 |
+| `acfun_account` | 读 | 登录状态、凭据来源、写操作是否开启、下一步操作提示。 |
-插件还附带一个面向模型的技能文件 `skills/plugin-tool-acfun/SKILL.md`,说明工具
-调用顺序和写操作的安全规则。
+插件还附带面向模型的技能文件 `skills/plugin-tool-acfun/SKILL.md`,说明调用顺序、
+写操作安全规则和动态相关限制。
## 安装
@@ -32,11 +33,35 @@
plugin_manager action=install_bundle target=C:\path\to\dsh-acfun
```
-插件会向 Loader 插入一行(`id: acfun`)。本地目录是链接安装,修改插件代码后
-需要重启 Harness 才能加载新的模块实例。
+插件会插入两行:`acfun`(工具、面板接口、扫码登录)与
+`acfun-skill-filesystem`(把本包的 `skills/` 暴露给 Agent)。本地目录是链接
+安装,修改插件代码后需要重启 Harness 才能加载新的模块实例。
卸载:`plugin_manager action=remove_bundle target=dsh-plugin-acfun`。
+## 扫码登录与 Web UI 面板
+
+插件自带 Client 半(`client.js`),在 **设置 → AcFun** 增加一页:显示登录状态、
+凭据来源、写操作是否开启,渲染二维码并轮询直到手机确认。
+
+流程全部在 Host 侧(`lib/qr-flow.js`):
+
+1. `GET https://scan.acfun.cn/rest/pc-direct/qr/start?type=WEB_LOGIN` 返回 base64
+ PNG 以及 `qrLoginToken`、`qrLoginSignature`、`expireTime`(120 秒)。
+2. `scanResult` 是 A 站保持的一次长轮询,直到手机扫码或二维码过期;扫码成功时
+ 返回**轮换后的新签名**。
+3. 用新签名调用 `acceptResult`,A 站通过 `Set-Cookie` 下发会话;插件捕获 Cookie,
+ 经 Host 凭据服务持久化为 `acfun-session` 记录,并把 provider 切到该会话。
+
+面板通过同源 `/acfun-panel/*` JSON 接口访问 Host,跨源请求会被拒绝。扫码确认这
+一步插件无法自行验证,只有真实手机扫码才会走到确认分支。
+
+**需要重启一次:** 如果某个包在 Host 已经扫描过之后才新增 Client 半,则需要重启
+一次 Harness,再刷新页面,**设置 → AcFun** 才会出现(Boot 图是在启动时按当时的
+Loader 条目组合的)。可用 `GET /acfun-panel/state` 的 `client.path`(必须非空)或
+`GET /acfun-panel/diagnose` 判断,细节与证据见
+[`docs/client-panel.md`](docs/client-panel.md)。
+
## 配置
配置位于 `cordis.patch.yml` 的 `acfun` 行,所有键都可省略。
@@ -49,66 +74,73 @@ plugin_manager action=install_bundle target=C:\path\to\dsh-acfun
| `requestTimeoutMs` | `30000` | 单次 HTTP 请求超时。 |
| `timeoutMs` | `45000` | 单次工具调用的协作式预算(写操作至少 60 秒)。 |
-使用环境变量的例子:
+面板扫码得到的 Cookie 以凭据记录保存,优先于 `cookie`/`ACFUN_COOKIE`;在面板退出
+登录后回退到配置值。
-```powershell
-$env:ACFUN_COOKIE = 'auth_key=...; acPasstoken=...; _did=...'
-```
-
-### 获取 Cookie
+### 不用面板时手动获取 Cookie
1. 在桌面浏览器登录 。
2. 打开开发者工具 → Network,刷新任意 acfun.cn 页面,复制文档请求的完整
- `Cookie` 请求头(包含 `auth_key`、`acPasstoken`、`_did` 等会话值)。
-3. 把整行粘贴到 `cookie` 或 `ACFUN_COOKIE`。
+ `Cookie` 请求头(包含 `auth_key`、`acPasstoken`、`_did` 等)。
+3. 粘贴到 `cookie` 或 `ACFUN_COOKIE`。
-这串 Cookie 等同于账号密码,插件不会把它写进日志。
+这串 Cookie 等同于账号密码,插件不会写进日志。
## 安全设计
- 读取类工具不会修改任何内容,也不需要 Cookie。
-- 写操作必须同时具备 `allowWriteActions: true` 与 Cookie;只满足其一时工具会
- 返回 `WRITE_DISABLED` / `LOGIN_REQUIRED`,并且不会发出任何写请求。
+- 写操作必须同时具备 `allowWriteActions: true` 与登录状态;只满足其一时返回
+ `WRITE_DISABLED` / `LOGIN_REQUIRED`,不会发出写请求。
- 写工具声明为非并发安全,Harness 不会并行执行两个账号操作。
-- AcFun 返回人机验证/身份验证要求(`result` 400001–410999)时,插件以
- `CAPTCHA_REQUIRED` 报告,绝不尝试绕过验证码。
+- AcFun 返回人机验证要求(`result` 400001–410999)时报告 `CAPTCHA_REQUIRED`,
+ 绝不尝试绕过。
## 验证情况
2026-10-27 对 acfun.cn 实测(匿名):
-- 六个读取工具(`acfun_search` 含 `sort: "views"`、`acfun_video`、
- `acfun_comments`、`acfun_danmaku`、`acfun_rank`、`acfun_playurl`)都返回真实
- 数据,并通过输出 schema 校验与渲染(`node scripts/live-check.mjs`)。
-- 八个工具通过模拟 Cordis 上下文完成注册,写操作守卫正确失败关闭
+- 九个读取工具(`acfun_search` 含 `sort: "views"`、`acfun_video`、
+ `acfun_comments`、`acfun_danmaku`、`acfun_rank`、`acfun_playurl`、
+ `acfun_moment`、`acfun_moment_comments`、`acfun_topic`)返回真实数据并通过
+ 输出 schema 校验与渲染(`node scripts/live-check.mjs`)。
+- 扫码登录的二维码下发、等待与中止路径已实测(真实 ticket + PNG;未扫码时保持长
+ 轮询到 120 秒过期,然后报告 expired)。
+- 十二个工具通过模拟 Cordis 上下文注册,面板接口在本地 HTTP 服务器上验证
+ (含跨源拒绝、登出),写操作守卫正确失败关闭
(`node scripts/self-check.mjs`)。
-- 写接口路由存在,匿名调用返回 `{"result":-401}`;但本次没有 AcFun 账号
- Cookie,**未**验证登录后的成功路径。首次使用时请以 A 站页面上的实际结果为准,
- 不要在结果不明时重复调用。
+- 写接口路由存在,匿名调用返回 `{"result":-401}`;但**未**验证登录后的成功路径
+ (包括扫码最后一步和动态写操作),因为本次没有可用账号。首次使用时请以 A 站
+ 页面上的实际结果为准,不要在结果不明时重复调用。
## 已知限制
-- A 站的 pc-direct 搜索接口忽略 `page`/`pageSize`,永远只返回第一页 30 条,
- 因此 `acfun_search` 无法翻页;排序只有 `sort` 一个有效参数。
-- 不支持番剧(bangumi)与文章页面——`acfun_video` 会返回 `UNSUPPORTED_RESOURCE`,
- 不会瞎猜。
-- 弹幕翻页依赖 A 站不透明的 `pcursor`,插件通过 `cursor`/`nextCursor` 透传。
-- 播放地址是带签名的临时 m3u8 链接,过期后重新调用 `acfun_playurl`;下载/合并
- 流媒体不在本插件范围内。
-- 插件只调用 acfun.cn 的公开 Web 接口,属于非官方实现,A 站改动接口后可能失效。
+- A 站的 pc-direct 搜索接口忽略 `page`/`pageSize`,永远只返回第一页 30 条;只有
+ `sort` 有效。
+- 不支持番剧(bangumi)与文章页面——`acfun_video` 会返回
+ `UNSUPPORTED_RESOURCE`。
+- **没有公开的“广场”列表接口。** 约 560 条路径探测后,各站点都没有公开的
+ 广场/推荐动态列表;动态 id 只能通过 `acfun_topic`(SSR 页面,最多 15 条,无分页)
+ 或分享链接获得,动态列表类接口都需要登录。
+- 发布、删除、转发、投票动态只能在 App 内完成(需要签名 token),Web 插件做不了。
+- 动态的点赞/投蕉/评论契约来自站点自身代码,只验证到 `401` 未登录,属于实验性
+ 功能。
+- 弹幕翻页依赖 A 站不透明的 `pcursor`,通过 `cursor`/`nextCursor` 透传。
+- 播放地址是带签名的临时 m3u8,过期需重新调用;下载/合并流媒体不在范围内。
+- 插件只调用 acfun.cn 的公开 Web 接口,属于非官方实现,A 站改版后可能失效。
## 开发
```
-node scripts/self-check.mjs # 离线:导出、工具 schema、写操作守卫
-node scripts/live-check.mjs # 在线:真实读取 + schema 校验 + 渲染
+node scripts/self-check.mjs # 离线:导出、工具 schema、扫码状态机、面板 HTTP
+node scripts/live-check.mjs # 在线:真实读取、二维码下发、schema 校验与渲染
```
-无构建步骤、无运行时依赖。`lib/` 下全部是只依赖 Node 内置能力的纯 ESM。插件
-故意不导出 `Config` schema:工作区链接安装的 bundle 无法解析 `@deepseek-ai/*`
-包,所以由 `lib/config.js` 校验配置并带告警地回退到默认值。
+无构建步骤、无运行时依赖。`lib/` 与 `client.js` 都是只依赖 Node/浏览器内置能力的
+纯 JS。插件故意不导出 `Config` schema:工作区链接安装的 bundle 无法解析
+`@deepseek-ai/*` 包,所以由 `lib/config.js` 校验配置并带告警地回退默认值。
-接口逆向笔记见 [docs/acfun-api.md](docs/acfun-api.md)。
+接口笔记:`docs/acfun-api.md`(视频/评论/弹幕/排行)、`docs/acfun-login.md`
+(扫码登录)、`docs/acfun-moment.md`(广场/动态调研与结论)。
## 许可证
diff --git a/client.js b/client.js
new file mode 100644
index 0000000..3c16251
--- /dev/null
+++ b/client.js
@@ -0,0 +1,456 @@
+/**
+ * AcFun settings panel (Browser half).
+ *
+ * Static Client plugin: this bundle is served under `/plugins` from the package
+ * root and registers a `settings.section` page that talks to the Host half over
+ * the same-origin `/acfun-panel` routes (`lib/panel.js`).
+ *
+ * Plain JavaScript, no JSX, no imports. React comes from the browser module
+ * table; the only other symbols are the closure parameters provided by the
+ * module loader.
+ */
+window.__ModuleLoader__.load({
+ id: 'dsh-plugin-acfun',
+ factory(require) {
+ const React = require('react')
+ const h = React.createElement
+
+ const API = '/acfun-panel'
+
+ const TEXT = {
+ zh: {
+ sectionTitle: 'AcFun',
+ sectionHint: '登录 A 站账号后,互动工具(点赞 / 投蕉 / 收藏 / 关注 / 评论)才能使用。',
+ status: '登录状态',
+ loggedIn: '已登录(Cookie 已配置)',
+ loggedOut: '未登录(匿名读取)',
+ source: '凭据来源',
+ sourceConfig: '插件配置',
+ sourceEnv: '环境变量',
+ sourceQr: '扫码登录',
+ sourceNone: '未配置',
+ writes: '互动工具',
+ writesOn: '已开启',
+ writesOff: '已关闭(allowWriteActions: false)',
+ loginSection: '扫码登录',
+ loginHint: '用 AcFun App 扫描二维码。二维码由 A 站生成,只在服务端轮询状态,不经过第三方。',
+ startLogin: '生成二维码',
+ refreshQr: '刷新二维码',
+ cancelLogin: '取消',
+ logout: '退出登录',
+ waiting: '等待扫码…',
+ scanned: '已扫码,请在手机上确认',
+ confirmed: '登录成功,Cookie 已保存',
+ expired: '二维码已过期,请刷新',
+ error: '登录失败',
+ idle: '尚未开始',
+ loading: '加载中…',
+ retry: '重试',
+ tools: '可用工具',
+ qrAlt: 'AcFun 登录二维码',
+ unavailable: '当前 Host 未启用扫码登录接口。',
+ cookieHint: '也可以把浏览器 Cookie 填入插件配置的 cookie / ACFUN_COOKIE。',
+ },
+ en: {
+ sectionTitle: 'AcFun',
+ sectionHint: 'Sign in to AcFun to enable interaction tools (like / banana / favorite / follow / comment).',
+ status: 'Account',
+ loggedIn: 'Signed in (cookie configured)',
+ loggedOut: 'Anonymous (reads only)',
+ source: 'Credential source',
+ sourceConfig: 'Plugin config',
+ sourceEnv: 'Environment variable',
+ sourceQr: 'QR login',
+ sourceNone: 'Not configured',
+ writes: 'Interaction tools',
+ writesOn: 'Enabled',
+ writesOff: 'Disabled (allowWriteActions: false)',
+ loginSection: 'QR login',
+ loginHint: 'Scan the code with the AcFun app. The code is issued by AcFun; only the login state is polled by the Host.',
+ startLogin: 'Show QR code',
+ refreshQr: 'Refresh QR code',
+ cancelLogin: 'Cancel',
+ logout: 'Sign out',
+ waiting: 'Waiting for scan…',
+ scanned: 'Scanned — confirm on your phone',
+ confirmed: 'Signed in; cookie saved',
+ expired: 'QR code expired — refresh it',
+ error: 'Login failed',
+ idle: 'Not started',
+ loading: 'Loading…',
+ retry: 'Retry',
+ tools: 'Available tools',
+ qrAlt: 'AcFun login QR code',
+ unavailable: 'QR login is not available in this Host.',
+ cookieHint: 'You can also paste browser cookies into the plugin config cookie / ACFUN_COOKIE.',
+ },
+ }
+
+ /** Pick a dictionary from the live locale snapshot. */
+ function pickText(ctx) {
+ let active = 'en'
+ try {
+ active = ctx.locale.getLocale().active || 'en'
+ } catch {
+ active = 'en'
+ }
+ return String(active).toLowerCase().startsWith('zh') ? TEXT.zh : TEXT.en
+ }
+
+ /** Subscribe one component to locale changes. */
+ function useLocale(ctx) {
+ const [dict, setDict] = React.useState(() => pickText(ctx))
+ React.useEffect(() => {
+ let dispose
+ try {
+ dispose = ctx.locale.subscribe(() => setDict(pickText(ctx)))
+ } catch {
+ dispose = undefined
+ }
+ return () => {
+ if (typeof dispose === 'function') dispose()
+ }
+ }, [])
+ return dict
+ }
+
+ /** Call one panel route. */
+ async function call(path, options) {
+ const response = await fetch(API + path, {
+ method: options && options.method ? options.method : 'GET',
+ headers: { Accept: 'application/json' },
+ body: options && options.body !== undefined ? JSON.stringify(options.body) : undefined,
+ })
+ const text = await response.text()
+ let payload
+ try {
+ payload = text ? JSON.parse(text) : {}
+ } catch {
+ throw new Error('AcFun panel returned malformed JSON (HTTP ' + response.status + ').')
+ }
+ if (!response.ok || payload.ok === false) {
+ const message = payload && payload.error && payload.error.message ? payload.error.message : 'HTTP ' + response.status
+ throw new Error(message)
+ }
+ return payload
+ }
+
+ const COLORS = {
+ bgLayer1: 'var(--dsw-alias-bg-layer-1)',
+ bgLayer2: 'var(--dsw-alias-bg-layer-2)',
+ border: 'var(--dsw-alias-border-l1)',
+ borderStrong: 'var(--dsw-alias-border-l2)',
+ primary: 'var(--dsw-alias-label-primary)',
+ secondary: 'var(--dsw-alias-label-secondary)',
+ accent: 'var(--dsw-alias-brand-primary)',
+ success: 'var(--dsw-alias-state-success-primary)',
+ error: 'var(--dsw-alias-state-error-primary)',
+ warn: 'var(--dsw-alias-state-warn-primary)',
+ idle: 'var(--dsw-alias-state-idle-primary)',
+ }
+
+ const styles = {
+ page: { display: 'flex', flexDirection: 'column', gap: '18px', color: COLORS.primary, maxWidth: '760px' },
+ header: { display: 'flex', flexDirection: 'column', gap: '6px' },
+ title: { margin: 0, fontSize: '18px', fontWeight: 600 },
+ hint: { margin: 0, color: COLORS.secondary, fontSize: '13px', lineHeight: '20px' },
+ card: {
+ border: '1px solid ' + COLORS.border,
+ background: COLORS.bgLayer1,
+ borderRadius: '12px',
+ padding: '16px 18px',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '10px',
+ },
+ cardTitle: { fontSize: '14px', fontWeight: 600 },
+ row: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '16px', fontSize: '13px' },
+ label: { color: COLORS.secondary },
+ value: { color: COLORS.primary },
+ badge: { borderRadius: '999px', padding: '2px 10px', fontSize: '12px', border: '1px solid currentColor' },
+ buttons: { display: 'flex', gap: '10px', flexWrap: 'wrap' },
+ button: {
+ appearance: 'none',
+ border: '1px solid transparent',
+ borderRadius: '8px',
+ padding: '7px 14px',
+ fontSize: '13px',
+ fontWeight: 500,
+ cursor: 'pointer',
+ background: COLORS.accent,
+ color: '#fff',
+ },
+ buttonGhost: {
+ appearance: 'none',
+ borderRadius: '8px',
+ padding: '7px 14px',
+ fontSize: '13px',
+ cursor: 'pointer',
+ background: 'transparent',
+ color: COLORS.primary,
+ border: '1px solid ' + COLORS.borderStrong,
+ },
+ qrBox: {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ gap: '10px',
+ padding: '14px',
+ background: '#fff',
+ borderRadius: '12px',
+ alignSelf: 'flex-start',
+ },
+ qrImage: { width: '220px', height: '220px', display: 'block' },
+ qrFallback: {
+ width: '220px',
+ minHeight: '120px',
+ wordBreak: 'break-all',
+ color: '#333',
+ fontSize: '12px',
+ fontFamily: 'monospace',
+ },
+ stateLine: { display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px' },
+ tools: { display: 'flex', flexDirection: 'column', gap: '6px', margin: 0, padding: 0, listStyle: 'none' },
+ toolName: { fontFamily: 'monospace', fontSize: '12px', color: COLORS.accent },
+ toolSummary: { color: COLORS.secondary, fontSize: '12px', lineHeight: '18px' },
+ error: { color: COLORS.error, fontSize: '13px', margin: 0 },
+ footer: { color: COLORS.secondary, fontSize: '12px', lineHeight: '18px', margin: 0 },
+ }
+
+ const STATE_LABEL = {
+ idle: 'idle',
+ waiting: 'waiting',
+ scanned: 'scanned',
+ confirmed: 'confirmed',
+ expired: 'expired',
+ error: 'error',
+ }
+
+ /** Small label + value row. */
+ function Row(props) {
+ return h('div', { style: styles.row }, h('span', { style: styles.label }, props.label), props.children)
+ }
+
+ /** One badge whose color follows a state. */
+ function Badge(props) {
+ const color = props.tone === 'ok' ? COLORS.success : props.tone === 'warn' ? COLORS.warn : props.tone === 'error' ? COLORS.error : COLORS.idle
+ return h('span', { style: { ...styles.badge, color, whiteSpace: 'nowrap' } }, props.children)
+ }
+
+ /** The settings page component. */
+ function AcfunPanel(props) {
+ const ctx = props.ctx
+ const t = useLocale(ctx)
+ const [state, setState] = React.useState({ status: 'loading', data: null, error: '' })
+ const [login, setLogin] = React.useState(null)
+ const [busy, setBusy] = React.useState(false)
+ const [actionError, setActionError] = React.useState('')
+
+ const loadState = React.useCallback(async () => {
+ try {
+ const payload = await call('/state')
+ setState({ status: 'ready', data: payload, error: '' })
+ } catch (error) {
+ setState({ status: 'error', data: null, error: String((error && error.message) || error) })
+ }
+ }, [])
+
+ React.useEffect(() => {
+ loadState()
+ }, [])
+
+ // Poll while a QR ticket is waiting for the phone.
+ React.useEffect(() => {
+ if (!login || !login.ticket) return undefined
+ if (login.state !== 'waiting' && login.state !== 'scanned') return undefined
+ const timer = window.setInterval(async () => {
+ try {
+ const payload = await call('/login/poll?ticket=' + encodeURIComponent(login.ticket))
+ setLogin(payload.login || null)
+ if (payload.login && (payload.login.state === 'confirmed' || payload.login.state === 'expired' || payload.login.state === 'error')) {
+ window.clearInterval(timer)
+ if (payload.login.state === 'confirmed') await loadState()
+ }
+ } catch (error) {
+ setActionError(String((error && error.message) || error))
+ window.clearInterval(timer)
+ }
+ }, 2000)
+ return () => window.clearInterval(timer)
+ }, [login && login.ticket, login && login.state])
+
+ const startLogin = React.useCallback(async () => {
+ setBusy(true)
+ setActionError('')
+ try {
+ const payload = await call('/login/start', { method: 'POST', body: {} })
+ setLogin(payload.login || null)
+ } catch (error) {
+ setActionError(String((error && error.message) || error))
+ } finally {
+ setBusy(false)
+ }
+ }, [])
+
+ const cancelLogin = React.useCallback(async () => {
+ setBusy(true)
+ setActionError('')
+ try {
+ const payload = await call('/login/cancel', { method: 'POST', body: {} })
+ setLogin(payload.login || null)
+ } catch (error) {
+ setActionError(String((error && error.message) || error))
+ } finally {
+ setBusy(false)
+ }
+ }, [])
+
+ const logout = React.useCallback(async () => {
+ setBusy(true)
+ setActionError('')
+ try {
+ await call('/logout', { method: 'POST', body: {} })
+ setLogin(null)
+ await loadState()
+ } catch (error) {
+ setActionError(String((error && error.message) || error))
+ } finally {
+ setBusy(false)
+ }
+ }, [loadState])
+
+ const data = state.data
+ const auth = data ? data.auth : null
+ const sourceLabel = auth
+ ? auth.source === 'config'
+ ? t.sourceConfig
+ : auth.source === 'env'
+ ? t.sourceEnv
+ : auth.source === 'qr'
+ ? t.sourceQr
+ : t.sourceNone
+ : ''
+
+ const loginState = login && login.state ? login.state : 'idle'
+ const stateText =
+ loginState === 'waiting'
+ ? t.waiting
+ : loginState === 'scanned'
+ ? t.scanned
+ : loginState === 'confirmed'
+ ? t.confirmed
+ : loginState === 'expired'
+ ? t.expired
+ : loginState === 'error'
+ ? t.error
+ : t.idle
+
+ const qr = login && login.qr ? login.qr : null
+
+ return h(
+ 'div',
+ { style: styles.page },
+ h(
+ 'div',
+ { style: styles.header },
+ h('h2', { style: styles.title }, t.sectionTitle),
+ h('p', { style: styles.hint }, t.sectionHint),
+ ),
+ state.status === 'loading' ? h('p', { style: styles.hint }, t.loading) : null,
+ state.status === 'error'
+ ? h(
+ 'div',
+ { style: styles.card },
+ h('p', { style: styles.error }, state.error),
+ h('div', { style: styles.buttons }, h('button', { style: styles.buttonGhost, onClick: loadState }, t.retry)),
+ )
+ : null,
+ auth
+ ? h(
+ 'div',
+ { style: styles.card },
+ h(
+ Row,
+ { label: t.status },
+ h(Badge, { tone: auth.configured ? 'ok' : 'idle' }, auth.configured ? t.loggedIn : t.loggedOut),
+ ),
+ h(Row, { label: t.source }, h('span', { style: styles.value }, sourceLabel)),
+ h(
+ Row,
+ { label: t.writes },
+ h(Badge, { tone: auth.writesEnabled ? 'ok' : 'warn' }, auth.writesEnabled ? t.writesOn : t.writesOff),
+ ),
+ h('p', { style: styles.footer }, t.cookieHint),
+ )
+ : null,
+ h(
+ 'div',
+ { style: styles.card },
+ h('div', { style: styles.cardTitle }, t.loginSection),
+ h('p', { style: styles.hint }, t.loginHint),
+ login && login.available === false ? h('p', { style: styles.hint }, t.unavailable) : null,
+ qr
+ ? h(
+ 'div',
+ { style: styles.qrBox },
+ qr.kind === 'image-url' || qr.kind === 'data-url'
+ ? h('img', { src: qr.value, alt: t.qrAlt, style: styles.qrImage })
+ : h('div', { style: styles.qrFallback }, qr.value),
+ )
+ : null,
+ h(
+ 'div',
+ { style: styles.stateLine },
+ h('span', { style: styles.label }, stateText),
+ login && login.message ? h('span', { style: styles.value }, login.message) : null,
+ ),
+ h(
+ 'div',
+ { style: styles.buttons },
+ h(
+ 'button',
+ { style: styles.button, disabled: busy, onClick: startLogin },
+ login && login.ticket ? t.refreshQr : t.startLogin,
+ ),
+ login && login.ticket
+ ? h('button', { style: styles.buttonGhost, disabled: busy, onClick: cancelLogin }, t.cancelLogin)
+ : null,
+ auth && auth.configured && auth.source === 'qr'
+ ? h('button', { style: styles.buttonGhost, disabled: busy, onClick: logout }, t.logout)
+ : null,
+ ),
+ actionError ? h('p', { style: styles.error }, actionError) : null,
+ ),
+ data && data.tools && data.tools.length > 0
+ ? h(
+ 'div',
+ { style: styles.card },
+ h('div', { style: styles.cardTitle }, t.tools),
+ h(
+ 'ul',
+ { style: styles.tools },
+ data.tools.map((tool) =>
+ h(
+ 'li',
+ { key: tool.name },
+ h('div', { style: styles.toolName }, tool.name),
+ h('div', { style: styles.toolSummary }, tool.summary),
+ ),
+ ),
+ ),
+ )
+ : null,
+ )
+ }
+
+ return {
+ inject: ['slots', 'locale'],
+ apply(ctx) {
+ ctx.effect(() => ctx.slots.inject('settings.section', () => ctx.slots.register(
+ { name: 'settings.section', id: 'acfun', order: 16, label: 'AcFun' },
+ (props) => h(AcfunPanel, Object.assign({}, props, { ctx })),
+ )))
+ },
+ }
+ },
+})
diff --git a/docs/acfun-login.md b/docs/acfun-login.md
new file mode 100644
index 0000000..7758790
--- /dev/null
+++ b/docs/acfun-login.md
@@ -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,` (the page does exactly this). |
+| `qrLoginToken` | opaque ticket. Shape `1...`; 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=&qrLoginSignature=
+```
+
+| 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":""}`
+(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=&qrLoginSignature=
+```
+
+**[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=&password=&key=&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: — 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).
diff --git a/docs/acfun-moment.md b/docs/acfun-moment.md
new file mode 100644
index 0000000..8510b09
--- /dev/null
+++ b/docs/acfun-moment.md
@@ -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`, `Acfun 动态详情`, 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
+
+
+
+
+ …228…
+
+```
+
+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 `` 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 |
diff --git a/docs/acfun-panel-diagnosis.md b/docs/acfun-panel-diagnosis.md
new file mode 100644
index 0000000..447057f
--- /dev/null
+++ b/docs/acfun-panel-diagnosis.md
@@ -0,0 +1,25 @@
+# Raw client-composition diagnosis
+
+Captured from a running Desktop Host on 2026-10-27 while `dsh-plugin-acfun`
+v0.1.0 was active (all twelve tools live, `/acfun-panel/state` answering).
+
+Request: `GET http://127.0.0.1:/acfun-panel/diagnose`
+
+Interpretation:
+
+- `client.available: true` — the Host exposes the `clientModules` service.
+- `client.path: null` — the Web carrier has **no registered bundle** for this
+ package, so `client.js` cannot be requested yet.
+- `client.entries` — the composed boot graph, i.e. every client module the page
+ would load. `dsh-plugin-acfun` is absent.
+
+Conclusion: the Host half is fine; the Client half has not been composed. The
+graph is built from the live Loader entry set at boot, so a package that gains a
+`dsh.client` declaration after the page booted needs a Harness restart (and then
+a page reload) — see `client-panel.md`.
+
+Raw response:
+
+```json
+{"ok":true,"client":{"available":true,"packageId":"dsh-plugin-acfun","path":null,"entries":[{"id":"@deepseek-ai/dsh-api-gateway","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-open-in-app","module":"","platform":""},{"id":"@deepseek-ai/dsh-api-job-controller","module":"","platform":""},{"id":"@deepseek-ai/dsh-api-terminal-controller","module":"","platform":""},{"id":"@deepseek-ai/dsh-api-workspace-files","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-settings-account","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-hmr","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-modules","module":"","platform":""},{"id":"@deepseek-ai/dsh-cordis-client-runner","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-theme","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-locale","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-shortcuts","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-shortcuts","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-layout","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-renderer","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-session","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-resources","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-sidebar","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-sidebar-right","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-sidebar-documentpreview","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-sidebar-browser","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-sidebar-terminal","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-sidebar-files","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-settings","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-settings-general","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-settings-models","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-plugin-manager","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-settings-plugin-inventory","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-conversation","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-approval","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-chat","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-brand-official","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-attachment","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-tool","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-cordis","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-workspace","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-workflow-run","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-input-trigger","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-commands","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-skill","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-subagent","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-reference","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-secret-mask","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-jobs","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-schedule","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-goal","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-message-feedback","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-model-selection","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-deepseek-model-label-1017","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-permission-presets","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-agent-preset","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-entry-customization","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-settings-session-log","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-settings-plugins","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-settings-shell","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-settings-agent-loop","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-settings-subagent","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-settings-web-search","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-plan","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-user-questions","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-trajectory","module":"","platform":""},{"id":"@deepseek-ai/dsh-experimental-client-ui-agent-team","module":"","platform":""},{"id":"@deepseek-ai/dsh-api-remote-access","module":"","platform":""},{"id":"@deepseek-ai/dsh-session-log-export","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-file-upload","module":"","platform":""},{"id":"@deepseek-ai/dsh-api-remotes","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-deliverables","module":"","platform":""},{"id":"@deepseek-ai/dsh-typert-registry","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-product-analytics","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-connection","module":"","platform":""},{"id":"@deepseek-ai/dsh-api-workspace-controller","module":"","platform":""},{"id":"@deepseek-ai/dsh-api-session-controller","module":"","platform":""},{"id":"@deepseek-ai/dsh-client-ui-directory-picker-native","module":"","platform":""}],"error":""}}
+```
diff --git a/docs/client-panel.md b/docs/client-panel.md
new file mode 100644
index 0000000..6cd5b9b
--- /dev/null
+++ b/docs/client-panel.md
@@ -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:/acfun-panel/state # client.path
+GET http://127.0.0.1:/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.
diff --git a/index.js b/index.js
index fd6e5ae..b873039 100644
--- a/index.js
+++ b/index.js
@@ -1,21 +1,28 @@
/**
- * dsh-plugin-acfun — AcFun (A站) reads and interactions as DeepSeek Harness
- * tools.
+ * dsh-plugin-acfun — AcFun (A站) reads, interactions, QR login and a settings
+ * panel for the DeepSeek Harness Web UI.
*
- * Host-only bundle: no Client half, no runtime imports of `@deepseek-ai/*`.
- * That is deliberate — a workspace-linked bundle resolves bare specifiers from
- * its own directory, so importing `@deepseek-ai/dsh-tools` or schemastery
- * would fail to activate. Tool definitions are therefore plain objects and the
- * config schema is validated in `lib/config.js`.
+ * Host half: registers the model tools, the `/acfun-panel` HTTP surface used by
+ * the Client panel, and the QR-login state machine. No runtime dependency on
+ * `@deepseek-ai/*`: a workspace-linked bundle cannot resolve bare harness
+ * specifiers from its own directory, so tool definitions are plain objects and
+ * the config schema is validated locally.
*
- * Reads work anonymously. Every mutating call additionally requires
- * `allowWriteActions: true` plus a login cookie, so installing the bundle never
- * gives the model write access to an AcFun account by accident.
+ * Browser half: `client.js` registers a Settings → AcFun page through the
+ * `settings.section` slot and talks to this half over same-origin HTTP.
*/
+import { createRequire } from 'node:module'
+
import { resolveConfig } from './lib/config.js'
import { AcfunProvider } from './lib/provider.js'
import { ACFUN_GUIDANCE, createAcfunTools } from './lib/tools.js'
+import { createPanelRoutes } from './lib/panel.js'
+import { AcfunLoginManager, createCredentialSessionStore } from './lib/login.js'
+import { AcfunQrFlow } from './lib/qr-flow.js'
+
+const require = createRequire(import.meta.url)
+const pkg = require('./package.json')
/** Cordis plugin name used by loader diagnostics. */
export const name = 'dsh-plugin-acfun'
@@ -36,22 +43,32 @@ function asEffect(ctx, factory) {
return
}
const dispose = factory()
- if (typeof dispose === 'function' && typeof ctx.on === 'function') {
- // Older Cordis minors without `effect` still expose the dispose event.
- ctx.on('dispose', dispose)
+ if (typeof dispose === 'function' && typeof ctx.on === 'function') ctx.on('dispose', dispose)
+}
+
+/** Read one optional Host service across Cordis minors. */
+function optionalService(ctx, key) {
+ if (typeof ctx.get === 'function') {
+ try {
+ const value = ctx.get(key)
+ if (value !== undefined) return value
+ } catch {
+ // Fall through to the direct property below.
+ }
}
+ return ctx[key]
}
/**
- * Activate the plugin: register the seven AcFun tools plus one system-prompt
- * guidance section.
+ * Activate the plugin: register the nine AcFun tools, one system-prompt
+ * guidance section, the QR-login manager, and — when this Host runs a Web
+ * carrier — the panel routes.
*
* @param {any} ctx - Cordis context with `tools` and `systemPrompt`.
* @param {unknown} config - the Loader row config (see cordis.patch.yml).
*/
export function apply(ctx, config) {
const resolved = resolveConfig(config)
-
if (resolved.warnings.length > 0 && typeof ctx.logger?.warn === 'function') {
for (const warning of resolved.warnings) ctx.logger.warn(`[dsh-plugin-acfun] ${warning}`)
}
@@ -62,8 +79,32 @@ export function apply(ctx, config) {
allowWriteActions: resolved.allowWriteActions,
})
+ // Durable session storage (Host credential service). Optional: without it a
+ // QR login only lives as long as this activation.
+ const credentials = optionalService(ctx, 'credentials')
+ const store = createCredentialSessionStore(credentials)
+ const login = new AcfunLoginManager({
+ provider,
+ store,
+ flow: new AcfunQrFlow(),
+ })
+
+ const sessionSource = () => {
+ if (login.authSource === 'qr') return 'qr'
+ return resolved.cookieSource === 'config' ? 'config' : resolved.cookieSource === 'env' ? 'env' : 'none'
+ }
+
+ // Restore a persisted QR session without delaying activation.
+ Promise.resolve()
+ .then(() => login.restore())
+ .catch((error) => {
+ if (typeof ctx.logger?.warn === 'function') ctx.logger.warn(`[dsh-plugin-acfun] could not restore session: ${String(error?.message ?? error)}`)
+ })
+
+ const tools = createAcfunTools(provider, resolved, { sessionSource, login })
+
asEffect(ctx, () => {
- const disposers = createAcfunTools(provider, resolved).map((definition) => ctx.tools.register(definition))
+ const disposers = tools.map((definition) => ctx.tools.register(definition))
return () => {
for (const dispose of disposers) {
if (typeof dispose === 'function') dispose()
@@ -79,8 +120,34 @@ export function apply(ctx, config) {
})
return typeof dispose === 'function' ? dispose : undefined
})
+
+ // The panel is a Web-UI surface; a headless/SDK profile simply has no
+ // webServer and the tools stay available without it.
+ const webServer = optionalService(ctx, 'webServer')
+ if (webServer !== undefined && typeof webServer.register === 'function') {
+ asEffect(ctx, () => {
+ const routes = createPanelRoutes({
+ provider,
+ login,
+ version: String(pkg.version ?? '0.0.0'),
+ allowWriteActions: resolved.allowWriteActions,
+ cookieSource: sessionSource,
+ tools,
+ clientModules: optionalService(ctx, 'clientModules'),
+ })
+ const disposers = routes.map((route) => webServer.register(route))
+ return () => {
+ for (const dispose of disposers) {
+ if (typeof dispose === 'function') dispose()
+ }
+ }
+ })
+ }
}
export { AcfunProvider } from './lib/provider.js'
export { ACFUN_GUIDANCE, createAcfunTools } from './lib/tools.js'
export { resolveConfig } from './lib/config.js'
+export { AcfunLoginManager, createCredentialSessionStore } from './lib/login.js'
+export { AcfunQrFlow, QR_SERVICE_URL, QR_PATHS } from './lib/qr-flow.js'
+export { createPanelRoutes, PANEL_PATH } from './lib/panel.js'
diff --git a/lib/login.js b/lib/login.js
new file mode 100644
index 0000000..d504f7c
--- /dev/null
+++ b/lib/login.js
@@ -0,0 +1,254 @@
+/**
+ * QR-login state machine.
+ *
+ * The manager owns one pending login ticket, asks the injected `flow` for a QR
+ * code and for status updates, persists a confirmed cookie through the injected
+ * `store`, and points the provider at it. The AcFun endpoint contract lives in
+ * the flow (`lib/qr-flow.js`), so tests and the panel can exercise the state
+ * machine without touching the network.
+ */
+
+import { AcfunError } from './provider.js'
+
+/** Credential record key used to persist a QR-obtained session. */
+export const SESSION_RECORD_KEY = 'acfun-session'
+
+/** How long a confirmed session is trusted before it is re-read from the store. */
+const IDLE = 'idle'
+
+/**
+ * Durable session store over the Host credential service.
+ *
+ * A `grant` record is the generic opaque-record kind, so the cookie round-trips
+ * without a schema while staying out of the plugin config. With no credential
+ * service the store degrades to memory-only for the lifetime of the plugin.
+ *
+ * @param {{readRecord: Function, modifyRecord: Function, deleteRecord: Function} | undefined} credentials
+ */
+export function createCredentialSessionStore(credentials) {
+ let memory
+ return {
+ /** @returns {Promise<{cookie: string, savedAt: number} | undefined>} */
+ async read() {
+ if (credentials === undefined) return memory
+ try {
+ const record = await credentials.readRecord(SESSION_RECORD_KEY)
+ if (record === undefined || record === null) return undefined
+ if (record.kind !== 'grant') return undefined
+ const payload = record.payload
+ if (payload === null || typeof payload !== 'object') return undefined
+ if (typeof payload.cookie !== 'string' || payload.cookie.length === 0) return undefined
+ return { cookie: payload.cookie, savedAt: Number(payload.savedAt) || 0 }
+ } catch {
+ return undefined
+ }
+ },
+ /** @param {string} cookie */
+ async write(cookie) {
+ memory = { cookie, savedAt: Date.now() }
+ if (credentials === undefined) return
+ await credentials.modifyRecord(SESSION_RECORD_KEY, async () => ({
+ kind: 'grant',
+ payload: { cookie, savedAt: Date.now(), plugin: 'dsh-plugin-acfun' },
+ }))
+ },
+ async clear() {
+ memory = undefined
+ if (credentials === undefined) return
+ await credentials.deleteRecord(SESSION_RECORD_KEY)
+ },
+ }
+}
+
+/**
+ * One QR-login manager bound to a provider and a session store.
+ */
+export class AcfunLoginManager {
+ /**
+ * @param {object} options
+ * @param {import('./provider.js').AcfunProvider} options.provider
+ * @param {{read: Function, write: Function, clear: Function}} options.store
+ * @param {{start: Function, poll: Function, cancel?: Function} | null} [options.flow]
+ * @param {() => number} [options.now]
+ */
+ constructor(options) {
+ this.provider = options.provider
+ this.store = options.store
+ this.flow = options.flow ?? null
+ this.now = options.now ?? (() => Date.now())
+ this.ticket = ''
+ this.qr = null
+ this.expiresAt = 0
+ this.state = IDLE
+ this.message = ''
+ this.authSource = 'none'
+ this.aborter = null
+ this.loop = null
+ }
+
+ /** Whether a QR flow is wired in this Host. */
+ get available() {
+ return this.flow !== null
+ }
+
+ /** Immutable view for the panel and tools. */
+ snapshot() {
+ return {
+ available: this.available,
+ state: this.state,
+ ticket: this.ticket,
+ qr: this.qr,
+ expiresAt: this.expiresAt,
+ message: this.message,
+ authSource: this.authSource,
+ }
+ }
+
+ /** Reset the pending ticket without touching the stored session. */
+ #reset(state = IDLE, message = '') {
+ this.ticket = ''
+ this.qr = null
+ this.expiresAt = 0
+ this.state = state
+ this.message = message
+ }
+
+ /**
+ * Load a persisted QR session at activation.
+ *
+ * @returns {Promise<{loaded: boolean, cookie?: string}>}
+ */
+ async restore() {
+ const saved = await this.store.read()
+ if (saved === undefined) return { loaded: false }
+ // A cookie from the plugin config wins until the user explicitly logs in
+ // again; a stored QR session fills an otherwise anonymous provider.
+ if (!this.provider.loggedIn) {
+ this.provider.setCookie(saved.cookie)
+ this.authSource = 'qr'
+ }
+ return { loaded: true, cookie: saved.cookie }
+ }
+
+ /** Start a fresh QR login, replacing any pending ticket. */
+ async start() {
+ if (this.flow === null) {
+ throw new AcfunError(
+ 'AcFun QR login is unavailable: this Host did not detect the login endpoint contract.',
+ { code: 'QR_LOGIN_UNAVAILABLE' },
+ )
+ }
+ await this.cancel()
+ const started = await this.flow.start()
+ this.ticket = String(started?.ticket ?? '')
+ this.qr = started?.qr ?? null
+ this.expiresAt = Number(started?.expiresAt) || 0
+ this.state = 'waiting'
+ this.message = ''
+ // The QR service long-polls, so the exchange runs in the background and the
+ // panel reads this in-memory state instead of blocking an HTTP response.
+ this.#launchLoop()
+ return this.snapshot()
+ }
+
+ /** Run the waiting → scanned → confirmed loop in the background. */
+ #launchLoop() {
+ const aborter = new AbortController()
+ this.aborter = aborter
+ this.loop = (async () => {
+ const signal = aborter.signal
+ try {
+ while (!signal.aborted) {
+ if (this.expiresAt > 0 && this.now() > this.expiresAt) {
+ this.state = 'expired'
+ this.message = 'The QR code expired; start a new login.'
+ return
+ }
+ const scanned = await this.flow.waitForScan({ signal })
+ if (signal.aborted) return
+ if (scanned.state === 'waiting') {
+ if (this.expiresAt > 0 && this.now() > this.expiresAt) {
+ this.state = 'expired'
+ this.message = 'The QR code expired; start a new login.'
+ return
+ }
+ await new Promise((resolve) => setTimeout(resolve, 1500))
+ continue
+ }
+ if (scanned.state !== 'scanned') {
+ this.state = scanned.state === 'expired' ? 'expired' : 'error'
+ this.message =
+ scanned.message ||
+ (this.state === 'expired' ? 'The QR code expired; start a new login.' : 'AcFun rejected this QR login.')
+ return
+ }
+ this.state = 'scanned'
+ this.message = 'Scanned; confirm the login on your phone.'
+ const accepted = await this.flow.accept({ signal })
+ if (signal.aborted) return
+ if (accepted.state === 'confirmed') {
+ try {
+ await this.store.write(accepted.cookie)
+ } catch (error) {
+ this.state = 'error'
+ this.message = `Login succeeded but the session could not be saved: ${error instanceof Error ? error.message : String(error)}`
+ return
+ }
+ this.provider.setCookie(accepted.cookie)
+ this.authSource = 'qr'
+ this.qr = null
+ this.state = 'confirmed'
+ this.message = 'Signed in to AcFun; the session cookie is saved.'
+ return
+ }
+ this.state = accepted.state === 'expired' ? 'expired' : 'error'
+ this.message = accepted.message || 'AcFun login failed.'
+ return
+ }
+ } catch (error) {
+ if (signal.aborted) return
+ this.state = error?.name === 'TimeoutError' ? 'expired' : 'error'
+ this.message = error?.name === 'TimeoutError' ? 'The QR code expired; start a new login.' : String(error?.message ?? error)
+ } finally {
+ if (this.aborter === aborter) this.aborter = null
+ }
+ })()
+ }
+
+ /**
+ * Snapshot poll used by the panel and tools. The network work happens in the
+ * background loop, so this never blocks on AcFun.
+ *
+ * @param {string} ticket - ticket returned by `start`; a stale ticket is ignored.
+ */
+ async poll(ticket) {
+ if (this.ticket !== '' && String(ticket ?? '') !== this.ticket) return this.snapshot()
+ return this.snapshot()
+ }
+
+ /** Cancel the pending ticket; the stored session is untouched. */
+ async cancel() {
+ if (this.aborter !== null) {
+ this.aborter.abort()
+ this.aborter = null
+ }
+ if (this.ticket !== '' && this.flow !== null && typeof this.flow.cancel === 'function') {
+ try {
+ await this.flow.cancel(this.ticket)
+ } catch {
+ // Cancelling is best-effort; the ticket expires on AcFun's side anyway.
+ }
+ }
+ this.#reset('idle', '')
+ return this.snapshot()
+ }
+
+ /** Forget the QR session and return the provider to anonymous/config mode. */
+ async logout() {
+ await this.cancel()
+ await this.store.clear()
+ this.provider.setCookie('')
+ this.authSource = 'none'
+ return this.snapshot()
+ }
+}
diff --git a/lib/panel.js b/lib/panel.js
new file mode 100644
index 0000000..4bf3e0a
--- /dev/null
+++ b/lib/panel.js
@@ -0,0 +1,218 @@
+/**
+ * Host-side HTTP surface for the AcFun settings panel.
+ *
+ * Static Client plugins reach their Host half over plain same-origin HTTP: the
+ * Web carrier serves these routes, and `client.js` calls them with `fetch`.
+ * Responses are JSON and never contain the session cookie itself — only a
+ * description of it.
+ *
+ * The panel is a local-management surface, so every route rejects a request
+ * whose `Origin`/`Referer` host differs from the Host header, and nothing is
+ * cached.
+ */
+
+import { AcfunError } from './provider.js'
+
+/** Mount point of the panel API under the Web origin. */
+export const PANEL_PATH = '/acfun-panel'
+
+/** Largest JSON request body the panel accepts. */
+const MAX_BODY_BYTES = 64 * 1024
+
+/** Write one JSON response. */
+function sendJson(res, status, payload) {
+ const body = Buffer.from(JSON.stringify(payload), 'utf8')
+ res.writeHead(status, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Content-Length': body.length,
+ 'Cache-Control': 'no-store',
+ 'X-Content-Type-Options': 'nosniff',
+ })
+ res.end(body)
+}
+
+/** Send one structured failure. */
+function sendError(res, error) {
+ const known = error instanceof AcfunError
+ sendJson(res, known ? 400 : 500, {
+ ok: false,
+ error: {
+ code: known ? error.code : 'PANEL_ERROR',
+ message: error instanceof Error ? error.message : String(error),
+ },
+ })
+}
+
+/** Reject a cross-origin browser request; absent headers (curl) pass. */
+function trustedRequest(req) {
+ const host = req.headers.host
+ if (typeof host !== 'string' || host.length === 0) return false
+ for (const header of ['origin', 'referer']) {
+ const value = req.headers[header]
+ if (typeof value !== 'string' || value.length === 0) continue
+ try {
+ if (new URL(value).host !== host) return false
+ } catch {
+ return false
+ }
+ }
+ return true
+}
+
+/** Read a small JSON request body, treating an empty body as `{}`. */
+async function readJsonBody(req) {
+ const chunks = []
+ let size = 0
+ for await (const chunk of req) {
+ size += chunk.length
+ if (size > MAX_BODY_BYTES) throw new AcfunError('Request body is too large.', { code: 'BODY_TOO_LARGE' })
+ chunks.push(chunk)
+ }
+ if (chunks.length === 0) return {}
+ try {
+ const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
+ return parsed !== null && typeof parsed === 'object' ? parsed : {}
+ } catch {
+ throw new AcfunError('Request body is not valid JSON.', { code: 'BAD_JSON' })
+ }
+}
+
+/** Summarize the provider session for the panel. */
+function authPayload(provider, context) {
+ const described = provider.describeSession()
+ return {
+ configured: described.configured,
+ length: described.length,
+ hasAuthKey: described.hasAuthKey,
+ hasDid: described.hasDid,
+ source: context.cookieSource(),
+ writesEnabled: context.allowWriteActions,
+ }
+}
+
+/**
+ * Build the panel routes for one plugin activation.
+ *
+ * @param {object} options
+ * @param {import('./provider.js').AcfunProvider} options.provider - live client whose cookie may change.
+ * @param {{snapshot: Function, start: Function, poll: Function, cancel: Function, logout: Function}} options.login - QR login manager.
+ * @param {string} options.version - plugin version reported to the panel.
+ * @param {boolean} options.allowWriteActions - whether write tools are enabled.
+ * @param {() => string} options.cookieSource - current cookie source label.
+ * @param {Array<{name: string, description: string}>} options.tools - registered tool metadata.
+ * @returns {Array<{kind: 'prefix', path: string, handler: Function}>}
+ */
+export function createPanelRoutes(options) {
+ const context = {
+ allowWriteActions: options.allowWriteActions === true,
+ cookieSource: typeof options.cookieSource === 'function' ? options.cookieSource : () => 'none',
+ }
+
+ /** Report whether the Web carrier registered this package's client bundle. */
+ const clientModule = () => {
+ const service = options.clientModules
+ if (service === undefined || typeof service.clientPath !== 'function') return { available: false, path: null }
+ try {
+ const path = service.clientPath('dsh-plugin-acfun')
+ return { available: true, path: typeof path === 'string' ? path : null }
+ } catch {
+ return { available: true, path: null }
+ }
+ }
+
+ /** Diagnostics: which client module rows the carrier currently knows. */
+ const clientDiagnostics = () => {
+ const service = options.clientModules
+ const out = { available: false, packageId: 'dsh-plugin-acfun', path: null, entries: [], error: '' }
+ if (service === undefined || typeof service.clientPath !== 'function') return out
+ out.available = true
+ try {
+ const path = service.clientPath('dsh-plugin-acfun')
+ out.path = typeof path === 'string' ? path : null
+ } catch (error) {
+ out.error = String(error?.message ?? error)
+ }
+ try {
+ const graph = typeof service.graph === 'function' ? service.graph() : undefined
+ const entries = graph?.entries
+ if (Array.isArray(entries)) {
+ out.entries = entries.map((entry) => ({
+ id: String(entry?.id ?? entry?.name ?? ''),
+ module: String(entry?.module ?? entry?.moduleName ?? ''),
+ platform: String(entry?.platform ?? ''),
+ }))
+ }
+ } catch (error) {
+ out.error = out.error.length > 0 ? out.error : String(error?.message ?? error)
+ }
+ return out
+ }
+
+ const state = async () => ({
+ ok: true,
+ plugin: { name: 'dsh-plugin-acfun', version: options.version },
+ auth: authPayload(options.provider, context),
+ login: options.login.snapshot(),
+ client: clientModule(),
+ tools: options.tools.map((tool) => ({
+ name: tool.name,
+ summary: String(tool.description).split('.')[0].slice(0, 140),
+ })),
+ })
+
+ const handler = async (req, res) => {
+ if (!trustedRequest(req)) {
+ sendJson(res, 403, { ok: false, error: { code: 'FORBIDDEN', message: 'Cross-origin panel request rejected.' } })
+ return
+ }
+ let path
+ try {
+ path = new URL(req.url ?? '/', 'http://localhost').pathname
+ } catch {
+ sendJson(res, 400, { ok: false, error: { code: 'BAD_URL', message: 'Malformed request URL.' } })
+ return
+ }
+ if (path !== PANEL_PATH && !path.startsWith(`${PANEL_PATH}/`)) {
+ sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: `Unknown AcFun panel path: ${path}` } })
+ return
+ }
+ const route = path.slice(PANEL_PATH.length) || '/'
+ const method = req.method ?? 'GET'
+
+ try {
+ if (route === '/state' && method === 'GET') {
+ sendJson(res, 200, await state())
+ return
+ }
+ if (route === '/diagnose' && method === 'GET') {
+ sendJson(res, 200, { ok: true, client: clientDiagnostics() })
+ return
+ }
+ if (route === '/login/start' && method === 'POST') {
+ await readJsonBody(req)
+ sendJson(res, 200, { ok: true, login: await options.login.start() })
+ return
+ }
+ if (route === '/login/poll' && method === 'GET') {
+ const ticket = new URL(req.url ?? '/', 'http://localhost').searchParams.get('ticket') ?? ''
+ sendJson(res, 200, { ok: true, login: await options.login.poll(ticket) })
+ return
+ }
+ if (route === '/login/cancel' && method === 'POST') {
+ await readJsonBody(req)
+ sendJson(res, 200, { ok: true, login: await options.login.cancel() })
+ return
+ }
+ if (route === '/logout' && method === 'POST') {
+ await readJsonBody(req)
+ sendJson(res, 200, { ok: true, login: await options.login.logout(), auth: authPayload(options.provider, context) })
+ return
+ }
+ sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: `Unknown AcFun panel route: ${method} ${route}` } })
+ } catch (error) {
+ sendError(res, error)
+ }
+ }
+
+ return [{ kind: 'prefix', path: PANEL_PATH, handler }]
+}
diff --git a/lib/provider.js b/lib/provider.js
index 586be0b..ed87caf 100644
--- a/lib/provider.js
+++ b/lib/provider.js
@@ -36,6 +36,17 @@ export const DEFAULT_USER_AGENT =
/** The `sourceType` AcFun uses for video (douga) comment threads. */
export const COMMENT_SOURCE_TYPE = 3
+/** The `sourceType` AcFun uses for moment (动态) comment threads. */
+export const MOMENT_COMMENT_SOURCE_TYPE = 4
+
+/** The `resourceType` AcFun uses for moment likes / bananas. */
+export const MOMENT_RESOURCE_TYPE = 10
+
+/** Canonical public URL of one AcFun moment (user-facing share page). */
+export function momentShareUrl(momentId) {
+ return `https://m.acfun.cn/communityCircle/moment/${momentId}`
+}
+
/** The `resourceType` AcFun uses for video favorites. */
export const FAVORITE_RESOURCE_TYPE = 9
@@ -133,7 +144,12 @@ export function formatDuration(milliseconds) {
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`
}
-/** Format an epoch-millisecond timestamp as an ISO date-time, or ''. */
+/**
+ * Format an epoch-millisecond timestamp as an ISO date-time, or ''.
+ *
+ * @param {unknown} milliseconds
+ * @returns {string}
+ */
export function formatTimestamp(milliseconds) {
const value = Number(milliseconds)
if (!Number.isFinite(value) || value <= 0) return ''
@@ -141,11 +157,62 @@ export function formatTimestamp(milliseconds) {
return Number.isNaN(date.getTime()) ? '' : date.toISOString()
}
+/**
+ * Validate a banana (投蕉) amount.
+ *
+ * @param {unknown} count
+ * @returns {number} an integer between 1 and 10.
+ */
+function validateBananaCount(count) {
+ const amount = Number(count ?? 1)
+ if (!Number.isInteger(amount) || amount < 1 || amount > 10) {
+ throw new AcfunError('count must be an integer between 1 and 10.', { code: 'INVALID_ARGUMENT' })
+ }
+ return amount
+}
+
/** The canonical web URL of one AcFun video. */
export function acfunVideoUrl(acId) {
return `https://www.acfun.cn/v/ac${acId}`
}
+/**
+ * Normalize a `comment/listByFloor` payload (videos and moments share it).
+ *
+ * @param {Record} data - the API body.
+ * @param {number} fallbackPage - page used when the body omits `curPage`.
+ * @returns {{items: object[], currentPage: number, totalPages: number, totalCount: number, pageSize: number}}
+ */
+export function mapCommentPage(data, fallbackPage = 1) {
+ const map = data.commentsMap !== null && typeof data.commentsMap === 'object' ? data.commentsMap : {}
+ const orderedIds = Array.isArray(data.commentIds)
+ ? data.commentIds.map((id) => String(id))
+ : Object.keys(map).map((key) => key.replace(/^c/, ''))
+ const items = []
+ for (const id of orderedIds) {
+ const entry = map[`c${id}`] ?? map[id]
+ if (entry === null || typeof entry !== 'object') continue
+ items.push({
+ commentId: String(entry.cid ?? id),
+ userId: String(entry.userId ?? ''),
+ userName: String(entry.userName ?? ''),
+ content: normalizeUbb(entry.content),
+ floor: Number(entry.floor) || 0,
+ postedAt: String(entry.postDate ?? ''),
+ timestamp: Number(entry.timestamp) || 0,
+ isUp: entry.isUp === true,
+ isDeleted: entry.isDelete === true,
+ })
+ }
+ return {
+ items,
+ currentPage: Number(data.curPage) || fallbackPage,
+ totalPages: Number(data.totalPage) || 1,
+ totalCount: Number(data.totalCount) || items.length,
+ pageSize: Number(data.pageSize) || items.length,
+ }
+}
+
/**
* Accept `42588725`, `ac42588725`, `AC42588725`, or any acfun.cn URL that
* contains `/v/ac`, and return the bare numeric ac id.
@@ -216,6 +283,29 @@ export function extractAssignedJson(html, marker) {
return undefined
}
+/**
+ * Accept a numeric moment id, a `momentId=` query fragment, or a
+ * `m.acfun.cn/communityCircle/moment/` share URL.
+ *
+ * @param {unknown} input - model-supplied moment identifier.
+ * @returns {string} bare numeric moment id.
+ */
+export function normalizeMomentId(input) {
+ if (typeof input !== 'string') {
+ throw new AcfunError('momentId must be a string such as "5093831" or a communityCircle/moment URL.', {
+ code: 'INVALID_ARGUMENT',
+ })
+ }
+ const value = input.trim()
+ if (/^\d+$/.test(value)) return value
+ const fromUrl = /\/moment\/(\d+)/i.exec(value) ?? /[?&]momentId=(\d+)/i.exec(value)
+ if (fromUrl !== null) return fromUrl[1]
+ throw new AcfunError(
+ `Cannot read an AcFun moment id from "${value}". Pass the numeric momentId or a https://m.acfun.cn/communityCircle/moment/... URL.`,
+ { code: 'INVALID_ARGUMENT' },
+ )
+}
+
/**
* Read the H.264 representation ladder out of a `ksPlayJson` string.
*
@@ -255,6 +345,80 @@ export function readPlayQualities(playJson) {
return qualities
}
+/** Strip HTML tags/scripts and collapse whitespace into one readable line. */
+function stripHtml(value) {
+ return decodeEntities(
+ String(value ?? '')
+ .replace(/