feat: AcFun tools plugin for DeepSeek Harness

Eight tools (search, video, comments, danmaku, rank, playurl, interact,
post_comment) over acfun.cn's public web API, a model-facing skill, a
bundle patch with a skill-filesystem row, and offline/live checks.

Reads are anonymous; writes require allowWriteActions plus a login cookie.
This commit is contained in:
tamakyi
2026-10-27 11:26:51 +08:00
commit f9bedde19e
19 changed files with 3530 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
* text=auto eol=lf
*.png binary
*.jpg binary
*.webp binary
+4
View File
@@ -0,0 +1,4 @@
node_modules/
*.log
.DS_Store
Thumbs.db
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 dsh-acfun contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+129
View File
@@ -0,0 +1,129 @@
# 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).
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
`allowWriteActions: true` **and** an AcFun login cookie, so installing the bundle
never silently gives the model write access to an account.
## Tools
| Tool | Kind | What it does |
| --- | --- | --- |
| `acfun_search` | read | Keyword video search; `sort: "views"` orders by play count (AcFun returns one fixed first page of up to 30). |
| `acfun_video` | read | Full metadata for one video: author, channel, tags, stats, publish time, cover. |
| `acfun_comments` | read | One page of a video's comment thread, in floor order. |
| `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. |
A model-facing skill (`skills/plugin-tool-acfun/SKILL.md`) teaches the tool
sequence and the write-safety rules.
## Install
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.
To remove it, use `plugin_manager action=remove_bundle target=dsh-plugin-acfun`.
## Configuration
The row config lives in `cordis.patch.yml`; every key is optional.
| Key | Default | Meaning |
| --- | --- | --- |
| `cookie` | `''` | Full AcFun `Cookie` header value. Prefer the environment variable so the secret does not enter the patch. |
| `cookieEnv` | `ACFUN_COOKIE` | Environment variable read when `cookie` is empty. |
| `allowWriteActions` | `false` | Master switch for `acfun_interact` and `acfun_post_comment`. |
| `requestTimeoutMs` | `30000` | Per-HTTP-request timeout. |
| `timeoutMs` | `45000` | Cooperative per-tool-call budget (write calls use at least 60 s). |
Example with the environment variable:
```powershell
$env:ACFUN_COOKIE = 'auth_key=...; acPasstoken=...; _did=...'
```
### Getting the cookie
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
`Cookie` request header of the document request (it contains `auth_key`,
`acPasstoken`, `_did`, and other session values).
3. Paste it as one line into `cookie` or `ACFUN_COOKIE`.
Treat that string like a password: it grants full access to the account. The
plugin never logs it.
## Safety model
- Reads cannot mutate anything and need no cookie.
- Writes require both `allowWriteActions: true` and a cookie. With only one of
them the tools return `WRITE_DISABLED` / `LOGIN_REQUIRED` and perform no HTTP
write request.
- Write tools are declared non-concurrency-safe so the harness will not run two
account mutations in parallel.
- A CAPTCHA/identity-verification challenge from AcFun (`result` 400001–410999)
is surfaced as `CAPTCHA_REQUIRED`; the plugin never tries to solve it.
## Verification status
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
(`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`).
- 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.
## Known limitations
- AcFun's pc-direct search endpoint ignores `page`/`pageSize` and always returns
its first page (30 entries), so `acfun_search` cannot paginate. `sort` is the
only ordering control AcFun honours.
- Bangumi (番剧) and article pages are not supported — `acfun_video` reports
`UNSUPPORTED_RESOURCE` instead of guessing.
- 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
expires. Downloading/merging streams is out of scope.
- This plugin only talks to acfun.cn's public web endpoints; it is unofficial and
can break when AcFun changes them.
## Development
```
node scripts/self-check.mjs # offline: exports, tool schemas, write guard
node scripts/live-check.mjs # online: real reads + 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.
See `docs/acfun-api.md` for the endpoint notes this plugin is based on.
## License
MIT — see `LICENSE`.
+115
View File
@@ -0,0 +1,115 @@
# dsh-plugin-acfun
给 **DeepSeek Harness** 用的 **AcFun(A站)** 插件:搜索视频、读取视频详情、
评论、弹幕、日/周排行榜和可播放的 m3u8 地址;只有在你主动开启后,才能用自己
的账号进行互动(点赞、投蕉、收藏、关注、发评论)。
这是社区 B 站工具插件在 A 站上的对应实现。读取类工具默认匿名可用;所有写操作
必须同时满足 `allowWriteActions: true` **和** AcFun 登录 Cookie,因此装好插件
并不会悄悄把账号的写权限交给模型。
## 工具
| 工具 | 类型 | 作用 |
| --- | --- | --- |
| `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` | 写 | 发表评论或回复某条评论。 |
插件还附带一个面向模型的技能文件 `skills/plugin-tool-acfun/SKILL.md`,说明工具
调用顺序和写操作的安全规则。
## 安装
把工作区目录作为 bundle 安装:
```
plugin_manager action=install_bundle target=C:\path\to\dsh-acfun
```
插件会向 Loader 插入一行(`id: acfun`)。本地目录是链接安装,修改插件代码后
需要重启 Harness 才能加载新的模块实例。
卸载:`plugin_manager action=remove_bundle target=dsh-plugin-acfun`。
## 配置
配置位于 `cordis.patch.yml` 的 `acfun` 行,所有键都可省略。
| 键 | 默认值 | 含义 |
| --- | --- | --- |
| `cookie` | `''` | 完整的 AcFun `Cookie` 请求头。推荐用环境变量,避免密钥写进 patch。 |
| `cookieEnv` | `ACFUN_COOKIE` | `cookie` 为空时读取的环境变量名。 |
| `allowWriteActions` | `false` | `acfun_interact` / `acfun_post_comment` 的总开关。 |
| `requestTimeoutMs` | `30000` | 单次 HTTP 请求超时。 |
| `timeoutMs` | `45000` | 单次工具调用的协作式预算(写操作至少 60 秒)。 |
使用环境变量的例子:
```powershell
$env:ACFUN_COOKIE = 'auth_key=...; acPasstoken=...; _did=...'
```
### 获取 Cookie
1. 在桌面浏览器登录 <https://www.acfun.cn/>。
2. 打开开发者工具 → Network,刷新任意 acfun.cn 页面,复制文档请求的完整
`Cookie` 请求头(包含 `auth_key`、`acPasstoken`、`_did` 等会话值)。
3. 把整行粘贴到 `cookie` 或 `ACFUN_COOKIE`。
这串 Cookie 等同于账号密码,插件不会把它写进日志。
## 安全设计
- 读取类工具不会修改任何内容,也不需要 Cookie。
- 写操作必须同时具备 `allowWriteActions: true` 与 Cookie;只满足其一时工具会
返回 `WRITE_DISABLED` / `LOGIN_REQUIRED`,并且不会发出任何写请求。
- 写工具声明为非并发安全,Harness 不会并行执行两个账号操作。
- 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 上下文完成注册,写操作守卫正确失败关闭
(`node scripts/self-check.mjs`)。
- 写接口路由存在,匿名调用返回 `{"result":-401}`;但本次没有 AcFun 账号
Cookie,**未**验证登录后的成功路径。首次使用时请以 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 站改动接口后可能失效。
## 开发
```
node scripts/self-check.mjs # 离线:导出、工具 schema、写操作守卫
node scripts/live-check.mjs # 在线:真实读取 + schema 校验 + 渲染
```
无构建步骤、无运行时依赖。`lib/` 下全部是只依赖 Node 内置能力的纯 ESM。插件
故意不导出 `Config` schema:工作区链接安装的 bundle 无法解析 `@deepseek-ai/*`
包,所以由 `lib/config.js` 校验配置并带告警地回退到默认值。
接口逆向笔记见 [docs/acfun-api.md](docs/acfun-api.md)。
## 许可证
MIT,见 `LICENSE`。
+27
View File
@@ -0,0 +1,27 @@
# dsh-plugin-acfun bundle patch — one insert row over the profile tree.
# Override any field in the profile's cordis.patch.yml — later layers win per row.
- insert:
- id: acfun
name: 'dsh-plugin-acfun'
config:
# Optional: AcFun login cookie string (see README.zh.md). Leave empty for
# anonymous reads. Prefer the environment variable over inlining a secret.
cookie: ''
# Environment variable read when `cookie` is empty.
cookieEnv: ACFUN_COOKIE
# Interactions (like / coin / favorite / follow / comment) stay off until
# this is explicitly true. Reads never require it.
allowWriteActions: false
# Per-request timeout (ms).
requestTimeoutMs: 30000
# Cooperative per-call tool budget (ms). Write calls use at least 60s.
timeoutMs: 45000
# Second row: expose this bundle's model-facing Skill (skills/) to the Agent.
# The packaged Skill root is resolved from the installed npm identity anchored
# at the profile (Loader baseUrl), never concatenated onto baseUrl.
- id: acfun-skill-filesystem
name: '@deepseek-ai/dsh-skill-filesystem'
config:
providerName: acfun-plugin
includeDefaultRoots: false
bundledSkillDir: !!js process.getBuiltinModule('node:path').join(process.getBuiltinModule('node:path').dirname(process.getBuiltinModule('node:module').createRequire(baseUrl).resolve('dsh-plugin-acfun/package.json')), 'skills')
+841
View File
@@ -0,0 +1,841 @@
# AcFun (acfun.cn / "A站") Web API — reverse-engineered for a Node.js `fetch` plugin
All probes were executed live on **2026-10-27 (Asia/Shanghai)** against `https://www.acfun.cn`
using desktop Chrome UA and `Referer: https://www.acfun.cn/`. Every endpoint below is marked
**[VERIFIED]** (called live, real status/JSON pasted) or **[UNVERIFIED]** (contract only — usually
because it needs a logged-in session I do not have; writes were *never* executed).
---
## 0. Conventions, host map, and shared plumbing
### 0.1 Base hosts
| Purpose | Host |
|---|---|
| PC web JSON API | `https://www.acfun.cn` (paths under `/rest/pc-direct/...`) |
| Login / token service | `https://id.app.acfun.cn` (`/rest/web/token/get`) |
| Like ("顶") service | `https://kuaishouzt.com` (`/rest/zt/interact/add`), also `api.kuaishouzt.com` |
| App API (legacy, mostly dead) | `https://api-ipv6.acfunchina.com` (`/rest/app/...`) |
| Video CDN (m3u8/ts) | `https://tx-video.acfun.cn`, `https://tx-safety-video.acfun.cn`, `https://ali-safety-video.acfun.cn` |
### 0.2 Two different "video ids" — this is the #1 gotcha
The PC web uses **two numeric ids per video**:
| Name in payload | Meaning | Example | Used by |
|---|---|---|---|
| `dougaId` / `contentId` / `id` | The "ac number" — what appears in `ac12345678` and in `/v/acNNNN` URLs | `42588725` | page URL, comments (`sourceId`), favorites (`resourceId`), banana (`resourceId`), interact (`objectId`) |
| `videoId` / `currentVideoId` / `currentVideoInfo.id` | Per-part id (a video can have several parts) | `32224197` | playInfo (`videoId`), danmaku (`resourceId`) |
**[VERIFIED]** `/v/ac42588725` → HTTP 200, full SSR payload.
**[VERIFIED]** `/v/ac32224197` (using the *part* id in the URL) → **HTTP 404** (the app shell).
So a plugin's `getVideo(acId)` must treat the number the user typed as `dougaId`; the `videoId`
needed by playInfo/danmaku comes out of the detail payload (`currentVideoId`, `videoList[].id`).
### 0.3 Required request headers
**[VERIFIED]** JSON reads work **without** `Referer` and even with curl's default UA:
```
no-referer search HTTP 200
no-UA search HTTP 200 -> {"result":0,"totalNum":547,...}
```
Recommended (matches the browser, avoids surprises):
```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/
Accept: application/json, text/plain, */*
```
- `Content-Type: application/x-www-form-urlencoded; charset=UTF-8` for every POST listed here
(the site's own client sends form-encoded, **not** JSON, for `/rest/pc-direct/*`).
- **No `sign` / `__` / `md5` parameter is needed** on any read endpoint verified here. The site's
own comment widget (`@ac/pc-comment.*.js`) posts plain form data with
`subBiz=jimu&kpf=PC_WEB&kpn=ACFUN_APP` and no signature. (Auth token is a separate thing, §7.)
### 0.4 Common response envelope
```json
{"result": 0, "host-name": "hb2-acfun-kce-node20.aliyun"}
```
Observed `result` codes:
| `result` | HTTP | Meaning |
|---|---|---|
| `0` | 200 | success |
| `1` | 200 | generic "ok" for some interact endpoints (`/rest/zt/interact/add`) |
| `11` | 200 | `"网络错误,请稍后再试"` / `"请检查下网络连接是否正常"` — handler ran but request context/params rejected (e.g. POST to a route that wants different params) |
| `21` | 200 | `"参数格式错误,请仔细阅读API文档"` — missing/malformed parameter |
| `-401` | **401** | `"user not login"` — route exists, cookie session missing/invalid |
| `-401` | **401** | `"token value error"` — route exists, requires the `acfun.midground.api_st` token (§7) |
| `105001` | 200 | app API (`api-ipv6.acfunchina.com`) — auth token invalid/missing |
### 0.5 Auth cookies (names only — never logged in here)
**[VERIFIED]** an anonymous visit to `https://www.acfun.cn/` sets only:
```
.acfun.cn _did web_83374403393B0029
www.acfun.cn csrfToken Pkyh8iuCGfRggSGwYox04LCz
```
**[UNVERIFIED]** logged-in session (from community docs for `id.app.acfun.cn/rest/app/login/signin`
plus the PC web's own `getCookie("auth_key")` usage):
```
acPasstoken <- login response field acPassToken (the main session cookie)
auth_key <- login response field auth_key (numeric userId)
_userid <- numeric userId
acPlatform = 2 (PC web)
_did, csrfToken (set for anonymous too)
```
Probes with forged values (`acPasstoken=deadbeef; auth_key=123456; _userid=123456; acPlatform=2`)
still return `{"result":-401,"error_msg":"user not login"}` — proving the server validates the
session rather than merely checking cookie presence. **Minimum cookie set remains UNVERIFIED.**
---
## 1. Video detail — [VERIFIED] via SSR page (there is no working `pc-direct` JSON endpoint)
### 1.1 Endpoint
```
GET https://www.acfun.cn/v/ac{dougaId} # dougaId == contentId == the "ac" number
```
- HTTP **200**, `text/html`, ~139 KB.
- The page embeds the entire structured video object as a JS assignment:
```js
window.pageInfo = window.videoInfo = { ... }; // then: window.videoResource = {}; ...
```
Parse with a regex from `window.pageInfo = window.videoInfo = ` up to `window.videoResource`, trim
the trailing `;`, then `JSON.parse`. In PowerShell (what I actually ran):
```powershell
$c = Get-Content page.html -Raw
$s = $c.IndexOf('window.pageInfo = window.videoInfo = ') + 38
$e = $c.IndexOf('window.videoResource', $s)
$o = ($c.Substring($s, $e-$s).Trim().TrimEnd(';') | ConvertFrom-Json)
```
Node equivalent for the plugin:
```js
const html = await (await fetch(`https://www.acfun.cn/v/ac${acId}`, {headers})).text();
const start = html.indexOf('window.pageInfo = window.videoInfo = ');
const end = html.indexOf('window.videoResource', start);
const info = JSON.parse(html.slice(start + 38, end).trim().replace(/;\s*$/, ''));
```
**Evidence:** HTTP 200, payload length 15,567 chars, 63 top-level keys.
```
HTTP 200 https://www.acfun.cn/v/ac42588725
LEN 138828 (html) pageInfo JSON LEN 15567
TOP KEYS: currentVideoId, pctr, commentCountRealValue, groupId, bananaCountShow,
stowCountShow, giftPeachCountShow, stowCount, channel, likeCount, hasHotComment,
shareCountShow, isDislike, shareCount, picShareUrl, danmakuCount, currentVideoInfo,
viewCountShow, dougaId, commentCountTenThousandShow, coverImgInfo, commentCount,
coverUrl, disableEdit, createTimeMillis, superUbb, shareUrl, status, isFavorite,
isLike, description, title, belongToSpecifyArubamu, result, videoList, isThrowBanana,
viewCount, bananaCount, likeCountShow, coverCdnUrls, giftPeachCount, isRewardSupportted,
commentCountShow, durationMillis, host-name, tagList, danmakuCountShow, createTime,
user, mkey, priority
```
### 1.2 Field mapping (exactly what a plugin needs)
| Plugin field | Path | Sample (ac42588725) |
|---|---|---|
| ac number | `dougaId` | `42588725` |
| part/video id (for playInfo/danmaku) | `currentVideoId` | `32224197` |
| title | `title` | `【原神】三周年超治愈的歌曲《你呀你呀》` |
| description | `description` | `""` (many videos have empty) |
| author name | `user.name` | `天阶夜色凉如水de陈秀凤` |
| author id | `user.id` | `74428749` |
| author avatar | `user.headUrl` (also `user.headCdnUrls[].url`) | `…/defaultAvatar.jpg?imageslim` |
| author signature | `user.signature` | — |
| author fans | `user.fanCountValue` (number) / `user.fanCount` (display) | — |
| is following author | `user.isFollowing` | `false` |
| views | `viewCount` (`viewCountShow` display) | `5682` |
| danmaku count | `danmakuCount` (`danmakuCountShow`) | `8` |
| comments | `commentCount` (also `commentCountRealValue`) | `21` |
| likes/"顶" | `likeCount` (`likeCountShow`) | `217` |
| favorites/collect | `stowCount` (`stowCountShow`) | `324` |
| bananas/投蕉 | `bananaCount` (`bananaCountShow` string `"119"`) | `119` |
| shares | `shareCount` (`shareCountShow`) | `34` |
| publish time | `createTimeMillis` (ms epoch) / `createTime` (`"2023-10-8"`) | `1696772749620` |
| duration | `durationMillis` (ms) | `196976` (= 3:16) |
| cover | `coverUrl` (`coverImgInfo.thumbnailImage.cdnUrls[].url` variants) | `https://tx-free-imgs.acfun.cn/newUpload/…png?imageslim` |
| channel | `channel.id`, `channel.name`, `channel.parentId`, `channel.parentName` | `106 / 动画综合 / 1 / 动画` |
| tags | `tagList[]` → `{id, name}` | `[{id:"609267",name:"原神空"},{id:"371390",name:"#原神"},{id:"9546",name:"原神"}]` |
| parts | `videoList[]` → `{id, title, durationMillis}` | `[{id:"32224197",title:"noTitle",durationMillis:196976}]` |
| playback (embedded!) | `currentVideoInfo.ksPlayJson` / `ksPlayJsonHevc` | see §4 |
| viewer state | `isLike`, `isFavorite`, `isThrowBanana`, `isFollowing` | all `false` anonymously |
| video status | `status` | `2` |
### 1.3 Endpoint status (dead paths)
**[VERIFIED 404]** — all return the SPA 404 HTML (~102.5 KB `<!DOCTYPE html>` shell):
```
/rest/pc-direct/video/index?videoId=32224197
/rest/pc-direct/video/detail?videoId=32224197
/rest/pc-direct/video/getVideoInfo?videoId=32224197
/rest/pc-direct/video/info?videoId=32224197
```
Caveat: the SSR payload is the only *complete* detail source. The search API (§2) can supply a
partial substitute (title/author/avatar/counts/cover/duration/channelId/ctime) but has **no
description and no tag names**, and it only works if the video is searchable.
---
## 2. Video search — [VERIFIED]
### 2.1 Endpoint
```
GET https://www.acfun.cn/rest/pc-direct/search/video?keyword={urlencoded}&page=1&pageSize=20[&sortType=N]
```
Also accepts **POST** with the same form fields (identical JSON result). No referer/auth needed.
**Evidence (baseline, the example that already worked):**
```
HTTP 200 len 23026
{"result":0,"totalNum":600,"videoList":[{...}]}
```
### 2.2 Parameters — and the pagination trap
| Param | Status | Notes |
|---|---|---|
| `keyword` | **[VERIFIED]** required, URL-encoded UTF-8 | `keyword=原神` → 600 hits |
| `page` | **[VERIFIED] ignored** | `page=1/2/3` returns the *identical* 30 rows (first id always `32224197`) |
| `pageSize` | **[VERIFIED] ignored** | `pageSize=5/10/20/50` all return exactly **30** rows |
| `pageNum`, `pageNo`, `pn`, `offset`, `cursor`, `pcursor`, `currentPage` | **[VERIFIED] ignored** | all identical results (tried each) |
| `sortType` | **[VERIFIED] honoured** | `0`/`1`/omitted = relevance (default); **`2` = most-viewed** (ordering by `viewCount` desc: `29725, 29452, 8253, 7443, 5682`); `3`/`4` behaved like `2` |
Evidence for ignored pagination:
```
pageSize=5 -> HTTP 200 count=30 total=600
pageSize=20 -> HTTP 200 count=30 total=600
page=2 -> HTTP 200 count=30 first=32224197 (same as page 1)
GET 龙 page1 sort2 -> 200 total=600 count=30 first=17777112
GET 龙 page2 sort2 -> 200 total=600 count=30 first=17777112
POST (page=2) -> 200 len=22040 (same shape; page still not applied)
```
The response echoes bogus pagination metadata (`pageNum=20`, `pageSize=30` regardless of input),
which is a server-side bug. **Conclusion: only the first 30 results are obtainable.** Deep search
paging must go through the SSR search page (`/search?keyword=...&page=N`, server-rendered) instead.
`totalNum` sanity check: obscure keyword `qzxwvn` → `totalNum=0`, `videoList=[]`; `test` → 547;
common terms (`原神`, `编程`, `龙`) → `600` (appears to be a display cap at 600).
### 2.3 Response field map (`videoList[]`)
```
top-level keys: result, totalNum, videoList, requestId, pageSize, host-name, pageNum
videoList[0] keys: groupId, emTitle, viewCountInfo, danmakuCountInfo, displayInfo,
belongToSpecifyArubamu, channelId, viewCount, commentCount, coverUrl, userImg, videoId,
contentId, danmuCount, playDuration, userName, userId, id, itemType, ctime, title
```
| Plugin field | Path | Sample |
|---|---|---|
| ac number | `contentId` (== `id`) | `42588725` |
| part id (for playInfo/danmaku) | `videoId` | `32224197` |
| title | `title` | `【原神】三周年超治愈的歌曲《你呀你呀》` |
| title with `<em>` highlights | `emTitle` | `【<em>原神</em>】…` |
| author name | `userName` | `天阶夜色凉如水de陈秀凤` |
| author id | `userId` | `74428749` |
| author avatar | `userImg` | `https://tx-free-imgs.acfun.cn/…?imageslim` |
| views | `viewCount` / `viewCountInfo` | `5682` / `"5682次播放"` |
| comments | `commentCount` | `21` |
| danmaku | `danmuCount` / `danmakuCountInfo` | `8` |
| duration | `playDuration` (`"mm:ss"` string!) | `"03:16"` |
| published | `ctime` (ms epoch) | `1696772749620` |
| channel | `channelId` (id only — no name!) | `106` |
| cover | `coverUrl` | `https://tx-free-imgs.acfun.cn/newUpload/…png?imageslim` |
| type | `itemType` | `2` (video) |
| display string | `displayInfo` | `"5682次播放 2023-10-08"` |
| short description | `decr` (some items only) | — |
### 2.4 Sibling search endpoints
**[VERIFIED 200]** `GET /rest/pc-direct/search/suggest?keyword=...` — used by the header search box
(found in `common/widget/searchBox/index.*.js`).
**[VERIFIED 200, documented]** `GET /rest/pc-direct/search/user?keyword=...` (returns `userList`).
---
## 3. Comments (评论 / 回复) — [VERIFIED]
### 3.1 Main comment list (new "JIMU" comment system — works anonymously)
```
GET https://www.acfun.cn/rest/pc-direct/comment/listByFloor
?sourceId={dougaId}&sourceType=3&page=1&supportZtEmot=true
```
- `sourceId` = the **ac number** (`dougaId`), *not* `videoId`.
- `sourceType` = **3** for videos (the embedded comment objects themselves carry
`"sourceType": 3`, which is the authoritative value). Lenient server quirk: `sourceType=1`
also returned the same 21 comments; `sourceType=2` returned 0. Use **3**.
- `supportZtEmot=true` enables Kuaishou emoticon rendering in `content`.
- Optional `pivotCommentId` / `newPivotCommentId` / `t` (cache-buster ms) accepted.
**Evidence (HTTP 200):**
```json
{"result":0,"curPage":1,"isUp":false,"sourceType":3,"totalPage":1,
"pivotCommentId":0,"commentsMap":{...},"contentUbbVersion":"1.0",
"pageSize":50,"totalCount":21,"commentIds":["c806796787", ...]}
```
`totalCount=21` matches `commentCount`/`commentCountRealValue` in pageInfo. `pageSize` is 50,
`totalPage` 1. `commentsMap` is an **object keyed by `"c" + cid`** (`"c806796787"`), and
`commentIds[]` lists those keys in display order (newest first).
**Comment object field map** (from `commentsMap["c…"]`):
```
userHeadImgInfo, avatarFrameImgInfo, imageInfo, cid, userId, isUpDelete, floor, postDate,
isUp, userName, sourceType, verified, sourceId, isDelete, headUrl, nameRed, avatarFrame,
quoteId, nameColor, verifiedTypes, deviceModel, isSignedUpCollege, avatarImage, imageUrl,
content, timestamp
```
| Plugin field | Path | Sample |
|---|---|---|
| comment id | `cid` | `806796787` |
| author id / name | `userId` / `userName` | `77984979` / `不知何处吊湘君de段倩玉` |
| avatar | `headUrl[]` → `{cdn,url}`; also `userHeadImgInfo.thumbnailImage.cdnUrls[].url` | `https://tx-free-imgs.acfun.cn/…` |
| content (UBB, may contain `[emot=acfun,ID/]`) | `content` | `[emot=acfun,2789/][emot=acfun,2789/]…` |
| floor | `floor` | `10` |
| posted | `timestamp` (ms) / `postDate` (`"07-28 21:32"` local, no year) | `1785245573037` |
| is uploader | `isUp` | `false` |
| verified badge | `verified` / `verifiedTypes[]` | `0` |
| name color | `nameColor`, `nameRed` | `0` |
| deleted markers | `isDelete`, `isUpDelete` | `false` |
| device | `deviceModel` | `HUAWEI ART-AL00x` |
| quote | `quoteId` | `0` |
| source | `sourceId`, `sourceType` | `42588725`, `3` |
Note: `listByFloor` comment objects have **no** `likeCount` / `subCommentCount` fields; reply
counts come from the sublist call (§3.2). Total count comes from `totalCount` on the list response.
### 3.2 Reply / sub-comments — [VERIFIED]
```
GET https://www.acfun.cn/rest/pc-direct/comment/sublist
?sourceId={dougaId}&sourceType=3&rootCommentId={cid}&page=1&supportZtEmot=true
```
**Evidence (HTTP 200):**
```json
{"result":0,"subCommentCount":0,"curPage":1,"subComments":[],"sourceType":3,
"totalPage":0,"contentUbbVersion":"1.0","pcursor":"no_more","pageSize":10,
"contentTitle":"【原神】三周年超治愈的歌曲《你呀你呀》",
"rootComment":{...}}
```
`subComments[]` items follow the same shape as §3.1 (they additionally carry `subCommentCount` /
reply-level fields). `pageSize=10`, `pcursor` ∈ `{number, "no_more"}`.
### 3.3 Dead / auth-gated comment routes
**[VERIFIED]** `POST /rest/pc-direct/comment/list` (`objectId`+`pcursor`+`pageSize`,
the "SECONDARY"/legacy widget path) — **does not work anonymously**:
```
POST /rest/pc-direct/comment/list (objectId=42588725&pcursor=&pageSize=20&subBiz=jimu&kpf=PC_WEB&kpn=ACFUN_APP)
-> HTTP 200 {"result":11,"error_msg":"网络错误,请稍后再试"}
GET /rest/pc-direct/comment/list?objectId=…&objectType=…&page=…&pageSize=…
-> HTTP 200 {"result":21,"error_msg":"参数格式错误,请仔细阅读API文档。"}
```
`/comment/subCommentList`, `/comment/hotList`, `/comment/replyList` → **[VERIFIED 404]** (SPA shell).
The legacy `objectType` parameter does not exist in the new widget at all (0 occurrences in
`@ac/pc-comment.*.js`); classification is by `sourceId`+`sourceType` only.
---
## 4. Playback (playInfo / m3u8 / quality list) — [VERIFIED]
### 4.1 playInfo endpoint
```
GET https://www.acfun.cn/rest/pc-direct/play/playInfo/ksPlayJson?videoId={videoId}
```
(POST with the same form body also works. `videoId` is the **part id** from pageInfo
`currentVideoId`, not the ac number. Extra `resourceId`/`resourceType` params are ignored.)
**Evidence (HTTP 200, 7,494 bytes):**
```json
{"result":0,"playInfo":{
"streams":[],
"ksPlayJsonHevc":"{\"version\":\"1.0.0\",\"businessType\":1,...}",
"domainInfos":…, "duration":…, "sizeType":…, "firstFrameUrls":…,
"userPlayedSeconds":0, "danmakuGuidePosition":…, "durationMillis":196976,
"ksPlayJson":"{\"version\":\"1.0.0\",\"businessType\":1,\"mediaType\":1,
\"videoId\":\"a7856caac8f5d4cc\",\"hideAuto\":false,\"adaptationSet\":[{
\"id\":0,\"duration\":196976,\"representation\":[
{\"id\":1,\"url\":\"https://tx-video.acfun.cn/mediacloud/acfun/acfun_video/a7856caac8f5d4cc-…-hls_1080p_h264_6m_1.m3u8?sign=6971e530669cf53e31dbf8a424d4acc6&t=1793091872&us=2128358904\",
\"backupUrl\":[\"https://ali-safety-video.acfun.cn/…\"],\"maxBitrate\":6000,\"avgBitrate\":1483,
\"width\":1920,\"heigh\":1080,\"qualityType\":\"1080p+\",\"qualityLabel\":\"1080P+\",…}]}]}",
"isKsManifest":…, "transcodeInfos":[…]},
"host-name":"hb2-acfun-kce-node20.aliyun"}
```
Parse: `JSON.parse(playInfo.ksPlayJson)` → `adaptationSet[0].representation[]`.
**Quality list actually returned for this video (HTTP 200):**
| id | qualityType | qualityLabel | width×height | maxBitrate | avgBitrate |
|---|---|---|---|---|---|
| 1 | `1080p+` | 1080P+ | 1920×1080* | 6000 | 1483 |
| 2 | `1080p` | 1080P | 1920×1080* | 3000 | 1143 |
| 3 | `720p` | 720P | 1280×… | 2000 | 779 |
| 4 | `540p` | 540P | 960×… | 900 | 431 |
| 5 | `360p` | 360P | 640×… | 500 | 288 |
\* the JSON field is misspelled `heigh` (not `height`) — read `rep.heigh`.
Page-level **quality catalogue** is also embedded in the HTML as
`window.qualityConfig` (all 16 tiers the player can negotiate):
`2160p120HDR, 2160p120, 2160p60HDR, 2160p60, 2160pHDR, 2160p, 1080p60HDR, 1080p60, 1080p+,
1080pHDR, 1080p, 720p60, 720p (defaultSelect), 540p, 480p, 360p` with `{qualityType, qualityLabel,
definition, disableAutoSwitch?, limitType?, defaultSelect?}`. Individual videos expose only the
tiers they were transcoded to (the 5 above), so treat `qualityConfig` as the label dictionary and
`representation[]` as the actual streams.
### 4.2 m3u8 reachability — [VERIFIED]
```
m3u8 fetch HTTP 200 ; first line: #EXTM3U
```
- Signed query: `?sign=<md5>&t=<unix seconds>&us=<int>`. **Expiring** (the `t` value is a future
timestamp ≈ +? minutes; treat the URL as short-lived, re-call playInfo when it 403s).
- Streams are HLS (`#EXTM3U` with `.ts` slices; `m3u8Slice` also inlines the playlist in pageInfo).
- CDN hosts seen: `tx-video.acfun.cn`, `tx-safety-video.acfun.cn`, `ali-safety-video.acfun.cn`.
- Anonymous access is fine — no cookies needed for either playInfo or the CDN.
### 4.3 Danmaku (弹幕) read — [VERIFIED]
```
POST https://www.acfun.cn/rest/pc-direct/new-danmaku/list
Content-Type: application/x-www-form-urlencoded
resourceId={videoId}&resourceType=9&enableAdvanced=true&pcursor=1&count=200&sortType=1&asc=false
```
- `resourceId` = **`videoId`** (part id). `resourceType` = **9** (video).
**[VERIFIED]** `resourceId=42588725` (ac number) → `totalCount=0`; `resourceId=32224197`
(videoId) → `totalCount=5`, 5 items.
- Old-style XML danmaku endpoints are dead; this is the JSON API the current PC player uses.
**Evidence (HTTP 200):**
```json
{"result":0,"danmakus":[
{"likeCount":0,"color":16777215,"mode":1,"roleId":0,"danmakuId":287561494,
"danmakuType":0,"danmakuStyle":…,"danmakuAvatarUrl":…,"isLike":false,"rank":5,
"createTime":1778494836010,"userId":77697264,"body":"啊啊啊好萌的和声",
"danmakuImgUrl":…,"position":11880,"size":25}, …],
"styleDanmakuCount":0,"requestId":"…","pcursor":"no_more",
"host-name":"hb2-acfun-kce-node24.aliyun","totalCount":5}
```
| Plugin field | Path | Sample |
|---|---|---|
| danmaku id | `danmakuId` | `287561494` |
| text | `body` | `啊啊啊好萌的和声` |
| time offset in video | `position` (**milliseconds**) | `11880` |
| color | `color` (int RGB) | `16777215` (white) |
| mode (scroll/top/bottom) | `mode` | `1` (1 scroll, 5 top, 4 bottom per player conventions) |
| font size | `size` | `25` |
| created | `createTime` (ms epoch) | `1778494836010` |
| author | `userId` | `77697264` |
| advanced/style | `danmakuType`, `danmakuStyle`, `danmakuImgUrl`, `danmakuAvatarUrl` | — |
| likes | `likeCount`, `isLike` | `0`, `false` |
Notes:
- Page `danmakuCount` (8) ≠ `totalCount` here (5): some entries are filtered/special; also
`enableAdvanced=true`/`sortType=1` change the set. Use `totalCount` as authoritative for the API.
- Pagination: `pcursor` from the response (`"no_more"` when done), `count` page size, `asc` order.
- Posting danmaku (write, not executed): `POST /rest/pc-direct/new-danmaku/add` with
`body,color,id(=ac number),mode,position,size,subChannelId,subChannelName,type=douga,videoId`
— **[UNVERIFIED]**, requires login, `Referer` set to the video page.
---
## 5. Hot / ranking / feeds — mixed
### 5.1 Banana / channel ranking — [VERIFIED] (the only public "hot" list that works)
```
POST https://www.acfun.cn/rest/pc-direct/rank/channel
Content-Type: application/x-www-form-urlencoded
channelId=0&rankPeriod=day&rankLimit=10 # rankPeriod: day | week ; GET also works
```
**Evidence (HTTP 200):**
```json
{"result":0,"host-name":"hb2-acfun-kce-node24.aliyun","rankList":[
{"groupId":"Ml8wXzE3OTMwNzAzMDY4ODhfMzg1MA_1&-124&&rlg1","userId":17735808,
"dougaId":"48940670","contentId":48940670,"title":"XBOX联名,一个比一个抽象",
"userName":"Shi咪","channelName":"生活日常","channelId":86,
"viewCount":1615,"danmuCount":47,"commentCount":23,"bananaCount":1072,"likeCount":253,
"durationMillis":358099,"videoCover":"https://tx-free-imgs.acfun.cn/newUpload/…",
"createTimeMillis":1793014710758,"isFollowing":false,"isLike":false,"isFavorite":false,
"isThrowBanana":false,"tagList":[…],"videoList":[…],"user":{…},"channel":{…}}]}
```
`rankList[]` is a **superset of the search item shape** — it carries `title`, `description`,
`channelName`, `tagList`, `durationMillis` and full `user` object, so it is a decent
"browse without search" feed. Field names: `dougaId`/`contentId` (ac number), `contentTitle`
(alias of title on some nodes), `videoCover`/`coverUrl`, `createTimeMillis`, `viewCount`,
`danmuCount`, `commentCount`, `bananaCount`, `likeCount`, `durationMillis`.
`POST /rest/pc-direct/rank/banana` (`channelId=0&rankPeriod=DAY`) → **[VERIFIED]** HTTP 200,
same `rankList` shape (每日香蕉榜).
### 5.2 Following feed — [VERIFIED] route, empty without login
```
GET https://www.acfun.cn/rest/pc-direct/feed/webPush?count=20&pcursor=0
```
```json
{"result":0,"feedList":[],"followUpers":[],"pcursor":"no_more",
"host-name":"hb2-acfun-kce-node24.aliyun","requestId":"…"}
```
Route exists and returns a valid envelope, but `feedList` is empty anonymously → **login required
for real content** [UNVERIFIED for populated shape; item fields match the rank item shape per
community docs: `videoId`, `caption`, `coverUrl`, `playDuration`, `channel`, `viewCount`,
`commentCount`, `user`, `tag`, `likeCount`, `createTime`].
### 5.3 Dead endpoints — **[VERIFIED 404]** (all returned the 102.5 KB SPA shell)
```
GET/POST /rest/pc-direct/rank/video (tried rankId=1, channelId+rankPeriod variants)
GET/POST /rest/pc-direct/rank/video/day
/rest/pc-direct/rank/douga
/rest/pc-direct/rank/list
/rest/pc-direct/rank/hot
/rest/pc-direct/feed/recommend
/rest/pc-direct/feed/hot
/rest/pc-direct/feed/list
/rest/pc-direct/recommend/list
/rest/pc-direct/recommend
/rest/pc-direct/video/recommend
/rest/pc-direct/hotVideo
/rest/pc-direct/index/recommend
/rest/pc-direct/index/hot
```
There is **no working anonymous "recommended for you" JSON endpoint** as of 2026-10. The homepage
(`https://www.acfun.cn/`) is fully SSR (pagelets `pagelet_douga`, `pagelet_dance`, …,
`pagelet_monkey_recommend`); a plugin that wants a home feed must either scrape those pagelets from
the HTML or use `rank/channel`.
---
## 6. Interactions (cookie-authenticated) — routes **[VERIFIED]**, semantics **[UNVERIFIED]**
> **I never executed a successful write** (no AcFun account, and the task forbids logging in).
> Everything below is proven to exist by unauthenticated probing: each endpoint answers
> **HTTP 401 with JSON** instead of the 404 app shell. Parameters come from the site's own
> JS bundles (`@ac/pc-comment.*.js`, `newVideo/widget/bread/bread.*.js`) — that is the exact
> contract the browser uses.
### 6.1 Like / "顶" (点赞 + 取消) — [VERIFIED route, 401]
The PC web does **not** use a `pc-direct` thumb endpoint (all `/rest/pc-direct/thumb/*` → 404).
It calls the Kuaishou-side interact service:
```
POST https://kuaishouzt.com/rest/zt/interact/add # 点赞
POST https://kuaishouzt.com/rest/zt/interact/delete # 取消点赞
Content-Type: application/x-www-form-urlencoded; withCredentials: true
```
Form fields (from `bread.es6`, `likeParams` + token merge):
| Param | Value |
|---|---|
| `objectId` | **ac number** (`contentId` / `dougaId`) |
| `objectType` | `2` |
| `interactType` | `1` |
| `subBiz` | `mainApp` |
| `kpn` | `ACFUN_APP` |
| `kpf` | `PC_WEB` |
| `acfun.midground.api_st` | token from §7, param name is `` `${sid}_st` `` with `sid = "acfun.midground.api"` |
| `userId` | `userId` returned by the token call |
| `extParams` | object: `{isPlaying, showCount, otherBtnClickedCount, playBtnClickedCount}` (jQuery serializes as `extParams[...]`) |
Success response: `{"result":1,"hostName":"…"}` (`1`, not `0`). Button state then flips locally.
**Evidence (unauthenticated):**
```
POST https://kuaishouzt.com/rest/zt/interact/add
-> HTTP 401 {"result":-401,"error_msg":"token value error","error_id":""}
```
Note: pageInfo contains `window.likeDomain = "https://kuaishouzt.com"` — use that value rather than
hardcoding; it is how the page resolves the host.
### 6.2 Coin / 投蕉 — [VERIFIED route, 401]
```
POST https://www.acfun.cn/rest/pc-direct/banana/throwBanana
Content-Type: application/x-www-form-urlencoded
resourceId={ac number}&count={1..5}&resourceType=2
```
**Evidence (unauthenticated):**
```
POST /rest/pc-direct/banana/throwBanana (resourceId=42588725&count=2&resourceType=2)
-> HTTP 401 {"result":-401,"error_msg":"user not login"}
```
App/legacy duplicate (same error class): `POST https://api-ipv6.acfunchina.com/rest/app/banana/throwBanana`
with `resourceId,count,resourceType`. Success shape per community docs:
`{"result":0,"extData":{"bananaRealCount":5,"criticalHitInfo":null}}`. Rules: cannot banana your own
video (page checks `auth_key !== videoInfo.user.id`), 1–5 per call.
### 6.3 Favorite / 收藏 — [VERIFIED routes, 401 "token value error"]
Videos ("v" category, `resourceType = 9`) use a multi-folder system:
```
POST /rest/pc-direct/favorite/folder/list {resourceId}
POST /rest/pc-direct/favorite/folder/add {name} # 1-40 chars, CJK/alnum/_
POST /rest/pc-direct/favorite/resource/add {resourceId, resourceType, addFolderIds} # comma list
POST /rest/pc-direct/favorite/resource/remove {resourceId, resourceType, delFolderIds}
POST /rest/pc-direct/favorite/resource/updateFolder {resourceId, resourceType, addFolderIds, delFolderIds}
```
`resourceId` = **ac number**, `resourceType` = **9** for videos. Max 10 folders per operation; a
folder can be full (`resourceCount >= favoriteCountLimit`) or `type:1` (default folder, no limit).
Bangumi (番剧) uses a different pair: `POST /rest/pc-direct/favorite` `{resourceId,resourceType}`
and `POST /rest/pc-direct/unFavorite` `{resourceIds,resourceType}`.
**Evidence (unauthenticated):**
```
POST /rest/pc-direct/favorite/resource/add -> HTTP 401 {"result":-401,"error_msg":"token value error"}
POST /rest/pc-direct/favorite/folder/list -> HTTP 401 {"result":-401,"error_msg":"token value error"}
```
Important: favorites require the **midground token** (§7), not just the session cookie — the error
message differs from the plain `user not login` routes. Success is `result:0` plus a `dataList` for
`folder/list` (`[{folderId, name, type, resourceCount, favoriteCountLimit, inFolder}]`).
### 6.4 Follow / 关注 (and unfollow) — [VERIFIED route, 401]
```
POST https://www.acfun.cn/rest/pc-direct/relation/follow
Content-Type: application/x-www-form-urlencoded
toUserId={userId}&action=1&groupId=-1 # action: 1 = follow, 2 = unfollow ; groupId -1 = default
```
Group management (optional): `POST /rest/pc-direct/relation/getGroups`, `getFollows`
(`action=7` follows / `8` fans, `page`, `count`, `groupId`).
**Evidence (unauthenticated):**
```
POST /rest/pc-direct/relation/follow (toUserId=52326&action=1&groupId=-1)
-> HTTP 401 {"result":-401,"error_msg":"user not login"}
```
### 6.5 Post a comment (and delete/like) — [VERIFIED routes, 401]
```
POST https://www.acfun.cn/rest/pc-direct/comment/add
Content-Type: application/x-www-form-urlencoded
objectId={ac number}&content={UBB text}&replyToCommentId=0&subBiz=jimu&kpf=PC_WEB&kpn=ACFUN_APP
```
- `objectId` = ac number (`dougaId`), **not** `sourceId`/`sourceType` (despite the list endpoint
using those). This is what `@ac/pc-comment`'s `Ke()` sends.
- `replyToCommentId = 0` for a top-level comment, or the parent `cid` to reply.
- `content` is UBB text; emoticons are `[emot=acfun,{emotionId}/]`. `ztExtParams` is optional
(image payload for the image-comment mode).
- Success: `{"result":0,"commentId":…}` (the widget then reloads the list).
- A captcha/verification branch exists for `result` 400001–410999: the site redirects to
`error_url` and obtains a `{type, token}` before retrying — a plugin should surface that failure
rather than retry blindly.
Related:
```
POST /rest/pc-direct/comment/delete {sourceId, sourceType, commentId}
POST /rest/pc-direct/comment/like {commentId} # or {sourceId, sourceType, commentId}
POST /rest/pc-direct/comment/unlike {commentId}
```
**Evidence (unauthenticated):**
```
POST /rest/pc-direct/comment/add (objectId=42588725&content=test&replyToCommentId=0)
-> HTTP 401 {"result":-401,"error_msg":"user not login"}
POST /rest/pc-direct/comment/like -> HTTP 401 {"result":-401,"error_msg":"user not login"}
```
### 6.6 Miscellaneous authenticated read routes (useful for "am I logged in?")
| Route | Method | Anon result |
|---|---|---|
| `/rest/pc-direct/user/personalInfo` | POST | `401 {"result":-401,"error_msg":"user not login"}` [VERIFIED] |
| `/rest/pc-direct/user/userInfo?userId=` | POST/GET | public profile [UNVERIFIED live, route from bundle] |
| `/rest/pc-direct/relation/getGroups` | POST | 401 |
| `/rest/pc-direct/browse/history/list` | POST | 401 |
| `/rest/pc-direct/user/signIn` | POST | 401 |
`personalInfo` doubles as the cheapest session check: `result:0` + `info.userId` means the
`acPasstoken`/`auth_key` cookie pair is valid.
---
## 7. Token flow for like / favorite (midground token) — [VERIFIED endpoints, auth required]
The site's `common.es6` exposes `getTokenGen("acfun.midground.api")`:
```
POST https://id.app.acfun.cn/rest/web/token/get
Content-Type: application/x-www-form-urlencoded
sid=acfun.midground.api
```
Success (community docs, needs a logged-in cookie jar on `id.app.acfun.cn`):
```json
{"result":0,"ssecurity":"***","userId":625724,
"acfun.midground.api_st":"***","acfun.midground.api.at":"***"}
```
- `acfun.midground.api_st` is then sent as a **form field** (not header) to
`kuaishouzt.com/rest/zt/interact/*` and to `/rest/pc-direct/favorite/*`, with `userId`.
- The PC page itself calls this token endpoint through `common/static/js/common.*.js`
(`rest/web/token/get` appears there).
- **[VERIFIED]** unauthenticated: `POST https://id.app.acfun.cn/rest/web/token/get` →
`HTTP 401 {"result":-401,"error_msg":"token value error"}` — route exists, session required.
- Token is short-lived; re-fetch per action batch rather than caching for hours. Treat the whole
like/favorite flow as **[UNVERIFIED]** end-to-end because it cannot be tested without credentials.
---
## 8. CORS, redirects, rate-limit, encoding quirks
### 8.1 CORS — [VERIFIED]
Response headers on `/rest/pc-direct/search/video`:
```
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
Strict-Transport-Security: max-age=172800
X-Cache: MISS TCP_MISS
```
- ACAO is pinned to `https://www.acfun.cn` — a browser page on another origin cannot read these
responses. **Node.js `fetch` is unaffected** (no CORS enforcement), which is why this plugin
approach works server-side.
- Cookie-authenticated calls need the cookie jar and `credentials: 'include'` only if you ever run
in a browser-like context; in Node just set the `Cookie` header.
- The kuaishouzt interact host answers with the same auth envelope; no ACAO needed from Node.
### 8.2 Required headers / redirects — [VERIFIED]
- No `Referer` and no custom UA needed for JSON reads (both tested → 200).
- `www.acfun.cn` serves everything over HTTPS directly; no redirect chains observed
(`curl -L` not needed). `m.acfun.cn` is a different (mobile) frontend — do not mix.
- 404s are **HTTP 404 with the full SPA HTML shell**, ~102.5 KB. A plugin MUST check
`content-type` / body prefix before `JSON.parse`; otherwise a dead path throws a confusing
JSON syntax error.
### 8.3 Rate limiting — [VERIFIED: none hit]
- 6 rapid consecutive search calls: `200 200 200 200 200 200`.
- During a 20-call batch at ~0 ms spacing I saw a few transient `curl` transport failures
(no response / connection reset), which recovered immediately on retry. That is the only
throttling-like behavior observed.
- Practical advice: 200–500 ms between calls, retry once on network error; no `429` seen.
### 8.4 Encoding
- All JSON is UTF-8 (`Content-Type: application/json;charset=UTF-8`). Decode as UTF-8 — Chinese
text is not escaped, so a wrong code page garbles it (this bit me in the Windows console only).
- `Content-Type` for POSTs: `application/x-www-form-urlencoded; charset=UTF-8`. json bodies are
**not** used by `/rest/pc-direct/*`.
- `playDuration` in search results is a `"mm:ss"` **string**, not seconds; `durationMillis`
elsewhere is milliseconds.
---
## 9. Recommended plugin mapping (what to call for each capability)
| Capability | Call |
|---|---|
| resolve `ac123` → detail | `GET /v/ac{acId}` → parse `window.pageInfo` (§1) |
| get part id for playback | `pageInfo.currentVideoId` (or search `videoId`) |
| search videos | `GET /rest/pc-direct/search/video?keyword=&sortType=0\|2` (first 30 only) |
| comments | `GET /rest/pc-direct/comment/listByFloor?sourceId={acId}&sourceType=3&page=N` |
| replies | `GET /rest/pc-direct/comment/sublist?sourceId={acId}&sourceType=3&rootCommentId={cid}&page=N` |
| play URL / qualities | `GET /rest/pc-direct/play/playInfo/ksPlayJson?videoId={videoId}` → `JSON.parse(playInfo.ksPlayJson)` |
| danmaku | `POST /rest/pc-direct/new-danmaku/list` (`resourceId={videoId}&resourceType=9&…`) |
| hot/ranking | `POST /rest/pc-direct/rank/channel` (`channelId=0&rankPeriod=day\|week&rankLimit=N`) |
| follow feed | `GET /rest/pc-direct/feed/webPush` (login) |
| like / favorite / follow / banana / comment | §6 + token §7 (login required) |
### Dead / do-not-use as of 2026-10-27
`/rest/pc-direct/video/{index,detail,getVideoInfo,info}`, `/rest/pc-direct/rank/video*`,
`/rest/pc-direct/feed/{recommend,hot,list}`, `/rest/pc-direct/recommend*`, `/rest/pc-direct/hot*`,
`/rest/pc-direct/thumb/*`, `/rest/pc-direct/coin/*`, `/rest/pc-direct/stow|collect/*`,
`/rest/pc-direct/comment/{list (POST anon), subCommentList, hotList, replyList}`,
`/rest/pc-direct/play/{playInfo,m3u8}`. All answered **404 + SPA shell** (or `result:11/21` for
routes that exist but reject anonymous/legacy usage). The current replacements are the SSR page
(§1), `play/playInfo/ksPlayJson` (§4) and `comment/listByFloor` (§3).
### Open items (explicitly UNVERIFIED — no login used)
1. End-to-end success shapes for like/favorite/follow/banana/comment-add (only 401 proofs here).
2. The exact minimum cookie set for authenticated calls (`acPasstoken`+`auth_key` per community
docs; PC additionally sends `_userid`, `acPlatform=2`, `csrfToken`, `_did`).
3. Whether `kuaishouzt.com` vs `api.kuaishouzt.com` matters for `/rest/zt/interact/*` in 2026
(page uses `window.likeDomain` = `https://kuaishouzt.com`; the older `api.` host returned 502 on
a probe of a different path).
4. Populated `feed/webPush` item shape (empty without login).
+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="AcFun tools">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#fd6b6b"/>
<stop offset="1" stop-color="#c81f4d"/>
</linearGradient>
<linearGradient id="play" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#fff8f0"/>
<stop offset="1" stop-color="#ffe1c4"/>
</linearGradient>
</defs>
<rect x="2" y="2" width="60" height="60" rx="16" fill="url(#bg)"/>
<rect x="2.75" y="2.75" width="58.5" height="58.5" rx="15.25" fill="none" stroke="#ffffff" stroke-opacity="0.35" stroke-width="1.5"/>
<path d="M19 45 L27.5 19 H36.5 L45 45 H37.6 L35.9 39.2 H28.1 L26.4 45 Z M30.2 32.6 H33.8 L32 25.6 Z" fill="url(#play)"/>
<path d="M33.4 12.5 L39.6 8.6 L40.9 11.4 L34.7 15.3 Z" fill="#ffffff" fill-opacity="0.55"/>
</svg>

After

Width:  |  Height:  |  Size: 912 B

+86
View File
@@ -0,0 +1,86 @@
/**
* dsh-plugin-acfun — AcFun (A站) reads and interactions as DeepSeek Harness
* tools.
*
* 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`.
*
* 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.
*/
import { resolveConfig } from './lib/config.js'
import { AcfunProvider } from './lib/provider.js'
import { ACFUN_GUIDANCE, createAcfunTools } from './lib/tools.js'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'dsh-plugin-acfun'
/** Services required before the plugin starts. */
export const inject = ['tools', 'systemPrompt']
/**
* Register one setup factory as a plugin-owned effect when the context offers
* `effect`, falling back to a direct call for older Cordis minors.
*
* @param {any} ctx - plugin context.
* @param {() => (() => void) | void} factory - performs registrations and returns a disposer.
*/
function asEffect(ctx, factory) {
if (typeof ctx.effect === 'function') {
ctx.effect(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)
}
}
/**
* Activate the plugin: register the seven AcFun tools plus one system-prompt
* guidance section.
*
* @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}`)
}
const provider = new AcfunProvider({
cookie: resolved.cookie,
requestTimeoutMs: resolved.requestTimeoutMs,
allowWriteActions: resolved.allowWriteActions,
})
asEffect(ctx, () => {
const disposers = createAcfunTools(provider, resolved).map((definition) => ctx.tools.register(definition))
return () => {
for (const dispose of disposers) {
if (typeof dispose === 'function') dispose()
}
}
})
asEffect(ctx, () => {
const dispose = ctx.systemPrompt.section({
name: 'tool:acfun',
order: 112,
text: ACFUN_GUIDANCE,
})
return typeof dispose === 'function' ? dispose : undefined
})
}
export { AcfunProvider } from './lib/provider.js'
export { ACFUN_GUIDANCE, createAcfunTools } from './lib/tools.js'
export { resolveConfig } from './lib/config.js'
+83
View File
@@ -0,0 +1,83 @@
/**
* Plugin config resolution.
*
* The plugin deliberately uses no runtime imports (no schemastery, no
* dsh-tools): a workspace-linked bundle resolves bare `@deepseek-ai/*`
* specifiers from its own directory, so it cannot see the dsh installation's
* packages. Everything here is plain ESM so `node scripts/live-check.mjs` can
* exercise it before the bundle is installed.
*/
/** Environment variable read when `cookie` is empty. */
export const DEFAULT_COOKIE_ENV = 'ACFUN_COOKIE'
/** Per-request timeout in milliseconds. */
export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000
/** Cooperative per-tool-call budget in milliseconds. */
export const DEFAULT_TOOL_TIMEOUT_MS = 45_000
/** Cooperative budget for a write call (login + token + action). */
export const DEFAULT_WRITE_TIMEOUT_MS = 60_000
/** Largest list size the rank tool accepts (server caps around this). */
export const RANK_MAX_LIMIT = 50
const DEFAULTS = Object.freeze({
cookie: '',
cookieEnv: DEFAULT_COOKIE_ENV,
allowWriteActions: false,
requestTimeoutMs: DEFAULT_REQUEST_TIMEOUT_MS,
timeoutMs: DEFAULT_TOOL_TIMEOUT_MS,
})
/** A non-empty trimmed string, or undefined. */
function text(value) {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined
}
/** A positive finite number, or undefined. */
function positive(value) {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined
}
/**
* Resolve the row config into a plain settings object.
*
* Unknown keys are ignored on purpose: the profile patch may carry comments or
* keys from an older version, and a stale key must not block activation.
*
* @param {unknown} raw - the Loader row `config` object, possibly undefined.
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env] - environment for `cookieEnv`.
* @returns {{cookie: string, cookieEnv: string, allowWriteActions: boolean, requestTimeoutMs: number, timeoutMs: number, warnings: string[]}}
*/
export function resolveConfig(raw, env = process.env) {
const source = raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}
const warnings = []
if (raw !== undefined && (raw === null || typeof raw !== 'object' || Array.isArray(raw))) {
warnings.push('config must be an object; using defaults')
}
const cookieEnv = text(source.cookieEnv) ?? DEFAULT_COOKIE_ENV
const literalCookie = text(source.cookie)
const envCookie = text(env?.[cookieEnv])
const cookie = literalCookie ?? envCookie ?? ''
if (source.cookie !== undefined && source.cookie !== '' && literalCookie === undefined) {
warnings.push('config.cookie is set but is not a non-empty string; ignoring it')
}
if (typeof source.allowWriteActions !== 'undefined' && typeof source.allowWriteActions !== 'boolean') {
warnings.push('config.allowWriteActions is not a boolean; using false')
}
return {
cookie,
cookieEnv,
cookieSource: literalCookie !== undefined ? 'config' : envCookie !== undefined ? 'env' : 'none',
allowWriteActions: source.allowWriteActions === true,
requestTimeoutMs: positive(source.requestTimeoutMs) ?? DEFAULT_REQUEST_TIMEOUT_MS,
timeoutMs: positive(source.timeoutMs) ?? DEFAULT_TOOL_TIMEOUT_MS,
warnings,
}
}
+1076
View File
File diff suppressed because it is too large Load Diff
+649
View File
@@ -0,0 +1,649 @@
/**
* Model-facing AcFun tools and their formatters.
*
* Deliberately dependency-free: every tool is a plain `ToolDefinition` object
* (`name`, `description`, `parameters` as raw JSON Schema, `output.schema` as
* raw JSON Schema, and `render`), so the bundle needs no `@deepseek-ai/*`
* 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'
/** 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.',
].join(' ')
/** One text content block. */
const text = (value) => [{ type: 'text', text: value }]
/**
* Compile an author-friendly property map into a raw JSON Schema object node.
* A property marked `required: true` contributes to the object's `required`
* array; the marker itself is removed from the emitted schema.
*/
function objectNode(properties) {
const required = []
const cleaned = {}
for (const [key, value] of Object.entries(properties)) {
const { required: isRequired, ...schema } = value
if (isRequired === true) required.push(key)
cleaned[key] = schema
}
const node = { type: 'object', additionalProperties: false, properties: cleaned }
if (required.length > 0) node.required = required
return node
}
/** A required scalar property. */
const required = (schema) => ({ ...schema, required: true })
const string = (description, extra = {}) => ({ type: 'string', description, ...extra })
const number = (description, extra = {}) => ({ type: 'number', description, ...extra })
const boolean = (description, extra = {}) => ({ type: 'boolean', description, ...extra })
const arrayOf = (items, description) => ({ type: 'array', description, items })
/** A compact one-line list entry: `- [title](url) — meta`. */
function bullet(title, url, meta) {
return meta.length > 0 ? `- [${title}](${url}) — ${meta}` : `- [${title}](${url})`
}
/** Join non-empty metadata fragments with ` · `. */
function meta(...fragments) {
return fragments.filter((fragment) => typeof fragment === 'string' && fragment.length > 0).join(' · ')
}
/** Format the search result for the model. */
export function formatSearch(value) {
const sortNote = value.sort === 'views' ? 'sorted by most-viewed' : 'sorted by relevance'
const header = `Found ${value.total} AcFun videos for "${value.keyword}" (${sortNote}); showing ${value.returned}. AcFun's search API ignores paging parameters and returns one fixed first page.`
if (value.items.length === 0) return `${header}\n\nNo videos matched this keyword.`
const lines = value.items.map((item) =>
bullet(
item.title,
item.url,
meta(
item.authorName.length > 0 ? `UP ${item.authorName}` : '',
`${item.viewCount} plays`,
item.danmakuCount > 0 ? `${item.danmakuCount} danmaku` : '',
item.commentCount > 0 ? `${item.commentCount} comments` : '',
item.duration,
item.publishedAt.slice(0, 10),
`acId ${item.acId}`,
),
),
)
return `${header}\n\n${lines.join('\n')}\n\nUse acfun_video with one of those acId values for full metadata.`
}
/** Format one video detail. */
export function formatVideo(value) {
const lines = [`# ${value.title}`, `URL: ${value.url}`, `acId: ${value.acId}`]
const author = meta(
`UP ${value.authorName || 'unknown'}${value.authorId ? ` (id ${value.authorId})` : ''}`,
value.authorFollowers > 0 ? `${value.authorFollowers} followers` : '',
value.authorSignature.length > 0 ? value.authorSignature : '',
)
lines.push(`Author: ${author}`)
lines.push(
`Channel: ${meta(value.channelParent, value.channel, `duration ${value.duration}`, `published ${value.publishedAt.slice(0, 10)}`)}`,
)
lines.push(
`Stats: ${meta(
`${value.viewCount} plays`,
`${value.likeCount} likes`,
`${value.favoriteCount} favorites`,
`${value.bananaCount} bananas`,
`${value.commentCount} comments`,
`${value.danmakuCount} danmaku`,
)}`,
)
if (value.tags.length > 0) lines.push(`Tags: ${value.tags.map((tag) => tag.name).join(', ')}`)
lines.push(
`Interaction state: ${meta(
value.loggedIn ? 'logged in' : 'anonymous (no cookie)',
`liked ${value.liked ? 'yes' : 'no'}`,
`favorited ${value.favorited ? 'yes' : 'no'}`,
`banana given ${value.bananaGiven ? 'yes' : 'no'}`,
)}`,
)
if (value.description.length > 0) lines.push(`\nDescription:\n${value.description}`)
return lines.join('\n')
}
/** Format one comment page. */
export function formatComments(value) {
const header = `Comments on ${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,
item.isUp ? 'UP主' : '',
`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_comments again with page: ${value.page + 1}.`
: 'This is the last comment page.'
return `${header}\n\n${lines.join('\n')}\n\n${footer}`
}
/** Format one danmaku page. */
export function formatDanmaku(value) {
const header = `Danmaku on ${acfunVideoUrl(value.acId)} — ${value.totalCount} total, ${value.returned} in this batch, ordered by in-video position.`
if (value.items.length === 0) return `${header}\n\nNo danmaku in this batch.`
const lines = value.items.map((item) => {
const metaText = meta(
item.time,
item.mode > 0 ? `mode ${item.mode}` : '',
item.color.length > 0 ? item.color : '',
item.userId.length > 0 ? `user ${item.userId}` : '',
item.likeCount > 0 ? `${item.likeCount} likes` : '',
)
return `- ${metaText}\n ${item.body.replace(/\n/g, '\n ')}`
})
const footer =
value.nextCursor.length > 0
? `More danmaku follow: call acfun_danmaku again with cursor: "${value.nextCursor}".`
: 'This is the last danmaku batch.'
return `${header}\n\n${lines.join('\n')}\n\n${footer}`
}
/** Format one ranking page. */
export function formatRank(value) {
const scope = value.channelId === 0 ? 'all channels' : `channel ${value.channelId}`
const header = `AcFun ${value.period} ranking (${scope}) — ${value.returned} entries.`
if (value.items.length === 0) return `${header}\n\nNo entries for this period/channel.`
const lines = value.items.map((item, index) => {
const suffix = meta(
item.authorName.length > 0 ? `UP ${item.authorName}` : '',
item.channelName,
item.duration,
item.danmakuCount > 0 ? `${item.danmakuCount} danmaku` : '',
item.likeCountText.length > 0 ? `${item.likeCountText} likes` : '',
item.commentCountText.length > 0 ? `${item.commentCountText} comments` : '',
`acId ${item.acId}`,
)
return `${index + 1}. [${item.title}](${item.url})${suffix.length > 0 ? ` — ${suffix}` : ''}`
})
return `${header}\n\n${lines.join('\n')}`
}
/** Format the playable quality ladder. */
export function formatPlayUrls(value) {
const header = `Playable streams for ${value.title} (${value.duration}) — ${acfunVideoUrl(value.acId)}`
const lines = value.qualities.map((quality) =>
`- ${quality.label} (${quality.width}x${quality.height}): ${quality.url}`,
)
return `${header}\n\n${lines.join('\n')}\n\n${value.chosenUrl.length > 0 ? `Selected ${value.chosenLabel}: ${value.chosenUrl}` : 'No quality was requested; the first (highest) entry is the default.'}\n\nThese are time-limited m3u8 URLs; re-run acfun_playurl if one expires.`
}
/** Format one interaction outcome. */
export function formatInteract(value) {
return `${value.message}\n\nAction: ${value.action} · acId: ${value.acId || '-'} · userId: ${value.targetUserId || '-'}`
}
/** Format one posted comment. */
export function formatPostComment(value) {
return `${value.message}\n\nComment id: ${value.commentId || '(not returned)'} · video: ${value.url}`
}
/** Resolve a requested quality label/height against the available ladder. */
function chooseQuality(qualities, requested) {
if (qualities.length === 0) return undefined
const wanted = typeof requested === 'string' ? requested.trim().toLowerCase() : ''
if (wanted.length === 0) return qualities[0]
const exact = qualities.find((quality) => quality.label.toLowerCase() === wanted)
if (exact !== undefined) return exact
const partial = qualities.find((quality) => quality.label.toLowerCase().includes(wanted))
if (partial !== undefined) return partial
const height = /^(\d{3,4})p?$/.exec(wanted)
if (height !== null) {
const target = Number(height[1])
const ranked = [...qualities].sort((left, right) => Math.abs(left.height - target) - Math.abs(right.height - target))
if (ranked[0] !== undefined) return ranked[0]
}
throw new AcfunError(
`No AcFun stream matches quality "${requested}". Available: ${qualities.map((quality) => quality.label).join(', ')}.`,
{ code: 'INVALID_ARGUMENT' },
)
}
/** Assert one required non-empty string argument; return its trimmed value. */
function requireText(args, name, description) {
const value = args?.[name]
if (typeof value !== 'string' || value.trim().length === 0) {
throw new AcfunError(`${name} must be a non-empty string${description ? ` (${description})` : ''}.`, {
code: 'INVALID_ARGUMENT',
})
}
return value.trim()
}
/** Assert one positive integer argument with a bound. */
function requireInteger(args, name, { min = 1, max = Number.MAX_SAFE_INTEGER, fallback } = {}) {
const raw = args?.[name]
const value = raw === undefined || raw === null ? fallback : raw
if (!Number.isInteger(value) || value < min || value > max) {
throw new AcfunError(`${name} must be an integer between ${min} and ${max}.`, { code: 'INVALID_ARGUMENT' })
}
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 }
}
/**
* Build the seven 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.
* @returns {object[]} registry-ready ToolDefinition objects.
*/
export function createAcfunTools(provider, config) {
const readTimeout = config.timeoutMs
const writeTimeout = Math.max(config.timeoutMs, 60_000)
const searchTool = {
name: 'acfun_search',
description:
'Search acfun.cn (A站) for videos by keyword. Returns matching videos with acId, title, uploader, play/danmaku/comment counts, duration, and publish date (at most the first 30 results; AcFun ignores paging). sort "views" orders by play count, "relevance" keeps AcFun\'s default order. Follow up with acfun_video for full metadata, acfun_comments/acfun_danmaku for the thread and 弹幕, or acfun_playurl for a stream URL.',
parameters: objectNode({
keyword: required(string('The search keyword, for example "原神" or "动画".')),
sort: string('Result order: "relevance" (default) or "views" (most-viewed first).', {
enum: [...SEARCH_SORTS],
}),
}),
output: {
schema: objectNode({
keyword: required(string('The normalized search keyword.')),
sort: required(string('The requested result order.')),
total: required(number('How many results AcFun reports in total.')),
returned: required(number('How many items are in this response.')),
items: required(
arrayOf(
objectNode({
acId: required(string('AcFun video id used by the other acfun tools.')),
title: required(string('Video title.')),
authorId: required(string('Uploader user id.')),
authorName: required(string('Uploader display name.')),
viewCount: required(number('Play count.')),
danmakuCount: required(number('Danmaku count.')),
commentCount: required(number('Comment count.')),
duration: required(string('Duration as m:ss or h:mm:ss.')),
publishedAt: required(string('ISO-8601 publish time.')),
coverUrl: required(string('Cover image URL.')),
url: required(string('Canonical acfun.cn video URL.')),
}),
'Matching videos.',
),
),
}),
render: (_args, value) => text(formatSearch(value)),
},
timeoutMs: readTimeout,
isConcurrencySafe: () => true,
async execute(args, exec) {
const keyword = requireText(args, 'keyword', 'the search phrase')
const sort = typeof args?.sort === 'string' ? args.sort.trim() : 'relevance'
const result = await provider.search(keyword, { sort, signal: exec?.signal })
return result
},
}
const videoTool = {
name: 'acfun_video',
description:
'Read full metadata for one acfun.cn video: title, description, uploader, channel, tags, duration, publish time, play/like/favorite/banana/comment/danmaku counts, cover, and — when a login cookie is configured — whether you already liked/favorited/tipped it. Accepts a numeric acId (42588725), the ac-prefixed form (ac42588725), or a /v/ac... URL.',
parameters: objectNode({
acId: required(string('Video identifier: numeric acId, ac-prefixed id, or acfun.cn /v/ac... URL.')),
}),
output: {
schema: objectNode({
acId: required(string('Bare numeric AcFun video id.')),
videoId: required(number('Internal video (currentVideoId) id.')),
title: required(string('Video title.')),
description: required(string('Video description, empty when absent.')),
authorId: required(string('Uploader user id.')),
authorName: required(string('Uploader display name.')),
authorAvatar: required(string('Uploader avatar URL.')),
authorSignature: required(string('Uploader signature, empty when absent.')),
authorFollowers: required(number('Uploader follower count.')),
channel: required(string('AcFun channel name, for example 动画综合.')),
channelParent: required(string('Parent channel name, for example 动画.')),
channelId: required(number('AcFun channel id.')),
tags: required(
arrayOf(
objectNode({
name: required(string('Tag name.')),
id: required(string('Tag id.')),
}),
'Video tags.',
),
),
durationMillis: required(number('Duration in milliseconds.')),
duration: required(string('Duration as m:ss or h:mm:ss.')),
publishedAt: required(string('ISO-8601 publish time.')),
viewCount: required(number('Play count.')),
danmakuCount: required(number('Danmaku count.')),
commentCount: required(number('Comment count.')),
likeCount: required(number('Like count.')),
favoriteCount: required(number('Favorite count.')),
bananaCount: required(number('Banana (投蕉) count.')),
coverUrl: required(string('Cover image URL.')),
url: required(string('Canonical acfun.cn video URL.')),
loggedIn: required(boolean('Whether a login cookie is configured.')),
liked: required(boolean('Whether the configured account liked this video; false when anonymous.')),
favorited: required(boolean('Whether the configured account favorited this video; false when anonymous.')),
bananaGiven: required(boolean('Whether the configured account tipped this video; false when anonymous.')),
}),
render: (_args, value) => text(formatVideo(value)),
},
timeoutMs: readTimeout,
isConcurrencySafe: () => true,
async execute(args, exec) {
const acId = requireText(args, 'acId', 'numeric id, ac-prefixed id, or video URL')
return provider.video(acId, { signal: exec?.signal })
},
}
const commentsTool = {
name: 'acfun_comments',
description:
'Read one page of the comment thread (楼) of an acfun.cn video, in floor order. Returns comment ids, authors, floor numbers, post times, UP-main flags, and plain-text content. Use the returned commentId with acfun_post_comment replies.',
parameters: objectNode({
acId: required(string('Video identifier: numeric acId, ac-prefixed id, or acfun.cn /v/ac... URL.')),
page: number('1-based comment page. Defaults to 1.'),
}),
output: {
schema: objectNode({
acId: required(string('Bare numeric AcFun video id.')),
page: required(number('Current comment page.')),
totalPages: required(number('Total number of comment pages.')),
totalCount: required(number('Total comments on the video.')),
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 acfun.cn video URL.')),
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 with UBB markup stripped.')),
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 video uploader.')),
isDeleted: required(boolean('Whether AcFun reports the comment as deleted.')),
}),
'Comments in floor order.',
),
),
}),
render: (_args, value) => text(formatComments(value)),
},
timeoutMs: readTimeout,
isConcurrencySafe: () => true,
async execute(args, exec) {
const acId = requireText(args, 'acId', 'numeric id, ac-prefixed id, or video URL')
const page = requireInteger(args, 'page', { min: 1, max: 500, fallback: 1 })
return provider.comments(acId, { page, signal: exec?.signal })
},
}
const danmakuTool = {
name: 'acfun_danmaku',
description:
'Read one batch of the danmaku (弹幕) list of an acfun.cn video, ordered by in-video position. Returns each danmaku body, position (ms), mode, color, author and like count. Pass the returned nextCursor to continue with the next batch.',
parameters: objectNode({
acId: required(string('Video identifier: numeric acId, ac-prefixed id, or acfun.cn /v/ac... URL.')),
limit: number(`How many danmaku to request, 1-${DANMAKU_MAX_COUNT}. Defaults to ${DANMAKU_MAX_COUNT}.`),
cursor: string('Opaque continuation cursor from a previous call\'s nextCursor. Omit for the first batch.'),
}),
output: {
schema: objectNode({
acId: required(string('Bare numeric AcFun video id.')),
videoId: required(number('The part videoId AcFun addresses danmaku by.')),
title: required(string('Video title.')),
totalCount: required(number('Total danmaku AcFun reports for the video.')),
returned: required(number('Danmaku in this batch.')),
nextCursor: required(string('Cursor for the next batch, empty when there is no more.')),
items: required(
arrayOf(
objectNode({
danmakuId: required(string('Danmaku id.')),
body: required(string('Danmaku text.')),
position: required(number('In-video position in milliseconds.')),
time: required(string('In-video position as m:ss or h:mm:ss.')),
color: required(string('Text color as #rrggbb, empty when absent.')),
mode: required(number('AcFun danmaku mode: 1 scroll, 4 bottom, 5 top, and so on.')),
userId: required(string('Author user id.')),
createdAt: required(string('ISO-8601 creation time, empty when absent.')),
likeCount: required(number('Like count.')),
}),
'Danmaku ordered by in-video position.',
),
),
}),
render: (_args, value) => text(formatDanmaku(value)),
},
timeoutMs: readTimeout,
isConcurrencySafe: () => true,
async execute(args, exec) {
const acId = requireText(args, 'acId', 'numeric id, ac-prefixed id, or video URL')
const limit = requireInteger(args, 'limit', { min: 1, max: DANMAKU_MAX_COUNT, fallback: DANMAKU_MAX_COUNT })
const cursor = typeof args?.cursor === 'string' ? args.cursor : ''
return provider.danmaku(acId, { limit, cursor, signal: exec?.signal })
},
}
const rankTool = {
name: 'acfun_rank',
description:
'Read the AcFun video ranking (排行榜) for one period: day or week. channelId 0 means all channels; pass a channel id to narrow it. Returns ranked videos with title, uploader, channel, duration, and like/comment/danmaku hints.',
parameters: objectNode({
period: string('Ranking period: "day" (default) or "week".', { enum: [...RANK_PERIODS] }),
channelId: number('AcFun channel id, 0 (default) for all channels.'),
limit: number('How many entries to return, 1-50. Defaults to 10.'),
}),
output: {
schema: objectNode({
period: required(string('The requested ranking period.')),
channelId: required(number('The requested channel id.')),
returned: required(number('How many entries are in this response.')),
items: required(
arrayOf(
objectNode({
acId: required(string('AcFun video id.')),
title: required(string('Video title.')),
description: required(string('Short description, empty when absent.')),
authorId: required(string('Uploader user id.')),
authorName: required(string('Uploader display name.')),
channelName: required(string('AcFun channel name.')),
coverUrl: required(string('Cover image URL.')),
duration: required(string('Duration as m:ss or h:mm:ss.')),
danmakuCount: required(number('Danmaku count.')),
likeCountText: required(string('Server-formatted like count, for example "253" or "1.2万".')),
commentCountText: required(string('Server-formatted comment count.')),
publishedAt: required(string('ISO-8601 publish time.')),
url: required(string('Canonical acfun.cn video URL.')),
}),
'Ranked videos in rank order.',
),
),
}),
render: (_args, value) => text(formatRank(value)),
},
timeoutMs: readTimeout,
isConcurrencySafe: () => true,
async execute(args, exec) {
const period = args?.period === undefined ? 'day' : String(args.period).trim()
const channelId = requireInteger(args, 'channelId', { min: 0, max: 10_000, fallback: 0 })
const limit = requireInteger(args, 'limit', { min: 1, max: 50, fallback: 10 })
return provider.rank({ period, channelId, limit, signal: exec?.signal })
},
}
const playUrlTool = {
name: 'acfun_playurl',
description:
'Return the playable m3u8 stream URLs of one acfun.cn video across its quality ladder (for example 1080P+, 1080P, 720P, 540P, 360P). Optionally name a quality; otherwise the highest is selected. The URLs are time-limited, so re-run when one expires.',
parameters: objectNode({
acId: required(string('Video identifier: numeric acId, ac-prefixed id, or acfun.cn /v/ac... URL.')),
quality: string('Optional quality label such as "720P" or "1080P+". Defaults to the highest available.'),
}),
output: {
schema: objectNode({
acId: required(string('Bare numeric AcFun video id.')),
title: required(string('Video title.')),
duration: required(string('Duration as m:ss or h:mm:ss.')),
qualities: required(
arrayOf(
objectNode({
label: required(string('Quality label.')),
width: required(number('Video width in pixels.')),
height: required(number('Video height in pixels.')),
url: required(string('Time-limited m3u8 stream URL.')),
}),
'Playable qualities, highest first.',
),
),
chosenLabel: required(string('Label of the selected quality, empty when none applied.')),
chosenUrl: required(string('URL of the selected quality, empty when none applied.')),
}),
render: (_args, value) => text(formatPlayUrls(value)),
},
timeoutMs: readTimeout,
isConcurrencySafe: () => true,
async execute(args, exec) {
const acId = requireText(args, 'acId', 'numeric id, ac-prefixed id, or video URL')
const requested = typeof args?.quality === 'string' ? args.quality : ''
const result = await provider.playUrls(acId, { signal: exec?.signal })
const chosen = chooseQuality(result.qualities, requested)
return {
acId: result.acId,
title: result.title,
duration: result.duration,
qualities: result.qualities,
chosenLabel: chosen?.label ?? '',
chosenUrl: chosen?.url ?? '',
}
},
}
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.',
parameters: objectNode({
action: required(
string('Interaction to perform.', {
enum: ['like', 'unlike', 'banana', 'favorite', 'unfavorite', 'follow', 'unfollow'],
}),
),
acId: string('Video identifier for like/unlike/banana/favorite/unfavorite.'),
userId: string('Target user id for follow/unfollow.'),
count: number('Bananas to tip for action "banana", 1-10. Defaults to 1.'),
folderId: string('Optional favorite folder id for action "favorite".'),
}),
output: {
schema: objectNode({
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.')),
}),
render: (_args, value) => text(formatInteract(value)),
},
timeoutMs: writeTimeout,
isConcurrencySafe: () => false,
async execute(args, exec) {
const action = requireText(args, 'action')
const signal = exec?.signal
switch (action) {
case 'like':
case 'unlike': {
const acId = requireText(args, 'acId', 'required for like/unlike')
const outcome = await provider.setLike(acId, action === 'like', { signal })
return interactOutput(action, outcome.message, acId)
}
case 'banana': {
const acId = requireText(args, 'acId', 'required for banana')
const count = requireInteger(args, 'count', { min: 1, max: 10, fallback: 1 })
const outcome = await provider.throwBanana(acId, count, { signal })
return interactOutput(action, outcome.message, acId)
}
case 'favorite':
case 'unfavorite': {
const acId = requireText(args, 'acId', 'required for favorite/unfavorite')
const folderId = typeof args?.folderId === 'string' ? args.folderId : undefined
const outcome = await provider.setFavorite(acId, action === 'favorite', { folderId, signal })
return interactOutput(action, outcome.message, acId)
}
case 'follow':
case 'unfollow': {
const userId = requireText(args, 'userId', 'required for follow/unfollow')
const outcome = await provider.setFollow(userId, action === 'follow', { signal })
return interactOutput(action, outcome.message, '', userId)
}
default:
throw new AcfunError(`Unknown acfun_interact action: ${action}.`, { code: 'INVALID_ARGUMENT' })
}
},
}
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.',
parameters: objectNode({
acId: required(string('Video identifier: numeric acId, ac-prefixed id, or acfun.cn /v/ac... URL.')),
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.')),
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.')),
}),
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 outcome = await provider.postComment(acId, content, { replyToCommentId, signal: exec?.signal })
const normalized = normalizeAcId(acId)
return {
ok: outcome.ok,
acId: normalized,
commentId: outcome.commentId,
message: outcome.message,
url: acfunVideoUrl(normalized),
}
},
}
return [searchTool, videoTool, commentsTool, danmakuTool, rankTool, playUrlTool, interactTool, commentTool]
}
+6
View File
@@ -0,0 +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."
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"meta": {
"title": "AcFun 工具",
"description": "搜索 A 站视频,读取视频详情/评论/弹幕/热榜/播放地址;配置登录 Cookie 并开启写操作后,可在 A 站点赞、投蕉、收藏、关注和发评论。"
}
}
+47
View File
@@ -0,0 +1,47 @@
{
"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.",
"keywords": [
"deepseek-harness",
"dsh-plugin",
"acfun",
"a站",
"acfun.cn",
"video",
"danmaku",
"弹幕",
"agent-tool"
],
"type": "module",
"main": "index.js",
"exports": {
".": "./index.js",
"./locale/*.json": "./locale/*.json",
"./icon": "./icon.svg",
"./package.json": "./package.json"
},
"files": [
"index.js",
"lib/**/*",
"cordis.patch.yml",
"locale/*.json",
"icon.svg",
"skills/**/*",
"docs/**/*",
"scripts/**/*",
"README.md",
"README.zh.md",
"LICENSE"
],
"license": "MIT",
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
}
},
"scripts": {
"self-check": "node scripts/self-check.mjs",
"live-check": "node scripts/live-check.mjs"
}
}
+94
View File
@@ -0,0 +1,94 @@
/**
* Live post-install smoke test for the AcFun read tools.
*
* Exercises the same tool definitions the Harness registers, against the live
* acfun.cn API, validates every canonical value against the tool's declared
* output schema, and renders it. Write tools are expected to fail closed.
*
* Run with: node scripts/live-check.mjs
*/
import { AcfunProvider } from '../lib/provider.js'
import { resolveConfig } from '../lib/config.js'
import { createAcfunTools } from '../lib/tools.js'
import { validateValue } from './schema-check.mjs'
const provider = new AcfunProvider({ requestTimeoutMs: 30_000 })
const config = resolveConfig({})
const tools = new Map(createAcfunTools(provider, config).map((tool) => [tool.name, tool]))
const exec = { signal: new AbortController().signal }
const failures = []
/** Execute one tool, validate its canonical output and render it. */
async function run(toolName, args, { expectError } = {}) {
const tool = tools.get(toolName)
if (tool === undefined) {
failures.push(`${toolName}: not registered`)
return undefined
}
console.log(`\n=== ${toolName} ${JSON.stringify(args)}`)
const started = Date.now()
let value
try {
value = await tool.execute(args, exec)
} catch (error) {
if (expectError !== undefined) {
if (error?.code === expectError) {
console.log(` ok failed closed with ${error.code}: ${error.message}`)
return undefined
}
failures.push(`${toolName}: expected ${expectError}, got ${error?.code ?? error?.name}: ${error?.message}`)
return undefined
}
failures.push(`${toolName}: threw ${error?.name}: ${error?.message}`)
return undefined
}
if (expectError !== undefined) {
failures.push(`${toolName}: expected ${expectError} but the call succeeded`)
return value
}
const schemaErrors = validateValue(value, tool.output.schema)
if (schemaErrors.length > 0) failures.push(`${toolName}: output schema violations: ${schemaErrors.join('; ')}`)
else console.log(' ok canonical value matches output schema')
let rendered
try {
rendered = tool.output.render(args, value)
} catch (error) {
failures.push(`${toolName}: render threw ${error?.message}`)
return value
}
const text = rendered.map((block) => block.text).join('\n')
console.log(` ok rendered ${text.length} chars in ${Date.now() - started} ms`)
console.log(
text
.split('\n')
.slice(0, 12)
.map((line) => ` | ${line}`)
.join('\n'),
)
return value
}
const search = await run('acfun_search', { keyword: '原神' })
await run('acfun_search', { keyword: '原神', sort: 'views' })
const acId = search?.items?.[0]?.acId ?? '42588725'
console.log(`\n(using acId ${acId} for the follow-up calls)`)
const video = await run('acfun_video', { acId })
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 })
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_post_comment', { acId, content: 'live-check should not post this' }, { expectError: 'WRITE_DISABLED' })
if (failures.length > 0) {
console.error(`\n${failures.length} live check(s) failed`)
for (const failure of failures) console.error(`- ${failure}`)
process.exitCode = 1
} else {
console.log('\nall live read checks passed; write tools failed closed as designed')
}
+140
View File
@@ -0,0 +1,140 @@
/**
* Minimal validator for the JSON Schema subset dsh-tools enforces:
* type / properties / required / additionalProperties / items / enum / const /
* oneOf, plus annotations. Used by the pre-install checks so a schema mistake
* fails locally instead of at plugin activation.
*/
const ANNOTATIONS = new Set(['description', 'title', 'default', 'examples'])
const CONSTRAINTS = new Set(['type', 'oneOf', 'properties', 'required', 'additionalProperties', 'items', 'enum', 'const'])
const TYPES = new Set(['object', 'array', 'string', 'number', 'integer', 'boolean', 'null'])
/** Check that one schema node stays inside the enforced keyword subset. */
export function assertSchemaSupported(schema, path = '$') {
const errors = []
if (schema === null || typeof schema !== 'object' || Array.isArray(schema)) {
return [`${path}: schema node must be an object`]
}
for (const key of Object.keys(schema)) {
if (!CONSTRAINTS.has(key) && !ANNOTATIONS.has(key)) errors.push(`${path}: unsupported keyword "${key}"`)
}
if (schema.type !== undefined && !TYPES.has(schema.type)) errors.push(`${path}: unsupported type "${schema.type}"`)
if (schema.enum !== undefined && !Array.isArray(schema.enum)) errors.push(`${path}.enum: must be an array`)
if (schema.oneOf !== undefined) {
if (!Array.isArray(schema.oneOf) || schema.oneOf.length < 2) errors.push(`${path}.oneOf: needs at least two branches`)
else schema.oneOf.forEach((branch, index) => errors.push(...assertSchemaSupported(branch, `${path}.oneOf[${index}]`)))
}
if (schema.type === 'object') {
if (schema.additionalProperties === undefined) errors.push(`${path}: object schema must declare additionalProperties`)
else if (typeof schema.additionalProperties !== 'boolean') errors.push(`${path}.additionalProperties: must be boolean`)
if (schema.properties !== undefined) {
if (typeof schema.properties !== 'object' || schema.properties === null) errors.push(`${path}.properties: must be an object`)
else {
for (const [key, child] of Object.entries(schema.properties)) {
errors.push(...assertSchemaSupported(child, `${path}.properties.${key}`))
}
}
}
if (schema.required !== undefined) {
if (!Array.isArray(schema.required)) errors.push(`${path}.required: must be an array`)
else {
const declared = schema.properties ?? {}
for (const name of schema.required) {
if (typeof name !== 'string') errors.push(`${path}.required: entries must be strings`)
else if (!(name in declared)) errors.push(`${path}.required: "${name}" is not declared in properties`)
}
}
}
}
if (schema.type === 'array' && schema.items !== undefined) {
errors.push(...assertSchemaSupported(schema.items, `${path}.items`))
}
return errors
}
/** Whether a JSON value matches one scalar type name. */
function matchesType(value, type) {
switch (type) {
case 'object':
return value !== null && typeof value === 'object' && !Array.isArray(value)
case 'array':
return Array.isArray(value)
case 'string':
return typeof value === 'string'
case 'number':
return typeof value === 'number' && Number.isFinite(value)
case 'integer':
return typeof value === 'number' && Number.isInteger(value)
case 'boolean':
return typeof value === 'boolean'
case 'null':
return value === null
default:
return false
}
}
/** Validate one value against the schema subset; returns path-qualified errors. */
export function validateValue(value, schema, path = '$') {
const errors = []
if (schema === null || typeof schema !== 'object') return [`${path}: invalid schema`]
if (schema.oneOf !== undefined) {
const branchResults = schema.oneOf.map((branch) => validateValue(value, branch, path))
const matching = branchResults.filter((result) => result.length === 0).length
if (matching !== 1) errors.push(`${path}: value must match exactly one oneOf branch (matched ${matching})`)
return errors
}
if (schema.const !== undefined && value !== schema.const) errors.push(`${path}: must equal ${JSON.stringify(schema.const)}`)
if (schema.enum !== undefined && !schema.enum.includes(value)) {
errors.push(`${path}: must be one of ${schema.enum.map((entry) => JSON.stringify(entry)).join(', ')}`)
}
if (schema.type !== undefined && !matchesType(value, schema.type)) {
errors.push(`${path}: expected ${schema.type}, got ${value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value}`)
return errors
}
if (schema.type === 'object') {
const properties = schema.properties ?? {}
for (const name of schema.required ?? []) {
if (!Object.prototype.hasOwnProperty.call(value, name)) errors.push(`${path}.${name}: missing required property`)
}
for (const [key, child] of Object.entries(value)) {
if (Object.prototype.hasOwnProperty.call(properties, key)) {
errors.push(...validateValue(child, properties[key], `${path}.${key}`))
} else if (schema.additionalProperties === false) {
errors.push(`${path}.${key}: unexpected property (additionalProperties: false)`)
}
}
}
if (schema.type === 'array') {
const items = schema.items
if (items !== undefined) value.forEach((entry, index) => errors.push(...validateValue(entry, items, `${path}[${index}]`)))
}
return errors
}
/** Build a minimal conforming sample value for schema-rendering checks. */
export function sampleValue(schema) {
if (schema.oneOf !== undefined) return sampleValue(schema.oneOf[0])
if (schema.const !== undefined) return schema.const
if (schema.enum !== undefined) return schema.enum[0]
switch (schema.type) {
case 'object': {
const out = {}
for (const [key, child] of Object.entries(schema.properties ?? {})) out[key] = sampleValue(child)
return out
}
case 'array':
return []
case 'string':
return 'sample'
case 'number':
case 'integer':
return 0
case 'boolean':
return false
case 'null':
return null
default:
return null
}
}
+133
View File
@@ -0,0 +1,133 @@
/**
* 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.
*
* Run with: node scripts/self-check.mjs
*/
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 { assertSchemaSupported, sampleValue, validateValue } from './schema-check.mjs'
const failures = []
const check = (label, condition, detail = '') => {
if (condition) {
console.log(` ok ${label}`)
} else {
failures.push(`${label}${detail ? `: ${detail}` : ''}`)
console.log(` FAIL ${label}${detail ? `: ${detail}` : ''}`)
}
}
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))
console.log('\nmock activation')
const registered = []
const sections = []
const effects = []
const mockCtx = {
effect(factory) {
effects.push(factory)
factory()
},
tools: {
register(definition) {
registered.push(definition)
return () => {}
},
},
systemPrompt: {
section(section) {
sections.push(section)
return () => {}
},
},
}
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(
'tool names are unique acfun_* names',
new Set(registered.map((tool) => tool.name)).size === 8 && registered.every((tool) => tool.name.startsWith('acfun_')),
registered.map((tool) => tool.name).join(','),
)
console.log('\ntool definitions')
for (const tool of registered) {
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 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 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('; '))
let rendered
try {
rendered = tool.output.render({}, sample)
} catch (error) {
check(`${tool.name}: render does not throw`, false, String(error?.message ?? error))
continue
}
check(
`${tool.name}: render returns text content blocks`,
Array.isArray(rendered) && rendered.length > 0 && rendered.every((block) => block?.type === 'text' && typeof block.text === 'string'),
)
check(`${tool.name}: render uses its value`, rendered.some((block) => block.text.includes('sample')))
}
console.log('\nwrite guard fails closed')
const provider = new AcfunProvider({})
const tools = createAcfunTools(provider, resolveConfig({}))
const interact = tools.find((tool) => tool.name === 'acfun_interact')
const postComment = tools.find((tool) => tool.name === 'acfun_post_comment')
const exec = { signal: new AbortController().signal }
for (const [tool, args] of [
[interact, { action: 'like', acId: '42588725' }],
[postComment, { action: 'post', acId: '42588725', content: 'hello' }],
]) {
try {
await tool.execute(args, exec)
check(`${tool.name}: refuses to write without allowWriteActions`, false, 'execute resolved unexpectedly')
} catch (error) {
check(`${tool.name}: refuses to write without allowWriteActions`, error?.code === 'WRITE_DISABLED', String(error?.code))
}
}
const enabledProvider = new AcfunProvider({ allowWriteActions: true })
const enabledTools = createAcfunTools(enabledProvider, resolveConfig({ allowWriteActions: true }))
try {
await enabledTools.find((tool) => tool.name === 'acfun_interact').execute({ action: 'follow', userId: '1' }, exec)
check('write tools require a cookie even when enabled', false, 'execute resolved unexpectedly')
} catch (error) {
check('write tools require a cookie even when enabled', error?.code === 'LOGIN_REQUIRED', String(error?.code))
}
console.log('\nconfig resolution')
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('bad config values fall back with warnings', resolveConfig(null).warnings.length > 0)
if (failures.length > 0) {
console.error(`\n${failures.length} check(s) failed`)
process.exitCode = 1
} else {
console.log('\nall offline checks passed')
}
+53
View File
@@ -0,0 +1,53 @@
---
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.
---
# 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.
## Read 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,
so say so instead of retrying with `page`.
2. `acfun_video` — full metadata for one `acId`. Accepts `42588725`,
`ac42588725`, or `https://www.acfun.cn/v/ac42588725`.
3. `acfun_comments` — one comment page in floor order; pass
`page: previous.page + 1` while `hasMore` is true. Comment ids are usable as
`replyToCommentId`.
4. `acfun_danmaku` — one danmaku batch ordered by in-video position; pass
`cursor: previous.nextCursor` while it is non-empty.
5. `acfun_rank` — `period: "day" | "week"`, `channelId: 0` for all channels.
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.
## 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.
- 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.
- Make the call at most once. If the result is ambiguous, re-read with
`acfun_video` / `acfun_comments` instead of repeating the mutation.
- `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.
- `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.
- 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
formatted text (`likeCountText`, `commentCountText`) must be quoted as text.