@zhin.js/plugin-bilibili 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.
package/README.md CHANGED
@@ -1,3 +1,137 @@
1
- # Temporary Holding Version
1
+ # @zhin.js/plugin-bilibili
2
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.
3
+ 订阅 B 站(哔哩哔哩)UP 主的**开播提醒**和**新动态**,推送到订阅所在的私聊、群聊或频道。与平台无关:任何接入了 OutboundHost 的适配器都可以用。
4
+
5
+ Subscribe to Bilibili live starts and UP dynamics per conversation. Platform-agnostic: works with any adapter that exposes the OutboundHost. English below.
6
+
7
+ ## 安装
8
+
9
+ ```bash
10
+ pnpm exec zhin install @zhin.js/plugin-bilibili
11
+ ```
12
+
13
+ 对应的 `zhin.config.yml`:
14
+
15
+ ```yaml
16
+ configVersion: 2
17
+ plugins:
18
+ bilibili: "@zhin.js/plugin-bilibili"
19
+ bilibili:
20
+ pollCron: "0 * * * * *" # 每 60 秒一轮
21
+ jitterMs: 15000 # 每轮随机延迟 0–15 秒
22
+ # cookie: ${BILIBILI_COOKIE} # 可选,风控严格时再配
23
+ ```
24
+
25
+ 推送需要 Runtime 提供 Schedule(定时任务)和 Outbound(主动发消息);订阅持久化需要配置数据库,没有数据库时订阅只保存在内存里,重启即丢失。
26
+
27
+ ## 配置
28
+
29
+ | 字段 | 默认值 | 说明 |
30
+ | --- | --- | --- |
31
+ | `pollCron` | `0 * * * * *` | 轮询频率(6 段 Cron),默认每 60 秒 |
32
+ | `jitterMs` | `15000` | 每轮开始前的随机延迟上限,避免整点并发 |
33
+ | `requestGapMs` | `1500` | 同一轮里相邻两个 UP 主的动态请求间隔 |
34
+ | `maxBackoffMinutes` | `30` | 失败后指数退避(1、2、4… 分钟)的上限;风控响应退避更久 |
35
+ | `maxPerChannel` | `20` | 每个会话最多订阅多少个 UP 主 |
36
+ | `timeout` | `10000` | 单次 HTTP 请求超时(毫秒) |
37
+ | `live` / `dynamic` | `true` | 分别开关开播轮询和动态轮询 |
38
+ | `cookie` | `""` | 可选 Cookie(如 `SESSDATA=...`)。写成 `${BILIBILI_COOKIE}`,把值放进 `.env`,不要提交到仓库 |
39
+
40
+ ## 命令
41
+
42
+ | 命令 | 说明 |
43
+ | --- | --- |
44
+ | `bili sub <uid> [all\|live\|dynamic]` | 订阅 UP 主,`uid` 也可以是空间链接;类型默认 `all`(开播 + 动态),也可写 `直播` / `动态`。对已订阅的 UP 主再执行一次可修改类型 |
45
+ | `bili unsub <uid>` | 取消当前会话的订阅 |
46
+ | `bili list` | 查看当前会话的订阅,以及正在退避的请求 |
47
+ | `bili live <房间号>` | 查询直播间状态,支持 `https://live.bilibili.com/...` 链接 |
48
+
49
+ 订阅按「适配器 + 机器人账号 + 会话」隔离,不同群互不影响。
50
+
51
+ ### 权限
52
+
53
+ - 群聊和频道里,`bili sub` / `bili unsub` 只有**群主、群管理员或机器人主人**(`role(owner,admin,master)`)能用,其他成员会收到提示,订阅不会改动。
54
+ - 私聊里任何人都可以订阅、取消自己这个私聊会话。
55
+ - `bili list`、`bili live` 是只读命令,不限制。
56
+
57
+ 角色来自适配器给出的平台无关身份 `sender.role`(如 icqq / OneBot 的 `owner`、`admin`,以及主人账号的 `master`),判断用的是命令路由同一套内置 permit 检查。适配器拿不到群角色时,群里只有机器人主人能管理订阅。
58
+
59
+ ## AI 工具
60
+
61
+ 技能 `bilibili` 提供两个只读工具:
62
+
63
+ - `bili_live_status`:按直播间号或 UID 查询是否在播。
64
+ - `bili_list_subscriptions`:列出全部订阅。
65
+
66
+ 订阅的增删只走聊天命令。
67
+
68
+ ## 工作原理
69
+
70
+ - **开播**:所有被订阅的 UP 主合并成一次批量请求(`get_status_info_by_uids`,无需登录)。从「未开播 / 轮播」变为「直播中」,或者开播时间变化(说明是新的一场)时推送一次。
71
+ - **动态**:逐个请求 UP 主空间动态(`x/polymer/web-dynamic/v1/feed/space`,带 WBI 签名和匿名 `buvid3`),按发布时间和已推送 ID 双重去重;置顶的旧动态不会推送,单个 UP 主每轮最多推 3 条。直播推荐类动态交给开播提醒处理。
72
+ - **首次观察只记基线**:新订阅或重启后的第一轮不推送历史内容。
73
+ - **降级**:接口返回格式变了、风控(`-352`、`-412`、`-799`、HTTP 412)或网络错误时,不会让定时任务失败,只在日志里提示并对该来源指数退避,`bili list` 里能看到恢复时间。风控时会丢弃缓存的 WBI 密钥和设备 ID,下次重新获取。
74
+
75
+ ## 数据库
76
+
77
+ | 表 | 用途 |
78
+ | --- | --- |
79
+ | `bilibili_subscriptions` | 订阅:`uid`、`uname`、`kinds`、会话地址、创建人 |
80
+ | `bilibili_watch_state` | 每个 UP 主的开播状态、动态游标、最近推送过的动态 ID |
81
+
82
+ 取消最后一个订阅时会清掉对应 UP 主的游标。
83
+
84
+ ## 注意事项
85
+
86
+ - 这些都是 B 站网页端的非公开接口,随时可能变化。动态接口对匿名请求和机房 IP 风控很严:可能返回 412 / -352,也可能悄悄返回空列表。插件把空列表当成「本轮没拿到」,不会据此建立基线或推送旧内容;超过 6 小时的动态只记为已读、不推送。长期拿不到动态时,可以配置登录后的 `cookie`,或只开 `live`。
87
+ - 请保持保守的轮询频率,不要把 `pollCron` 调到几秒一次。
88
+
89
+ ---
90
+
91
+ ## English
92
+
93
+ ### Install
94
+
95
+ ```bash
96
+ pnpm exec zhin install @zhin.js/plugin-bilibili
97
+ ```
98
+
99
+ ```yaml
100
+ configVersion: 2
101
+ plugins:
102
+ bilibili: "@zhin.js/plugin-bilibili"
103
+ bilibili:
104
+ pollCron: "0 * * * * *"
105
+ # cookie: ${BILIBILI_COOKIE}
106
+ ```
107
+
108
+ Pushing needs the Schedule and Outbound hosts; persistence needs a configured database (otherwise subscriptions are kept in memory).
109
+
110
+ ### Commands
111
+
112
+ - `bili sub <uid> [all|live|dynamic]`: subscribe the current conversation (uid or space URL). Running it again changes the kind.
113
+ - `bili unsub <uid>`: unsubscribe.
114
+ - `bili list`: list subscriptions and sources currently backing off.
115
+ - `bili live <room>`: show a live room's status (room id or URL).
116
+
117
+ **Permissions:** in groups and channels, `bili sub` / `bili unsub` are limited to the group owner, group admins and the bot master (`role(owner,admin,master)`); other members get a notice and nothing changes. In private chat anyone can manage their own conversation. `bili list` and `bili live` are read-only and unrestricted. Roles come from the adapter's platform-agnostic `sender.role` and are checked with the same built-in permit checker the command router uses; if an adapter does not report group roles, only the bot master can manage group subscriptions.
118
+
119
+ ### Configuration
120
+
121
+ `pollCron` (default every 60s), `jitterMs` (random delay before each poll), `requestGapMs`, `maxBackoffMinutes`, `maxPerChannel`, `timeout`, `live`, `dynamic`, and an optional `cookie` that should be referenced as `${BILIBILI_COOKIE}` from `.env`.
122
+
123
+ ### How it works
124
+
125
+ Live status is polled in one batched, unauthenticated request; a push happens on the offline→live transition or when a new session start time appears. Dynamics are fetched per UP from the WBI-signed space feed with an anonymous `buvid3` cookie, deduplicated by publish time and pushed IDs; old pinned items are ignored and at most 3 items per UP are pushed per tick. The first observation only records a baseline. Payload changes, risk control (`-352`, `-412`, `-799`, HTTP 412) and network errors never fail the scheduled job: the affected source backs off exponentially and `bili list` shows when it will retry.
126
+
127
+ ### Caveats
128
+
129
+ These are undocumented Bilibili web endpoints and may change. The dynamic feed is aggressively risk-controlled for anonymous and datacenter clients: it may answer 412 / -352 or silently return an empty list. Empty answers are never used as a baseline, and dynamics older than 6 hours are marked seen without being pushed. Configure a logged-in `cookie`, or enable `live` only, if dynamics keep failing.
130
+
131
+ ## AI tools
132
+
133
+ `bili_live_status` and `bili_list_subscriptions` (skill `bilibili`), both read-only.
134
+
135
+ ## License
136
+
137
+ MIT
@@ -0,0 +1,26 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ import { defineCommand } from 'zhin.js/command';
3
+ import { channelSourceError, extractChannelInfo, subscriptionScope } from "../../../lib/channel.js";
4
+ import { bilibiliRuntimeToken } from "../../../lib/runtime.js";
5
+ import { getBiliSubs } from "../../../lib/store.js";
6
+ const KIND_TEXT = { all: '开播+动态', live: '开播', dynamic: '动态' };
7
+ export default defineCommand({
8
+ description: '查看当前会话的 B 站订阅',
9
+ async execute({ input, use }) {
10
+ const runtime = use(bilibiliRuntimeToken);
11
+ const Subs = getBiliSubs(runtime.db);
12
+ if (!Subs)
13
+ return 'B 站订阅数据库尚未就绪,请稍后重试';
14
+ const channel = extractChannelInfo(input);
15
+ if (!channel)
16
+ return channelSourceError(input);
17
+ const rows = await Subs.select().where(subscriptionScope(channel));
18
+ if (rows.length === 0)
19
+ return '当前会话没有订阅任何 UP 主\n使用 bili sub <uid> 添加订阅';
20
+ const lines = rows.map((row, index) => `${index + 1}. ${String(row.uname || '(未知)')}(UID ${String(row.uid)})· ${KIND_TEXT[String(row.kinds)] ?? '开播+动态'}`);
21
+ const status = runtime.watcher.backoffStatus();
22
+ if (status.length > 0)
23
+ lines.push('', '暂停中的请求:', ...status);
24
+ return `当前订阅 (${rows.length}/${runtime.config.maxPerChannel}):\n${lines.join('\n')}`;
25
+ },
26
+ });
@@ -0,0 +1,31 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ import { defineCommand } from 'zhin.js/command';
3
+ import { BiliApiError, parseRoomId } from "../../../lib/api.js";
4
+ import { formatRoom } from "../../../lib/format.js";
5
+ import { bilibiliRuntimeToken } from "../../../lib/runtime.js";
6
+ export default defineCommand({
7
+ description: '查询 B 站直播间状态',
8
+ params: { room: { type: 'string', description: '直播间号或直播间链接' } },
9
+ async execute({ params, use }) {
10
+ const roomId = parseRoomId(String(params.room ?? ''));
11
+ if (!roomId)
12
+ return '请提供直播间号或链接,格式:bili live <房间号>';
13
+ const { client } = use(bilibiliRuntimeToken);
14
+ try {
15
+ const room = await client.getRoomInfo(roomId);
16
+ let uname = '';
17
+ let liveTime = 0;
18
+ if (room.uid) {
19
+ const info = (await client.getLiveStatus([room.uid]).catch(() => new Map())).get(room.uid);
20
+ uname = info?.uname ?? '';
21
+ liveTime = info?.liveTime ?? 0;
22
+ }
23
+ return formatRoom(room, uname, liveTime);
24
+ }
25
+ catch (error) {
26
+ if (error instanceof BiliApiError && error.kind === 'not_found')
27
+ return `直播间 ${roomId} 不存在`;
28
+ return `查询失败:${error.message}`;
29
+ }
30
+ },
31
+ });
@@ -0,0 +1,68 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ import { defineCommand } from 'zhin.js/command';
3
+ import { BiliApiError, parseUid } from "../../../lib/api.js";
4
+ import { channelSourceError, extractChannelInfo, subscriptionScope } from "../../../lib/channel.js";
5
+ import { bilibiliRuntimeToken } from "../../../lib/runtime.js";
6
+ import { canManageSubscription, SUBSCRIPTION_PERMISSION_DENIED_MESSAGE } from "../../../lib/permission.js";
7
+ import { getBiliSubs, parseKinds } from "../../../lib/store.js";
8
+ const KIND_TEXT = { all: '开播 + 动态', live: '开播', dynamic: '动态' };
9
+ export default defineCommand({
10
+ description: '订阅 B 站 UP 主的开播和动态推送(群聊限群主、管理员或机器人主人)',
11
+ params: {
12
+ uid: { type: 'string', description: 'UP 主 UID 或空间链接' },
13
+ kind: { type: 'string', default: 'all', description: 'all(默认)/ live / dynamic' },
14
+ },
15
+ async execute({ params, input, use, adapter, scene, sender }) {
16
+ const runtime = use(bilibiliRuntimeToken);
17
+ const Subs = getBiliSubs(runtime.db);
18
+ if (!Subs)
19
+ return 'B 站订阅数据库尚未就绪,请稍后重试';
20
+ const uid = parseUid(String(params.uid ?? ''));
21
+ if (!uid)
22
+ return '请提供 UP 主 UID 或空间链接,格式:bili sub <uid> [all|live|dynamic]';
23
+ const kinds = parseKinds(String(params.kind ?? 'all'));
24
+ if (!kinds)
25
+ return '订阅类型只能是 all、live 或 dynamic';
26
+ const channel = extractChannelInfo(input);
27
+ if (!channel)
28
+ return channelSourceError(input);
29
+ if (!canManageSubscription({ adapter, scene, sender }))
30
+ return SUBSCRIPTION_PERMISSION_DENIED_MESSAGE;
31
+ const scope = subscriptionScope(channel);
32
+ const existing = await Subs.select().where({ uid, ...scope });
33
+ if (existing.length > 0) {
34
+ if (existing[0].kinds === kinds)
35
+ return '当前会话已订阅该 UP 主,无需重复添加';
36
+ await Subs.update({ kinds }).where({ uid, ...scope });
37
+ return `已将 ${String(existing[0].uname || uid)} 的订阅改为:${KIND_TEXT[kinds]}`;
38
+ }
39
+ const current = await Subs.select().where(scope);
40
+ if (current.length >= runtime.config.maxPerChannel) {
41
+ return `当前会话订阅数已达上限 (${runtime.config.maxPerChannel}),请先取消一些`;
42
+ }
43
+ let uname = '';
44
+ try {
45
+ uname = (await runtime.client.getUserCard(uid)).name;
46
+ }
47
+ catch (error) {
48
+ if (error instanceof BiliApiError && error.kind === 'not_found')
49
+ return `找不到 UID 为 ${uid} 的用户`;
50
+ // Lookup is cosmetic; subscribe anyway and let the watcher fill gaps.
51
+ }
52
+ await Subs.insert({
53
+ uid,
54
+ uname,
55
+ kinds,
56
+ ...scope,
57
+ creator_id: channel.senderId,
58
+ created_at: new Date().toISOString(),
59
+ });
60
+ await runtime.watcher.baseline(uid).catch(() => undefined);
61
+ const notes = [];
62
+ if (!runtime.outbound)
63
+ notes.push('⚠ 未接入 OutboundHost,暂时无法主动推送');
64
+ if (!runtime.persistent)
65
+ notes.push('⚠ 未配置数据库,订阅在重启后会丢失');
66
+ return [`订阅成功:${uname || uid}(UID ${uid}),类型:${KIND_TEXT[kinds]}`, ...notes].join('\n');
67
+ },
68
+ });
@@ -0,0 +1,35 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ import { defineCommand } from 'zhin.js/command';
3
+ import { parseUid } from "../../../lib/api.js";
4
+ import { channelSourceError, extractChannelInfo, subscriptionScope } from "../../../lib/channel.js";
5
+ import { bilibiliRuntimeToken } from "../../../lib/runtime.js";
6
+ import { canManageSubscription, SUBSCRIPTION_PERMISSION_DENIED_MESSAGE } from "../../../lib/permission.js";
7
+ import { getBiliState, getBiliSubs } from "../../../lib/store.js";
8
+ export default defineCommand({
9
+ description: '取消当前会话对某个 UP 主的订阅(群聊限群主、管理员或机器人主人)',
10
+ params: { uid: { type: 'string', description: 'UP 主 UID 或空间链接' } },
11
+ async execute({ params, input, use, adapter, scene, sender }) {
12
+ const runtime = use(bilibiliRuntimeToken);
13
+ const Subs = getBiliSubs(runtime.db);
14
+ if (!Subs)
15
+ return 'B 站订阅数据库尚未就绪,请稍后重试';
16
+ const uid = parseUid(String(params.uid ?? ''));
17
+ if (!uid)
18
+ return '请提供 UP 主 UID,格式:bili unsub <uid>';
19
+ const channel = extractChannelInfo(input);
20
+ if (!channel)
21
+ return channelSourceError(input);
22
+ if (!canManageSubscription({ adapter, scene, sender }))
23
+ return SUBSCRIPTION_PERMISSION_DENIED_MESSAGE;
24
+ const scope = subscriptionScope(channel);
25
+ const existing = await Subs.select().where({ uid, ...scope });
26
+ if (existing.length === 0)
27
+ return '当前会话没有订阅该 UP 主';
28
+ await Subs.delete().where({ uid, ...scope });
29
+ // Drop the watcher cursor once nobody watches this UP any more.
30
+ const remaining = await Subs.select().where({ uid });
31
+ if (remaining.length === 0)
32
+ await getBiliState(runtime.db)?.delete().where({ uid });
33
+ return `已取消订阅:${String(existing[0].uname || uid)}`;
34
+ },
35
+ });
package/lib/api.d.ts ADDED
@@ -0,0 +1,68 @@
1
+ export declare const DEFAULT_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36";
2
+ export type BiliErrorKind = 'risk' | 'not_found' | 'http' | 'shape' | 'network';
3
+ export declare class BiliApiError extends Error {
4
+ readonly kind: BiliErrorKind;
5
+ readonly code: number | undefined;
6
+ constructor(kind: BiliErrorKind, message: string, code?: number);
7
+ }
8
+ export interface LiveInfo {
9
+ readonly uid: string;
10
+ readonly uname: string;
11
+ readonly roomId: string;
12
+ readonly title: string;
13
+ /** 0 = offline, 1 = live, 2 = replay carousel. */
14
+ readonly liveStatus: number;
15
+ /** Unix seconds of the current live start, 0 when unknown. */
16
+ readonly liveTime: number;
17
+ readonly cover: string;
18
+ readonly area: string;
19
+ readonly url: string;
20
+ }
21
+ export interface RoomInfo {
22
+ readonly uid: string;
23
+ readonly roomId: string;
24
+ readonly title: string;
25
+ readonly liveStatus: number;
26
+ readonly area: string;
27
+ readonly online: number;
28
+ readonly url: string;
29
+ }
30
+ export interface DynamicItem {
31
+ readonly id: string;
32
+ readonly author: string;
33
+ /** Unix seconds. */
34
+ readonly pubTs: number;
35
+ readonly type: string;
36
+ readonly pinned: boolean;
37
+ readonly summary: string;
38
+ readonly url: string;
39
+ }
40
+ export interface UserCard {
41
+ readonly uid: string;
42
+ readonly name: string;
43
+ }
44
+ export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
45
+ export interface BiliClientOptions {
46
+ readonly timeout?: number;
47
+ readonly cookie?: string;
48
+ readonly fetch?: FetchLike;
49
+ readonly now?: () => number;
50
+ }
51
+ export declare class BiliClient {
52
+ #private;
53
+ constructor(options?: BiliClientOptions);
54
+ /** Forget cached device id and WBI key, e.g. after a risk-control response. */
55
+ resetSession(): void;
56
+ /** Batch live status by UP uid (no auth). */
57
+ getLiveStatus(uids: readonly string[]): Promise<Map<string, LiveInfo>>;
58
+ getRoomInfo(roomId: string): Promise<RoomInfo>;
59
+ getUserCard(uid: string): Promise<UserCard>;
60
+ /** Latest space dynamics of one UP (WBI signed). Newest first, as returned. */
61
+ getDynamics(uid: string): Promise<DynamicItem[]>;
62
+ }
63
+ /** Turn one raw feed item into a compact, push-ready record (undefined = skip). */
64
+ export declare function parseDynamic(raw: unknown): DynamicItem | undefined;
65
+ /** Accepts `123`, `UID:123`, `https://space.bilibili.com/123/...`. */
66
+ export declare function parseUid(input: string): string | undefined;
67
+ /** Accepts `21452505` or `https://live.bilibili.com/21452505`. */
68
+ export declare function parseRoomId(input: string): string | undefined;
package/lib/api.js ADDED
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Minimal Bilibili web API client.
3
+ *
4
+ * Only public, key-less endpoints are used. Every response is validated
5
+ * defensively: when Bilibili changes a payload we raise a `BiliApiError` with
6
+ * kind `shape` instead of crashing the poller, and risk-control responses
7
+ * (-352 / -412 / -799 / HTTP 412) are reported as kind `risk` so callers can
8
+ * back off.
9
+ */
10
+ import { mixinKey, signWbi, wbiKeyFromUrl } from './wbi.js';
11
+ export const DEFAULT_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36';
12
+ export class BiliApiError extends Error {
13
+ kind;
14
+ code;
15
+ constructor(kind, message, code) {
16
+ super(message);
17
+ this.name = 'BiliApiError';
18
+ this.kind = kind;
19
+ this.code = code;
20
+ }
21
+ }
22
+ const RISK_CODES = new Set([-352, -412, -509, -799, -401]);
23
+ const NOT_FOUND_CODES = new Set([-404, -626, 4100000, 60004, 1]);
24
+ const WBI_TTL_MS = 6 * 60 * 60 * 1000;
25
+ /**
26
+ * Browser fingerprint hints the web client sends with the space feed. Without
27
+ * them anonymous requests are answered with -352 far more often.
28
+ */
29
+ const DM_PARAMS = Object.freeze({
30
+ dm_img_list: '[]',
31
+ dm_img_str: Buffer.from('WebGL 1.0 (OpenGL ES 2.0 Chromium)').toString('base64').replace(/=+$/u, ''),
32
+ dm_cover_img_str: Buffer.from('ANGLE (Apple, ANGLE Metal Renderer: Apple M1, Unspecified Version)Google Inc. (Apple)')
33
+ .toString('base64')
34
+ .replace(/=+$/u, ''),
35
+ });
36
+ function isRecord(value) {
37
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
38
+ }
39
+ function str(value) {
40
+ return typeof value === 'string' ? value : typeof value === 'number' && Number.isFinite(value) ? String(value) : '';
41
+ }
42
+ function int(value) {
43
+ const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : Number.NaN;
44
+ return Number.isFinite(parsed) ? Math.trunc(parsed) : 0;
45
+ }
46
+ function clip(text, max = 120) {
47
+ const flat = text.replace(/\s+/gu, ' ').trim();
48
+ return flat.length > max ? `${flat.slice(0, max)}…` : flat;
49
+ }
50
+ function absoluteUrl(url) {
51
+ if (url.startsWith('//'))
52
+ return `https:${url}`;
53
+ return url;
54
+ }
55
+ export class BiliClient {
56
+ #timeout;
57
+ #userCookie;
58
+ #fetch;
59
+ #now;
60
+ #buvid;
61
+ #wbi;
62
+ constructor(options = {}) {
63
+ this.#timeout = options.timeout ?? 10_000;
64
+ this.#userCookie = options.cookie?.trim() ?? '';
65
+ this.#fetch = options.fetch ?? ((input, init) => fetch(input, init));
66
+ this.#now = options.now ?? Date.now;
67
+ }
68
+ /** Forget cached device id and WBI key, e.g. after a risk-control response. */
69
+ resetSession() {
70
+ this.#buvid = undefined;
71
+ this.#wbi = undefined;
72
+ }
73
+ async #request(url, init = {}) {
74
+ const headers = {
75
+ 'user-agent': DEFAULT_USER_AGENT,
76
+ accept: 'application/json, text/plain, */*',
77
+ referer: init.referer ?? 'https://www.bilibili.com/',
78
+ ...init.headers,
79
+ };
80
+ if (init.cookie !== false) {
81
+ const cookie = await this.#cookie();
82
+ if (cookie)
83
+ headers.cookie = cookie;
84
+ }
85
+ let response;
86
+ try {
87
+ response = await this.#fetch(url, {
88
+ method: init.method ?? 'GET',
89
+ headers,
90
+ ...(init.body === undefined ? {} : { body: init.body }),
91
+ signal: AbortSignal.timeout(this.#timeout),
92
+ });
93
+ }
94
+ catch (error) {
95
+ const cause = error;
96
+ throw new BiliApiError('network', cause.name === 'TimeoutError' ? `请求超时(${this.#timeout}ms)` : `请求失败: ${cause.message}`);
97
+ }
98
+ if (response.status === 412)
99
+ throw new BiliApiError('risk', 'B 站风控拦截(HTTP 412)', 412);
100
+ if (!response.ok)
101
+ throw new BiliApiError('http', `HTTP ${response.status}`, response.status);
102
+ let body;
103
+ try {
104
+ body = await response.json();
105
+ }
106
+ catch {
107
+ throw new BiliApiError('shape', '接口返回的不是 JSON');
108
+ }
109
+ if (!isRecord(body))
110
+ throw new BiliApiError('shape', '接口返回格式异常');
111
+ const code = int(body.code);
112
+ if (code !== 0 && !init.acceptCodes?.includes(code)) {
113
+ const message = str(body.message) || str(body.msg) || `错误码 ${code}`;
114
+ if (RISK_CODES.has(code)) {
115
+ this.resetSession();
116
+ throw new BiliApiError('risk', `B 站风控拦截(${code}: ${message})`, code);
117
+ }
118
+ if (NOT_FOUND_CODES.has(code))
119
+ throw new BiliApiError('not_found', message, code);
120
+ throw new BiliApiError('http', message, code);
121
+ }
122
+ return body.data;
123
+ }
124
+ async #cookie() {
125
+ if (this.#userCookie)
126
+ return this.#userCookie;
127
+ if (this.#buvid !== undefined)
128
+ return this.#buvid;
129
+ try {
130
+ const data = await this.#request('https://api.bilibili.com/x/frontend/finger/spi', { cookie: false });
131
+ const b3 = isRecord(data) ? str(data.b_3) : '';
132
+ const b4 = isRecord(data) ? str(data.b_4) : '';
133
+ this.#buvid = b3 ? `buvid3=${b3}${b4 ? `; buvid4=${encodeURIComponent(b4)}` : ''}` : '';
134
+ }
135
+ catch {
136
+ // Device id is best effort; most endpoints still answer without it.
137
+ this.#buvid = '';
138
+ }
139
+ return this.#buvid;
140
+ }
141
+ async #wbiKey() {
142
+ if (this.#wbi && this.#now() - this.#wbi.at < WBI_TTL_MS)
143
+ return this.#wbi.key;
144
+ // `nav` answers -101 (not logged in) for guests but still carries wbi_img.
145
+ const data = await this.#request('https://api.bilibili.com/x/web-interface/nav', { acceptCodes: [-101] });
146
+ const img = isRecord(data) && isRecord(data.wbi_img) ? data.wbi_img : undefined;
147
+ const imgUrl = img ? str(img.img_url) : '';
148
+ const subUrl = img ? str(img.sub_url) : '';
149
+ if (!imgUrl || !subUrl)
150
+ throw new BiliApiError('shape', '无法获取 WBI 密钥');
151
+ const key = mixinKey(wbiKeyFromUrl(imgUrl), wbiKeyFromUrl(subUrl));
152
+ this.#wbi = { key, at: this.#now() };
153
+ return key;
154
+ }
155
+ /** Batch live status by UP uid (no auth). */
156
+ async getLiveStatus(uids) {
157
+ const result = new Map();
158
+ for (let index = 0; index < uids.length; index += 50) {
159
+ const chunk = uids.slice(index, index + 50).map(Number);
160
+ const data = await this.#request('https://api.live.bilibili.com/room/v1/Room/get_status_info_by_uids', {
161
+ method: 'POST',
162
+ headers: { 'content-type': 'application/json' },
163
+ body: JSON.stringify({ uids: chunk }),
164
+ referer: 'https://live.bilibili.com/',
165
+ });
166
+ // An empty result is serialised as [] instead of {}.
167
+ if (Array.isArray(data))
168
+ continue;
169
+ if (!isRecord(data))
170
+ throw new BiliApiError('shape', '开播状态接口格式异常');
171
+ for (const [key, value] of Object.entries(data)) {
172
+ if (!isRecord(value))
173
+ continue;
174
+ const uid = str(value.uid) || key;
175
+ const roomId = str(value.room_id);
176
+ result.set(uid, {
177
+ uid,
178
+ uname: str(value.uname),
179
+ roomId,
180
+ title: str(value.title),
181
+ liveStatus: int(value.live_status),
182
+ liveTime: int(value.live_time),
183
+ cover: str(value.cover_from_user) || str(value.keyframe),
184
+ area: str(value.area_v2_name) || str(value.area_name),
185
+ url: roomId ? `https://live.bilibili.com/${roomId}` : '',
186
+ });
187
+ }
188
+ }
189
+ return result;
190
+ }
191
+ async getRoomInfo(roomId) {
192
+ const data = await this.#request(`https://api.live.bilibili.com/room/v1/Room/get_info?room_id=${encodeURIComponent(roomId)}`, { referer: 'https://live.bilibili.com/' });
193
+ if (!isRecord(data) || !str(data.room_id))
194
+ throw new BiliApiError('shape', '直播间接口格式异常');
195
+ const realId = str(data.room_id);
196
+ return {
197
+ uid: str(data.uid),
198
+ roomId: realId,
199
+ title: str(data.title),
200
+ liveStatus: int(data.live_status),
201
+ area: str(data.area_name),
202
+ online: int(data.online),
203
+ url: `https://live.bilibili.com/${realId}`,
204
+ };
205
+ }
206
+ async getUserCard(uid) {
207
+ const data = await this.#request(`https://api.bilibili.com/x/web-interface/card?mid=${encodeURIComponent(uid)}`);
208
+ const card = isRecord(data) && isRecord(data.card) ? data.card : undefined;
209
+ if (!card)
210
+ throw new BiliApiError('shape', '用户信息接口格式异常');
211
+ return { uid: str(card.mid) || uid, name: str(card.name) };
212
+ }
213
+ /** Latest space dynamics of one UP (WBI signed). Newest first, as returned. */
214
+ async getDynamics(uid) {
215
+ const key = await this.#wbiKey();
216
+ const query = signWbi({ host_mid: uid, features: 'itemOpusStyle', ...DM_PARAMS }, key, Math.floor(this.#now() / 1000));
217
+ const data = await this.#request(`https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/space?${query}`, {
218
+ referer: `https://space.bilibili.com/${uid}/dynamic`,
219
+ headers: { origin: 'https://space.bilibili.com' },
220
+ });
221
+ if (!isRecord(data) || !Array.isArray(data.items))
222
+ throw new BiliApiError('shape', '动态接口格式异常');
223
+ const items = [];
224
+ for (const raw of data.items) {
225
+ const item = parseDynamic(raw);
226
+ if (item)
227
+ items.push(item);
228
+ }
229
+ return items;
230
+ }
231
+ }
232
+ /** Turn one raw feed item into a compact, push-ready record (undefined = skip). */
233
+ export function parseDynamic(raw) {
234
+ if (!isRecord(raw))
235
+ return undefined;
236
+ const id = str(raw.id_str);
237
+ if (!id)
238
+ return undefined;
239
+ const type = str(raw.type);
240
+ // Live rooms are pushed by the live watcher, not as dynamics.
241
+ if (type === 'DYNAMIC_TYPE_LIVE_RCMD' || type === 'DYNAMIC_TYPE_LIVE')
242
+ return undefined;
243
+ const modules = isRecord(raw.modules) ? raw.modules : {};
244
+ const author = isRecord(modules.module_author) ? modules.module_author : {};
245
+ const dynamic = isRecord(modules.module_dynamic) ? modules.module_dynamic : {};
246
+ const tag = isRecord(modules.module_tag) ? modules.module_tag : {};
247
+ const major = isRecord(dynamic.major) ? dynamic.major : {};
248
+ const desc = isRecord(dynamic.desc) ? str(dynamic.desc.text) : '';
249
+ const archive = isRecord(major.archive) ? major.archive : undefined;
250
+ const opus = isRecord(major.opus) ? major.opus : undefined;
251
+ const article = isRecord(major.article) ? major.article : undefined;
252
+ const opusText = opus && isRecord(opus.summary) ? str(opus.summary.text) : '';
253
+ const opusTitle = opus ? str(opus.title) : '';
254
+ let summary;
255
+ let url = `https://t.bilibili.com/${id}`;
256
+ switch (type) {
257
+ case 'DYNAMIC_TYPE_AV':
258
+ summary = `投稿了视频:${archive ? str(archive.title) : desc}`;
259
+ if (archive && str(archive.jump_url))
260
+ url = absoluteUrl(str(archive.jump_url));
261
+ break;
262
+ case 'DYNAMIC_TYPE_ARTICLE':
263
+ summary = `发布了专栏:${opusTitle || (article ? str(article.title) : '') || clip(opusText)}`;
264
+ break;
265
+ case 'DYNAMIC_TYPE_FORWARD':
266
+ summary = `转发了动态:${clip(desc) || '(无附言)'}`;
267
+ break;
268
+ default:
269
+ summary = clip(opusText || desc || opusTitle) || '发布了新动态';
270
+ }
271
+ return {
272
+ id,
273
+ author: str(author.name),
274
+ pubTs: int(author.pub_ts),
275
+ type,
276
+ pinned: str(tag.text) === '置顶',
277
+ summary,
278
+ url,
279
+ };
280
+ }
281
+ /** Accepts `123`, `UID:123`, `https://space.bilibili.com/123/...`. */
282
+ export function parseUid(input) {
283
+ const text = input.trim();
284
+ const fromUrl = /space\.bilibili\.com\/(\d{1,20})/u.exec(text);
285
+ if (fromUrl)
286
+ return fromUrl[1];
287
+ const plain = /^(?:uid[::]?)?(\d{1,20})$/iu.exec(text);
288
+ return plain?.[1];
289
+ }
290
+ /** Accepts `21452505` or `https://live.bilibili.com/21452505`. */
291
+ export function parseRoomId(input) {
292
+ const text = input.trim();
293
+ const fromUrl = /live\.bilibili\.com\/(?:h5\/)?(\d{1,20})/u.exec(text);
294
+ if (fromUrl)
295
+ return fromUrl[1];
296
+ return /^\d{1,20}$/u.test(text) ? text : undefined;
297
+ }
298
+ //# sourceMappingURL=api.js.map