@zhin.js/adapter-douyin-live 0.0.0-stage → 0.1.1

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,117 @@
1
- # Temporary Holding Version
1
+ # @zhin.js/adapter-douyin-live
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
+ > **Experimental · 非官方协议** — 基于抖音直播网页端 **非官方、未公开** 的弹幕 WebSocket(`webcast/im/push/v2`)与页面数据,**随时可能因平台改版、签名升级或风控而失效**;不进 stable smoke,接口与配置可能不兼容变更。使用前请自行确认符合抖音用户协议,仅用于**你自己的直播间**。
4
+
5
+ > 🚧 **当前只听不说**:`sendMode` 默认 `off`。`sendMode: direct` 尚未实现(没有稳定、可测试的发弹幕接口),发送会明确返回 `not_implemented`,回复不会出现在直播间。
6
+
7
+ 账号由 `DouyinLiveAdapter` 管理。Endpoint 直接继承实际 `DouyinLiveClient`,保留原生方法与事件;框架生命周期、规范入站与发送经 `endpointSpec` 和 AdapterIndex 接入,不占用 Client 的原生方法。
8
+
9
+ `Session.event` 使用 Client 已公开的 `DouyinLiveChat`(`uid`、`nickname`、`text`、`timestamp`、可选 `id` / `roomId`)事件对象,与 `danmaku` 事件保持同一引用。网络帧经包内最小 protobuf 解码器解析,未作为公开事件 DTO。
10
+
11
+ Zhin.js 抖音直播弹幕适配器(Plugin Runtime):让 Bot 在**你自己的直播间**里看弹幕,弹幕与 B 站直播弹幕一样进入 Agent 的弹幕窗口。设计见 [ADR 0038:连续输入合流——高热群与弹幕](https://github.com/zhinjs/zhin/blob/main/docs/adr/0038-continuous-input-merge.md) §5,讨论见 [#1049](https://github.com/zhinjs/zhin/issues/1049)、[#1135](https://github.com/zhinjs/zhin/issues/1135)。
12
+
13
+ ## 先读这几条
14
+
15
+ - **非官方协议,随时会坏**:房间号解析、WebSocket 地址参数、`signature` 签名、protobuf 字段都来自对网页端的观察,平台一改就失效。失效时连接会反复失败并退避重连,请关注日志。
16
+ - **签名不内置**:平台要求 WebSocket 地址带 `signature`(由网页端混淆脚本生成)。本包**不内置、不 vendor** 任何平台脚本或第三方抓取器;可配 `signerUrl` 指向你自己部署的签名服务。不配时不带签名连接,**平台可能直接拒绝握手**。
17
+ - **一窗只回一条**:本适配器声明 `liveDanmaku` 能力。Agent 不逐条回弹幕,而是把一段时间内的弹幕收进窗口,由模型挑一条(或总结后)回一次。窗口参数见下文「弹幕窗口」。
18
+ - **弹幕回合只读**:弹幕回合按匿名观众的权限跑,只开放声明了 `readOnly: true` 的工具,不出任何审批卡,不跑 Shell / MCP / 子 Agent。
19
+ - **默认只听不说(sendMode α)**:`sendMode` 默认 `off`;`direct` 目前只做房间与内容校验,然后返回 `not_implemented`,不会调用任何平台写接口。
20
+ - **Cookie 等同账号凭据**:`cookie` 可选(不填时启动时申请游客 `ttwid`)。若填写浏览器 Cookie,**用环境变量注入,不要写进 `zhin.config.yml` 明文,不要提交到仓库,不要贴进日志或 issue**。本包从不记录 Cookie。
21
+ - **只进自己的直播间**:只连 `roomId` 指定的一个房间。禁止用于营销、引流、刷屏或采集他人直播间数据。
22
+
23
+ ## 当前能力
24
+
25
+ | 能力 | 状态 |
26
+ |---|---|
27
+ | web_rid → 内部 room_id(直播间页内嵌数据),游客 `ttwid` | ✅ 已实现(每次重连重新解析) |
28
+ | 弹幕 WebSocket:10 秒 `hb` 心跳、`needAck` 回 ack、看门狗、断线退避重连 | ✅ 已实现 |
29
+ | 解 gzip `PushFrame` → `Response`,只解析 `WebcastChatMessage` | ✅ 已实现(礼物、进场、点赞、关注等忽略) |
30
+ | 按消息 id 去重(最近 500 条) | ✅ 已实现 |
31
+ | WebSocket `signature` | 🚧 不内置;可配 `signerUrl` 外部签名服务 |
32
+ | 发弹幕(`sendMode: direct`) | 🚧 未实现,返回 `not_implemented` |
33
+ | 过滤自己发的弹幕 | 🚧 暂无(本包不发弹幕,也不识别登录账号) |
34
+ | 礼物 / 进场 / 点赞 / 关注事件、扫码登录、多直播间、Console 页面 | 🚧 未做 |
35
+
36
+ ## 前置条件
37
+
38
+ 1. 你自己的抖音直播间 web_rid(`https://live.douyin.com/<web_rid>` 里的数字)作为 `roomId`
39
+ 2. 可选:浏览器 Cookie 整串,放进 `.env`(已被 gitignore):`DOUYIN_LIVE_COOKIE`。不填则使用游客 `ttwid`
40
+ 3. 可选:自建签名服务地址 `signerUrl`(见下文「签名」)
41
+ 4. Agent 已启用(`ai.agent`):弹幕窗口由 Agent 的 `inboundQueue.live` 驱动
42
+
43
+ ## 最小配置
44
+
45
+ ```yaml
46
+ configVersion: 2
47
+ plugins:
48
+ douyin-live: "@zhin.js/adapter-douyin-live"
49
+ douyin-live:
50
+ sendMode: off # 默认;direct 尚未实现(返回 not_implemented)
51
+ maxLength: 50 # 为后续 direct 发送预留
52
+ maxChunks: 2
53
+ minSendIntervalSec: 5
54
+ endpoints:
55
+ - id: my-live
56
+ roomId: "123456789" # web_rid;只填你自己的直播间
57
+ cookie: ${DOUYIN_LIVE_COOKIE}
58
+ # signerUrl: http://127.0.0.1:8080/sign
59
+ ```
60
+
61
+ 必填字段(`endpoints[i]`):`id`、`roomId`。
62
+
63
+ ## 签名
64
+
65
+ 连接前按网页端规则把固定的一组查询参数(`live_id`、`aid`、`version_code`、`webcast_sdk_version`、`room_id`、`user_unique_id`、`identity` 等)拼成 `k=v,k=v,...` 取 md5,得到 `X-MS-STUB`。配置了 `signerUrl` 时请求:
66
+
67
+ ```text
68
+ GET <signerUrl>?stub=<X-MS-STUB>
69
+ ```
70
+
71
+ 响应为纯文本签名,或 JSON `{ "signature": "..." }`,结果作为 WebSocket 的 `signature` 参数。签名服务由你自行部署与维护,本包不提供实现。
72
+
73
+ ## 弹幕窗口
74
+
75
+ 窗口由 Agent 配置控制(不是本适配器的配置),与 `@zhin.js/adapter-bilibili-live` 相同:
76
+
77
+ ```yaml
78
+ ai:
79
+ agent:
80
+ inboundQueue:
81
+ live:
82
+ mode: pick-one # pick-one:挑一条回;summary:总结窗口后回一次
83
+ windowMs: 10000 # 第一条弹幕后等多久发车
84
+ minIntervalMs: 15000 # 两次弹幕回复的最小间隔
85
+ ```
86
+
87
+ ## 会话与消息
88
+
89
+ | 字段 | 值 |
90
+ |---|---|
91
+ | `conversation` | `{ kind: 'channel', id: <web_rid> }`(与配置 `roomId` 一致) |
92
+ | `message.id` | 平台消息 id(取不到时用时间戳 + uid + 序号) |
93
+ | `sender.id` | 观众 user id;取不到时为 `anon:<昵称>` |
94
+ | `metadata` | `{ roomId: <内部 room_id>, liveDanmaku: true }` |
95
+
96
+ 出站(将来 `direct`)只支持纯文本:Markdown 去格式,图片等其他段不支持;抖音弹幕没有「回复」字段,`mention` 折叠进正文 `@昵称`。
97
+
98
+ ## 磁盘占用
99
+
100
+ 本适配器不落盘:去重表(最近 500 条消息 id)与连接状态都在内存里,重启即清空。
101
+
102
+ ## 已知限制
103
+
104
+ - 平台协议非公开,字段与签名随时可能变化;不签名时可能无法连接。
105
+ - 取不到 uid 的观众按昵称区分,同名观众会被当成同一个人。
106
+ - 本切片不提供 Console 页面与 RPC。
107
+
108
+ ## 故障排查
109
+
110
+ | 现象 | 原因与处理 |
111
+ |---|---|
112
+ | 日志 `douyin_live_unsigned` | 未配 `signerUrl`,不带签名连接。若握手被拒,部署签名服务后配 `signerUrl` |
113
+ | `Douyin live page returned no room_id` | 直播间未开播、web_rid 填错,或页面改版。核对 `roomId` |
114
+ | `Douyin live did not issue a ttwid cookie` | 游客 Cookie 申请失败(风控 / 网络)。可在 `cookie` 里带上浏览器的 `ttwid` |
115
+ | 连接反复断开重连 | 签名失效、Cookie 失效或协议改版;看门狗 30 秒无数据也会重连 |
116
+ | 发送返回 `send_disabled` | `sendMode: off`(默认),只听不说 |
117
+ | 发送返回 `not_implemented` | `sendMode: direct` 尚未实现,改回 `off` |
@@ -0,0 +1,26 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ /**
3
+ * Convention entry: discover `adapters/douyin-live/index.ts` → defineAdapter.
4
+ * Experimental:连抖音直播弹幕 WebSocket(非官方协议);声明 `liveDanmaku`,Agent 按弹幕窗口一窗只回一条(ADR 0038 §5)。
5
+ */
6
+ import { defineAdapter } from 'zhin.js/adapter';
7
+ import { DouyinLiveAdapter } from "../../lib/adapter.js";
8
+ export { DouyinLiveEndpoint } from "../../lib/endpoint.js";
9
+ export default defineAdapter({
10
+ capabilities: ['inbound', 'outbound', 'liveDanmaku'],
11
+ // 弹幕只支持纯文本;平台没有「回复」字段,mention 折叠进正文 `@昵称`。
12
+ profile: {
13
+ default: {
14
+ messaging: {
15
+ segments: {
16
+ supported: ['text', 'mention'],
17
+ html: 'text',
18
+ markdown: 'text',
19
+ interactive: 'text',
20
+ },
21
+ },
22
+ delivery: { send: true, spool: true },
23
+ },
24
+ },
25
+ createAdapter: context => new DouyinLiveAdapter(context),
26
+ });
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Convention entry: discover `adapters/douyin-live/index.ts` → defineAdapter.
3
+ * Experimental:连抖音直播弹幕 WebSocket(非官方协议);声明 `liveDanmaku`,Agent 按弹幕窗口一窗只回一条(ADR 0038 §5)。
4
+ */
5
+ import { defineAdapter } from 'zhin.js/adapter';
6
+ import { DouyinLiveAdapter } from '../../src/adapter.js';
7
+ import type { DouyinLiveEndpointConfig } from '../../src/protocol.js';
8
+
9
+ export { DouyinLiveEndpoint } from '../../src/endpoint.js';
10
+ export type { DouyinLiveEndpointOptions } from '../../src/endpoint.js';
11
+
12
+ export default defineAdapter<DouyinLiveEndpointConfig>({
13
+ capabilities: ['inbound', 'outbound', 'liveDanmaku'],
14
+ // 弹幕只支持纯文本;平台没有「回复」字段,mention 折叠进正文 `@昵称`。
15
+ profile: {
16
+ default: {
17
+ messaging: {
18
+ segments: {
19
+ supported: ['text', 'mention'],
20
+ html: 'text',
21
+ markdown: 'text',
22
+ interactive: 'text',
23
+ },
24
+ },
25
+ delivery: { send: true, spool: true },
26
+ },
27
+ },
28
+ createAdapter: context => new DouyinLiveAdapter(context),
29
+ });
@@ -0,0 +1,17 @@
1
+ import { Adapter, type AdapterContext, type EndpointCreationContext } from 'zhin.js/adapter';
2
+ import type { DouyinLiveClientEventMap } from './client.js';
3
+ import { DouyinLiveEndpoint } from './endpoint.js';
4
+ import { type DouyinLiveEndpointConfig } from './protocol.js';
5
+ export declare class DouyinLiveAdapter extends Adapter<DouyinLiveEndpoint, DouyinLiveEndpointConfig, DouyinLiveClientEventMap> {
6
+ constructor(_context: AdapterContext<DouyinLiveEndpointConfig>);
7
+ get configurationIdentityFields(): readonly string[];
8
+ /** `cookie` 是账号凭据且无身份探针可校验,故不在可热更新字段里:换 Cookie 需显式替换 endpoint。 */
9
+ get configurationUpdateFields(): readonly string[];
10
+ protected validateConfiguration(config: DouyinLiveEndpointConfig): void;
11
+ protected createEndpoint(input: DouyinLiveEndpointConfig, execution: EndpointCreationContext): DouyinLiveEndpoint;
12
+ }
13
+ declare module '@zhin.js/feature-kit' {
14
+ interface AdapterRegistry {
15
+ readonly 'douyin-live': DouyinLiveAdapter;
16
+ }
17
+ }
package/lib/adapter.js ADDED
@@ -0,0 +1,15 @@
1
+ import { Adapter } from 'zhin.js/adapter';
2
+ import { DouyinLiveEndpoint } from './endpoint.js';
3
+ import { resolveDouyinLiveConfig } from './protocol.js';
4
+ export class DouyinLiveAdapter extends Adapter {
5
+ constructor(_context) { super(); }
6
+ get configurationIdentityFields() { return ['id']; }
7
+ /** `cookie` 是账号凭据且无身份探针可校验,故不在可热更新字段里:换 Cookie 需显式替换 endpoint。 */
8
+ get configurationUpdateFields() {
9
+ return ['roomId', 'sendMode', 'maxLength', 'maxChunks', 'minSendIntervalSec', 'signerUrl'];
10
+ }
11
+ validateConfiguration(config) { resolveDouyinLiveConfig(config); }
12
+ createEndpoint(input, execution) {
13
+ return new DouyinLiveEndpoint({ id: execution.canonicalId, config: resolveDouyinLiveConfig(input), signal: execution.signal });
14
+ }
15
+ }
@@ -0,0 +1,73 @@
1
+ import type { DouyinLiveChat, ResolvedDouyinLiveConfig } from './protocol.js';
2
+ export type DouyinLiveFetch = (url: string, init?: {
3
+ readonly method?: string;
4
+ readonly headers?: Record<string, string>;
5
+ readonly signal?: AbortSignal;
6
+ }) => Promise<{
7
+ readonly ok: boolean;
8
+ readonly status: number;
9
+ readonly headers: {
10
+ get(name: string): string | null;
11
+ getSetCookie?(): string[];
12
+ };
13
+ text(): Promise<string>;
14
+ }>;
15
+ /**
16
+ * 由 `X-MS-STUB`(参数串 md5)算出 WebSocket `signature`。
17
+ * 返回空串 / 缺失视为签名失败并抛错(不会静默降级为无签名);想无签名连接就不要配置 signer。
18
+ * `signal` 带 15 s 请求超时与客户端取消。
19
+ */
20
+ export type DouyinLiveSigner = (stub: string, signal?: AbortSignal) => Promise<string> | string;
21
+ export interface DouyinLiveRoomInfo {
22
+ /** 平台内部 room_id(WebSocket 用)。 */
23
+ readonly roomId: string;
24
+ }
25
+ export declare const DOUYIN_LIVE_PUSH_URL = "wss://webcast100-ws-web-lq.douyin.com/webcast/im/push/v2/";
26
+ /** 平台会拒绝明显的非浏览器 UA;与 `browser_version` 参数保持一致。 */
27
+ export declare const DOUYIN_LIVE_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";
28
+ export declare class DouyinLiveApiError extends Error {
29
+ readonly httpStatus?: number;
30
+ constructor(message: string, httpStatus?: number);
31
+ }
32
+ /**
33
+ * `X-MS-STUB` = md5(`k1=v1,k2=v2,...`),按 `STUB_KEYS` 顺序取 WebSocket 查询参数。
34
+ * md5 是平台协议规定的参数摘要(不是安全用途);输入只有公开参数与随机游客设备 id,不含 Cookie 或账号凭据。
35
+ */
36
+ export declare function buildSignatureStub(params: ReadonlyMap<string, string> | Record<string, string>): string;
37
+ export declare class DouyinLiveClient {
38
+ #private;
39
+ readonly config: ResolvedDouyinLiveConfig;
40
+ readonly fetch: DouyinLiveFetch;
41
+ readonly now: () => number;
42
+ /** 游客设备 id:同一 endpoint 生命周期内固定。 */
43
+ readonly userUniqueId: string;
44
+ constructor(config: ResolvedDouyinLiveConfig, fetch: DouyinLiveFetch, options?: {
45
+ readonly now?: () => number;
46
+ readonly signal?: AbortSignal;
47
+ readonly sign?: DouyinLiveSigner;
48
+ readonly userUniqueId?: string;
49
+ });
50
+ /** 是否已有 `ttwid`(配置 Cookie 自带或已申请到游客 ttwid)。 */
51
+ get hasTtwid(): boolean;
52
+ /** 是否能生成 WebSocket `signature`。 */
53
+ get canSign(): boolean;
54
+ /** 连接 / 请求用的 Cookie:配置 Cookie 原样保留,缺 `ttwid` 时补上游客 ttwid。 */
55
+ cookieHeader(extra?: Record<string, string>): string;
56
+ /** 没有 `ttwid` 时访问直播首页申请游客 ttwid(平台经 Set-Cookie 下发)。 */
57
+ ensureTtwid(): Promise<void>;
58
+ /** web_rid → 内部 room_id:解析直播间页内嵌数据。每次(重)连都重新取。 */
59
+ resolveRoom(): Promise<DouyinLiveRoomInfo>;
60
+ /** 弹幕 WebSocket 查询参数(不含 signature)。 */
61
+ pushParams(roomId: string): Map<string, string>;
62
+ /** 弹幕 WebSocket 地址;能签名时追加 `signature`,否则不带(平台可能拒绝握手)。 */
63
+ buildPushUrl(roomId: string): Promise<string>;
64
+ /**
65
+ * 取签名:优先注入的 signer,其次 `signerUrl`;都没有返回空串(unsigned)。
66
+ * 配了签名来源却拿不到可用签名时抛错(可见、可重试),不静默降级成无签名连接。
67
+ */
68
+ signature(stub: string): Promise<string>;
69
+ }
70
+ /** 直播间页内嵌 JSON(转义或未转义)里的 `roomId`。 */
71
+ export declare function parseRoomIdFromHtml(html: string): string | undefined;
72
+ /** 原生事件:`danmaku`(解析后的 `WebcastChatMessage`)。 */
73
+ export type DouyinLiveClientEventMap = Record<string, DouyinLiveChat>;
package/lib/client.js ADDED
@@ -0,0 +1,236 @@
1
+ /**
2
+ * 抖音直播 Web 侧 client(非官方协议,Experimental)。
3
+ * 读:游客 `ttwid`、直播间页 → 内部 room_id、弹幕 WebSocket 地址(含可选 signature)。
4
+ * 本包不做发弹幕(sendMode α),也不内置平台混淆签名脚本。永远不要记录 Cookie。
5
+ */
6
+ import { createHash, randomInt } from 'node:crypto';
7
+ const REQUEST_TIMEOUT_MS = 15_000;
8
+ const LIVE_BASE_URL = 'https://live.douyin.com';
9
+ export const DOUYIN_LIVE_PUSH_URL = 'wss://webcast100-ws-web-lq.douyin.com/webcast/im/push/v2/';
10
+ /** 平台会拒绝明显的非浏览器 UA;与 `browser_version` 参数保持一致。 */
11
+ export const DOUYIN_LIVE_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';
12
+ /** `X-MS-STUB` 参与 md5 的参数(顺序固定,取自网页端 SDK)。 */
13
+ const STUB_KEYS = [
14
+ 'live_id', 'aid', 'version_code', 'webcast_sdk_version', 'room_id', 'sub_room_id', 'sub_channel_id',
15
+ 'did_rule', 'user_unique_id', 'device_platform', 'device_type', 'ac', 'identity',
16
+ ];
17
+ const MAX_SIGNATURE_LENGTH = 512;
18
+ export class DouyinLiveApiError extends Error {
19
+ httpStatus;
20
+ constructor(message, httpStatus) {
21
+ super(message);
22
+ this.name = 'DouyinLiveApiError';
23
+ if (httpStatus !== undefined)
24
+ this.httpStatus = httpStatus;
25
+ }
26
+ }
27
+ /**
28
+ * `X-MS-STUB` = md5(`k1=v1,k2=v2,...`),按 `STUB_KEYS` 顺序取 WebSocket 查询参数。
29
+ * md5 是平台协议规定的参数摘要(不是安全用途);输入只有公开参数与随机游客设备 id,不含 Cookie 或账号凭据。
30
+ */
31
+ export function buildSignatureStub(params) {
32
+ const get = (key) => (params instanceof Map ? params.get(key) : params[key]) ?? '';
33
+ const plain = STUB_KEYS.map((key) => `${key}=${get(key)}`).join(',');
34
+ return createHash('md5').update(plain).digest('hex');
35
+ }
36
+ function cookieValue(cookie, name) {
37
+ if (!cookie)
38
+ return undefined;
39
+ for (const part of cookie.split(';')) {
40
+ const index = part.indexOf('=');
41
+ if (index > 0 && part.slice(0, index).trim() === name) {
42
+ const value = part.slice(index + 1).trim();
43
+ if (value)
44
+ return value;
45
+ }
46
+ }
47
+ return undefined;
48
+ }
49
+ function randomDigits(length) {
50
+ let out = String(randomInt(1, 10));
51
+ while (out.length < length)
52
+ out += String(randomInt(0, 10));
53
+ return out;
54
+ }
55
+ function randomHex(length) {
56
+ let out = '';
57
+ while (out.length < length)
58
+ out += randomInt(0, 16).toString(16);
59
+ return out;
60
+ }
61
+ export class DouyinLiveClient {
62
+ config;
63
+ fetch;
64
+ now;
65
+ #signer;
66
+ #signal;
67
+ #ttwid;
68
+ /** 游客设备 id:同一 endpoint 生命周期内固定。 */
69
+ userUniqueId;
70
+ constructor(config, fetch, options = {}) {
71
+ this.config = config;
72
+ this.fetch = fetch;
73
+ this.now = options.now ?? Date.now;
74
+ this.#signal = options.signal;
75
+ this.#signer = options.sign;
76
+ this.#ttwid = cookieValue(config.cookie, 'ttwid');
77
+ this.userUniqueId = options.userUniqueId ?? randomDigits(19);
78
+ }
79
+ /** 是否已有 `ttwid`(配置 Cookie 自带或已申请到游客 ttwid)。 */
80
+ get hasTtwid() {
81
+ return Boolean(this.#ttwid);
82
+ }
83
+ /** 是否能生成 WebSocket `signature`。 */
84
+ get canSign() {
85
+ return Boolean(this.#signer || this.config.signerUrl);
86
+ }
87
+ /** 连接 / 请求用的 Cookie:配置 Cookie 原样保留,缺 `ttwid` 时补上游客 ttwid。 */
88
+ cookieHeader(extra = {}) {
89
+ const parts = [];
90
+ if (this.config.cookie)
91
+ parts.push(this.config.cookie.replace(/;\s*$/, ''));
92
+ if (this.#ttwid && !cookieValue(this.config.cookie, 'ttwid'))
93
+ parts.push(`ttwid=${this.#ttwid}`);
94
+ for (const [key, value] of Object.entries(extra)) {
95
+ if (!cookieValue(this.config.cookie, key))
96
+ parts.push(`${key}=${value}`);
97
+ }
98
+ return parts.join('; ');
99
+ }
100
+ #requestSignal() {
101
+ const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
102
+ return this.#signal ? AbortSignal.any([this.#signal, timeout]) : timeout;
103
+ }
104
+ async #get(url, headers = {}) {
105
+ this.#signal?.throwIfAborted();
106
+ const response = await this.fetch(url, {
107
+ method: 'GET',
108
+ headers: { 'user-agent': DOUYIN_LIVE_USER_AGENT, ...headers },
109
+ signal: this.#requestSignal(),
110
+ });
111
+ this.#signal?.throwIfAborted();
112
+ if (!response.ok)
113
+ throw new DouyinLiveApiError(`HTTP ${response.status}`, response.status);
114
+ return response;
115
+ }
116
+ /** 没有 `ttwid` 时访问直播首页申请游客 ttwid(平台经 Set-Cookie 下发)。 */
117
+ async ensureTtwid() {
118
+ if (this.#ttwid)
119
+ return;
120
+ const response = await this.#get(`${LIVE_BASE_URL}/`);
121
+ const cookies = typeof response.headers.getSetCookie === 'function'
122
+ ? response.headers.getSetCookie()
123
+ : [response.headers.get('set-cookie') ?? ''];
124
+ for (const line of cookies) {
125
+ const match = /(?:^|[;,]\s*)ttwid=([^;,\s]+)/.exec(line);
126
+ if (match?.[1]) {
127
+ this.#ttwid = match[1];
128
+ return;
129
+ }
130
+ }
131
+ throw new DouyinLiveApiError('Douyin live did not issue a ttwid cookie');
132
+ }
133
+ /** web_rid → 内部 room_id:解析直播间页内嵌数据。每次(重)连都重新取。 */
134
+ async resolveRoom() {
135
+ await this.ensureTtwid();
136
+ const response = await this.#get(`${LIVE_BASE_URL}/${encodeURIComponent(this.config.roomId)}`, {
137
+ cookie: this.cookieHeader({ __ac_nonce: `0${randomHex(20)}` }),
138
+ referer: `${LIVE_BASE_URL}/`,
139
+ });
140
+ const html = await response.text();
141
+ const roomId = parseRoomIdFromHtml(html);
142
+ if (!roomId)
143
+ throw new DouyinLiveApiError('Douyin live page returned no room_id (room offline or page changed)');
144
+ return Object.freeze({ roomId });
145
+ }
146
+ /** 弹幕 WebSocket 查询参数(不含 signature)。 */
147
+ pushParams(roomId) {
148
+ const nowMs = Math.floor(this.now());
149
+ // 随机游客设备 id(非账号身份、非秘密);平台把它同时用作 did 与 user_unique_id。
150
+ const deviceId = this.userUniqueId;
151
+ return new Map([
152
+ ['app_name', 'douyin_web'], ['version_code', '180800'], ['webcast_sdk_version', '1.0.14-beta.0'],
153
+ ['update_version_code', '1.0.14-beta.0'], ['compress', 'gzip'], ['device_platform', 'web'],
154
+ ['cookie_enabled', 'true'], ['screen_width', '1536'], ['screen_height', '864'],
155
+ ['browser_language', 'zh-CN'], ['browser_platform', 'Win32'], ['browser_name', 'Mozilla'],
156
+ ['browser_version', DOUYIN_LIVE_USER_AGENT.slice('Mozilla/'.length)], ['browser_online', 'true'],
157
+ ['tz_name', 'Asia/Shanghai'], ['cursor', `d-1_u-1_fh-0_t-${nowMs}_r-1`],
158
+ ['internal_ext', `internal_src:dim|wss_push_room_id:${roomId}|wss_push_did:${deviceId}|first_req_ms:${nowMs}|fetch_time:${nowMs}|seq:1|wss_info:0-${nowMs}-0-0`],
159
+ ['host', LIVE_BASE_URL], ['aid', '6383'], ['live_id', '1'], ['did_rule', '3'], ['endpoint', 'live_pc'],
160
+ ['support_wrds', '1'], ['user_unique_id', deviceId], ['im_path', '/webcast/im/fetch/'], ['identity', 'audience'],
161
+ ['need_persist_msg_count', '15'], ['insert_task_id', ''], ['live_reason', ''], ['room_id', roomId],
162
+ ['heartbeatDuration', '0'],
163
+ ]);
164
+ }
165
+ /** 弹幕 WebSocket 地址;能签名时追加 `signature`,否则不带(平台可能拒绝握手)。 */
166
+ async buildPushUrl(roomId) {
167
+ const params = this.pushParams(roomId);
168
+ const signature = await this.signature(buildSignatureStub(params));
169
+ if (signature)
170
+ params.set('signature', signature);
171
+ const query = [...params].map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join('&');
172
+ return `${DOUYIN_LIVE_PUSH_URL}?${query}`;
173
+ }
174
+ /**
175
+ * 取签名:优先注入的 signer,其次 `signerUrl`;都没有返回空串(unsigned)。
176
+ * 配了签名来源却拿不到可用签名时抛错(可见、可重试),不静默降级成无签名连接。
177
+ */
178
+ async signature(stub) {
179
+ let value;
180
+ if (this.#signer) {
181
+ const signal = this.#requestSignal();
182
+ signal.throwIfAborted();
183
+ value = await withAbort(Promise.resolve().then(() => this.#signer(stub, signal)), signal);
184
+ }
185
+ else if (this.config.signerUrl) {
186
+ const separator = this.config.signerUrl.includes('?') ? '&' : '?';
187
+ const response = await this.#get(`${this.config.signerUrl}${separator}stub=${encodeURIComponent(stub)}`);
188
+ value = parseSignerResponse(await response.text());
189
+ }
190
+ else {
191
+ return '';
192
+ }
193
+ value = String(value ?? '').trim();
194
+ if (!value)
195
+ throw new DouyinLiveApiError('Douyin live signer returned an empty signature');
196
+ if (value.length > MAX_SIGNATURE_LENGTH || /[\s&#]/.test(value)) {
197
+ throw new DouyinLiveApiError('Douyin live signer returned an invalid signature');
198
+ }
199
+ return value;
200
+ }
201
+ }
202
+ /** 让不理会 signal 的注入 signer 也受请求超时 / 取消约束。 */
203
+ function withAbort(promise, signal) {
204
+ // 超时 / 取消后 signer 仍可能 reject:始终挂上处理器,避免 unhandled rejection。
205
+ promise.catch(() => undefined);
206
+ if (signal.aborted)
207
+ return Promise.reject(signal.reason);
208
+ return new Promise((resolve, reject) => {
209
+ const onAbort = () => reject(signal.reason);
210
+ signal.addEventListener('abort', onAbort, { once: true });
211
+ promise.then((value) => { signal.removeEventListener('abort', onAbort); resolve(value); }, (error) => { signal.removeEventListener('abort', onAbort); reject(error); });
212
+ });
213
+ }
214
+ /** 直播间页内嵌 JSON(转义或未转义)里的 `roomId`。 */
215
+ export function parseRoomIdFromHtml(html) {
216
+ const patterns = [/roomId\\":\\"(\d+)\\"/, /"roomId":"(\d+)"/];
217
+ for (const pattern of patterns) {
218
+ const match = pattern.exec(html);
219
+ if (match?.[1] && !/^0+$/.test(match[1]))
220
+ return match[1];
221
+ }
222
+ return undefined;
223
+ }
224
+ function parseSignerResponse(body) {
225
+ const text = body.trim();
226
+ if (!text.startsWith('{'))
227
+ return text;
228
+ try {
229
+ const json = JSON.parse(text);
230
+ const value = json.signature ?? json['X-Bogus'] ?? json.data?.signature;
231
+ return typeof value === 'string' ? value : '';
232
+ }
233
+ catch {
234
+ return '';
235
+ }
236
+ }
@@ -0,0 +1,37 @@
1
+ import { endpointSpec, type Endpoint, type EndpointSpecification } from 'zhin.js/adapter';
2
+ import type { CapabilityId } from 'zhin.js';
3
+ import { DouyinLiveClient, type DouyinLiveFetch, type DouyinLiveSigner } from './client.js';
4
+ import { type ResolvedDouyinLiveConfig } from './protocol.js';
5
+ /** 最小 WebSocket 面(`ws` 或测试替身)。 */
6
+ export interface LiveSocket {
7
+ binaryType: string;
8
+ send(data: Uint8Array): void;
9
+ close(): void;
10
+ addEventListener(type: 'open' | 'message' | 'close' | 'error', listener: (event: {
11
+ data?: unknown;
12
+ code?: number;
13
+ }) => void): void;
14
+ }
15
+ /** 建连:需要自定义 `cookie` / `user-agent` 请求头,所以默认用 `ws` 而不是全局 WebSocket。 */
16
+ export type LiveSocketFactory = (url: string, headers: Readonly<Record<string, string>>) => LiveSocket;
17
+ export interface DouyinLiveEndpointOptions {
18
+ readonly id: CapabilityId;
19
+ readonly signal?: AbortSignal;
20
+ readonly config: ResolvedDouyinLiveConfig;
21
+ readonly fetch?: DouyinLiveFetch;
22
+ readonly socket?: LiveSocketFactory;
23
+ /** 可选签名函数(优先于 `config.signerUrl`)。 */
24
+ readonly sign?: DouyinLiveSigner;
25
+ readonly now?: () => number;
26
+ /** 固定游客设备 id(测试用)。 */
27
+ readonly userUniqueId?: string;
28
+ }
29
+ export declare class DouyinLiveEndpoint extends DouyinLiveClient implements Endpoint {
30
+ #private;
31
+ readonly [endpointSpec]: EndpointSpecification;
32
+ constructor(options: DouyinLiveEndpointOptions);
33
+ /** web_rid(会话 id,与配置 `roomId` 一致)。 */
34
+ get webRid(): string;
35
+ /** 平台内部 room_id(连上之后可用)。 */
36
+ get internalRoomId(): string | undefined;
37
+ }