@bililive-tools/douyin-recorder
Version:
@bililive-tools douyin recorder implemention
221 lines (220 loc) • 7.18 kB
JavaScript
import axios from "axios";
import { isEmpty } from "lodash-es";
import { assert } from "./utils.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);
// 尝试从页面内容中提取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("; ");
cookieCache = {
startTimestamp: now,
cookies,
};
return cookies;
};
export async function getRoomInfo(webRoomId, opts = {}) {
let cookies = undefined;
if (opts.auth) {
cookies = opts.auth;
}
else {
// 抖音的 'webcast/room/web/enter' api 会需要 ttwid 的 cookie,这个 cookie 是由这个请求的响应头设置的,
// 所以在这里请求一次自动设置。
cookies = await getCookie();
}
const res = await requester.get("https://live.douyin.com/webcast/room/web/enter/", {
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,
// enter_source:,
"Room-Enter-User-Login-Ab": 0,
is_need_double_stream: "false",
},
headers: {
cookie: cookies,
},
});
// 无 cookie 时 code 为 10037
if (res.data.status_code === 10037 && opts.retryOnSpecialCode) {
// resp 自动设置 cookie
// const cookieRes = await requester.get("https://live.douyin.com/favicon.ico");
// const cookies = cookieRes.headers["set-cookie"]
// .map((cookie) => {
// return cookie.split(";")[0];
// })
// .join("; ");
// console.log("cookies", cookies);
return getRoomInfo(webRoomId, {
retryOnSpecialCode: false,
doubleScreen: opts.doubleScreen,
});
}
assert(res.data.status_code === 0, `Unexpected resp, code ${res.data.status_code}, msg ${JSON.stringify(res.data.data)}, id ${webRoomId}`);
const data = res.data.data;
const room = data.data[0];
assert(room, `No room data, id ${webRoomId}`);
if (room?.stream_url == null) {
return {
living: false,
roomId: webRoomId,
owner: data.user.nickname,
title: room?.title ?? data.user.nickname,
streams: [],
sources: [],
avatar: data.user?.avatar_thumb?.url_list?.[0],
cover: room.cover?.url_list?.[0],
liveId: room.id_str,
};
}
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: "",
};
qualities = pull_data.options.qualities;
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 = JSON.parse(stream_data).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.room_status === 0,
roomId: webRoomId,
owner: data.user.nickname,
title: room.title,
streams,
sources,
avatar: data.user.avatar_thumb.url_list[0],
cover: room.cover?.url_list?.[0],
liveId: room.id_str,
};
}