diff --git a/danmu_api/worker.js b/danmu_api/worker.js index 5e5e6fb..ba6f8b0 100644 --- a/danmu_api/worker.js +++ b/danmu_api/worker.js @@ -28,6 +28,13 @@ const requestHistory = new Map(); let redisValid = false; // 存储查询关键字上次选择的animeId,用于下次match自动匹配时优先选择该anime let lastSelectMap = new Map(); +// 存储上一次各变量哈希值 +let lastHashes = { + animes: null, + episodeIds: null, + episodeNum: null, + lastSelectMap: null +}; // 搜索结果缓存,存储格式:{ keyword: { results, timestamp } } const searchCache = new Map(); // 弹幕缓存,存储格式:{ videoUrl: { comments, timestamp } } @@ -526,7 +533,7 @@ function addAnime(anime) { removeEarliestAnime(); } - console.log("animes: ", animes); + log("info", "animes: ", animes); return true; } @@ -839,6 +846,7 @@ async function getPageTitle(url) { // 使用 GET 发送简单命令(如 PING 检查连接) async function pingRedis() { const url = `${redisUrl}/ping`; + log("info", `[redis] 开始发送 PING 请求:`, url); try { const response = await fetch(url, { method: 'GET', @@ -860,6 +868,7 @@ async function pingRedis() { // 使用 GET 发送 GET 命令(读取键值) async function getRedisKey(key) { const url = `${redisUrl}/get/${key}`; + log("info", `[redis] 开始发送 GET 请求:`, url); try { const response = await fetch(url, { method: 'GET', @@ -878,9 +887,19 @@ async function getRedisKey(key) { } } -// 使用 POST 发送 SET 命令(写入键值,值在请求体中) +// 使用 POST 发送 SET 命令,仅在值变化时更新 async function setRedisKey(key, value) { + const serializedValue = JSON.stringify(value); + const currentHash = simpleHash(serializedValue); + + // 检查值是否变化 + if (lastHashes[key] === currentHash) { + log("info", `[redis] 键 ${key} 无变化,跳过 SET 请求`); + return { result: "OK" }; // 模拟成功响应 + } + const url = `${redisUrl}/set/${key}`; + log("info", `[redis] 开始发送 SET 请求:`, url); try { const response = await fetch(url, { method: 'POST', @@ -888,22 +907,35 @@ async function setRedisKey(key, value) { 'Authorization': `Bearer ${redisToken}`, 'Content-Type': 'application/json' }, - body: JSON.stringify(value) // 值作为 body 发送,支持 JSON 或字符串 + body: serializedValue }); - return await response.json(); // 预期: ["OK"] + const result = await response.json(); + lastHashes[key] = currentHash; // 更新哈希值 + log("info", `[redis] 键 ${key} 更新成功`); + return result; // 预期: ["OK"] } catch (error) { - log("error", `[redis] 请求失败:`, error.message); + log("error", `[redis] SET 请求失败:`, error.message); log("error", '- 错误类型:', error.name); if (error.cause) { - log("error", '- 码:', error.cause.code); // e.g., 'ECONNREFUSED', 'ETIMEDOUT', 'ECONNRESET' + log("error", '- 码:', error.cause.code); log("error", '- 原因:', error.cause.message); } } } -// 复杂命令,如 SETEX(设置键值并过期,使用查询参数) +// 使用 POST 发送 SETEX 命令,仅在值变化时更新 async function setRedisKeyWithExpiry(key, value, expirySeconds) { + const serializedValue = JSON.stringify(value); + const currentHash = simpleHash(serializedValue); + + // 检查值是否变化 + if (lastHashes[key] === currentHash) { + log("info", `[redis] 键 ${key} 无变化,跳过 SETEX 请求`); + return { result: "OK" }; // 模拟成功响应 + } + const url = `${redisUrl}/set/${key}?EX=${expirySeconds}`; + log("info", `[redis] 开始发送 SETEX 请求:`, url); try { const response = await fetch(url, { method: 'POST', @@ -911,49 +943,102 @@ async function setRedisKeyWithExpiry(key, value, expirySeconds) { 'Authorization': `Bearer ${redisToken}`, 'Content-Type': 'application/json' }, - body: JSON.stringify(value) + body: serializedValue }); - return await response.json(); + const result = await response.json(); + lastHashes[key] = currentHash; // 更新哈希值 + log("info", `[redis] 键 ${key} 更新成功(带过期时间 ${expirySeconds}s)`); + return result; } catch (error) { - log("error", `[redis] 请求失败:`, error.message); + log("error", `[redis] SETEX 请求失败:`, error.message); log("error", '- 错误类型:', error.name); if (error.cause) { - log("error", '- 码:', error.cause.code); // e.g., 'ECONNREFUSED', 'ETIMEDOUT', 'ECONNRESET' + log("error", '- 码:', error.cause.code); log("error", '- 原因:', error.cause.message); } } } -// 从redis获取变量数据 -async function getCaches() { - if (animes.length === 0) { - log("info", 'getCaches start.'); - const [kv_animes, kv_episodeIds, kv_episodeNum, kv_logBuffer, kv_lastSelectMap] = await Promise.all([ - getRedisKey('animes'), - getRedisKey('episodeIds'), - getRedisKey('episodeNum'), - getRedisKey('logBuffer'), - getRedisKey('lastSelectMap'), - ]); - - animes = kv_animes.result ? JSON.parse(kv_animes.result) : animes; - episodeIds = kv_episodeIds.result ? JSON.parse(kv_episodeIds.result) : episodeIds; - episodeNum = kv_episodeNum.result ? JSON.parse(kv_episodeNum.result) : episodeNum; - logBuffer = kv_logBuffer.result ? JSON.parse(kv_logBuffer.result) : logBuffer; - lastSelectMap = kv_lastSelectMap.result ? JSON.parse(kv_lastSelectMap.result) : lastSelectMap; +// 通用的 pipeline 请求函数 +async function runPipeline(commands) { + const url = `${redisUrl}/pipeline`; + log("info", `[redis] 开始发送 PIPELINE 请求:`, url); + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${redisToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(commands) // commands 是一个数组,包含多个 Redis 命令 + }); + const result = await response.json(); + return result; // 返回结果数组,按命令顺序 + } catch (error) { + log("error", `[redis] Pipeline 请求失败:`, error.message); + log("error", '- 错误类型:', error.name); + if (error.cause) { + log("error", '- 码:', error.cause.code); + log("error", '- 原因:', error.cause.message); } + } } -// 存储更新后的变量到redis -async function updateCaches() { - log("info", 'updateCaches start.'); - await Promise.all([ - setRedisKey('animes', animes), - setRedisKey('episodeIds', episodeIds), - setRedisKey('episodeNum', episodeNum), - setRedisKey('logBuffer', logBuffer), - setRedisKey('lastSelectMap', lastSelectMap) - ]); +// 优化后的 getRedisCaches,单次请求获取所有键 +async function getRedisCaches() { + if (animes.length === 0) { + log("info", 'getCaches start.'); + const keys = ['animes', 'episodeIds', 'episodeNum', 'lastSelectMap']; + const commands = keys.map(key => ['GET', key]); // 构造 pipeline 命令 + const results = await runPipeline(commands); + + // 解析结果,按顺序赋值 + animes = results[0].result ? JSON.parse(results[0].result) : animes; + episodeIds = results[1].result ? JSON.parse(results[1].result) : episodeIds; + episodeNum = results[2].result ? JSON.parse(results[2].result) : episodeNum; + lastSelectMap = results[3].result ? JSON.parse(results[3].result) : lastSelectMap; + + // 更新哈希值 + lastHashes.animes = simpleHash(JSON.stringify(animes)); + lastHashes.episodeIds = simpleHash(JSON.stringify(episodeIds)); + lastHashes.episodeNum = simpleHash(JSON.stringify(episodeNum)); + lastHashes.lastSelectMap = simpleHash(JSON.stringify(lastSelectMap)); + } +} + +// 优化后的 updateRedisCaches,仅更新有变化的变量 +async function updateRedisCaches() { + log("info", 'updateCaches start.'); + const commands = []; + const updates = []; + + // 检查每个变量的哈希值 + const variables = [ + { key: 'animes', value: animes }, + { key: 'episodeIds', value: episodeIds }, + { key: 'episodeNum', value: episodeNum }, + { key: 'lastSelectMap', value: lastSelectMap } + ]; + + for (const { key, value } of variables) { + const currentHash = simpleHash(JSON.stringify(value)); + if (currentHash !== lastHashes[key]) { + commands.push(['SET', key, JSON.stringify(value)]); + updates.push({ key, hash: currentHash }); + } + } + + // 如果有需要更新的键,执行 pipeline + if (commands.length > 0) { + log("info", `Updating ${commands.length} changed keys: ${updates.map(u => u.key).join(', ')}`); + await runPipeline(commands); + // 更新哈希值 + updates.forEach(({ key, hash }) => { + lastHashes[key] = hash; + }); + } else { + log("info", 'No changes detected, skipping Redis update.'); + } } // ===================== @@ -1793,6 +1878,30 @@ function encryptStr(str) { return '*'.repeat(str.length); } +// RGB 转整数的函数 +function rgbToInt(color) { + // 检查 RGB 值是否有效 + if ( + typeof color.r !== 'number' || color.r < 0 || color.r > 255 || + typeof color.g !== 'number' || color.g < 0 || color.g > 255 || + typeof color.b !== 'number' || color.b < 0 || color.b > 255 + ) { + return -1; + } + return color.r * 256 * 256 + color.g * 256 + color.b; +} + +// 简单的字符串哈希函数 +function simpleHash(str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash >>> 0; // 确保为无符号 32 位整数 + } + return hash.toString(16); // 转换为十六进制 +} + // ===================== // 腾讯视频搜索和分集 // ===================== @@ -2423,7 +2532,7 @@ async function fetchMangoTV(inputUrl) { // 弹幕和视频信息 API 基础地址 const api_video_info = "https://pcweb.api.mgtv.com/video/info"; - const api_danmaku = "https://galaxy.bz.mgtv.com/rdbarrage"; + const api_ctl_barrage = "https://galaxy.bz.mgtv.com/getctlbarrage"; // 解析 URL 获取 cid 和 vid // 手动解析 URL(没有 URL 对象的情况下) @@ -2454,7 +2563,7 @@ async function fetchMangoTV(inputUrl) { }, }); } catch (error) { - log("info", "请求视频信息失败:", error); + log("error", "请求视频信息失败:", error); return []; } @@ -2464,23 +2573,64 @@ async function fetchMangoTV(inputUrl) { log("info", "标题:", 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", - }, - }) - ); + try { + const ctlBarrageUrl = `${api_ctl_barrage}?version=8.1.39&abroad=0&uuid=&os=10.15.7&platform=0&mac=&vid=${vid}&pid=&cid=${cid}&ticket=`; + const res = await httpGet(ctlBarrageUrl, { + 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 ctlBarrage = typeof res.data === "string" ? JSON.parse(res.data) : res.data; + + // 每1分钟一个分段 + for (let i = 0; i < Math.ceil(time_to_second(time) / 60); i += 1) { + const danmakuUrl = `https://${ctlBarrage.data?.cdn_list.split(',')[0]}/${ctlBarrage.data?.cdn_version}/${i}.json`; + 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", + }, + }) + ); + } + } catch (error) { + log("error", "请求弹幕分片失败:", error); + return []; } log("info", "弹幕分段数量:", promises.length); + // 默认颜色值 + const DEFAULT_COLOR_INT = -1; + + // 处理 v2_color 对象的转换逻辑 + function transformV2Color(v2_color) { + // 如果 v2_color 不存在,返回默认值 + if (!v2_color) { + return DEFAULT_COLOR_INT; + } + // 计算左右颜色的整数值 + const leftColor = rgbToInt(v2_color.color_left); + const rightColor = rgbToInt(v2_color.color_right); + // 如果左右颜色均为 -1,返回默认值 + if (leftColor === -1 && rightColor === -1) { + return DEFAULT_COLOR_INT; + } + // 如果左颜色无效,返回右颜色 + if (leftColor === -1) { + return rightColor; + } + // 如果右颜色无效,返回左颜色 + if (rightColor === -1) { + return leftColor; + } + // 返回左右颜色的平均值 + return Math.floor((leftColor + rightColor) / 2); + } + // 解析弹幕数据 let contents = []; try { @@ -2502,10 +2652,15 @@ async function fetchMangoTV(inputUrl) { uid: 0, //发送人的 id content: "", }; - if (item.type === 1) { - content.ct = 5; - } else if (item.type === 2) { - content.ct = 4; + if (item?.v2_color) { + content.color = transformV2Color(item?.v2_color); + } + if (item?.v2_position) { + if (item?.v2_position === 1) { + content.ct = 5; + } else if (item?.v2_position === 2) { + content.ct = 4; + } } content.timepoint = item.time / 1000; content.content = item.content; @@ -4556,7 +4711,7 @@ async function searchAnime(url) { // 如果有新的anime获取到,则更新redis if (redisValid && curAnimes.length !== 0) { - await updateCaches(); + await updateRedisCaches(); } return jsonResponse({ @@ -4667,7 +4822,7 @@ async function searchAnime(url) { // 如果有新的anime获取到,则更新redis if (redisValid && curAnimes.length !== 0) { - await updateCaches(); + await updateRedisCaches(); } // 缓存搜索结果 @@ -5262,8 +5417,8 @@ async function handleRequest(req, env, deployPlatform, clientIp) { otherServer = resolveOtherServer(env); envs["otherServer"] = otherServer; vodServers = resolveVodServers(env); - vodReturnMode = resolveVodReturnMode(env); envs["vodServers"] = vodServers.map(s => `${s.name}@${s.url}`).join(','); + vodReturnMode = resolveVodReturnMode(env); envs["vodReturnMode"] = vodReturnMode; bilibliCookie = resolveBilibiliCookie(env); envs["bilibliCookie"] = encryptStr(bilibliCookie); @@ -5310,7 +5465,7 @@ async function handleRequest(req, env, deployPlatform, clientIp) { log("info", `client ip: ${clientIp}`); if (redisValid && path !== "/favicon.ico" && path !== "/robots.txt") { - await getCaches(); + await getRedisCaches(); } function handleHomepage() { @@ -5321,7 +5476,7 @@ async function handleRequest(req, env, deployPlatform, clientIp) { envs: envs, repository: "https://github.com/huangxd-/danmu_api.git", description: "一个人人都能部署的基于 js 的弹幕 API 服务器,支持爱优腾芒哔人韩巴弹幕直接获取,兼容弹弹play的搜索、详情查询和弹幕获取接口,并提供日志记录,支持vercel/cloudflare/docker/claw等部署方式,不用提前下载弹幕,没有nas或小鸡也能一键部署。", - notice: "本项目仅为个人爱好开发,代码开源。如有任何侵权行为,请联系本人删除。有问题提issue或私信机器人都ok。https://t.me/ddjdd_bot" + notice: "本项目仅为个人爱好开发,代码开源。如有任何侵权行为,请联系本人删除。有问题提issue或私信机器人都ok,TG MSG ROBOT: [https://t.me/ddjdd_bot]; 推荐加互助群咨询,TG GROUP: [https://t.me/logvar_danmu_group]; 关注频道获取最新更新内容,TG CHANNEL: [https://t.me/logvar_danmu_channel]。" }); }