@bililive-tools/douyin-recorder 1.5.3 → 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 +18 -2
- package/lib/douyin_api.d.ts +10 -1
- package/lib/douyin_api.js +346 -28
- package/lib/index.js +46 -14
- package/lib/loadBalancer/example.d.ts +2 -0
- package/lib/loadBalancer/example.js +130 -0
- package/lib/loadBalancer/loadBalancer.d.ts +71 -0
- package/lib/loadBalancer/loadBalancer.js +196 -0
- package/lib/loadBalancer/loadBalancerManager.d.ts +75 -0
- package/lib/loadBalancer/loadBalancerManager.js +127 -0
- package/lib/stream.d.ts +12 -1
- package/lib/stream.js +22 -3
- package/lib/types.d.ts +33 -0
- package/lib/types.js +1 -0
- package/lib/utils.d.ts +1 -0
- package/lib/utils.js +67 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -45,7 +45,10 @@ interface Options {
|
|
|
45
45
|
videoFormat?: "auto"; // 视频格式: "auto", "ts", "mkv" ,auto模式下, 分段使用 "ts",不分段使用 "mp4"
|
|
46
46
|
useServerTimestamp?: boolean; // 控制弹幕是否使用服务端时间戳,默认为true
|
|
47
47
|
doubleScreen?: boolean; // 是否使用双屏直播流,开启后如果是双屏直播,那么就使用拼接的流,默认为true
|
|
48
|
-
|
|
48
|
+
recorderType?: "auto" | "ffmpeg" | "mesio"; // 底层录制器,使用mesio时videoFormat参数无效
|
|
49
|
+
auth?: string; // 传递cookie
|
|
50
|
+
uid?: string; // 参数为 sec_user_uid 参数
|
|
51
|
+
api?: "web" | "webHTML" | "mobile" | "userHTML" | "balance" | "random"; // 使用不同的接口,默认使用web,具体区别见文档
|
|
49
52
|
}
|
|
50
53
|
```
|
|
51
54
|
|
|
@@ -71,10 +74,23 @@ interface Options {
|
|
|
71
74
|
import { provider } from "@bililive-tools/douyin-recorder";
|
|
72
75
|
|
|
73
76
|
const url = "https://live.douyin.com/203641303310";
|
|
74
|
-
// 同样支持解析 https://v.douyin.com/DpfoBLAXoHM/
|
|
77
|
+
// 同样支持解析 https://v.douyin.com/DpfoBLAXoHM/, https://www.douyin.com/user/MS4wLjABAAAAE2ebAEBniL_0rF0vIDV4vCpdcH5RxpYBovopAURblNs
|
|
75
78
|
const { id } = await provider.resolveChannelInfoFromURL(url);
|
|
76
79
|
```
|
|
77
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
|
+
|
|
78
94
|
# 协议
|
|
79
95
|
|
|
80
96
|
与原项目保存一致为 LGPL
|
package/lib/douyin_api.d.ts
CHANGED
|
@@ -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/
|
|
@@ -6,9 +7,10 @@
|
|
|
6
7
|
export declare function resolveShortURL(shortURL: string): Promise<string>;
|
|
7
8
|
export declare const getCookie: () => Promise<string>;
|
|
8
9
|
export declare function getRoomInfo(webRoomId: string, opts?: {
|
|
9
|
-
retryOnSpecialCode?: boolean;
|
|
10
10
|
auth?: string;
|
|
11
11
|
doubleScreen?: boolean;
|
|
12
|
+
api?: APIType;
|
|
13
|
+
uid?: string | number;
|
|
12
14
|
}): Promise<{
|
|
13
15
|
living: boolean;
|
|
14
16
|
roomId: string;
|
|
@@ -19,7 +21,14 @@ 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
|
}>;
|
|
27
|
+
/**
|
|
28
|
+
* 解析抖音号
|
|
29
|
+
* @param url
|
|
30
|
+
*/
|
|
31
|
+
export declare function parseUser(url: string): Promise<any>;
|
|
23
32
|
export interface StreamProfile {
|
|
24
33
|
desc: string;
|
|
25
34
|
key: string;
|
package/lib/douyin_api.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { URL, URLSearchParams } from "url";
|
|
1
2
|
import axios from "axios";
|
|
2
3
|
import { isEmpty } from "lodash-es";
|
|
3
|
-
import { assert } from "./utils.js";
|
|
4
|
+
import { assert, get__ac_signature } from "./utils.js";
|
|
4
5
|
import { ABogus } from "./sign.js";
|
|
5
6
|
const requester = axios.create({
|
|
6
7
|
timeout: 10e3,
|
|
@@ -19,6 +20,14 @@ const requester = axios.create({
|
|
|
19
20
|
export async function resolveShortURL(shortURL) {
|
|
20
21
|
// 获取跳转后的页面内容
|
|
21
22
|
const response = await requester.get(shortURL);
|
|
23
|
+
const redirectedURL = response.request.res.responseUrl;
|
|
24
|
+
if (redirectedURL.includes("/user/")) {
|
|
25
|
+
const secUid = new URL(redirectedURL).searchParams.get("sec_uid");
|
|
26
|
+
if (!secUid) {
|
|
27
|
+
throw new Error("无法从短链接解析出直播间ID");
|
|
28
|
+
}
|
|
29
|
+
return parseUser(`https://www.douyin.com/user/${secUid}`);
|
|
30
|
+
}
|
|
22
31
|
// 尝试从页面内容中提取webRid
|
|
23
32
|
const webRidMatch = response.data.match(/"webRid\\":\\"(\d+)\\"/);
|
|
24
33
|
if (webRidMatch) {
|
|
@@ -85,7 +94,179 @@ export const getCookie = async () => {
|
|
|
85
94
|
};
|
|
86
95
|
return cookies;
|
|
87
96
|
};
|
|
88
|
-
|
|
97
|
+
function generateNonce() {
|
|
98
|
+
// 21味随机字母数字组合
|
|
99
|
+
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
|
100
|
+
let nonce = "";
|
|
101
|
+
for (let i = 0; i < 21; i++) {
|
|
102
|
+
nonce += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
103
|
+
}
|
|
104
|
+
return nonce;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
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页面来获取房间数据
|
|
208
|
+
* @param webRoomId
|
|
209
|
+
* @param opts
|
|
210
|
+
*/
|
|
211
|
+
async function getRoomInfoByHtml(webRoomId, opts = {}) {
|
|
212
|
+
const url = `https://live.douyin.com/${webRoomId}`;
|
|
213
|
+
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";
|
|
214
|
+
const nonce = generateNonce();
|
|
215
|
+
let cookies = undefined;
|
|
216
|
+
if (opts.auth) {
|
|
217
|
+
cookies = opts.auth;
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
221
|
+
const signed = get__ac_signature(timestamp, url, nonce, ua);
|
|
222
|
+
cookies = `__ac_nonce=${nonce}; __ac_signature=${signed}; __ac_referer=__ac_blank`;
|
|
223
|
+
}
|
|
224
|
+
const res = await axios.get(url, {
|
|
225
|
+
headers: {
|
|
226
|
+
"User-Agent": ua,
|
|
227
|
+
cookie: cookies,
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
const regex = /(\{\\"state\\":.*?)\]\\n"\]\)/;
|
|
231
|
+
const match = res.data.match(regex);
|
|
232
|
+
if (!match) {
|
|
233
|
+
throw new Error("No match found in HTML");
|
|
234
|
+
}
|
|
235
|
+
let jsonStr = match[1];
|
|
236
|
+
jsonStr = jsonStr.replace(/\\"/g, '"');
|
|
237
|
+
jsonStr = jsonStr.replace(/\\"/g, '"');
|
|
238
|
+
try {
|
|
239
|
+
const data = JSON.parse(jsonStr);
|
|
240
|
+
const roomInfo = data.state.roomStore.roomInfo;
|
|
241
|
+
const streamData = data.state.streamStore.streamData;
|
|
242
|
+
return {
|
|
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",
|
|
248
|
+
room: {
|
|
249
|
+
title: roomInfo?.room?.title ?? "",
|
|
250
|
+
cover: roomInfo?.room?.cover?.url_list?.[0] ?? "",
|
|
251
|
+
id_str: roomInfo?.room?.id_str ?? "",
|
|
252
|
+
stream_url: {
|
|
253
|
+
pull_datas: roomInfo?.room?.stream_url?.pull_datas,
|
|
254
|
+
live_core_sdk_data: {
|
|
255
|
+
pull_data: {
|
|
256
|
+
options: { qualities: streamData.H264_streamData?.options?.qualities ?? [] },
|
|
257
|
+
stream_data: streamData.H264_streamData?.stream ?? {},
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
catch (e) {
|
|
265
|
+
console.error("Failed to parse JSON:", e);
|
|
266
|
+
throw e;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
async function getRoomInfoByWeb(webRoomId, opts = {}) {
|
|
89
270
|
let cookies = undefined;
|
|
90
271
|
if (opts.auth) {
|
|
91
272
|
cookies = opts.auth;
|
|
@@ -109,7 +290,6 @@ export async function getRoomInfo(webRoomId, opts = {}) {
|
|
|
109
290
|
browser_name: "Chrome",
|
|
110
291
|
browser_version: "108.0.0.0",
|
|
111
292
|
web_rid: webRoomId,
|
|
112
|
-
// enter_source:,
|
|
113
293
|
"Room-Enter-User-Login-Ab": 0,
|
|
114
294
|
is_need_double_stream: "false",
|
|
115
295
|
};
|
|
@@ -121,36 +301,116 @@ export async function getRoomInfo(webRoomId, opts = {}) {
|
|
|
121
301
|
"User-Agent": ua,
|
|
122
302
|
},
|
|
123
303
|
});
|
|
124
|
-
// 无 cookie 时 code 为 10037
|
|
125
|
-
if (res.data.status_code === 10037 && opts.retryOnSpecialCode) {
|
|
126
|
-
// resp 自动设置 cookie
|
|
127
|
-
// const cookieRes = await requester.get("https://live.douyin.com/favicon.ico");
|
|
128
|
-
// const cookies = cookieRes.headers["set-cookie"]
|
|
129
|
-
// .map((cookie) => {
|
|
130
|
-
// return cookie.split(";")[0];
|
|
131
|
-
// })
|
|
132
|
-
// .join("; ");
|
|
133
|
-
// console.log("cookies", cookies);
|
|
134
|
-
return getRoomInfo(webRoomId, {
|
|
135
|
-
retryOnSpecialCode: false,
|
|
136
|
-
doubleScreen: opts.doubleScreen,
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
304
|
assert(res.data.status_code === 0, `Unexpected resp, code ${res.data.status_code}, msg ${JSON.stringify(res.data.data)}, id ${webRoomId}, cookies: ${cookies}`);
|
|
140
305
|
const data = res.data.data;
|
|
141
|
-
const room = data
|
|
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;
|
|
344
|
+
return {
|
|
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",
|
|
350
|
+
room: {
|
|
351
|
+
title: room?.title,
|
|
352
|
+
cover: room?.cover?.url_list?.[0],
|
|
353
|
+
id_str: room?.id_str,
|
|
354
|
+
stream_url: room?.stream_url,
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
export async function getRoomInfo(webRoomId, opts = {}) {
|
|
359
|
+
let data;
|
|
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") {
|
|
372
|
+
data = await getRoomInfoByHtml(webRoomId, opts);
|
|
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
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
data = await getRoomInfoByWeb(webRoomId, opts);
|
|
382
|
+
}
|
|
383
|
+
// console.log(JSON.stringify(data, null, 2));
|
|
384
|
+
const room = data.room;
|
|
142
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
|
+
}
|
|
143
401
|
if (room?.stream_url == null) {
|
|
144
402
|
return {
|
|
145
403
|
living: false,
|
|
146
404
|
roomId: webRoomId,
|
|
147
|
-
owner: data.
|
|
148
|
-
title: room?.title ?? data.
|
|
405
|
+
owner: data.nickname,
|
|
406
|
+
title: room?.title ?? data.nickname,
|
|
149
407
|
streams: [],
|
|
150
408
|
sources: [],
|
|
151
|
-
avatar: data.
|
|
152
|
-
cover: room.cover
|
|
409
|
+
avatar: data.avatar,
|
|
410
|
+
cover: room.cover,
|
|
153
411
|
liveId: room.id_str,
|
|
412
|
+
uid: data.sec_uid,
|
|
413
|
+
api: data.api,
|
|
154
414
|
};
|
|
155
415
|
}
|
|
156
416
|
let qualities = [];
|
|
@@ -162,14 +422,16 @@ export async function getRoomInfo(webRoomId, opts = {}) {
|
|
|
162
422
|
},
|
|
163
423
|
stream_data: "",
|
|
164
424
|
};
|
|
425
|
+
// @ts-ignore
|
|
165
426
|
qualities = pull_data.options.qualities;
|
|
427
|
+
// @ts-ignore
|
|
166
428
|
stream_data = pull_data.stream_data;
|
|
167
429
|
}
|
|
168
430
|
if (!stream_data) {
|
|
169
431
|
qualities = room.stream_url.live_core_sdk_data.pull_data.options.qualities;
|
|
170
432
|
stream_data = room.stream_url.live_core_sdk_data.pull_data.stream_data;
|
|
171
433
|
}
|
|
172
|
-
const streamData = JSON.parse(stream_data).data;
|
|
434
|
+
const streamData = typeof stream_data === "string" ? JSON.parse(stream_data).data : stream_data;
|
|
173
435
|
const streams = qualities.map((info) => ({
|
|
174
436
|
desc: info.name,
|
|
175
437
|
key: info.sdk_key,
|
|
@@ -218,14 +480,70 @@ export async function getRoomInfo(webRoomId, opts = {}) {
|
|
|
218
480
|
];
|
|
219
481
|
// console.log(JSON.stringify(sources, null, 2), qualities);
|
|
220
482
|
return {
|
|
221
|
-
living: data.
|
|
483
|
+
living: data.living,
|
|
222
484
|
roomId: webRoomId,
|
|
223
|
-
owner: data.
|
|
485
|
+
owner: data.nickname,
|
|
224
486
|
title: room.title,
|
|
225
487
|
streams,
|
|
226
488
|
sources,
|
|
227
|
-
avatar: data.
|
|
228
|
-
cover: room.cover
|
|
489
|
+
avatar: data.avatar,
|
|
490
|
+
cover: room.cover,
|
|
229
491
|
liveId: room.id_str,
|
|
492
|
+
uid: data.sec_uid,
|
|
493
|
+
api: data.api,
|
|
230
494
|
};
|
|
231
495
|
}
|
|
496
|
+
let nonceCache;
|
|
497
|
+
/**
|
|
498
|
+
* 获取nonce
|
|
499
|
+
*/
|
|
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
|
+
}
|
|
506
|
+
const res = await requester.get(url);
|
|
507
|
+
if (!res.headers["set-cookie"]) {
|
|
508
|
+
throw new Error("No cookie in response");
|
|
509
|
+
}
|
|
510
|
+
const cookies = {};
|
|
511
|
+
(res.headers["set-cookie"] ?? []).forEach((cookie) => {
|
|
512
|
+
const [key, _] = cookie.split(";");
|
|
513
|
+
const [keyPart, valuePart] = key.split("=");
|
|
514
|
+
if (!keyPart || !valuePart)
|
|
515
|
+
return;
|
|
516
|
+
cookies[keyPart.trim()] = valuePart.trim();
|
|
517
|
+
});
|
|
518
|
+
const nonce = cookies["__ac_nonce"];
|
|
519
|
+
if (nonce) {
|
|
520
|
+
nonceCache = {
|
|
521
|
+
startTimestamp: now,
|
|
522
|
+
nonce: nonce,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
return nonce;
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* 解析抖音号
|
|
529
|
+
* @param url
|
|
530
|
+
*/
|
|
531
|
+
export async function parseUser(url) {
|
|
532
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
533
|
+
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";
|
|
534
|
+
const nonce = (await getNonce(url)) ?? generateNonce();
|
|
535
|
+
const signed = get__ac_signature(timestamp, url, nonce, ua);
|
|
536
|
+
const res = await requester.get(url, {
|
|
537
|
+
headers: {
|
|
538
|
+
"User-Agent": ua,
|
|
539
|
+
cookie: `__ac_nonce=${nonce}; __ac_signature=${signed}`,
|
|
540
|
+
},
|
|
541
|
+
});
|
|
542
|
+
const text = res.data;
|
|
543
|
+
const regex = /\\"uniqueId\\":\\"(.*?)\\"/;
|
|
544
|
+
const match = text.match(regex);
|
|
545
|
+
if (match && match[1]) {
|
|
546
|
+
return match[1];
|
|
547
|
+
}
|
|
548
|
+
return null;
|
|
549
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import mitt from "mitt";
|
|
3
|
-
import { defaultFromJSON, defaultToJSON, genRecorderUUID, genRecordUUID,
|
|
3
|
+
import { defaultFromJSON, defaultToJSON, genRecorderUUID, genRecordUUID, createBaseRecorder, } from "@bililive-tools/manager";
|
|
4
4
|
import { getInfo, getStream } from "./stream.js";
|
|
5
5
|
import { ensureFolderExist, singleton } from "./utils.js";
|
|
6
|
-
import { resolveShortURL } from "./douyin_api.js";
|
|
6
|
+
import { resolveShortURL, parseUser } from "./douyin_api.js";
|
|
7
7
|
import DouYinDanmaClient from "douyin-danma-listener";
|
|
8
8
|
function createRecorder(opts) {
|
|
9
9
|
// 内部实现时,应该只有 proxy 包裹的那一层会使用这个 recorder 标识符,不应该有直接通过
|
|
@@ -29,7 +29,10 @@ function createRecorder(opts) {
|
|
|
29
29
|
},
|
|
30
30
|
async getLiveInfo() {
|
|
31
31
|
const channelId = this.channelId;
|
|
32
|
-
const info = await getInfo(channelId
|
|
32
|
+
const info = await getInfo(channelId, {
|
|
33
|
+
cookie: this.auth,
|
|
34
|
+
uid: this.uid,
|
|
35
|
+
});
|
|
33
36
|
return {
|
|
34
37
|
channelId,
|
|
35
38
|
...info,
|
|
@@ -77,10 +80,19 @@ const ffmpegInputOptions = [
|
|
|
77
80
|
const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isManualStart, }) {
|
|
78
81
|
if (this.recordHandle != null)
|
|
79
82
|
return this.recordHandle;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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) {
|
|
84
96
|
this.tempStopIntervalCheck = true;
|
|
85
97
|
}
|
|
86
98
|
else {
|
|
@@ -88,7 +100,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
88
100
|
}
|
|
89
101
|
if (this.tempStopIntervalCheck)
|
|
90
102
|
return null;
|
|
91
|
-
if (!living)
|
|
103
|
+
if (!this.liveInfo.living)
|
|
92
104
|
return null;
|
|
93
105
|
let res;
|
|
94
106
|
try {
|
|
@@ -102,6 +114,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
102
114
|
if (isManualStart) {
|
|
103
115
|
strictQuality = false;
|
|
104
116
|
}
|
|
117
|
+
// TODO: 检查mobile接口处理双屏录播流
|
|
105
118
|
res = await getStream({
|
|
106
119
|
channelId: this.channelId,
|
|
107
120
|
quality: this.quality,
|
|
@@ -111,19 +124,29 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
111
124
|
cookie: this.auth,
|
|
112
125
|
formatPriorities: this.formatPriorities,
|
|
113
126
|
doubleScreen: this.doubleScreen,
|
|
127
|
+
api: this.api,
|
|
128
|
+
uid: this.uid,
|
|
114
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();
|
|
115
136
|
}
|
|
116
137
|
catch (err) {
|
|
117
|
-
this.
|
|
138
|
+
if (this.qualityRetry > 0)
|
|
139
|
+
this.qualityRetry -= 1;
|
|
140
|
+
this.state = "check-error";
|
|
118
141
|
throw err;
|
|
119
142
|
}
|
|
143
|
+
const { owner, title } = this.liveInfo;
|
|
120
144
|
this.state = "recording";
|
|
121
145
|
const { currentStream: stream, sources: availableSources, streams: availableStreams } = res;
|
|
122
146
|
this.availableStreams = availableStreams.map((s) => s.desc);
|
|
123
147
|
this.availableSources = availableSources.map((s) => s.name);
|
|
124
148
|
this.usedStream = stream.name;
|
|
125
149
|
this.usedSource = stream.source;
|
|
126
|
-
// TODO: emit update event
|
|
127
150
|
let isEnded = false;
|
|
128
151
|
let isCutting = false;
|
|
129
152
|
const onEnd = (...args) => {
|
|
@@ -136,12 +159,13 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
136
159
|
isEnded = true;
|
|
137
160
|
this.emit("DebugLog", {
|
|
138
161
|
type: "common",
|
|
139
|
-
text: `
|
|
162
|
+
text: `record end, reason: ${JSON.stringify(args, (_, v) => (v instanceof Error ? v.stack : v))}`,
|
|
140
163
|
});
|
|
141
164
|
const reason = args[0] instanceof Error ? args[0].message : String(args[0]);
|
|
142
165
|
this.recordHandle?.stop(reason);
|
|
143
166
|
};
|
|
144
|
-
|
|
167
|
+
let recorderType = this.recorderType === "mesio" ? "mesio" : "ffmpeg";
|
|
168
|
+
const recorder = createBaseRecorder(recorderType, {
|
|
145
169
|
url: stream.url,
|
|
146
170
|
outputOptions: ffmpegOutputOptions,
|
|
147
171
|
inputOptions: ffmpegInputOptions,
|
|
@@ -198,7 +222,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
198
222
|
}
|
|
199
223
|
this.emit("progress", progress);
|
|
200
224
|
});
|
|
201
|
-
const client = new DouYinDanmaClient(liveInfo
|
|
225
|
+
const client = new DouYinDanmaClient(this?.liveInfo?.liveId, {
|
|
202
226
|
cookie: this.auth,
|
|
203
227
|
});
|
|
204
228
|
client.on("chat", (msg) => {
|
|
@@ -235,7 +259,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
235
259
|
type: "give_gift",
|
|
236
260
|
timestamp: this.useServerTimestamp ? serverTimestamp : Date.now(),
|
|
237
261
|
name: msg.gift.name,
|
|
238
|
-
price:
|
|
262
|
+
price: msg.gift.diamondCount / 10 || 0,
|
|
239
263
|
count: Number(msg.totalCount ?? 1),
|
|
240
264
|
color: "#ffffff",
|
|
241
265
|
sender: {
|
|
@@ -362,6 +386,13 @@ export const provider = {
|
|
|
362
386
|
throw new Error(`解析抖音短链接失败: ${err?.message}`);
|
|
363
387
|
}
|
|
364
388
|
}
|
|
389
|
+
else if (channelURL.includes("/user/")) {
|
|
390
|
+
// 解析用户主页
|
|
391
|
+
id = await parseUser(channelURL);
|
|
392
|
+
if (!id) {
|
|
393
|
+
throw new Error(`解析抖音用户主页失败`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
365
396
|
else {
|
|
366
397
|
// 处理常规直播链接
|
|
367
398
|
id = path.basename(new URL(channelURL).pathname);
|
|
@@ -372,6 +403,7 @@ export const provider = {
|
|
|
372
403
|
title: info.title,
|
|
373
404
|
owner: info.owner,
|
|
374
405
|
avatar: info.avatar,
|
|
406
|
+
uid: info.uid,
|
|
375
407
|
};
|
|
376
408
|
},
|
|
377
409
|
createRecorder(opts) {
|