增加芒果tv/哔哩哔哩/优酷弹幕下载
This commit is contained in:
1 parent
5210aca3d3
commit
dde5e82b88
3 files changed
+739
-22
No files matched your search
+8
-7
@@ -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
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user