From bc320de259ca7111aacd506a40937026cbaea1b3 Mon Sep 17 00:00:00 2001
From: huangxd- <649924757@qq.com>
Date: Sun, 12 Oct 2025 22:30:45 +0800
Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0upstash=20redis=E7=9A=84?=
=?UTF-8?q?=E6=94=AF=E6=8C=81=EF=BC=8C=E5=B9=B6=E4=BF=AE=E5=A4=8D=E4=B8=80?=
=?UTF-8?q?=E4=BA=9Bbug=20(#45)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* 韩剧tv增加分类类型
* claw部署修改为不稳定不推荐
* 修复vod中集标题可能重复的问题,将后面出现的相同集标题的episode过滤,只对match自动匹配接口有效
* 修复360综艺集标题命名规范与vod不一致的问题
* 360综艺集可能存在乱序的情况,获取集后尝试进行排序
* 修改vod默认源为https://gctf.tfdh.top
* 增加upstash redis支持,用于持久化存储,不会因为冷启动而丢失过去的查询信息(在cf/eo/claw上配置后应该能更稳定点,但会稍微影响请求速度)
* 修复vod和vod2如果url重复导致vod2中episodes不全的问题
* 支持记住之前搜索title时手动选择的anime,并在后续的match自动匹配时优选该anime【实验性】
修复home页面json中episodeTitleFilter实际存在值但没有打印的问题
* commentId增加lastSelectMap上传redis
* 过滤favicon.ico请求的redis请求
---
README.md | 17 +-
danmu_api/worker.js | 590 +++++++++++++++++++++++++++++----------
danmu_api/worker.test.js | 24 +-
3 files changed, 477 insertions(+), 154 deletions(-)
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>/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