@bililive-tools/douyin-recorder 1.6.0 → 1.7.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
@@ -46,8 +46,9 @@ interface Options {
46
46
  useServerTimestamp?: boolean; // 控制弹幕是否使用服务端时间戳,默认为true
47
47
  doubleScreen?: boolean; // 是否使用双屏直播流,开启后如果是双屏直播,那么就使用拼接的流,默认为true
48
48
  recorderType?: "auto" | "ffmpeg" | "mesio"; // 底层录制器,使用mesio时videoFormat参数无效
49
- auth?: string; // 传递cookie
50
- api?: "web" | "webHTML"; // 使用不同的接口
49
+ auth?: string; // 传递cookie
50
+ uid?: string; // 参数为 sec_user_uid 参数
51
+ api?: "web" | "webHTML" | "mobile" | "userHTML" | "balance" | "random"; // 使用不同的接口,默认使用web,具体区别见文档
51
52
  }
52
53
  ```
53
54
 
@@ -77,6 +78,19 @@ const url = "https://live.douyin.com/203641303310";
77
78
  const { id } = await provider.resolveChannelInfoFromURL(url);
78
79
  ```
79
80
 
81
+ ## 不同请求接口的区别
82
+
83
+ `mobile` 及 `userHTML` 必须传入 `uid` 参数
84
+
85
+ | 描述 | 备注 |
86
+ | ---------------- | ---------------------------------------- |
87
+ | web直播间接口 | 效果不错 |
88
+ | mobile直播间接口 | 易风控,无验证码,海外IP可能无法使用 |
89
+ | 直播间web解析 | 易风控,有验证码,单个接口1M流量 |
90
+ | 用户web解析 | 不易风控,海外IP无法使用,单个接口1M流量 |
91
+ | 负载均衡 | 使用负载均衡算法来分摊防止风控 |
92
+ | 随机 | 从几个接口里挑一个 |
93
+
80
94
  # 协议
81
95
 
82
96
  与原项目保存一致为 LGPL
