@bililive-tools/douyu-recorder
Version:
bililive-tools douyu recorder implemention
118 lines (117 loc) • 4.55 kB
JavaScript
import crypto from "node:crypto";
import safeEval from "safe-eval";
import { uuid } from "./utils.js";
import queryString from "query-string";
import { requester } from "./requester.js";
/**
* 对斗鱼 getH5Play 接口的封装
*/
export async function getLiveInfo(opts) {
const sign = await getSignFn(opts.channelId, opts.rejectSignFnCache);
const did = uuid().replace(/-/g, "");
const time = Math.ceil(Date.now() / 1000);
const signedStr = String(sign(opts.channelId, did, time));
// TODO: 这里类型处理的有点问题,先用 as 顶着
// @ts-ignore
const signed = queryString.parse(signedStr);
// TODO: 以后可以试试换成 https://open.douyu.com/source/api/9 里提供的公开接口,
// 不过公开接口可能会存在最高码率的限制。
const res = await requester.post(`https://www.douyu.com/lapi/live/getH5Play/${opts.channelId}`, new URLSearchParams({
...signed,
cdn: opts.cdn ?? "",
// 相当于清晰度类型的 id,给 -1 会由后端决定,0为原画
rate: String(opts.rate ?? 0),
// 是否只录制音频
fa: opts.onlyAudio ? "1" : "0",
}));
if (res.status !== 200) {
if (res.status === 403 && res.data === "鉴权失败" && !opts.rejectSignFnCache) {
// 使用非缓存的sign函数再次签名
return getLiveInfo({ ...opts, rejectSignFnCache: true });
}
throw new Error(`Unexpected status code, ${res.status}, ${res.data}`);
}
// TODO: assert data not string
if (typeof res.data === "string")
throw new Error();
const json = res.data;
// 不存在的房间、已被封禁、未开播
if ([-3, -4, -5].includes(json.error))
return { living: false };
// 其他
if (json.error !== 0) {
// 时间戳错误,目前不确定原因,但重新获取几次 sign 函数可解决。
// TODO: 这里与 getSignFn 隐式的耦合了
if (json.error === -9)
delete signCaches[opts.channelId];
throw new Error("Unexpected error code, " + json.error);
}
const streamUrl = `${json.data.rtmp_url}/${json.data.rtmp_live}`;
let cdn = json.data.rtmp_cdn;
let onlyAudio = false;
try {
const url = new URL(streamUrl);
cdn = url.searchParams.get("fcdn") ?? "";
if (url.searchParams.get("only-audio") == "1") {
onlyAudio = true;
}
}
catch (error) {
console.warn("解析 rtmp_url 失败", error);
}
return {
living: true,
sources: json.data.cdnsWithName,
streams: json.data.multirates,
isSupportRateSwitch: json.data.rateSwitch === 1,
isOriginalStream: json.data.rateSwitch !== 1,
currentStream: {
onlyAudio,
source: cdn,
name: json.data.rateSwitch !== 1
? "原画"
: (json.data.multirates.find(({ rate }) => rate === json.data.rate)?.name ?? "未知"),
rate: json.data.rate,
url: streamUrl,
},
};
}
// 斗鱼为了判断是否是浏览器环境,会在 sign 过程中去验证一些 window / document 上的函数
// 是否是 native 的,这里利用 proxy 来模拟。
const disguisedNativeMethods = new Proxy({}, {
get: function () {
return "function () { [native code] }";
},
});
const signCaches = {};
async function getSignFn(address, rejectCache) {
if (!rejectCache && Object.hasOwn(signCaches, address)) {
// 有缓存, 直接使用
return signCaches[address];
}
const res = await requester.get("https://www.douyu.com/swf_api/homeH5Enc?rids=" + address);
const json = res.data;
if (json.error !== 0)
throw new Error("Unexpected error code, " + json.error);
const code = json.data && json.data["room" + address];
if (!code)
throw new Error("Unexpected result with homeH5Enc, " + JSON.stringify(json));
const sign = safeEval(`(function func(a,b,c){${code};return ub98484234(a,b,c)})`, {
CryptoJS: {
MD5: (str) => {
return crypto.createHash("md5").update(str).digest("hex");
},
},
window: disguisedNativeMethods,
document: disguisedNativeMethods,
});
signCaches[address] = sign;
return sign;
}
/**
* 获取直播间相关信息
*/
export async function getRoomInfo(roomId) {
const response = await requester.get(`https://www.douyu.com/betard/${roomId}`);
return response.data;
}