增加芒果tv/哔哩哔哩/优酷弹幕下载

This commit is contained in:
huangxd- committed 2025-09-14 09:15:53 +08:00
1 parent 5210aca3d3
commit dde5e82b88
3 files changed
+739 -22

No files matched your search

+8 -7
View File
@@ -32,7 +32,7 @@
```bash
npm start
```
服务器将在 `http://{ip}:9321` 运行。
服务器将在 `http://{ip}:9321` 运行,默认token是`87654321`。
或者使用下面的命令
```bash
# 启动
@@ -43,10 +43,11 @@
4. **测试 API**:
使用 Postman 或 curl 测试:
- `GET http://{ip}:9321/api/v2/search/anime?keyword=Anime%20A`
- `GET http://{ip}:9321/api/v2/bangumi/1`
- `GET http://{ip}:9321/api/v2/comment/1?withRelated=true&chConvert=1`
- `GET http://{ip}:9321/api/logs`
- `GET http://{ip}:9321/87654321`
- `GET http://{ip}:9321/87654321/api/v2/search/anime?keyword=Anime%20A`
- `GET http://{ip}:9321/87654321/api/v2/bangumi/1`
- `GET http://{ip}:9321/87654321/api/v2/comment/1?withRelated=true&chConvert=1`
- `GET http://{ip}:9321/87654321/api/logs`
## 使用 Docker 运行
1. **构建 Docker 镜像**:
@@ -61,7 +62,7 @@
- 使用`-e TOKEN=your_token_here`设置`TOKEN`环境变量,覆盖Dockerfile中的默认值。
3. **测试 API**:
使用 `http://{ip}:9321` 访问上述 API 接口。
使用 `http://{ip}:9321/{TOKEN}` 访问上述 API 接口。
## Docker 一键启动
1. **拉取镜像**:
@@ -76,7 +77,7 @@
- 使用`-e TOKEN=your_token_here`设置`TOKEN`环境变量。
3. **测试 API**:
使用 `http://{ip}:9321` 访问上述 API 接口。
使用 `http://{ip}:9321/{TOKEN}` 访问上述 API 接口。
## 部署到 Vercel
+695 -11
View File
@@ -404,6 +404,196 @@ function buildQueryString(params) {
return queryString;
}
function time_to_second(time) {
const parts = time.split(":").map(Number);
let seconds = 0;
if (parts.length === 3) {
seconds = parts[0] * 3600 + parts[1] * 60 + parts[2];
} else if (parts.length === 2) {
seconds = parts[0] * 60 + parts[1];
} else {
seconds = parts[0];
}
return seconds;
}
// md5.js 本地版本
function md5(message) {
// --- UTF-8 转换 ---
function toUtf8(str) {
let utf8 = "";
for (let i = 0; i < str.length; i++) {
const charCode = str.charCodeAt(i);
if (charCode < 0x80) {
utf8 += String.fromCharCode(charCode);
} else if (charCode < 0x800) {
utf8 += String.fromCharCode(0xc0 | (charCode >> 6));
utf8 += String.fromCharCode(0x80 | (charCode & 0x3f));
} else {
utf8 += String.fromCharCode(0xe0 | (charCode >> 12));
utf8 += String.fromCharCode(0x80 | ((charCode >> 6) & 0x3f));
utf8 += String.fromCharCode(0x80 | (charCode & 0x3f));
}
}
return utf8;
}
message = toUtf8(message);
function rotateLeft(lValue, iShiftBits) {
return (lValue << iShiftBits) | (lValue >>> (32 - iShiftBits));
}
function addUnsigned(lX, lY) {
const lX4 = lX & 0x40000000;
const lY4 = lY & 0x40000000;
const lX8 = lX & 0x80000000;
const lY8 = lY & 0x80000000;
const lResult = (lX & 0x3FFFFFFF) + (lY & 0x3FFFFFFF);
if (lX4 & lY4) return lResult ^ 0x80000000 ^ lX8 ^ lY8;
if (lX4 | lY4) {
if (lResult & 0x40000000) return lResult ^ 0xC0000000 ^ lX8 ^ lY8;
else return lResult ^ 0x40000000 ^ lX8 ^ lY8;
} else return lResult ^ lX8 ^ lY8;
}
function F(x, y, z) { return (x & y) | (~x & z); }
function G(x, y, z) { return (x & z) | (y & ~z); }
function H(x, y, z) { return x ^ y ^ z; }
function I(x, y, z) { return y ^ (x | ~z); }
function FF(a, b, c, d, x, s, ac) {
a = addUnsigned(a, addUnsigned(addUnsigned(F(b, c, d), x), ac));
return addUnsigned(rotateLeft(a, s), b);
}
function GG(a, b, c, d, x, s, ac) {
a = addUnsigned(a, addUnsigned(addUnsigned(G(b, c, d), x), ac));
return addUnsigned(rotateLeft(a, s), b);
}
function HH(a, b, c, d, x, s, ac) {
a = addUnsigned(a, addUnsigned(addUnsigned(H(b, c, d), x), ac));
return addUnsigned(rotateLeft(a, s), b);
}
function II(a, b, c, d, x, s, ac) {
a = addUnsigned(a, addUnsigned(addUnsigned(I(b, c, d), x), ac));
return addUnsigned(rotateLeft(a, s), b);
}
function convertToWordArray(str) {
const lMessageLength = str.length;
const lNumberOfWords = (((lMessageLength + 8) >>> 6) + 1) * 16;
const lWordArray = new Array(lNumberOfWords).fill(0);
for (let i = 0; i < lMessageLength; i++) {
lWordArray[i >> 2] |= str.charCodeAt(i) << ((i % 4) * 8);
}
lWordArray[lMessageLength >> 2] |= 0x80 << ((lMessageLength % 4) * 8);
lWordArray[lNumberOfWords - 2] = lMessageLength * 8;
return lWordArray;
}
function wordToHex(lValue) {
let wordToHexValue = "";
for (let lCount = 0; lCount <= 3; lCount++) {
const lByte = (lValue >>> (lCount * 8)) & 255;
let wordToHexValueTemp = "0" + lByte.toString(16);
wordToHexValue += wordToHexValueTemp.substr(wordToHexValueTemp.length - 2, 2);
}
return wordToHexValue;
}
let x = convertToWordArray(message);
let a = 0x67452301;
let b = 0xEFCDAB89;
let c = 0x98BADCFE;
let d = 0x10325476;
for (let k = 0; k < x.length; k += 16) {
let AA = a, BB = b, CC = c, DD = d;
// --- Round 1 ---
a = FF(a, b, c, d, x[k + 0], 7, 0xD76AA478);
d = FF(d, a, b, c, x[k + 1], 12, 0xE8C7B756);
c = FF(c, d, a, b, x[k + 2], 17, 0x242070DB);
b = FF(b, c, d, a, x[k + 3], 22, 0xC1BDCEEE);
a = FF(a, b, c, d, x[k + 4], 7, 0xF57C0FAF);
d = FF(d, a, b, c, x[k + 5], 12, 0x4787C62A);
c = FF(c, d, a, b, x[k + 6], 17, 0xA8304613);
b = FF(b, c, d, a, x[k + 7], 22, 0xFD469501);
a = FF(a, b, c, d, x[k + 8], 7, 0x698098D8);
d = FF(d, a, b, c, x[k + 9], 12, 0x8B44F7AF);
c = FF(c, d, a, b, x[k + 10], 17, 0xFFFF5BB1);
b = FF(b, c, d, a, x[k + 11], 22, 0x895CD7BE);
a = FF(a, b, c, d, x[k + 12], 7, 0x6B901122);
d = FF(d, a, b, c, x[k + 13], 12, 0xFD987193);
c = FF(c, d, a, b, x[k + 14], 17, 0xA679438E);
b = FF(b, c, d, a, x[k + 15], 22, 0x49B40821);
// --- Round 2 ---
a = GG(a, b, c, d, x[k + 1], 5, 0xF61E2562);
d = GG(d, a, b, c, x[k + 6], 9, 0xC040B340);
c = GG(c, d, a, b, x[k + 11], 14, 0x265E5A51);
b = GG(b, c, d, a, x[k + 0], 20, 0xE9B6C7AA);
a = GG(a, b, c, d, x[k + 5], 5, 0xD62F105D);
d = GG(d, a, b, c, x[k + 10], 9, 0x02441453);
c = GG(c, d, a, b, x[k + 15], 14, 0xD8A1E681);
b = GG(b, c, d, a, x[k + 4], 20, 0xE7D3FBC8);
a = GG(a, b, c, d, x[k + 9], 5, 0x21E1CDE6);
d = GG(d, a, b, c, x[k + 14], 9, 0xC33707D6);
c = GG(c, d, a, b, x[k + 3], 14, 0xF4D50D87);
b = GG(b, c, d, a, x[k + 8], 20, 0x455A14ED);
a = GG(a, b, c, d, x[k + 13], 5, 0xA9E3E905);
d = GG(d, a, b, c, x[k + 2], 9, 0xFCEFA3F8);
c = GG(c, d, a, b, x[k + 7], 14, 0x676F02D9);
b = GG(b, c, d, a, x[k + 12], 20, 0x8D2A4C8A);
// --- Round 3 ---
a = HH(a, b, c, d, x[k + 5], 4, 0xFFFA3942);
d = HH(d, a, b, c, x[k + 8], 11, 0x8771F681);
c = HH(c, d, a, b, x[k + 11], 16, 0x6D9D6122);
b = HH(b, c, d, a, x[k + 14], 23, 0xFDE5380C);
a = HH(a, b, c, d, x[k + 1], 4, 0xA4BEEA44);
d = HH(d, a, b, c, x[k + 4], 11, 0x4BDECFA9);
c = HH(c, d, a, b, x[k + 7], 16, 0xF6BB4B60);
b = HH(b, c, d, a, x[k + 10], 23, 0xBEBFBC70);
a = HH(a, b, c, d, x[k + 13], 4, 0x289B7EC6);
d = HH(d, a, b, c, x[k + 0], 11, 0xEAA127FA);
c = HH(c, d, a, b, x[k + 3], 16, 0xD4EF3085);
b = HH(b, c, d, a, x[k + 6], 23, 0x04881D05);
a = HH(a, b, c, d, x[k + 9], 4, 0xD9D4D039);
d = HH(d, a, b, c, x[k + 12], 11, 0xE6DB99E5);
c = HH(c, d, a, b, x[k + 15], 16, 0x1FA27CF8);
b = HH(b, c, d, a, x[k + 2], 23, 0xC4AC5665);
// --- Round 4 ---
a = II(a, b, c, d, x[k + 0], 6, 0xF4292244);
d = II(d, a, b, c, x[k + 7], 10, 0x432AFF97);
c = II(c, d, a, b, x[k + 14], 15, 0xAB9423A7);
b = II(b, c, d, a, x[k + 5], 21, 0xFC93A039);
a = II(a, b, c, d, x[k + 12], 6, 0x655B59C3);
d = II(d, a, b, c, x[k + 3], 10, 0x8F0CCC92);
c = II(c, d, a, b, x[k + 10], 15, 0xFFEFF47D);
b = II(b, c, d, a, x[k + 1], 21, 0x85845DD1);
a = II(a, b, c, d, x[k + 8], 6, 0x6FA87E4F);
d = II(d, a, b, c, x[k + 15], 10, 0xFE2CE6E0);
c = II(c, d, a, b, x[k + 6], 15, 0xA3014314);
b = II(b, c, d, a, x[k + 13], 21, 0x4E0811A1);
a = II(a, b, c, d, x[k + 4], 6, 0xF7537E82);
d = II(d, a, b, c, x[k + 11], 10, 0xBD3AF235);
c = II(c, d, a, b, x[k + 2], 15, 0x2AD7D2BB);
b = II(b, c, d, a, x[k + 9], 21, 0xEB86D391);
a = addUnsigned(a, AA);
b = addUnsigned(b, BB);
c = addUnsigned(c, CC);
d = addUnsigned(d, DD);
}
return (wordToHex(a) + wordToHex(b) + wordToHex(c) + wordToHex(d)).toLowerCase();
}
// =====================
// 获取腾讯弹幕
// =====================
@@ -667,6 +857,493 @@ async function fetchIqiyi(inputUrl) {
return convertToDanmakuXML(contents);
}
// =====================
// 获取芒果TV弹幕
// =====================
async function fetchMangoTV(inputUrl) {
log("log", "开始从本地请求芒果TV弹幕...", inputUrl);
// 弹幕和视频信息 API 基础地址
const api_video_info = "https://pcweb.api.mgtv.com/video/info";
const api_danmaku = "https://galaxy.bz.mgtv.com/rdbarrage";
// 解析 URL 获取 cid 和 vid
// 手动解析 URL(没有 URL 对象的情况下)
const regex = /^(https?:\/\/[^\/]+)(\/[^?#]*)/;
const match = inputUrl.match(regex);
let path;
if (match) {
path = match[2].split('/').filter(Boolean); // 分割路径并去掉空字符串
log("log", path);
} else {
log("error", 'Invalid URL');
return null;
}
const cid = path[path.length - 2];
const vid = path[path.length - 1].split(".")[0];
log("log", "cid:", cid, "vid:", vid);
// 获取页面标题和视频时长
let res;
try {
const videoInfoUrl = `${api_video_info}?cid=${cid}&vid=${vid}`;
res = await httpGet(videoInfoUrl, {
headers: {
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
},
});
} catch (error) {
log("log", "请求视频信息失败:", error);
return null;
}
const data = typeof res.data === "string" ? JSON.parse(res.data) : res.data;
const title = data.data.info.videoName;
const time = data.data.info.time;
log("log", "标题:", title);
// 计算弹幕分段请求
const step = 60 * 1000; // 每60秒一个分段
const end_time = time_to_second(time) * 1000; // 将视频时长转换为毫秒
const promises = [];
for (let i = 0; i < end_time; i += step) {
const danmakuUrl = `${api_danmaku}?vid=${vid}&cid=${cid}&time=${i}`;
promises.push(
httpGet(danmakuUrl, {
headers: {
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
},
})
);
}
log("log", "弹幕分段数量:", promises.length);
// 解析弹幕数据
let contents = [];
try {
const results = await Promise.allSettled(promises);
const datas = results
.filter(result => result.status === "fulfilled")
.map(result => result.value.data);
for (const data of datas) {
const dataJson = typeof data === "string" ? JSON.parse(data) : data;
if (!dataJson.data.items) continue;
for (const item of dataJson.data.items) {
const content = {
timepoint: 0, // 弹幕发送时间(秒)
ct: 1, // 弹幕类型,1-3 为滚动弹幕、4 为底部、5 为顶端、6 为逆向、7 为精确、8 为高级
size: 25, //字体大小,25 为中,18 为小
color: 16777215, //弹幕颜色,RGB 颜色转为十进制后的值,16777215 为白色
unixtime: Math.floor(Date.now() / 1000), //Unix 时间戳格式
uid: 0, //发送人的 id
content: "",
};
content.timepoint = item.time / 1000;
content.content = item.content;
content.uid = item.uid;
contents.push(content);
}
}
} catch (error) {
log("error", "解析弹幕数据失败:", error);
return null;
}
printFirst200Chars(contents);
// 返回结果
return convertToDanmakuXML(contents);
}
// =====================
// 获取bilibili弹幕
// =====================
async function fetchBilibili(inputUrl) {
log("log", "开始从本地请求B站弹幕...", inputUrl);
// 弹幕和视频信息 API 基础地址
const api_video_info = "https://api.bilibili.com/x/web-interface/view";
const api_epid_cid = "https://api.bilibili.com/pgc/view/web/season";
// 解析 URL 获取必要参数
// 手动解析 URL(没有 URL 对象的情况下)
const regex = /^(https?:\/\/[^\/]+)(\/[^?#]*)/;
const match = inputUrl.match(regex);
let path;
if (match) {
path = match[2].split('/').filter(Boolean); // 分割路径并去掉空字符串
path.unshift("");
log("log", path);
} else {
log("error", 'Invalid URL');
return null;
}
let title, danmakuUrl;
// 普通投稿视频
if (inputUrl.includes("video/")) {
try {
// 获取查询字符串部分(从 `?` 开始的部分)
const queryString = inputUrl.split('?')[1];
// 如果查询字符串存在,则查找参数 p
let p = 1; // 默认值为 1
if (queryString) {
const params = queryString.split('&'); // 按 `&` 分割多个参数
for (let param of params) {
const [key, value] = param.split('='); // 分割每个参数的键值对
if (key === 'p') {
p = value || 1; // 如果找到 p,使用它的值,否则使用默认值
}
}
}
log("log", "p: ", p);
let videoInfoUrl;
if (inputUrl.includes("BV")) {
videoInfoUrl = `${api_video_info}?bvid=${path[2]}`;
} else {
videoInfoUrl = `${api_video_info}?aid=${path[2].substring(2)}`;
}
const res = await httpGet(videoInfoUrl, {
headers: {
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
},
});
const data = typeof res.data === "string" ? JSON.parse(res.data) : res.data;
if (data.code !== 0) {
log("error", "获取普通投稿视频信息失败:", data.message);
return null;
}
const cid = data.data.pages[p - 1].cid;
danmakuUrl = `https://comment.bilibili.com/${cid}.xml`;
} catch (error) {
log("error", "请求普通投稿视频信息失败:", error);
return null;
}
// 番剧
} else if (inputUrl.includes("bangumi/") && inputUrl.includes("ep")) {
try {
const epid = path.slice(-1)[0].slice(2);
const epInfoUrl = `${api_epid_cid}?ep_id=${epid}`;
const res = await httpGet(epInfoUrl, {
headers: {
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
},
});
const data = typeof res.data === "string" ? JSON.parse(res.data) : res.data;
if (data.code !== 0) {
log("error", "获取番剧视频信息失败:", data.message);
return null;
}
for (const episode of data.result.episodes) {
if (episode.id == epid) {
title = episode.share_copy;
const cid = episode.cid;
danmakuUrl = `https://comment.bilibili.com/${cid}.xml`;
break;
}
}
if (!danmakuUrl) {
log("error", "未找到匹配的番剧集信息");
return null;
}
} catch (error) {
log("error", "请求番剧视频信息失败:", error);
return null;
}
} else {
log("error", "不支持的B站视频网址,仅支持普通视频(av,bv)、剧集视频(ep)");
return null;
}
const response = await httpGet(danmakuUrl, {
headers: {
"Content-Type": "application/xml",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
},
});
const contents = response.data;
printFirst200Chars(contents);
return contents;
}
// =====================
// 获取优酷弹幕
// =====================
function convertYoukuUrl(url) {
// 使用正则表达式提取 vid 参数
const vidMatch = url.match(/vid=([^&]+)/);
if (!vidMatch || !vidMatch[1]) {
return null; // 如果没有找到 vid 参数,返回 null
}
const vid = vidMatch[1];
// 构造新的 URL
return `https://v.youku.com/v_show/id_${vid}.html`;
}
async function fetchYouku(inputUrl) {
log("log", "开始从本地请求优酷弹幕...", inputUrl);
// 弹幕和视频信息 API 基础地址
const api_video_info = "https://openapi.youku.com/v2/videos/show.json";
const api_danmaku = "https://acs.youku.com/h5/mopen.youku.danmu.list/1.0/";
// 手动解析 URL(没有 URL 对象的情况下)
const regex = /^(https?:\/\/[^\/]+)(\/[^?#]*)/;
const match = inputUrl.match(regex);
let path;
if (match) {
path = match[2].split('/').filter(Boolean); // 分割路径并去掉空字符串
path.unshift("");
log("log", path);
} else {
log("error", 'Invalid URL');
return null;
}
const video_id = path[path.length - 1].split(".")[0].slice(3);
log("log", "video_id:", video_id);
// 获取页面标题和视频时长
let res;
try {
const videoInfoUrl = `${api_video_info}?client_id=53e6cc67237fc59a&video_id=${video_id}&package=com.huawei.hwvplayer.youku&ext=show`;
res = await httpGet(videoInfoUrl, {
headers: {
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36",
},
allow_redirects: false
});
} catch (error) {
log("error", "请求视频信息失败:", error);
return null;
}
const data = typeof res.data === "string" ? JSON.parse(res.data) : res.data;
const title = data.title;
const duration = data.duration;
log("log", "标题:", title, "时长:", duration);
// 获取 cna 和 tk_enc
let cna, _m_h5_tk_enc, _m_h5_tk;
try {
const cnaUrl = "https://log.mmstat.com/eg.js";
const tkEncUrl = "https://acs.youku.com/h5/mtop.com.youku.aplatform.weakget/1.0/?jsv=2.5.1&appKey=24679788";
const cnaRes = await httpGet(cnaUrl, {
headers: {
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36",
},
allow_redirects: false
});
log("log", "cnaRes: ", cnaRes);
log("log", "cnaRes.headers: ", cnaRes.headers);
const etag = cnaRes.headers["etag"] || cnaRes.headers["Etag"];
log("log", "etag: ", etag);
// const match = cnaRes.headers["set-cookie"].match(/cna=([^;]+)/);
// cna = match ? match[1] : null;
cna = etag.replace(/^"|"$/g, '');
log("log", "cna: ", cna);
let tkEncRes;
while (!tkEncRes) {
tkEncRes = await httpGet(tkEncUrl, {
headers: {
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36",
},
allow_redirects: false
});
}
log("log", "tkEncRes: ", tkEncRes);
log("log", "tkEncRes.headers: ", tkEncRes.headers);
const tkEncSetCookie = tkEncRes.headers["set-cookie"] || tkEncRes.headers["Set-Cookie"];
log("log", "tkEncSetCookie: ", tkEncSetCookie);
// 获取 _m_h5_tk_enc
const tkEncMatch = tkEncSetCookie.match(/_m_h5_tk_enc=([^;]+)/);
_m_h5_tk_enc = tkEncMatch ? tkEncMatch[1] : null;
// 获取 _m_h5_tkh
const tkH5Match = tkEncSetCookie.match(/_m_h5_tk=([^;]+)/);
_m_h5_tk = tkH5Match ? tkH5Match[1] : null;
log("log", "_m_h5_tk_enc:", _m_h5_tk_enc);
log("log", "_m_h5_tk:", _m_h5_tk);
} catch (error) {
log("error", "获取 cna 或 tk_enc 失败:", error);
return null;
}
// 计算弹幕分段请求
const step = 60; // 每60秒一个分段
const max_mat = Math.floor(duration / step) + 1;
let contents = [];
for (let mat = 0; mat < max_mat; mat++) {
const msg = {
ctime: Date.now(),
ctype: 10004,
cver: "v1.0",
guid: cna,
mat: mat,
mcount: 1,
pid: 0,
sver: "3.1.0",
type: 1,
vid: video_id,
};
const str = JSON.stringify(msg);
// const buff = Buffer.from(str, "utf-8");
// const msg_b64encode = buff.toString("base64");
// msg.msg = msg_b64encode;
// 2. 处理 UTF-8 编码(替代 TextEncoder 和 Buffer)
// 对于简单的 ASCII 字符串,btoa 可以直接处理
// 如果需要支持 UTF-8 字符(例如中文),需要手动编码
function utf8ToLatin1(str) {
// 将 UTF-8 字符串转换为 Latin-1 可用的字符串
// 浏览器 btoa 只能处理 Latin-1(0-255 字符码)
let result = '';
for (let i = 0; i < str.length; i++) {
const charCode = str.charCodeAt(i);
if (charCode > 255) {
// 对于非 Latin-1 字符(如中文),可以用 encodeURIComponent 转义
result += encodeURIComponent(str[i]);
} else {
result += str[i];
}
}
return result;
}
function base64Encode(input) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
let output = '';
let buffer = 0;
let bufferLength = 0;
for (let i = 0; i < input.length; i++) {
buffer = (buffer << 8) | input.charCodeAt(i);
bufferLength += 8;
while (bufferLength >= 6) {
output += chars[(buffer >> (bufferLength - 6)) & 0x3F];
bufferLength -= 6;
}
}
if (bufferLength > 0) {
output += chars[(buffer << (6 - bufferLength)) & 0x3F];
}
while (output.length % 4 !== 0) {
output += '=';
}
return output;
}
// 3. 转为 Base64 编码
const msg_b64encode = base64Encode(utf8ToLatin1(str));
// 4. 将 Base64 编码存入 msg.msg
msg.msg = msg_b64encode;
msg.sign = md5(`${msg_b64encode}MkmC9SoIw6xCkSKHhJ7b5D2r51kBiREr`).toString().toLowerCase();
const data = JSON.stringify(msg);
const t = Date.now();
const params = {
jsv: "2.5.6",
appKey: "24679788",
t: t,
sign: md5([_m_h5_tk.slice(0, 32), t, "24679788", data].join("&")).toString().toLowerCase(),
api: "mopen.youku.danmu.list",
v: "1.0",
type: "originaljson",
dataType: "jsonp",
timeout: "20000",
jsonpIncPrefix: "utility",
};
let queryString = buildQueryString(params);
const url = `${api_danmaku}?${queryString}`;
log("log", "piece_url: ", url);
try {
const response = await httpPost(url, buildQueryString({ data: data }), {
headers: {
"Cookie": `_m_h5_tk=${_m_h5_tk};_m_h5_tk_enc=${_m_h5_tk_enc};`,
"Referer": "https://v.youku.com",
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36",
},
allow_redirects: false
});
if (response.data.data && response.data.data.result) {
const result = JSON.parse(response.data.data.result);
if (result.code === "-1") continue;
const danmus = result.data.result;
for (const danmu of danmus) {
const content = {
timepoint: 0, // 弹幕发送时间(秒)
ct: 1, // 弹幕类型,1-3 为滚动弹幕、4 为底部、5 为顶端、6 为逆向、7 为精确、8 为高级
size: 25, //字体大小,25 为中,18 为小
color: 16777215, //弹幕颜色,RGB 颜色转为十进制后的值,16777215 为白色
unixtime: Math.floor(Date.now() / 1000), //Unix 时间戳格式
uid: 0, //发送人的 id
content: "",
};
content.timepoint = danmu.playat / 1000;
if (danmu.propertis?.color) {
content.color = JSON.parse(danmu.propertis).color;
}
content.content = danmu.content;
contents.push(content);
}
}
} catch (error) {
log("error", "请求失败:", error.message); // 输出错误信息
return null;
}
}
printFirst200Chars(contents);
// 返回结果
return convertToDanmakuXML(contents);
}
// =====================
// 路由请求相关
// =====================
@@ -817,7 +1494,7 @@ async function getBangumi(path) {
// Extracted function for GET /api/v2/comment/:commentId
async function getComment(path) {
const commentId = parseInt(path.split("/").pop());
const url = findUrlById(commentId);
let url = findUrlById(commentId);
if (!url) {
log("error", `Comment with ID ${commentId} not found`);
return jsonResponse({ count: 0, comments: [] }, 404);
@@ -832,15 +1509,22 @@ async function getComment(path) {
if (url.includes('.iqiyi.com')) {
danmus = await fetchIqiyi(url);
}
// if (url.includes('.mgtv.com')) {
// danmus = await fetchMangoTV(url);
// }
// if (url.includes('.bilibili.com')) {
// danmus = await fetchBilibili(url);
// }
// if (url.includes('.youku.com')) {
// danmus = await fetchYouku(url);
// }
if (url.includes('.mgtv.com')) {
danmus = await fetchMangoTV(url);
}
if (url.includes('.bilibili.com')) {
danmus = await fetchBilibili(url);
// bilibili直接返回xml
return danmus;
}
if (url.includes('.youku.com')) {
// 处理302场景
// https://v.youku.com/video?vid=XNjQ4MTIwOTE2NA==&tpa=dW5pb25faWQ9MTAyMjEzXzEwMDAwNl8wMV8wMQ需要转成https://v.youku.com/v_show/id_XNjQ4MTIwOTE2NA==.html
if (url.includes("youku.com/video?vid")) {
url = convertYoukuUrl(url);
}
danmus = await fetchYouku(url);
}
return jsonResponse({ count: danmus.length, comments: danmus });
}
@@ -935,4 +1619,4 @@ export async function vercelHandler(req, res) {
}
// 为了测试导出 handleRequest
export { handleRequest, searchAnime, getBangumi, getComment, fetchTencentVideo, fetchIqiyi };
export { handleRequest, searchAnime, getBangumi, getComment, fetchTencentVideo, fetchIqiyi, fetchMangoTV, fetchBilibili, fetchYouku };
+36 -4
View File
@@ -1,6 +1,6 @@
const test = require('node:test');
const assert = require('node:assert').strict;
const { handleRequest, searchAnime, getBangumi, getComment, fetchTencentVideo, fetchIqiyi} = require('./worker');
const { handleRequest, searchAnime, getBangumi, getComment, fetchTencentVideo, fetchIqiyi, fetchMangoTV, fetchBilibili, fetchYouku} = require('./worker');
// Mock Request class for testing
class MockRequest {
@@ -45,11 +45,32 @@ test('worker.js API endpoints', async (t) => {
// assert(res.length > 2, `Expected res.length > 2, but got ${res.length}`);
// });
// await t.test('GET mango danmu', async () => {
// const res = await fetchMangoTV("https://www.mgtv.com/b/771610/23300622.html");
// assert(res.length > 2, `Expected res.length > 2, but got ${res.length}`);
// });
// await t.test('GET bilibili danmu', async () => {
// const res = await fetchBilibili("https://www.bilibili.com/bangumi/play/ep1231564");
// assert(res.length > 2, `Expected res.length > 2, but got ${res.length}`);
// });
// await t.test('GET youku danmu', async () => {
// const res = await fetchYouku("https://v.youku.com/v_show/id_XNjQ3ODMyNjU3Mg==.html");
// assert(res.length > 2, `Expected res.length > 2, but got ${res.length}`);
// });
await t.test('GET realistic danmu', async () => {
// tencent
// const keyword = "子夜归";
// iqiyi
const keyword = "赴山海";
// const keyword = "赴山海";
// mango
// const keyword = "锦月如歌";
// bilibili
const keyword = "国王排名";
// youku
// const keyword = "黑白局";
const searchUrl = new URL(`${urlPrefix}/${token}/api/v2/search/anime?keyword=${keyword}`);
const searchRes = await searchAnime(searchUrl);
@@ -63,7 +84,18 @@ test('worker.js API endpoints', async (t) => {
const commentUrl = new URL(`${urlPrefix}/${token}/api/v2/comment/${bangumiData.bangumi.episodes[0].episodeId}?withRelated=true&chConvert=1`);
const commentRes = await getComment(commentUrl.pathname);
const commentData = await commentRes.json();
assert(commentData.count > 0, `Expected commentData.count > 0, but got ${commentData.count}`);
try {
const commentData = await commentRes.json();
assert(commentData.count > 0, `Expected commentData.count > 0, but got ${commentData.count}`);
} catch (e) {
if (e instanceof TypeError) {
// 确保 commentRes 是字符串,否则可能需要进一步处理
assert(typeof commentRes === 'string' && commentRes.trim().startsWith('<?xml version'),
`Expected commentRes to start with '<?xml version', but got: ${commentRes.substring(0, 200)}`);
} else {
// 其他错误继续抛出
throw e;
}
}
});
});