diff --git a/README.md b/README.md index e93bddd..a931c00 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ LogVar 弹幕 API 服务器 - `GET /api/logs`:获取最近的日志(最多 500 行,格式为 `[时间戳] 级别: 消息`)。 - **日志记录**:捕获 `console.log`(info 级别)和 `console.error`(error 级别),JSON 内容格式化输出。 - **部署支持**:支持本地运行、Docker 容器化、Vercel 一键部署、Cloudflare 一键部署和 Docker 一键启动。 +- **手动选择记忆**:支持记住之前搜索title时手动选择的anime,并在后续的match自动匹配时优选该anime【实验性】。 ## 前置条件 - Node.js(v18.0.0 或更高版本;理论兼容更低版本,请自行测试) @@ -172,7 +173,9 @@ LogVar 弹幕 API 服务器 > -> 如果访问遇到404等问题,可能是edgeone pages修改了访问策略,每次接口请求都转发到了新的环境,没有缓存,导致获取不到对应的弹幕,推荐用vercel部署。 +> 如果每次访问都遇到404等问题,可能是edgeone pages修改了访问策略,每次接口请求都转发到了新的环境,没有缓存,导致获取不到对应的弹幕,推荐用vercel部署。 +> +> 解决方法:请配置环境变量`UPSTASH_REDIS_REST_URL`和`UPSTASH_REDIS_REST_TOKEN`,开启upstash redis存储 ## 部署到 Cloudflare @@ -221,20 +224,22 @@ LogVar 弹幕 API 服务器 | ----------- | ----------- | | TOKEN | 【可选】自定义用户token,不填默认为`87654321` | | OTHER_SERVER | 【可选】兜底第三方弹幕服务器,不填默认为`https://api.danmu.icu` | -| VOD_SERVER | 【可选】vod查询站点,不填默认为`https://www.caiji.cyou` | -| VOD_SERVER2 | 【可选】vod2查询站点,如果想开启vod2源,请先填写VOD_SERVER2源地址,如`https://zy.xmm.hk`,并在SOURCE_ORDER环境变量中增加`vod2` | +| VOD_SERVER | 【可选】vod查询站点,不填默认为`https://gctf.tfdh.top` | +| VOD_SERVER2 | 【可选】vod2查询站点,如果想开启vod2源,请先填写VOD_SERVER2源地址,如`https://www.caiji.cyou`、`https://zy.xmm.hk`,并在SOURCE_ORDER环境变量中增加`vod2` | | BILIBILI_COOKIE | 【可选】b站cookie(填入后能抓取完整弹幕),如 `buvid3=E2BCA ... eao6; theme-avatar-tip-show=SHOWED`,请自行通过浏览器或抓包工具抓取,热心网友测试后,实际最少只需取 `SESSDATA=xxxx` 字段 | | YOUKU_CONCURRENCY | 【可选】youku弹幕请求并发数,用于加快youku弹幕请求速度,不填默认为`8`,最高`16` | | SOURCE_ORDER | 【可选】源排序,用于按源对返回资源的排序(注意:先后顺序会影响自动匹配最终的返回),默认是`vod,360,renren,hanjutv`,表示vod数据排在最前,hanjutv数据排在最后,示例:`360,renren`:只返回360数据和renren数据,且360数据靠前;当前可选择的源字段有 `vod,vod2,360,renren,hanjutv,bahamut` | | PLATFORM_ORDER | 【可选】自动匹配优选平台,按顺序优先返回指定平台弹幕,默认为空,即返回第一个满足条件的平台,示例:`bilibili1,qq`,表示如果有b站的播放源,则优先返回b站的弹幕,否则就返回腾讯的弹幕,两者都没有,则返回第一个满足条件的平台;当前可选择的平台字段有 `qiyi, bilibili1, imgo, youku, qq, renren, hanjutv, bahamut` | -| EPISODE_TITLE_FILTER | 【可选】剧集标题正则过滤,按正则关键字对剧集或综艺的集标题进行过滤,适用于过滤一些预告或综艺非正式集,默认值如下 | +| EPISODE_TITLE_FILTER | 【可选】剧集标题正则过滤,按正则关键字对剧集或综艺的集标题进行过滤,适用于过滤一些预告或综艺非正式集,只支持match自动匹配,默认值如下 | | BLOCKED_WORDS | 【可选】弹幕屏蔽词列表,默认为空,示例如下 | | GROUP_MINUTE | 【可选】合并去重分钟数,表示按n分钟分组后对弹幕合并去重,默认为1,最大值为30,0表示不去重 | | PROXY_URL | 【可选】代理地址,示例: `http://127.0.0.1:7897` ,目前只对巴哈姆特生效(注意:如果巴哈姆特请求不通,会拖慢搜索返回速度,所以除vercel/cloudflare之外默认不开启bahamut源,开启请先在SOURCE_ORDER环境变量中添加`bahamut`)如果你使用docker部署并且访问不了bahamut源,请配置代理地址;vercel/cf中理应都自然能联通,不用填写 | +| UPSTASH_REDIS_REST_URL | 【可选】update redis url,需配合UPSTASH_REDIS_REST_TOKEN使用,用于持久化存储,不会因为冷启动而丢失过去的查询信息(在cf/eo/claw上配置后应该能更稳定点,但会稍微影响请求速度),获取方法请参考:`https://cloud.tencent.cn/developer/article/2424508` | +| UPSTASH_REDIS_REST_TOKEN | 【可选】update redis token,需配合UPSTASH_REDIS_REST_URL使用,用于持久化存储,不会因为冷启动而丢失过去的查询信息(在cf/eo/claw上配置后应该能更稳定点,但会稍微影响请求速度),获取方法请参考:`https://cloud.tencent.cn/developer/article/2424508` | ```regex # EPISODE_TITLE_FILTER 默认值 -(特别|惊喜|纳凉)?企划|合伙人手记|超前|速览|vlog|reaction|纯享|加更|抢先|抢鲜|预告|花絮|特辑|彩蛋|专访|幕后|直播|未播|衍生|番外|会员|片花|精华|看点|速看|解读|影评|解说|吐槽|盘点|拍摄花絮|制作花絮|幕后花絮|未播花絮|独家花絮|花絮特辑|先导预告|终极预告|正式预告|官方预告|彩蛋片段|删减片段|未播片段|番外彩蛋|精彩片段|精彩看点|精彩回顾|精彩集锦|看点解析|看点预告|NG镜头|NG花絮|番外篇|番外特辑|制作特辑|拍摄特辑|幕后特辑|导演特辑|演员特辑|片尾曲|插曲|主题曲|背景音乐|OST|音乐MV|歌曲MV|前季回顾|剧情回顾|往期回顾|内容总结|剧情盘点|精选合集|剪辑合集|混剪视频|独家专访|演员访谈|导演访谈|主创访谈|媒体采访|发布会采访|抢先看|抢先版|试看版|短剧|精编|会员版|Plus|独家版|特别版|短片|合唱|陪看|MV|高清正片|发布会|.{2,}篇|观察室|上班那点事儿|周top|赛段|直拍|REACTION|VLOG|全纪录|开播|先导|总宣|展演 +(特别|惊喜|纳凉)?企划|合伙人手记|超前|速览|vlog|reaction|纯享|加更|抢先|抢鲜|预告|花絮|特辑|彩蛋|专访|幕后|直播|未播|衍生|番外|会员|片花|精华|看点|速看|解读|影评|解说|吐槽|盘点|拍摄花絮|制作花絮|幕后花絮|未播花絮|独家花絮|花絮特辑|先导预告|终极预告|正式预告|官方预告|彩蛋片段|删减片段|未播片段|番外彩蛋|精彩片段|精彩看点|精彩回顾|精彩集锦|看点解析|看点预告|NG镜头|NG镜头|NG花絮|番外篇|番外特辑|制作特辑|拍摄特辑|幕后特辑|导演特辑|演员特辑|片尾曲|插曲|高光回顾|背景音乐|OST|音乐MV|歌曲MV|前季回顾|剧情回顾|往期回顾|内容总结|剧情盘点|精选合集|剪辑合集|混剪视频|独家专访|演员访谈|导演访谈|主创访谈|媒体采访|发布会采访|抢先看|抢先版|试看版|短剧|精编|会员版|Plus|独家版|特别版|短片|陪看|高清正片|发布会|.{2,}篇|(?!.*(入局|破冰局|做局)).{2,}局|观察室|上班那点事儿|周top|赛段|直拍|REACTION|VLOG|全纪录|开播|先导|总宣|展演|集锦|旅行日记 # 如果你想新增过滤词,请自定义EPISODE_TITLE_FILTER,示例如下,每个词用'|'隔开,增加的词都会追加到默认值后面 测试|test @@ -297,7 +302,7 @@ danmu_api/ - cloudflare貌似被哔风控了。 - 如果想更换兜底第三方弹幕服务器,请添加环境变量`OTHER_SERVER`,示例`https://api.danmu.icu`。 - 如果想更换vod站点,请添加环境变量`VOD_SERVER`,示例`https://www.caiji.cyou`。 -- 推荐vercel和claw部署,cloudflare/edgeone不稳定,当然最稳定还是自己本地docker部署最佳。 +- 推荐vercel部署,cloudflare/edgeone/claw不稳定,当然最稳定还是自己本地docker部署最佳。 - /api/v2/comment接口1分钟内同一IP只能请求三次。 ### 关联项目 diff --git a/danmu_api/worker.js b/danmu_api/worker.js index 3290992..0b8ba2d 100644 --- a/danmu_api/worker.js +++ b/danmu_api/worker.js @@ -1,19 +1,24 @@ // 全局状态(Cloudflare 和 Vercel 都可能重用实例) // ⚠️ 不是持久化存储,每次冷启动会丢失 -const VERSION = "1.3.0"; +const VERSION = "1.3.1"; let animes = []; let episodeIds = []; let episodeNum = 10001; // 全局变量,用于自增 ID // 日志存储,最多保存 500 行 -const logBuffer = []; +let logBuffer = []; const MAX_LOGS = 500; const MAX_ANIMES = 100; +const MAX_LAST_SELECT_MAP = 100; const vodAllowedPlatforms = ["qiyi", "bilibili1", "imgo", "youku", "qq"]; const allowedPlatforms = ["qiyi", "bilibili1", "imgo", "youku", "qq", "renren", "hanjutv", "bahamut"]; let envs = {}; // 定义一个全局变量来记录每个 IP 地址的请求历史 const requestHistory = new Map(); +// redis是否生效 +let redisValid = false; +// 存储查询关键字上次选择的animeId,用于下次match自动匹配时优先选择该anime +let lastSelectMap = new Map(); // ===================== // 环境变量处理 @@ -38,7 +43,7 @@ function resolveOtherServer(env) { return DEFAULT_OTHER_SERVER; } -const DEFAULT_VOD_SERVER = "https://www.caiji.cyou"; // 默认 vod站点 +const DEFAULT_VOD_SERVER = "https://gctf.tfdh.top"; // 默认 vod站点 let vodServer = DEFAULT_VOD_SERVER; function resolveVodServer(env) { @@ -147,10 +152,10 @@ function resolvePlatformOrder(env) { const DEFAULT_EPISODE_TITLE_FILTER = "(特别|惊喜|纳凉)?企划|合伙人手记|超前|速览|vlog|reaction|纯享|加更|抢先|抢鲜|预告|花絮|" + "特辑|彩蛋|专访|幕后|直播|未播|衍生|番外|会员|片花|精华|看点|速看|解读|影评|解说|吐槽|盘点|拍摄花絮|制作花絮|幕后花絮|未播花絮|独家花絮|" + "花絮特辑|先导预告|终极预告|正式预告|官方预告|彩蛋片段|删减片段|未播片段|番外彩蛋|精彩片段|精彩看点|精彩回顾|精彩集锦|看点解析|看点预告|" + - "NG镜头|NG花絮|番外篇|番外特辑|制作特辑|拍摄特辑|幕后特辑|导演特辑|演员特辑|片尾曲|插曲|主题曲|背景音乐|OST|音乐MV|歌曲MV|前季回顾|" + + "NG镜头|NG花絮|番外篇|番外特辑|制作特辑|拍摄特辑|幕后特辑|导演特辑|演员特辑|片尾曲|插曲|高光回顾|背景音乐|OST|音乐MV|歌曲MV|前季回顾|" + "剧情回顾|往期回顾|内容总结|剧情盘点|精选合集|剪辑合集|混剪视频|独家专访|演员访谈|导演访谈|主创访谈|媒体采访|发布会采访|抢先看|抢先版|" + - "试看版|短剧|精编|会员版|Plus|独家版|特别版|短片|合唱|陪看|MV|高清正片|发布会|.{2,}篇|观察室|上班那点事儿|周top|赛段|直拍|REACTION|" + - "VLOG|全纪录|开播|先导|总宣|展演"; // 默认 剧集标题正则过滤 + "试看版|短剧|精编|会员版|Plus|独家版|特别版|短片|陪看|高清正片|发布会|.{2,}篇|(?!.*(入局|破冰局|做局)).{2,}局|观察室|上班那点事儿|" + + "周top|赛段|直拍|REACTION|VLOG|全纪录|开播|先导|总宣|展演|集锦|旅行日记"; // 默认 剧集标题正则过滤 let episodeTitleFilter; // 这里既支持 Cloudflare env,也支持 Node process.env @@ -225,17 +230,37 @@ function resolveProxyUrl(env) { return DEFAULT_PROXY_URL; } +const DEFAULT_UPSTASH_REDIS_REST_URL = ""; // 默认 upstash redis url +let redisUrl = DEFAULT_UPSTASH_REDIS_REST_URL; + +// 这里既支持 Cloudflare env,也支持 Node process.env +function resolveRedisUrl(env) { + if (env && env.UPSTASH_REDIS_REST_URL) return env.UPSTASH_REDIS_REST_URL; // Cloudflare Workers + if (typeof process !== "undefined" && process.env?.UPSTASH_REDIS_REST_URL) return process.env.UPSTASH_REDIS_REST_URL; // Vercel / Node + return DEFAULT_UPSTASH_REDIS_REST_URL; +} + +const DEFAULT_UPSTASH_REDIS_REST_TOKEN = ""; // 默认 upstash redis url +let redisToken = DEFAULT_UPSTASH_REDIS_REST_TOKEN; + +// 这里既支持 Cloudflare env,也支持 Node process.env +function resolveRedisToken(env) { + if (env && env.UPSTASH_REDIS_REST_TOKEN) return env.UPSTASH_REDIS_REST_TOKEN; // Cloudflare Workers + if (typeof process !== "undefined" && process.env?.UPSTASH_REDIS_REST_TOKEN) return process.env.UPSTASH_REDIS_REST_TOKEN; // Vercel / Node + return DEFAULT_UPSTASH_REDIS_REST_TOKEN; +} + // ===================== // 数据结构处理函数 // ===================== // 添加元素到 episodeIds:检查 url 是否存在,若不存在则以自增 id 添加 function addEpisode(url, title) { - // 检查是否已存在相同的 url - const exists = episodeIds.some(episode => episode.url === url); - if (exists) { - log("log", `URL ${url} already exists in episodeIds, skipping addition.`); - return null; // 返回 null 表示未添加 + // 检查是否已存在相同的 url 和 title + const existingEpisode = episodeIds.find(episode => episode.url === url && episode.title === title); + if (existingEpisode) { + log("info", `Episode with URL ${url} and title ${title} already exists in episodeIds, returning existing episode.`); + return existingEpisode; // 返回已存在的 episode } // 自增 episodeNum 并使用作为 id @@ -244,7 +269,7 @@ function addEpisode(url, title) { // 添加新对象 episodeIds.push(newEpisode); - log("log", `Added to episodeIds: ${JSON.stringify(newEpisode)}`); + log("info", `Added to episodeIds: ${JSON.stringify(newEpisode)}`); return newEpisode; // 返回新添加的对象 } @@ -254,7 +279,7 @@ function removeEpisodeByUrl(url) { episodeIds = episodeIds.filter(episode => episode.url !== url); const removedCount = initialLength - episodeIds.length; if (removedCount > 0) { - log("log", `Removed ${removedCount} episode(s) from episodeIds with URL: ${url}`); + log("info", `Removed ${removedCount} episode(s) from episodeIds with URL: ${url}`); return true; } log("error", `No episode found in episodeIds with URL: ${url}`); @@ -265,7 +290,7 @@ function removeEpisodeByUrl(url) { function findUrlById(id) { const episode = episodeIds.find(episode => episode.id === id); if (episode) { - log("log", `Found URL for ID ${id}: ${episode.url}`); + log("info", `Found URL for ID ${id}: ${episode.url}`); return episode.url; } log("error", `No URL found for ID: ${id}`); @@ -276,7 +301,7 @@ function findUrlById(id) { function findTitleById(id) { const episode = episodeIds.find(episode => episode.id === id); if (episode) { - log("log", `Found TITLE for ID ${id}: ${episode.title}`); + log("info", `Found TITLE for ID ${id}: ${episode.title}`); return episode.title; } log("error", `No TITLE found for ID: ${id}`); @@ -312,7 +337,7 @@ function addAnime(anime) { // 添加到 animes animes.push(animeCopy); - log("log", `Added anime: ${JSON.stringify(animeCopy)}`); + log("info", `Added anime: ${JSON.stringify(animeCopy)}`); // 检查是否超过 MAX_ANIMES,超过则删除最早的 if (animes.length > MAX_ANIMES) { @@ -331,7 +356,7 @@ function removeEarliestAnime() { // 移除最早的 anime(第一个元素) const removedAnime = animes.shift(); - log("log", `Removed earliest anime: ${JSON.stringify(removedAnime)}`); + log("info", `Removed earliest anime: ${JSON.stringify(removedAnime)}`); // 从 episodeIds 删除该 anime 的所有 links 中的 url if (removedAnime.links && Array.isArray(removedAnime.links)) { @@ -345,12 +370,72 @@ function removeEarliestAnime() { return true; } +// 将所有动漫的 animeId 存入 lastSelectMap 的 animeIds 数组中 +function storeAnimeIdsToMap(curAnimes, key) { + const uniqueAnimeIds = new Set(); + for (const anime of curAnimes) { + uniqueAnimeIds.add(anime.animeId); + } + + // 保存旧的prefer值 + const oldPrefer = lastSelectMap[key]?.prefer; + + // 如果key已存在,先删除它(为了更新顺序) + if (lastSelectMap[key]) { + delete lastSelectMap[key]; + } + + // 添加新记录,保留prefer字段 + lastSelectMap[key] = { + animeIds: [...uniqueAnimeIds], + ...(oldPrefer !== undefined && { prefer: oldPrefer }) + }; + + // 检查大小限制 + const keys = Object.keys(lastSelectMap); + if (keys.length > MAX_LAST_SELECT_MAP) { + // 删除最早的记录(第一个key) + delete lastSelectMap[keys[0]]; + } +} + +// 根据给定的 commentId 查找对应的 animeId +function findAnimeIdByCommentId(commentId) { + for (const anime of animes) { + for (const link of anime.links) { + if (link.id === commentId) { + return anime.animeId; + } + } + } + return null; +} + +// 通过 animeId 查找 lastSelectMap 中 animeIds 包含该 animeId 的 key,并设置其 prefer 为 animeId +function setPreferByAnimeId(animeId) { + for (const key in lastSelectMap) { + if (lastSelectMap[key].animeIds.includes(animeId)) { + lastSelectMap[key].prefer = animeId; + return key; // 返回被修改的 key + } + } + return null; // 如果没有找到匹配的 key,返回 null +} + +// 通过title查询优选animeId +function getPreferAnimeId(title) { + if (!lastSelectMap[title] || !lastSelectMap[title]["prefer"]) { + return null; + } + return lastSelectMap[title]["prefer"]; +} + // ===================== // 请求工具方法 // ===================== async function httpGet(url, options) { - log("log", `[iOS模拟] HTTP GET: ${url}`); + log("info", `[iOS模拟] HTTP GET: ${url}`); try { const response = await fetch(url, { @@ -367,7 +452,7 @@ async function httpGet(url, options) { let data; if (options.base64Data) { - log("log", "base64模式"); + log("info", "base64模式"); // 先拿二进制 const arrayBuffer = await response.arrayBuffer(); @@ -383,7 +468,7 @@ async function httpGet(url, options) { data = btoa(binary); // 得到 base64 字符串 } else if (options.zlibMode) { - log("log", "zlib模式") + log("info", "zlib模式") // 获取 ArrayBuffer const arrayBuffer = await response.arrayBuffer(); @@ -455,7 +540,7 @@ async function httpGet(url, options) { } async function httpPost(url, body, options = {}) { - log("log", `[iOS模拟] HTTP POST: ${url}`); + log("info", `[iOS模拟] HTTP POST: ${url}`); // 处理请求头、body 和其他参数 const { headers = {}, params, allow_redirects = true } = options; @@ -507,6 +592,130 @@ async function httpPost(url, body, options = {}) { } } +// ===================== +// upstash redis 读写请求 (先简单实现,不加锁) +// ===================== + +// 使用 GET 发送简单命令(如 PING 检查连接) +async function pingRedis() { + const url = `${redisUrl}/ping`; + try { + const response = await fetch(url, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${redisToken}` + } + }); + return await response.json(); // 预期: ["PONG"] + } catch (error) { + log("error", `[redis] 请求失败:`, error.message); + log("error", '- 错误类型:', error.name); + if (error.cause) { + log("error", '- 码:', error.cause.code); // e.g., 'ECONNREFUSED', 'ETIMEDOUT', 'ECONNRESET' + log("error", '- 原因:', error.cause.message); + } + } +} + +// 使用 GET 发送 GET 命令(读取键值) +async function getRedisKey(key) { + const url = `${redisUrl}/get/${key}`; + try { + const response = await fetch(url, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${redisToken}` + } + }); + return await response.json(); // 预期: ["value"] 或 null + } catch (error) { + log("error", `[redis] 请求失败:`, error.message); + log("error", '- 错误类型:', error.name); + if (error.cause) { + log("error", '- 码:', error.cause.code); // e.g., 'ECONNREFUSED', 'ETIMEDOUT', 'ECONNRESET' + log("error", '- 原因:', error.cause.message); + } + } +} + +// 使用 POST 发送 SET 命令(写入键值,值在请求体中) +async function setRedisKey(key, value) { + const url = `${redisUrl}/set/${key}`; + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${redisToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(value) // 值作为 body 发送,支持 JSON 或字符串 + }); + return await response.json(); // 预期: ["OK"] + } catch (error) { + log("error", `[redis] 请求失败:`, error.message); + log("error", '- 错误类型:', error.name); + if (error.cause) { + log("error", '- 码:', error.cause.code); // e.g., 'ECONNREFUSED', 'ETIMEDOUT', 'ECONNRESET' + log("error", '- 原因:', error.cause.message); + } + } +} + +// 复杂命令,如 SETEX(设置键值并过期,使用查询参数) +async function setRedisKeyWithExpiry(key, value, expirySeconds) { + const url = `${redisUrl}/set/${key}?EX=${expirySeconds}`; + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${redisToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(value) + }); + return await response.json(); + } catch (error) { + log("error", `[redis] 请求失败:`, error.message); + log("error", '- 错误类型:', error.name); + if (error.cause) { + log("error", '- 码:', error.cause.code); // e.g., 'ECONNREFUSED', 'ETIMEDOUT', 'ECONNRESET' + 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; + } +} + +// 存储更新后的变量到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) + ]); +} + // ===================== // 中文繁简转化 // ===================== @@ -558,14 +767,14 @@ async function get360Animes(title) { ); const data = response.data; - log("log", "360kan response:", data); + log("info", "360kan response:", data); let animes = []; if ('rows' in data.data.longData) { animes = data.data.longData.rows; } - log("log", `360kan animes.length: ${animes.length}`); + log("info", `360kan animes.length: ${animes.length}`); return animes; } catch (error) { @@ -579,7 +788,7 @@ async function get360Animes(title) { } // 查询360kan综艺详情 -async function get360Zongyi(entId, site, year) { +async function get360Zongyi(title, entId, site, year) { try { let links = []; for (let j = 0; j <= 10; j++) { @@ -594,18 +803,39 @@ async function get360Zongyi(entId, site, year) { ); const data = await response.data; - log("log", "360kan zongyi response:", data); + log("info", "360kan zongyi response:", data); const episodeList = data.data.list; if (!episodeList) { break; } for (const episodeInfo of episodeList) { - links.push({"name": episodeInfo.id, "url": episodeInfo.url, "title": `【${site}】${episodeInfo.name}(${episodeInfo.period})`}); + // Extract episode number from episodeInfo.name (e.g., "第10期下:地球团熟人局大胆开麦,做晚宴超催泪" -> "10") + const epNumMatch = episodeInfo.name.match(/第(\d+)期([上中下])?/) || episodeInfo.period.match(/第(\d+)期([上中下])?/); + let epNum = epNumMatch ? epNumMatch[1] : null; + if (epNum && epNumMatch[2]) { + epNum = epNumMatch[2] === "上" ? `${epNum}.1` : + epNumMatch[2] === "中" ? `${epNum}.2` : `${epNum}.3`; + } + + links.push({ + "name": episodeInfo.id, + "url": episodeInfo.url, + "title": `【${site}】${title}(${year}) #${episodeInfo.name} ${episodeInfo.period}#`, + "sort": epNum || episodeInfo.sort || null + }); } - log("log", `links.length: ${links.length}`); + log("info", `links.length: ${links.length}`); } + // Sort links by pubdate numerically + links.sort((a, b) => { + if (!a.sort || !b.sort) return 0; + const aNum = parseFloat(a.sort); + const bNum = parseFloat(b.sort); + return aNum - bNum; + }); + return links; } catch (error) { log("error", "get360Animes error:", { @@ -631,13 +861,13 @@ async function getVodAnimes(title, server) { ); // 检查 response.data.list 是否存在且长度大于 0 if (response && response.data && response.data.list && response.data.list.length > 0) { - log("log", `请求 ${vodServer} 成功`); + log("info", `请求 ${vodServer} 成功`); const data = response.data; - log("log", "vod response: ↓↓↓"); + log("info", "vod response: ↓↓↓"); printFirst200Chars(data); return data.list; } else { - log("log", `请求 ${vodServer} 成功,但 response.data.list 为空`); + log("info", `请求 ${vodServer} 成功,但 response.data.list 为空`); return []; } } catch (error) { @@ -668,7 +898,7 @@ function printFirst200Chars(data) { return; } - log("log", dataToPrint.slice(0, 200)); // 打印前200个字符 + log("info", dataToPrint.slice(0, 200)); // 打印前200个字符 } function groupDanmusByMinute(filteredDanmus, n) { @@ -823,16 +1053,16 @@ function convertToDanmakuJson(contents, platform) { // 去除两边的 `/` 并转化为正则 return new RegExp(pattern.slice(1, -1)); } catch (e) { - console.error(`无效的正则表达式: ${pattern}`, e); + log("error", `无效的正则表达式: ${pattern}`, e); return null; } } return null; // 如果不是有效的正则格式则返回 null }).filter(regex => regex !== null); // 过滤掉无效的项 - log("log", "原始屏蔽词字符串:", blockedWords); + log("info", "原始屏蔽词字符串:", blockedWords); const regexArrayToString = array => Array.isArray(array) ? array.map(regex => regex.toString()).join('\n') : String(array); - log("log", "屏蔽词列表:", regexArrayToString(regexArray)); + log("info", "屏蔽词列表:", regexArrayToString(regexArray)); // 过滤列表 const filteredDanmus = danmus.filter(item => { @@ -840,14 +1070,14 @@ function convertToDanmakuJson(contents, platform) { }); // 按n分钟内去重 - log("log", "去重分钟数:", groupMinute); + log("info", "去重分钟数:", groupMinute); const groupedDanmus = groupDanmusByMinute(filteredDanmus, groupMinute); - log("log", "danmus_original:", danmus.length); - log("log", "danmus_filter:", filteredDanmus.length); - log("log", "danmus_group:", groupedDanmus.length); + log("info", "danmus_original:", danmus.length); + log("info", "danmus_filter:", filteredDanmus.length); + log("info", "danmus_group:", groupedDanmus.length); // 输出前五条弹幕 - log("log", "Top 5 danmus:", JSON.stringify(groupedDanmus.slice(0, 5), null, 2)); + log("info", "Top 5 danmus:", JSON.stringify(groupedDanmus.slice(0, 5), null, 2)); return groupedDanmus; } @@ -1268,7 +1498,7 @@ function encryptStr(str) { // ===================== async function fetchTencentVideo(inputUrl) { - log("log", "开始从本地请求腾讯视频弹幕...", inputUrl); + log("info", "开始从本地请求腾讯视频弹幕...", inputUrl); // 弹幕 API 基础地址 const api_danmaku_base = "https://dm.video.qq.com/barrage/base/"; @@ -1287,7 +1517,7 @@ async function fetchTencentVideo(inputUrl) { vid = lastPart.split('.')[0]; // 去除文件扩展名 } - log("log", "vid:", vid); + log("info", "vid:", vid); // 获取页面标题 let res; @@ -1306,7 +1536,7 @@ async function fetchTencentVideo(inputUrl) { // 使用正则表达式提取 标签内容 const titleMatch = res.data.match(/<title[^>]*>(.*?)<\/title>/i); const title = titleMatch ? titleMatch[1].split("_")[0] : "未知标题"; - log("log", "标题:", title); + log("info", "标题:", title); // 获取弹幕基础数据 try { @@ -1341,7 +1571,7 @@ async function fetchTencentVideo(inputUrl) { ); } - log("log", "弹幕分段数量:", promises.length); + log("info", "弹幕分段数量:", promises.length); // 解析弹幕数据 let contents = []; @@ -1405,7 +1635,7 @@ async function fetchTencentVideo(inputUrl) { // ===================== async function fetchIqiyi(inputUrl) { - log("log", "开始从本地请求爱奇艺弹幕...", inputUrl); + log("info", "开始从本地请求爱奇艺弹幕...", inputUrl); // 弹幕 API 基础地址 const api_decode_base = "https://pcw-api.iq.com/api/decode/"; @@ -1421,7 +1651,7 @@ async function fetchIqiyi(inputUrl) { return []; } tvid = idMatch[1]; - log("log", "tvid:", tvid); + log("info", "tvid:", tvid); // 获取 tvid 的解码信息 const decodeUrl = `${api_decode_base}${tvid}?platformId=3&modeCode=intl&langCode=sg`; @@ -1433,7 +1663,7 @@ async function fetchIqiyi(inputUrl) { }); const data = typeof res.data === "string" ? JSON.parse(res.data) : res.data; tvid = data.data.toString(); - log("log", "解码后 tvid:", tvid); + log("info", "解码后 tvid:", tvid); } catch (error) { log("error", "请求解码信息失败:", error); return []; @@ -1455,7 +1685,7 @@ async function fetchIqiyi(inputUrl) { duration = videoInfo.durationSec; albumid = videoInfo.albumId; categoryid = videoInfo.channelId || videoInfo.categoryId; - log("log", "标题:", title, "时长:", duration); + log("info", "标题:", title, "时长:", duration); } catch (error) { log("error", "请求视频基础信息失败:", error); return []; @@ -1463,7 +1693,7 @@ async function fetchIqiyi(inputUrl) { // 计算弹幕分段数量(每5分钟一个分段) const page = Math.ceil(duration / (60 * 5)); - log("log", "弹幕分段数量:", page); + log("info", "弹幕分段数量:", page); // 构建弹幕请求 const promises = []; @@ -1549,7 +1779,7 @@ async function fetchIqiyi(inputUrl) { // ===================== async function fetchMangoTV(inputUrl) { - log("log", "开始从本地请求芒果TV弹幕...", inputUrl); + log("info", "开始从本地请求芒果TV弹幕...", inputUrl); // 弹幕和视频信息 API 基础地址 const api_video_info = "https://pcweb.api.mgtv.com/video/info"; @@ -1563,7 +1793,7 @@ async function fetchMangoTV(inputUrl) { let path; if (match) { path = match[2].split('/').filter(Boolean); // 分割路径并去掉空字符串 - log("log", path); + log("info", path); } else { log("error", 'Invalid URL'); return []; @@ -1571,7 +1801,7 @@ async function fetchMangoTV(inputUrl) { const cid = path[path.length - 2]; const vid = path[path.length - 1].split(".")[0]; - log("log", "cid:", cid, "vid:", vid); + log("info", "cid:", cid, "vid:", vid); // 获取页面标题和视频时长 let res; @@ -1584,14 +1814,14 @@ async function fetchMangoTV(inputUrl) { }, }); } catch (error) { - log("log", "请求视频信息失败:", error); + log("info", "请求视频信息失败:", error); return []; } 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); + log("info", "标题:", title); // 计算弹幕分段请求 const step = 60 * 1000; // 每60秒一个分段 @@ -1609,7 +1839,7 @@ async function fetchMangoTV(inputUrl) { ); } - log("log", "弹幕分段数量:", promises.length); + log("info", "弹幕分段数量:", promises.length); // 解析弹幕数据 let contents = []; @@ -1659,7 +1889,7 @@ async function fetchMangoTV(inputUrl) { // ===================== async function fetchBilibili(inputUrl) { - log("log", "开始从本地请求B站弹幕...", inputUrl); + log("info", "开始从本地请求B站弹幕...", inputUrl); // 弹幕和视频信息 API 基础地址 const api_video_info = "https://api.bilibili.com/x/web-interface/view"; @@ -1674,7 +1904,7 @@ async function fetchBilibili(inputUrl) { if (match) { path = match[2].split('/').filter(Boolean); // 分割路径并去掉空字符串 path.unshift(""); - log("log", path); + log("info", path); } else { log("error", 'Invalid URL'); return []; @@ -1699,7 +1929,7 @@ async function fetchBilibili(inputUrl) { } } } - log("log", "p: ", p); + log("info", "p: ", p); let videoInfoUrl; if (inputUrl.includes("BV")) { @@ -1773,11 +2003,11 @@ async function fetchBilibili(inputUrl) { log("error", "不支持的B站视频网址,仅支持普通视频(av,bv)、剧集视频(ep)"); return []; } - log("log", danmakuUrl, cid, aid, duration); + log("info", danmakuUrl, cid, aid, duration); // 计算视频的分片数量 const maxLen = Math.floor(duration / 360) + 1; - log("log", "maxLen: ", maxLen); + log("info", "maxLen: ", maxLen); const segmentList = []; for (let i = 0; i < maxLen; i += 1) { @@ -1799,7 +2029,7 @@ async function fetchBilibili(inputUrl) { try { const allComments = await Promise.all( segmentList.map(async (segment) => { - log("log", "正在请求弹幕数据...", segment.url); + log("info", "正在请求弹幕数据...", segment.url); try { // 请求单个分片的弹幕数据 let res = await httpGet(segment.url, { @@ -1845,7 +2075,7 @@ function convertYoukuUrl(url) { } async function fetchYouku(inputUrl) { - log("log", "开始从本地请求优酷弹幕...", inputUrl); + log("info", "开始从本地请求优酷弹幕...", inputUrl); if (!inputUrl) { return []; @@ -1863,14 +2093,14 @@ async function fetchYouku(inputUrl) { if (match) { path = match[2].split('/').filter(Boolean); // 分割路径并去掉空字符串 path.unshift(""); - log("log", path); + log("info", path); } else { log("error", 'Invalid URL'); return []; } const video_id = path[path.length - 1].split(".")[0].slice(3); - log("log", "video_id:", video_id); + log("info", "video_id:", video_id); // 获取页面标题和视频时长 let res; @@ -1891,7 +2121,7 @@ async function fetchYouku(inputUrl) { const data = typeof res.data === "string" ? JSON.parse(res.data) : res.data; const title = data.title; const duration = data.duration; - log("log", "标题:", title, "时长:", duration); + log("info", "标题:", title, "时长:", duration); // 获取 cna 和 tk_enc let cna, _m_h5_tk_enc, _m_h5_tk; @@ -1905,14 +2135,14 @@ async function fetchYouku(inputUrl) { }, allow_redirects: false }); - log("log", "cnaRes: ", cnaRes); - log("log", "cnaRes.headers: ", cnaRes.headers); + log("info", "cnaRes: ", cnaRes); + log("info", "cnaRes.headers: ", cnaRes.headers); const etag = cnaRes.headers["etag"] || cnaRes.headers["Etag"]; - log("log", "etag: ", etag); + log("info", "etag: ", etag); // const match = cnaRes.headers["set-cookie"].match(/cna=([^;]+)/); // cna = match ? match[1] : null; cna = etag.replace(/^"|"$/g, ''); - log("log", "cna: ", cna); + log("info", "cna: ", cna); let tkEncRes; while (!tkEncRes) { @@ -1924,10 +2154,10 @@ async function fetchYouku(inputUrl) { allow_redirects: false }); } - log("log", "tkEncRes: ", tkEncRes); - log("log", "tkEncRes.headers: ", tkEncRes.headers); + log("info", "tkEncRes: ", tkEncRes); + log("info", "tkEncRes.headers: ", tkEncRes.headers); const tkEncSetCookie = tkEncRes.headers["set-cookie"] || tkEncRes.headers["Set-Cookie"]; - log("log", "tkEncSetCookie: ", tkEncSetCookie); + log("info", "tkEncSetCookie: ", tkEncSetCookie); // 获取 _m_h5_tk_enc const tkEncMatch = tkEncSetCookie.match(/_m_h5_tk_enc=([^;]+)/); @@ -1937,8 +2167,8 @@ async function fetchYouku(inputUrl) { 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); + log("info", "_m_h5_tk_enc:", _m_h5_tk_enc); + log("info", "_m_h5_tk:", _m_h5_tk); } catch (error) { log("error", "获取 cna 或 tk_enc 失败:", error); return []; @@ -2022,7 +2252,7 @@ async function fetchYouku(inputUrl) { const queryString = buildQueryString(params); const url = `${api_danmaku}?${queryString}`; - log("log", "piece_url: ", url); + log("info", "piece_url: ", url); const response = await httpPost(url, buildQueryString({ data: data }), { headers: { @@ -2105,7 +2335,7 @@ async function fetchOtherServer(inputUrl) { } ); - log("log", `danmu response from ${otherServer}: ↓↓↓`); + log("info", `danmu response from ${otherServer}: ↓↓↓`); printFirst200Chars(response.data); return convertToDanmakuJson(response.data, "other_server"); @@ -2833,13 +3063,13 @@ function formatRenrenComments(items) { async function getRenRenComments(episodeId, progressCallback=null){ if(progressCallback) await progressCallback(5,"开始获取弹幕人人弹幕"); - log("log", "开始获取弹幕人人弹幕"); + log("info", "开始获取弹幕人人弹幕"); const raw = await fetchEpisodeDanmu(episodeId); if(progressCallback) await progressCallback(85,`原始弹幕 ${raw.length} 条,正在规范化`); - log("log", `原始弹幕 ${raw.length} 条,正在规范化`); + log("info", `原始弹幕 ${raw.length} 条,正在规范化`); const formatted = formatRenrenComments(raw); if(progressCallback) await progressCallback(100,`弹幕处理完成,共 ${formatted.length} 条`); - log("log", `弹幕处理完成,共 ${formatted.length} 条`); + log("info", `弹幕处理完成,共 ${formatted.length} 条`); return convertToDanmakuJson(formatted, "renren"); } @@ -2857,18 +3087,18 @@ async function hanjutvSearch(keyword) { // 判断 resp 和 resp.data 是否存在 if (!resp || !resp.data) { - log("log", "hanjutvSearchresp: 请求失败或无数据返回"); + log("info", "hanjutvSearchresp: 请求失败或无数据返回"); return []; } // 判断 seriesData 是否存在 if (!resp.data.seriesData || !resp.data.seriesData.seriesList) { - log("log", "hanjutvSearchresp: seriesData 或 seriesList 不存在"); + log("info", "hanjutvSearchresp: seriesData 或 seriesList 不存在"); return []; } // 正常情况下输出 JSON 字符串 - log("log", `hanjutvSearchresp: ${JSON.stringify(resp.data.seriesData.seriesList)}`); + log("info", `hanjutvSearchresp: ${JSON.stringify(resp.data.seriesData.seriesList)}`); let resList = []; for (const anime of resp.data.seriesData.seriesList) { @@ -2898,18 +3128,18 @@ async function getHanjutvDetail(sid) { // 判断 resp 和 resp.data 是否存在 if (!resp || !resp.data) { - log("log", "getHanjutvDetail: 请求失败或无数据返回"); + log("info", "getHanjutvDetail: 请求失败或无数据返回"); return []; } // 判断 seriesData 是否存在 if (!resp.data.series) { - log("log", "getHanjutvDetail: series 不存在"); + log("info", "getHanjutvDetail: series 不存在"); return []; } // 正常情况下输出 JSON 字符串 - log("log", `getHanjutvDetail: ${JSON.stringify(resp.data.series)}`); + log("info", `getHanjutvDetail: ${JSON.stringify(resp.data.series)}`); return resp.data.series; } catch (error) { @@ -2934,20 +3164,20 @@ async function getHanjutvEpisodes(sid) { // 判断 resp 和 resp.data 是否存在 if (!resp || !resp.data) { - log("log", "getHanjutvEposides: 请求失败或无数据返回"); + log("info", "getHanjutvEposides: 请求失败或无数据返回"); return []; } // 判断 seriesData 是否存在 if (!resp.data.episodes) { - log("log", "getHanjutvEposides: episodes 不存在"); + log("info", "getHanjutvEposides: episodes 不存在"); return []; } const sortedEpisodes = resp.data.episodes.sort((a, b) => a.serialNo - b.serialNo); // 正常情况下输出 JSON 字符串 - log("log", `getHanjutvEposides: ${JSON.stringify(sortedEpisodes)}`); + log("info", `getHanjutvEposides: ${JSON.stringify(sortedEpisodes)}`); return sortedEpisodes; } catch (error) { @@ -3011,13 +3241,13 @@ function formatHanjutvComments(items) { async function getHanjutvComments(pid, progressCallback=null){ if(progressCallback) await progressCallback(5,"开始获取弹幕韩剧TV弹幕"); - log("log", "开始获取弹幕韩剧TV弹幕"); + log("info", "开始获取弹幕韩剧TV弹幕"); const raw = await fetchHanjutvEpisodeDanmu(pid); if(progressCallback) await progressCallback(85,`原始弹幕 ${raw.length} 条,正在规范化`); - log("log", `原始弹幕 ${raw.length} 条,正在规范化`); + log("info", `原始弹幕 ${raw.length} 条,正在规范化`); const formatted = formatHanjutvComments(raw); if(progressCallback) await progressCallback(100,`弹幕处理完成,共 ${formatted.length} 条`); - log("log", `弹幕处理完成,共 ${formatted.length} 条`); + log("info", `弹幕处理完成,共 ${formatted.length} 条`); return convertToDanmakuJson(formatted, "hanjutv"); } @@ -3036,18 +3266,18 @@ async function bahamutSearch(keyword) { // 判断 resp 和 resp.data 是否存在 if (!resp || !resp.data) { - log("log", "bahamutSearchresp: 请求失败或无数据返回"); + log("info", "bahamutSearchresp: 请求失败或无数据返回"); return []; } // 判断 anime 是否存在 if (!resp.data.anime) { - log("log", "bahamutSearchresp: anime 不存在"); + log("info", "bahamutSearchresp: anime 不存在"); return []; } // 正常情况下输出 JSON 字符串 - log("log", `bahamutSearchresp: ${JSON.stringify(resp.data.anime)}`); + log("info", `bahamutSearchresp: ${JSON.stringify(resp.data.anime)}`); return resp.data.anime; } catch (error) { @@ -3073,18 +3303,18 @@ async function getBahamutEpisodes(videoSn) { // 判断 resp 和 resp.data 是否存在 if (!resp || !resp.data) { - log("log", "getBahamutEposides: 请求失败或无数据返回"); + log("info", "getBahamutEposides: 请求失败或无数据返回"); return []; } // 判断 seriesData 是否存在 if (!resp.data.data || !resp.data.data.video || !resp.data.data.anime) { - log("log", "getBahamutEposides: video 或 anime 不存在"); + log("info", "getBahamutEposides: video 或 anime 不存在"); return []; } // 正常情况下输出 JSON 字符串 - log("log", `getBahamutEposides: ${JSON.stringify(resp.data.data)}`); + log("info", `getBahamutEposides: ${JSON.stringify(resp.data.data)}`); return resp.data.data; } catch (error) { @@ -3139,15 +3369,15 @@ function formatBahamutComments(items) { async function getBahamutComments(pid, progressCallback=null){ if(progressCallback) await progressCallback(5,"开始获取弹幕巴哈姆特弹幕"); - log("log", "开始获取弹幕巴哈姆特弹幕"); + log("info", "开始获取弹幕巴哈姆特弹幕"); const raw = await fetchBahamutEpisodeDanmu(pid); if(progressCallback) await progressCallback(85,`原始弹幕 ${raw.length} 条,正在规范化`); - log("log", `原始弹幕 ${raw.length} 条,正在规范化`); + log("info", `原始弹幕 ${raw.length} 条,正在规范化`); const formatted = formatBahamutComments(raw); if(progressCallback) await progressCallback(100,`弹幕处理完成,共 ${formatted.length} 条`); - log("log", `弹幕处理完成,共 ${formatted.length} 条`); + log("info", `弹幕处理完成,共 ${formatted.length} 条`); // 输出前五条弹幕 - log("log", "Top 5 danmus:", JSON.stringify(formatted.slice(0, 5), null, 2)); + log("info", "Top 5 danmus:", JSON.stringify(formatted.slice(0, 5), null, 2)); return formatted; } @@ -3367,7 +3597,7 @@ async function handle360Animes(animes360, curAnimes) { if (vodAllowedPlatforms.includes(site)) { const yearLinks = await Promise.all( anime.playlinks_year[site].map(async (year) => { - return await get360Zongyi(anime.id, site, year); + return await get360Zongyi(anime.titleTxt, anime.id, site, year); }) ); return yearLinks.flat(); // 将每个年份的子链接合并到一个数组 @@ -3451,7 +3681,7 @@ async function handleRenrenAnimes(animesRenren, queryTitle, curAnimes) { } async function handleHanjutvAnimes(animesHanjutv, queryTitle, curAnimes) { - const cateMap = {1: "韩剧", 2: "综艺", 3: "电影", 5: "美剧"} + const cateMap = {1: "韩剧", 2: "综艺", 3: "电影", 4: "日剧", 5: "美剧", 6: "泰剧", 7: "国产剧"} function getCategory(key) { return cateMap[key] || "其他"; @@ -3554,13 +3784,13 @@ async function handleBahamutAnimes(animesBahamut, queryTitle, curAnimes) { // Extracted function for GET /api/v2/search/anime async function searchAnime(url) { const queryTitle = url.searchParams.get("keyword"); - log("log", `Search anime with keyword: ${queryTitle}`); + log("info", `Search anime with keyword: ${queryTitle}`); const curAnimes = []; try { // 根据 sourceOrderArr 动态构建请求数组 - log("log", `Search sourceOrderArr: ${sourceOrderArr}`); + log("info", `Search sourceOrderArr: ${sourceOrderArr}`); const requestPromises = sourceOrderArr.map(source => { if (source === "vod") return getVodAnimes(queryTitle, vodServer); if (source === "vod2") return getVodAnimes(queryTitle, vodServer2); @@ -3610,6 +3840,13 @@ async function searchAnime(url) { log("error", "发生错误:", error); } + storeAnimeIdsToMap(curAnimes, queryTitle); + + // 如果有新的anime获取到,则更新redis + if (redisValid && curAnimes.length !== 0) { + await updateCaches(); + } + return jsonResponse({ errorCode: 0, success: true, @@ -3618,24 +3855,43 @@ async function searchAnime(url) { }); } -async function matchAniAndEp(season, episode, searchData, title, req, platform) { +function filterSameEpisodeTitle(filteredTmpEpisodes) { + const filteredEpisodes = filteredTmpEpisodes.filter((episode, index, episodes) => { + const filterEp = extractEpTitle(episode.episodeTitle); + // 如果没有提取到标题,返回 true 保留该 episode + if (!filterEp) return true; + // 查找当前 episode 标题是否在之前的 episodes 中出现过 + return !episodes.slice(0, index).some(prevEpisode => { + const prevFilterEp = extractEpTitle(prevEpisode.episodeTitle); + return prevFilterEp === filterEp; + }); + }); + return filteredEpisodes; +} + +async function matchAniAndEp(season, episode, searchData, title, req, platform, preferAnimeId) { let resAnime; let resEpisode; if (season && episode) { // 判断剧集 for (const anime of searchData.animes) { + if (preferAnimeId && anime.bangumiId.toString() !== preferAnimeId.toString()) continue; if (anime.animeTitle.includes(title)) { let originBangumiUrl = new URL(req.url.replace("/match", `bangumi/${anime.bangumiId}`)); const bangumiRes = await getBangumi(originBangumiUrl.pathname); const bangumiData = await bangumiRes.json(); log("info", "判断剧集", bangumiData); - // 过滤符合条件的 episodes - const filteredEpisodes = bangumiData.bangumi.episodes.filter(episode => { + // 过滤集标题正则条件的 episode + const filteredTmpEpisodes = bangumiData.bangumi.episodes.filter(episode => { const filterEp = extractEpTitle(episode.episodeTitle); return filterEp && !episodeTitleFilter.test(filterEp); // 如果##中的内容匹配正则表达式 }); + // 过滤集标题一致的 episode,且保留首次出现的集标题的 episode + const filteredEpisodes = filterSameEpisodeTitle(filteredTmpEpisodes); + log("info", "过滤后的集标题", filteredEpisodes.map(episode => episode.episodeTitle)); + if (platform) { const firstIndex = filteredEpisodes.findIndex(episode => extractTitle(episode.episodeTitle) === platform); const indexCount = filteredEpisodes.filter(episode => extractTitle(episode.episodeTitle) === platform).length; @@ -3662,6 +3918,7 @@ async function matchAniAndEp(season, episode, searchData, title, req, platform) } else { // 判断电影 for (const anime of searchData.animes) { + if (preferAnimeId && anime.bangumiId.toString() !== preferAnimeId.toString()) continue; const animeTitle = anime.animeTitle.split("(")[0].trim(); if (animeTitle === title) { let originBangumiUrl = new URL(req.url.replace("/match", `bangumi/${anime.bangumiId}`)); @@ -3690,6 +3947,38 @@ async function matchAniAndEp(season, episode, searchData, title, req, platform) return {resEpisode, resAnime}; } +async function fallbackMatchAniAndEp(searchData, req, season, episode, resEpisode, resAnime) { + for (const anime of searchData.animes) { + let originBangumiUrl = new URL(req.url.replace("/match", `bangumi/${anime.bangumiId}`)); + const bangumiRes = await getBangumi(originBangumiUrl.pathname); + const bangumiData = await bangumiRes.json(); + log("info", bangumiData); + if (season && episode) { + // 过滤集标题正则条件的 episode + const filteredTmpEpisodes = bangumiData.bangumi.episodes.filter(episode => { + const filterEp = extractEpTitle(episode.episodeTitle); + return filterEp && !episodeTitleFilter.test(filterEp); // 如果##中的内容匹配正则表达式 + }); + + // 过滤集标题一致的 episode,且保留首次出现的集标题的 episode + const filteredEpisodes = filterSameEpisodeTitle(filteredTmpEpisodes); + + if (filteredEpisodes.length >= episode) { + resEpisode = filteredEpisodes[episode - 1]; + resAnime = anime; + break; + } + } else { + if (bangumiData.bangumi.episodes.length > 0) { + resEpisode = bangumiData.bangumi.episodes[0]; + resAnime = anime; + break; + } + } + } + return {resEpisode, resAnime}; +} + // Extracted function for POST /api/v2/match async function matchAnime(url, req) { try { @@ -3735,6 +4024,10 @@ async function matchAnime(url, req) { const searchData = await searchRes.json(); log("info", `searchData: ${searchData.animes}`); + // 获取prefer animeId + const preferAnimeId = getPreferAnimeId(title); + log("info", `prefer animeId: ${preferAnimeId}`); + let resAnime; let resEpisode; @@ -3745,7 +4038,7 @@ async function matchAnime(url, req) { log("info", `Preferred platform: ${preferredPlatform || 'none'}`); for (const platform of dynamicPlatformOrder) { - const __ret = await matchAniAndEp(season, episode, searchData, title, req, platform); + const __ret = await matchAniAndEp(season, episode, searchData, title, req, platform, preferAnimeId); resEpisode = __ret.resEpisode; resAnime = __ret.resAnime; @@ -3757,31 +4050,9 @@ async function matchAnime(url, req) { // 如果都没有找到则返回第一个满足剧集数的剧集 if (!resAnime) { - for (const anime of searchData.animes) { - let originBangumiUrl = new URL(req.url.replace("/match", `bangumi/${anime.bangumiId}`)); - const bangumiRes = await getBangumi(originBangumiUrl.pathname); - const bangumiData = await bangumiRes.json(); - log("info", bangumiData); - if (season && episode) { - // 过滤符合条件的 episodes - const filteredEpisodes = bangumiData.bangumi.episodes.filter(episode => { - const filterEp = extractEpTitle(episode.episodeTitle); - return filterEp && !episodeTitleFilter.test(filterEp); // 如果##中的内容匹配正则表达式 - }); - - if (filteredEpisodes.length >= episode) { - resEpisode = filteredEpisodes[episode-1]; - resAnime = anime; - break; - } - } else { - if (bangumiData.bangumi.episodes.length > 0) { - resEpisode = bangumiData.bangumi.episodes[0]; - resAnime = anime; - break; - } - } - } + const __ret = await fallbackMatchAniAndEp(searchData, req, season, episode, resEpisode, resAnime); + resEpisode = __ret.resEpisode; + resAnime = __ret.resAnime; } let resData = { @@ -3827,7 +4098,7 @@ async function searchEpisodes(url) { const anime = url.searchParams.get("anime"); const episode = url.searchParams.get("episode") || ""; - log("log", `Search episodes with anime: ${anime}, episode: ${episode}`); + log("info", `Search episodes with anime: ${anime}, episode: ${episode}`); if (!anime) { log("error", "Missing anime parameter"); @@ -3843,7 +4114,7 @@ async function searchEpisodes(url) { const searchData = await searchRes.json(); if (!searchData.success || !searchData.animes || searchData.animes.length === 0) { - log("log", "No anime found for the given title"); + log("info", "No anime found for the given title"); return jsonResponse({ errorCode: 0, success: true, @@ -3901,7 +4172,7 @@ async function searchEpisodes(url) { } } - log("log", `Found ${resultAnimes.length} animes with filtered episodes`); + log("info", `Found ${resultAnimes.length} animes with filtered episodes`); return jsonResponse({ errorCode: 0, @@ -3922,7 +4193,7 @@ async function getBangumi(path) { 404 ); } - log("log", `Fetched details for anime ID: ${animeId}`); + log("info", `Fetched details for anime ID: ${animeId}`); let resData = { errorCode: 0, @@ -3971,14 +4242,14 @@ async function getComment(path) { let url = findUrlById(commentId); let title = findTitleById(commentId); let plat = (title.match(/【(.*?)】/) || [null])[0]?.replace(/[【】]/g, ''); - log("log", "comment url...", url); - log("log", "comment title...", title); - log("log", "comment platform...", plat); + log("info", "comment url...", url); + log("info", "comment title...", title); + log("info", "comment platform...", plat); if (!url) { log("error", `Comment with ID ${commentId} not found`); return jsonResponse({ count: 0, comments: [] }, 404); } - log("log", `Fetched comment ID: ${commentId}`); + log("info", `Fetched comment ID: ${commentId}`); // 处理302场景 // https://v.youku.com/video?vid=XNjQ4MTIwOTE2NA==&tpa=dW5pb25faWQ9MTAyMjEzXzEwMDAwNl8wMV8wMQ需要转成https://v.youku.com/v_show/id_XNjQ4MTIwOTE2NA==.html @@ -3986,7 +4257,7 @@ async function getComment(path) { url = convertYoukuUrl(url); } - log("log", "开始从本地请求弹幕...", url); + log("info", "开始从本地请求弹幕...", url); let danmus = []; if (url.includes('.qq.com')) { danmus = await fetchTencentVideo(url); @@ -4004,7 +4275,7 @@ async function getComment(path) { danmus = await fetchYouku(url); } - // 请求人人弹幕 + // 请求其他平台弹幕 const urlPattern = /^(https?:\/\/)?([\w.-]+)\.([a-z]{2,})(\/.*)?$/i; if (!urlPattern.test(url)) { if (plat === "renren") { @@ -4021,6 +4292,12 @@ async function getComment(path) { danmus = await fetchOtherServer(url); } + const animeId = findAnimeIdByCommentId(commentId); + setPreferByAnimeId(animeId); + if (redisValid && animeId) { + await setRedisKey('lastSelectMap', lastSelectMap); + } + return jsonResponse({ count: danmus.length, comments: danmus }); } @@ -4042,13 +4319,27 @@ async function handleRequest(req, env, deployPlatform, clientIp) { platformOrderArr = resolvePlatformOrder(env); envs["platformOrderArr"] = platformOrderArr; episodeTitleFilter = resolveEpisodeTitleFilter(env); - envs["episodeTitleFilter"] = episodeTitleFilter; + envs["episodeTitleFilter"] = episodeTitleFilter.toString(); blockedWords = resolveBlockedWords(env); envs["blockedWords"] = blockedWords; groupMinute = resolveGroupMinute(env); envs["groupMinute"] = groupMinute; proxyUrl = resolveProxyUrl(env); envs["proxyUrl"] = proxyUrl; + redisUrl = resolveRedisUrl(env); + envs["redisUrl"] = encryptStr(redisUrl); + redisToken = resolveRedisToken(env); + envs["redisToken"] = encryptStr(redisToken); + envs["redisValid"] = redisValid; + + // 测试redis是否可用 + if (!redisValid && redisUrl && redisToken) { + const res = await pingRedis(); + if (res && res.result && res.result === "PONG") { + redisValid = true; + envs["redisValid"] = redisValid; + } + } const url = new URL(req.url); let path = url.pathname; @@ -4057,8 +4348,12 @@ async function handleRequest(req, env, deployPlatform, clientIp) { log("info", `request url: ${JSON.stringify(url)}`); log("info", `client ip: ${clientIp}`); + if (redisValid && path !== "/favicon.ico" && path !== "/robots.txt") { + await getCaches(); + } + function handleHomepage() { - log("log", "Accessed homepage with repository information"); + log("info", "Accessed homepage with repository information"); return jsonResponse({ message: "Welcome to the LogVar Danmu API server", version: VERSION, @@ -4090,7 +4385,7 @@ async function handleRequest(req, env, deployPlatform, clientIp) { // 移除 token 部分,剩下的才是真正的路径 path = "/" + parts.slice(1).join("/"); - log("log", path); + log("info", path); // 智能处理API路径前缀,确保最终有一个正确的 /api/v2 if (path !== "/" && path !== "/api/logs") { @@ -4236,4 +4531,5 @@ export async function vercelHandler(req, res) { // 为了测试导出 handleRequest export { handleRequest, searchAnime, searchEpisodes, matchAnime, getBangumi, getComment, fetchTencentVideo, fetchIqiyi, fetchMangoTV, fetchBilibili, fetchYouku, fetchOtherServer, httpGet, httpPost, hanjutvSearch, getHanjutvEpisodes, - getHanjutvComments, getHanjutvDetail, bahamutSearch, getBahamutEpisodes, getBahamutComments}; + getHanjutvComments, getHanjutvDetail, bahamutSearch, getBahamutEpisodes, getBahamutComments, pingRedis, getRedisKey, + setRedisKey, setRedisKeyWithExpiry}; diff --git a/danmu_api/worker.test.js b/danmu_api/worker.test.js index 2e5247f..a569c19 100644 --- a/danmu_api/worker.test.js +++ b/danmu_api/worker.test.js @@ -3,7 +3,8 @@ const assert = require('node:assert').strict; const { handleRequest, searchAnime, matchAnime, searchEpisode, getBangumi, getComment, fetchTencentVideo, fetchIqiyi, fetchMangoTV, fetchBilibili, fetchYouku, fetchOtherServer, httpGet, httpPost, hanjutvSearch, getHanjutvEpisodes, getHanjutvComments, getHanjutvDetail, - bahamutSearch, getBahamutEpisodes, getBahamutComments} = require('./worker'); + bahamutSearch, getBahamutEpisodes, getBahamutComments, pingRedis, getRedisKey, + setRedisKey, setRedisKeyWithExpiry} = require('./worker'); // Mock Request class for testing class MockRequest { @@ -189,4 +190,25 @@ test('worker.js API endpoints', async (t) => { // assert.equal(res.status, 200); // assert.deepEqual(responseBody.success, true); // }); + + // 测试upstash redis + // await t.test('GET redis pingRedis', async () => { + // const res = await pingRedis(); + // assert(res.result === "PONG", `Expected res.result === "PONG", but got ${res.result}`); + // }); + + // await t.test('SET redis setRedisKey', async () => { + // const res = await setRedisKey('mykey', 'Hello World'); + // assert(res.result === "OK", `Expected res.result === "OK", but got ${res.result}`); + // }); + + // await t.test('GET redis getRedisKey', async () => { + // const res = await getRedisKey('mykey'); + // assert(res.result.toString() === "\"Hello World\"", `Expected res.result === "\"Hello World\"", but got ${res.result}`); + // }); + + // await t.test('SET redis setRedisKeyWithExpiry', async () => { + // const res = await setRedisKeyWithExpiry('expkey', 'Temporary Value', 10); + // assert(res.result === "OK", `Expected res.result === "OK", but got ${res.result}`); + // }); }); \ No newline at end of file