@bililive-tools/douyin-recorder
Version:
@bililive-tools douyin recorder implemention
578 lines (577 loc) • 18.9 kB
JavaScript
import { URL, URLSearchParams } from "url";
import axios from "axios";
import { isEmpty } from "lodash-es";
import { assert, get__ac_signature } from "./utils.js";
import { ABogus } from "./sign.js";
const requester = axios.create({
timeout: 10e3,
// axios 会自动读取环境变量中的 http_proxy 和 https_proxy 并应用,这会让请求发往代理的 host。
// 所以这里需要主动禁用代理功能。
proxy: false,
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36",
},
});
/**
* 从抖音短链接解析得到直播间ID
* @param shortURL 短链接,如 https://v.douyin.com/DpfoBLAXoHM/
* @returns webRoomId 直播间ID
*/
export async function resolveShortURL(shortURL) {
// 获取跳转后的页面内容
const response = await requester.get(shortURL);
const redirectedURL = response.request.res.responseUrl;
if (redirectedURL.includes("/user/")) {
const secUid = new URL(redirectedURL).searchParams.get("sec_uid");
if (!secUid) {
throw new Error("无法从短链接解析出直播间ID");
}
return parseUser(`https://www.douyin.com/user/${secUid}`);
}
// 尝试从页面内容中提取webRid
const webRidMatch = response.data.match(/"webRid\\":\\"(\d+)\\"/);
if (webRidMatch) {
return webRidMatch[1];
}
throw new Error("无法从短链接解析出直播间ID");
}
const qualityList = [
{
key: "origin",
desc: "原画",
},
{
key: "uhd",
desc: "蓝光",
},
{
key: "hd",
desc: "超清",
},
{
key: "sd",
desc: "高清",
},
{
key: "ld",
desc: "标清",
},
{
key: "ao",
desc: "音频流",
},
{
key: "real_origin",
desc: "真原画",
},
];
let cookieCache;
export const getCookie = async () => {
const now = new Date().getTime();
// 缓存6小时
if (cookieCache?.startTimestamp && now - cookieCache.startTimestamp < 6 * 60 * 60 * 1000) {
return cookieCache.cookies;
}
const res = await requester.get("https://live.douyin.com/");
if (!res.headers["set-cookie"]) {
throw new Error("No cookie in response");
}
const cookies = (res.headers["set-cookie"] ?? [])
.map((cookie) => {
return cookie.split(";")[0];
})
.join("; ");
if (!cookies.includes("ttwid")) {
// 如果不含ttwid,且已经存在含ttwid的cookie,将缓存时间直接增加1小时,复用之前的参数
if (cookieCache?.cookies) {
cookieCache.startTimestamp += 60 * 60 * 1000; // 增加1小时
return cookieCache.cookies;
}
}
cookieCache = {
startTimestamp: now,
cookies,
};
return cookies;
};
function generateNonce() {
// 21味随机字母数字组合
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let nonce = "";
for (let i = 0; i < 21; i++) {
nonce += chars.charAt(Math.floor(Math.random() * chars.length));
}
return nonce;
}
/**
* 随机选择一个可用的 API 接口
* @returns 随机选择的 API 类型
*/
export function selectRandomAPI(exclude) {
const availableAPIs = ["web", "webHTML", "mobile", "userHTML"];
if (exclude && exclude.length > 0) {
for (const api of exclude) {
const index = availableAPIs.indexOf(api);
if (index !== -1) {
availableAPIs.splice(index, 1);
}
}
}
const randomIndex = Math.floor(Math.random() * availableAPIs.length);
return availableAPIs[randomIndex];
}
/**
* 通过解析用户html页面来获取房间数据
* @param secUserId
* @param opts
*/
async function getRoomInfoByUserWeb(secUserId, opts = {}) {
const url = `https://www.douyin.com/user/${secUserId}`;
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";
let nonce = "068ea1c0100bb2c06590f";
try {
nonce = await getNonce(url);
}
catch (error) {
console.warn("获取nonce失败,使用默认值", error);
}
let cookies = undefined;
if (opts.auth) {
cookies = opts.auth;
}
else {
const timestamp = Math.floor(Date.now() / 1000);
const signed = get__ac_signature(timestamp, url, nonce, ua);
cookies = `__ac_nonce=${nonce}; __ac_signature=${signed}; __ac_referer=__ac_blank`;
}
const res = await requester.get(url, {
headers: {
"User-Agent": ua,
cookie: cookies,
},
});
if (res.data.includes("验证码")) {
throw new Error("需要验证码,请在浏览器中打开链接获取" + url);
}
if (!res.data.includes("抖音号")) {
throw new Error("userHTML页面没有正常加载" + String(res.data));
}
if (!res.data.includes("直播中")) {
return {
living: false,
nickname: "",
sec_uid: "",
avatar: "",
api: "userHTML",
room: null,
};
}
const userRegex = /(\{\\"user\\":.*?)\]\\n"\]\)/;
// fs.writeFileSync("douyin.html", res.data);
const userMatch = res.data.match(userRegex);
if (!userMatch) {
throw new Error("No match found in HTML");
}
let userJsonStr = userMatch[1];
userJsonStr = userJsonStr
.replace(/\\"/g, '"')
.replace(/\\"/g, '"')
.replace(/"\$\w+"/g, "null");
// const roomRegex = /(\{\\"common\\":.*?)"\]\)/;
// const roomMatch = res.data.match(roomRegex);
// if (!roomMatch) {
// throw new Error("No room match found in HTML");
// }
// let roomJsonStr = roomMatch[1];
// roomJsonStr = roomJsonStr
// .replace(/\\"/g, '"')
// .replace(/\\"/g, '"')
// .replace(/"\$\w+"/g, "null");
try {
// console.log(userJsonStr);
const userData = JSON.parse(userJsonStr);
// console.log(JSON.stringify(userData, null, 2));
// const roomData = JSON.parse(roomJsonStr);
// console.log(roomData);
// const roomInfo = data.state.roomStore.roomInfo;
// const streamData = data.state.streamStore.streamData;
return {
living: userData?.user?.user?.roomData?.status === 2,
nickname: userData?.user?.user?.nickname ?? "",
sec_uid: userData?.user?.user?.secUid ?? "",
avatar: userData?.user?.user?.avatar ?? "",
api: "webHTML",
room: {
title: "",
cover: "",
id_str: userData?.user?.user?.roomIdStr,
stream_url: null,
},
};
}
catch (e) {
console.error("Failed to parse JSON:", e);
throw e;
}
}
/**
* 通过解析直播html页面来获取房间数据
* @param webRoomId
* @param opts
*/
async function getRoomInfoByHtml(webRoomId, opts = {}) {
const url = `https://live.douyin.com/${webRoomId}`;
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";
const nonce = generateNonce();
let cookies = undefined;
if (opts.auth) {
cookies = opts.auth;
}
else {
const timestamp = Math.floor(Date.now() / 1000);
const signed = get__ac_signature(timestamp, url, nonce, ua);
cookies = `__ac_nonce=${nonce}; __ac_signature=${signed}; __ac_referer=__ac_blank`;
}
const res = await requester.get(url, {
headers: {
"User-Agent": ua,
cookie: cookies,
},
});
const regex = /(\{\\"state\\":.*?)\]\\n"\]\)/;
const match = res.data.match(regex);
if (!match) {
throw new Error("No match found in HTML");
}
let jsonStr = match[1];
jsonStr = jsonStr.replace(/\\"/g, '"');
jsonStr = jsonStr.replace(/\\"/g, '"');
try {
const data = JSON.parse(jsonStr);
const roomInfo = data.state.roomStore.roomInfo;
const streamData = data.state.streamStore.streamData;
return {
living: roomInfo?.room?.status === 2,
nickname: roomInfo?.anchor?.nickname ?? "",
sec_uid: roomInfo?.anchor?.sec_uid ?? "",
avatar: roomInfo?.anchor?.avatar_thumb?.url_list?.[0] ?? "",
api: "webHTML",
room: {
title: roomInfo?.room?.title ?? "",
cover: roomInfo?.room?.cover?.url_list?.[0] ?? "",
id_str: roomInfo?.room?.id_str ?? "",
stream_url: {
pull_datas: roomInfo?.room?.stream_url?.pull_datas,
live_core_sdk_data: {
pull_data: {
options: { qualities: streamData.H264_streamData?.options?.qualities ?? [] },
stream_data: streamData.H264_streamData?.stream ?? {},
},
},
},
},
};
}
catch (e) {
console.error("Failed to parse JSON:", e);
throw e;
}
}
async function getRoomInfoByWeb(webRoomId, opts = {}) {
let cookies = undefined;
if (opts.auth) {
cookies = opts.auth;
}
else {
// 抖音的 'webcast/room/web/enter' api 会需要 ttwid 的 cookie,这个 cookie 是由这个请求的响应头设置的,
// 所以在这里请求一次自动设置。
cookies = await getCookie();
}
const params = {
aid: 6383,
live_id: 1,
device_platform: "web",
language: "zh-CN",
enter_from: "web_live",
cookie_enabled: "true",
screen_width: 1920,
screen_height: 1080,
browser_language: "zh-CN",
browser_platform: "MacIntel",
browser_name: "Chrome",
browser_version: "108.0.0.0",
web_rid: webRoomId,
"Room-Enter-User-Login-Ab": 0,
is_need_double_stream: "false",
};
const abogus = new ABogus();
const [query, _, ua] = abogus.generateAbogus(new URLSearchParams(params).toString(), "");
const res = await requester.get(`https://live.douyin.com/webcast/room/web/enter/?${query}`, {
headers: {
cookie: cookies,
"User-Agent": ua,
},
});
if (res.data.status_code === 30003) {
// 直播已结束
return {
living: false,
nickname: "",
sec_uid: "",
avatar: "",
api: "web",
room: {
title: "",
cover: "",
id_str: "",
stream_url: null,
},
};
}
assert(res.data.status_code === 0, `Unexpected resp, code ${res.data.status_code}, msg ${JSON.stringify(res.data.data)}, id ${webRoomId}, cookies: ${cookies}`);
const data = res.data.data;
const room = data?.data?.[0];
return {
living: data?.room_status === 0,
nickname: data?.user?.nickname ?? "",
avatar: data?.user?.avatar_thumb?.url_list?.[0] ?? "",
sec_uid: data?.user?.sec_uid ?? "",
api: "web",
room: {
title: room?.title ?? "",
cover: room?.cover?.url_list?.[0] ?? "",
id_str: room?.id_str ?? "",
stream_url: room?.stream_url,
},
};
}
async function getRoomInfoByMobile(secUserId, opts = {}) {
if (!secUserId) {
console.error(opts);
throw new Error("Mobile API need secUserId, please set uid field");
}
if (typeof secUserId === "number") {
throw new Error("Mobile API need secUserId string, please set uid field");
}
const params = {
app_id: 1128,
live_id: 1,
verifyFp: "",
room_id: 2,
type_id: 0,
sec_user_id: secUserId,
};
const res = await requester.get(`https://webcast.amemv.com/webcast/room/reflow/info/`, {
params,
headers: {
// cookie: opts.auth,
},
});
// @ts-ignore
const room = res?.data?.data?.room;
return {
living: room?.status === 2,
nickname: room?.owner?.nickname,
sec_uid: room?.owner?.sec_uid,
avatar: room?.owner?.avatar_thumb?.url_list?.[0],
api: "mobile",
room: {
title: room?.title,
cover: room?.cover?.url_list?.[0],
id_str: room?.id_str,
stream_url: room?.stream_url,
},
};
}
export async function getRoomInfo(webRoomId, opts = {}) {
let data;
let api = opts.api ?? "web";
// 如果选择了 random,则随机选择一个可用的接口
if (api === "random") {
api = selectRandomAPI();
}
if (api === "mobile" || api === "userHTML") {
// mobile 接口需要 sec_uid 参数,老数据可能没有,实现兼容
if (!opts.uid || typeof opts.uid !== "string") {
api = "web";
}
}
if (api === "webHTML") {
data = await getRoomInfoByHtml(webRoomId, opts);
}
else if (api === "mobile") {
data = await getRoomInfoByMobile(opts.uid, opts);
}
else if (api === "userHTML") {
data = await getRoomInfoByUserWeb(opts.uid, opts);
}
else {
data = await getRoomInfoByWeb(webRoomId, opts);
}
// console.log(JSON.stringify(data, null, 2));
const room = data.room;
if (api === "userHTML") {
return {
living: data.living,
roomId: webRoomId,
owner: data.nickname,
title: room?.title ?? data.nickname,
streams: [],
sources: [],
avatar: data.avatar,
cover: room?.cover ?? "",
liveId: room?.id_str ?? "",
uid: data.sec_uid,
api: data.api,
};
}
assert(room, `No room data, id ${webRoomId}`);
if (room?.stream_url == null) {
return {
living: false,
roomId: webRoomId,
owner: data.nickname,
title: room?.title ?? data.nickname,
streams: [],
sources: [],
avatar: data.avatar,
cover: room.cover,
liveId: room.id_str,
uid: data.sec_uid,
api: data.api,
};
}
let qualities = [];
let stream_data = "";
if (opts.doubleScreen && !isEmpty(room.stream_url.pull_datas)) {
const pull_data = Object.values(room.stream_url.pull_datas)[0] ?? {
options: {
qualities: [],
},
stream_data: "",
};
// @ts-ignore
qualities = pull_data.options.qualities;
// @ts-ignore
stream_data = pull_data.stream_data;
}
if (!stream_data) {
qualities = room.stream_url.live_core_sdk_data.pull_data.options.qualities;
stream_data = room.stream_url.live_core_sdk_data.pull_data.stream_data;
}
const streamData = typeof stream_data === "string" ? JSON.parse(stream_data).data : stream_data;
const streams = qualities.map((info) => ({
desc: info.name,
key: info.sdk_key,
bitRate: info.v_bit_rate,
}));
// 转换流数据结构
const streamList = Object.entries(streamData)
.map(([quality, info]) => {
const stream = info?.main;
const name = qualityList.find((item) => item.key === quality)?.desc;
return {
quality: quality,
name: name ?? "未知",
flv: stream?.flv,
hls: stream?.hls,
};
})
.filter((stream) => stream.flv || stream.hls);
const aoStream = streamList.find((stream) => stream.quality === "ao");
if (!!aoStream) {
// 真原画流是在ao流中拿到的
streamList.push({
quality: "real_origin",
name: "真原画",
flv: (aoStream?.flv ?? "").replace("&only_audio=1", ""),
hls: (aoStream?.hls ?? "").replace("&only_audio=1", ""),
});
}
streamList.sort((a, b) => {
const aIndex = qualityList.findIndex((item) => item.key === a.quality);
const bIndex = qualityList.findIndex((item) => item.key === b.quality);
// 如果找不到对应的质量等级,将其排在最后
if (aIndex === -1)
return 1;
if (bIndex === -1)
return -1;
return aIndex - bIndex;
});
// 看起来抖音是自动切换 cdn 的,所以这里固定返回一个默认的 source。
const sources = [
{
name: "自动",
streamMap: streamData,
streams: streamList,
},
];
// console.log(JSON.stringify(sources, null, 2), qualities);
return {
living: data.living,
roomId: webRoomId,
owner: data.nickname,
title: room.title,
streams,
sources,
avatar: data.avatar,
cover: room.cover,
liveId: room.id_str,
uid: data.sec_uid,
api: data.api,
};
}
let nonceCache;
/**
* 获取nonce
*/
async function getNonce(url) {
const now = new Date().getTime();
// 缓存6小时
if (nonceCache?.startTimestamp && now - nonceCache.startTimestamp < 6 * 60 * 60 * 1000) {
return nonceCache.nonce;
}
const res = await requester.get(url);
if (!res.headers["set-cookie"]) {
throw new Error("No cookie in response");
}
const cookies = {};
(res.headers["set-cookie"] ?? []).forEach((cookie) => {
const [key, _] = cookie.split(";");
const [keyPart, valuePart] = key.split("=");
if (!keyPart || !valuePart)
return;
cookies[keyPart.trim()] = valuePart.trim();
});
const nonce = cookies["__ac_nonce"];
if (nonce) {
nonceCache = {
startTimestamp: now,
nonce: nonce,
};
}
return nonce;
}
/**
* 解析抖音号
* @param url
*/
export async function parseUser(url) {
const timestamp = Math.floor(Date.now() / 1000);
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";
const nonce = (await getNonce(url)) ?? generateNonce();
const signed = get__ac_signature(timestamp, url, nonce, ua);
const res = await requester.get(url, {
headers: {
"User-Agent": ua,
cookie: `__ac_nonce=${nonce}; __ac_signature=${signed}`,
},
});
const text = res.data;
const regex = /\\"uniqueId\\":\\"(.*?)\\"/;
const match = text.match(regex);
if (match && match[1]) {
return match[1];
}
return null;
}