dsh-shlr-bilitoolkit 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +122 -3
  2. package/cordis.patch.yml +13 -0
  3. package/dist/apis/article.d.ts +12 -0
  4. package/dist/apis/article.js +49 -0
  5. package/dist/apis/auth.d.ts +20 -0
  6. package/dist/apis/auth.js +83 -0
  7. package/dist/apis/comment.d.ts +37 -0
  8. package/dist/apis/comment.js +164 -0
  9. package/dist/apis/download.d.ts +8 -0
  10. package/dist/apis/download.js +60 -0
  11. package/dist/apis/dynamic.d.ts +23 -0
  12. package/dist/apis/dynamic.js +154 -0
  13. package/dist/apis/interact.d.ts +29 -0
  14. package/dist/apis/interact.js +224 -0
  15. package/dist/apis/message.d.ts +18 -0
  16. package/dist/apis/message.js +109 -0
  17. package/dist/apis/notify.d.ts +17 -0
  18. package/dist/apis/notify.js +54 -0
  19. package/dist/apis/profile.d.ts +15 -0
  20. package/dist/apis/profile.js +59 -0
  21. package/dist/apis/relation.d.ts +13 -0
  22. package/dist/apis/relation.js +47 -0
  23. package/dist/apis/search.d.ts +17 -0
  24. package/dist/apis/search.js +81 -0
  25. package/dist/apis/space.d.ts +17 -0
  26. package/dist/apis/space.js +89 -0
  27. package/dist/apis/video.d.ts +18 -0
  28. package/dist/apis/video.js +153 -0
  29. package/dist/cache.d.ts +18 -0
  30. package/dist/cache.js +75 -0
  31. package/dist/client.d.ts +85 -0
  32. package/dist/client.js +325 -0
  33. package/dist/dsh.d.ts +25 -0
  34. package/dist/dsh.js +484 -0
  35. package/dist/endpoints.d.ts +55 -0
  36. package/dist/endpoints.js +69 -0
  37. package/dist/errors.d.ts +18 -0
  38. package/dist/errors.js +50 -0
  39. package/dist/formatting.d.ts +7 -0
  40. package/dist/formatting.js +69 -0
  41. package/dist/index.d.ts +22 -0
  42. package/dist/index.js +21 -0
  43. package/dist/models.d.ts +215 -0
  44. package/dist/models.js +876 -0
  45. package/dist/pagination.d.ts +7 -0
  46. package/dist/pagination.js +20 -0
  47. package/dist/util.d.ts +2 -0
  48. package/dist/util.js +4 -0
  49. package/dist/wbi.d.ts +21 -0
  50. package/dist/wbi.js +105 -0
  51. package/package.json +52 -6
