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:
@@ -0,0 +1,4 @@
|
||||
* text=auto eol=lf
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.webp binary
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -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.
|
||||
@@ -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
@@ -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`。
|
||||
@@ -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')
|
||||
@@ -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).
|
||||
@@ -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 |
@@ -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'
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+649
@@ -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]
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "AcFun 工具",
|
||||
"description": "搜索 A 站视频,读取视频详情/评论/弹幕/热榜/播放地址;配置登录 Cookie 并开启写操作后,可在 A 站点赞、投蕉、收藏、关注和发评论。"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user