From f9bedde19eb67bb149f8d415f1b2a81e06d70334 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Tue, 27 Oct 2026 11:26:51 +0800 Subject: [PATCH] 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. --- .gitattributes | 4 + .gitignore | 4 + LICENSE | 21 + README.md | 129 ++++ README.zh.md | 115 +++ cordis.patch.yml | 27 + docs/acfun-api.md | 841 ++++++++++++++++++++++ icon.svg | 16 + index.js | 86 +++ lib/config.js | 83 +++ lib/provider.js | 1076 +++++++++++++++++++++++++++++ lib/tools.js | 649 +++++++++++++++++ locale/en.json | 6 + locale/zh.json | 6 + package.json | 47 ++ scripts/live-check.mjs | 94 +++ scripts/schema-check.mjs | 140 ++++ scripts/self-check.mjs | 133 ++++ skills/plugin-tool-acfun/SKILL.md | 53 ++ 19 files changed, 3530 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 README.zh.md create mode 100644 cordis.patch.yml create mode 100644 docs/acfun-api.md create mode 100644 icon.svg create mode 100644 index.js create mode 100644 lib/config.js create mode 100644 lib/provider.js create mode 100644 lib/tools.js create mode 100644 locale/en.json create mode 100644 locale/zh.json create mode 100644 package.json create mode 100644 scripts/live-check.mjs create mode 100644 scripts/schema-check.mjs create mode 100644 scripts/self-check.mjs create mode 100644 skills/plugin-tool-acfun/SKILL.md diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5a14f49 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +* text=auto eol=lf +*.png binary +*.jpg binary +*.webp binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fc5c6be --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +*.log +.DS_Store +Thumbs.db diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4f52df9 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..713de9e --- /dev/null +++ b/README.md @@ -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 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`. diff --git a/README.zh.md b/README.zh.md new file mode 100644 index 0000000..2024f3b --- /dev/null +++ b/README.zh.md @@ -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. 在桌面浏览器登录 。 +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`。 diff --git a/cordis.patch.yml b/cordis.patch.yml new file mode 100644 index 0000000..dff6333 --- /dev/null +++ b/cordis.patch.yml @@ -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') diff --git a/docs/acfun-api.md b/docs/acfun-api.md new file mode 100644 index 0000000..48d400e --- /dev/null +++ b/docs/acfun-api.md @@ -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 `` 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 `` highlights | `emTitle` | `【原神】…` | +| 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=&t=&us=`. **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). diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..f85c399 --- /dev/null +++ b/icon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/index.js b/index.js new file mode 100644 index 0000000..fd6e5ae --- /dev/null +++ b/index.js @@ -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' diff --git a/lib/config.js b/lib/config.js new file mode 100644 index 0000000..1c8961e --- /dev/null +++ b/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} [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, + } +} diff --git a/lib/provider.js b/lib/provider.js new file mode 100644 index 0000000..586be0b --- /dev/null +++ b/lib/provider.js @@ -0,0 +1,1076 @@ +/** + * AcFun (acfun.cn) web API provider. + * + * Zero dependencies: this module only uses the global `fetch`, `URL`, + * `URLSearchParams`, and `AbortSignal`. No `@deepseek-ai/*` import appears in + * the package, so a workspace-linked bundle activates without installing a + * second copy of the harness core. + * + * Verified surface (2026-10, anonymous): + * GET /rest/pc-direct/search/video?keyword= keyword video search + * GET /v/ac{id} page-embedded videoInfo + play URLs + * GET /rest/pc-direct/comment/listByFloor comment list (floor order) + * POST /rest/pc-direct/rank/channel day/week ranking + * Cookie-authenticated surface (routes verified, responses verified as + * "user not login" without a session): + * POST /rest/pc-direct/relation/follow follow / unfollow + * POST /rest/pc-direct/banana/throwBanana tip bananas + * POST /rest/zt/interact/add|delete like / unlike (signed token) + * POST /rest/pc-direct/favorite/... favorite / unfavorite + * POST /rest/pc-direct/comment/add post a comment + */ + +/** Default origin for every acfun.cn web page and pc-direct API. */ +export const DEFAULT_BASE_URL = 'https://www.acfun.cn' + +/** Origin of the token service used by the like flow. */ +export const DEFAULT_TOKEN_BASE_URL = 'https://id.app.acfun.cn' + +/** Origin of the signed interaction API (like / unlike). */ +export const DEFAULT_INTERACT_BASE_URL = 'https://kuaishouzt.com' + +/** Desktop Chrome UA; AcFun rejects some obviously scripted user agents. */ +export const DEFAULT_USER_AGENT = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36' + +/** The `sourceType` AcFun uses for video (douga) comment threads. */ +export const COMMENT_SOURCE_TYPE = 3 + +/** The `resourceType` AcFun uses for video favorites. */ +export const FAVORITE_RESOURCE_TYPE = 9 + +/** The `resourceType` AcFun uses for the danmaku list of one part. */ +export const DANMAKU_RESOURCE_TYPE = 9 + +/** Largest danmaku page the plugin requests. */ +export const DANMAKU_MAX_COUNT = 200 + +/** Search sort keys accepted by `acfun_search`. */ +export const SEARCH_SORTS = Object.freeze(['relevance', 'views']) + +/** `sid` required by id.app.acfun.cn for a like token. */ +export const MIDGROUND_TOKEN_SID = 'acfun.midground.api' + +/** Ranking periods the rank/channel endpoint accepts. */ +export const RANK_PERIODS = Object.freeze(['day', 'week']) + +/** The marker that precedes the video metadata JSON on a /v/ac page. */ +const VIDEO_INFO_MARKER = 'window.pageInfo = window.videoInfo = ' + +/** Comment UBB emot token, for example `[emot=acfun,2789/]`. */ +const UBB_EMOT = /\[emot=[^\]]*\]/gi + +/** + * One AcFun failure with a stable code and a model-facing message. + */ +export class AcfunError extends Error { + /** + * @param {string} message - human-readable, actionable message. + * @param {{code?: string, status?: number, route?: string, detail?: string}} [info] + */ + constructor(message, info = {}) { + super(message) + this.name = 'AcfunError' + this.code = info.code ?? 'ACFUN_ERROR' + this.status = info.status + this.route = info.route + this.detail = info.detail + } +} + +/** Trim a trailing slash so path joins stay predictable. */ +function withoutTrailingSlash(value) { + return value.endsWith('/') ? value.slice(0, -1) : value +} + +/** Parse one Cookie header into a plain name → value map. */ +export function parseCookieHeader(header) { + const out = Object.create(null) + if (typeof header !== 'string') return out + for (const part of header.split(';')) { + const index = part.indexOf('=') + if (index <= 0) continue + const name = part.slice(0, index).trim() + if (name.length === 0) continue + out[name] = part.slice(index + 1).trim() + } + return out +} + +/** Decode the small set of HTML entities AcFun uses in text fields. */ +export function decodeEntities(value) { + if (typeof value !== 'string' || value.indexOf('&') < 0) return value ?? '' + return value + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/�*39;|'/g, "'") + .replace(/ /g, ' ') + .replace(/&/g, '&') +} + +/** Turn AcFun UBB comment markup into readable plain text. */ +export function normalizeUbb(value) { + if (typeof value !== 'string') return '' + return decodeEntities( + value + .replace(//gi, '\n') + .replace(UBB_EMOT, '[表情]') + .replace(/\[\/?[a-z]+(?:=[^\]]*)?\]/gi, ''), + ) + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim() +} + +/** Format a millisecond duration as `h:mm:ss` or `m:ss`. */ +export function formatDuration(milliseconds) { + const total = Math.max(0, Math.round((Number(milliseconds) || 0) / 1000)) + const hours = Math.floor(total / 3600) + const minutes = Math.floor((total % 3600) / 60) + const seconds = total % 60 + const pad = (n) => String(n).padStart(2, '0') + return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}` +} + +/** Format an epoch-millisecond timestamp as an ISO date-time, or ''. */ +export function formatTimestamp(milliseconds) { + const value = Number(milliseconds) + if (!Number.isFinite(value) || value <= 0) return '' + const date = new Date(value) + return Number.isNaN(date.getTime()) ? '' : date.toISOString() +} + +/** The canonical web URL of one AcFun video. */ +export function acfunVideoUrl(acId) { + return `https://www.acfun.cn/v/ac${acId}` +} + +/** + * Accept `42588725`, `ac42588725`, `AC42588725`, or any acfun.cn URL that + * contains `/v/ac`, and return the bare numeric ac id. + * + * @param {unknown} input - model-supplied video identifier. + * @returns {string} bare numeric ac id. + */ +export function normalizeAcId(input) { + if (typeof input !== 'string') { + throw new AcfunError('acId must be a string such as "42588725" or "ac42588725".', { + code: 'INVALID_ARGUMENT', + }) + } + const value = input.trim() + if (/^\d+$/.test(value)) return value + const prefixed = /^ac(\d+)$/i.exec(value) + if (prefixed !== null) return prefixed[1] + const fromUrl = /\/v\/ac(\d+)/i.exec(value) ?? /[?&](?:ac|acId|videoId)=(\d+)/i.exec(value) + if (fromUrl !== null) return fromUrl[1] + throw new AcfunError( + `Cannot read an AcFun video id from "${value}". Pass the numeric id (42588725), the "ac" form (ac42588725), or a https://www.acfun.cn/v/ac... URL.`, + { code: 'INVALID_ARGUMENT' }, + ) +} + +/** + * Scan a document for `marker` followed by a balanced JSON value and parse it. + * + * AcFun embeds `window.pageInfo = window.videoInfo = {...}` inside an inline + * script, so a plain JSON.parse of the page is impossible. + * + * @param {string} html - full page source. + * @param {string} marker - exact prefix to find. + * @returns {Record | undefined} parsed value, or undefined. + */ +export function extractAssignedJson(html, marker) { + if (typeof html !== 'string') return undefined + const start = html.indexOf(marker) + if (start < 0) return undefined + const from = start + marker.length + let depth = 0 + let inString = false + let escaped = false + for (let index = from; index < html.length; index += 1) { + const char = html[index] + if (inString) { + if (escaped) escaped = false + else if (char === '\\') escaped = true + else if (char === '"') inString = false + continue + } + if (char === '"') { + inString = true + continue + } + if (char === '{') depth += 1 + else if (char === '}') { + depth -= 1 + if (depth === 0) { + try { + return JSON.parse(html.slice(from, index + 1)) + } catch { + return undefined + } + } + } + } + return undefined +} + +/** + * Read the H.264 representation ladder out of a `ksPlayJson` string. + * + * @param {unknown} playJson - the JSON string (or object) AcFun embeds. + * @returns {Array<{label: string, width: number, height: number, url: string}>} + */ +export function readPlayQualities(playJson) { + let parsed = playJson + if (typeof playJson === 'string') { + try { + parsed = JSON.parse(playJson) + } catch { + return [] + } + } + if (parsed === null || typeof parsed !== 'object') return [] + const sets = Array.isArray(parsed.adaptationSet) ? parsed.adaptationSet : [] + const qualities = [] + for (const set of sets) { + const representations = Array.isArray(set?.representation) ? set.representation : [] + for (const representation of representations) { + if (typeof representation?.url !== 'string' || representation.url.length === 0) continue + const height = Number(representation.height) || 0 + const width = Number(representation.width) || 0 + const label = + typeof representation.qualityLabel === 'string' && representation.qualityLabel.length > 0 + ? representation.qualityLabel + : height > 0 + ? `${height}P` + : typeof representation.name === 'string' + ? representation.name + : 'auto' + qualities.push({ label, width, height, url: representation.url }) + } + } + qualities.sort((left, right) => right.height - left.height) + return qualities +} + +/** + * One AcFun API client bound to a cookie and timeout budget. + */ +export class AcfunProvider { + /** + * @param {object} [options] + * @param {string} [options.baseUrl] - acfun.cn origin. + * @param {string} [options.tokenBaseUrl] - token service origin. + * @param {string} [options.cookie] - full Cookie header value; empty means anonymous. + * @param {string} [options.userAgent] - User-Agent header. + * @param {number} [options.requestTimeoutMs] - per-HTTP-request timeout. + * @param {boolean} [options.allowWriteActions] - gate for every mutating call. + */ + constructor(options = {}) { + this.baseUrl = withoutTrailingSlash(options.baseUrl ?? DEFAULT_BASE_URL) + this.tokenBaseUrl = withoutTrailingSlash(options.tokenBaseUrl ?? DEFAULT_TOKEN_BASE_URL) + this.interactBaseUrl = withoutTrailingSlash(options.interactBaseUrl ?? DEFAULT_INTERACT_BASE_URL) + this.cookie = typeof options.cookie === 'string' ? options.cookie.trim() : '' + this.userAgent = options.userAgent ?? DEFAULT_USER_AGENT + this.requestTimeoutMs = options.requestTimeoutMs ?? 30_000 + this.allowWriteActions = options.allowWriteActions === true + this.cookies = parseCookieHeader(this.cookie) + } + + /** Whether a login cookie is configured. */ + get loggedIn() { + return this.cookie.length > 0 + } + + /** Whether mutating calls are permitted by configuration and login state. */ + get writable() { + return this.allowWriteActions && this.loggedIn + } + + /** Reject a mutating call with a specific, actionable reason. */ + assertWritable(action = 'this action') { + if (!this.allowWriteActions) { + throw new AcfunError( + `${action} is disabled: set "allowWriteActions: true" in this plugin's config to enable AcFun interactions.`, + { code: 'WRITE_DISABLED' }, + ) + } + if (!this.loggedIn) { + throw new AcfunError( + `${action} requires an AcFun login cookie: set "cookie" in this plugin's config (or the ${'ACFUN_COOKIE'} environment variable).`, + { code: 'LOGIN_REQUIRED' }, + ) + } + } + + /** Headers shared by every request. */ + #headers(extra = {}) { + const headers = { + 'User-Agent': this.userAgent, + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.6', + Referer: `${this.baseUrl}/`, + ...extra, + } + if (this.cookie.length > 0) headers.Cookie = this.cookie + return headers + } + + /** Combine the caller signal with the request timeout. */ + #signal(signal) { + const timeout = AbortSignal.timeout(this.requestTimeoutMs) + if (!signal) return timeout + return typeof AbortSignal.any === 'function' ? AbortSignal.any([signal, timeout]) : timeout + } + + /** Build one absolute URL with an optional query object. */ + #url(pathOrUrl, query) { + const absolute = /^https?:\/\//i.test(pathOrUrl) ? pathOrUrl : `${this.baseUrl}${pathOrUrl}` + if (query === undefined) return absolute + const url = new URL(absolute) + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null) continue + url.searchParams.set(key, String(value)) + } + return url.toString() + } + + /** + * Perform one HTTP request and return the raw text plus response metadata. + * + * @param {string} pathOrUrl - path on baseUrl, or absolute URL. + * @param {{method?: string, query?: Record, form?: Record, jsonBody?: unknown, headers?: Record, signal?: AbortSignal, accept?: string}} [options] + * @returns {Promise<{status: number, contentType: string, text: string, url: string}>} + */ + async #http(pathOrUrl, options = {}) { + const method = options.method ?? 'GET' + const headers = this.#headers({ Accept: options.accept ?? 'application/json, text/plain, */*', ...options.headers }) + let body + if (options.form !== undefined) { + headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8' + headers['X-Requested-With'] = 'XMLHttpRequest' + body = new URLSearchParams() + for (const [key, value] of Object.entries(options.form)) { + if (value === undefined || value === null) continue + body.set(key, String(value)) + } + } else if (options.jsonBody !== undefined) { + headers['Content-Type'] = 'application/json;charset=UTF-8' + headers['X-Requested-With'] = 'XMLHttpRequest' + body = JSON.stringify(options.jsonBody) + } + + const url = this.#url(pathOrUrl, options.query) + let response + try { + response = await fetch(url, { method, headers, body, signal: this.#signal(options.signal), redirect: 'follow' }) + } catch (cause) { + if (cause?.name === 'AbortError' || cause?.name === 'TimeoutError') { + throw new AcfunError( + `AcFun request timed out after ${this.requestTimeoutMs} ms (${method} ${url}).`, + { code: 'TIMEOUT', route: url, detail: String(cause) }, + ) + } + throw new AcfunError(`Network error talking to AcFun (${method} ${url}): ${String(cause?.message ?? cause)}`, { + code: 'NETWORK', + route: url, + detail: String(cause), + }) + } + const text = await response.text() + return { + status: response.status, + contentType: response.headers.get('content-type') ?? '', + text, + url: response.url || url, + } + } + + /** + * Perform one JSON API call and unwrap the AcFun `{result: 0, ...}` envelope. + * + * @param {string} path - pc-direct path. + * @param {object} [options] - same options as #http. + * @returns {Promise>} the parsed body when `result === 0`. + */ + async #api(path, options = {}) { + const response = await this.#http(path, options) + if (!response.contentType.includes('json')) { + if (response.status === 404) { + throw new AcfunError(`AcFun endpoint not found: ${path}`, { code: 'NOT_FOUND', route: path, status: 404 }) + } + throw new AcfunError( + `AcFun returned a non-JSON response for ${path} (HTTP ${response.status}). The endpoint may have moved.`, + { code: 'UNEXPECTED_RESPONSE', route: path, status: response.status, detail: response.text.slice(0, 200) }, + ) + } + let payload + try { + payload = JSON.parse(response.text) + } catch { + throw new AcfunError(`AcFun returned malformed JSON for ${path}.`, { + code: 'MALFORMED_JSON', + route: path, + status: response.status, + detail: response.text.slice(0, 200), + }) + } + if (payload !== null && typeof payload === 'object' && typeof payload.result === 'number' && payload.result !== 0) { + throw this.#apiError(path, payload, response.status) + } + return payload + } + + /** Map an AcFun `result` code to a stable error. */ + #apiError(path, payload, status) { + const code = payload.result + const serverMessage = typeof payload.error_msg === 'string' ? payload.error_msg : '' + const base = `AcFun API ${path} failed with result ${code}${serverMessage ? `: ${serverMessage}` : ''}` + if (code === -401 || code === 401) { + return new AcfunError(`${base}. The configured AcFun login cookie is missing or has expired.`, { + code: 'LOGIN_REQUIRED', + route: path, + status, + detail: serverMessage, + }) + } + if (code >= 400_001 && code <= 410_999) { + return new AcfunError( + `${base}. AcFun asks for an identity-verification/CAPTCHA step for this account, which this plugin cannot complete; finish the action once in a browser and retry later.`, + { code: 'CAPTCHA_REQUIRED', route: path, status, detail: serverMessage }, + ) + } + return new AcfunError(base, { code: 'API_ERROR', route: path, status, detail: serverMessage }) + } + + /** + * Search AcFun videos by keyword. + * + * The current pc-direct search endpoint ignores paging parameters and always + * returns its first page (server default `pageSize: 30`), so this returns at + * most 30 items. `sortType` is honoured: 0/omitted is relevance and 2 is + * most-viewed. + * + * @param {string} keyword - non-empty search phrase. + * @param {{sort?: string, signal?: AbortSignal}} [options] + * @returns {Promise<{keyword: string, sort: string, total: number, returned: number, items: object[]}>} + */ + async search(keyword, options = {}) { + const phrase = typeof keyword === 'string' ? keyword.trim() : '' + if (phrase.length === 0) { + throw new AcfunError('keyword must be a non-empty string.', { code: 'INVALID_ARGUMENT' }) + } + const sort = typeof options.sort === 'string' ? options.sort.trim() : 'relevance' + if (!SEARCH_SORTS.includes(sort)) { + throw new AcfunError(`sort must be one of ${SEARCH_SORTS.join(', ')}.`, { code: 'INVALID_ARGUMENT' }) + } + const data = await this.#api('/rest/pc-direct/search/video', { + query: { keyword: phrase, ...sort === 'views' ? { sortType: 2 } : {} }, + signal: options.signal, + }) + const list = Array.isArray(data.videoList) ? data.videoList : [] + const items = [] + for (const entry of list) { + if (entry === null || typeof entry !== 'object') continue + const acId = entry.contentId ?? entry.id + if (acId === undefined || acId === null || !/^\d+$/.test(String(acId))) continue + items.push({ + acId: String(acId), + title: decodeEntities(String(entry.title ?? '')).trim(), + authorId: String(entry.userId ?? ''), + authorName: decodeEntities(String(entry.userName ?? '')), + viewCount: Number(entry.viewCount) || 0, + danmakuCount: Number(entry.danmuCount) || 0, + commentCount: Number(entry.commentCount) || 0, + duration: typeof entry.playDuration === 'string' ? entry.playDuration : '', + publishedAt: formatTimestamp(entry.ctime), + coverUrl: typeof entry.coverUrl === 'string' ? entry.coverUrl : '', + url: acfunVideoUrl(String(acId)), + }) + } + return { + keyword: phrase, + sort, + total: Number(data.totalNum) || items.length, + returned: items.length, + items, + } + } + + /** + * Read one video's full metadata from its `/v/ac{id}` page. + * + * @param {string|number} input - ac id, "ac" form, or acfun URL. + * @param {{signal?: AbortSignal}} [options] + * @returns {Promise>} + */ + async video(input, options = {}) { + const acId = normalizeAcId(String(input)) + const response = await this.#http(`/v/ac${acId}`, { + accept: 'text/html,application/xhtml+xml', + signal: options.signal, + }) + if (response.status === 404 || response.status === 502) { + throw new AcfunError( + `AcFun video ${acId} is unavailable (HTTP ${response.status}). It may have been deleted or restricted.`, + { code: 'NOT_FOUND', route: `/v/ac${acId}`, status: response.status }, + ) + } + if (response.status !== 200) { + throw new AcfunError(`AcFun returned HTTP ${response.status} for video ${acId}.`, { + code: 'HTTP_ERROR', + route: `/v/ac${acId}`, + status: response.status, + }) + } + const info = extractAssignedJson(response.text, VIDEO_INFO_MARKER) + if (info === undefined || info === null) { + if (response.text.includes('bangumiData') || response.text.includes('articleInfo')) { + throw new AcfunError( + `AcFun ${acId} resolves to a bangumi or article page, not a video page; only video pages are supported.`, + { code: 'UNSUPPORTED_RESOURCE', route: `/v/ac${acId}` }, + ) + } + throw new AcfunError( + `Could not read metadata from AcFun video ${acId}; the page layout may have changed.`, + { code: 'PARSE_ERROR', route: `/v/ac${acId}` }, + ) + } + const dougaId = String(info.dougaId ?? '') + if (!/^\d+$/.test(dougaId)) { + throw new AcfunError(`AcFun video ${acId} did not expose a usable video id; it may be region-locked or deleted.`, { + code: 'NOT_FOUND', + route: `/v/ac${acId}`, + }) + } + const user = info.user !== null && typeof info.user === 'object' ? info.user : {} + const channel = info.channel !== null && typeof info.channel === 'object' ? info.channel : {} + const tags = Array.isArray(info.tagList) + ? info.tagList + .filter((tag) => tag !== null && typeof tag === 'object' && typeof tag.name === 'string') + .map((tag) => ({ name: tag.name, id: String(tag.id ?? '') })) + : [] + const current = info.currentVideoInfo !== null && typeof info.currentVideoInfo === 'object' ? info.currentVideoInfo : {} + const durationMillis = Number(current.durationMillis) || Number(info.durationMillis) || 0 + const description = typeof info.description === 'string' && info.description !== 'null' ? info.description : '' + const coverUrl = + typeof info.coverUrl === 'string' && info.coverUrl.length > 0 + ? info.coverUrl + : Array.isArray(info.coverCdnUrls) && typeof info.coverCdnUrls[0]?.url === 'string' + ? info.coverCdnUrls[0].url + : '' + return { + acId: dougaId, + videoId: Number(info.currentVideoId) || 0, + title: String(info.title ?? '').trim(), + description: description.trim(), + authorId: String(user.id ?? ''), + authorName: String(user.name ?? ''), + authorAvatar: typeof user.headUrl === 'string' ? user.headUrl : '', + authorSignature: typeof user.signature === 'string' ? user.signature : '', + authorFollowers: Number(user.fanCountValue) || 0, + channel: String(channel.name ?? ''), + channelParent: String(channel.parentName ?? ''), + channelId: Number(channel.id) || 0, + tags, + durationMillis, + duration: formatDuration(durationMillis), + publishedAt: formatTimestamp(info.createTimeMillis), + viewCount: Number(info.viewCount) || 0, + danmakuCount: Number(info.danmakuCount) || 0, + commentCount: Number(info.commentCountRealValue ?? info.commentCount) || 0, + likeCount: Number(info.likeCount) || 0, + favoriteCount: Number(info.stowCount) || 0, + bananaCount: Number(info.bananaCount) || 0, + coverUrl, + url: acfunVideoUrl(dougaId), + loggedIn: this.loggedIn, + liked: info.isLike === true, + favorited: info.isFavorite === true, + bananaGiven: info.isThrowBanana === true, + } + } + + /** + * Read the playable quality ladder of one video. + * + * @param {string|number} input - ac id, "ac" form, or acfun URL. + * @param {{signal?: AbortSignal}} [options] + * @returns {Promise<{acId: string, title: string, duration: string, qualities: Array<{label: string, width: number, height: number, url: string}>}>} + */ + async playUrls(input, options = {}) { + const acId = normalizeAcId(String(input)) + const response = await this.#http(`/v/ac${acId}`, { + accept: 'text/html,application/xhtml+xml', + signal: options.signal, + }) + if (response.status !== 200) { + throw new AcfunError(`AcFun returned HTTP ${response.status} for video ${acId}.`, { + code: response.status === 404 ? 'NOT_FOUND' : 'HTTP_ERROR', + route: `/v/ac${acId}`, + status: response.status, + }) + } + const info = extractAssignedJson(response.text, VIDEO_INFO_MARKER) + if (info === undefined || info === null) { + throw new AcfunError(`Could not read play information for AcFun video ${acId}; the page layout may have changed.`, { + code: 'PARSE_ERROR', + route: `/v/ac${acId}`, + }) + } + const current = info.currentVideoInfo !== null && typeof info.currentVideoInfo === 'object' ? info.currentVideoInfo : {} + let qualities = readPlayQualities(current.ksPlayJson) + if (qualities.length === 0) qualities = readPlayQualities(current.ksPlayJsonHevc) + if (qualities.length === 0) { + // Fallback: the page omitted ksPlayJson, so ask the play API directly. + // It addresses the part by `videoId` (currentVideoId), not by ac id. + const videoId = Number(info.currentVideoId) || Number(current.id) || 0 + if (videoId > 0) { + try { + const data = await this.#api('/rest/pc-direct/play/playInfo/ksPlayJson', { + query: { videoId }, + signal: options.signal, + }) + qualities = readPlayQualities(data.playInfo?.ksPlayJson ?? data.ksPlayJson) + } catch (error) { + if (!(error instanceof AcfunError)) throw error + } + } + } + if (qualities.length === 0) { + throw new AcfunError( + `AcFun exposed no playable stream for video ${acId}. It may be a paid/paid-preview video, region-locked, or deleted.`, + { code: 'NO_PLAY_URL', route: `/v/ac${acId}` }, + ) + } + return { + acId: String(info.dougaId ?? acId), + title: String(info.title ?? '').trim(), + duration: formatDuration(Number(current.durationMillis) || Number(info.durationMillis) || 0), + qualities, + } + } + + /** + * Read one page of a video's comment list. + * + * @param {string|number} input - ac id, "ac" form, or acfun URL. + * @param {{page?: number, signal?: AbortSignal}} [options] + * @returns {Promise>} + */ + async comments(input, options = {}) { + const acId = normalizeAcId(String(input)) + const page = Number(options.page ?? 1) + const data = await this.#api('/rest/pc-direct/comment/listByFloor', { + query: { + sourceId: acId, + sourceType: COMMENT_SOURCE_TYPE, + page: Number.isInteger(page) && page > 0 ? page : 1, + pivotCommentId: '', + newPivotCommentId: '', + t: '', + supportZtEmot: 'true', + }, + signal: options.signal, + }) + const map = data.commentsMap !== null && typeof data.commentsMap === 'object' ? data.commentsMap : {} + const orderedIds = Array.isArray(data.commentIds) + ? data.commentIds.map((id) => String(id)) + : Object.keys(map).map((key) => key.replace(/^c/, '')) + const items = [] + for (const id of orderedIds) { + const entry = map[`c${id}`] ?? map[id] + if (entry === null || typeof entry !== 'object') continue + items.push({ + commentId: String(entry.cid ?? id), + userId: String(entry.userId ?? ''), + userName: String(entry.userName ?? ''), + content: normalizeUbb(entry.content), + floor: Number(entry.floor) || 0, + postedAt: String(entry.postDate ?? ''), + timestamp: Number(entry.timestamp) || 0, + isUp: entry.isUp === true, + isDeleted: entry.isDelete === true, + }) + } + const totalPages = Number(data.totalPage) || 1 + const currentPage = Number(data.curPage) || page + return { + acId, + page: currentPage, + totalPages, + totalCount: Number(data.totalCount) || items.length, + pageSize: Number(data.pageSize) || items.length, + returned: items.length, + hasMore: currentPage < totalPages, + url: acfunVideoUrl(acId), + items, + } + } + + /** + * Read one page of a video's danmaku (弹幕) list. + * + * AcFun addresses danmaku by the part's `videoId` (currentVideoId), not by + * the ac/douga id, so this first resolves the video page. + * + * @param {string|number} input - ac id, "ac" form, or acfun URL. + * @param {{limit?: number, cursor?: string, signal?: AbortSignal}} [options] + * @returns {Promise>} + */ + async danmaku(input, options = {}) { + const acId = normalizeAcId(String(input)) + const limit = Number(options.limit ?? DANMAKU_MAX_COUNT) + if (!Number.isInteger(limit) || limit < 1 || limit > DANMAKU_MAX_COUNT) { + throw new AcfunError(`limit must be an integer between 1 and ${DANMAKU_MAX_COUNT}.`, { + code: 'INVALID_ARGUMENT', + }) + } + const cursor = typeof options.cursor === 'string' && options.cursor.trim().length > 0 ? options.cursor.trim() : '1' + const video = await this.video(acId, { signal: options.signal }) + if (!(video.videoId > 0)) { + throw new AcfunError(`AcFun video ${acId} did not expose a part videoId, so danmaku cannot be requested.`, { + code: 'NO_VIDEO_PART', + route: `/v/ac${acId}`, + }) + } + const data = await this.#api('/rest/pc-direct/new-danmaku/list', { + method: 'POST', + form: { + resourceId: video.videoId, + resourceType: DANMAKU_RESOURCE_TYPE, + enableAdvanced: 'true', + pcursor: cursor, + count: limit, + sortType: 1, + asc: 'false', + }, + signal: options.signal, + }) + const list = Array.isArray(data.danmakus) ? data.danmakus : [] + const items = [] + for (const entry of list) { + if (entry === null || typeof entry !== 'object') continue + if (typeof entry.body !== 'string') continue + const position = Number(entry.position) + items.push({ + danmakuId: String(entry.danmakuId ?? ''), + body: decodeEntities(entry.body).trim(), + position: Number.isFinite(position) ? position : 0, + time: formatDuration(Number.isFinite(position) ? position : 0), + color: typeof entry.color === 'number' ? `#${entry.color.toString(16).padStart(6, '0')}` : '', + mode: Number(entry.mode) || 0, + userId: String(entry.userId ?? ''), + createdAt: formatTimestamp(entry.createTime), + likeCount: Number(entry.likeCount) || 0, + }) + } + items.sort((left, right) => left.position - right.position) + const nextCursor = typeof data.pcursor === 'string' && data.pcursor !== 'no_more' ? data.pcursor : '' + return { + acId, + videoId: video.videoId, + title: video.title, + totalCount: Number(data.totalCount) || items.length, + returned: items.length, + nextCursor, + items, + } + } + + /** + * Read the day/week ranking of one channel (`channelId: 0` means all). + * + * @param {{period?: string, channelId?: number, limit?: number, signal?: AbortSignal}} [options] + * @returns {Promise>} + */ + async rank(options = {}) { + const period = typeof options.period === 'string' ? options.period.trim() : 'day' + if (!RANK_PERIODS.includes(period)) { + throw new AcfunError(`period must be one of ${RANK_PERIODS.join(', ')}.`, { code: 'INVALID_ARGUMENT' }) + } + const channelId = Number(options.channelId ?? 0) + if (!Number.isInteger(channelId) || channelId < 0) { + throw new AcfunError('channelId must be a non-negative integer (0 means all channels).', { + code: 'INVALID_ARGUMENT', + }) + } + const limit = Number(options.limit ?? 10) + if (!Number.isInteger(limit) || limit < 1 || limit > 50) { + throw new AcfunError('limit must be an integer between 1 and 50.', { code: 'INVALID_ARGUMENT' }) + } + const data = await this.#api('/rest/pc-direct/rank/channel', { + method: 'POST', + form: { rankPeriod: period, channelId, rankLimit: limit }, + signal: options.signal, + }) + const list = Array.isArray(data.rankList) ? data.rankList : [] + const items = [] + for (const entry of list) { + if (entry === null || typeof entry !== 'object') continue + const acId = String(entry.dougaId ?? entry.contentId ?? '') + if (!/^\d+$/.test(acId)) continue + items.push({ + acId, + title: decodeEntities(String(entry.contentTitle ?? '')).trim(), + description: decodeEntities(String(entry.contentDesc ?? '')).trim(), + authorId: String(entry.userId ?? ''), + authorName: decodeEntities(String(entry.userName ?? '')), + channelName: String(entry.channelName ?? ''), + coverUrl: typeof entry.videoCover === 'string' ? entry.videoCover : '', + duration: formatDuration(Number(entry.durationMillis) || Number(entry.duration) || 0), + danmakuCount: Number(entry.danmuCount) || 0, + likeCountText: String(entry.likeCountShow ?? ''), + commentCountText: String(entry.commentCountShow ?? ''), + publishedAt: formatTimestamp(entry.contributeTime), + url: acfunVideoUrl(acId), + }) + } + return { period, channelId, returned: items.length, items } + } + + /** + * Follow or unfollow one AcFun user. + * + * @param {string|number} userId - target user id. + * @param {boolean} follow - true to follow, false to unfollow. + * @param {{signal?: AbortSignal}} [options] + * @returns {Promise<{ok: true, message: string}>} + */ + async setFollow(userId, follow, options = {}) { + this.assertWritable(follow ? 'Following a user' : 'Unfollowing a user') + const target = String(userId ?? '').trim() + if (!/^\d+$/.test(target)) { + throw new AcfunError('userId must be a numeric AcFun user id.', { code: 'INVALID_ARGUMENT' }) + } + await this.#api('/rest/pc-direct/relation/follow', { + method: 'POST', + form: { toUserId: target, action: follow ? 1 : 2, groupId: 0 }, + signal: options.signal, + }) + return { ok: true, message: follow ? `Followed AcFun user ${target}.` : `Unfollowed AcFun user ${target}.` } + } + + /** + * Tip bananas (AcFun's投蕉) to one video. + * + * @param {string|number} input - ac id. + * @param {number} count - bananas to tip. + * @param {{signal?: AbortSignal}} [options] + * @returns {Promise<{ok: true, message: string, bananaCount?: number}>} + */ + async throwBanana(input, count, options = {}) { + this.assertWritable('Tipping bananas (投蕉)') + const acId = normalizeAcId(String(input)) + const amount = Number(count ?? 1) + if (!Number.isInteger(amount) || amount < 1 || amount > 10) { + throw new AcfunError('count must be an integer between 1 and 10.', { code: 'INVALID_ARGUMENT' }) + } + const udid = this.cookies._did + const headers = udid === undefined ? {} : { udid } + const data = await this.#api('/rest/pc-direct/banana/throwBanana', { + method: 'POST', + form: { resourceId: acId, count: amount, resourceType: 2 }, + headers, + signal: options.signal, + }) + const total = Number(data.extData?.bananaRealCount) + return { + ok: true, + message: `Tipped ${amount} banana(s) to ac${acId}.`, + ...Number.isFinite(total) ? { bananaCount: total } : {}, + } + } + + /** + * Fetch the short-lived signed token the like endpoint requires. + * + * @param {{signal?: AbortSignal}} [options] + * @returns {Promise<{key: string, value: string, userId: string}>} + */ + async midgroundToken(options = {}) { + const data = await this.#api(`${this.tokenBaseUrl}/rest/web/token/get`, { + method: 'POST', + form: { sid: MIDGROUND_TOKEN_SID }, + signal: options.signal, + }) + const key = `${MIDGROUND_TOKEN_SID}_st` + const value = data[key] + if (typeof value !== 'string' || value.length === 0) { + const fallback = Object.keys(data).find((name) => name.endsWith('_st') && typeof data[name] === 'string') + if (fallback === undefined) { + throw new AcfunError('AcFun token service did not return a like token for this session.', { + code: 'LOGIN_REQUIRED', + route: '/rest/web/token/get', + }) + } + return { key: fallback, value: data[fallback], userId: String(data.userId ?? '') } + } + return { key, value, userId: String(data.userId ?? '') } + } + + /** + * Like or unlike one video. + * + * @param {string|number} input - ac id. + * @param {boolean} like - true to like, false to remove the like. + * @param {{signal?: AbortSignal}} [options] + * @returns {Promise<{ok: true, message: string}>} + */ + async setLike(input, like, options = {}) { + this.assertWritable(like ? 'Liking a video' : 'Unliking a video') + const acId = normalizeAcId(String(input)) + const token = await this.midgroundToken({ signal: options.signal }) + const form = { + kpn: 'ACFUN_APP', + kpf: 'PC_WEB', + subBiz: 'mainApp', + interactType: 1, + objectType: 2, + objectId: acId, + userId: token.userId, + [token.key]: token.value, + 'extParams[isPlaying]': 'false', + 'extParams[showCount]': '0', + 'extParams[otherBtnClickedCount]': '0', + } + await this.#api(`${this.interactBaseUrl}${like ? '/rest/zt/interact/add' : '/rest/zt/interact/delete'}`, { + method: 'POST', + form, + signal: options.signal, + }) + return { ok: true, message: like ? `Liked ac${acId}.` : `Removed the like from ac${acId}.` } + } + + /** + * List the signed-in user's favorite folders for one resource. + * + * @param {string} acId - bare ac id. + * @param {{token?: {key: string, value: string, userId: string}, signal?: AbortSignal}} [options] + * @returns {Promise>} + */ + async favoriteFolders(acId, options = {}) { + const token = options.token ?? (await this.midgroundToken({ signal: options.signal })) + const data = await this.#api('/rest/pc-direct/favorite/folder/list', { + method: 'POST', + form: { resourceId: acId, [token.key]: token.value, userId: token.userId }, + signal: options.signal, + }) + const list = Array.isArray(data.dataList) ? data.dataList : [] + return list + .filter((folder) => folder !== null && typeof folder === 'object') + .map((folder) => ({ + folderId: String(folder.folderId ?? ''), + name: String(folder.name ?? ''), + inFolder: folder.inFolder === true, + type: Number(folder.type) || 0, + })) + .filter((folder) => folder.folderId.length > 0) + } + + /** + * Add one video to a favorite folder, or remove it from the folders it is in. + * + * @param {string|number} input - ac id. + * @param {boolean} favorite - true to favorite, false to unfavorite. + * @param {{folderId?: string, signal?: AbortSignal}} [options] + * @returns {Promise<{ok: true, message: string, folderName: string}>} + */ + async setFavorite(input, favorite, options = {}) { + this.assertWritable(favorite ? 'Favoriting a video' : 'Unfavoriting a video') + const acId = normalizeAcId(String(input)) + const token = await this.midgroundToken({ signal: options.signal }) + const folders = await this.favoriteFolders(acId, { token, signal: options.signal }) + + if (favorite) { + const explicit = typeof options.folderId === 'string' ? options.folderId.trim() : '' + const target = + explicit.length > 0 + ? folders.find((folder) => folder.folderId === explicit) ?? + { folderId: explicit, name: explicit, inFolder: false, type: 0 } + : folders.find((folder) => folder.inFolder) ?? + folders.find((folder) => folder.type === 1) ?? + folders[0] + if (target === undefined) { + throw new AcfunError( + 'No AcFun favorite folder was returned for this account; open the favorite dialog once in a browser to create the default folder, then retry.', + { code: 'NO_FOLDER', route: '/rest/pc-direct/favorite/folder/list' }, + ) + } + await this.#api('/rest/pc-direct/favorite/resource/add', { + method: 'POST', + form: { + resourceId: acId, + resourceType: FAVORITE_RESOURCE_TYPE, + addFolderIds: target.folderId, + [token.key]: token.value, + userId: token.userId, + }, + signal: options.signal, + }) + return { ok: true, message: `Favorited ac${acId} into "${target.name || target.folderId}".`, folderName: target.name } + } + + const containing = folders.filter((folder) => folder.inFolder) + if (containing.length === 0) { + return { ok: true, message: `ac${acId} is not in any favorite folder; nothing to remove.`, folderName: '' } + } + await this.#api('/rest/pc-direct/favorite/resource/remove', { + method: 'POST', + form: { + resourceId: acId, + resourceType: FAVORITE_RESOURCE_TYPE, + delFolderIds: containing.map((folder) => folder.folderId).join(','), + [token.key]: token.value, + userId: token.userId, + }, + signal: options.signal, + }) + return { + ok: true, + message: `Removed ac${acId} from favorite folder(s): ${containing.map((folder) => folder.name || folder.folderId).join(', ')}.`, + folderName: containing.map((folder) => folder.name).join(','), + } + } + + /** + * Post a top-level comment, or a reply when `replyToCommentId` is set. + * + * @param {string|number} input - ac id. + * @param {string} content - plain text or AcFun UBB content. + * @param {{replyToCommentId?: string, signal?: AbortSignal}} [options] + * @returns {Promise<{ok: true, commentId: string, message: string}>} + */ + async postComment(input, content, options = {}) { + this.assertWritable('Posting a comment') + const acId = normalizeAcId(String(input)) + const body = typeof content === 'string' ? content.trim() : '' + if (body.length === 0) { + throw new AcfunError('content must be a non-empty string.', { code: 'INVALID_ARGUMENT' }) + } + if (body.length > 2000) { + throw new AcfunError('content is longer than 2000 characters.', { code: 'INVALID_ARGUMENT' }) + } + const replyTo = String(options.replyToCommentId ?? '').trim() + if (replyTo.length > 0 && !/^\d+$/.test(replyTo)) { + throw new AcfunError('replyToCommentId must be a numeric comment id.', { code: 'INVALID_ARGUMENT' }) + } + const data = await this.#api('/rest/pc-direct/comment/add', { + method: 'POST', + jsonBody: { + sourceId: acId, + sourceType: COMMENT_SOURCE_TYPE, + content: body, + replyToCommentId: replyTo.length > 0 ? Number(replyTo) : 0, + }, + signal: options.signal, + }) + const commentId = String(data.commentId ?? data.cid ?? '') + return { + ok: true, + commentId, + message: replyTo.length > 0 ? `Replied to comment ${replyTo} on ac${acId}.` : `Commented on ac${acId}.`, + } + } +} diff --git a/lib/tools.js b/lib/tools.js new file mode 100644 index 0000000..9192c1b --- /dev/null +++ b/lib/tools.js @@ -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] +} diff --git a/locale/en.json b/locale/en.json new file mode 100644 index 0000000..eb3f5d3 --- /dev/null +++ b/locale/en.json @@ -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." + } +} diff --git a/locale/zh.json b/locale/zh.json new file mode 100644 index 0000000..ead49c7 --- /dev/null +++ b/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "AcFun 工具", + "description": "搜索 A 站视频,读取视频详情/评论/弹幕/热榜/播放地址;配置登录 Cookie 并开启写操作后,可在 A 站点赞、投蕉、收藏、关注和发评论。" + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a2db6b1 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/scripts/live-check.mjs b/scripts/live-check.mjs new file mode 100644 index 0000000..9ebf8d8 --- /dev/null +++ b/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') +} diff --git a/scripts/schema-check.mjs b/scripts/schema-check.mjs new file mode 100644 index 0000000..48041ec --- /dev/null +++ b/scripts/schema-check.mjs @@ -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 + } +} diff --git a/scripts/self-check.mjs b/scripts/self-check.mjs new file mode 100644 index 0000000..4714243 --- /dev/null +++ b/scripts/self-check.mjs @@ -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') +} + diff --git a/skills/plugin-tool-acfun/SKILL.md b/skills/plugin-tool-acfun/SKILL.md new file mode 100644 index 0000000..9e3017e --- /dev/null +++ b/skills/plugin-tool-acfun/SKILL.md @@ -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.