From dde5e82b888e3068be32a134786cb280fe571c90 Mon Sep 17 00:00:00 2001 From: huangxd- <649924757@qq.com> Date: Sun, 14 Sep 2025 09:15:53 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E8=8A=92=E6=9E=9Ctv/?= =?UTF-8?q?=E5=93=94=E5=93=A9=E5=93=94=E5=93=A9/=E4=BC=98=E9=85=B7?= =?UTF-8?q?=E5=BC=B9=E5=B9=95=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- danmu_api/README.md | 15 +- danmu_api/worker.js | 706 ++++++++++++++++++++++++++++++++++++++- danmu_api/worker.test.js | 40 ++- 3 files changed, 739 insertions(+), 22 deletions(-) diff --git a/danmu_api/README.md b/danmu_api/README.md index e0964dc..295c111 100644 --- a/danmu_api/README.md +++ b/danmu_api/README.md @@ -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 diff --git a/danmu_api/worker.js b/danmu_api/worker.js index 9bc352e..dfb0339 100644 --- a/danmu_api/worker.js +++ b/danmu_api/worker.js @@ -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 }; \ No newline at end of file +export { handleRequest, searchAnime, getBangumi, getComment, fetchTencentVideo, fetchIqiyi, fetchMangoTV, fetchBilibili, fetchYouku }; \ No newline at end of file diff --git a/danmu_api/worker.test.js b/danmu_api/worker.test.js index 667e559..8237197 100644 --- a/danmu_api/worker.test.js +++ b/danmu_api/worker.test.js @@ -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('