@@ -1,3 +1,4 @@
1
+ import type { APIType, RealAPIType } from "./types.js";
1
2
  /**
2
3
  * 从抖音短链接解析得到直播间ID
3
4
  * @param shortURL 短链接,如 https://v.douyin.com/DpfoBLAXoHM/
@@ -8,7 +9,8 @@ export declare const getCookie: () => Promise<string>;
8
9
  export declare function getRoomInfo(webRoomId: string, opts?: {
9
10
  auth?: string;
10
11
  doubleScreen?: boolean;
11
- api?: "web" | "webHTML";
12
+ api?: APIType;
13
+ uid?: string | number;
12
14
  }): Promise<{
13
15
  living: boolean;
14
16
  roomId: string;
@@ -19,6 +21,8 @@ export declare function getRoomInfo(webRoomId: string, opts?: {
19
21
  avatar: string;
20
22
  cover: string;
21
23
  liveId: string;
24
+ uid: string;
25
+ api: RealAPIType;
22
26
  }>;
23
27
  /**
24
28
  * 解析抖音号
package/lib/douyin_api.js CHANGED
@@ -104,7 +104,107 @@ function generateNonce() {
104
104
  return nonce;
105
105
  }
106
106
  /**
107
- * 通过解析html页面来获取房间数据
107
+ * 随机选择一个可用的 API 接口
108
+ * @returns 随机选择的 API 类型
109
+ */
110
+ function selectRandomAPI() {
111
+ const availableAPIs = ["web", "webHTML", "mobile", "userHTML"];
112
+ const randomIndex = Math.floor(Math.random() * availableAPIs.length);
113
+ return availableAPIs[randomIndex];
114
+ }
115
+ /**
116
+ * 通过解析直播html页面来获取房间数据
117
+ * @param secUserId
118
+ * @param opts
119
+ */
120
+ async function getRoomInfoByUserWeb(secUserId, opts = {}) {
121
+ const url = `https://www.douyin.com/user/${secUserId}`;
122
+ const ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 Edg/133.0.0.0";
123
+ let nonce = "068ea1c0100bb2c06590f";
124
+ try {
125
+ nonce = await getNonce(url);
126
+ }
127
+ catch (error) {
128
+ console.warn("获取nonce失败,使用默认值", error);
129
+ }
130
+ let cookies = undefined;
131
+ if (opts.auth) {
132
+ cookies = opts.auth;
133
+ }
134
+ else {
135
+ const timestamp = Math.floor(Date.now() / 1000);
136
+ const signed = get__ac_signature(timestamp, url, nonce, ua);
137
+ cookies = `__ac_nonce=${nonce}; __ac_signature=${signed}; __ac_referer=__ac_blank`;
138
+ }
139
+ const res = await axios.get(url, {
140
+ headers: {
141
+ "User-Agent": ua,
142
+ cookie: cookies,
143
+ },
144
+ });
145
+ if (res.data.includes("验证码")) {
146
+ throw new Error("需要验证码,请在浏览器中打开链接获取" + url);
147
+ }
148
+ if (!res.data.includes("直播中")) {
149
+ return {
150
+ living: false,
151
+ nickname: "",
152
+ sec_uid: "",
153
+ avatar: "",
154
+ api: "webHTML",
155
+ room: null,
156
+ };
157
+ }
158
+ const userRegex = /(\{\\"user\\":.*?)\]\\n"\]\)/;
159
+ // fs.writeFileSync("douyin.html", res.data);
160
+ const userMatch = res.data.match(userRegex);
161
+ if (!userMatch) {
162
+ throw new Error("No match found in HTML");
163
+ }
164
+ let userJsonStr = userMatch[1];
165
+ userJsonStr = userJsonStr
166
+ .replace(/\\"/g, '"')
167
+ .replace(/\\"/g, '"')
168
+ .replace(/"\$\w+"/g, "null");
169
+ // const roomRegex = /(\{\\"common\\":.*?)"\]\)/;
170
+ // const roomMatch = res.data.match(roomRegex);
171
+ // if (!roomMatch) {
172
+ // throw new Error("No room match found in HTML");
173
+ // }
174
+ // let roomJsonStr = roomMatch[1];
175
+ // roomJsonStr = roomJsonStr
176
+ // .replace(/\\"/g, '"')
177
+ // .replace(/\\"/g, '"')
178
+ // .replace(/"\$\w+"/g, "null");
179
+ try {
180
+ // console.log(userJsonStr);
181
+ const userData = JSON.parse(userJsonStr);
182
+ // console.log(JSON.stringify(userData, null, 2));
183
+ // const roomData = JSON.parse(roomJsonStr);
184
+ // console.log(roomData);
185
+ // const roomInfo = data.state.roomStore.roomInfo;
186
+ // const streamData = data.state.streamStore.streamData;
187
+ return {
188
+ living: userData?.user?.user?.roomData?.status === 2,
189
+ nickname: userData?.user?.user?.nickname ?? "",
190
+ sec_uid: userData?.user?.user?.secUid ?? "",
191
+ avatar: userData?.user?.user?.avatar ?? "",
192
+ api: "webHTML",
193
+ room: {
194
+ title: "",
195
+ cover: "",
196
+ id_str: userData?.user?.user?.roomIdStr,
197
+ stream_url: null,
198
+ },
199
+ };
200
+ }
201
+ catch (e) {
202
+ console.error("Failed to parse JSON:", e);
203
+ throw e;
204
+ }
205
+ }
206
+ /**
207
+ * 通过解析用户html页面来获取房间数据
108
208
  * @param webRoomId
109
209
  * @param opts
110
210
  */
@@ -140,15 +240,17 @@ async function getRoomInfoByHtml(webRoomId, opts = {}) {
140
240
  const roomInfo = data.state.roomStore.roomInfo;
141
241
  const streamData = data.state.streamStore.streamData;
142
242
  return {
143
- living: roomInfo.room.status === 2,
144
- nickname: roomInfo.anchor.nickname,
145
- avatar: roomInfo.anchor?.avatar_thumb?.url_list?.[0],
243
+ living: roomInfo?.room?.status === 2,
244
+ nickname: roomInfo?.anchor?.nickname ?? "",
245
+ sec_uid: roomInfo?.anchor?.sec_uid ?? "",
246
+ avatar: roomInfo?.anchor?.avatar_thumb?.url_list?.[0] ?? "",
247
+ api: "userHTML",
146
248
  room: {
147
- title: roomInfo.room.title,
148
- cover: roomInfo.room.cover?.url_list?.[0],
149
- id_str: roomInfo.room.id_str,
249
+ title: roomInfo?.room?.title ?? "",
250
+ cover: roomInfo?.room?.cover?.url_list?.[0] ?? "",
251
+ id_str: roomInfo?.room?.id_str ?? "",
150
252
  stream_url: {
151
- pull_datas: roomInfo.room?.stream_url?.pull_datas,
253
+ pull_datas: roomInfo?.room?.stream_url?.pull_datas,
152
254
  live_core_sdk_data: {
153
255
  pull_data: {
154
256
  options: { qualities: streamData.H264_streamData?.options?.qualities ?? [] },
@@ -201,30 +303,101 @@ async function getRoomInfoByWeb(webRoomId, opts = {}) {
201
303
  });
202
304
  assert(res.data.status_code === 0, `Unexpected resp, code ${res.data.status_code}, msg ${JSON.stringify(res.data.data)}, id ${webRoomId}, cookies: ${cookies}`);
203
305
  const data = res.data.data;
204
- const room = data.data[0];
205
- assert(room, `No room data, id ${webRoomId}`);
306
+ const room = data?.data?.[0];
307
+ return {
308
+ living: data?.room_status === 0,
309
+ nickname: data?.user?.nickname ?? "",
310
+ avatar: data?.user?.avatar_thumb?.url_list?.[0] ?? "",
311
+ sec_uid: data?.user?.sec_uid ?? "",
312
+ api: "web",
313
+ room: {
314
+ title: room?.title ?? "",
315
+ cover: room?.cover?.url_list?.[0] ?? "",
316
+ id_str: room?.id_str ?? "",
317
+ stream_url: room?.stream_url,
318
+ },
319
+ };
320
+ }
321
+ async function getRoomInfoByMobile(secUserId, opts = {}) {
322
+ if (!secUserId) {
323
+ throw new Error("Mobile API need secUserId, please set uid field");
324
+ }
325
+ if (typeof secUserId === "number") {
326
+ throw new Error("Mobile API need secUserId string, please set uid field");
327
+ }
328
+ const params = {
329
+ app_id: 1128,
330
+ live_id: 1,
331
+ verifyFp: "",
332
+ room_id: 2,
333
+ type_id: 0,
334
+ sec_user_id: secUserId,
335
+ };
336
+ const res = await requester.get(`https://webcast.amemv.com/webcast/room/reflow/info/`, {
337
+ params,
338
+ headers: {
339
+ cookie: opts.auth,
340
+ },
341
+ });
342
+ // @ts-ignore
343
+ const room = res?.data?.data?.room;
206
344
  return {
207
- living: data.room_status === 0,
208
- nickname: data.user.nickname,
209
- avatar: data?.user?.avatar_thumb?.url_list?.[0],
345
+ living: room?.status === 2,
346
+ nickname: room?.owner?.nickname,
347
+ sec_uid: room?.owner?.sec_uid,
348
+ avatar: room?.owner?.avatar_thumb?.url_list?.[0],
349
+ api: "mobile",
210
350
  room: {
211
- title: room.title,
212
- cover: room.cover?.url_list?.[0],
213
- id_str: room.id_str,
214
- stream_url: room.stream_url,
351
+ title: room?.title,
352
+ cover: room?.cover?.url_list?.[0],
353
+ id_str: room?.id_str,
354
+ stream_url: room?.stream_url,
215
355
  },
216
356
  };
217
357
  }
218
358
  export async function getRoomInfo(webRoomId, opts = {}) {
219
359
  let data;
220
- if (opts.api === "webHTML") {
360
+ let api = opts.api ?? "web";
361
+ // 如果选择了 random,则随机选择一个可用的接口
362
+ if (api === "random") {
363
+ api = selectRandomAPI();
364
+ }
365
+ if (api === "mobile" || api === "userHTML") {
366
+ // mobile 接口需要 sec_uid 参数,老数据可能没有,实现兼容
367
+ if (!opts.uid || typeof opts.uid !== "string") {
368
+ api = "web";
369
+ }
370
+ }
371
+ if (api === "webHTML") {
221
372
  data = await getRoomInfoByHtml(webRoomId, opts);
222
373
  }
374
+ else if (api === "mobile") {
375
+ data = await getRoomInfoByMobile(opts.uid, opts);
376
+ }
377
+ else if (api === "userHTML") {
378
+ data = await getRoomInfoByUserWeb(opts.uid, opts);
379
+ }
223
380
  else {
224
381
  data = await getRoomInfoByWeb(webRoomId, opts);
225
382
  }
383
+ // console.log(JSON.stringify(data, null, 2));
226
384
  const room = data.room;
227
385
  assert(room, `No room data, id ${webRoomId}`);
386
+ if (api === "userHTML") {
387
+ return {
388
+ living: data.living,
389
+ roomId: webRoomId,
390
+ owner: data.nickname,
391
+ title: room?.title ?? data.nickname,
392
+ streams: [],
393
+ sources: [],
394
+ avatar: data.avatar,
395
+ cover: room.cover,
396
+ liveId: room.id_str,
397
+ uid: data.sec_uid,
398
+ api: data.api,
399
+ };
400
+ }
228
401
  if (room?.stream_url == null) {
229
402
  return {
230
403
  living: false,
@@ -236,6 +409,8 @@ export async function getRoomInfo(webRoomId, opts = {}) {
236
409
  avatar: data.avatar,
237
410
  cover: room.cover,
238
411
  liveId: room.id_str,
412
+ uid: data.sec_uid,
413
+ api: data.api,
239
414
  };
240
415
  }
241
416
  let qualities = [];
@@ -314,12 +489,20 @@ export async function getRoomInfo(webRoomId, opts = {}) {
314
489
  avatar: data.avatar,
315
490
  cover: room.cover,
316
491
  liveId: room.id_str,
492
+ uid: data.sec_uid,
493
+ api: data.api,
317
494
  };
318
495
  }
496
+ let nonceCache;
319
497
  /**
320
498
  * 获取nonce
321
499
  */
322
500
  async function getNonce(url) {
501
+ const now = new Date().getTime();
502
+ // 缓存6小时
503
+ if (nonceCache?.startTimestamp && now - nonceCache.startTimestamp < 6 * 60 * 60 * 1000) {
504
+ return nonceCache.nonce;
505
+ }
323
506
  const res = await requester.get(url);
324
507
  if (!res.headers["set-cookie"]) {
325
508
  throw new Error("No cookie in response");
@@ -332,7 +515,14 @@ async function getNonce(url) {
332
515
  return;
333
516
  cookies[keyPart.trim()] = valuePart.trim();
334
517
  });
335
- return cookies["__ac_nonce"];
518
+ const nonce = cookies["__ac_nonce"];
519
+ if (nonce) {
520
+ nonceCache = {
521
+ startTimestamp: now,
522
+ nonce: nonce,
523
+ };
524
+ }
525
+ return nonce;
336
526
  }
337
527
  /**
338
528
  * 解析抖音号
package/lib/index.js CHANGED
@@ -31,7 +31,7 @@ function createRecorder(opts) {
31
31
  const channelId = this.channelId;
32
32
  const info = await getInfo(channelId, {
33
33
  cookie: this.auth,
34
- api: this.api,
34
+ uid: this.uid,
35
35
  });
36
36
  return {
37
37
  channelId,
@@ -80,13 +80,19 @@ const ffmpegInputOptions = [
80
80
  const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isManualStart, }) {
81
81
  if (this.recordHandle != null)
82
82
  return this.recordHandle;
83
- const liveInfo = await getInfo(this.channelId, {
84
- cookie: this.auth,
85
- api: this.api,
86
- });
87
- const { living, owner, title } = liveInfo;
88
- this.liveInfo = liveInfo;
89
- if (liveInfo.liveId === banLiveId) {
83
+ try {
84
+ const liveInfo = await getInfo(this.channelId, {
85
+ cookie: this.auth,
86
+ api: this.api,
87
+ uid: this.uid,
88
+ });
89
+ this.liveInfo = liveInfo;
90
+ }
91
+ catch (error) {
92
+ this.state = "check-error";
93
+ throw error;
94
+ }
95
+ if (this.liveInfo.liveId && this.liveInfo.liveId === banLiveId) {
90
96
  this.tempStopIntervalCheck = true;
91
97
  }
92
98
  else {
@@ -94,7 +100,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
94
100
  }
95
101
  if (this.tempStopIntervalCheck)
96
102
  return null;
97
- if (!living)
103
+ if (!this.liveInfo.living)
98
104
  return null;
99
105
  let res;
100
106
  try {
@@ -108,6 +114,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
108
114
  if (isManualStart) {
109
115
  strictQuality = false;
110
116
  }
117
+ // TODO: 检查mobile接口处理双屏录播流
111
118
  res = await getStream({
112
119
  channelId: this.channelId,
113
120
  quality: this.quality,
@@ -118,14 +125,22 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
118
125
  formatPriorities: this.formatPriorities,
119
126
  doubleScreen: this.doubleScreen,
120
127
  api: this.api,
128
+ uid: this.uid,
121
129
  });
130
+ this.liveInfo.owner = res.owner;
131
+ this.liveInfo.title = res.title;
132
+ this.liveInfo.cover = res.cover;
133
+ this.liveInfo.liveId = res.liveId;
134
+ this.liveInfo.avatar = res.avatar;
135
+ this.liveInfo.startTime = new Date();
122
136
  }
123
137
  catch (err) {
124
138
  if (this.qualityRetry > 0)
125
139
  this.qualityRetry -= 1;
126
- this.state = "idle";
140
+ this.state = "check-error";
127
141
  throw err;
128
142
  }
143
+ const { owner, title } = this.liveInfo;
129
144
  this.state = "recording";
130
145
  const { currentStream: stream, sources: availableSources, streams: availableStreams } = res;
131
146
  this.availableStreams = availableStreams.map((s) => s.desc);
@@ -150,7 +165,6 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
150
165
  this.recordHandle?.stop(reason);
151
166
  };
152
167
  let recorderType = this.recorderType === "mesio" ? "mesio" : "ffmpeg";
153
- // TODO:测试只录制音频,hls以及fmp4
154
168
  const recorder = createBaseRecorder(recorderType, {
155
169
  url: stream.url,
156
170
  outputOptions: ffmpegOutputOptions,
@@ -208,7 +222,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
208
222
  }
209
223
  this.emit("progress", progress);
210
224
  });
211
- const client = new DouYinDanmaClient(liveInfo.liveId, {
225
+ const client = new DouYinDanmaClient(this?.liveInfo?.liveId, {
212
226
  cookie: this.auth,
213
227
  });
214
228
  client.on("chat", (msg) => {
@@ -389,6 +403,7 @@ export const provider = {
389
403
  title: info.title,
390
404
  owner: info.owner,
391
405
  avatar: info.avatar,
406
+ uid: info.uid,
392
407
  };
393
408
  },
394
409
  createRecorder(opts) {
@@ -0,0 +1,2 @@
1
+ declare function runExamples(): Promise<void>;
2
+ export { runExamples };
@@ -0,0 +1,130 @@
1
+ /**
2
+ * 抖音录播器负载均衡使用示例
3
+ */
4
+ import { getInfo } from "../stream.js";
5
+ import { loadBalancer } from "./loadBalancerManager.js";
6
+ async function basicExample() {
7
+ console.log("=== 基本使用示例 ===");
8
+ try {
9
+ // 使用负载均衡模式获取房间信息
10
+ const roomInfo = await getInfo("测试房间ID", {
11
+ api: "balance", // 关键:使用balance模式启用负载均衡
12
+ cookie: "your-cookie-here",
13
+ });
14
+ console.log("获取到房间信息:", {
15
+ title: roomInfo.title,
16
+ owner: roomInfo.owner,
17
+ living: roomInfo.living,
18
+ });
19
+ }
20
+ catch (error) {
21
+ console.error("获取房间信息失败:", error.message);
22
+ }
23
+ }
24
+ async function managementExample() {
25
+ console.log("\n=== 管理功能示例 ===");
26
+ // 1. 查看当前状态
27
+ console.log("1. 当前负载均衡器状态:");
28
+ loadBalancer.printStatus();
29
+ // 2. 检查API健康状态
30
+ console.log("\n2. API健康状态检查:");
31
+ const apis = ["web", "webHTML", "mobile", "userHTML"];
32
+ apis.forEach((api) => {
33
+ const isHealthy = loadBalancer.isAPIHealthy(api);
34
+ console.log(`${api}: ${isHealthy ? "✅ 健康" : "❌ 不健康"}`);
35
+ });
36
+ // 3. 获取推荐API
37
+ const recommendedAPI = loadBalancer.getRecommendedAPI();
38
+ console.log(`\n3. 推荐使用的API: ${recommendedAPI}`);
39
+ // 4. 配置调整示例
40
+ console.log("\n4. 配置调整示例:");
41
+ console.log("原始配置:", loadBalancer.getConfig());
42
+ // 调整配置:更保守的失败策略
43
+ loadBalancer.updateConfig({
44
+ maxFailures: 2, // 减少到2次失败就禁用
45
+ blockDuration: 120000, // 禁用2分钟
46
+ });
47
+ console.log("调整后配置:", loadBalancer.getConfig());
48
+ // 5. API权重调整示例
49
+ console.log("\n5. API权重调整示例:");
50
+ console.log("调整前状态:");
51
+ console.table(loadBalancer.getStatus());
52
+ // 提高mobile接口的优先级和权重
53
+ loadBalancer.updateAPIConfig("mobile", {
54
+ priority: 1, // 最高优先级
55
+ weight: 5, // 最高权重
56
+ });
57
+ console.log("调整mobile接口权重后:");
58
+ console.table(loadBalancer.getStatus());
59
+ }
60
+ async function errorHandlingExample() {
61
+ console.log("\n=== 错误处理示例 ===");
62
+ // 模拟检查被禁用的API
63
+ const blockedAPIs = loadBalancer.getBlockedAPIs();
64
+ if (blockedAPIs.length > 0) {
65
+ console.log(`发现 ${blockedAPIs.length} 个被禁用的API:`, blockedAPIs);
66
+ // 可以选择重置这些API
67
+ console.log("重置被禁用的API...");
68
+ blockedAPIs.forEach((api) => {
69
+ loadBalancer.resetAPI(api);
70
+ });
71
+ console.log("重置完成,当前健康的API:", loadBalancer.getHealthyAPIs());
72
+ }
73
+ else {
74
+ console.log("所有API都处于健康状态 ✅");
75
+ }
76
+ }
77
+ async function monitoringExample() {
78
+ console.log("\n=== 监控示例 ===");
79
+ // 模拟监控函数
80
+ function checkSystemHealth() {
81
+ const status = loadBalancer.getStatus();
82
+ const healthyCount = status.filter((s) => !s.isBlocked).length;
83
+ const totalCount = status.length;
84
+ console.log(`系统健康状态: ${healthyCount}/${totalCount} 个API可用`);
85
+ if (healthyCount < totalCount / 2) {
86
+ console.warn("⚠️ 警告:超过一半的API不可用!");
87
+ loadBalancer.printStatus();
88
+ // 可以触发告警或自动恢复逻辑
89
+ console.log("执行自动恢复...");
90
+ loadBalancer.resetAll();
91
+ }
92
+ // 显示详细状态
93
+ status.forEach((s) => {
94
+ if (s.isBlocked) {
95
+ const retryTime = new Date(s.nextRetryTime).toLocaleString();
96
+ console.log(`❌ ${s.api}: 被禁用,下次重试时间: ${retryTime}`);
97
+ }
98
+ else if (s.failureCount > 0) {
99
+ console.log(`⚠️ ${s.api}: 失败 ${s.failureCount} 次,仍可用`);
100
+ }
101
+ else {
102
+ console.log(`✅ ${s.api}: 健康`);
103
+ }
104
+ });
105
+ }
106
+ // 执行健康检查
107
+ checkSystemHealth();
108
+ // 在实际应用中,可以设置定时器
109
+ // setInterval(checkSystemHealth, 60000); // 每分钟检查一次
110
+ }
111
+ // 主函数
112
+ async function runExamples() {
113
+ try {
114
+ await basicExample();
115
+ await managementExample();
116
+ await errorHandlingExample();
117
+ await monitoringExample();
118
+ console.log("\n=== 示例执行完成 ===");
119
+ console.log("\n最终状态:");
120
+ loadBalancer.printStatus();
121
+ }
122
+ catch (error) {
123
+ console.error("示例执行失败:", error);
124
+ }
125
+ }
126
+ // 如果直接运行此文件
127
+ if (import.meta.url === `file://${process.argv[1]}`) {
128
+ runExamples();
129
+ }
130
+ export { runExamples };
@@ -0,0 +1,71 @@
1
+ import type { APIType, APIEndpoint, APIEndpointStatus, LoadBalancerConfig } from "../types.js";
2
+ /**
3
+ * API 负载均衡器类
4
+ * 实现多个 API 接口的负载均衡调用,具备失败重试和禁用机制
5
+ */
6
+ export declare class APILoadBalancer {
7
+ private endpoints;
8
+ private config;
9
+ constructor(config?: Partial<LoadBalancerConfig>);
10
+ /**
11
+ * 初始化 API 端点配置
12
+ */
13
+ private initializeEndpoints;
14
+ /**
15
+ * 获取下一个可用的 API 端点
16
+ * 使用加权轮询算法,优先选择权重高且未被禁用的端点
17
+ */
18
+ private getNextEndpoint;
19
+ /**
20
+ * 记录 API 调用失败
21
+ */
22
+ private recordFailure;
23
+ /**
24
+ * 记录 API 调用成功
25
+ */
26
+ private recordSuccess;
27
+ /**
28
+ * 使用负载均衡策略调用 getRoomInfo
29
+ */
30
+ callWithLoadBalance(webRoomId: string, opts?: {
31
+ auth?: string;
32
+ doubleScreen?: boolean;
33
+ uid?: string | number;
34
+ }): Promise<{
35
+ living: boolean;
36
+ roomId: string;
37
+ owner: string;
38
+ title: string;
39
+ streams: any[];
40
+ sources: any[];
41
+ avatar: string;
42
+ cover: string;
43
+ liveId: string;
44
+ uid: string;
45
+ }>;
46
+ /**
47
+ * 获取当前端点状态(用于调试和监控)
48
+ */
49
+ getEndpointStatus(): APIEndpointStatus[];
50
+ /**
51
+ * 手动重置某个端点的状态
52
+ */
53
+ resetEndpoint(apiType: APIType): void;
54
+ /**
55
+ * 重置所有端点状态
56
+ */
57
+ resetAllEndpoints(): void;
58
+ /**
59
+ * 更新端点配置
60
+ */
61
+ updateEndpointConfig(apiType: APIType, updates: Partial<APIEndpoint>): void;
62
+ /**
63
+ * 获取负载均衡器配置
64
+ */
65
+ getConfig(): LoadBalancerConfig;
66
+ /**
67
+ * 更新负载均衡器配置
68
+ */
69
+ updateConfig(updates: Partial<LoadBalancerConfig>): void;
70
+ }
71
+ export declare const globalLoadBalancer: APILoadBalancer;
@@ -0,0 +1,196 @@
1
+ import { getRoomInfo } from "../douyin_api.js";
2
+ /**
3
+ * API 负载均衡器类
4
+ * 实现多个 API 接口的负载均衡调用,具备失败重试和禁用机制
5
+ */
6
+ export class APILoadBalancer {
7
+ endpoints = [];
8
+ config;
9
+ constructor(config) {
10
+ this.config = {
11
+ maxFailures: 3, // 连续失败3次后禁用
12
+ blockDuration: 3 * 60 * 1000, // 禁用3分钟
13
+ retryMultiplier: 1.5, // 重试时间倍增
14
+ healthCheckInterval: 30 * 1000, // 30秒健康检查
15
+ ...config,
16
+ };
17
+ // 初始化可用的 API 端点
18
+ this.initializeEndpoints();
19
+ }
20
+ /**
21
+ * 初始化 API 端点配置
22
+ */
23
+ initializeEndpoints() {
24
+ const defaultEndpoints = [
25
+ { name: "web", priority: 2, weight: 1 },
26
+ { name: "webHTML", priority: 1, weight: 1 },
27
+ { name: "mobile", priority: 6, weight: 1 },
28
+ { name: "userHTML", priority: 4, weight: 1 },
29
+ ];
30
+ this.endpoints = defaultEndpoints.map((endpoint) => ({
31
+ endpoint,
32
+ failureCount: 0,
33
+ lastFailureTime: 0,
34
+ isBlocked: false,
35
+ nextRetryTime: 0,
36
+ }));
37
+ }
38
+ /**
39
+ * 获取下一个可用的 API 端点
40
+ * 使用加权轮询算法,优先选择权重高且未被禁用的端点
41
+ */
42
+ getNextEndpoint() {
43
+ const now = Date.now();
44
+ // 清理过期的禁用状态
45
+ this.endpoints.forEach((status) => {
46
+ if (status.isBlocked && now >= status.nextRetryTime) {
47
+ status.isBlocked = false;
48
+ status.failureCount = Math.max(0, status.failureCount - 1); // 部分恢复
49
+ }
50
+ });
51
+ // 获取可用的端点
52
+ const availableEndpoints = this.endpoints.filter((status) => !status.isBlocked);
53
+ if (availableEndpoints.length === 0) {
54
+ return null; // 所有端点都被禁用
55
+ }
56
+ // 按优先级和权重排序
57
+ availableEndpoints.sort((a, b) => {
58
+ if (a.endpoint.priority !== b.endpoint.priority) {
59
+ return a.endpoint.priority - b.endpoint.priority; // 优先级越小越好
60
+ }
61
+ return b.endpoint.weight - a.endpoint.weight; // 权重越大越好
62
+ });
63
+ // 使用加权随机选择
64
+ const totalWeight = availableEndpoints.reduce((sum, status) => sum + status.endpoint.weight, 0);
65
+ const random = Math.random() * totalWeight;
66
+ let currentWeight = 0;
67
+ for (const status of availableEndpoints) {
68
+ currentWeight += status.endpoint.weight;
69
+ if (random <= currentWeight) {
70
+ return status;
71
+ }
72
+ }
73
+ // 如果加权选择失败,返回第一个可用的
74
+ return availableEndpoints[0];
75
+ }
76
+ /**
77
+ * 记录 API 调用失败
78
+ */
79
+ recordFailure(apiType, error) {
80
+ const status = this.endpoints.find((s) => s.endpoint.name === apiType);
81
+ if (!status)
82
+ return;
83
+ status.failureCount++;
84
+ status.lastFailureTime = Date.now();
85
+ // 如果失败次数超过阈值,禁用该端点
86
+ if (status.failureCount >= this.config.maxFailures) {
87
+ status.isBlocked = true;
88
+ const blockDuration = this.config.blockDuration *
89
+ Math.pow(this.config.retryMultiplier, status.failureCount - this.config.maxFailures);
90
+ status.nextRetryTime = Date.now() + blockDuration;
91
+ console.warn(`API ${apiType} has been blocked due to ${status.failureCount} failures. Next retry at: ${(new Date(status.nextRetryTime).toISOString(), error.message)}`);
92
+ }
93
+ }
94
+ /**
95
+ * 记录 API 调用成功
96
+ */
97
+ recordSuccess(apiType) {
98
+ const status = this.endpoints.find((s) => s.endpoint.name === apiType);
99
+ if (!status)
100
+ return;
101
+ // 成功调用后,减少失败计数
102
+ if (status.failureCount > 0) {
103
+ status.failureCount = Math.max(0, status.failureCount - 1);
104
+ }
105
+ // 如果之前被禁用,现在可以恢复
106
+ if (status.isBlocked && status.failureCount === 0) {
107
+ status.isBlocked = false;
108
+ status.nextRetryTime = 0;
109
+ }
110
+ }
111
+ /**
112
+ * 使用负载均衡策略调用 getRoomInfo
113
+ */
114
+ async callWithLoadBalance(webRoomId, opts = {}) {
115
+ const maxAttempts = this.endpoints.length;
116
+ let lastError = null;
117
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
118
+ const endpointStatus = this.getNextEndpoint();
119
+ if (!endpointStatus) {
120
+ throw new Error("所有 API 端点都不可用,请稍后重试");
121
+ }
122
+ const apiType = endpointStatus.endpoint.name;
123
+ try {
124
+ const result = await getRoomInfo(webRoomId, {
125
+ ...opts,
126
+ api: apiType,
127
+ });
128
+ // 调用成功,记录成功状态
129
+ this.recordSuccess(apiType);
130
+ return result;
131
+ }
132
+ catch (error) {
133
+ lastError = error;
134
+ this.recordFailure(apiType, lastError);
135
+ console.warn(`API ${apiType} failed (attempt ${attempt + 1}/${maxAttempts}):`, lastError.message);
136
+ // 如果这是最后一次尝试,或者没有更多可用端点,则抛出错误
137
+ if (attempt === maxAttempts - 1) {
138
+ break;
139
+ }
140
+ }
141
+ }
142
+ throw new Error(`所有 API 调用都失败了。最后一个错误: ${lastError?.message || "未知错误"}`);
143
+ }
144
+ /**
145
+ * 获取当前端点状态(用于调试和监控)
146
+ */
147
+ getEndpointStatus() {
148
+ return this.endpoints.map((status) => ({ ...status }));
149
+ }
150
+ /**
151
+ * 手动重置某个端点的状态
152
+ */
153
+ resetEndpoint(apiType) {
154
+ const status = this.endpoints.find((s) => s.endpoint.name === apiType);
155
+ if (status) {
156
+ status.failureCount = 0;
157
+ status.lastFailureTime = 0;
158
+ status.isBlocked = false;
159
+ status.nextRetryTime = 0;
160
+ }
161
+ }
162
+ /**
163
+ * 重置所有端点状态
164
+ */
165
+ resetAllEndpoints() {
166
+ this.endpoints.forEach((status) => {
167
+ status.failureCount = 0;
168
+ status.lastFailureTime = 0;
169
+ status.isBlocked = false;
170
+ status.nextRetryTime = 0;
171
+ });
172
+ }
173
+ /**
174
+ * 更新端点配置
175
+ */
176
+ updateEndpointConfig(apiType, updates) {
177
+ const status = this.endpoints.find((s) => s.endpoint.name === apiType);
178
+ if (status) {
179
+ Object.assign(status.endpoint, updates);
180
+ }
181
+ }
182
+ /**
183
+ * 获取负载均衡器配置
184
+ */
185
+ getConfig() {
186
+ return { ...this.config };
187
+ }
188
+ /**
189
+ * 更新负载均衡器配置
190
+ */
191
+ updateConfig(updates) {
192
+ Object.assign(this.config, updates);
193
+ }
194
+ }
195
+ // 创建全局单例实例
196
+ export const globalLoadBalancer = new APILoadBalancer();
@@ -0,0 +1,75 @@
1
+ import type { APIType, LoadBalancerConfig } from "../types.js";
2
+ /**
3
+ * 负载均衡器管理工具类
4
+ * 提供简化的接口来管理和配置负载均衡器
5
+ */
6
+ export declare class LoadBalancerManager {
7
+ /**
8
+ * 获取所有端点的当前状态
9
+ */
10
+ static getStatus(): {
11
+ api: APIType;
12
+ priority: number;
13
+ weight: number;
14
+ failureCount: number;
15
+ isBlocked: boolean;
16
+ lastFailureTime: string;
17
+ nextRetryTime: string;
18
+ }[];
19
+ /**
20
+ * 重置指定 API 的状态
21
+ */
22
+ static resetAPI(apiType: APIType): void;
23
+ /**
24
+ * 重置所有 API 的状态
25
+ */
26
+ static resetAll(): void;
27
+ /**
28
+ * 更新 API 端点的配置
29
+ */
30
+ static updateAPIConfig(apiType: APIType, config: {
31
+ priority?: number;
32
+ weight?: number;
33
+ }): void;
34
+ /**
35
+ * 获取负载均衡器配置
36
+ */
37
+ static getConfig(): LoadBalancerConfig;
38
+ /**
39
+ * 更新负载均衡器配置
40
+ */
41
+ static updateConfig(config: Partial<LoadBalancerConfig>): void;
42
+ /**
43
+ * 获取健康的(未被禁用的)API 列表
44
+ */
45
+ static getHealthyAPIs(): APIType[];
46
+ /**
47
+ * 获取被禁用的 API 列表
48
+ */
49
+ static getBlockedAPIs(): APIType[];
50
+ /**
51
+ * 检查特定 API 是否可用
52
+ */
53
+ static isAPIHealthy(apiType: APIType): boolean;
54
+ /**
55
+ * 获取推荐使用的 API(基于当前状态和权重)
56
+ */
57
+ static getRecommendedAPI(): APIType | null;
58
+ /**
59
+ * 打印当前负载均衡器状态(用于调试)
60
+ */
61
+ static printStatus(): void;
62
+ }
63
+ export declare const loadBalancer: {
64
+ getStatus: typeof LoadBalancerManager.getStatus;
65
+ resetAPI: typeof LoadBalancerManager.resetAPI;
66
+ resetAll: typeof LoadBalancerManager.resetAll;
67
+ updateAPIConfig: typeof LoadBalancerManager.updateAPIConfig;
68
+ getConfig: typeof LoadBalancerManager.getConfig;
69
+ updateConfig: typeof LoadBalancerManager.updateConfig;
70
+ getHealthyAPIs: typeof LoadBalancerManager.getHealthyAPIs;
71
+ getBlockedAPIs: typeof LoadBalancerManager.getBlockedAPIs;
72
+ isAPIHealthy: typeof LoadBalancerManager.isAPIHealthy;
73
+ getRecommendedAPI: typeof LoadBalancerManager.getRecommendedAPI;
74
+ printStatus: typeof LoadBalancerManager.printStatus;
75
+ };
@@ -0,0 +1,127 @@
1
+ import { globalLoadBalancer } from "./loadBalancer.js";
2
+ /**
3
+ * 负载均衡器管理工具类
4
+ * 提供简化的接口来管理和配置负载均衡器
5
+ */
6
+ export class LoadBalancerManager {
7
+ /**
8
+ * 获取所有端点的当前状态
9
+ */
10
+ static getStatus() {
11
+ return globalLoadBalancer.getEndpointStatus().map((status) => ({
12
+ api: status.endpoint.name,
13
+ priority: status.endpoint.priority,
14
+ weight: status.endpoint.weight,
15
+ failureCount: status.failureCount,
16
+ isBlocked: status.isBlocked,
17
+ lastFailureTime: status.lastFailureTime
18
+ ? new Date(status.lastFailureTime).toISOString()
19
+ : null,
20
+ nextRetryTime: status.nextRetryTime ? new Date(status.nextRetryTime).toISOString() : null,
21
+ }));
22
+ }
23
+ /**
24
+ * 重置指定 API 的状态
25
+ */
26
+ static resetAPI(apiType) {
27
+ if (apiType === "balance") {
28
+ throw new Error("Cannot reset 'balance' type. Use resetAll() instead.");
29
+ }
30
+ globalLoadBalancer.resetEndpoint(apiType);
31
+ }
32
+ /**
33
+ * 重置所有 API 的状态
34
+ */
35
+ static resetAll() {
36
+ globalLoadBalancer.resetAllEndpoints();
37
+ }
38
+ /**
39
+ * 更新 API 端点的配置
40
+ */
41
+ static updateAPIConfig(apiType, config) {
42
+ if (apiType === "balance") {
43
+ throw new Error("Cannot update 'balance' type configuration.");
44
+ }
45
+ globalLoadBalancer.updateEndpointConfig(apiType, config);
46
+ }
47
+ /**
48
+ * 获取负载均衡器配置
49
+ */
50
+ static getConfig() {
51
+ return globalLoadBalancer.getConfig();
52
+ }
53
+ /**
54
+ * 更新负载均衡器配置
55
+ */
56
+ static updateConfig(config) {
57
+ globalLoadBalancer.updateConfig(config);
58
+ }
59
+ /**
60
+ * 获取健康的(未被禁用的)API 列表
61
+ */
62
+ static getHealthyAPIs() {
63
+ return globalLoadBalancer
64
+ .getEndpointStatus()
65
+ .filter((status) => !status.isBlocked)
66
+ .map((status) => status.endpoint.name);
67
+ }
68
+ /**
69
+ * 获取被禁用的 API 列表
70
+ */
71
+ static getBlockedAPIs() {
72
+ return globalLoadBalancer
73
+ .getEndpointStatus()
74
+ .filter((status) => status.isBlocked)
75
+ .map((status) => status.endpoint.name);
76
+ }
77
+ /**
78
+ * 检查特定 API 是否可用
79
+ */
80
+ static isAPIHealthy(apiType) {
81
+ if (apiType === "balance")
82
+ return true; // balance 类型总是可用的
83
+ const status = globalLoadBalancer.getEndpointStatus().find((s) => s.endpoint.name === apiType);
84
+ return status ? !status.isBlocked : false;
85
+ }
86
+ /**
87
+ * 获取推荐使用的 API(基于当前状态和权重)
88
+ */
89
+ static getRecommendedAPI() {
90
+ const healthyEndpoints = globalLoadBalancer
91
+ .getEndpointStatus()
92
+ .filter((status) => !status.isBlocked)
93
+ .sort((a, b) => {
94
+ if (a.endpoint.priority !== b.endpoint.priority) {
95
+ return a.endpoint.priority - b.endpoint.priority;
96
+ }
97
+ return b.endpoint.weight - a.endpoint.weight;
98
+ });
99
+ return healthyEndpoints.length > 0 ? healthyEndpoints[0].endpoint.name : null;
100
+ }
101
+ /**
102
+ * 打印当前负载均衡器状态(用于调试)
103
+ */
104
+ static printStatus() {
105
+ console.log("=== 负载均衡器状态 ===");
106
+ console.log("配置:", LoadBalancerManager.getConfig());
107
+ console.log("端点状态:");
108
+ console.table(LoadBalancerManager.getStatus());
109
+ console.log("健康的 APIs:", LoadBalancerManager.getHealthyAPIs());
110
+ console.log("被禁用的 APIs:", LoadBalancerManager.getBlockedAPIs());
111
+ console.log("推荐 API:", LoadBalancerManager.getRecommendedAPI());
112
+ }
113
+ }
114
+ // 暴露简化的管理函数
115
+ export const loadBalancer = {
116
+ getStatus: LoadBalancerManager.getStatus,
117
+ resetAPI: LoadBalancerManager.resetAPI,
118
+ resetAll: LoadBalancerManager.resetAll,
119
+ updateAPIConfig: LoadBalancerManager.updateAPIConfig,
120
+ getConfig: LoadBalancerManager.getConfig,
121
+ updateConfig: LoadBalancerManager.updateConfig,
122
+ getHealthyAPIs: LoadBalancerManager.getHealthyAPIs,
123
+ getBlockedAPIs: LoadBalancerManager.getBlockedAPIs,
124
+ isAPIHealthy: LoadBalancerManager.isAPIHealthy,
125
+ getRecommendedAPI: LoadBalancerManager.getRecommendedAPI,
126
+ printStatus: LoadBalancerManager.printStatus,
127
+ };
package/lib/stream.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import type { Recorder } from "@bililive-tools/manager";
2
+ import type { APIType, RealAPIType } from "./types.js";
2
3
  export declare function getInfo(channelId: string, opts?: {
3
4
  cookie?: string;
4
- api?: "web" | "webHTML";
5
+ api?: APIType;
6
+ uid?: string | number;
5
7
  }): Promise<{
6
8
  living: boolean;
7
9
  owner: string;
@@ -11,6 +13,8 @@ export declare function getInfo(channelId: string, opts?: {
11
13
  cover: string;
12
14
  startTime: Date;
13
15
  liveId: string;
16
+ uid: string;
17
+ api: RealAPIType;
14
18
  }>;
15
19
  export declare function getStream(opts: Pick<Recorder, "channelId" | "quality" | "streamPriorities" | "sourcePriorities"> & {
16
20
  rejectCache?: boolean;
@@ -18,7 +22,8 @@ export declare function getStream(opts: Pick<Recorder, "channelId" | "quality" |
18
22
  cookie?: string;
19
23
  formatPriorities?: Array<"flv" | "hls">;
20
24
  doubleScreen?: boolean;
21
- api?: "web" | "webHTML";
25
+ api?: APIType;
26
+ uid?: string | number;
22
27
  }): Promise<{
23
28
  currentStream: {
24
29
  name: string;
@@ -34,4 +39,6 @@ export declare function getStream(opts: Pick<Recorder, "channelId" | "quality" |
34
39
  avatar: string;
35
40
  cover: string;
36
41
  liveId: string;
42
+ uid: string;
43
+ api: RealAPIType;
37
44
  }>;
package/lib/stream.js CHANGED
@@ -1,6 +1,17 @@
1
1
  import { getRoomInfo } from "./douyin_api.js";
2
+ import { globalLoadBalancer } from "./loadBalancer/loadBalancer.js";
2
3
  export async function getInfo(channelId, opts) {
3
- const info = await getRoomInfo(channelId, opts ?? {});
4
+ let info;
5
+ // 如果使用 balance 模式,使用负载均衡器
6
+ if (opts?.api === "balance") {
7
+ info = await globalLoadBalancer.callWithLoadBalance(channelId, {
8
+ auth: opts.cookie,
9
+ uid: opts.uid,
10
+ });
11
+ }
12
+ else {
13
+ info = await getRoomInfo(channelId, opts ?? {});
14
+ }
4
15
  return {
5
16
  living: info.living,
6
17
  owner: info.owner,
@@ -10,13 +21,21 @@ export async function getInfo(channelId, opts) {
10
21
  cover: info.cover,
11
22
  startTime: new Date(),
12
23
  liveId: info.liveId,
24
+ uid: info.uid,
25
+ api: info.api,
13
26
  };
14
27
  }
15
28
  export async function getStream(opts) {
29
+ let api = opts.api ?? "web";
30
+ if (api === "userHTML") {
31
+ // userHTML 接口只能用于状态检测
32
+ api = "web";
33
+ }
16
34
  const info = await getRoomInfo(opts.channelId, {
17
35
  doubleScreen: opts.doubleScreen ?? true,
18
36
  auth: opts.cookie,
19
- api: opts.api ?? "web",
37
+ api: api,
38
+ uid: opts.uid,
20
39
  });
21
40
  if (!info.living) {
22
41
  throw new Error("It must be called getStream when living");
package/lib/types.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ export type APIType = "web" | "webHTML" | "mobile" | "userHTML" | "balance" | "random";
2
+ export type RealAPIType = Exclude<APIType, "balance" | "random">;
3
+ export interface APIEndpoint {
4
+ name: APIType;
5
+ priority: number;
6
+ weight: number;
7
+ }
8
+ export interface APIEndpointStatus {
9
+ endpoint: APIEndpoint;
10
+ failureCount: number;
11
+ lastFailureTime: number;
12
+ isBlocked: boolean;
13
+ nextRetryTime: number;
14
+ }
15
+ export interface LoadBalancerConfig {
16
+ maxFailures: number;
17
+ blockDuration: number;
18
+ retryMultiplier: number;
19
+ healthCheckInterval: number;
20
+ }
21
+ export interface RoomInfo {
22
+ living: boolean;
23
+ nickname: string;
24
+ sec_uid: string;
25
+ avatar: string;
26
+ api: RealAPIType;
27
+ room: {
28
+ title: string;
29
+ cover: string;
30
+ id_str: string;
31
+ stream_url: any | null;
32
+ } | null;
33
+ }
package/lib/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bililive-tools/douyin-recorder",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "@bililive-tools douyin recorder implemention",
5
5
  "main": "./lib/index.js",
6
6
  "type": "module",
@@ -38,8 +38,8 @@
38
38
  "lodash-es": "^4.17.21",
39
39
  "mitt": "^3.0.1",
40
40
  "sm-crypto": "^0.3.13",
41
- "douyin-danma-listener": "0.2.0",
42
- "@bililive-tools/manager": "^1.6.0"
41
+ "@bililive-tools/manager": "^1.6.1",
42
+ "douyin-danma-listener": "0.2.0"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/node": "*"