feat: QR login, AcFun settings panel, and 动态 (moment) tools
- scan.acfun.cn QR flow (start -> long-poll scanResult -> acceptResult) with rotated-signature handling, background poller, durable session in the Host credential service, and cookie handoff to the provider - Client half: Settings -> AcFun page (status, QR code, poll, logout) over a same-origin /acfun-panel JSON API with cross-origin rejection - acfun_moment / acfun_moment_comments / acfun_topic read tools, moment_like / moment_unlike / moment_banana actions, and comment posting on moments - acfun_account status tool; docs for the QR contract, the 广场/动态 research verdict, and the client-composition restart requirement
This commit is contained in:
@@ -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 <https://www.acfun.cn/> 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
|
||||
|
||||
|
||||
+79
-47
@@ -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. 在桌面浏览器登录 <https://www.acfun.cn/>。
|
||||
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`(广场/动态调研与结论)。
|
||||
|
||||
## 许可证
|
||||
|
||||
|
||||
@@ -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 })),
|
||||
)))
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,589 @@
|
||||
# AcFun (acfun.cn / A站) QR-code login (扫码登录) — live-verified contract
|
||||
|
||||
Reverse-engineered on **2026-10-27 (Asia/Shanghai)** by probing `https://www.acfun.cn/login/`,
|
||||
its webpack bundles, and the QR service at `https://scan.acfun.cn` with Node 24 `fetch`
|
||||
(desktop Chrome UA, UTF-8, `res.headers.getSetCookie()`). **No AcFun account, phone, or scan was
|
||||
used** — every state below was reached anonymously. Each claim is marked **[VERIFIED]** (a live
|
||||
request produced the status/body pasted) or **[UNVERIFIED]** (derived from the site's own
|
||||
JavaScript, or impossible to reach without scanning the code).
|
||||
|
||||
Probe scripts live in `%TEMP%\ac-qr\*.mjs` and are **not** part of this repo.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
| Question | Answer |
|
||||
|---|---|
|
||||
| Is web QR login alive in 2026-10? | **Yes — [VERIFIED] live and working.** The modern flow is **not** the old `sdk-pay` `/rest/c/infra/ks/qr/*` paths. |
|
||||
| QR service host | **`https://scan.acfun.cn`** (discovered in `login/widget/login/login.fe91a542c7bccee57336.js`) |
|
||||
| Create | `GET https://scan.acfun.cn/rest/pc-direct/qr/start?type=WEB_LOGIN` → `result:0`, `qrLoginToken`, `qrLoginSignature`, `expireTime:120000`, `imageData` (base64 **PNG**, 125×125) |
|
||||
| Poll | `GET .../qr/scanResult?qrLoginToken=&qrLoginSignature=` — one **long-poll** held until scan or ticket expiry (≤120 s). No polling interval. |
|
||||
| Success exchange | `GET .../qr/acceptResult?qrLoginToken=&qrLoginSignature=` using the **rotated** signature returned by a successful `scanResult` |
|
||||
| Session transport | **Cookies**, not a JSON token: `acceptResult` → `result:0`, page then redirects with no token in the URL → `Set-Cookie` (INFERRED; cannot scan). |
|
||||
| Extra step after scan | Yes: the phone must **authorize** the login ("扫码成功!请在手机上进行授权操作"); `acceptResult` waits for that. No captcha on the QR path. |
|
||||
| Cookies needed *before* starting | **None** — `start`/`scanResult` work with an empty cookie jar (VERIFIED); `_did` is auto-seeded by any `www.acfun.cn` request in any case. |
|
||||
| Anti-bot | None on the QR endpoints: no `Referer`/UA/`X-Requested-With`/`csrfToken` requirement, no captcha, no signature param, 15 rapid `start`s all `result:0`. |
|
||||
| QR lifetime | **120 s absolute from `start`** (proven: a poll opened 60 s late was held only 60.5 s and ended at start+120.5 s). |
|
||||
| Timing trap | A terminal poll returns `100400002 "token expired"`; **re-polling that token is useless** (`result:10 "client error"`). Restart with `start`. |
|
||||
|
||||
---
|
||||
|
||||
## 0. Where the contract comes from
|
||||
|
||||
**[VERIFIED]** `https://www.acfun.cn/login/` → HTTP 200, `text/html`, 68,206 bytes. It loads
|
||||
|
||||
```
|
||||
https://ali-imgs.acfun.cn/kos/nlav10360/static/login/widget/login/login.fe91a542c7bccee57336.js
|
||||
```
|
||||
|
||||
whose `created` hook hardcodes the three endpoints (single line, un-minified enough to read):
|
||||
|
||||
```js
|
||||
this.url = "https://scan.acfun.cn/rest/pc-direct/qr/start";
|
||||
this.checkurl = "https://scan.acfun.cn/rest/pc-direct/qr/scanResult";
|
||||
this.accepturl = "https://scan.acfun.cn/rest/pc-direct/qr/acceptResult";
|
||||
// createcode(): GET this.url + "?type=WEB_LOGIN", xhrFields:{withCredentials:true}, cache:false
|
||||
// img.src = "data:image/png;base64," + resp.imageData
|
||||
// checkstatus(resp.qrLoginToken, resp.qrLoginSignature)
|
||||
// checkstatus(): GET this.checkurl + "?qrLoginToken=..&qrLoginSignature=..", timeout: 120000
|
||||
// switch(resp.result){ case 0: ...checkaccept(token, resp.qrLoginSignature)
|
||||
// case 100400002: status-fresh // "二维码已失效"
|
||||
// default: status-fail }
|
||||
// checkaccept(): GET this.accepturl + "?qrLoginToken=..&qrLoginSignature=..", timeout: 120000
|
||||
// case 0: location.href = returnUrl (whitelisted) or "/"
|
||||
// case 100400002: status-fresh ; default: status-fail + reload in 3 s
|
||||
```
|
||||
|
||||
The state names are corroborated by the login page's own markup/CSS (`status-fresh` =
|
||||
"二维码已失效"/"请点击刷新", `status-success` = "扫码成功!请在手机上进行授权操作",
|
||||
`status-fail` = "登录失败"). This is why `100400002` means **expired**, not "still waiting".
|
||||
|
||||
The `sdk-pay.*.js` bundle on the same page contains a completely different, generic
|
||||
**Kuaishou Passport** QR API (`/rest/c/infra/ks/qr/start`, `/rest/c/infra/ks/qr/scanResult`,
|
||||
`/rest/c/infra/ks/new/qr/acceptResult`, `/pass/kuaishou/login/qr/callback`). It is parameterized by
|
||||
`baseUrl/sid/qrType/kpn` and is **not** the AcFun web login — see §8.2 for the live 404/307/500
|
||||
proof.
|
||||
|
||||
---
|
||||
|
||||
## 1. The flow, step by step
|
||||
|
||||
All QR calls are plain GETs, JSON in / JSON out, UTF-8, HTTP 200 even for errors.
|
||||
|
||||
### Step 0 (optional) — visit the login page / any `www.acfun.cn` page
|
||||
|
||||
```
|
||||
GET https://www.acfun.cn/login/
|
||||
```
|
||||
|
||||
**[VERIFIED]** HTTP 200 and:
|
||||
|
||||
```
|
||||
set-cookie: csrfToken=LFSE8AMawbKPYyoUMjbkJaf0; path=/
|
||||
set-cookie: _did=web_970527494F3D75A9; path=/; expires=Wed, 27 Oct 2027 03:25:51 GMT; domain=.acfun.cn
|
||||
```
|
||||
|
||||
- `_did` is device id, 1-year lifetime, `Domain=.acfun.cn` — **any** `www.acfun.cn` response
|
||||
re-seeds it if absent (verified even on 404 responses), so a Node plugin does not need to
|
||||
pre-seed it, but sending it is harmless and more browser-like.
|
||||
- `csrfToken` is host-only for `www.acfun.cn`; it is **not** required by the QR service
|
||||
(all QR probes below were made with an empty jar).
|
||||
- `window.loginConfig` on that page only holds a `domainWhitelist` of `returnUrl` targets and the
|
||||
background image URL — no token/secret.
|
||||
|
||||
The QR service (`scan.acfun.cn`) is a **different origin**; nothing in the start/scan/accept
|
||||
requests depends on the `www` cookies.
|
||||
|
||||
### Step 1 — create the QR code
|
||||
|
||||
```
|
||||
GET https://scan.acfun.cn/rest/pc-direct/qr/start?type=WEB_LOGIN
|
||||
```
|
||||
|
||||
| Param | Required | Value |
|
||||
|---|---|---|
|
||||
| `type` | recommended | **`WEB_LOGIN`** is the only accepted value. Omitted → also works (defaults to WEB_LOGIN). `APP_LOGIN`, `WEB`, `web_login` → `100400000 "type is illegal"`. |
|
||||
| (others) | — | none. No `sid`, no `kpn`, no sign, no cache-buster needed. |
|
||||
|
||||
Headers actually sent by the site: the jQuery default set plus `withCredentials:true`
|
||||
(i.e. cookies if any). For Node:
|
||||
|
||||
```http
|
||||
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36
|
||||
Referer: https://www.acfun.cn/login/
|
||||
Accept: application/json, text/plain, */*
|
||||
```
|
||||
…but **[VERIFIED] none of them are enforced**: `curl/8.5.0` UA, no UA, no Referer and no cookies
|
||||
all returned `result:0` with a fresh QR.
|
||||
|
||||
**[VERIFIED] Response — HTTP 200, `application/json;charset=UTF-8`:**
|
||||
|
||||
```json
|
||||
{
|
||||
"result": 0,
|
||||
"next": "scanResult",
|
||||
"expireTime": 120000,
|
||||
"qrLoginSignature": "314fe07642a8e8a8bbaafdf813f0b967",
|
||||
"imageData": "iVBORw0KGgoAAAANSUhEUgAAAH0AAAB9AQAAAACn+1GIAAACY0lEQVR42s3V…", // 892 base64 chars
|
||||
"qrLoginToken": "1.2824b14669ff2ed2000001a17b64957e5745425f4c4f47494e.f88ab57e30c18e149cfd46ca63c019c2"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `result` | `0` = ok |
|
||||
| `next` | name of the endpoint to call next (`scanResult`) |
|
||||
| `expireTime` | **120000 ms**, absolute lifetime measured from this response |
|
||||
| `qrLoginSignature` | 32-hex signature bound to this token for the *scan* step |
|
||||
| `imageData` | **base64 PNG**, decode → 665 bytes, `\x89PNG…`, IHDR **125×125**. Not a URL, not a content string. Render as `data:image/png;base64,<imageData>` (the page does exactly this). |
|
||||
| `qrLoginToken` | opaque ticket. Shape `1.<hex>.<hex>.<hex>`; note the ASCII string `WEB_LOGIN` is hex-encoded inside it (`5745425f4c4f47494e`) — do not parse, store opaquely. |
|
||||
|
||||
**[VERIFIED] Response headers** (useful for the CORS story):
|
||||
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
server: Tengine
|
||||
content-type: application/json;charset=UTF-8
|
||||
access-control-allow-origin: https://www.acfun.cn
|
||||
access-control-allow-credentials: true
|
||||
x-kslogid: 793071737767276976
|
||||
keep-alive: timeout=120
|
||||
```
|
||||
|
||||
`Set-Cookie`: **none** — the QR service is stateless until `acceptResult`.
|
||||
|
||||
### Step 2 — wait for the scan (long-poll)
|
||||
|
||||
```
|
||||
GET https://scan.acfun.cn/rest/pc-direct/qr/scanResult?qrLoginToken=<token>&qrLoginSignature=<sig>
|
||||
```
|
||||
|
||||
| Param | Required | Notes |
|
||||
|---|---|---|
|
||||
| `qrLoginToken` | yes | from Step 1 |
|
||||
| `qrLoginSignature` | yes | from Step 1 (later: from Step 2's success body) |
|
||||
| (others) | — | none |
|
||||
|
||||
**[VERIFIED] It is a long-poll, not a short poll.** The request is held open until the phone scans
|
||||
or the ticket expires; the site's own jQuery sets `timeout: 120000`, matching `expireTime`.
|
||||
|
||||
Measured hold times (no phone involved → always ended in expiry):
|
||||
|
||||
```
|
||||
start → scanResult immediately : 120,489 ms (probe 05)
|
||||
start → scanResult immediately : 120,788 ms (probe 10, first of two concurrent polls)
|
||||
start → idle 60 s → scanResult : held 60,520 ms, returned at start+120,534 ms (probe 14)
|
||||
```
|
||||
|
||||
→ **the TTL is absolute from `start`**, not per-request. A late poll does not get a fresh 120 s.
|
||||
|
||||
**[VERIFIED] Terminal state when nobody scans (HTTP 200):**
|
||||
|
||||
```json
|
||||
{"result":100400002,"error_msg":"token expired"}
|
||||
```
|
||||
|
||||
**[VERIFIED] Re-polling that same token (or opening a second concurrent poll on it)** returns
|
||||
immediately (62 ms / 699 ms):
|
||||
|
||||
```json
|
||||
{"result":10,"error_msg":"client error"}
|
||||
```
|
||||
|
||||
**So there is exactly one poll per ticket.** `result:10` = "this ticket is not pollable any more
|
||||
(in flight elsewhere, or terminal/consumed)".
|
||||
|
||||
**[VERIFIED] Error shapes for bad input:**
|
||||
|
||||
```
|
||||
scanResult with no params -> {"result":11,"error_msg":"网络错误,请稍后再试"}
|
||||
scanResult valid token, wrong sig -> {"result":21,"error_msg":"sign error"}
|
||||
```
|
||||
|
||||
**[UNVERIFIED] Success:** on scan the body becomes `{"result":0, …, "qrLoginSignature":"<rotated>"}`
|
||||
(possibly with user info). The rotation is proven by the bundle calling
|
||||
`checkaccept(token, resp.qrLoginSignature)` — i.e. Step 3 must use the signature from **this**
|
||||
response, not the one from Step 1. A `result:0` here means "QR decoded / scanned", not "logged in":
|
||||
the UI shows **"扫码成功!请在手机上进行授权操作"** at this point.
|
||||
|
||||
### Step 3 — exchange the scanned ticket for the session
|
||||
|
||||
```
|
||||
GET https://scan.acfun.cn/rest/pc-direct/qr/acceptResult?qrLoginToken=<token>&qrLoginSignature=<rotated sig from Step 2>
|
||||
```
|
||||
|
||||
**[VERIFIED] without scanning, this call always fails fast** (62 ms):
|
||||
|
||||
```
|
||||
start sig, unscanned ticket -> {"result":21,"error_msg":"sign error"}
|
||||
POST acceptResult (same params) -> {"result":21,"error_msg":"sign error"} (GET only)
|
||||
acceptResult, no signature -> {"result":11,"error_msg":"网络错误,请稍后再试"}
|
||||
acceptResult with an expired token -> {"result":21,"error_msg":"sign error"}
|
||||
```
|
||||
|
||||
This is strong evidence that the signature is **step-scoped**: the Step-1 signature is valid for
|
||||
`scanResult` but not for `acceptResult`. It also means "sign error" here does **not** mean the
|
||||
ticket is invalid — it means you called Step 3 too early or with the wrong signature.
|
||||
|
||||
**[UNVERIFIED — needs a phone]** `result:0` here is the actual login. The page then does
|
||||
`window.location.href = returnUrl || "/"` — **no token is placed in the URL**, and the bundle reads
|
||||
no token from the body, so the authenticated session must arrive as **`Set-Cookie` headers on the
|
||||
`acceptResult` response** (scoped `Domain=.acfun.cn`, otherwise `www.acfun.cn` could not see it).
|
||||
The response is a credentialed cross-origin XHR (`withCredentials:true`, and the server advertises
|
||||
`Access-Control-Allow-Credentials: true`), which only makes sense if cookies are the payload.
|
||||
`result:100400002` here would mean the ticket died while waiting for phone-side authorization;
|
||||
any other code is a hard failure.
|
||||
|
||||
### Step 4 — persist and validate
|
||||
|
||||
Serialize the captured cookie jar into one `Cookie:` header and use it for `www.acfun.cn`.
|
||||
Cheapest validation (documented in [`docs/acfun-api.md`](./acfun-api.md) §6.6):
|
||||
|
||||
```
|
||||
POST https://www.acfun.cn/rest/pc-direct/user/personalInfo
|
||||
-> {"result":0, "info": {"userId": …, …}} # session valid
|
||||
-> {"result":-401,"error_msg":"user not login"} # not valid
|
||||
```
|
||||
|
||||
A confirmed login must at minimum contain an `auth_key` cookie (numeric user id): the site's own
|
||||
front-end decides "logged in" by reading it (`A.Cookie.getCookie("auth_key")` in
|
||||
`common/widget/header/user.*.js` and `core.*.js`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Response state machine
|
||||
|
||||
```
|
||||
GET /qr/start?type=WEB_LOGIN
|
||||
│
|
||||
│ result:0 (QR displayed; nothing polled yet = "waiting")
|
||||
▼
|
||||
GET /qr/scanResult ← ONE long-poll, held up to 120 s from start
|
||||
│
|
||||
├── result:0 ──────────► scanned ("扫码成功!请在手机上进行授权操作")
|
||||
│ │
|
||||
│ ▼
|
||||
│ GET /qr/acceptResult (with rotated signature)
|
||||
│ ├── result:0 ─────────► confirmed (Set-Cookie, redirect)
|
||||
│ ├── result:100400002 ─► expired (start again)
|
||||
│ └── other ────────────► failed
|
||||
│
|
||||
├── result:100400002 ──► expired ("二维码已失效" / "请点击刷新") → start again
|
||||
│
|
||||
└── other (10/11/21) ──► failed ("登录失败", page reloads after 3 s) → start again
|
||||
```
|
||||
|
||||
| State | Trigger (observed) | UI (site) | Plugin action |
|
||||
|---|---|---|---|
|
||||
| waiting | request held open | QR image + tip | keep the single poll open |
|
||||
| **scanned** | `scanResult` → `result:0` | "扫码成功!请在手机上进行授权操作" | call `acceptResult` with **new** signature |
|
||||
| **confirmed** | `acceptResult` → `result:0` | redirect to `returnUrl` or `/` | save cookie, mark logged in |
|
||||
| **expired** | `scanResult`/`acceptResult` → `100400002` | "二维码已失效 / 请点击刷新" | call `start` again; never re-poll |
|
||||
| **failed** | any other `result` (`10`, `11`, `21`, `100400000`) | "登录失败", reload after 3 s | surface error, offer restart |
|
||||
| network error / abort | fetch throws | treated as "fresh" (expired) | surface, offer restart |
|
||||
|
||||
Observed `result` catalogue on this service:
|
||||
|
||||
| `result` | HTTP | Seen from | Meaning |
|
||||
|---|---|---|---|
|
||||
| `0` | 200 | start (verified), scan/accept (bundle) | ok / scanned / confirmed |
|
||||
| `10` | 200 | scanResult | `client error` — ticket not pollable: in-flight elsewhere, terminal, or consumed |
|
||||
| `11` | 200 | scanResult / acceptResult | `网络错误,请稍后再试` — missing params |
|
||||
| `21` | 200 | scanResult / acceptResult | `sign error` — wrong/expired-step signature |
|
||||
| `100400000` | 200 | start (POST, wrong `type`) | `type is illegal` |
|
||||
| `100400002` | 200 | scanResult (verified), acceptResult (bundle) | `token expired` / QR dead → restart |
|
||||
|
||||
---
|
||||
|
||||
## 3. Capturing the session cookie in Node
|
||||
|
||||
Node `fetch` has **no cookie jar** and does not persist `Set-Cookie`. Capture every response with
|
||||
`res.headers.getSetCookie()` (Node ≥ 19.7; Node 24 here) and rebuild the header manually. Do **not**
|
||||
use `headers.get('set-cookie')` — multiple cookies are comma-joined and `Expires=Wed, 27 Oct …`
|
||||
contains commas.
|
||||
|
||||
```js
|
||||
const jar = new Map();
|
||||
|
||||
function absorb(res) {
|
||||
for (const raw of res.headers.getSetCookie()) {
|
||||
const [pair] = raw.split(';');
|
||||
const i = pair.indexOf('=');
|
||||
jar.set(pair.slice(0, i).trim(), pair.slice(i + 1).trim());
|
||||
}
|
||||
}
|
||||
|
||||
const cookieHeader = () =>
|
||||
[...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
|
||||
```
|
||||
|
||||
Apply `absorb` to **all three** QR responses (`start`, `scanResult`, `acceptResult`) — the session
|
||||
is expected on `acceptResult`, but absorbing everywhere is free and future-proof. Use
|
||||
`redirect: 'manual'` on `acceptResult` if you want to see a possible redirect's own `Set-Cookie`
|
||||
(`fetch` follows redirects by default and may drop intermediate headers).
|
||||
|
||||
Expected cookies in a successful jar **[INFERRED — cannot verify without scanning]**:
|
||||
|
||||
| Cookie | Role | Evidence level |
|
||||
|---|---|---|
|
||||
| `auth_key` | numeric user id; the site's "am I logged in?" check | name **VERIFIED** as read by site JS; set-at-login INFERRED |
|
||||
| `acPasstoken` | main session token | INFERRED (community docs; see [api doc §0.5](./acfun-api.md)) |
|
||||
| `_userid` | numeric user id | INFERRED |
|
||||
| `acPlatform` | `2` = PC web | INFERRED |
|
||||
| `_did` | device id, `Domain=.acfun.cn`, 1 y | **VERIFIED** (anonymous) |
|
||||
| `csrfToken` | host-only `www.acfun.cn` CSRF token | **VERIFIED** (anonymous) |
|
||||
| `ac_username`, `ac_userimg`, `userLevel`, `ac_time`, `checkMobile`, `checkEmail`, `online_status`, `userGroupLevel`, `uuid` | UI/profile hints | names **VERIFIED** as read by site JS (`core.*.js`, `user.*.js`); values INFERRED |
|
||||
|
||||
Note the ordering trap: `scan.acfun.cn` must be able to *set* `Domain=.acfun.cn` cookies for the
|
||||
`www.acfun.cn` calls later; if `acceptResult` ever returns `result:0` with **no** `Set-Cookie`,
|
||||
treat the login as failed rather than saving an empty jar.
|
||||
|
||||
Once confirmed, pass `cookieHeader()` verbatim as the plugin's `Cookie` header (this repo's
|
||||
`config.cookie` / `ACFUN_COOKIE`, `lib/providers` `setCookie()`).
|
||||
|
||||
---
|
||||
|
||||
## 4. CORS, origins, and required headers
|
||||
|
||||
**[VERIFIED]**
|
||||
|
||||
```http
|
||||
access-control-allow-origin: https://www.acfun.cn
|
||||
access-control-allow-credentials: true
|
||||
```
|
||||
|
||||
- The QR host only whitelists `https://www.acfun.cn` as a browser origin, and allows credentials.
|
||||
A browser page on another origin could not read these responses; **Node `fetch` ignores CORS**,
|
||||
so this is a non-issue for the plugin.
|
||||
- `Origin` request header is **not** required (verified: requests with no `Origin` succeed).
|
||||
- `Referer` is **not** required (verified: `start` with no Referer → `result:0`).
|
||||
- `User-Agent` is **not** required (verified: `curl/8.5.0`, `node`, and no UA at all).
|
||||
- `X-Requested-With` is **not** required (the site's own jQuery does not set it for these calls).
|
||||
- `withCredentials`/cookies are **not** required for `start`/`scanResult` (verified with an empty
|
||||
jar end-to-end, including the 120 s expiry path). Send `_did` if you have it, purely to look
|
||||
browser-like.
|
||||
- HTTPS only; no redirect chain observed. Reject any response whose `content-type` is not
|
||||
`application/json` (dead routes on `acfun.cn` answer 404 with an HTML SPA shell, and
|
||||
`scan.acfun.cn/` itself answers 500 HTML).
|
||||
|
||||
---
|
||||
|
||||
## 5. Anti-bot / rate-limit observations
|
||||
|
||||
**[VERIFIED]**
|
||||
|
||||
- No captcha, no `csrfToken`, no `sign`/`__`/`md5` parameter, no device attestation (`safety_id`)
|
||||
anywhere in the QR path. (The `stochastic` + `safety_id` machinery in the login bundle belongs to
|
||||
the **password** branch only.)
|
||||
- 15 consecutive `start` calls with ~0 ms spacing: `result` all `0`, **15 distinct tokens**, no
|
||||
`429`, no throttling. Practical guidance: one `start` per UI action; do not hammer.
|
||||
- **One poll per ticket.** A second simultaneous `scanResult` on the same ticket gets
|
||||
`result:10` immediately (verified), so a buggy client that fires overlapping polls will burn the
|
||||
ticket.
|
||||
- The QR dies **120 s after `start`** regardless of when you polled (verified with a 60 s-delayed
|
||||
poll).
|
||||
- `scan.acfun.cn` root path returns HTTP 500 HTML — only the `/rest/pc-direct/qr/*` routes are
|
||||
meaningful.
|
||||
|
||||
No evidence of Origin/Referer enforcement or IP binding of the ticket (a ticket created with **no**
|
||||
cookies was polled successfully with **no** cookies).
|
||||
|
||||
---
|
||||
|
||||
## 6. Fallback paths (and what NOT to use)
|
||||
|
||||
The web QR flow is **alive in 2026-10** — no fallback is required. Still, for completeness:
|
||||
|
||||
### 6.1 Password login (exists, NOT probed here)
|
||||
|
||||
From the same `login.*.js` bundle (route existence **[VERIFIED from site JS]**, behavior
|
||||
**[UNVERIFIED]** — no account was used, per the task rules):
|
||||
|
||||
```
|
||||
POST https://id.app.acfun.cn/rest/web/login/signin
|
||||
Content-Type: application/x-www-form-urlencoded; withCredentials
|
||||
|
||||
username=<phone/email>&password=<pw>&key=<captchaKey>&captcha=<4 chars>
|
||||
# sets a `stochastic` cookie (random btoa, path=/) *before* posting
|
||||
# result:0 -> success (Set-Cookie on id.app.acfun.cn/.acfun.cn); page fills avatar/username
|
||||
# result:100001016 / 100001018 -> captcha required / invalid
|
||||
GET https://id.app.acfun.cn/rest/web/login/captcha # -> {result:0, image, key}
|
||||
```
|
||||
|
||||
Captcha kicks in after >3 failures (`ac_login_error` cookie counter). Do not implement this without
|
||||
user consent; it is a different security class from scanning.
|
||||
|
||||
### 6.2 Manual cookie paste (already supported — recommended fallback)
|
||||
|
||||
`config.cookie` / `ACFUN_COOKIE` (see `README.md` §"Getting the cookie"): log in in a desktop
|
||||
browser → DevTools → Network → any `www.acfun.cn` document/`rest` request → copy the whole
|
||||
`Cookie` request header (contains at least `auth_key`, `acPasstoken`, `_did`, `_userid`,
|
||||
`acPlatform`, `csrfToken`; include everything). Paste as one line. This is the least-bad
|
||||
alternative if a future redeploy breaks the QR contract.
|
||||
|
||||
### 6.3 Dead ends — the Kuaishou `/rest/c/infra/ks/qr/*` paths
|
||||
|
||||
**[VERIFIED] not the AcFun login, do not chase them** (they exist only inside the embedded
|
||||
Kuaishou Passport SDK in `@ac/sdk-pay`, driven by `baseUrl/sid/qrType/kpn`):
|
||||
|
||||
```
|
||||
GET https://passport.kuaishou.com/rest/c/infra/ks/qr/start?sid=acfun -> 307 Location: https://passport.kuaishou.com/pc/account/login
|
||||
GET https://id.app.acfun.cn/rest/c/infra/ks/qr/start?sid=acfun -> 404 (Tomcat HTML)
|
||||
GET https://scan.acfun.cn/rest/c/infra/ks/qr/start?sid=acfun -> 500 (nginx error page)
|
||||
GET https://scan.acfun.cn/ -> 500
|
||||
```
|
||||
|
||||
`passport.kuaishou.com/pc/identity/qrcode` (from `@ks/identity-verification`) is the
|
||||
**captcha/identity-verification iframe**, not login. `id.app.acfun.cn/rest/web/*` is the
|
||||
password/captcha + midground-token host (see [api doc §7](./acfun-api.md)).
|
||||
|
||||
---
|
||||
|
||||
## 7. Minimal Node implementation sketch
|
||||
|
||||
```js
|
||||
// qr-flow.mjs — verified shape; 120 s TTL is absolute from start()
|
||||
const ORIGIN = 'https://scan.acfun.cn';
|
||||
const HEADERS = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
|
||||
'(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Referer': 'https://www.acfun.cn/login/',
|
||||
};
|
||||
|
||||
export class QrFlow {
|
||||
jar = new Map();
|
||||
token = ''; sig = ''; expiresAt = 0;
|
||||
|
||||
#absorb(res) { /* getSetCookie() → Map, see §3 */ }
|
||||
#cookie() { return [...this.jar].map(([k, v]) => `${k}=${v}`).join('; '); }
|
||||
|
||||
#get(path, query, signal) {
|
||||
const u = new URL(ORIGIN + path);
|
||||
for (const [k, v] of Object.entries(query)) u.searchParams.set(k, v);
|
||||
const headers = { ...HEADERS };
|
||||
if (this.jar.size) headers.Cookie = this.#cookie();
|
||||
return fetch(u, { headers, signal });
|
||||
}
|
||||
|
||||
async start() {
|
||||
const j = await (await this.#get('/rest/pc-direct/qr/start', { type: 'WEB_LOGIN' })).json();
|
||||
if (j.result !== 0) throw new Error(`QR start failed: ${j.result} ${j.error_msg ?? ''}`);
|
||||
this.token = j.qrLoginToken;
|
||||
this.sig = j.qrLoginSignature;
|
||||
this.expiresAt = Date.now() + (j.expireTime || 120_000);
|
||||
return {
|
||||
qrDataUrl: `data:image/png;base64,${j.imageData}`, // 125×125 PNG
|
||||
expiresAt: this.expiresAt,
|
||||
ticket: this.token,
|
||||
};
|
||||
}
|
||||
|
||||
// ONE call; long-polls until scan or ticket expiry. Do not re-call this ticket.
|
||||
async waitForScan(signal) {
|
||||
const t = AbortSignal.timeout(Math.max(5_000, this.expiresAt - Date.now() + 5_000));
|
||||
const r = await this.#get('/rest/pc-direct/qr/scanResult',
|
||||
{ qrLoginToken: this.token, qrLoginSignature: this.sig },
|
||||
signal ? AbortSignal.any([signal, t]) : t);
|
||||
this.#absorb(r);
|
||||
const j = await r.json();
|
||||
if (j.result === 0) {
|
||||
if (j.qrLoginSignature) this.sig = j.qrLoginSignature; // rotation (bundle-proven)
|
||||
return 'scanned';
|
||||
}
|
||||
if (j.result === 100400002) return 'expired';
|
||||
return `error:${j.result}:${j.error_msg ?? ''}`; // incl. 10 = already consumed
|
||||
}
|
||||
|
||||
// Call only after 'scanned', with the ROTATED signature.
|
||||
async accept(signal) {
|
||||
const t = AbortSignal.timeout(120_000);
|
||||
const r = await this.#get('/rest/pc-direct/qr/acceptResult',
|
||||
{ qrLoginToken: this.token, qrLoginSignature: this.sig },
|
||||
signal ? AbortSignal.any([signal, t]) : t);
|
||||
this.#absorb(r); // ← session Set-Cookie here
|
||||
const j = await r.json();
|
||||
if (j.result === 0) return { state: 'confirmed', cookie: this.#cookie() };
|
||||
if (j.result === 100400002) return { state: 'expired' };
|
||||
return { state: 'failed', code: j.result, message: j.error_msg };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Loop shape for a plugin/UI: `start()` → display `qrDataUrl` → **one** `waitForScan()` holds up to
|
||||
`expireTime`; on `scanned` → `accept()`; on `expired`/`failed`/timeout → surface and let the user
|
||||
press refresh (`start()` again). Never poll `scanResult` again after any terminal code.
|
||||
|
||||
---
|
||||
|
||||
## 8. Implementation gotchas (contract traps)
|
||||
|
||||
1. **`100400002` is terminal.** The server returns it when the 120 s long-poll ends with no scan
|
||||
(`"token expired"`). The site maps it to the *"二维码已失效 / 请点击刷新"* overlay. A client that
|
||||
treats it as "not scanned yet, keep waiting" will re-poll the dead ticket and get
|
||||
`{"result":10,"error_msg":"client error"}` in a tight loop until its own clock expires.
|
||||
*(As of 2026-10-27, `lib/qr-flow.js` in this repo maps `100400002` to `waiting`; live evidence
|
||||
says it is terminal. Keep that mapping only if you re-verify a waiting response — otherwise
|
||||
re-polling spins on `result:10`.)*
|
||||
2. **Exactly one poll per ticket.** A second concurrent `scanResult` returns `result:10`
|
||||
immediately (verified). Serialize the poll; don't retry the same ticket on network error —
|
||||
`start()` a new one.
|
||||
3. **Signature rotation.** `acceptResult` rejects the `start` signature with
|
||||
`{"result":21,"error_msg":"sign error"}` even for a fresh, valid ticket. Use the signature from
|
||||
the `scanResult` success body; the bundle does this explicitly.
|
||||
4. **Absolute TTL.** `expireTime:120000` counts from `start`, not from the poll. A poll opened at
|
||||
t+60 s is held only ~60 s.
|
||||
5. **GET only.** POST to `start`/`acceptResult` gets `result:21` / `100400000` even with a correct
|
||||
body.
|
||||
6. **`type` must be `WEB_LOGIN`** (or omitted). Anything else: `100400000 "type is illegal"`.
|
||||
7. **The QR is a base64 PNG**, not a URL/string: `data:image/png;base64,` + `imageData`
|
||||
(665 bytes, 125×125). No image host involved, nothing to download.
|
||||
8. **No cookies are needed to start**; don't block login on `_did` seeding. Still forward whatever
|
||||
jar you have, and capture `Set-Cookie` on all three responses.
|
||||
9. **`acceptResult` body carries no token** — do not look for one; the session is cookies.
|
||||
10. **Error responses are HTTP 200.** Branch on `result`, not status. Only transport failures,
|
||||
HTML bodies, and 5xx are exceptional.
|
||||
|
||||
---
|
||||
|
||||
## 9. VERIFIED / UNVERIFIED table
|
||||
|
||||
| # | Claim | Status | Evidence |
|
||||
|---|---|---|---|
|
||||
| 1 | QR service origin is `https://scan.acfun.cn` | **VERIFIED** | `login.*.js` strings + live 200s |
|
||||
| 2 | `GET /rest/pc-direct/qr/start?type=WEB_LOGIN` returns base64 PNG + token + signature + expireTime | **VERIFIED** | 200 `{"result":0,"next":"scanResult","expireTime":120000,…}` |
|
||||
| 3 | `imageData` is a 125×125 PNG (665 B decoded) | **VERIFIED** | PNG signature + IHDR read |
|
||||
| 4 | `start` needs no cookies, no Referer, no UA, no Origin | **VERIFIED** | 4 header variants → `result:0` |
|
||||
| 5 | `type` only `WEB_LOGIN` (or omitted) | **VERIFIED** | `APP_LOGIN`/`WEB`/`web_login` → `100400000 type is illegal` |
|
||||
| 6 | `start` is GET-only | **VERIFIED** | POST → `100400000 type is illegal` |
|
||||
| 7 | `scanResult` long-polls and expires at 120 s from `start` | **VERIFIED** | holds 120.489 s, 120.788 s; 60 s-late poll held 60.52 s |
|
||||
| 8 | Expiry body is `{"result":100400002,"error_msg":"token expired"}` | **VERIFIED** | probes 05/10/14 |
|
||||
| 9 | Second poll / re-poll → `{"result":10,"error_msg":"client error"}` | **VERIFIED** | probe 10 (699 ms), probe 05 (62 ms) |
|
||||
| 10 | Wrong signature → `{"result":21,"error_msg":"sign error"}` | **VERIFIED** | probes 04/07/10 |
|
||||
| 11 | Missing params → `{"result":11,"error_msg":"网络错误,请稍后再试"}` | **VERIFIED** | probes 07/08 |
|
||||
| 12 | `acceptResult` with unscanned ticket → `21 sign error` (even with start sig) | **VERIFIED** | probe 10 |
|
||||
| 13 | Signature rotates for `acceptResult` | **VERIFIED (bundle)** / body **[UNVERIFIED]** | `checkaccept(t, a.qrLoginSignature)` in `login.*.js` |
|
||||
| 14 | `scanResult` success code is `0` (scanned) | **[UNVERIFIED]** (bundle only) | `switch(a.result){case 0: status-success…}` |
|
||||
| 15 | Session arrives as `Set-Cookie` on `acceptResult`, not JSON | **[UNVERIFIED]** (strong inference) | redirect with no token; `withCredentials`; ACAC:true; no token read by bundle |
|
||||
| 16 | Cookie names `acPasstoken`,`_userid`,`acPlatform` are set at login | **[UNVERIFIED]** | community docs; front-end proves `auth_key`/`ac_username`/`ac_userimg`/… |
|
||||
| 17 | Front-end reads `auth_key`,`ac_username`,`ac_userimg`,`userLevel`,`ac_time`,`checkMobile`,`_did` | **VERIFIED** | `core.*.js`, `user.*.js` |
|
||||
| 18 | Phone-side authorization is a separate step after scan | **VERIFIED (UI+bundle)** | "扫码成功!请在手机上进行授权操作" + separate `acceptResult` |
|
||||
| 19 | No captcha / csrf / signed params in the QR path | **VERIFIED** | all QR probes anonymous, no extra params |
|
||||
| 20 | 15 rapid `start`s: no rate limit, unique tokens | **VERIFIED** | probe 07 |
|
||||
| 21 | `passport.kuaishou.com`/`/rest/c/infra/ks/qr/*` is NOT the AcFun login | **VERIFIED** | 307 / 404 / 500 probes |
|
||||
| 22 | Password login route `id.app.acfun.cn/rest/web/login/signin` exists | **VERIFIED (bundle)** / behavior **[UNVERIFIED]** | login bundle; not called |
|
||||
| 23 | `acceptResult` success body / cookie attribute list | **[UNVERIFIED]** | requires a real phone scan |
|
||||
|
||||
---
|
||||
|
||||
## 10. Re-verifying later
|
||||
|
||||
- Login page: <https://www.acfun.cn/login/> — grep the served HTML for
|
||||
`login/widget/login/login.` and fetch that bundle; it holds the three absolute endpoint URLs.
|
||||
If the host/paths change, they change there first.
|
||||
- Sanity check that the flow is alive without scanning: one `start` must return `result:0` with a
|
||||
fresh 892-ish-char `imageData`, and one `scanResult` must still be held for ~120 s before
|
||||
`100400002`. If `start` ever returns `100400000`/`11`/HTML, the contract moved — fall back to
|
||||
cookie paste (§6.2) and update this file.
|
||||
- Probe scripts used for this document: `%TEMP%\ac-qr\01…14-*.mjs` (kept out of the repo).
|
||||
@@ -0,0 +1,600 @@
|
||||
# AcFun 广场 / 动态 (moment feed) — reverse-engineered for a Node.js `fetch` plugin
|
||||
|
||||
All probes were executed live on **2026-10-27 (Asia/Shanghai)** against `acfun.cn` hosts
|
||||
(`www.acfun.cn`, `m.acfun.cn`, `api-ipv6.acfunchina.com`, `kuaishouzt.com`) using a desktop Chrome
|
||||
User-Agent and `Referer: https://www.acfun.cn/` (or `https://m.acfun.cn/` for mobile hosts).
|
||||
No AcFun account was used. Every claim below is marked **[VERIFIED]** (called live, real
|
||||
status/JSON/payload pasted) or **[UNVERIFIED]** (contract read out of the site's own JS, or a write
|
||||
that could only be probed up to its auth error). **No write was ever executed.**
|
||||
|
||||
Probe scripts live in `%TEMP%\acfun-moment-probe\` (`p1…p33*.mjs`) and are not part of the repo.
|
||||
|
||||
---
|
||||
|
||||
## 0. Verdict first (read this if nothing else)
|
||||
|
||||
| Question | Answer |
|
||||
|---|---|
|
||||
| Is there an official **web** 广场/动态 feed? | **No.** `https://www.acfun.cn/moment/` → `302 → /404`. The www nav contains no 动态/广场 entry at all. `/member/feeds` exists but `302`s anonymous visitors to the login page. There is **no moment bundle** in the www app (40 bundles / 50 `/rest/` paths scanned; only one internal bundle mentions `moment`). |
|
||||
| Does a **usable moment read API** exist? | **Yes — one.** `GET /rest/pc-direct/moment/detail?momentId={id}` returns a full moment object **anonymously**. Moment comments work anonymously too (`comment/listByFloor` with `sourceType=4`). |
|
||||
| Is there a public "square"/recommend **list** API? | **Not found.** ~560 guessed paths across `/rest/pc-direct/*`, `/rest/mobile-direct/*`, `/rest/app/*`, `/rest/zt/*` and five hosts. The only moment *lists* are (a) **login-scoped** following feeds (`feed/followFeedV2`, `feed/followDougaFeed`, `feed/webPush`) and (b) the **mobile-web topic page HTML**, which server-renders a fixed 15 items with no pagination. |
|
||||
| Can a plugin post a 动态? | **No.** Publish is app-only: `POST https://api-ipv6.acfunchina.com/rest/app/moment/add` → `401 {"result":-401,"error_msg":"token value error"}`. Requires the signed `acfun.midground.api_st` token *(see §5)*, i.e. an AcFun **app** session, not a web cookie. |
|
||||
| What is the real 2026 surface? | **社区圈 / 话题 (`communityCircle`)** on mobile web: `https://m.acfun.cn/communityCircle/{tagId}` is alive with freshly-dated content (observed `2026-10-13`), a topic index (e.g. 教育 = `38关注 | 10458内容`), and moment ids in a **new 7-digit space** (e.g. `5093831`). |
|
||||
| Does the community build its own web 广场? | Yes — "[【油猴脚本】网页端动态广场!](https://www.acfun.cn/a/ac48817170)" (2026-09-01) plus Greasy Fork scripts `579890` / `584032`. That is direct evidence there is an API but **no official web UI**. (Greasy Fork was unreachable from this network, so their exact endpoint list could not be read; the endpoints below were derived from AcFun's own bundles and verified live.) |
|
||||
|
||||
**Bottom line:** a plugin can be a **moment reader + comment reader** today, with no cookie at all,
|
||||
plus a cookie-gated "following feed" reader. Every write except maybe like/banana/comment is
|
||||
effectively impossible.
|
||||
|
||||
---
|
||||
|
||||
## 1. Hosts and URL prefixes (the one thing that trips everyone up)
|
||||
|
||||
| Purpose | Host / prefix |
|
||||
|---|---|
|
||||
| PC web JSON BFF | `https://www.acfun.cn/rest/pc-direct/...` |
|
||||
| PC web **SSR-internal** BFF | `https://www.acfun.cn/rest/pc-node/...` — **[VERIFIED]** every call → `502`; not publicly routed |
|
||||
| Mobile web JSON BFF | `https://m.acfun.cn/rest/mobile-direct/...` — **[VERIFIED]** `502` on `www.acfun.cn`, JSON on `m.acfun.cn` (**host-bound**) |
|
||||
| Mobile web pages | `https://m.acfun.cn/communityCircle/...` |
|
||||
| App API | `https://api-ipv6.acfunchina.com/rest/app/...` |
|
||||
| App API (IPv6-less siblings) | `https://api.acfun.cn` → `502` **[VERIFIED]**; `app.acfun.cn` / `ias.acfun.cn` / `zt.acfun.cn` → DNS/connect failure |
|
||||
| Like / interact service | `https://kuaishouzt.com/rest/zt/...`, `https://api.kuaishouzt.com/rest/zt/...` |
|
||||
| Auth token service | `https://id.app.acfun.cn/rest/web/token/get` |
|
||||
| Captcha / QR | `https://www.acfun.cn/rest/c/infra/captcha/{get,verify}`, `/rest/c/infra/ks/qr/*` (from bundles) |
|
||||
|
||||
**[VERIFIED]** The PC bundle states the prefix rule verbatim — this is why `pc-node` exists:
|
||||
|
||||
```js
|
||||
function p(){return "server"===e.env.VUE_ENV||a.a.prototype.$isServer ? "/rest/pc-node" : "/rest/pc-direct"}
|
||||
```
|
||||
|
||||
A Node plugin is **not** the AcFun SSR server, so it must use `/rest/pc-direct` (or `/rest/mobile-direct`
|
||||
on `m.acfun.cn`, or `/rest/app` on the app host). `/rest/pc-node/*` is unusable from outside.
|
||||
|
||||
**Recommended headers** (what the mobile pages answer best to — see §3.4 for the `Accept` quirk):
|
||||
|
||||
```http
|
||||
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36
|
||||
Referer: https://www.acfun.cn/ (or https://m.acfun.cn/ for mobile hosts)
|
||||
Accept: */*
|
||||
Content-Type: application/x-www-form-urlencoded; charset=UTF-8 # for POSTs
|
||||
```
|
||||
|
||||
No `sign`/`__`/`md5` parameter is needed on any read verified here. Writes need the midground token (§5).
|
||||
|
||||
---
|
||||
|
||||
## 2. Feature map — what actually exists in 2026
|
||||
|
||||
### 2.1 Page routes (all fetched live)
|
||||
|
||||
| URL | Result | Verdict |
|
||||
|---|---|---|
|
||||
| `https://www.acfun.cn/moment/` | `302 → /404` (52-byte body) | **dead** |
|
||||
| `https://www.acfun.cn/moment` | `302 → /404` | **dead** |
|
||||
| `https://www.acfun.cn/moment/am:1` | `302 → /404` | **dead** (the `/moment/am:momentId` route string still sits in an old bundle) |
|
||||
| `https://www.acfun.cn/moment/square` | `302 → /404` | **dead** |
|
||||
| `https://www.acfun.cn/square`, `/community`, `/discovery`, `/dynamic`, `/feed`, `/am`, `/index/moment`, `/square/` | `502` + 18 366-byte "出错啦!" page | **nonexistent** (`502` is AcFun's unknown-route response on www) |
|
||||
| `https://www.acfun.cn/member/feeds`, `/member/` | `302 //www.acfun.cn?returnUrl=https%3A%2F%2Fwww.acfun.cn%2Fmember` | exists, **login-gated** (个人动态中心) |
|
||||
| `https://www.acfun.cn/u/1` | `200`, 73 523 B | user space — tabs are 投稿/收藏/关注/粉丝; **no 动态 tab**, zero occurrences of `moment`/`动态` |
|
||||
| `https://www.acfun.cn/` | `200`, 155 119 B, 8 bigPipe pagelets (`pagelet_player_info, pagelet_navigation, pagelet_live, pagelet_spring_festival, pagelet_monkey_recommend, pagelet_top_area, pagelet_banner, pagelet_list_banana`) | nav = 首页 / 直播 / 番剧 / video channels / 排行榜 — **no 广场, no 动态** |
|
||||
| `https://m.acfun.cn/` | `302 → http://www.acfun.cn` | mobile home redirects to www |
|
||||
| `https://m.acfun.cn/communityCircle` | `200` (31–40 KB shell) | 社区圈 index, **client-rendered** (no tags in HTML) |
|
||||
| `https://m.acfun.cn/communityCircle/square` | `200` shell | 话题广场, **client-rendered, no tags in HTML** |
|
||||
| `https://m.acfun.cn/communityCircle/tops`, `/tag` | `200` shell | same |
|
||||
| `https://m.acfun.cn/communityCircle/{tagId}` | `200`, **144 KB, 15 moment items SSR'd** | **the working 广场 read surface** (§3.4) |
|
||||
| `https://m.acfun.cn/communityCircle/moment/{momentId}` | `200`, `<title>Acfun 动态详情</title>`, contains `.moment-text`, `.moment-img-box`, `#comment` | **canonical human-readable moment page** |
|
||||
| `https://m.acfun.cn/moment/200` | 404 page (HTTP 200) | wrong path |
|
||||
|
||||
### 2.2 JS bundle evidence (www, 40 bundles / 2.7 MB scanned)
|
||||
|
||||
Only **one** www bundle mentions `moment`: `https://ali-imgs.acfun.cn/kos/nlav10360/static/js/app.11fc9e51.js`
|
||||
(the personal-center/header SPA). It contains the complete moment *store*, the feed *list* actions, and
|
||||
the interaction *actions* — but **no publish action and no moment page component**. Extracted facts:
|
||||
|
||||
```js
|
||||
// moment store normalisation — THE key mapping
|
||||
n.resourceType = 10; // moments are resourceType 10
|
||||
n.resourceId = n.momentId;
|
||||
|
||||
// moment detail
|
||||
GetMomentDetail: function(e,t){
|
||||
return a.a.get(Object(a.b)(e.rootState, Object(a.e)() + "/moment/detail?momentId=" + t), …)
|
||||
.then(function(t){ … if(0===n.result) e.commit(f.CHANGE_MOMENT_DETAIL,{moment:n.moment, repostSource:n.repostSource}) })
|
||||
}
|
||||
|
||||
// following feed list
|
||||
GetFeedsFlow: function(e,t){ var n=t.pcursor||0, o=t.pageSize||20, c=t.resourceTypes||0;
|
||||
… a.a.get(prefix + "/feed/followFeedV2?pcursor="+n+"&count="+o+"&resourceTypes="+c) …
|
||||
// resourceTypes === 0 -> ChangeFeedsFlow (all), else -> ChangeFeedsArticleFlow (articles only)
|
||||
}
|
||||
|
||||
// like / unlike
|
||||
var h = "like"===u ? "/rest/zt/interact/add" : "/rest/zt/interact/delete";
|
||||
a.a.post("//"+ztDomain+h, qs.stringify({
|
||||
objectId:n, objectType:o, userId:d,
|
||||
"acfun.midground.api_st":s, kpn:"ACFUN_APP", kpf:"PC_WEB", subBiz:"mainApp", interactType:1
|
||||
}), {headers:{"Content-Type":"application/x-www-form-urlencoded"}})
|
||||
// success is checked as: 1 === t.data.result
|
||||
|
||||
// banana on a moment
|
||||
FeedThrowBanana: function(e,t){ var n=t.resourceId,r=t.resourceType,o=t.count;
|
||||
a.a.post(prefix + "/banana/throwBanana", qs.stringify({resourceId:n, resourceType:r, count:o}), …)
|
||||
// success is checked as: 0 === n.data.result
|
||||
}
|
||||
|
||||
// other endpoints present in the same bundle
|
||||
"/feed/followFeedV2?pcursor=0&count=5&resourceTypes=0"
|
||||
"/feed/followDougaFeed?pcursor="
|
||||
"/feed/webPush?count=50&pcursor=0"
|
||||
"/feed/favorite/bangumi?count=10"
|
||||
```
|
||||
|
||||
Routes in that bundle: `/member/feeds`, `/member/feeds/fans`, `/member/feeds/following`,
|
||||
`/moment/am:momentId` (legacy string), `/moments/...` none. No publish/edit/delete anywhere.
|
||||
|
||||
---
|
||||
|
||||
## 3. Read contracts (each one verified live)
|
||||
|
||||
### 3.1 Moment detail — **the one reliable read** **[VERIFIED]**
|
||||
|
||||
```
|
||||
GET https://www.acfun.cn/rest/pc-direct/moment/detail?momentId={momentId}
|
||||
```
|
||||
|
||||
- Method/params: `GET`, single query param `momentId` (numeric). **Anonymous works.** No cookie needed.
|
||||
- Also served identically by `https://api-ipv6.acfunchina.com/rest/app/moment/detail?momentId=…` and
|
||||
`https://m.acfun.cn/rest/mobile-direct/moment/detail?momentId=…` **[VERIFIED]** (all three → `result:0`).
|
||||
|
||||
Live, trimmed (`momentId=5093831`, current-era content):
|
||||
|
||||
```json
|
||||
{"result":0,"host-name":"hb2-acfun-kce-node24.aliyun","requestId":"eac1ac4e8c4e4fbd8d82a662927e7cc2",
|
||||
"moment":{
|
||||
"momentId":"5093831","momentType":2,"createTime":"2026-09-23",
|
||||
"text":"#教育##父亲##贷款#","replaceUbbText":"#教育##父亲##贷款#",
|
||||
"user":{"id":"23240229","name":"红格男(RSM)",
|
||||
"headUrl":"https://tx-free-imgs.acfun.cn/newUpload/23240229_cfb0100bc9a64c0aa96d8…"},
|
||||
"imgs":[{"w":1080,"h":2554,"type":4,"originUrl":"https://tx-free-imgs.acfun.cn/newUpload/23240229_44bf454add3…"}],
|
||||
"likeCount":13,"commentCount":6,"bananaCount":5,"shareCount":0,
|
||||
"isLike":false,"isThrowBanana":false,"visibleForFans":false,"originResourceType":0,
|
||||
"tagList":[{"tagId":40887,"tagName":"贷款"},{"tagId":488,"tagName":"教育"},{"tagId":1968,"tagName":"父亲"}],
|
||||
"shareUrl":"https://m.acfun.cn/communityCircle/moment/5093831"}}
|
||||
```
|
||||
|
||||
Top-level keys: `result, host-name, requestId, moment` — plus `repostSource` **only when the moment is a
|
||||
repost** (then counts/author live under `repostSource`, per the store mutation).
|
||||
|
||||
#### Moment id space — two eras **[VERIFIED]**
|
||||
|
||||
`moment/detail` distinguishes "not found" reasons, which lets you map the id space:
|
||||
|
||||
| `momentId` | `result` | `error_msg` |
|
||||
|---|---|---|
|
||||
| `1`, `100` | `140005` | `动态不存在或者已被删除` (in-range, deleted) |
|
||||
| `10000`, `1000000`, `10000000`, `100000000`, `48910450`, `48896959` | `140002` | `内容不存在` (out of range) |
|
||||
| `200`, `202`, `300`, `5093831`, `5093817` | **`0`** | real moments |
|
||||
|
||||
Binary search pinned the boundary: ids **≤ 7525** are "in range" (`140005`), ids **≥ 7526** are
|
||||
`140002`. That range is the **legacy 2019 动态 system**: every live id found in `1..1200` is dated
|
||||
`2019-05-23 … 2019-06-14`, user set is the 2019 launch crowd (七里香004, soso15, ACer第一次耳机 …),
|
||||
`momentType` 1/2, text like "樱花" / "搞笑图片". The **current** system uses **7-digit ids** (`5093831`
|
||||
→ dated `2026-09-23`, tag list with 话题 ids) and is reachable **only** by knowing the id (from a topic
|
||||
page) — there is no enumerable list here.
|
||||
|
||||
So: `getMoment(id)` works for both eras, but ids cannot be discovered by the API alone.
|
||||
|
||||
### 3.2 Moment comments — **[VERIFIED]**
|
||||
|
||||
```
|
||||
GET https://www.acfun.cn/rest/pc-direct/comment/listByFloor
|
||||
?sourceId={momentId}&sourceType=4&page=1&supportZtEmot=true
|
||||
```
|
||||
|
||||
**`sourceType = 4` identifies a moment.** Proof from a live matrix on `sourceId=202` (a moment whose
|
||||
`commentCount` is 2):
|
||||
|
||||
| `sourceType` | HTTP | payload |
|
||||
|---|---|---|
|
||||
| **4** | 200 | `{"result":0,"curPage":1,"isUp":false,"sourceType":4,"totalPage":1,"totalCount":2, "commentsMap":{…},"commentIds":[…],"pageSize":50,…}` — **2 real comments** |
|
||||
| 3 | 200 | `{"result":0,"sourceType":3,"totalPage":0,"totalCount":0,"commentsMap":{}}` (video type — empty) |
|
||||
| 2 | 200 | `{"result":0,"sourceType":2,"totalPage":0,"totalCount":0}` (valid but unrelated) |
|
||||
| 10 | 200 | `{"result":21,"error_msg":"参数格式错误,请仔细阅读API文档。"}` (invalid) |
|
||||
|
||||
Comment object keys **[VERIFIED]**:
|
||||
`imageInfo, userHeadImgInfo, avatarFrameImgInfo, imageUrl, isDelete, quoteId, nameRed, nameColor, headUrl,
|
||||
verifiedTypes, deviceModel, isSignedUpCollege, avatarFrame, avatarImage, sourceId, cid, isUpDelete, floor,
|
||||
postDate, sourceType, userName, userId, isUp, verified, content, timestamp`
|
||||
(e.g. `{"userName":"soso15","userId":"13360169","content":"666666"}`).
|
||||
|
||||
Note the **numbering split**, this is the classic trap: comments use `sourceType 4`, while
|
||||
like/banana use `resourceType 10` (§4). They are different enums for the same object.
|
||||
|
||||
`/rest/mobile-direct/comment/list` also exists on `m.acfun.cn` (`result:21` without params → route alive)
|
||||
but its param names (`resourceId`/`resourceType`) were rejected in probing — **[UNVERIFIED]**; use the
|
||||
`pc-direct` `listByFloor` form above, which is fully verified.
|
||||
|
||||
### 3.3 Following feeds (the "动态流") — route **[VERIFIED]**, data login-scoped
|
||||
|
||||
```
|
||||
GET https://www.acfun.cn/rest/pc-direct/feed/followFeedV2?pcursor=0&count=20&resourceTypes=0
|
||||
GET https://www.acfun.cn/rest/pc-direct/feed/followDougaFeed?pcursor=0&count=20
|
||||
GET https://www.acfun.cn/rest/pc-direct/feed/webPush?count=50&pcursor=0
|
||||
GET https://www.acfun.cn/rest/pc-direct/feed/favorite/bangumi?count=10
|
||||
```
|
||||
|
||||
Anonymous live results — **note these are `200`, not auth errors**, because the routes are
|
||||
guest-tolerant and simply have nothing to show a guest with `followingCount: 0`:
|
||||
|
||||
```json
|
||||
// followFeedV2
|
||||
{"result":0,"feedList":[],"pullCount":0,"userInfo":{"followingCount":0},
|
||||
"requestId":"ea7f66cd511f4926bf9e25c55435415a","followTags":[],"pcursor":"no_more","ups":[],"upsPos":0}
|
||||
// followDougaFeed
|
||||
{"result":0,"feedList":[],"userInfo":{"followingCount":0},"followUpers":[],"pcursor":"no_more","pageSize":10,"favBangumis":[]}
|
||||
// webPush
|
||||
{"result":0,"feedList":[],"followUpers":[],"pcursor":"no_more"}
|
||||
// favorite/bangumi -> AUTH ERROR
|
||||
{"result":-401,"error_msg":"user not login"}
|
||||
```
|
||||
|
||||
So `followFeedV2` is the **right endpoint for "关注动态"** and it *does* carry moments in `feedList`
|
||||
(the store action names them `feeds`, and the item schema is the same `resourceType 10` object), but
|
||||
its content is entirely a function of the logged-in user's follows. **It is useless anonymously and
|
||||
it is not a 广场.**
|
||||
|
||||
Params: `pcursor` (opaque cursor; `"no_more"` = end), `count` (default 20 in JS), `resourceTypes`
|
||||
(`0` = all → `ChangeFeedsFlow`; non-zero → article flow, per the bundle's ternary).
|
||||
|
||||
The app API mirrors these **[VERIFIED]**: `/rest/app/feed/followFeed` → `200`
|
||||
`{"result":0,"feedList":[],"pageNo":1,"totalPage":0,"pageSize":10}`, `/rest/app/feed/followFeedV2`
|
||||
→ `200` same shape, `/rest/app/feed/followDougaFeed` → `result:11`.
|
||||
|
||||
### 3.4 Topic page (社区圈) — the only public "广场" read, **HTML only** **[VERIFIED]**
|
||||
|
||||
```
|
||||
GET https://m.acfun.cn/communityCircle/{tagId}
|
||||
```
|
||||
|
||||
Live (`tagId=488`, 教育): `200`, **144 110 bytes**, title `Acfun【教育】话题,快来参与`,
|
||||
topic header `教育` + `38关注 | 10458内容`, and **15 server-rendered moment items**, newest dated
|
||||
`2026-10-13` — i.e. this subsystem is genuinely in use today.
|
||||
|
||||
Item markup (stable, scrape-able):
|
||||
|
||||
```html
|
||||
<li class="resources-item ks-1px-b type-1"
|
||||
moment-id="" resource-type="1" resource-id="48910450" data-repost="false">
|
||||
<div class="resources-user">…<p class="user-name">c137牛奶娘</p><p class="release-time">2026-10-13</p>…</div>
|
||||
<div class="resources-article"><p class="article-title">…</p><p class="article-body">…</p></div>
|
||||
<div class="resources-tag pics-0"><a class="tag-item" href="/communityCircle/488">#教育</a>…</div>
|
||||
<div class="resources-base">…<span class="base-text">228</span>…</div>
|
||||
</li>
|
||||
```
|
||||
|
||||
Observed `resource-type` values and their `moment-id`:
|
||||
|
||||
| `resource-type` | `moment-id` | meaning |
|
||||
|---|---|---|
|
||||
| `1` | `""` (empty) | 图文动态/article-style post; only `resource-id` (e.g. `48910450`) — **not** a moment id |
|
||||
| `2` | `""` | video-style post; only `resource-id` |
|
||||
| `3` | **`5093831`, `5093817`** | **real moment** — this is how you harvest current moment ids |
|
||||
|
||||
⚠️ **The `Accept` header flips the renderer** — this cost me a lot of time, so it is worth stating
|
||||
precisely. Same URL, two outcomes:
|
||||
|
||||
| Request headers | Length | Items |
|
||||
|---|---|---|
|
||||
| default `Accept` (or none, or `Referer` www vs m — irrelevant) | 144 110 | **15** ✅ |
|
||||
| `Accept: text/html,application/xhtml+xml…` | 31 628 | **0** (JS shell) |
|
||||
| `User-Agent: …Googlebot…` | 41 722 | 15 (different shell) |
|
||||
|
||||
So a Node `fetch` must send **default/`*/*` Accept** to get the SSR'd list, and must **not** pretend to be a
|
||||
browser navigation.
|
||||
|
||||
⚠️ **No pagination.** `?page=2`, `?page=3`, `?sort=reply`, `?sort=post` all return the *identical* 15
|
||||
items (byte-identical length, same first `resource-id=48910450`). The `热门排序 / 回复时间 / 发帖时间`
|
||||
filter and the infinite scroll are client-side-only and their data source is **not** reachable:
|
||||
`/communityCircle/square`, `/tops`, `/tag`, `/communityCircle` are all empty shells with **zero** tag
|
||||
entries or `/rest/` URLs in the HTML.
|
||||
|
||||
### 3.5 `feed/hot` — **[VERIFIED]** but it is **videos, not moments**
|
||||
|
||||
```
|
||||
GET https://www.acfun.cn/rest/pc-direct/feed/hot (also /rest/mobile-direct/feed/hot, /rest/app/feed/hot)
|
||||
```
|
||||
|
||||
`200` `{"result":0,"feeds":[…], "host-name":…, "requestId":…}` — 9–14 items, **every one `type: 2`**
|
||||
(douga video), with `dougaId`/`videoId`/`caption`/`commentCount`/`bananaCount` etc. It is the hot
|
||||
**video** module. `count`, `resourceTypes`, `type`, `pcursor` are **all ignored** (same 9–14 items each
|
||||
time, no `pcursor` in the response). Do not mistake it for a 广场 moment feed — but it is a fine
|
||||
anonymous "hot feed" and the item schema is the same feed-item envelope that moments use.
|
||||
|
||||
---
|
||||
|
||||
## 4. Interaction endpoints (contracts from the site's own JS; writes only probed to their auth error)
|
||||
|
||||
> I have no account, so each write below was attempted **unauthenticated only** to prove the route and
|
||||
> observe the auth tier. **No like/banana/comment/follow/post was ever actually performed.**
|
||||
|
||||
### 4.1 Like / unlike a moment **[UNVERIFIED — route + auth tier verified]**
|
||||
|
||||
```
|
||||
POST https://kuaishouzt.com/rest/zt/interact/add # like
|
||||
POST https://kuaishouzt.com/rest/zt/interact/delete # unlike
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
objectId={momentId}&objectType=10&userId={authorUserId}
|
||||
&acfun.midground.api_st={token}&kpn=ACFUN_APP&kpf=PC_WEB&subBiz=mainApp&interactType=1
|
||||
```
|
||||
|
||||
- `objectType` = the moment's `resourceType` = **10** (from the store's `n.resourceType=10`).
|
||||
- Success is `{"result":1}` (note: **1**, not 0 — this endpoint's envelope differs from `pc-direct`).
|
||||
- Auth tier: **signed midground token**. **[VERIFIED]** unauthenticated call →
|
||||
`401 {"result":-401,"error_msg":"token value error","error_id":""}`.
|
||||
(Note `/rest/zt/interact/delete` is the *unlike* route — it is not "delete a moment".)
|
||||
|
||||
### 4.2 投蕉 (banana) on a moment **[UNVERIFIED — route + auth tier verified]**
|
||||
|
||||
```
|
||||
POST https://www.acfun.cn/rest/pc-direct/banana/throwBanana
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
resourceId={momentId}&resourceType=10&count={1..5}
|
||||
```
|
||||
|
||||
**[VERIFIED]** unauthenticated → `401 {"result":-401,"error_msg":"user not login"}` (same for
|
||||
`resourceType=1`), i.e. route exists, **plain session cookie** tier. Success would be `result:0`.
|
||||
|
||||
### 4.3 Comment / reply on a moment **[UNVERIFIED]**
|
||||
|
||||
```
|
||||
POST https://www.acfun.cn/rest/pc-direct/comment/add
|
||||
objectId={momentId}&content={ubb text}&replyToCommentId={0|commentId}
|
||||
```
|
||||
|
||||
Inferred from the verified comment *list* pairing (`sourceId`+`sourceType=4`) and the existing
|
||||
video-comment contract in [`docs/acfun-api.md`](acfun-api.md) (§3), which documents `comment/add` taking
|
||||
`objectId`. Whether a moment post also needs `sourceType=4` is **[UNVERIFIED]** — the mobile comment
|
||||
widget constructs itself with `initCommentWidget({resourceId: momentId, resourceType: 4, …})`
|
||||
(from `pages/momentDetail/index.*.js`), so send `sourceType=4` as well to be safe.
|
||||
Auth tier: **plain session cookie** (`user not login`). Mobile equivalent that exists but whose params I
|
||||
could not pin down: `POST https://m.acfun.cn/rest/mobile-direct/comment/add` → `result:11` **[VERIFIED route]**.
|
||||
|
||||
### 4.4 Follow the moment author **[UNVERIFIED]**
|
||||
|
||||
```
|
||||
POST https://www.acfun.cn/rest/pc-direct/relation/follow # {targetUid} / {herdId}
|
||||
```
|
||||
(`/rest/pc-direct/relation/follow` and `/rest/pc-direct/relation/getGroups` are both present in the www
|
||||
bundles.) Cookie tier.
|
||||
|
||||
### 4.5 Vote (投票动态) **[UNVERIFIED — route + auth tier verified]**
|
||||
|
||||
```
|
||||
POST https://api-ipv6.acfunchina.com/rest/app/moment/vote
|
||||
```
|
||||
**[VERIFIED]** `401 {"result":-401,"error_msg":"token value error"}` → app token tier. The moment store
|
||||
has an `UpdateFeedVote` mutation and the mobile page reads `vote__radio` DOM, so 投票 moments exist as
|
||||
`momentType` variants; the web cannot drive them.
|
||||
|
||||
### 4.6 Repost / 转发 **[UNVERIFIED — no route found]**
|
||||
|
||||
The store has `updateFeedRepostFollow` / `updateFeedRepostFavorite` mutations and renders
|
||||
`repostSource`, so reposts exist and **can be read** (a repost's payload has a `repostSource` sibling).
|
||||
But **no repost/forward endpoint was found**: `/rest/pc-direct/moment/{repost,forward,share}` → `404`,
|
||||
and `/rest/app/moment/repost` → `404`. Most likely reposting = `moment/add` with an origin field
|
||||
(app-only). Treat as impossible for a plugin.
|
||||
|
||||
### 4.7 Publish / delete a 动态 **[UNVERIFIED — app-only, token-gated]**
|
||||
|
||||
```
|
||||
POST https://api-ipv6.acfunchina.com/rest/app/moment/add # publish
|
||||
POST https://api-ipv6.acfunchina.com/rest/app/moment/delete # delete
|
||||
```
|
||||
|
||||
**[VERIFIED]** `POST moment/add` (any body: `content=test`, `momentId=1`, or empty) →
|
||||
`401 {"result":-401,"error_msg":"token value error"}` — the handler ran, so the route is real; the
|
||||
`GET` form returns `result:11` (`网络错误,请稍后再试`). `moment/delete` returns `result:11` on GET
|
||||
(route alive). **Every** `/rest/pc-direct/moment/{add,create,publish,post,delete,remove,repost,forward,
|
||||
share,like,unlike,vote,update,edit}` returns **HTTP 404 on www** — there is no web publish path at all,
|
||||
which is exactly why the community wrote a userscript instead of using the site.
|
||||
|
||||
Image upload for a moment would go through `/rest/pc-direct/image/upload/getToken` +
|
||||
`getUrlAfterUpload` (present in bundles) — **[UNVERIFIED]**, and pointless without a publish route.
|
||||
|
||||
---
|
||||
|
||||
## 5. Auth tiers, signing, captcha
|
||||
|
||||
| Tier | What it needs | Evidence seen |
|
||||
|---|---|---|
|
||||
| **Anonymous** | nothing | `moment/detail`, `comment/listByFloor`, `feed/followFeedV2`, `feed/hot`, `m.acfun.cn/communityCircle/*` all `200` |
|
||||
| **Session cookie** | `auth_key` / `_did` / `csrfToken` cookies from a real login | `401 {"result":-401,"error_msg":"user not login"}` for `feed/favorite/bangumi`, `banana/throwBanana` |
|
||||
| **Signed midground token** | `acfun.midground.api_st`, obtained from `https://id.app.acfun.cn/rest/web/token/get` with a **logged-in app/web session**; sent as a form field | `401 {"result":-401,"error_msg":"token value error"}` for `kuaishouzt/rest/zt/interact/add`, `/rest/app/moment/add`, `/rest/app/moment/delete`, `/rest/app/moment/vote` |
|
||||
|
||||
- **No HMAC/md5 request signing** was observed on any read, nor on `interact/add` (it sends plain form
|
||||
fields plus the token). The "signature" is effectively just the midground token.
|
||||
- **Anonymous visit cookies** are only `csrfToken` and `_did` — no session.
|
||||
- **Captcha / identity**: `/rest/c/infra/captcha/{get,verify}` and `/rest/c/infra/ks/qr/{start,scanResult,
|
||||
acceptResult}` exist for login/QR. Both the desktop and mobile pages load
|
||||
`@ks/identity-verification` (`static/@ks/identity-verification.81c79f9a249d01283c29.js`), so publishing
|
||||
may also require 实名/手机号 identity verification **on top of** the token — **[UNVERIFIED]**, but the
|
||||
SDK's presence on the very pages that would post is a strong hint.
|
||||
- Observed `result` codes relevant here: `0` ok · `1` ok for `interact/add` · `11` handler ran, context/
|
||||
params rejected · `21` param format error · `-401` auth (cookie or token) · **`140002` 内容不存在** ·
|
||||
**`140005` 动态不存在或者已被删除** · `129000` kuaishouzt "Server Error." (missing token entirely).
|
||||
|
||||
---
|
||||
|
||||
## 6. Field maps
|
||||
|
||||
### 6.1 Moment object (`moment/detail` → `moment`, and `feedList[]` items when `resourceType`/`type` is a moment)
|
||||
|
||||
| Field | Type | Meaning / note |
|
||||
|---|---|---|
|
||||
| `momentId` | string | **the id** — string, not number. Used by detail + comments (`sourceId`) |
|
||||
| `momentType` | number | `1` text-only, `2` image/text, `3` vote/其他 (observed 1/2 live; `3` inferred from `/communityCircle/{tagId}` items whose `moments-type-3` markup carries a vote) |
|
||||
| `text` | string | raw content, includes `#话题#` markers and newlines |
|
||||
| `replaceUbbText` | string | same text after UBB processing |
|
||||
| `createTime` | `YYYY-MM-DD` **string** | day precision only — no time-of-day |
|
||||
| `user` | object | see 6.2 |
|
||||
| `imgs[]` | array | `{width,height,size,type,url,expandedUrl,originUrl}`; `type` 1/3/4 observed (gif vs jpg vs png); `originUrl` is the full-size original, `url` is a 224×224 thumb (`imageView2/5/w/224/h/224`) |
|
||||
| `imgInfos[]` | array | the newer `<ImageInfo>` envelope (`thumbnailImage/smallSharedImage/expandedImage/originImage`, each `{cdnUrls:[{url,freeTrafficCdn}]}`) — prefer `imgs[].originUrl` for a plugin |
|
||||
| `likeCount` / `commentCount` / `bananaCount` / `shareCount` | number | counters; `bananaCount` = 投蕉 |
|
||||
| `isLike` / `isThrowBanana` | boolean | always `false` anonymously; `true`/`false` when a cookie is present |
|
||||
| `visibleForFans` | boolean | 粉丝可见 flag |
|
||||
| `originResourceType` | number | `0` for a native moment; non-zero when it wraps another resource |
|
||||
| `tagList[]` | array | `{tagId, tagName}` — the 话题 links; `tagId` feeds `/communityCircle/{tagId}` |
|
||||
| `shareUrl` | string | `https://m.acfun.cn/communityCircle/moment/{momentId}` — **use this as the canonical URL** |
|
||||
| `groupId` | string | per-request rendering group, not stable — ignore |
|
||||
| `voteInfo` | object | present on 投票 moments only (referenced by the `UpdateFeedVote` mutation) |
|
||||
| `repostSource` | object (sibling of `moment`) | on reposts: a second moment-shaped object with its own `user`/counts; the store reads `moment.repostSource.*` |
|
||||
|
||||
### 6.2 Author object (`moment.user`)
|
||||
|
||||
`id` (numeric string), `name`, `headUrl`, `headCdnUrls[]`, `userHeadImgInfo`, `avatarFrame`/`avatarFramePcImg`/
|
||||
`avatarFrameMobileImg`, `signature`, `gender`, `fanCount`/`fanCountValue`, `followingCount`/
|
||||
`followingCountValue`, `contributeCount`/`contributeCountValue`, `isFollowing`, `followingStatus`,
|
||||
`isFollowed`, `href`, `action`, `nameColor`, `socialMedal`, `sexTrend`.
|
||||
The store normalises this to `{userHead, userId, userName, liveId}` — if you cache, keep `id`+`name`.
|
||||
|
||||
### 6.3 Feed-item envelope (`feedList[]`, `feed/hot[]`)
|
||||
|
||||
`groupId, contentId, videoId, dougaId, sourceId, type, caption, title, description, coverUrl, coverUrls[],
|
||||
coverCdnUrls[], displayPlayCount, viewCount, displayDanmakuCount, danmakuCount, likeCount, likeCountShow,
|
||||
commentCount, commentCountRealValue, bananaCount, bananaCountShow, shareCount, stowCount, isLike,
|
||||
isThrowBanana, isFavorite, isFollowing, createTime, createTimeMillis, contributeTime, playDuration,
|
||||
durationMillis, videoList[] (part list with id/title/danmakuCount), tagList[], user, channel,
|
||||
superUbb, status, expTag, videoSizeType, shareUrl, picShareUrl`
|
||||
For a moment, `type`/`resourceType` is **10** and the moment body sits alongside as `moment` /
|
||||
`repostSource` (per the store). `feed/hot` items are `type:2` videos.
|
||||
|
||||
---
|
||||
|
||||
## 7. Dead ends, 404s, and app-only
|
||||
|
||||
**[VERIFIED] 404 on `www.acfun.cn`** (route simply not served publicly):
|
||||
`/rest/pc-direct/moment/{add,create,publish,post,delete,remove,repost,forward,share,like,unlike,vote,
|
||||
update,edit}` · `/rest/pc-direct/feed/{publish,addMoment,square,recommend,circle,topic,list,getFeed,
|
||||
feedList,queryFeed,recommendList,recommendMoment,discover,index,home,timeline,circleFeed,topicFeed,
|
||||
hotMoment,userFeed,userMoment,user,contribute}` · `/rest/pc-direct/moment/{list,getList,userList,hot,
|
||||
recommend,square}` · `/rest/pc-direct/{topic/*,communityCircle/*,tag/resources,resources/list,resource/list}`.
|
||||
|
||||
**[VERIFIED] 404 on `api-ipv6.acfunchina.com`**: `/rest/app/moment/{square,list,recommend,hot,feed,
|
||||
timeline,publish,create,repost}` · `/rest/app/{square/*,circle/list,community/*,feed/{square,list,
|
||||
recommend}}`.
|
||||
|
||||
**[VERIFIED] 404 on `m.acfun.cn`**: `/rest/mobile-direct/{topic/*,communityCircle/*,tag/*,moment/{list,
|
||||
square,recommend,hot,add,publish},feed/{square,recommend,list},square/list,resources/list}`.
|
||||
|
||||
**502 (wrong host)**: `/rest/pc-node/*` on www; `/rest/mobile-direct/*` on www.
|
||||
**HTML 200 (not JSON) on kuaishouzt.com**: `/rest/zt/{feed,moment,square}/*` — only
|
||||
`/rest/zt/interact/{add,delete}` is a real JSON endpoint there.
|
||||
|
||||
**App-only / effectively impossible for a web plugin**: publishing a 动态, deleting one, reposting,
|
||||
voting, and the app's 广场/推荐 timeline. All of these sit behind the midground token on
|
||||
`api-ipv6.acfunchina.com` and have no web counterpart. There is **no public square/recommend moment
|
||||
list API** on any host — after ~560 path probes this is a solid negative, not a gap in the search.
|
||||
|
||||
---
|
||||
|
||||
## 8. Feasibility and the recommended plugin surface
|
||||
|
||||
### 8.1 Verdict per capability
|
||||
|
||||
| Capability | Feasible? | Basis |
|
||||
|---|---|---|
|
||||
| Read one moment by id (text, images, tags, counts, author) | ✅ **reliable, anonymous** | `moment/detail` `result:0` on legacy + 2026 ids |
|
||||
| Read a moment's comments | ✅ **reliable, anonymous** | `comment/listByFloor?sourceType=4` `totalCount:2` with real comments |
|
||||
| Read a topic/话题's moments | ⚠️ **possible but HTML-only, first 15, no paging** | `m.acfun.cn/communityCircle/{tagId}` 144 KB SSR |
|
||||
| Read the 关注动态 stream | ⚠️ **cookie only** | `feed/followFeedV2` `200` but empty for guests |
|
||||
| Discover moment ids / browse a 广场 | ❌ **no API** | ~560 probes; topic SSR is the only discovery vector |
|
||||
| Like/unlike | 🔶 **plausible, unverifiable** | route + token tier verified via `401` |
|
||||
| 投蕉 | 🔶 **plausible, unverifiable** | route + cookie tier verified via `401` |
|
||||
| Comment/reply | 🔶 **plausible, unverifiable** | list side verified; `comment/add` from existing doc |
|
||||
| Follow author | 🔶 **plausible, unverifiable** | `/relation/follow` in bundles |
|
||||
| Repost | ❌ | no route found |
|
||||
| Publish / delete 动态 | ❌ **impossible for a web plugin** | app-only, midground token |
|
||||
| Vote | ❌ | app-only, midground token |
|
||||
|
||||
### 8.2 Smallest useful implementation
|
||||
|
||||
Two read tools, both anonymous, plus one optional cookie-gated tool. Do **not** ship a "square feed" tool.
|
||||
|
||||
```
|
||||
acfun_moment(momentId: string)
|
||||
GET https://www.acfun.cn/rest/pc-direct/moment/detail?momentId={id}
|
||||
-> { momentId, url, author{id,name,avatar}, text, createTime, images[], tags[{tagId,name}],
|
||||
counts{like,comment,banana,share}, isRepost, repostSource? , raw }
|
||||
Accepts a bare id, "am:5093831", or a https://m.acfun.cn/communityCircle/moment/5093831 URL.
|
||||
Errors: 140002 -> NOT_FOUND (out of range); 140005 -> DELETED.
|
||||
|
||||
acfun_moment_comments(momentId, page=1)
|
||||
GET https://www.acfun.cn/rest/pc-direct/comment/listByFloor
|
||||
?sourceId={id}&sourceType=4&page={page}&supportZtEmot=true
|
||||
-> { totalCount, totalPage, page, comments[{cid, floor, userName, userId, content, postDate, isUp}] }
|
||||
|
||||
acfun_topic_moments(tagId, limit<=15) # optional, mark as best-effort
|
||||
GET https://m.acfun.cn/communityCircle/{tagId} with DEFAULT Accept (no text/html!)
|
||||
Parse li.resources-item[resource-type=3] -> moment-id ; other types -> resource-id only.
|
||||
-> { tag{id,name,followers,contents}, items[{momentId?, resourceType, resourceId, user, time, text, stats}] }
|
||||
First page only; params page/sort do nothing.
|
||||
|
||||
acfun_following_feed(cursor) # only when a session cookie is configured
|
||||
GET .../feed/followFeedV2?pcursor={cursor||0}&count=20&resourceTypes=0
|
||||
-> { items[], nextCursor } ; nextCursor="no_more" terminates; empty list for anonymous.
|
||||
```
|
||||
|
||||
Mapping to the existing plugin shape in this repo: these are pure `fetch` GETs, so they slot in beside
|
||||
the existing `acfun_video` / `acfun_comments` tools with no auth plumbing (`lib/provider.js` style).
|
||||
`acfun_moment` is the one to ship first — it is the only capability that is both anonymous and
|
||||
complete. `acfun_topic_moments` is the only way to *discover* a moment id, so it earns its place
|
||||
despite being HTML scraping.
|
||||
|
||||
### 8.3 Risk notes
|
||||
|
||||
1. **HTML scraping is load-bearing for discovery.** `communityCircle/{tagId}` has no API, no pagination,
|
||||
and its SSR only appears when `Accept` is not `text/html`. A markup change breaks discovery, not
|
||||
reads. Isolate the parser and fail soft.
|
||||
2. **Two id spaces.** Legacy `1…7525` (2019, mostly deleted) and current 7-digit ids. Never
|
||||
assume contiguity, never enumerate to "browse" — validate the id and surface `140002`/`140005`
|
||||
distinctly (they mean different things).
|
||||
3. **`sourceType 4` vs `resourceType 10`.** Comments use 4, like/banana use 10. Mixing them silently
|
||||
yields empty comment lists (`sourceType 3` → `totalCount:0`) rather than an error.
|
||||
4. **Host binding.** `/rest/mobile-direct/*` only answers on `m.acfun.cn`; `/rest/pc-node/*` never
|
||||
answers publicly. Hard-coding the wrong pair gives `502`s that look like outages.
|
||||
5. **Guest-tolerant ≠ available.** `feed/followFeedV2` returns `200` with an empty list for a guest;
|
||||
a plugin must not report "no dynamics" when it means "not logged in".
|
||||
6. **Writes cannot be validated at all here.** `interact/add` success is `result:1` while every other
|
||||
route uses `result:0` — easy to get backwards. If write tools are ever enabled, require a cookie and
|
||||
treat any non-`0`/`1` as failure, and never retry blindly.
|
||||
7. **The feature is app-first and actively mutating.** www dropped `/moment/` entirely; the live
|
||||
subsystem moved to `m.acfun.cn/communityCircle` and the app. Treat every endpoint here as
|
||||
*verified on 2026-10-27* and re-probe before relying on it.
|
||||
|
||||
---
|
||||
|
||||
## 9. How each claim was produced (short reproduction log)
|
||||
|
||||
| # | Probe | What it established |
|
||||
|---|---|---|
|
||||
| p1 | fetch 7 candidate URLs, `redirect:'manual'` | `/moment/`→302 `/404`; `/square`,`/community`,`/discovery`→502 error page |
|
||||
| p2 | home page bigPipe frames + keyword scan | 8 pagelets, nav routes, **0** occurrences of moment/square/dynamic |
|
||||
| p3 | `pagelet_navigation` HTML + `/member/*` | nav = 首页/直播/番剧/channels/排行榜; `/member/feeds`→login redirect |
|
||||
| p4 | `/u/1`, `/u/2` space pages | no 动态 tab, no moment strings |
|
||||
| p5 | download 70 www bundles (2.7 MB), regex `/rest/` | 50 rest paths; **only** `app.11fc9e51.js` mentions moment |
|
||||
| p6–p9 | dissect `app.11fc9e51.js` | prefix fn, `moment/detail`, `followFeedV2`, `banana/throwBanana`, `interact/add`, `resourceType=10` |
|
||||
| p10 | live probe of 7 read + 2 write endpoints | detail 200, feeds 200-empty, `favorite/bangumi` 401, `throwBanana` 401, `interact/add` 401 token |
|
||||
| p11 | existence oracle over 533 `/rest/pc-direct/{moment,feed,...}` paths (502 vs JSON) | only `moment/detail` + `feed/hot` are JSON |
|
||||
| p12 | `feed/hot` shape + param matrix | 9–14 video items, `type:2` only, params ignored |
|
||||
| p13–p14 | moment-id range scan + binary search | boundary 7525/7526; `momentId=200/300` → `result:0` |
|
||||
| p15 | fetch + scan ids 1..1200 | all live moments dated 2019-05/06; full field list |
|
||||
| p16 | `m.acfun.cn/communityCircle*` + app host | `/communityCircle/square` 200; `/moment/detail` on app host 200 |
|
||||
| p17–p20 | mobile pagelets + 37 mobile bundles | pages client-rendered; comment widget uses `resourceType 4`, `mobile-direct` paths |
|
||||
| p21 | moment variety + comment `sourceType` matrix | `sourceType=4` → 2 real comments; 3/2 empty; 10 → result 21 |
|
||||
| p22–p23 | publish/repost oracle on www + m + app | www 404 all; `POST /rest/app/moment/add` → 401 token; `vote` → 401 |
|
||||
| p24–p26 | topic pages | `/communityCircle/488` SSR 15 items dated 2026-10-13; tag 教育 38关注/10458内容 |
|
||||
| p27–p29 | paging/sort/headers | `?page`/`?sort` no-ops; `Accept: text/html` kills the SSR list; `*/*` restores it |
|
||||
| p30 | moment page SSR | `m.acfun.cn/communityCircle/moment/{id}` → `Acfun 动态详情` with `.moment-text` |
|
||||
| p31–p32 | kuaishouzt + final list hunt | zt only has `interact/*`; no public/user moment list anywhere |
|
||||
| p33 | clean evidence capture | the JSON/comment/tag snippets pasted above |
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,55 @@
|
||||
# Client panel: why a restart is required
|
||||
|
||||
The AcFun settings page (`client.js`) is a **static Client module**: the Web
|
||||
carrier turns each package's `dsh.client` declaration into a browser bundle and
|
||||
serves it under `/plugins`. Two facts discovered while installing this bundle
|
||||
into a live session matter for anyone iterating on the panel.
|
||||
|
||||
## 1. The bundle is composed from the Loader, not from the filesystem
|
||||
|
||||
`dsh-client-modules` scans the *live Host Loader entries* for packages declaring
|
||||
`dsh.client` and composes `window.__DSH_BOOT__` from them. A package that is
|
||||
installed while the page is already open does not appear in that graph until the
|
||||
composition is re-run — which happens on Host restart, not on a page refresh.
|
||||
|
||||
Evidence gathered from the running Host (see `docs/acfun-panel-diagnosis.md`
|
||||
for the raw output): `ctx.clientModules.graph()` listed the ~75 shipped client
|
||||
modules after installing this bundle, and `ctx.clientModules.clientPath(
|
||||
'dsh-plugin-acfun')` returned `null`. The Host half was fully active at that
|
||||
moment (all twelve tools, `/acfun-panel/state`, QR issuance), so this is a
|
||||
client-composition fact, not a plugin bug.
|
||||
|
||||
## 2. Package metadata is cached per Loader specifier until restart
|
||||
|
||||
The same package declares `dsh.client`, adds `client.js`, and exports `./client`.
|
||||
Those three edits are read when the package is first scanned. In this session the
|
||||
first install happened **before** `client.js` existed, so the cached metadata for
|
||||
`dsh-plugin-acfun` had no client declaration; later reinstalls kept the cached
|
||||
record.
|
||||
|
||||
## What this means in practice
|
||||
|
||||
- Installing this bundle for the first time in a fresh Harness is enough: the
|
||||
next boot composes the panel automatically.
|
||||
- Upgrading a bundle that already declared `dsh.client` only needs a page reload
|
||||
after the Host restart.
|
||||
- When iterating on `client.js`, `plugin_manager action=remove_bundle` +
|
||||
`install_bundle` reloads the Host half, but the panel needs
|
||||
`restart the Harness → reload the page` to appear.
|
||||
|
||||
## Verifying without a browser
|
||||
|
||||
The Host exposes the composition state directly:
|
||||
|
||||
```
|
||||
GET http://127.0.0.1:<port>/acfun-panel/state # client.path
|
||||
GET http://127.0.0.1:<port>/acfun-panel/diagnose # client.entries
|
||||
```
|
||||
|
||||
`path` is non-null only when the carrier registered the bundle. `/diagnose` lists
|
||||
every composed client module id, which is the fastest way to tell "the panel is
|
||||
not composed yet" apart from "the panel crashed while rendering".
|
||||
|
||||
Slot registration itself is verifiable without a screenshot:
|
||||
`cordis_inspect_query` → `Slots.listSubTree` with `root: "settings.section"`
|
||||
shows the `acfun` occupant once the browser has loaded the module.
|
||||
@@ -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'
|
||||
|
||||
+254
@@ -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()
|
||||
}
|
||||
}
|
||||
+218
@@ -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 }]
|
||||
}
|
||||
+464
-40
@@ -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<string, any>} 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<digits>`, 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/<id>` 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(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||
.replace(/<[^>]*>/g, ' '),
|
||||
)
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** First capture group of `pattern` in `html`, HTML-stripped, or ''. */
|
||||
function firstGroup(html, pattern) {
|
||||
const match = pattern.exec(html)
|
||||
return match === null ? '' : stripHtml(match[1])
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one server-rendered `m.acfun.cn/communityCircle/{tagId}` page.
|
||||
*
|
||||
* @param {string} html - full mobile topic page.
|
||||
* @param {string} tagId - requested topic id (fallback for the link column).
|
||||
* @returns {Record<string, any>}
|
||||
*/
|
||||
export function parseTopicPage(html, tagId) {
|
||||
const pageText = stripHtml(html)
|
||||
const tagName =
|
||||
/【(.+?)】/.exec(firstGroup(html, /<title[^>]*>([\s\S]*?)<\/title>/i))?.[1] ??
|
||||
(firstGroup(html, /class="tag-name"[^>]*>([\s\S]*?)<\//i) || `话题 ${tagId}`)
|
||||
const followersText = /(\d[\d,.]*\s*万?)\s*关注/.exec(pageText)?.[1] ?? ''
|
||||
const contentsText = /(\d[\d,.]*\s*万?)\s*内容/.exec(pageText)?.[1] ?? ''
|
||||
const items = []
|
||||
const chunkPattern = /<li[^>]*class="[^"]*resources-item[^"]*"([\s\S]*?)(?=<li[^>]*class="[^"]*resources-item|<\/ul>|$)/gi
|
||||
let match
|
||||
while ((match = chunkPattern.exec(html)) !== null) {
|
||||
const chunk = match[1]
|
||||
const momentId = /moment-id="(\d*)"/.exec(chunk)?.[1] ?? ''
|
||||
const resourceType = Number(/resource-type="(\d*)"/.exec(chunk)?.[1] ?? 0) || 0
|
||||
const resourceId = /resource-id="(\d*)"/.exec(chunk)?.[1] ?? ''
|
||||
if (momentId.length === 0 && resourceId.length === 0) continue
|
||||
const url =
|
||||
momentId.length > 0
|
||||
? momentShareUrl(momentId)
|
||||
: resourceType === 2 && resourceId.length > 0
|
||||
? acfunVideoUrl(resourceId)
|
||||
: resourceId.length > 0
|
||||
? `https://www.acfun.cn/a/ac${resourceId}`
|
||||
: `https://m.acfun.cn/communityCircle/${tagId}`
|
||||
items.push({
|
||||
momentId,
|
||||
resourceType,
|
||||
resourceId,
|
||||
title: firstGroup(chunk, /class="article-title"[^>]*>([\s\S]*?)<\/p>/i),
|
||||
body: firstGroup(chunk, /class="article-body"[^>]*>([\s\S]*?)<\/p>/i),
|
||||
authorName: firstGroup(chunk, /class="user-name"[^>]*>([\s\S]*?)<\/(?:p|span)>/i),
|
||||
publishedAt: firstGroup(chunk, /class="release-time"[^>]*>([\s\S]*?)<\/(?:p|span)>/i),
|
||||
tags: [...chunk.matchAll(/class="tag-item"[^>]*>([\s\S]*?)<\/a>/gi)].map((entry) => stripHtml(entry[1])).filter(Boolean),
|
||||
coverUrl: /<img[^>]+src="([^"]+)"/i.exec(chunk)?.[1] ?? '',
|
||||
statTexts: [...chunk.matchAll(/class="base-text"[^>]*>([\s\S]*?)<\//gi)].map((entry) => stripHtml(entry[1])).filter(Boolean),
|
||||
url,
|
||||
})
|
||||
}
|
||||
return {
|
||||
tagId,
|
||||
tagName,
|
||||
followersText,
|
||||
contentsText,
|
||||
returned: items.length,
|
||||
items,
|
||||
note: 'HTML topic page: at most 15 newest items, no pagination; only resourceType 3 items carry a momentId.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One AcFun API client bound to a cookie and timeout budget.
|
||||
*/
|
||||
@@ -284,6 +448,30 @@ export class AcfunProvider {
|
||||
return this.cookie.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the session cookie (used after QR login and logout).
|
||||
*
|
||||
* @param {string} cookie - full Cookie header value; empty clears it.
|
||||
*/
|
||||
setCookie(cookie) {
|
||||
this.cookie = typeof cookie === 'string' ? cookie.trim() : ''
|
||||
this.cookies = parseCookieHeader(this.cookie)
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe the configured session without exposing it.
|
||||
*
|
||||
* @returns {{configured: boolean, length: number, hasAuthKey: boolean, hasDid: boolean}}
|
||||
*/
|
||||
describeSession() {
|
||||
return {
|
||||
configured: this.cookie.length > 0,
|
||||
length: this.cookie.length,
|
||||
hasAuthKey: typeof this.cookies.auth_key === 'string' && this.cookies.auth_key.length > 0,
|
||||
hasDid: typeof this.cookies._did === 'string' && this.cookies._did.length > 0,
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether mutating calls are permitted by configuration and login state. */
|
||||
get writable() {
|
||||
return this.allowWriteActions && this.loggedIn
|
||||
@@ -417,7 +605,8 @@ export class AcfunProvider {
|
||||
})
|
||||
}
|
||||
if (payload !== null && typeof payload === 'object' && typeof payload.result === 'number' && payload.result !== 0) {
|
||||
throw this.#apiError(path, payload, response.status)
|
||||
const accepted = Array.isArray(options.okResults) ? options.okResults : []
|
||||
if (!accepted.includes(payload.result)) throw this.#apiError(path, payload, response.status)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
@@ -441,6 +630,12 @@ export class AcfunProvider {
|
||||
{ code: 'CAPTCHA_REQUIRED', route: path, status, detail: serverMessage },
|
||||
)
|
||||
}
|
||||
if (code === 140_002 || code === 140_005) {
|
||||
return new AcfunError(
|
||||
`${base}. ${code === 140_002 ? 'The moment id is outside the current id range.' : 'The moment does not exist or was deleted.'}`,
|
||||
{ code: 'NOT_FOUND', route: path, status, detail: serverMessage },
|
||||
)
|
||||
}
|
||||
return new AcfunError(base, { code: 'API_ERROR', route: path, status, detail: serverMessage })
|
||||
}
|
||||
|
||||
@@ -674,41 +869,165 @@ export class AcfunProvider {
|
||||
},
|
||||
signal: options.signal,
|
||||
})
|
||||
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,
|
||||
})
|
||||
}
|
||||
const totalPages = Number(data.totalPage) || 1
|
||||
const currentPage = Number(data.curPage) || page
|
||||
const pageData = mapCommentPage(data, page)
|
||||
return {
|
||||
acId,
|
||||
page: currentPage,
|
||||
totalPages,
|
||||
totalCount: Number(data.totalCount) || items.length,
|
||||
pageSize: Number(data.pageSize) || items.length,
|
||||
returned: items.length,
|
||||
hasMore: currentPage < totalPages,
|
||||
page: pageData.currentPage,
|
||||
totalPages: pageData.totalPages,
|
||||
totalCount: pageData.totalCount,
|
||||
pageSize: pageData.pageSize,
|
||||
returned: pageData.items.length,
|
||||
hasMore: pageData.currentPage < pageData.totalPages,
|
||||
url: acfunVideoUrl(acId),
|
||||
items,
|
||||
items: pageData.items,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one AcFun moment (动态) by id.
|
||||
*
|
||||
* `moment/detail` works anonymously for both the legacy (≤7525) and current
|
||||
* (7-digit) id spaces; ids can only be discovered from a topic page or a
|
||||
* following feed.
|
||||
*
|
||||
* @param {string|number} input - numeric moment id or communityCircle/moment URL.
|
||||
* @param {{signal?: AbortSignal}} [options]
|
||||
* @returns {Promise<Record<string, any>>}
|
||||
*/
|
||||
async moment(input, options = {}) {
|
||||
const momentId = normalizeMomentId(String(input))
|
||||
const data = await this.#api('/rest/pc-direct/moment/detail', {
|
||||
query: { momentId },
|
||||
signal: options.signal,
|
||||
})
|
||||
const raw = data.moment
|
||||
if (raw === null || typeof raw !== 'object') {
|
||||
throw new AcfunError(`AcFun did not return a moment for id ${momentId}.`, {
|
||||
code: 'NOT_FOUND',
|
||||
route: '/rest/pc-direct/moment/detail',
|
||||
})
|
||||
}
|
||||
const user = raw.user !== null && typeof raw.user === 'object' ? raw.user : {}
|
||||
const images = []
|
||||
if (Array.isArray(raw.imgs)) {
|
||||
for (const image of raw.imgs) {
|
||||
if (image === null || typeof image !== 'object') continue
|
||||
const url = image.originUrl || image.expandedUrl || image.url
|
||||
if (typeof url === 'string' && url.length > 0) {
|
||||
images.push({ url, width: Number(image.w ?? image.width) || 0, height: Number(image.h ?? image.height) || 0 })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (images.length === 0 && Array.isArray(raw.imgInfos)) {
|
||||
for (const info of raw.imgInfos) {
|
||||
const urls = info?.originImage?.cdnUrls
|
||||
const url = Array.isArray(urls) ? urls.map((cdn) => cdn?.url).find((candidate) => typeof candidate === 'string' && candidate.length > 0) : undefined
|
||||
if (typeof url === 'string') images.push({ url, width: 0, height: 0 })
|
||||
}
|
||||
}
|
||||
const tags = Array.isArray(raw.tagList)
|
||||
? raw.tagList
|
||||
.filter((tag) => tag !== null && typeof tag === 'object' && typeof tag.tagName === 'string')
|
||||
.map((tag) => ({ id: String(tag.tagId ?? ''), name: tag.tagName }))
|
||||
: []
|
||||
const shareUrl = typeof raw.shareUrl === 'string' && raw.shareUrl.length > 0 ? raw.shareUrl : momentShareUrl(momentId)
|
||||
const repost = data.repostSource ?? raw.repostSource
|
||||
const repostedFrom = repost !== null && typeof repost === 'object' ? String(repost.text ?? '').slice(0, 300) : ''
|
||||
return {
|
||||
momentId: String(raw.momentId ?? momentId),
|
||||
momentType: Number(raw.momentType) || 0,
|
||||
text: String(raw.text ?? raw.replaceUbbText ?? '').trim(),
|
||||
createTime: String(raw.createTime ?? ''),
|
||||
authorId: String(user.id ?? ''),
|
||||
authorName: String(user.name ?? ''),
|
||||
authorAvatar: typeof user.headUrl === 'string' ? user.headUrl : '',
|
||||
authorSignature: typeof user.signature === 'string' ? user.signature : '',
|
||||
authorFollowers: Number(user.fanCountValue ?? user.fanCount) || 0,
|
||||
images,
|
||||
tags,
|
||||
likeCount: Number(raw.likeCount) || 0,
|
||||
commentCount: Number(raw.commentCount) || 0,
|
||||
bananaCount: Number(raw.bananaCount) || 0,
|
||||
shareCount: Number(raw.shareCount) || 0,
|
||||
isLike: raw.isLike === true,
|
||||
isThrowBanana: raw.isThrowBanana === true,
|
||||
visibleForFans: raw.visibleForFans === true,
|
||||
repostedFrom,
|
||||
shareUrl,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one page of a moment's comment thread.
|
||||
*
|
||||
* Moments use the same `comment/listByFloor` route as videos, with
|
||||
* `sourceType: 4` (the moment enum — likes/bananas use `resourceType: 10`).
|
||||
*
|
||||
* @param {string|number} input - numeric moment id or communityCircle/moment URL.
|
||||
* @param {{page?: number, signal?: AbortSignal}} [options]
|
||||
* @returns {Promise<Record<string, any>>}
|
||||
*/
|
||||
async momentComments(input, options = {}) {
|
||||
const momentId = normalizeMomentId(String(input))
|
||||
const page = Number(options.page ?? 1)
|
||||
const data = await this.#api('/rest/pc-direct/comment/listByFloor', {
|
||||
query: {
|
||||
sourceId: momentId,
|
||||
sourceType: MOMENT_COMMENT_SOURCE_TYPE,
|
||||
page: Number.isInteger(page) && page > 0 ? page : 1,
|
||||
pivotCommentId: '',
|
||||
newPivotCommentId: '',
|
||||
t: '',
|
||||
supportZtEmot: 'true',
|
||||
},
|
||||
signal: options.signal,
|
||||
})
|
||||
const pageData = mapCommentPage(data, page)
|
||||
return {
|
||||
momentId,
|
||||
page: pageData.currentPage,
|
||||
totalPages: pageData.totalPages,
|
||||
totalCount: pageData.totalCount,
|
||||
pageSize: pageData.pageSize,
|
||||
returned: pageData.items.length,
|
||||
hasMore: pageData.currentPage < pageData.totalPages,
|
||||
url: momentShareUrl(momentId),
|
||||
items: pageData.items,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort read of one AcFun topic ("社区圈") page: the only public feed
|
||||
* that lists current moment ids for anonymous clients.
|
||||
*
|
||||
* The page is server-rendered HTML (15 newest items, no pagination) and only
|
||||
* resource-type 3 items carry a `moment-id`.
|
||||
*
|
||||
* @param {string|number} tagId - numeric topic id, for example 488 (教育).
|
||||
* @param {{signal?: AbortSignal}} [options]
|
||||
* @returns {Promise<Record<string, any>>}
|
||||
*/
|
||||
async topicMoments(tagId, options = {}) {
|
||||
const id = String(tagId ?? '').trim()
|
||||
if (!/^\d+$/.test(id)) {
|
||||
throw new AcfunError('tagId must be a numeric topic id, for example "488".', { code: 'INVALID_ARGUMENT' })
|
||||
}
|
||||
const response = await this.#http(`https://m.acfun.cn/communityCircle/${id}`, {
|
||||
// The mobile page switches to a JS-only shell when a navigation Accept is
|
||||
// sent; the default wildcard keeps the server-rendered list.
|
||||
accept: '*/*',
|
||||
signal: options.signal,
|
||||
})
|
||||
if (response.status !== 200) {
|
||||
throw new AcfunError(`AcFun topic ${id} returned HTTP ${response.status}.`, {
|
||||
code: response.status === 404 ? 'NOT_FOUND' : 'HTTP_ERROR',
|
||||
route: `/communityCircle/${id}`,
|
||||
status: response.status,
|
||||
})
|
||||
}
|
||||
return parseTopicPage(response.text, id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one page of a video's danmaku (弹幕) list.
|
||||
*
|
||||
@@ -863,24 +1182,47 @@ export class AcfunProvider {
|
||||
async throwBanana(input, count, options = {}) {
|
||||
this.assertWritable('Tipping bananas (投蕉)')
|
||||
const acId = normalizeAcId(String(input))
|
||||
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' })
|
||||
const amount = validateBananaCount(count)
|
||||
const total = await this.#tipBanana(acId, amount, 2, options)
|
||||
return {
|
||||
ok: true,
|
||||
message: `Tipped ${amount} banana(s) to ac${acId}.`,
|
||||
...total === undefined ? {} : { bananaCount: total },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tip bananas to one moment (动态). Moments use `resourceType: 10`.
|
||||
*
|
||||
* @param {string|number} input - numeric moment id or communityCircle URL.
|
||||
* @param {number} count - bananas to tip, 1-10.
|
||||
* @param {{signal?: AbortSignal}} [options]
|
||||
* @returns {Promise<{ok: true, message: string, bananaCount?: number}>}
|
||||
*/
|
||||
async momentBanana(input, count, options = {}) {
|
||||
this.assertWritable('Tipping bananas to a moment')
|
||||
const momentId = normalizeMomentId(String(input))
|
||||
const amount = validateBananaCount(count)
|
||||
const total = await this.#tipBanana(momentId, amount, MOMENT_RESOURCE_TYPE, options)
|
||||
return {
|
||||
ok: true,
|
||||
message: `Tipped ${amount} banana(s) to moment ${momentId}.`,
|
||||
...total === undefined ? {} : { bananaCount: total },
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared banana request for videos (resourceType 2) and moments (10). */
|
||||
async #tipBanana(resourceId, amount, resourceType, options) {
|
||||
const udid = this.cookies._did
|
||||
const headers = udid === undefined ? {} : { udid }
|
||||
const data = await this.#api('/rest/pc-direct/banana/throwBanana', {
|
||||
method: 'POST',
|
||||
form: { resourceId: acId, count: amount, resourceType: 2 },
|
||||
form: { resourceId, count: amount, resourceType },
|
||||
headers,
|
||||
signal: options.signal,
|
||||
})
|
||||
const total = Number(data.extData?.bananaRealCount)
|
||||
return {
|
||||
ok: true,
|
||||
message: `Tipped ${amount} banana(s) to ac${acId}.`,
|
||||
...Number.isFinite(total) ? { bananaCount: total } : {},
|
||||
}
|
||||
return Number.isFinite(total) ? total : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -943,6 +1285,42 @@ export class AcfunProvider {
|
||||
return { ok: true, message: like ? `Liked ac${acId}.` : `Removed the like from ac${acId}.` }
|
||||
}
|
||||
|
||||
/**
|
||||
* Like or unlike one moment (动态).
|
||||
*
|
||||
* Moments are addressed by `objectType: 10` on the same signed-token
|
||||
* interaction endpoint as videos. The contract is derived from the site's own
|
||||
* store (`resourceType = 10`); the success envelope is `result: 1`, not 0, so
|
||||
* both codes are accepted.
|
||||
*
|
||||
* @param {string|number} input - numeric moment id or communityCircle URL.
|
||||
* @param {boolean} like - true to like, false to remove the like.
|
||||
* @param {{signal?: AbortSignal}} [options]
|
||||
* @returns {Promise<{ok: true, message: string}>}
|
||||
*/
|
||||
async setMomentLike(input, like, options = {}) {
|
||||
this.assertWritable(like ? 'Liking a moment' : 'Unliking a moment')
|
||||
const momentId = normalizeMomentId(String(input))
|
||||
const token = await this.midgroundToken({ signal: options.signal })
|
||||
const form = {
|
||||
kpn: 'ACFUN_APP',
|
||||
kpf: 'PC_WEB',
|
||||
subBiz: 'mainApp',
|
||||
interactType: 1,
|
||||
objectType: MOMENT_RESOURCE_TYPE,
|
||||
objectId: momentId,
|
||||
userId: token.userId,
|
||||
[token.key]: token.value,
|
||||
}
|
||||
await this.#api(`${this.interactBaseUrl}${like ? '/rest/zt/interact/add' : '/rest/zt/interact/delete'}`, {
|
||||
method: 'POST',
|
||||
form,
|
||||
okResults: [0, 1],
|
||||
signal: options.signal,
|
||||
})
|
||||
return { ok: true, message: like ? `Liked moment ${momentId}.` : `Removed the like from moment ${momentId}.` }
|
||||
}
|
||||
|
||||
/**
|
||||
* List the signed-in user's favorite folders for one resource.
|
||||
*
|
||||
@@ -1073,4 +1451,50 @@ export class AcfunProvider {
|
||||
message: replyTo.length > 0 ? `Replied to comment ${replyTo} on ac${acId}.` : `Commented on ac${acId}.`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a comment (or reply) on one moment (动态).
|
||||
*
|
||||
* The mobile comment widget builds itself with `resourceType: 4`, which is the
|
||||
* same enum the verified read path uses as `sourceType: 4`. Both `objectId`
|
||||
* and `sourceId` are sent because the two client variants disagree; the
|
||||
* request is documented as UNVERIFIED until an account can exercise it.
|
||||
*
|
||||
* @param {string|number} input - numeric moment id or communityCircle URL.
|
||||
* @param {string} content - plain text or AcFun UBB content.
|
||||
* @param {{replyToCommentId?: string, signal?: AbortSignal}} [options]
|
||||
* @returns {Promise<{ok: true, commentId: string, message: string, url: string}>}
|
||||
*/
|
||||
async postMomentComment(input, content, options = {}) {
|
||||
this.assertWritable('Commenting on a moment')
|
||||
const momentId = normalizeMomentId(String(input))
|
||||
const body = typeof content === 'string' ? content.trim() : ''
|
||||
if (body.length === 0) {
|
||||
throw new AcfunError('content must be a non-empty string.', { code: 'INVALID_ARGUMENT' })
|
||||
}
|
||||
if (body.length > 2000) {
|
||||
throw new AcfunError('content is longer than 2000 characters.', { code: 'INVALID_ARGUMENT' })
|
||||
}
|
||||
const replyTo = String(options.replyToCommentId ?? '').trim()
|
||||
if (replyTo.length > 0 && !/^\d+$/.test(replyTo)) {
|
||||
throw new AcfunError('replyToCommentId must be a numeric comment id.', { code: 'INVALID_ARGUMENT' })
|
||||
}
|
||||
const data = await this.#api('/rest/pc-direct/comment/add', {
|
||||
method: 'POST',
|
||||
jsonBody: {
|
||||
objectId: momentId,
|
||||
sourceId: momentId,
|
||||
sourceType: MOMENT_COMMENT_SOURCE_TYPE,
|
||||
content: body,
|
||||
replyToCommentId: replyTo.length > 0 ? Number(replyTo) : 0,
|
||||
},
|
||||
signal: options.signal,
|
||||
})
|
||||
return {
|
||||
ok: true,
|
||||
commentId: String(data.commentId ?? data.cid ?? ''),
|
||||
message: replyTo.length > 0 ? `Replied to comment ${replyTo} on moment ${momentId}.` : `Commented on moment ${momentId}.`,
|
||||
url: momentShareUrl(momentId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* AcFun QR-login endpoint flow.
|
||||
*
|
||||
* Contract reverse-engineered from the current login page bundle
|
||||
* (`login/widget/login/login.*.js`) and verified live on 2026-10-27:
|
||||
*
|
||||
* GET https://scan.acfun.cn/rest/pc-direct/qr/start?type=WEB_LOGIN
|
||||
* -> { result: 0, next: "scanResult", expireTime: 120000,
|
||||
* qrLoginToken, qrLoginSignature, imageData (base64 PNG) }
|
||||
* GET .../qr/scanResult?qrLoginToken=..&qrLoginSignature=..
|
||||
* -> one long-poll, held until the phone scans or the code expires.
|
||||
* result 0 => scanned; the response carries a refreshed
|
||||
* qrLoginSignature used for acceptResult
|
||||
* result 100400002 => terminal "token expired"
|
||||
* result 10 => terminal "client error" (ticket already used)
|
||||
* other => error
|
||||
* GET .../qr/acceptResult?qrLoginToken=..&qrLoginSignature=..
|
||||
* -> result 0 issues the session cookies via Set-Cookie;
|
||||
* any other result fails (e.g. result 21 "sign error" for a stale or
|
||||
* not-yet-scanned ticket).
|
||||
*
|
||||
* The flow keeps its own cookie jar because the session may arrive on any of
|
||||
* the three responses and must be replayed on the next request.
|
||||
*/
|
||||
|
||||
import { AcfunError } from './provider.js'
|
||||
|
||||
/** QR service origin. */
|
||||
export const QR_SERVICE_URL = 'https://scan.acfun.cn'
|
||||
|
||||
/** Paths relative to the QR service origin. */
|
||||
export const QR_PATHS = Object.freeze({
|
||||
start: '/rest/pc-direct/qr/start',
|
||||
scanResult: '/rest/pc-direct/qr/scanResult',
|
||||
acceptResult: '/rest/pc-direct/qr/acceptResult',
|
||||
})
|
||||
|
||||
/** Default server-declared expiry, used when `expireTime` is absent. */
|
||||
export const DEFAULT_QR_TTL_MS = 120_000
|
||||
|
||||
/** How long one long-poll may hold before we treat it as expired. */
|
||||
const LONG_POLL_TIMEOUT_MS = 135_000
|
||||
|
||||
/** The "token expired" result code (terminal, not "keep waiting"). */
|
||||
const RESULT_EXPIRED = 100_400_002
|
||||
|
||||
/** The "client error" result code: the ticket is already terminal/in-flight. */
|
||||
const RESULT_CLIENT_ERROR = 10
|
||||
|
||||
/** Parse one Set-Cookie header into name/value pairs. */
|
||||
function parseSetCookie(header) {
|
||||
const pairs = []
|
||||
for (const raw of header) {
|
||||
const first = raw.split(';')[0]
|
||||
const index = first.indexOf('=')
|
||||
if (index <= 0) continue
|
||||
pairs.push([first.slice(0, index).trim(), first.slice(index + 1).trim()])
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
/**
|
||||
* One QR login flow bound to a service origin.
|
||||
*/
|
||||
export class AcfunQrFlow {
|
||||
/**
|
||||
* @param {{serviceUrl?: string, userAgent?: string, longPollTimeoutMs?: number}} [options]
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this.serviceUrl = (options.serviceUrl ?? QR_SERVICE_URL).replace(/\/$/, '')
|
||||
this.userAgent = options.userAgent ?? 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'
|
||||
this.longPollTimeoutMs = options.longPollTimeoutMs ?? LONG_POLL_TIMEOUT_MS
|
||||
this.jar = new Map()
|
||||
this.token = ''
|
||||
this.signature = ''
|
||||
}
|
||||
|
||||
/** Headers shared by every QR request. */
|
||||
#headers(signal) {
|
||||
const headers = {
|
||||
'User-Agent': this.userAgent,
|
||||
Referer: 'https://www.acfun.cn/login/',
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
}
|
||||
if (this.jar.size > 0) headers.Cookie = this.#cookieHeader()
|
||||
return headers
|
||||
}
|
||||
|
||||
/** Serialize the jar for a Cookie request header. */
|
||||
#cookieHeader() {
|
||||
return [...this.jar.entries()].map(([name, value]) => `${name}=${value}`).join('; ')
|
||||
}
|
||||
|
||||
/** Merge response cookies into the jar. */
|
||||
#absorb(response) {
|
||||
const list = typeof response.headers.getSetCookie === 'function' ? response.headers.getSetCookie() : []
|
||||
for (const [name, value] of parseSetCookie(list)) this.jar.set(name, value)
|
||||
}
|
||||
|
||||
/** GET one QR endpoint and return the parsed body plus response. */
|
||||
async #get(path, query, signal) {
|
||||
const url = new URL(this.serviceUrl + path)
|
||||
for (const [key, value] of Object.entries(query ?? {})) url.searchParams.set(key, String(value))
|
||||
let response
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
headers: this.#headers(),
|
||||
redirect: 'follow',
|
||||
signal,
|
||||
})
|
||||
} catch (cause) {
|
||||
if (cause?.name === 'AbortError' || cause?.name === 'TimeoutError') throw cause
|
||||
throw new AcfunError(`QR service request failed: ${String(cause?.message ?? cause)}`, { code: 'NETWORK', route: path })
|
||||
}
|
||||
this.#absorb(response)
|
||||
const text = await response.text()
|
||||
let body
|
||||
try {
|
||||
body = JSON.parse(text)
|
||||
} catch {
|
||||
throw new AcfunError(`QR service returned malformed JSON for ${path}.`, {
|
||||
code: 'MALFORMED_JSON',
|
||||
route: path,
|
||||
status: response.status,
|
||||
detail: text.slice(0, 160),
|
||||
})
|
||||
}
|
||||
return { body, status: response.status }
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a fresh QR ticket.
|
||||
*
|
||||
* @returns {Promise<{ticket: string, qr: {kind: 'data-url', value: string}, expiresAt: number}>}
|
||||
*/
|
||||
async start() {
|
||||
this.jar.clear()
|
||||
const { body } = await this.#get(QR_PATHS.start, { type: 'WEB_LOGIN' })
|
||||
if (body?.result !== 0 || typeof body?.qrLoginToken !== 'string' || body.qrLoginToken.length === 0) {
|
||||
throw new AcfunError(`AcFun did not issue a QR code (result ${body?.result ?? '?'}: ${body?.error_msg ?? 'no message'}).`, {
|
||||
code: 'QR_START_FAILED',
|
||||
route: QR_PATHS.start,
|
||||
})
|
||||
}
|
||||
if (typeof body.imageData !== 'string' || body.imageData.length === 0) {
|
||||
throw new AcfunError('AcFun issued a QR ticket without image data.', { code: 'QR_START_FAILED', route: QR_PATHS.start })
|
||||
}
|
||||
this.token = body.qrLoginToken
|
||||
this.signature = String(body.qrLoginSignature ?? '')
|
||||
const ttl = Number(body.expireTime)
|
||||
return {
|
||||
ticket: this.token,
|
||||
qr: { kind: 'data-url', value: `data:image/png;base64,${body.imageData}` },
|
||||
expiresAt: Date.now() + (Number.isFinite(ttl) && ttl > 0 ? ttl : DEFAULT_QR_TTL_MS),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the phone to scan the current ticket.
|
||||
*
|
||||
* Resolves `waiting` when the server reports no scan yet, `scanned` once the
|
||||
* phone authorised the login (with a refreshed signature), and `error`
|
||||
* otherwise. A caller abort throws `AbortError`.
|
||||
*
|
||||
* @param {{signal?: AbortSignal}} [options]
|
||||
* @returns {Promise<{state: 'waiting'|'scanned'|'error', message: string}>}
|
||||
*/
|
||||
async waitForScan(options = {}) {
|
||||
const timeout = AbortSignal.timeout(this.longPollTimeoutMs)
|
||||
const signal = options.signal === undefined ? timeout : AbortSignal.any([options.signal, timeout])
|
||||
let response
|
||||
try {
|
||||
response = await this.#get(QR_PATHS.scanResult, { qrLoginToken: this.token, qrLoginSignature: this.signature }, signal)
|
||||
} catch (cause) {
|
||||
if (cause?.name === 'AbortError') throw cause
|
||||
if (cause?.name === 'TimeoutError') return { state: 'waiting', message: 'QR code expired on the server.' }
|
||||
throw cause
|
||||
}
|
||||
const body = response.body ?? {}
|
||||
if (body.result === 0) {
|
||||
if (typeof body.qrLoginSignature === 'string' && body.qrLoginSignature.length > 0) this.signature = body.qrLoginSignature
|
||||
return { state: 'scanned', message: '' }
|
||||
}
|
||||
if (body.result === RESULT_EXPIRED) {
|
||||
return { state: 'expired', message: 'The QR code expired; refresh it.' }
|
||||
}
|
||||
if (body.result === RESULT_CLIENT_ERROR) {
|
||||
return { state: 'expired', message: 'This QR code is no longer valid; refresh it.' }
|
||||
}
|
||||
return { state: 'error', message: `AcFun QR scan failed (result ${body.result}: ${body.error_msg ?? 'unknown error'}).` }
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange an authorised ticket for the session cookies.
|
||||
*
|
||||
* @param {{signal?: AbortSignal}} [options]
|
||||
* @returns {Promise<{state: 'confirmed'|'expired'|'error', message: string, cookie?: string}>}
|
||||
*/
|
||||
async accept(options = {}) {
|
||||
const timeout = AbortSignal.timeout(60_000)
|
||||
const signal = options.signal === undefined ? timeout : AbortSignal.any([options.signal, timeout])
|
||||
let response
|
||||
try {
|
||||
response = await this.#get(QR_PATHS.acceptResult, { qrLoginToken: this.token, qrLoginSignature: this.signature }, signal)
|
||||
} catch (cause) {
|
||||
if (cause?.name === 'AbortError') throw cause
|
||||
return { state: 'error', message: `AcFun login exchange failed: ${String(cause?.message ?? cause)}` }
|
||||
}
|
||||
const body = response.body ?? {}
|
||||
if (body.result === 0) {
|
||||
const cookie = this.#cookieHeader()
|
||||
if (cookie.length === 0) return { state: 'error', message: 'AcFun confirmed the login but returned no session cookies.' }
|
||||
return { state: 'confirmed', message: 'Signed in to AcFun.', cookie }
|
||||
}
|
||||
if (body.result === RESULT_EXPIRED || body.result === RESULT_CLIENT_ERROR) {
|
||||
return { state: 'expired', message: 'The QR code expired; refresh it.' }
|
||||
}
|
||||
return { state: 'error', message: `AcFun login failed (result ${body.result}: ${body.error_msg ?? 'sign error'}).` }
|
||||
}
|
||||
}
|
||||
+338
-25
@@ -7,18 +7,20 @@
|
||||
* import and `node scripts/live-check.mjs` can execute it outside the harness.
|
||||
*/
|
||||
|
||||
import { AcfunError, acfunVideoUrl, DANMAKU_MAX_COUNT, normalizeAcId, RANK_PERIODS, SEARCH_SORTS } from './provider.js'
|
||||
import { AcfunError, acfunVideoUrl, DANMAKU_MAX_COUNT, normalizeAcId, normalizeMomentId, RANK_PERIODS, SEARCH_SORTS } from './provider.js'
|
||||
|
||||
/** The system-prompt guidance registered by the plugin. */
|
||||
export const ACFUN_GUIDANCE = [
|
||||
'AcFun (A站) tools: use acfun_search to find videos, acfun_video for one result\'s full metadata,',
|
||||
'acfun_comments for its comment thread, acfun_danmaku for the 弹幕 list, acfun_rank for the day/week',
|
||||
'ranking, and acfun_playurl for an m3u8 play URL. acfun_interact (like / unlike / banana /',
|
||||
'favorite / unfavorite / follow / unfollow) and acfun_post_comment are disabled unless the plugin',
|
||||
'config sets allowWriteActions: true and a login cookie; when a write tool reports WRITE_DISABLED or',
|
||||
'LOGIN_REQUIRED, tell the user which config key is missing instead of retrying. Cite the video URL',
|
||||
'as a markdown link when you use its content, and never repeat a mutating call just because the',
|
||||
'result was unclear.',
|
||||
'ranking, and acfun_playurl for an m3u8 play URL. For 动态 (moments) use acfun_moment,',
|
||||
'acfun_moment_comments and acfun_topic: AcFun exposes no public 广场 feed, so moment ids come only',
|
||||
'from a topic page (acfun_topic) or a share link. acfun_interact (like / unlike / banana /',
|
||||
'favorite / unfavorite / follow / unfollow, plus moment_like / moment_unlike / moment_banana) and',
|
||||
'acfun_post_comment are disabled unless the plugin config sets allowWriteActions: true and a login',
|
||||
'cookie; when a write tool reports WRITE_DISABLED or LOGIN_REQUIRED, tell the user which config key',
|
||||
'is missing instead of retrying. Publishing a moment is app-only and unavailable. Cite the share URL',
|
||||
'as a markdown link when you use content, and never repeat a mutating call because the result was unclear.',
|
||||
].join(' ')
|
||||
|
||||
/** One text content block. */
|
||||
@@ -191,12 +193,76 @@ export function formatPlayUrls(value) {
|
||||
|
||||
/** Format one interaction outcome. */
|
||||
export function formatInteract(value) {
|
||||
return `${value.message}\n\nAction: ${value.action} · acId: ${value.acId || '-'} · userId: ${value.targetUserId || '-'}`
|
||||
const target = value.momentId.length > 0 ? `moment ${value.momentId}` : value.acId.length > 0 ? `ac${value.acId}` : value.targetUserId.length > 0 ? `user ${value.targetUserId}` : '-'
|
||||
return `${value.message}\n\nAction: ${value.action} · target: ${target}`
|
||||
}
|
||||
|
||||
/** Format one posted comment. */
|
||||
export function formatPostComment(value) {
|
||||
return `${value.message}\n\nComment id: ${value.commentId || '(not returned)'} · video: ${value.url}`
|
||||
const target = value.momentId.length > 0 ? `moment ${value.momentId}` : value.acId.length > 0 ? `ac${value.acId}` : 'unknown'
|
||||
return `${value.message}\n\nComment id: ${value.commentId || '(not returned)'} · target: ${target} · ${value.url}`
|
||||
}
|
||||
|
||||
/** Format the account/session summary. */
|
||||
export function formatAccount(value) {
|
||||
return [
|
||||
`AcFun account: ${value.loggedIn ? 'signed in' : 'anonymous'} (source: ${value.authSource}${value.hasAuthKey ? ', auth_key present' : ''}).`,
|
||||
`Interaction tools: ${value.writesEnabled ? 'enabled' : 'disabled'}${value.writesEnabled ? '' : ' (allowWriteActions is false)'}.`,
|
||||
`QR login available in this Host: ${value.qrLoginAvailable ? 'yes' : 'no'}; login state: ${value.loginState}.`,
|
||||
value.hint,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Format one moment (动态). */
|
||||
export function formatMoment(value) {
|
||||
const lines = [
|
||||
`# AcFun 动态 ${value.momentId}`,
|
||||
`URL: ${value.shareUrl}`,
|
||||
`Author: ${value.authorName || 'unknown'}${value.authorId ? ` (id ${value.authorId})` : ''}${value.authorFollowers > 0 ? ` · ${value.authorFollowers} followers` : ''}`,
|
||||
`Published: ${value.createTime || '(unknown)'} · type ${value.momentType}`,
|
||||
`Stats: ${value.likeCount} likes · ${value.commentCount} comments · ${value.bananaCount} bananas · ${value.shareCount} shares`,
|
||||
`Interaction state: ${value.isLike ? 'liked' : 'not liked'} · ${value.isThrowBanana ? 'banana given' : 'no banana'}${value.visibleForFans ? ' · followers-only' : ''}`,
|
||||
]
|
||||
if (value.tags.length > 0) lines.push(`Topics: ${value.tags.map((tag) => `#${tag.name}#(${tag.id})`).join(' ')}`)
|
||||
if (value.text.length > 0) lines.push(`\nText:\n${value.text}`)
|
||||
if (value.images.length > 0) {
|
||||
lines.push(`\nImages (${value.images.length}):`)
|
||||
for (const image of value.images) lines.push(`- ${image.url}${image.width > 0 ? ` (${image.width}x${image.height})` : ''}`)
|
||||
}
|
||||
if (value.repostedFrom.length > 0) lines.push(`\nRepost of: ${value.repostedFrom}`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/** Format one moment comment page. */
|
||||
export function formatMomentComments(value) {
|
||||
const header = `Comments on moment ${value.momentId} (${value.url}) — page ${value.page}/${value.totalPages}, ${value.totalCount} comments total, ${value.returned} on this page.`
|
||||
if (value.items.length === 0) return `${header}\n\nNo comments on this page.`
|
||||
const lines = value.items.map((item) => {
|
||||
const head = meta(
|
||||
`#${item.floor}`,
|
||||
item.userName.length > 0 ? `${item.userName} (id ${item.userId})` : `user ${item.userId}`,
|
||||
item.postedAt,
|
||||
`cid ${item.commentId}`,
|
||||
)
|
||||
const body = item.isDeleted ? '[deleted]' : item.content.length > 0 ? item.content : '[empty]'
|
||||
return `- ${head}\n ${body.replace(/\n/g, '\n ')}`
|
||||
})
|
||||
const footer = value.hasMore
|
||||
? `More comments follow: call acfun_moment_comments with page: ${value.page + 1}.`
|
||||
: 'This is the last comment page.'
|
||||
return `${header}\n\n${lines.join('\n')}\n\n${footer}`
|
||||
}
|
||||
|
||||
/** Format one topic page. */
|
||||
export function formatTopic(value) {
|
||||
const header = `AcFun topic ${value.tagName} (tagId ${value.tagId}) — ${meta(`${value.followersText || '?'} followers`, `${value.contentsText || '?'} posts`)}; showing ${value.returned}. ${value.note}`
|
||||
if (value.items.length === 0) return `${header}\n\nNo items could be parsed from this topic page.`
|
||||
const lines = value.items.map((item, index) => {
|
||||
const label = item.momentId.length > 0 ? `moment ${item.momentId}` : `resource ${item.resourceId} (type ${item.resourceType})`
|
||||
const title = item.title.length > 0 ? item.title : item.body.slice(0, 80)
|
||||
return `${index + 1}. [${title || label}](${item.url}) — ${meta(item.authorName, item.publishedAt, label, item.tags.join(' '))}`
|
||||
})
|
||||
return `${header}\n\n${lines.join('\n')}\n\nUse acfun_moment with a momentId above to read a full 动态.`
|
||||
}
|
||||
|
||||
/** Resolve a requested quality label/height against the available ladder. */
|
||||
@@ -241,19 +307,20 @@ function requireInteger(args, name, { min = 1, max = Number.MAX_SAFE_INTEGER, fa
|
||||
return value
|
||||
}
|
||||
|
||||
/** Map one provider banana result to the interaction output shape. */
|
||||
function interactOutput(action, message, acId = '', targetUserId = '') {
|
||||
return { action, ok: true, message, acId, targetUserId }
|
||||
/** Map one provider result to the interaction output shape. */
|
||||
function interactOutput(action, message, acId = '', targetUserId = '', momentId = '') {
|
||||
return { action, ok: true, message, acId, targetUserId, momentId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the seven AcFun tool definitions for one provider instance.
|
||||
* Build the AcFun tool definitions for one provider instance.
|
||||
*
|
||||
* @param {import('./provider.js').AcfunProvider} provider - bound API client.
|
||||
* @param {{timeoutMs: number, allowWriteActions: boolean}} config - resolved plugin config.
|
||||
* @param {{sessionSource?: () => string, login?: {available: boolean, snapshot: () => object}}} [context] - runtime session/UI wiring.
|
||||
* @returns {object[]} registry-ready ToolDefinition objects.
|
||||
*/
|
||||
export function createAcfunTools(provider, config) {
|
||||
export function createAcfunTools(provider, config, context = {}) {
|
||||
const readTimeout = config.timeoutMs
|
||||
const writeTimeout = Math.max(config.timeoutMs, 60_000)
|
||||
|
||||
@@ -406,6 +473,153 @@ export function createAcfunTools(provider, config) {
|
||||
},
|
||||
}
|
||||
|
||||
const momentTool = {
|
||||
name: 'acfun_moment',
|
||||
description:
|
||||
'Read one AcFun 动态 (moment) by id: text, topics, images, author, like/comment/banana/share counts, and the canonical share URL. AcFun has no public 广场 feed API, so ids come from a share link or from acfun_topic. Works anonymously.',
|
||||
parameters: objectNode({
|
||||
momentId: required(string('Numeric moment id (for example 5093831) or a https://m.acfun.cn/communityCircle/moment/... URL.')),
|
||||
}),
|
||||
output: {
|
||||
schema: objectNode({
|
||||
momentId: required(string('The moment id.')),
|
||||
momentType: required(number('1 text-only, 2 image/text, 3 vote/other.')),
|
||||
text: required(string('Moment text, including #topic# markers.')),
|
||||
createTime: required(string('Publish date as YYYY-MM-DD (day precision).')),
|
||||
authorId: required(string('Author user id.')),
|
||||
authorName: required(string('Author display name.')),
|
||||
authorAvatar: required(string('Author avatar URL.')),
|
||||
authorSignature: required(string('Author signature, empty when absent.')),
|
||||
authorFollowers: required(number('Author follower count.')),
|
||||
images: required(
|
||||
arrayOf(
|
||||
objectNode({
|
||||
url: required(string('Original image URL.')),
|
||||
width: required(number('Image width, 0 when unknown.')),
|
||||
height: required(number('Image height, 0 when unknown.')),
|
||||
}),
|
||||
'Attached images.',
|
||||
),
|
||||
),
|
||||
tags: required(
|
||||
arrayOf(
|
||||
objectNode({
|
||||
id: required(string('Topic (tag) id usable with acfun_topic.')),
|
||||
name: required(string('Topic name.')),
|
||||
}),
|
||||
'Topics attached to the moment.',
|
||||
),
|
||||
),
|
||||
likeCount: required(number('Like count.')),
|
||||
commentCount: required(number('Comment count.')),
|
||||
bananaCount: required(number('Banana (投蕉) count.')),
|
||||
shareCount: required(number('Share count.')),
|
||||
isLike: required(boolean('Whether the configured account liked it; false when anonymous.')),
|
||||
isThrowBanana: required(boolean('Whether the configured account tipped it; false when anonymous.')),
|
||||
visibleForFans: required(boolean('Whether the moment is followers-only.')),
|
||||
repostedFrom: required(string('Text excerpt of the reposted moment, empty when not a repost.')),
|
||||
shareUrl: required(string('Canonical share URL.')),
|
||||
}),
|
||||
render: (_args, value) => text(formatMoment(value)),
|
||||
},
|
||||
timeoutMs: readTimeout,
|
||||
isConcurrencySafe: () => true,
|
||||
async execute(args, exec) {
|
||||
const momentId = requireText(args, 'momentId', 'numeric id or communityCircle/moment URL')
|
||||
return provider.moment(momentId, { signal: exec?.signal })
|
||||
},
|
||||
}
|
||||
|
||||
const momentCommentsTool = {
|
||||
name: 'acfun_moment_comments',
|
||||
description:
|
||||
'Read one page of the comment thread of an AcFun 动态 (moment), in floor order. Moments use their own comment enum, so this is separate from acfun_comments.',
|
||||
parameters: objectNode({
|
||||
momentId: required(string('Numeric moment id or a communityCircle/moment URL.')),
|
||||
page: number('1-based comment page. Defaults to 1.'),
|
||||
}),
|
||||
output: {
|
||||
schema: objectNode({
|
||||
momentId: required(string('The moment id.')),
|
||||
page: required(number('Current comment page.')),
|
||||
totalPages: required(number('Total number of comment pages.')),
|
||||
totalCount: required(number('Total comments on the moment.')),
|
||||
pageSize: required(number('Server page size for this response.')),
|
||||
returned: required(number('Comments returned on this page.')),
|
||||
hasMore: required(boolean('Whether a later page exists.')),
|
||||
url: required(string('Canonical share URL of the moment.')),
|
||||
items: required(
|
||||
arrayOf(
|
||||
objectNode({
|
||||
commentId: required(string('Numeric comment id usable as replyToCommentId.')),
|
||||
userId: required(string('Commenter user id.')),
|
||||
userName: required(string('Commenter display name.')),
|
||||
content: required(string('Plain-text comment content.')),
|
||||
floor: required(number('Floor number.')),
|
||||
postedAt: required(string('Server-formatted post time.')),
|
||||
timestamp: required(number('Post time as epoch milliseconds.')),
|
||||
isUp: required(boolean('Whether the commenter is the moment author.')),
|
||||
isDeleted: required(boolean('Whether AcFun reports the comment as deleted.')),
|
||||
}),
|
||||
'Comments in floor order.',
|
||||
),
|
||||
),
|
||||
}),
|
||||
render: (_args, value) => text(formatMomentComments(value)),
|
||||
},
|
||||
timeoutMs: readTimeout,
|
||||
isConcurrencySafe: () => true,
|
||||
async execute(args, exec) {
|
||||
const momentId = requireText(args, 'momentId', 'numeric id or communityCircle/moment URL')
|
||||
const page = requireInteger(args, 'page', { min: 1, max: 500, fallback: 1 })
|
||||
return provider.momentComments(momentId, { page, signal: exec?.signal })
|
||||
},
|
||||
}
|
||||
|
||||
const topicTool = {
|
||||
name: 'acfun_topic',
|
||||
description:
|
||||
'Read one AcFun 社区圈 topic page (best effort): the tag header and up to 15 newest items, including the moment ids of resourceType 3 items. This is the only public way to discover current moment ids, because AcFun exposes no square/recommend moment list API. No pagination is available.',
|
||||
parameters: objectNode({
|
||||
tagId: required(string('Numeric topic (tag) id, for example 488 for 教育. A moment\'s tagList carries these ids.')),
|
||||
}),
|
||||
output: {
|
||||
schema: objectNode({
|
||||
tagId: required(string('The requested topic id.')),
|
||||
tagName: required(string('Topic display name.')),
|
||||
followersText: required(string('Follower count as shown, empty when not found.')),
|
||||
contentsText: required(string('Post count as shown, empty when not found.')),
|
||||
returned: required(number('How many items were parsed.')),
|
||||
note: required(string('Parsing caveat for this surface.')),
|
||||
items: required(
|
||||
arrayOf(
|
||||
objectNode({
|
||||
momentId: required(string('Moment id, empty unless resourceType is 3.')),
|
||||
resourceType: required(number('1 article-style, 2 video-style, 3 moment.')),
|
||||
resourceId: required(string('Resource id for non-moment items.')),
|
||||
title: required(string('Item title, empty when absent.')),
|
||||
body: required(string('Item body excerpt.')),
|
||||
authorName: required(string('Author display name.')),
|
||||
publishedAt: required(string('Server-formatted date.')),
|
||||
tags: required(arrayOf(string('Topic label including # marks.'), 'Topic labels.')),
|
||||
coverUrl: required(string('First image URL on the item, empty when absent.')),
|
||||
url: required(string('Public URL of the item.')),
|
||||
statTexts: required(arrayOf(string('Numeric stat labels shown on the item.'), 'Stat texts.')),
|
||||
}),
|
||||
'Topic items in page order.',
|
||||
),
|
||||
),
|
||||
}),
|
||||
render: (_args, value) => text(formatTopic(value)),
|
||||
},
|
||||
timeoutMs: readTimeout,
|
||||
isConcurrencySafe: () => true,
|
||||
async execute(args, exec) {
|
||||
const tagId = requireText(args, 'tagId', 'numeric topic id such as 488')
|
||||
return provider.topicMoments(tagId, { signal: exec?.signal })
|
||||
},
|
||||
}
|
||||
|
||||
const danmakuTool = {
|
||||
name: 'acfun_danmaku',
|
||||
description:
|
||||
@@ -549,16 +763,28 @@ export function createAcfunTools(provider, config) {
|
||||
const interactTool = {
|
||||
name: 'acfun_interact',
|
||||
description:
|
||||
'Perform one AcFun interaction on a video or user: like, unlike, banana (投蕉), favorite, unfavorite, follow, or unfollow. Requires allowWriteActions: true and a login cookie in the plugin config; otherwise it returns a clear configuration error instead of acting. Do not repeat a call whose result was ambiguous.',
|
||||
'Perform one AcFun interaction: like, unlike, banana (投蕉), favorite, unfavorite, follow, unfollow on a video/user, or moment_like / moment_unlike / moment_banana on a 动态. Requires allowWriteActions: true and a login cookie in the plugin config; otherwise it returns a clear configuration error instead of acting. Moment writes are less verified than video writes — never repeat a call whose result was ambiguous.',
|
||||
parameters: objectNode({
|
||||
action: required(
|
||||
string('Interaction to perform.', {
|
||||
enum: ['like', 'unlike', 'banana', 'favorite', 'unfavorite', 'follow', 'unfollow'],
|
||||
enum: [
|
||||
'like',
|
||||
'unlike',
|
||||
'banana',
|
||||
'favorite',
|
||||
'unfavorite',
|
||||
'follow',
|
||||
'unfollow',
|
||||
'moment_like',
|
||||
'moment_unlike',
|
||||
'moment_banana',
|
||||
],
|
||||
}),
|
||||
),
|
||||
acId: string('Video identifier for like/unlike/banana/favorite/unfavorite.'),
|
||||
momentId: string('Moment identifier for moment_like / moment_unlike / moment_banana.'),
|
||||
userId: string('Target user id for follow/unfollow.'),
|
||||
count: number('Bananas to tip for action "banana", 1-10. Defaults to 1.'),
|
||||
count: number('Bananas to tip for action "banana" or "moment_banana", 1-10. Defaults to 1.'),
|
||||
folderId: string('Optional favorite folder id for action "favorite".'),
|
||||
}),
|
||||
output: {
|
||||
@@ -566,8 +792,9 @@ export function createAcfunTools(provider, config) {
|
||||
action: required(string('The interaction that ran.')),
|
||||
ok: required(boolean('Whether AcFun accepted the interaction.')),
|
||||
message: required(string('Human-readable outcome.')),
|
||||
acId: required(string('Video id the action targeted, empty for follow/unfollow.')),
|
||||
targetUserId: required(string('User id the action targeted, empty for video actions.')),
|
||||
acId: required(string('Video id the action targeted, empty for other targets.')),
|
||||
targetUserId: required(string('User id the action targeted, empty for content actions.')),
|
||||
momentId: required(string('Moment id the action targeted, empty for non-moment actions.')),
|
||||
}),
|
||||
render: (_args, value) => text(formatInteract(value)),
|
||||
},
|
||||
@@ -602,6 +829,18 @@ export function createAcfunTools(provider, config) {
|
||||
const outcome = await provider.setFollow(userId, action === 'follow', { signal })
|
||||
return interactOutput(action, outcome.message, '', userId)
|
||||
}
|
||||
case 'moment_like':
|
||||
case 'moment_unlike': {
|
||||
const momentId = requireText(args, 'momentId', 'required for moment_like/moment_unlike')
|
||||
const outcome = await provider.setMomentLike(momentId, action === 'moment_like', { signal })
|
||||
return interactOutput(action, outcome.message, '', '', momentId)
|
||||
}
|
||||
case 'moment_banana': {
|
||||
const momentId = requireText(args, 'momentId', 'required for moment_banana')
|
||||
const count = requireInteger(args, 'count', { min: 1, max: 10, fallback: 1 })
|
||||
const outcome = await provider.momentBanana(momentId, count, { signal })
|
||||
return interactOutput(action, outcome.message, '', '', momentId)
|
||||
}
|
||||
default:
|
||||
throw new AcfunError(`Unknown acfun_interact action: ${action}.`, { code: 'INVALID_ARGUMENT' })
|
||||
}
|
||||
@@ -611,33 +850,48 @@ export function createAcfunTools(provider, config) {
|
||||
const commentTool = {
|
||||
name: 'acfun_post_comment',
|
||||
description:
|
||||
'Post a comment (or a reply) on one acfun.cn video. Requires allowWriteActions: true and a login cookie in the plugin config; otherwise it returns a clear configuration error. Keep content short and on-topic, and do not retry after an unclear result — fetch acfun_comments to check whether it appeared.',
|
||||
'Post a comment (or a reply) on one acfun.cn video or 动态. Pass acId for a video or momentId for a moment. Requires allowWriteActions: true and a login cookie in the plugin config; otherwise it returns a clear configuration error. Keep content short and on-topic, and do not retry after an unclear result — re-read the thread to check whether it appeared.',
|
||||
parameters: objectNode({
|
||||
acId: required(string('Video identifier: numeric acId, ac-prefixed id, or acfun.cn /v/ac... URL.')),
|
||||
acId: string('Video identifier: numeric acId, ac-prefixed id, or acfun.cn /v/ac... URL.'),
|
||||
momentId: string('Moment identifier: numeric id or communityCircle/moment URL. Use instead of acId.'),
|
||||
content: required(string('Comment text, 1-2000 characters. AcFun UBB markup is allowed.')),
|
||||
replyToCommentId: string('Optional numeric comment id to reply to; omit for a top-level comment.'),
|
||||
}),
|
||||
output: {
|
||||
schema: objectNode({
|
||||
ok: required(boolean('Whether AcFun accepted the comment.')),
|
||||
acId: required(string('Bare numeric AcFun video id.')),
|
||||
acId: required(string('Bare numeric video id, empty when commenting on a moment.')),
|
||||
momentId: required(string('Bare numeric moment id, empty when commenting on a video.')),
|
||||
commentId: required(string('New comment id when AcFun returns one, otherwise empty.')),
|
||||
message: required(string('Human-readable outcome.')),
|
||||
url: required(string('Canonical acfun.cn video URL.')),
|
||||
url: required(string('Canonical video or moment URL.')),
|
||||
}),
|
||||
render: (_args, value) => text(formatPostComment(value)),
|
||||
},
|
||||
timeoutMs: writeTimeout,
|
||||
isConcurrencySafe: () => false,
|
||||
async execute(args, exec) {
|
||||
const acId = requireText(args, 'acId', 'numeric id, ac-prefixed id, or video URL')
|
||||
const content = requireText(args, 'content', 'the comment text')
|
||||
const replyToCommentId = typeof args?.replyToCommentId === 'string' ? args.replyToCommentId : undefined
|
||||
const momentArg = typeof args?.momentId === 'string' && args.momentId.trim().length > 0 ? args.momentId.trim() : ''
|
||||
if (momentArg.length > 0) {
|
||||
const outcome = await provider.postMomentComment(momentArg, content, { replyToCommentId, signal: exec?.signal })
|
||||
return {
|
||||
ok: outcome.ok,
|
||||
acId: '',
|
||||
momentId: normalizeMomentId(momentArg),
|
||||
commentId: outcome.commentId,
|
||||
message: outcome.message,
|
||||
url: outcome.url,
|
||||
}
|
||||
}
|
||||
const acId = requireText(args, 'acId', 'or pass momentId')
|
||||
const outcome = await provider.postComment(acId, content, { replyToCommentId, signal: exec?.signal })
|
||||
const normalized = normalizeAcId(acId)
|
||||
return {
|
||||
ok: outcome.ok,
|
||||
acId: normalized,
|
||||
momentId: '',
|
||||
commentId: outcome.commentId,
|
||||
message: outcome.message,
|
||||
url: acfunVideoUrl(normalized),
|
||||
@@ -645,5 +899,64 @@ export function createAcfunTools(provider, config) {
|
||||
},
|
||||
}
|
||||
|
||||
return [searchTool, videoTool, commentsTool, danmakuTool, rankTool, playUrlTool, interactTool, commentTool]
|
||||
const accountTool = {
|
||||
name: 'acfun_account',
|
||||
description:
|
||||
'Report the configured AcFun account state: whether a login cookie is configured, where it came from (plugin config, environment variable, or QR login), whether interaction tools are enabled, and how to sign in. QR login itself happens in the Harness Web UI (Settings → AcFun), not in this tool.',
|
||||
parameters: objectNode({}),
|
||||
output: {
|
||||
schema: objectNode({
|
||||
loggedIn: required(boolean('Whether any AcFun session cookie is configured.')),
|
||||
cookieConfigured: required(boolean('Whether the provider currently holds a cookie.')),
|
||||
hasAuthKey: required(boolean('Whether the cookie contains an auth_key entry.')),
|
||||
authSource: required(string('Where the session came from: config, env, qr, or none.')),
|
||||
writesEnabled: required(boolean('Whether interaction tools are allowed by config.')),
|
||||
qrLoginAvailable: required(boolean('Whether this Host exposes QR login.')),
|
||||
loginState: required(string('Current QR login state: idle, waiting, scanned, confirmed, expired, or error.')),
|
||||
hint: required(string('Next step for the user, in one sentence.')),
|
||||
}),
|
||||
render: (_args, value) => text(formatAccount(value)),
|
||||
},
|
||||
timeoutMs: readTimeout,
|
||||
isConcurrencySafe: () => true,
|
||||
async execute() {
|
||||
const described = provider.describeSession()
|
||||
const login = context.login ?? null
|
||||
const loginState = login !== null && typeof login.snapshot === 'function' ? String(login.snapshot().state ?? 'idle') : 'idle'
|
||||
const qrAvailable = login !== null && login.available === true
|
||||
const source = typeof context.sessionSource === 'function' ? context.sessionSource() : described.configured ? 'config' : 'none'
|
||||
const hint = described.configured
|
||||
? config.allowWriteActions
|
||||
? 'AcFun reads and interactions are ready.'
|
||||
: 'Add "allowWriteActions: true" to the acfun plugin config to enable interactions.'
|
||||
: qrAvailable
|
||||
? 'Open Settings → AcFun in this app and scan the QR code, or set a cookie in the plugin config.'
|
||||
: 'Set "cookie" (or the ACFUN_COOKIE environment variable) in the acfun plugin config.'
|
||||
return {
|
||||
loggedIn: described.configured,
|
||||
cookieConfigured: described.configured,
|
||||
hasAuthKey: described.hasAuthKey,
|
||||
authSource: source,
|
||||
writesEnabled: config.allowWriteActions === true,
|
||||
qrLoginAvailable: qrAvailable,
|
||||
loginState,
|
||||
hint,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return [
|
||||
searchTool,
|
||||
videoTool,
|
||||
commentsTool,
|
||||
momentTool,
|
||||
momentCommentsTool,
|
||||
topicTool,
|
||||
danmakuTool,
|
||||
rankTool,
|
||||
playUrlTool,
|
||||
interactTool,
|
||||
commentTool,
|
||||
accountTool,
|
||||
]
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "AcFun Tools",
|
||||
"description": "Search AcFun videos, read video metadata, comments, danmaku, day/week ranking and playable stream URLs, and — after configuring a login cookie and enabling write actions — interact on AcFun."
|
||||
"description": "Search AcFun videos, read metadata, comments, danmaku, ranking, stream URLs and 动态 (moments), sign in by QR code in Settings → AcFun, and interact on AcFun when write actions are enabled."
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "AcFun 工具",
|
||||
"description": "搜索 A 站视频,读取视频详情/评论/弹幕/热榜/播放地址;配置登录 Cookie 并开启写操作后,可在 A 站点赞、投蕉、收藏、关注和发评论。"
|
||||
"description": "搜索 A 站视频,读取详情/评论/弹幕/排行/播放地址与动态,可在“设置 → AcFun”扫码登录;开启写操作后可点赞、投蕉、收藏、关注和发评论。"
|
||||
}
|
||||
}
|
||||
|
||||
+11
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dsh-plugin-acfun",
|
||||
"version": "0.1.0",
|
||||
"description": "DeepSeek Harness plugin: AcFun (A站) video search, metadata, comments, danmaku, day/week ranking, playable m3u8 URLs, and optional cookie-backed interactions (点赞/投蕉/收藏/关注/评论) plus a model-facing skill. Anonymous reads by default; writes stay disabled until both allowWriteActions and a login cookie are configured.",
|
||||
"description": "DeepSeek Harness plugin: AcFun (A站) video search, metadata, comments, danmaku, ranking, play URLs and 动态 (moments), QR-code login, a Settings → AcFun Web UI panel, and optional cookie-backed interactions. Anonymous reads by default; writes stay disabled until allowWriteActions and a login are configured.",
|
||||
"keywords": [
|
||||
"deepseek-harness",
|
||||
"dsh-plugin",
|
||||
@@ -11,18 +11,21 @@
|
||||
"video",
|
||||
"danmaku",
|
||||
"弹幕",
|
||||
"qr-login",
|
||||
"agent-tool"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"exports": {
|
||||
".": "./index.js",
|
||||
"./client": "./client.js",
|
||||
"./locale/*.json": "./locale/*.json",
|
||||
"./icon": "./icon.svg",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"index.js",
|
||||
"client.js",
|
||||
"lib/**/*",
|
||||
"cordis.patch.yml",
|
||||
"locale/*.json",
|
||||
@@ -38,6 +41,13 @@
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
},
|
||||
"client": {
|
||||
"platform": "web",
|
||||
"immediately": true,
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-ui-settings"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { AcfunProvider } from '../lib/provider.js'
|
||||
import { resolveConfig } from '../lib/config.js'
|
||||
import { createAcfunTools } from '../lib/tools.js'
|
||||
import { AcfunQrFlow } from '../lib/qr-flow.js'
|
||||
import { validateValue } from './schema-check.mjs'
|
||||
|
||||
const provider = new AcfunProvider({ requestTimeoutMs: 30_000 })
|
||||
@@ -79,12 +80,41 @@ await run('acfun_comments', { acId, page: 1 })
|
||||
await run('acfun_danmaku', { acId, limit: 50 })
|
||||
await run('acfun_rank', { period: 'day', limit: 5 })
|
||||
await run('acfun_playurl', { acId })
|
||||
await run('acfun_account', {})
|
||||
await run('acfun_moment', { momentId: process.env.ACFUN_MOMENT_ID ?? '5093831' })
|
||||
await run('acfun_moment_comments', { momentId: process.env.ACFUN_MOMENT_ID ?? '5093831', page: 1 })
|
||||
await run('acfun_topic', { tagId: process.env.ACFUN_TOPIC_ID ?? '488' })
|
||||
if (video !== undefined && video.title.length === 0) failures.push('acfun_video: empty title')
|
||||
|
||||
// Interactions must fail closed without allowWriteActions + cookie.
|
||||
await run('acfun_interact', { action: 'like', acId }, { expectError: 'WRITE_DISABLED' })
|
||||
await run('acfun_interact', { action: 'moment_like', momentId: process.env.ACFUN_MOMENT_ID ?? '5093831' }, { expectError: 'WRITE_DISABLED' })
|
||||
await run('acfun_post_comment', { acId, content: 'live-check should not post this' }, { expectError: 'WRITE_DISABLED' })
|
||||
|
||||
// QR login: ask AcFun for a real ticket and check the waiting state. No phone
|
||||
// scans it, so this proves issuance + the long-poll waiting path only.
|
||||
console.log('\n=== QR login (live, never scanned)')
|
||||
try {
|
||||
const flow = new AcfunQrFlow({ longPollTimeoutMs: 8_000 })
|
||||
const started = await flow.start()
|
||||
console.log(` ok issued ticket ${String(started.ticket).slice(0, 12)}… with QR ${started.qr.kind} (${started.qr.value.length} chars), ttl ${Math.round((started.expiresAt - Date.now()) / 1000)}s`)
|
||||
const aborter = new AbortController()
|
||||
const scan = await flow.waitForScan({ signal: aborter.signal })
|
||||
console.log(` ok unscanned ticket reports state=${scan.state}`)
|
||||
if (scan.state === 'waiting') {
|
||||
const pending = flow.waitForScan({ signal: aborter.signal })
|
||||
setTimeout(() => aborter.abort(), 400)
|
||||
try {
|
||||
await pending
|
||||
failures.push('QR wait did not abort promptly')
|
||||
} catch (error) {
|
||||
console.log(` ok abort cancels the long poll (${error?.name})`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
failures.push(`QR login live check failed: ${error?.name}: ${error?.message}`)
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`\n${failures.length} live check(s) failed`)
|
||||
for (const failure of failures) console.error(`- ${failure}`)
|
||||
|
||||
+177
-15
@@ -1,15 +1,21 @@
|
||||
/**
|
||||
/**
|
||||
* Offline pre-install check: load the bundle exactly as the Loader would
|
||||
* (`apply()` on a mock context), verify every tool definition, render each one
|
||||
* from a schema-shaped sample, and prove the write guard fails closed.
|
||||
* (`apply()` on a mock context), verify every tool definition, exercise the QR
|
||||
* login state machine with a fake flow, and serve the panel routes on an
|
||||
* ephemeral local HTTP server.
|
||||
*
|
||||
* Run with: node scripts/self-check.mjs
|
||||
*/
|
||||
|
||||
import http from 'node:http'
|
||||
|
||||
import { apply, inject, name } from '../index.js'
|
||||
import { resolveConfig } from '../lib/config.js'
|
||||
import { createAcfunTools, ACFUN_GUIDANCE } from '../lib/tools.js'
|
||||
import { AcfunProvider } from '../lib/provider.js'
|
||||
import { AcfunLoginManager, createCredentialSessionStore, SESSION_RECORD_KEY } from '../lib/login.js'
|
||||
import { createPanelRoutes, PANEL_PATH } from '../lib/panel.js'
|
||||
import { AcfunQrFlow, QR_PATHS } from '../lib/qr-flow.js'
|
||||
import { assertSchemaSupported, sampleValue, validateValue } from './schema-check.mjs'
|
||||
|
||||
const failures = []
|
||||
@@ -26,16 +32,21 @@ console.log('plugin exports')
|
||||
check('name is dsh-plugin-acfun', name === 'dsh-plugin-acfun', String(name))
|
||||
check('inject lists tools and systemPrompt', inject.includes('tools') && inject.includes('systemPrompt'), JSON.stringify(inject))
|
||||
check('guidance is non-empty prose', typeof ACFUN_GUIDANCE === 'string' && ACFUN_GUIDANCE.length > 80, String(ACFUN_GUIDANCE.length))
|
||||
check('QR endpoints are documented constants', QR_PATHS.start.startsWith('/rest/pc-direct/qr/') && QR_PATHS.scanResult.includes('scanResult'))
|
||||
|
||||
console.log('\nmock activation')
|
||||
const registered = []
|
||||
const sections = []
|
||||
const effects = []
|
||||
const routes = []
|
||||
const mockCtx = {
|
||||
effect(factory) {
|
||||
effects.push(factory)
|
||||
factory()
|
||||
},
|
||||
get(service) {
|
||||
if (service === 'credentials') return undefined
|
||||
if (service === 'webServer') return { register: (route) => { routes.push(route); return () => {} } }
|
||||
return undefined
|
||||
},
|
||||
tools: {
|
||||
register(definition) {
|
||||
registered.push(definition)
|
||||
@@ -53,23 +64,23 @@ apply(mockCtx, {})
|
||||
|
||||
check('one system-prompt section registered', sections.length === 1, JSON.stringify(sections))
|
||||
check('section name is tool:acfun', sections[0]?.name === 'tool:acfun', String(sections[0]?.name))
|
||||
check('eight tools registered', registered.length === 8, String(registered.length))
|
||||
check('twelve tools registered', registered.length === 12, String(registered.length))
|
||||
check(
|
||||
'tool names are unique acfun_* names',
|
||||
new Set(registered.map((tool) => tool.name)).size === 8 && registered.every((tool) => tool.name.startsWith('acfun_')),
|
||||
new Set(registered.map((tool) => tool.name)).size === 12 && registered.every((tool) => tool.name.startsWith('acfun_')),
|
||||
registered.map((tool) => tool.name).join(','),
|
||||
)
|
||||
check('panel routes registered', routes.length === 1 && routes[0].kind === 'prefix' && routes[0].path === PANEL_PATH, JSON.stringify(routes.map((route) => route.path)))
|
||||
|
||||
console.log('\ntool definitions')
|
||||
for (const tool of registered) {
|
||||
const params = assertSchemaSupported(tool.parameters, `${tool.name}.parameters`)
|
||||
const output = assertSchemaSupported(tool.output.schema, `${tool.name}.output`)
|
||||
check(`${tool.name}: has a description`, typeof tool.description === 'string' && tool.description.length > 40)
|
||||
check(`${tool.name}: parameters stay in the schema subset`, assertSchemaSupported(tool.parameters, `${tool.name}.parameters`).length === 0, assertSchemaSupported(tool.parameters, `${tool.name}.parameters`).join('; '))
|
||||
check(`${tool.name}: parameters stay in the schema subset`, params.length === 0, params.join('; '))
|
||||
check(`${tool.name}: parameters are an object root`, tool.parameters.type === 'object' && tool.parameters.additionalProperties === false)
|
||||
const outputErrors = assertSchemaSupported(tool.output.schema, `${tool.name}.output`)
|
||||
check(`${tool.name}: output stays in the schema subset`, outputErrors.length === 0, outputErrors.join('; '))
|
||||
check(`${tool.name}: output stays in the schema subset`, output.length === 0, output.join('; '))
|
||||
check(`${tool.name}: output is an object root`, tool.output.schema.type === 'object')
|
||||
check(`${tool.name}: execute is a function`, typeof tool.execute === 'function')
|
||||
|
||||
const sample = sampleValue(tool.output.schema)
|
||||
const valueErrors = validateValue(sample, tool.output.schema, `${tool.name}.sample`)
|
||||
check(`${tool.name}: schema sample validates`, valueErrors.length === 0, valueErrors.join('; '))
|
||||
@@ -96,7 +107,7 @@ const exec = { signal: new AbortController().signal }
|
||||
|
||||
for (const [tool, args] of [
|
||||
[interact, { action: 'like', acId: '42588725' }],
|
||||
[postComment, { action: 'post', acId: '42588725', content: 'hello' }],
|
||||
[postComment, { acId: '42588725', content: 'hello' }],
|
||||
]) {
|
||||
try {
|
||||
await tool.execute(args, exec)
|
||||
@@ -120,10 +131,161 @@ const withEnv = resolveConfig({}, { ACFUN_COOKIE: 'auth_key=abc; _did=xyz' })
|
||||
check('env cookie is read through cookieEnv', withEnv.cookie === 'auth_key=abc; _did=xyz' && withEnv.cookieSource === 'env')
|
||||
const literal = resolveConfig({ cookie: 'auth_key=literal', cookieEnv: 'OTHER' }, { OTHER: 'ignored' })
|
||||
check('literal cookie wins over env', literal.cookie === 'auth_key=literal' && literal.cookieSource === 'config')
|
||||
const defaults = resolveConfig(undefined)
|
||||
check('defaults keep writes disabled', defaults.allowWriteActions === false && defaults.cookie === '')
|
||||
check('defaults keep writes disabled', resolveConfig(undefined).allowWriteActions === false && resolveConfig(undefined).cookie === '')
|
||||
check('bad config values fall back with warnings', resolveConfig(null).warnings.length > 0)
|
||||
|
||||
console.log('\nQR login state machine (fake flow, no network)')
|
||||
{
|
||||
const fakeProvider = new AcfunProvider({})
|
||||
const writes = []
|
||||
const store = {
|
||||
async read() {
|
||||
return undefined
|
||||
},
|
||||
async write(cookie) {
|
||||
writes.push(cookie)
|
||||
},
|
||||
async clear() {
|
||||
writes.push('cleared')
|
||||
},
|
||||
}
|
||||
let step = 0
|
||||
const fakeFlow = {
|
||||
async start() {
|
||||
return { ticket: 'T-1', qr: { kind: 'data-url', value: 'data:image/png;base64,AAA' }, expiresAt: Date.now() + 60_000 }
|
||||
},
|
||||
async waitForScan() {
|
||||
step += 1
|
||||
return step === 1 ? { state: 'waiting', message: '' } : { state: 'scanned', message: '' }
|
||||
},
|
||||
async accept() {
|
||||
return { state: 'confirmed', message: 'ok', cookie: 'auth_key=abc; _did=xyz' }
|
||||
},
|
||||
}
|
||||
const manager = new AcfunLoginManager({ provider: fakeProvider, store, flow: fakeFlow })
|
||||
check('login is available with a flow', manager.available === true)
|
||||
const started = await manager.start()
|
||||
check('start returns a waiting ticket with a QR image', started.state === 'waiting' && started.ticket === 'T-1' && started.qr?.kind === 'data-url')
|
||||
const deadline = Date.now() + 6000
|
||||
while (manager.snapshot().state !== 'confirmed' && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
check('background loop reaches confirmed', manager.snapshot().state === 'confirmed', manager.snapshot().state)
|
||||
check('confirmed session is persisted', writes.includes('auth_key=abc; _did=xyz'), JSON.stringify(writes))
|
||||
check('provider receives the session cookie', fakeProvider.loggedIn && fakeProvider.describeSession().hasAuthKey)
|
||||
check('session source is qr', manager.authSource === 'qr')
|
||||
const cleared = await manager.logout()
|
||||
check('logout clears state and cookie', cleared.state === 'idle' && !fakeProvider.loggedIn && writes.includes('cleared'))
|
||||
check('QR image data is not exposed in snapshot after login', manager.snapshot().qr === null)
|
||||
}
|
||||
|
||||
{
|
||||
const store = { async read() { return undefined }, async write() {}, async clear() {} }
|
||||
const manager = new AcfunLoginManager({
|
||||
provider: new AcfunProvider({}),
|
||||
store,
|
||||
flow: {
|
||||
async start() {
|
||||
return { ticket: 'T-2', qr: { kind: 'data-url', value: 'data:image/png;base64,BBB' }, expiresAt: Date.now() + 200 }
|
||||
},
|
||||
async waitForScan() {
|
||||
return { state: 'waiting', message: '' }
|
||||
},
|
||||
},
|
||||
})
|
||||
await manager.start()
|
||||
await new Promise((resolve) => setTimeout(resolve, 2600))
|
||||
check('expired tickets stop polling and report expired', manager.snapshot().state === 'expired', manager.snapshot().state)
|
||||
await manager.cancel()
|
||||
}
|
||||
|
||||
console.log('\ncredential store')
|
||||
{
|
||||
const calls = []
|
||||
const fakeCredentials = {
|
||||
async readRecord(key) {
|
||||
calls.push(['read', key])
|
||||
return { kind: 'grant', payload: { cookie: 'auth_key=stored', savedAt: 1 } }
|
||||
},
|
||||
async modifyRecord(key, mutate) {
|
||||
calls.push(['modify', key])
|
||||
await mutate(undefined)
|
||||
},
|
||||
async deleteRecord(key) {
|
||||
calls.push(['delete', key])
|
||||
},
|
||||
}
|
||||
const store = createCredentialSessionStore(fakeCredentials)
|
||||
const loaded = await store.read()
|
||||
check('credential store reads a grant record', loaded?.cookie === 'auth_key=stored' && calls.some(([op, key]) => op === 'read' && key === SESSION_RECORD_KEY))
|
||||
await store.write('auth_key=next')
|
||||
check('credential store writes through modifyRecord', calls.some(([op]) => op === 'modify'))
|
||||
await store.clear()
|
||||
check('credential store clears the record', calls.some(([op]) => op === 'delete'))
|
||||
}
|
||||
|
||||
console.log('\npanel routes over local HTTP')
|
||||
{
|
||||
const panelProvider = new AcfunProvider({})
|
||||
const store = { async read() { return undefined }, async write() {}, async clear() {} }
|
||||
const login = new AcfunLoginManager({
|
||||
provider: panelProvider,
|
||||
store,
|
||||
flow: {
|
||||
async start() {
|
||||
return { ticket: 'P-1', qr: { kind: 'data-url', value: 'data:image/png;base64,CCC' }, expiresAt: Date.now() + 60_000 }
|
||||
},
|
||||
async waitForScan() {
|
||||
return { state: 'waiting', message: '' }
|
||||
},
|
||||
},
|
||||
})
|
||||
const panelTools = createAcfunTools(panelProvider, resolveConfig({}))
|
||||
const [route] = createPanelRoutes({
|
||||
provider: panelProvider,
|
||||
login,
|
||||
version: '0.0.0-test',
|
||||
allowWriteActions: false,
|
||||
cookieSource: () => 'none',
|
||||
tools: panelTools,
|
||||
})
|
||||
const server = http.createServer(route.handler)
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
const base = `http://127.0.0.1:${server.address().port}${PANEL_PATH}`
|
||||
try {
|
||||
const state = await (await fetch(`${base}/state`)).json()
|
||||
check('panel state reports the plugin and tools', state.ok === true && state.plugin.name === 'dsh-plugin-acfun' && state.tools.length === 12)
|
||||
check('panel state does not leak a cookie', state.auth.configured === false && state.auth.hasAuthKey === false)
|
||||
|
||||
const started = await (await fetch(`${base}/login/start`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })).json()
|
||||
check('panel start returns a QR data URL', started.ok === true && started.login.state === 'waiting' && started.login.qr.kind === 'data-url')
|
||||
|
||||
const polled = await (await fetch(`${base}/login/poll?ticket=${encodeURIComponent(started.login.ticket)}`)).json()
|
||||
check('panel poll returns the in-memory state', polled.ok === true && polled.login.state === 'waiting')
|
||||
|
||||
const crossed = await fetch(`${base}/state`, { headers: { Origin: 'https://evil.example' } })
|
||||
check('panel rejects cross-origin browser requests', crossed.status === 403, String(crossed.status))
|
||||
|
||||
const unknown = await fetch(`${base}/nope`)
|
||||
check('panel returns 404 for unknown routes', unknown.status === 404, String(unknown.status))
|
||||
|
||||
const loggedOut = await (await fetch(`${base}/logout`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })).json()
|
||||
check('panel logout returns to idle', loggedOut.ok === true && loggedOut.login.state === 'idle')
|
||||
} finally {
|
||||
await login.cancel()
|
||||
await new Promise((resolve) => server.close(resolve))
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nqr flow module')
|
||||
{
|
||||
const flow = new AcfunQrFlow({ serviceUrl: 'https://scan.acfun.cn', longPollTimeoutMs: 5000 })
|
||||
check('flow exposes documented paths', flow.serviceUrl === 'https://scan.acfun.cn')
|
||||
check('start is a function', typeof flow.start === 'function')
|
||||
check('waitForScan is a function', typeof flow.waitForScan === 'function')
|
||||
check('accept is a function', typeof flow.accept === 'function')
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`\n${failures.length} check(s) failed`)
|
||||
process.exitCode = 1
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
---
|
||||
name: acfun
|
||||
description: Use DeepSeek Harness's AcFun (A站, acfun.cn) tools to search videos, read video metadata, comment threads, danmaku, the day/week ranking, and playable m3u8 URLs; also like/unlike, tip bananas, favorite, follow, and post comments when the user has enabled AcFun write actions. Use when the user asks about AcFun / A 站 videos, wants a video's details, comments or 弹幕, the A 站 ranking, a playable/downloadable stream URL, or asks to interact on AcFun.
|
||||
description: Use DeepSeek Harness's AcFun (A站, acfun.cn) tools to search videos, read video metadata, comment threads, danmaku, the day/week ranking, playable m3u8 URLs and 动态 (moments); also like/unlike, tip bananas, favorite, follow, comment, and interact with moments when the user enabled AcFun write actions. Use when the user asks about AcFun / A 站 videos, wants a video's details, comments or 弹幕, the A 站 ranking, a playable stream URL, a 动态 (moment) or 社区圈 topic, or asks to interact on AcFun.
|
||||
---
|
||||
|
||||
# AcFun tools
|
||||
|
||||
Eight tools talk to acfun.cn through the `dsh-plugin-acfun` bundle. Six are
|
||||
anonymous reads; two can mutate the configured account and are disabled unless
|
||||
the user opted in.
|
||||
Twelve tools talk to acfun.cn through the `dsh-plugin-acfun` bundle. Nine are
|
||||
anonymous reads; `acfun_interact` and `acfun_post_comment` mutate the configured
|
||||
account and are disabled unless the user opted in. Login happens in the Harness
|
||||
Web UI (**Settings → AcFun**, QR code) or through a cookie in the plugin config.
|
||||
|
||||
## Read workflow
|
||||
## Video workflow
|
||||
|
||||
1. `acfun_search` — keyword search. Pass `sort: "views"` only when the user wants
|
||||
popular videos; AcFun returns at most its first 30 results and ignores paging,
|
||||
@@ -25,29 +26,51 @@ the user opted in.
|
||||
6. `acfun_playurl` — m3u8 ladder; name a `quality` such as `"720P"` or omit it
|
||||
for the highest. URLs are time-limited, so do not cache them in prose.
|
||||
|
||||
## 动态 (moment) workflow
|
||||
|
||||
AcFun has **no public 广场 feed**: do not promise a browsable square and do not
|
||||
invent ids. Reaching moments works like this:
|
||||
|
||||
1. `acfun_topic` with a numeric topic id (for example `488` for 教育) — the only
|
||||
public discovery surface. It parses the mobile 社区圈 page and returns at most
|
||||
15 newest items; only `resourceType: 3` items carry a `momentId`. There is no
|
||||
pagination, so say that instead of retrying with a page parameter.
|
||||
2. `acfun_moment` with that `momentId` (or a
|
||||
`https://m.acfun.cn/communityCircle/moment/...` share link) — full text,
|
||||
topics, images, author, counts and the canonical share URL.
|
||||
3. `acfun_moment_comments` — the moment's comment thread; moments use a different
|
||||
comment enum than videos, which is why this is a separate tool.
|
||||
4. Cite the returned `shareUrl`. A moment's `tagList` ids feed `acfun_topic`.
|
||||
|
||||
Publishing, deleting, reposting and voting on a 动态 are app-only (signed token);
|
||||
refuse them and explain, rather than attempting a creative workaround.
|
||||
|
||||
## Interaction workflow
|
||||
|
||||
`acfun_interact` (`like`, `unlike`, `banana`, `favorite`, `unfavorite`,
|
||||
`follow`, `unfollow`) and `acfun_post_comment` require
|
||||
`allowWriteActions: true` plus a login cookie in the plugin config.
|
||||
`acfun_interact` — video actions `like`, `unlike`, `banana`, `favorite`,
|
||||
`unfavorite`, `follow`, `unfollow`; moment actions `moment_like`,
|
||||
`moment_unlike`, `moment_banana`. `acfun_post_comment` posts on a video (`acId`)
|
||||
or a 动态 (`momentId`). All require `allowWriteActions: true` plus a login.
|
||||
|
||||
- Only act when the user explicitly asked for that interaction. Do not "helpfully"
|
||||
like, follow, favorite, or comment on something you merely read.
|
||||
- Before a mutating call, state what will happen and on which video/user. For
|
||||
comments, show the exact text if the user did not already dictate it.
|
||||
- Before a mutating call, state what will happen and on which video, moment, or
|
||||
user. For comments, show the exact text if the user did not already dictate it.
|
||||
- Make the call at most once. If the result is ambiguous, re-read with
|
||||
`acfun_video` / `acfun_comments` instead of repeating the mutation.
|
||||
`acfun_video` / `acfun_comments` / `acfun_moment_comments` instead of repeating
|
||||
the mutation.
|
||||
- Moment writes are newer and less verified than video writes; treat them as
|
||||
experimental and never retry blindly.
|
||||
- `WRITE_DISABLED` means `allowWriteActions` is false; `LOGIN_REQUIRED` means the
|
||||
cookie is missing or expired. Report the exact missing config key and stop —
|
||||
do not retry the same call.
|
||||
session is missing or expired — point the user to Settings → AcFun and stop.
|
||||
- `CAPTCHA_REQUIRED` means AcFun wants an interactive verification; ask the user
|
||||
to complete it in a browser, then continue later.
|
||||
|
||||
## Citing and reporting
|
||||
|
||||
- When you use a video's content, cite its `url` as a markdown link.
|
||||
- Prefer the compact tables/lists the tools render; do not dump raw JSON.
|
||||
- When you use content, cite its `url` / `shareUrl` as a markdown link.
|
||||
- Prefer the compact text the tools render; do not dump raw JSON.
|
||||
- Mention the limitations that affect the answer: search cannot paginate, bangumi
|
||||
and article pages are unsupported, and play URLs expire.
|
||||
- Never invent fields the tools did not return. Counts that AcFun only exposes as
|
||||
and article pages are unsupported, play URLs expire, 广场 has no public feed.
|
||||
- Never invent fields the tools did not return. Counts AcFun exposes only as
|
||||
formatted text (`likeCountText`, `commentCountText`) must be quoted as text.
|
||||
|
||||
Reference in New Issue
Block a user