package/README.md CHANGED
@@ -1,3 +1,122 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # dsh-shlr-bilitoolkit
2
+
3
+ B 站(哔哩哔哩)接口封装的 **TypeScript** 移植版,作为 [dsh](https://dshmp.com)(DeepSeek Harness)插件提供「视频 / 搜索 / 空间」等读取能力。
4
+
5
+ > 本项目从原 Python 工具箱(`bili/`)完整移植而来,覆盖视频、搜索、空间、评论、私信、互动(点赞/收藏/投币/关注/三连)、动态、专栏、关注关系、消息中心、个人资料与扫码登录,并作为 [dsh](https://dshmp.com)(DeepSeek Harness)插件提供上述能力。
6
+
7
+ ## 目录结构
8
+
9
+ ```
10
+ dsh-shlr-bilitoolkit/
11
+ ├── src/
12
+ │ ├── client.ts # BiliClient:Session / Cookie / 请求 / 节流 / 重试 / 风控 / 表单编码
13
+ │ ├── wbi.ts # WbiSigner:w_rid / wts 签名(含官方 mixin_key 算法)
14
+ │ ├── errors.ts # BiliApiError / CaptchaRequired / CookieError
15
+ │ ├── models.ts # Video / Article / Comment / PrivateMessage / RelationUser /
16
+ │ │ # Dynamic / Notice / User / Page / PlayInfo / Subtitle / Collection
17
+ │ ├── formatting.ts # 播放量、时长、HTML 清理等展示格式化
18
+ │ ├── pagination.ts # iterPages:统一翻页异步生成器
19
+ │ ├── endpoints.ts # 所有接口 URL 常量
20
+ │ ├── apis/
21
+ │ │ ├── video.ts # 推荐流 / 详情 / 分P / 播放地址 / 字幕
22
+ │ │ ├── search.ts # 搜索视频 / 用户
23
+ │ │ ├── space.ts # 空间信息 / 投稿 / 合集
24
+ │ │ ├── comment.ts # 评论:视频/动态/专栏 的读取与发表/回复/删除
25
+ │ │ ├── message.ts # 私信:发送 / 撤回 / 记录 / 未读
26
+ │ │ ├── interact.ts # 点赞 / 收藏 / 投币 / 关注 / 三连
27
+ │ │ ├── dynamic.ts # 动态:关注流 / 空间 / 发布 / 转发 / 点赞 / 删除
28
+ │ │ ├── article.ts # 专栏:详情 / UP 主列表
29
+ │ │ ├── relation.ts # 关注 / 粉丝 / 统计
30
+ │ │ ├── notify.ts # 消息中心:未读 / 回复我的 / @我的 / 收到的赞
31
+ │ │ ├── profile.ts # 个人资料:读取 / 改签名 / 改昵称性别生日
32
+ │ │ ├── auth.ts # 扫码登录(generate / poll / wait / login)
33
+ │ │ └── download.ts # 下载与 ffmpeg 合并工具
34
+ │ ├── dsh.ts # dsh 插件入口:apply(ctx) + 工具列表
35
+ │ └── index.ts # 对外导出
36
+ ├── tests/ # 离线单测(wbi 签名、格式化、模型)
37
+ ├── cordis.patch.yml # dsh bundle 的配置补丁
38
+ ├── dsh-manifest.json # 市场目录收录清单(schema v1)
39
+ ├── package.json # 含 dsh.bundle.patch 声明
40
+ └── tsconfig.json
41
+ ```
42
+
43
+ ## 构建与测试
44
+
45
+ ```bash
46
+ npm install
47
+ npm run typecheck # tsc --noEmit
48
+ npm test # 离线单测(node --test + tsx)
49
+ npm run build # 产出 dist/
50
+ ```
51
+
52
+ > 说明:`tsconfig` 使用 `moduleResolution: Bundler`,相对导入不带扩展名,适合被 dsh 运行时(基于打包器的 cordis 应用)消费。若要在原生 Node ESM 下直接 `node dist/index.js`,需改用 `NodeNext` 并补 `.js` 扩展名。
53
+
54
+ ## 当作库用
55
+
56
+ ```ts
57
+ import { BiliClient } from "dsh-shlr-bilitoolkit";
58
+
59
+ const client = new BiliClient({ cookieFile: "bili_cookies.json" });
60
+ await client.loadCookies();
61
+
62
+ const videos = await client.video.recommend(2, 10);
63
+ const detail = await client.video.detail("BV1xx411c7mD");
64
+ const text = await client.video.subtitleText("BV1xx411c7mD"); // 字幕纯文本
65
+ const [videos2, total] = await client.search.videos("孤独摇滚");
66
+ const info = await client.space.info(574115689);
67
+ const [ups, _] = await client.space.videos(574115689, 1, 10);
68
+ ```
69
+
70
+ ## 作为 dsh 插件
71
+
72
+ - 包名 `dsh-shlr-bilitoolkit`(符合 `dsh-` 前缀约定),`package.json` 里声明了 `dsh.bundle.patch: ./cordis.patch.yml`,因此会被 dsh 识别为插件而非普通库。
73
+ - 入口 `src/dsh.ts` 导出 `apply(ctx)`,注册一组工具(读取为主,写操作明确标注「需登录 / 谨慎」):
74
+ - 读取:`bili_recommend` / `bili_video_detail` / `bili_video_subtitle` / `bili_search_video` / `bili_search_user` / `bili_space_info` / `bili_space_videos` / `bili_article_view` / `bili_article_list` / `bili_dynamic_feed` / `bili_dynamic_space` / `bili_comment_list` / `bili_relation_followings` / `bili_relation_followers` / `bili_notify_unread` / `bili_notify_replies` / `bili_fav_folders`
75
+ - 写操作:`bili_like` / `bili_follow` / `bili_favorite` / `bili_send_comment` / `bili_send_message` / `bili_triple`(均会真实修改账号状态,需登录)
76
+ - 辅助:`bili_login_status`(查登录态)/ `bili_gen_qr`(生成扫码登录二维码)/ `bili_cache_clear`(清空本地缓存)
77
+ - 工具读取 `BILI_COOKIE_FILE`(默认 `bili_cookies.json`)作为登录态。
78
+ - **扫码登录(独立获取 Cookie)**:运行 `npm run login`,终端会打印 ASCII 二维码,用「哔哩哔哩」App 扫码并确认后,Cookie 自动写入 `bili_cookies.json`。也可在对话里先调 `bili_login_status` 查登录态、调 `bili_gen_qr` 拿二维码链接与可直接展示的 `ascii` 二维码。写操作(点赞 / 收藏 / 投币 / 关注 / 评论 / 私信 / 三连)都依赖该 Cookie 中的 `SESSDATA` / `bili_jct`。
79
+ - **缓存持久化**:除 Cookie 外,客户端还会把 WBI 签名密钥(`B 站每天轮换`)、`bvid→aid` 映射这类稳定 / 带 TTL 的数据写入本地 JSON 缓存(`BILI_CACHE_FILE`,默认 `bili_cache.json`),用临时文件 + 原子替换落盘、文件损坏会自动自愈。冷启动或跨进程时直接复用,避免每次都请求 `nav` 接口;密钥过期或失效时 `refresh()` 会重新拉取并覆盖缓存。若签名反复报 `-403/-352`,可调用 `bili_cache_clear` 强制清空缓存后重试(不影响 Cookie / 登录态)。
80
+
81
+ 安装到 dsh(任选其一,取决于你的发行方式):
82
+
83
+ ```bash
84
+ dsh plugin --profile <name> add dsh-shlr-bilitoolkit # 已发布到 npm
85
+ dsh plugin --profile <name> add github:your-name/dsh-shlr-bilitoolkit#<ref>
86
+ ```
87
+
88
+ > 插件入口已对齐 **官方 DeepSeek Harness(cordis 体系)** 的约定:模块导出 `name` / `inject = ["tools"]` / `apply(ctx)`,并在 `apply` 内用官方 `ctx.tools.register(defineTool({ name, description, parameters, output, execute }))` 注册每个工具(`defineTool` 来自 `@deepseek-ai/dsh-tools`,由宿主提供、插件不打包,保证单实例)。`parameters` 用官方简化 schema(`{ 参数名: { type, required, description, enum? } }`),工具返回规范 JSON 值,由 `output.render` 转成面向模型的文本。`cordis.patch.yml` 是官方组合包配置层(`- insert: - id: bili name: dsh-shlr-bilitoolkit`,按包名解析到 `main` 入口)。若你的 dsh 版本较旧、没有 `ctx.tools`,`apply` 仍保留了 `ctx.registerTool` / `ctx.services.tools.register` 的回退路径。
89
+
90
+ ## 与原 Python 版的对应关系
91
+
92
+ | Python | TS |
93
+ | --- | --- |
94
+ | `bili/client.py` | `src/client.ts` |
95
+ | `bili/wbi.py` | `src/wbi.ts` |
96
+ | `bili/video.py` | `src/apis/video.ts` |
97
+ | `bili/search.py` | `src/apis/search.ts` |
98
+ | `bili/space.py` | `src/apis/space.ts` |
99
+ | `bili/comment.py` | `src/apis/comment.ts` |
100
+ | `bili/message.py` | `src/apis/message.ts` |
101
+ | `bili/interact.py` | `src/apis/interact.ts` |
102
+ | `bili/dynamic.py` | `src/apis/dynamic.ts` |
103
+ | `bili/article.py` | `src/apis/article.ts` |
104
+ | `bili/relation.py` | `src/apis/relation.ts` |
105
+ | `bili/notify.py` | `src/apis/notify.ts` |
106
+ | `bili/profile.py` | `src/apis/profile.ts` |
107
+ | `bili/auth.py` | `src/apis/auth.ts` |
108
+ | `bili/download.py` | `src/apis/download.ts` |
109
+ | `bili/models.py` | `src/models.ts` |
110
+ | `bili/formatting.py` | `src/formatting.ts` |
111
+ | `bili/pagination.py` | `src/pagination.ts` |
112
+
113
+ 约定保持一致:`client.xxx.yyy()` 调用风格、`iter_*` 异步生成器、`toDict()` 导出、WBI 签名算法与密钥 6 小时缓存(已落地到 `bili_cache.json`,跨进程复用)。
114
+
115
+ ## 与原 Python 版的差异 / 已知限制
116
+
117
+ - **写接口为表单编码**:原 Python 用 `requests` 的 `data=` 默认表单;本包的 `client.post` 默认把对象编码为 `application/x-www-form-urlencoded`,仅动态点赞等少数接口显式走 JSON(`jsonBody=true`)。
118
+ - **下载 / ffmpeg**:`src/apis/download.ts` 提供 `downloadUrl` / `mergeWithFfmpeg`,为纯工具函数,未接入 dsh 工具(下载行为在 dsh 里通常不合适)。
119
+
120
+ ## 风控说明
121
+
122
+ 本包内置与原 Python 版一致的基础降风控策略:请求节流(默认 0.6s + 0~0.4s 抖动)、GET 退避重试、写操作不重试、HTTP 412 / `-412` 立即停手、WBI 签名。但单账号操作量上限、避免短时间切 IP/UA 等仍需使用习惯配合。
@@ -0,0 +1,13 @@
1
+ # dsh 组合包的配置层:当某个 profile 把本包列为 bundle 时,这一层会被叠加应用。
2
+ #
3
+ # 按官方约定,插件行用「包名」引用本包(name: dsh-shlr-bilitoolkit),
4
+ # Node 据此解析到 main(dist/dsh.js)。该入口导出 name / inject / apply,
5
+ # 由 Cordis 加载并调用 apply(ctx),apply 内部再用 ctx.tools.register(defineTool(...))
6
+ # 注册每个工具。
7
+ #
8
+ # 字段说明:
9
+ # id - 该插件行在本配置层中的唯一标识
10
+ # name - 包名(不是相对源码路径),由 Node 模块解析定位到 main
11
+ - insert:
12
+ - id: bili
13
+ name: dsh-shlr-bilitoolkit
@@ -0,0 +1,12 @@
1
+ /** 专栏:详情、UP 主专栏列表。 */
2
+ import type { BiliClient } from "../client";
3
+ import { Article } from "../models";
4
+ export declare function parseArticleId(value: unknown): number;
5
+ export declare class ArticleAPI {
6
+ private client;
7
+ constructor(client: BiliClient);
8
+ private static headers;
9
+ view(articleId: unknown): Promise<Article>;
10
+ listByUser(mid: number, page?: number, pageSize?: number, sort?: string): Promise<[Article[], number]>;
11
+ iterUser(mid: number, pageSize?: number, sort?: string, maxPages?: number): AsyncGenerator<Article, any, any>;
12
+ }
@@ -0,0 +1,49 @@
1
+ /** 专栏:详情、UP 主专栏列表。 */
2
+ import * as endpoints from "../endpoints";
3
+ import { BiliApiError } from "../errors";
4
+ import { Article } from "../models";
5
+ import { iterPages } from "../pagination";
6
+ const _ARTICLE_ID = /cv(\d+)/i;
7
+ const _NUMBER = /(\d+)/;
8
+ export function parseArticleId(value) {
9
+ if (typeof value === "number")
10
+ return value;
11
+ const text = String(value ?? "").trim();
12
+ const match = _ARTICLE_ID.exec(text) ?? _NUMBER.exec(text);
13
+ if (!match)
14
+ throw new Error(`无法识别专栏 id: ${value}`);
15
+ return Number(match[1]);
16
+ }
17
+ export class ArticleAPI {
18
+ client;
19
+ constructor(client) {
20
+ this.client = client;
21
+ }
22
+ static headers(cvid) {
23
+ return { Referer: `https://www.bilibili.com/read/cv${cvid}` };
24
+ }
25
+ async view(articleId) {
26
+ const cvid = parseArticleId(articleId);
27
+ const payload = await this.client.get(endpoints.ARTICLE_VIEW, { id: cvid }, ArticleAPI.headers(cvid));
28
+ const article = Article.fromApi(payload?.data);
29
+ if (!article.id)
30
+ throw new BiliApiError("未找到该专栏");
31
+ return article;
32
+ }
33
+ async listByUser(mid, page = 1, pageSize = 30, sort = "publish_time") {
34
+ const payload = await this.client.getSigned(endpoints.SPACE_ARTICLE, { mid: Number(mid), pn: page, ps: pageSize, sort }, { Referer: `https://space.bilibili.com/${mid}/article` });
35
+ const data = payload?.data ?? {};
36
+ const articles = [];
37
+ for (const item of data.articles ?? []) {
38
+ const article = Article.fromApi(item);
39
+ if (article.id)
40
+ articles.push(article);
41
+ }
42
+ const total = Number(data.count ?? articles.length);
43
+ return [articles, total];
44
+ }
45
+ iterUser(mid, pageSize = 30, sort = "publish_time", maxPages) {
46
+ const fetch = (page) => this.listByUser(mid, page, pageSize, sort);
47
+ return iterPages(fetch, maxPages);
48
+ }
49
+ }
@@ -0,0 +1,20 @@
1
+ /** 扫码登录:生成二维码、轮询状态。
2
+
3
+ 终端 ASCII 二维码渲染依赖 qrcode 库,dsh 环境通常不需要,故只保留
4
+ generate / poll / wait / login 的核心流程;如需在终端展示二维码,可另行
5
+ 调用 qrcode 库对 generate 返回的 url 进行渲染。
6
+ */
7
+ import type { BiliClient } from "../client";
8
+ export declare const QR_STATUS_SUCCESS = 0;
9
+ export declare const QR_STATUS_EXPIRED = 86038;
10
+ export declare const QR_STATUS_WAITING = 86101;
11
+ export declare const QR_STATUS_SCANNED = 86090;
12
+ export declare class QrCodeLogin {
13
+ private client;
14
+ private pollInterval;
15
+ constructor(client: BiliClient, pollInterval?: number);
16
+ generate(): Promise<[string, string]>;
17
+ poll(qrcodeKey: string): Promise<[number, string]>;
18
+ wait(qrcodeKey: string): Promise<boolean>;
19
+ login(attempts?: number): Promise<boolean>;
20
+ }
@@ -0,0 +1,83 @@
1
+ /** 扫码登录:生成二维码、轮询状态。
2
+
3
+ 终端 ASCII 二维码渲染依赖 qrcode 库,dsh 环境通常不需要,故只保留
4
+ generate / poll / wait / login 的核心流程;如需在终端展示二维码,可另行
5
+ 调用 qrcode 库对 generate 返回的 url 进行渲染。
6
+ */
7
+ import * as endpoints from "../endpoints";
8
+ import { BiliApiError } from "../errors";
9
+ import { sleep } from "../util";
10
+ export const QR_STATUS_SUCCESS = 0;
11
+ export const QR_STATUS_EXPIRED = 86038;
12
+ export const QR_STATUS_WAITING = 86101;
13
+ export const QR_STATUS_SCANNED = 86090;
14
+ export class QrCodeLogin {
15
+ client;
16
+ pollInterval;
17
+ constructor(client, pollInterval = 2) {
18
+ this.client = client;
19
+ this.pollInterval = pollInterval;
20
+ }
21
+ async generate() {
22
+ const payload = await this.client.get(endpoints.QRCODE_GENERATE);
23
+ const data = payload?.data ?? {};
24
+ const url = data.url;
25
+ const key = data.qrcode_key;
26
+ if (!url || !key)
27
+ throw new BiliApiError("二维码接口返回数据不完整");
28
+ return [url, key];
29
+ }
30
+ async poll(qrcodeKey) {
31
+ const payload = await this.client.get(endpoints.QRCODE_POLL, {
32
+ qrcode_key: qrcodeKey,
33
+ });
34
+ const data = payload?.data ?? {};
35
+ const code = data.code;
36
+ return [
37
+ code !== null && code !== undefined ? Number(code) : -1,
38
+ String(data.message ?? ""),
39
+ ];
40
+ }
41
+ async wait(qrcodeKey) {
42
+ let lastCode = null;
43
+ while (true) {
44
+ const [code, message] = await this.poll(qrcodeKey);
45
+ if (code === QR_STATUS_SUCCESS)
46
+ return true;
47
+ if (code === QR_STATUS_EXPIRED)
48
+ return false;
49
+ if (code !== lastCode) {
50
+ if (code === QR_STATUS_SCANNED) {
51
+ // 已扫码,等待手机确认
52
+ }
53
+ else if (code === QR_STATUS_WAITING) {
54
+ // 等待扫码
55
+ }
56
+ else {
57
+ return false;
58
+ }
59
+ lastCode = code;
60
+ }
61
+ await sleep(this.pollInterval * 1000);
62
+ }
63
+ }
64
+ async login(attempts = 3) {
65
+ for (let attempt = 1; attempt <= attempts; attempt++) {
66
+ let url;
67
+ let key;
68
+ try {
69
+ [url, key] = await this.generate();
70
+ }
71
+ catch (exc) {
72
+ if (exc instanceof BiliApiError)
73
+ return false;
74
+ throw exc;
75
+ }
76
+ if (await this.wait(key)) {
77
+ await this.client.saveCookies();
78
+ return true;
79
+ }
80
+ }
81
+ return false;
82
+ }
83
+ }
@@ -0,0 +1,37 @@
1
+ /** 评论相关接口:视频 / 动态 / 专栏的评论,发表 / 回复 / 删除。
2
+
3
+ B 站评论是「type + oid」的通用模型:
4
+ - 视频:type=1,oid=aid
5
+ - 动态:type 由动态的 comment_type 决定,oid 取 comment_id
6
+ - 专栏:type=12,oid=cvid
7
+ */
8
+ import type { BiliClient } from "../client";
9
+ import { Comment } from "../models";
10
+ export declare class CommentAPI {
11
+ private client;
12
+ constructor(client: BiliClient);
13
+ private static sortValue;
14
+ private static videoHeaders;
15
+ private static dynamicHeaders;
16
+ private static articleHeaders;
17
+ private dynamicTarget;
18
+ private list;
19
+ private send;
20
+ private replies;
21
+ private delete;
22
+ listVideo(bvid: string, page?: number, pageSize?: number, sort?: string | number): Promise<[Comment[], number]>;
23
+ iterVideo(bvid: string, pageSize?: number, sort?: string | number, maxPages?: number): AsyncGenerator<Comment, any, any>;
24
+ sendVideo(bvid: string, message: string, root?: number | null, parent?: number | null): Promise<Comment>;
25
+ replyVideo(bvid: string, rootRpid: number, message: string, parentRpid?: number | null): Promise<Comment>;
26
+ repliesVideo(bvid: string, rootRpid: number, page?: number, pageSize?: number): Promise<[Comment[], number]>;
27
+ deleteVideo(bvid: string, rpid: number): Promise<void>;
28
+ listDynamic(dynamicId: string | number, page?: number, pageSize?: number, sort?: string | number): Promise<[Comment[], number]>;
29
+ iterDynamic(dynamicId: string | number, pageSize?: number, sort?: string | number, maxPages?: number): AsyncGenerator<Comment, any, any>;
30
+ sendDynamic(dynamicId: string | number, message: string, root?: number | null, parent?: number | null): Promise<Comment>;
31
+ repliesDynamic(dynamicId: string | number, rootRpid: number, page?: number, pageSize?: number): Promise<[Comment[], number]>;
32
+ deleteDynamic(dynamicId: string | number, rpid: number): Promise<void>;
33
+ listArticle(articleId: unknown, page?: number, pageSize?: number, sort?: string | number): Promise<[Comment[], number]>;
34
+ sendArticle(articleId: unknown, message: string, root?: number | null, parent?: number | null): Promise<Comment>;
35
+ repliesArticle(articleId: unknown, rootRpid: number, page?: number, pageSize?: number): Promise<[Comment[], number]>;
36
+ deleteArticle(articleId: unknown, rpid: number): Promise<void>;
37
+ }
@@ -0,0 +1,164 @@
1
+ /** 评论相关接口:视频 / 动态 / 专栏的评论,发表 / 回复 / 删除。
2
+
3
+ B 站评论是「type + oid」的通用模型:
4
+ - 视频:type=1,oid=aid
5
+ - 动态:type 由动态的 comment_type 决定,oid 取 comment_id
6
+ - 专栏:type=12,oid=cvid
7
+ */
8
+ import * as endpoints from "../endpoints";
9
+ import { BiliApiError } from "../errors";
10
+ import { Comment } from "../models";
11
+ import { iterPages } from "../pagination";
12
+ import { parseArticleId } from "./article";
13
+ const SORT_BY_NAME = { time: 0, like: 1, reply: 2 };
14
+ export class CommentAPI {
15
+ client;
16
+ constructor(client) {
17
+ this.client = client;
18
+ }
19
+ static sortValue(sort) {
20
+ if (typeof sort === "string") {
21
+ if (!(sort in SORT_BY_NAME)) {
22
+ throw new Error(`sort 只支持 ${Object.keys(SORT_BY_NAME)} 或 0/1/2`);
23
+ }
24
+ return SORT_BY_NAME[sort];
25
+ }
26
+ return Number(sort);
27
+ }
28
+ static videoHeaders(bvid) {
29
+ return { Referer: `https://www.bilibili.com/video/${bvid}/` };
30
+ }
31
+ static dynamicHeaders(dynamicId) {
32
+ return { Referer: `https://t.bilibili.com/${dynamicId}` };
33
+ }
34
+ static articleHeaders(cvid) {
35
+ return { Referer: `https://www.bilibili.com/read/cv${cvid}` };
36
+ }
37
+ async dynamicTarget(dynamicId) {
38
+ const dynamic = await this.client.dynamic.detail(dynamicId);
39
+ if (!dynamic.commentType || !dynamic.commentId) {
40
+ throw new BiliApiError("该动态没有可用的评论信息");
41
+ }
42
+ return [dynamic.commentType, dynamic.commentId];
43
+ }
44
+ async list(oid, type, page, pageSize, sort, headers) {
45
+ const payload = await this.client.get(endpoints.REPLY_LIST, {
46
+ type,
47
+ oid,
48
+ pn: page,
49
+ ps: pageSize,
50
+ sort: CommentAPI.sortValue(sort),
51
+ }, headers);
52
+ const data = payload?.data ?? {};
53
+ const comments = (data.replies ?? [])
54
+ .map((r) => Comment.fromApi(r))
55
+ .filter((c) => c !== null);
56
+ const total = (data.page ?? {}).count ?? comments.length;
57
+ return [comments, total];
58
+ }
59
+ async send(oid, type, message, root, parent, headers) {
60
+ message = (message || "").trim();
61
+ if (!message)
62
+ throw new Error("评论内容不能为空");
63
+ const form = {
64
+ type,
65
+ oid,
66
+ message,
67
+ csrf: this.client.csrf,
68
+ plat: 1,
69
+ };
70
+ if (root)
71
+ form["root"] = root;
72
+ if (parent)
73
+ form["parent"] = parent;
74
+ const payload = await this.client.post(endpoints.REPLY_ADD, form, headers);
75
+ const data = payload?.data ?? {};
76
+ if (!data.rpid) {
77
+ throw new BiliApiError("评论接口未返回 rpid,可能被风控拦截");
78
+ }
79
+ return Comment.fromApi(data);
80
+ }
81
+ async replies(oid, type, rootRpid, page, pageSize, headers) {
82
+ const payload = await this.client.get(endpoints.REPLY_REPLY, { type, oid, root: Number(rootRpid), pn: page, ps: pageSize }, headers);
83
+ const data = payload?.data ?? {};
84
+ const comments = (data.replies ?? [])
85
+ .map((i) => Comment.fromApi(i))
86
+ .filter((c) => c !== null);
87
+ const total = (data.page ?? {}).count ?? comments.length;
88
+ return [comments, total];
89
+ }
90
+ async delete(oid, type, rpid, headers) {
91
+ await this.client.post(endpoints.REPLY_DEL, { type, oid, rpid, csrf: this.client.csrf }, headers);
92
+ }
93
+ // ------------------------------------------------------------------ 视频
94
+ async listVideo(bvid, page = 1, pageSize = 20, sort = "time") {
95
+ CommentAPI.sortValue(sort); // 先校验,避免参数错误时还去请求 aid
96
+ const aid = await this.client.video.resolveAid(bvid);
97
+ return this.list(aid, 1, page, pageSize, sort, CommentAPI.videoHeaders(bvid));
98
+ }
99
+ iterVideo(bvid, pageSize = 20, sort = "time", maxPages) {
100
+ const fetch = (page) => this.listVideo(bvid, page, pageSize, sort);
101
+ return iterPages(fetch, maxPages);
102
+ }
103
+ async sendVideo(bvid, message, root, parent) {
104
+ if (!(message || "").trim())
105
+ throw new Error("评论内容不能为空");
106
+ const aid = await this.client.video.resolveAid(bvid);
107
+ return this.send(aid, 1, message, root, parent, CommentAPI.videoHeaders(bvid));
108
+ }
109
+ async replyVideo(bvid, rootRpid, message, parentRpid) {
110
+ return this.sendVideo(bvid, message, rootRpid, parentRpid ?? rootRpid);
111
+ }
112
+ async repliesVideo(bvid, rootRpid, page = 1, pageSize = 20) {
113
+ const aid = await this.client.video.resolveAid(bvid);
114
+ return this.replies(aid, 1, rootRpid, page, pageSize, CommentAPI.videoHeaders(bvid));
115
+ }
116
+ async deleteVideo(bvid, rpid) {
117
+ const aid = await this.client.video.resolveAid(bvid);
118
+ await this.delete(aid, 1, rpid, CommentAPI.videoHeaders(bvid));
119
+ }
120
+ // ------------------------------------------------------------------ 动态
121
+ async listDynamic(dynamicId, page = 1, pageSize = 20, sort = "time") {
122
+ CommentAPI.sortValue(sort);
123
+ const [commentType, oid] = await this.dynamicTarget(dynamicId);
124
+ return this.list(Number(oid), commentType, page, pageSize, sort, CommentAPI.dynamicHeaders(dynamicId));
125
+ }
126
+ iterDynamic(dynamicId, pageSize = 20, sort = "time", maxPages) {
127
+ const fetch = (page) => this.listDynamic(dynamicId, page, pageSize, sort);
128
+ return iterPages(fetch, maxPages);
129
+ }
130
+ async sendDynamic(dynamicId, message, root, parent) {
131
+ if (!(message || "").trim())
132
+ throw new Error("评论内容不能为空");
133
+ const [commentType, oid] = await this.dynamicTarget(dynamicId);
134
+ return this.send(Number(oid), commentType, message, root, parent, CommentAPI.dynamicHeaders(dynamicId));
135
+ }
136
+ async repliesDynamic(dynamicId, rootRpid, page = 1, pageSize = 20) {
137
+ const [commentType, oid] = await this.dynamicTarget(dynamicId);
138
+ return this.replies(Number(oid), commentType, rootRpid, page, pageSize, CommentAPI.dynamicHeaders(dynamicId));
139
+ }
140
+ async deleteDynamic(dynamicId, rpid) {
141
+ const [commentType, oid] = await this.dynamicTarget(dynamicId);
142
+ await this.delete(Number(oid), commentType, rpid, CommentAPI.dynamicHeaders(dynamicId));
143
+ }
144
+ // ------------------------------------------------------------------ 专栏
145
+ async listArticle(articleId, page = 1, pageSize = 20, sort = "time") {
146
+ CommentAPI.sortValue(sort);
147
+ const cvid = parseArticleId(articleId);
148
+ return this.list(cvid, 12, page, pageSize, sort, CommentAPI.articleHeaders(cvid));
149
+ }
150
+ async sendArticle(articleId, message, root, parent) {
151
+ if (!(message || "").trim())
152
+ throw new Error("评论内容不能为空");
153
+ const cvid = parseArticleId(articleId);
154
+ return this.send(cvid, 12, message, root, parent, CommentAPI.articleHeaders(cvid));
155
+ }
156
+ async repliesArticle(articleId, rootRpid, page = 1, pageSize = 20) {
157
+ const cvid = parseArticleId(articleId);
158
+ return this.replies(cvid, 12, rootRpid, page, pageSize, CommentAPI.articleHeaders(cvid));
159
+ }
160
+ async deleteArticle(articleId, rpid) {
161
+ const cvid = parseArticleId(articleId);
162
+ await this.delete(cvid, 12, rpid, CommentAPI.articleHeaders(cvid));
163
+ }
164
+ }
@@ -0,0 +1,8 @@
1
+ /** 播放流下载与 ffmpeg 合并。
2
+
3
+ DASH 流的视频和音频是分开的,下载后需要合并;没有 ffmpeg 时保留两个文件。
4
+ 这些是不依赖 BiliClient 的纯工具函数,可在 Node 环境直接使用。
5
+ */
6
+ export declare function safeFilename(text: string, fallback?: string): string;
7
+ export declare function downloadUrl(url: string, destPath: string, referer?: string): Promise<void>;
8
+ export declare function mergeWithFfmpeg(videoPath: string, audioPath: string, outputPath: string): Promise<void>;
@@ -0,0 +1,60 @@
1
+ /** 播放流下载与 ffmpeg 合并。
2
+
3
+ DASH 流的视频和音频是分开的,下载后需要合并;没有 ffmpeg 时保留两个文件。
4
+ 这些是不依赖 BiliClient 的纯工具函数,可在 Node 环境直接使用。
5
+ */
6
+ import { createWriteStream } from "node:fs";
7
+ import { mkdir } from "node:fs/promises";
8
+ import * as path from "node:path";
9
+ import { spawn } from "node:child_process";
10
+ import axios from "axios";
11
+ import { BiliApiError } from "../errors";
12
+ const CHUNK_SIZE = 1 << 20;
13
+ const UNSAFE_FILENAME = /[\\/:*?"<>|]/;
14
+ export function safeFilename(text, fallback = "video") {
15
+ const cleaned = (text || "")
16
+ .trim()
17
+ .replace(UNSAFE_FILENAME, "_")
18
+ .replace(/^[. ]+|[. ]+$/g, "");
19
+ return cleaned || fallback;
20
+ }
21
+ export async function downloadUrl(url, destPath, referer) {
22
+ await mkdir(path.dirname(destPath), { recursive: true });
23
+ const headers = {};
24
+ if (referer)
25
+ headers["Referer"] = referer;
26
+ const response = await axios.get(url, {
27
+ headers,
28
+ responseType: "stream",
29
+ timeout: 30_000,
30
+ });
31
+ await new Promise((resolve, reject) => {
32
+ const writer = createWriteStream(destPath);
33
+ response.data.pipe(writer);
34
+ writer.on("finish", resolve);
35
+ writer.on("error", reject);
36
+ response.data.on("error", reject);
37
+ });
38
+ }
39
+ export async function mergeWithFfmpeg(videoPath, audioPath, outputPath) {
40
+ const ffmpeg = process.env.FFMPEG_PATH || "ffmpeg";
41
+ await new Promise((resolve, reject) => {
42
+ const proc = spawn(ffmpeg, [
43
+ "-y",
44
+ "-i",
45
+ videoPath,
46
+ "-i",
47
+ audioPath,
48
+ "-c",
49
+ "copy",
50
+ outputPath,
51
+ ]);
52
+ proc.on("error", reject);
53
+ proc.on("close", (code) => {
54
+ if (code === 0)
55
+ resolve();
56
+ else
57
+ reject(new BiliApiError(`ffmpeg 合并失败(退出码 ${code})`));
58
+ });
59
+ });
60
+ }
@@ -0,0 +1,23 @@
1
+ /** 动态:关注动态流、用户空间动态,以及发布 / 转发 / 点赞 / 删除。
2
+
3
+ 读取走网页版 polymer 接口,需要登录态。写操作(publish / repost / thumb /
4
+ delete)都是公开行为,受风控和人机验证限制,使用前请自行确认。
5
+ */
6
+ import type { BiliClient } from "../client";
7
+ import { Dynamic } from "../models";
8
+ export declare class DynamicAPI {
9
+ private client;
10
+ constructor(client: BiliClient);
11
+ private _detailCache;
12
+ detail(dynamicId: string | number): Promise<Dynamic>;
13
+ feed(page?: number, offset?: string, type?: string): Promise<[Dynamic[], string, boolean]>;
14
+ space(uid: number, offset?: string): Promise<[Dynamic[], string, boolean, number]>;
15
+ iterFeed(maxPages?: number, type?: string): AsyncGenerator<Dynamic>;
16
+ iterSpace(uid: number, maxPages?: number): AsyncGenerator<Dynamic>;
17
+ publish(text: string, atUids?: number[], ctrl?: any[], closeComment?: boolean): Promise<string>;
18
+ repost(dynamicId: string | number, text?: string): Promise<string>;
19
+ thumb(dynamicId: string | number, liked?: boolean): Promise<void>;
20
+ delete(dynamicId: string | number): Promise<void>;
21
+ private static dynamicId;
22
+ private static parseItems;
23
+ }