UNPKG

@bililive-tools/douyin-recorder

Version:
221 lines (220 loc) 7.18 kB
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, }; }