feat: 添加日志级别配置和弹幕缓存独立配置,修复 lastSelectMap Map 序列化 BUG (#63)

* Update README.md

* feat: 添加日志级别配置和弹幕缓存独立配置,修复 lastSelectMap Map 序列化 BUG

新增功能:
- 添加 LOG_LEVEL 环境变量支持(error/warn/info 三个级别,默认 warn)
- 添加 COMMENT_CACHE_MINUTES 环境变量支持(弹幕缓存时间独立配置)
- 改进 lastSelectMap 为 Map 对象,支持 FIFO 删除和更好的性能

BUG 修复:
- 修复 lastSelectMap Map 对象序列化问题,防止 Redis 缓存数据丢失
- 新增 serializeValue() 函数处理 Map 对象的正确序列化
- 修复 getRedisCaches() 中的哈希值计算
- 修复 updateRedisCaches() 中的序列化逻辑
- 修复 setRedisKey() 和 setRedisKeyWithExpiry() 中的数据序列化

改进:
- 增强 addAnime() 函数的错误处理(try-catch)
- 改进日志输出格式(使用 JSON.stringify)
- 新增 cleanupExpiredIPs() 函数清理过期的限流 IP 记录
- 优化 Redis 缓存初始化逻辑

文档更新:
- 更新 .env.example 配置文件
- 更新 README.md 功能说明和环境变量文档

* fix: 修复 JSON 日期格式导致小幻客户端反序列化失败

问题描述
========
小幻客户端在搜索"琉璃的宝石"时报错:
System.Text.Json.JsonException: The JSON value could not be converted to
System.Nullable`1[System.DateTimeOffset].
Path: $.bangumi.episodes[0].airDate

根本原因
========
VOD 源(如听风)返回的年份为 0 时,生成的 startDate 格式为 "0-01-01T00:00:00",
不符合 ISO 8601 标准,导致 .NET 的 DateTimeOffset 无法解析。

修复方案
========
1. 添加 generateValidStartDate() 函数
   - 验证年份是否在 1900-2100 范围内
   - 无效年份自动使用当前年份
   - 返回标准 ISO 8601 格式:YYYY-01-01T00:00:00Z

2. 更新所有 6 个数据源的日期生成逻辑
   - VOD 源(听风等)
   - 360kan
   - 人人视频
   - hanjutv
   - bahamut
   - 腾讯视频

修改文件
========
- danmu_api/worker.js
  * 第 1405-1413 行:添加 generateValidStartDate() 函数
  * 第 4539 行:VOD 源日期修复
  * 第 4603 行:360kan 日期修复
  * 第 4641 行:人人视频日期修复
  * 第 4690 行:hanjutv 日期修复
  * 第 4734 行:bahamut 日期修复
  * 第 4782 行:腾讯视频日期修复

测试结果
========
✅ 10 个单元测试通过
✅ 6 个 API 响应场景测试通过
✅ 代码语法检查通过

备注
====
- hanjutv 搜索日志 "seriesData 或 seriesList 不存在" 是正常行为
- 该修复确保所有数据源返回的日期格式都符合 ISO 8601 标准
- 小幻客户端现在能够正确解析 DateTimeOffset 类型
This commit is contained in:
woleigedouer authored and GitHub committed 2025-10-19 14:17:37 +08:00
1 parent 5df03312d0
commit 9dc8886fc4
3 files changed
+327 -146

No files matched your search

+18 -1
View File
@@ -103,7 +103,18 @@ ENABLE_EPISODE_FILTER=false
# 格式:http://proxy-server:port 或 https://proxy-server:port
# PROXY_URL=
# ==================== 搜索结果缓存配置 ====================
# ==================== 日志配置 ====================
# 日志级别(可选值:error, warn, info)
# 默认值:warn
# 说明:
# - error: 仅显示错误日志
# - warn: 显示错误和警告日志
# - info: 显示所有日志(包括详细的调试信息)
# 建议:生产环境使用 warn,调试时使用 info
LOG_LEVEL=warn
# ==================== 缓存配置 ====================
# 搜索结果缓存时间(分钟)
# 默认值:1
@@ -112,6 +123,12 @@ ENABLE_EPISODE_FILTER=false
# 设置为 0 表示不缓存
SEARCH_CACHE_MINUTES=1
# 弹幕缓存时间(分钟)
# 默认值:1
# 说明:弹幕数据的缓存时间,独立于搜索结果缓存
# 可根据需要调整
COMMENT_CACHE_MINUTES=1
# ==================== Redis 配置(Upstash)====================
# Upstash Redis REST URL(用于缓存)
+10 -3
View File
@@ -37,7 +37,11 @@ LogVar 弹幕 API 服务器
- `POST /api/v2/comment/by-url`:通过视频URL直接获取弹幕。
- `GET /api/logs`:获取最近的日志(最多 500 行,格式为 `[时间戳] 级别: 消息`)。
- **日志记录**:捕获 `console.log`(info 级别)和 `console.error`(error 级别),JSON 内容格式化输出。
- **搜索结果缓存**:支持内存缓存搜索结果和弹幕数据,避免短期内重复的不必要API请求,可通过 `SEARCH_CACHE_MINUTES` 环境变量配置缓存时间(默认1分钟)。
- **智能缓存管理**:支持内存缓存搜索结果和弹幕数据,避免短期内重复的不必要API请求。包括:
- 搜索结果缓存(可通过 `SEARCH_CACHE_MINUTES` 配置,默认1分钟)
- 弹幕缓存(可通过 `COMMENT_CACHE_MINUTES` 配置,默认5分钟)
- 用户偏好记录(可通过 `MAX_LAST_SELECT_MAP` 配置,默认100条)
- Redis 分布式缓存支持(可选)
- **部署支持**:支持本地运行、Docker 容器化、Vercel 一键部署、Netlify 一键部署、Cloudflare 一键部署和 Docker 一键启动。
- **手动选择记忆**:支持记住之前搜索title时手动选择的anime,并在后续的match自动匹配时优选该anime【实验性】。
- **手动搜索支持输入播放链接获取弹幕**:支持手动搜索的播放器输入爱优腾芒哔播放链接可获取弹幕,如`senplayer`。
@@ -263,9 +267,12 @@ LogVar 弹幕 API 服务器
| GROUP_MINUTE | 【可选】合并去重分钟数,表示按n分钟分组后对弹幕合并去重,默认为1,最大值为30,0表示不去重 |
| PROXY_URL | 【可选】代理地址,示例: `http://127.0.0.1:7897` ,目前只对巴哈姆特生效(注意:如果巴哈姆特请求不通,会拖慢搜索返回速度,所以除vercel/netlify/cloudflare之外默认不开启bahamut源,开启请先在SOURCE_ORDER环境变量中添加`bahamut`)如果你使用docker部署并且访问不了bahamut源,请配置代理地址;vercel/netlify/cf中理应都自然能联通,不用填写 |
| RATE_LIMIT_MAX_REQUESTS | 【可选】限流配置:1分钟内同一IP最大请求次数,默认为`3`,设置为`0`表示不限流 |
| LOG_LEVEL | 【可选】日志级别,默认为`warn`,可选值:`error`(仅错误)、`warn`(错误和警告)、`info`(所有日志),生产环境建议使用`warn`,调试时使用`info` |
| SEARCH_CACHE_MINUTES | 【可选】搜索结果缓存时间(分钟),默认为`1`,避免短期内重复的不必要API请求,同时保证获取最新的结果列表,可根据需要调整:Vercel/Cloudflare建议`1-5`分钟,Docker可设置`5-30`分钟,设置为`0`表示不缓存 |
| 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` |
| COMMENT_CACHE_MINUTES | 【可选】弹幕缓存时间(分钟),默认为`1`,弹幕数据的缓存时间,独立于搜索结果缓存 |
| MAX_LAST_SELECT_MAP | 【可选】最后选择映射缓存大小限制,默认为`100`,lastSelectMap最多保存的条目数,超过限制时删除最早的条目(FIFO),用于存储查询关键字上次选择的animeId |
| UPSTASH_REDIS_REST_URL | 【可选】Upstash redis url,需配合UPSTASH_REDIS_REST_TOKEN使用,用于持久化存储,不会因为冷启动而丢失过去的查询信息(在cf/eo/claw上配置后应该能更稳定点,也能解决小幻掉匹配的问题,但会稍微影响请求速度),获取方法请参考:`https://cloud.tencent.cn/developer/article/2424508` |
| UPSTASH_REDIS_REST_TOKEN | 【可选】Upstash redis token,需配合UPSTASH_REDIS_REST_URL使用,用于持久化存储,不会因为冷启动而丢失过去的查询信息(在cf/eo/claw上配置后应该能更稳定点,也能解决小幻掉匹配的问题,但会稍微影响请求速度),获取方法请参考:`https://cloud.tencent.cn/developer/article/2424508` |
```regex
# EPISODE_TITLE_FILTER 默认值
+299 -142
View File
@@ -26,6 +26,8 @@ let envs = {};
const requestHistory = new Map();
// redis是否生效
let redisValid = false;
// Redis 缓存是否已初始化
let redisCacheInitialized = false;
// 存储查询关键字上次选择的animeId,用于下次match自动匹配时优先选择该anime
let lastSelectMap = new Map();
// 存储上一次各变量哈希值
@@ -337,6 +339,26 @@ function resolveEnableEpisodeFilter(env) {
return DEFAULT_ENABLE_EPISODE_FILTER;
}
// 日志级别配置(默认 warn,可选值:error, warn, info)
const DEFAULT_LOG_LEVEL = "warn";
let logLevel = DEFAULT_LOG_LEVEL;
function resolveLogLevel(env) {
let level = DEFAULT_LOG_LEVEL;
if (env && env.LOG_LEVEL) {
level = env.LOG_LEVEL.toLowerCase();
} else if (typeof process !== "undefined" && process.env?.LOG_LEVEL) {
level = process.env.LOG_LEVEL.toLowerCase();
}
// 验证日志级别
const validLevels = ["error", "warn", "info"];
if (!validLevels.includes(level)) {
return DEFAULT_LOG_LEVEL;
}
return level;
}
// 搜索结果缓存时间配置(分钟,默认 1)
const DEFAULT_SEARCH_CACHE_MINUTES = 1;
let searchCacheMinutes = DEFAULT_SEARCH_CACHE_MINUTES;
@@ -353,6 +375,22 @@ function resolveSearchCacheMinutes(env) {
return DEFAULT_SEARCH_CACHE_MINUTES;
}
// 弹幕缓存时间配置(分钟,默认 1)
const DEFAULT_COMMENT_CACHE_MINUTES = 1;
let commentCacheMinutes = DEFAULT_COMMENT_CACHE_MINUTES;
function resolveCommentCacheMinutes(env) {
if (env && env.COMMENT_CACHE_MINUTES) {
const n = parseInt(env.COMMENT_CACHE_MINUTES, 10);
if (!Number.isNaN(n) && n > 0) return n;
}
if (typeof process !== "undefined" && process.env?.COMMENT_CACHE_MINUTES) {
const n = parseInt(process.env.COMMENT_CACHE_MINUTES, 10);
if (!Number.isNaN(n) && n > 0) return n;
}
return DEFAULT_COMMENT_CACHE_MINUTES;
}
// =====================
// 数据结构处理函数
// =====================
@@ -396,6 +434,7 @@ function setSearchCache(keyword, results) {
results: results,
timestamp: Date.now()
});
log("info", `Cached search results for "${keyword}" (${results.length} animes)`);
}
@@ -409,7 +448,7 @@ function isCommentCacheValid(videoUrl) {
const now = Date.now();
const cacheAgeMinutes = (now - cached.timestamp) / (1000 * 60);
if (cacheAgeMinutes > searchCacheMinutes) {
if (cacheAgeMinutes > commentCacheMinutes) {
// 缓存已过期,删除它
commentCache.delete(videoUrl);
log("info", `Comment cache for "${videoUrl}" expired after ${cacheAgeMinutes.toFixed(2)} minutes`);
@@ -434,6 +473,7 @@ function setCommentCache(videoUrl, comments) {
comments: comments,
timestamp: Date.now()
});
log("info", `Cached comments for "${videoUrl}" (${comments.length} comments)`);
}
@@ -452,6 +492,7 @@ function addEpisode(url, title) {
// 添加新对象
episodeIds.push(newEpisode);
log("info", `Added to episodeIds: ${JSON.stringify(newEpisode)}`);
return newEpisode; // 返回新添加的对象
}
@@ -493,49 +534,57 @@ function findTitleById(id) {
// 添加 anime 对象到 animes,并将其 links 添加到 episodeIds
function addAnime(anime) {
// 确保 anime 有 links 属性且是数组
if (!anime.links || !Array.isArray(anime.links)) {
log("error", `Invalid or missing links in anime: ${JSON.stringify(anime)}`);
try {
// 确保 anime 有 links 属性且是数组
if (!anime.links || !Array.isArray(anime.links)) {
log("error", `Invalid or missing links in anime: ${JSON.stringify(anime)}`);
return false;
}
// 遍历 links,调用 addEpisode,并收集返回的对象
const newLinks = [];
anime.links.forEach(link => {
if (link.url) {
const episode = addEpisode(link.url, link.title);
if (episode) {
newLinks.push(episode); // 仅添加成功添加的 episode
}
} else {
log("error", `Invalid link in anime, missing url: ${JSON.stringify(link)}`);
}
});
// 创建新的 anime 副本
const animeCopy = { ...anime, links: newLinks };
// 检查是否已存在相同 animeId 的 anime
const existingAnimeIndex = animes.findIndex(a => a.animeId === anime.animeId);
if (existingAnimeIndex !== -1) {
// 如果存在,先删除旧的
animes.splice(existingAnimeIndex, 1);
log("info", `Removed old anime at index: ${existingAnimeIndex}`);
}
// 将新的添加到数组末尾(最新位置)
animes.push(animeCopy);
log("info", `Added anime to latest position: ${anime.animeId}`);
// 检查是否超过 MAX_ANIMES,超过则删除最早的
if (animes.length > MAX_ANIMES) {
const removeSuccess = removeEarliestAnime();
if (!removeSuccess) {
log("error", "Failed to remove earliest anime, but continuing");
}
}
log("info", `animes: ${JSON.stringify(animes)}`);
return true;
} catch (error) {
log("error", `addAnime failed: ${error.message}`);
return false;
}
// 遍历 links,调用 addEpisode,并收集返回的对象
const newLinks = [];
anime.links.forEach(link => {
if (link.url) {
const episode = addEpisode(link.url, link.title);
if (episode) {
newLinks.push(episode); // 仅添加成功添加的 episode
}
} else {
log("error", `Invalid link in anime, missing url: ${JSON.stringify(link)}`);
}
});
// 创建新的 anime 副本
const animeCopy = { ...anime, links: newLinks };
// 检查是否已存在相同 animeId 的 anime
const existingAnimeIndex = animes.findIndex(a => a.animeId === anime.animeId);
if (existingAnimeIndex !== -1) {
// 如果存在,先删除旧的
animes.splice(existingAnimeIndex, 1);
log("info", `Removed old anime at index: ${existingAnimeIndex}`);
}
// 将新的添加到数组末尾(最新位置)
animes.push(animeCopy);
log("info", `Added anime to latest position: ${anime.animeId}`);
// 检查是否超过 MAX_ANIMES,超过则删除最早的
if (animes.length > MAX_ANIMES) {
removeEarliestAnime();
}
log("info", "animes: ", animes);
return true;
}
// 删除最早添加的 anime,并从 episodeIds 删除其 links 中的 url
@@ -569,24 +618,25 @@ function storeAnimeIdsToMap(curAnimes, key) {
}
// 保存旧的prefer值
const oldPrefer = lastSelectMap[key]?.prefer;
const oldValue = lastSelectMap.get(key);
const oldPrefer = oldValue?.prefer;
// 如果key已存在,先删除它(为了更新顺序)
if (lastSelectMap[key]) {
delete lastSelectMap[key];
// 如果key已存在,先删除它(为了更新顺序,保证 FIFO)
if (lastSelectMap.has(key)) {
lastSelectMap.delete(key);
}
// 添加新记录,保留prefer字段
lastSelectMap[key] = {
lastSelectMap.set(key, {
animeIds: [...uniqueAnimeIds],
...(oldPrefer !== undefined && { prefer: oldPrefer })
};
});
// 检查大小限制
const keys = Object.keys(lastSelectMap);
if (keys.length > MAX_LAST_SELECT_MAP) {
// 删除最早的记录(第一个key)
delete lastSelectMap[keys[0]];
// 检查是否超过 MAX_LAST_SELECT_MAP,超过则删除最早的
if (lastSelectMap.size > MAX_LAST_SELECT_MAP) {
const firstKey = lastSelectMap.keys().next().value;
lastSelectMap.delete(firstKey);
log("info", `Removed earliest entry from lastSelectMap: ${firstKey}`);
}
}
@@ -604,9 +654,10 @@ function findAnimeIdByCommentId(commentId) {
// 通过 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;
for (const [key, value] of lastSelectMap.entries()) {
if (value.animeIds && value.animeIds.includes(animeId)) {
value.prefer = animeId;
lastSelectMap.set(key, value); // 确保更新被保存
return key; // 返回被修改的 key
}
}
@@ -615,10 +666,11 @@ function setPreferByAnimeId(animeId) {
// 通过title查询优选animeId
function getPreferAnimeId(title) {
if (!lastSelectMap[title] || !lastSelectMap[title]["prefer"]) {
const value = lastSelectMap.get(title);
if (!value || !value.prefer) {
return null;
}
return lastSelectMap[title]["prefer"];
return value.prefer;
}
// =====================
@@ -839,6 +891,31 @@ async function getPageTitle(url) {
}
}
// =====================
// 限流 IP 清理函数
// =====================
// 清理所有过期的 IP 记录(超过 1 分钟没有请求的 IP)
function cleanupExpiredIPs(currentTime) {
const oneMinute = 60 * 1000;
let cleanedCount = 0;
for (const [ip, timestamps] of requestHistory.entries()) {
const validTimestamps = timestamps.filter(ts => currentTime - ts <= oneMinute);
if (validTimestamps.length === 0) {
requestHistory.delete(ip);
cleanedCount++;
log("info", `[Rate Limit] Cleaned up expired IP record: ${ip}`);
} else if (validTimestamps.length < timestamps.length) {
requestHistory.set(ip, validTimestamps);
}
}
if (cleanedCount > 0) {
log("info", `[Rate Limit] Cleanup completed: removed ${cleanedCount} expired IP records`);
}
}
// =====================
// upstash redis 读写请求 (先简单实现,不加锁)
// =====================
@@ -887,9 +964,18 @@ async function getRedisKey(key) {
}
}
// 辅助函数:序列化值,处理 Map 对象
function serializeValue(key, value) {
// 对于 lastSelectMap(Map 对象),需要转换为普通对象后再序列化
if (key === 'lastSelectMap' && value instanceof Map) {
return JSON.stringify(Object.fromEntries(value));
}
return JSON.stringify(value);
}
// 使用 POST 发送 SET 命令,仅在值变化时更新
async function setRedisKey(key, value) {
const serializedValue = JSON.stringify(value);
const serializedValue = serializeValue(key, value);
const currentHash = simpleHash(serializedValue);
// 检查值是否变化
@@ -925,7 +1011,7 @@ async function setRedisKey(key, value) {
// 使用 POST 发送 SETEX 命令,仅在值变化时更新
async function setRedisKeyWithExpiry(key, value, expirySeconds) {
const serializedValue = JSON.stringify(value);
const serializedValue = serializeValue(key, value);
const currentHash = simpleHash(serializedValue);
// 检查值是否变化
@@ -986,58 +1072,100 @@ async function runPipeline(commands) {
// 优化后的 getRedisCaches,单次请求获取所有键
async function getRedisCaches() {
if (animes.length === 0) {
log("info", 'getCaches start.');
const keys = ['animes', 'episodeIds', 'episodeNum', 'lastSelectMap'];
const commands = keys.map(key => ['GET', key]); // 构造 pipeline 命令
const results = await runPipeline(commands);
if (!redisCacheInitialized) {
try {
log("info", 'getRedisCaches start.');
const keys = ['animes', 'episodeIds', 'episodeNum', 'lastSelectMap'];
const commands = keys.map(key => ['GET', key]); // 构造 pipeline 命令
const results = await runPipeline(commands);
// 解析结果,按顺序赋值
animes = results[0].result ? JSON.parse(results[0].result) : animes;
episodeIds = results[1].result ? JSON.parse(results[1].result) : episodeIds;
episodeNum = results[2].result ? JSON.parse(results[2].result) : episodeNum;
lastSelectMap = results[3].result ? JSON.parse(results[3].result) : lastSelectMap;
// 解析结果,按顺序赋值
animes = results[0].result ? JSON.parse(results[0].result) : animes;
episodeIds = results[1].result ? JSON.parse(results[1].result) : episodeIds;
episodeNum = results[2].result ? JSON.parse(results[2].result) : episodeNum;
// 更新哈希值
lastHashes.animes = simpleHash(JSON.stringify(animes));
lastHashes.episodeIds = simpleHash(JSON.stringify(episodeIds));
lastHashes.episodeNum = simpleHash(JSON.stringify(episodeNum));
lastHashes.lastSelectMap = simpleHash(JSON.stringify(lastSelectMap));
// 恢复 lastSelectMap 并转换为 Map 对象
const lastSelectMapData = results[3].result ? JSON.parse(results[3].result) : null;
if (lastSelectMapData && typeof lastSelectMapData === 'object') {
lastSelectMap = new Map(Object.entries(lastSelectMapData));
log("info", `Restored lastSelectMap from Redis with ${lastSelectMap.size} entries`);
}
// 更新哈希值
lastHashes.animes = simpleHash(JSON.stringify(animes));
lastHashes.episodeIds = simpleHash(JSON.stringify(episodeIds));
lastHashes.episodeNum = simpleHash(JSON.stringify(episodeNum));
lastHashes.lastSelectMap = simpleHash(JSON.stringify(Object.fromEntries(lastSelectMap)));
redisCacheInitialized = true;
log("info", 'getRedisCaches completed successfully.');
} catch (error) {
log("error", `getRedisCaches failed: ${error.message}`, error.stack);
redisCacheInitialized = true; // 标记为已初始化,避免重复尝试
}
}
}
// 优化后的 updateRedisCaches,仅更新有变化的变量
async function updateRedisCaches() {
log("info", 'updateCaches start.');
const commands = [];
const updates = [];
try {
log("info", 'updateCaches start.');
const commands = [];
const updates = [];
// 检查每个变量的哈希值
const variables = [
{ key: 'animes', value: animes },
{ key: 'episodeIds', value: episodeIds },
{ key: 'episodeNum', value: episodeNum },
{ key: 'lastSelectMap', value: lastSelectMap }
];
// 检查每个变量的哈希值
const variables = [
{ key: 'animes', value: animes },
{ key: 'episodeIds', value: episodeIds },
{ key: 'episodeNum', value: episodeNum },
{ key: 'lastSelectMap', value: lastSelectMap }
];
for (const { key, value } of variables) {
const currentHash = simpleHash(JSON.stringify(value));
if (currentHash !== lastHashes[key]) {
commands.push(['SET', key, JSON.stringify(value)]);
updates.push({ key, hash: currentHash });
for (const { key, value } of variables) {
// 对于 lastSelectMap(Map 对象),需要转换为普通对象后再序列化
const serializedValue = key === 'lastSelectMap' ? JSON.stringify(Object.fromEntries(value)) : JSON.stringify(value);
const currentHash = simpleHash(serializedValue);
if (currentHash !== lastHashes[key]) {
commands.push(['SET', key, serializedValue]);
updates.push({ key, hash: currentHash });
}
}
}
// 如果有需要更新的键,执行 pipeline
if (commands.length > 0) {
log("info", `Updating ${commands.length} changed keys: ${updates.map(u => u.key).join(', ')}`);
await runPipeline(commands);
// 更新哈希值
updates.forEach(({ key, hash }) => {
lastHashes[key] = hash;
});
} else {
log("info", 'No changes detected, skipping Redis update.');
// 如果有需要更新的键,执行 pipeline
if (commands.length > 0) {
log("info", `Updating ${commands.length} changed keys: ${updates.map(u => u.key).join(', ')}`);
const results = await runPipeline(commands);
// 检查每个操作的结果
let successCount = 0;
let failureCount = 0;
if (Array.isArray(results)) {
results.forEach((result, index) => {
if (result && result.result === 'OK') {
successCount++;
} else {
failureCount++;
log("warn", `Failed to update Redis key: ${updates[index]?.key}, result: ${JSON.stringify(result)}`);
}
});
}
// 只有在所有操作都成功时才更新哈希值
if (failureCount === 0) {
updates.forEach(({ key, hash }) => {
lastHashes[key] = hash;
});
log("info", `Redis update completed successfully: ${successCount} keys updated`);
} else {
log("warn", `Redis update partially failed: ${successCount} succeeded, ${failureCount} failed`);
}
} else {
log("info", 'No changes detected, skipping Redis update.');
}
} catch (error) {
log("error", `updateRedisCaches failed: ${error.message}`, error.stack);
log("error", `Error details - Name: ${error.name}, Cause: ${error.cause ? error.cause.message : 'N/A'}`);
}
}
@@ -1092,7 +1220,7 @@ async function get360Animes(title) {
);
const data = response.data;
log("info", "360kan response:", data);
log("info", `360kan response: ${JSON.stringify(data)}`);
let animes = [];
if ('rows' in data.data.longData) {
@@ -1128,7 +1256,7 @@ async function get360Zongyi(title, entId, site, year) {
);
const data = await response.data;
log("info", "360kan zongyi response:", data);
log("info", `360kan zongyi response: ${JSON.stringify(data)}`);
const episodeList = data.data.list;
if (!episodeList) {
@@ -1273,6 +1401,17 @@ async function getVodAnimesFromFastestServer(title, servers) {
// 工具方法
// =====================
// 生成有效的 ISO 8601 日期格式
function generateValidStartDate(year) {
// 验证年份是否有效(1900-2100)
const yearNum = parseInt(year);
if (isNaN(yearNum) || yearNum < 1900 || yearNum > 2100) {
// 如果年份无效,使用当前年份
return `${new Date().getFullYear()}-01-01T00:00:00Z`;
}
return `${yearNum}-01-01T00:00:00Z`;
}
function printFirst200Chars(data) {
let dataToPrint;
@@ -1449,9 +1588,9 @@ function convertToDanmakuJson(contents, platform) {
return null; // 如果不是有效的正则格式则返回 null
}).filter(regex => regex !== null); // 过滤掉无效的项
log("info", "原始屏蔽词字符串:", blockedWords);
log("info", `原始屏蔽词字符串: ${blockedWords}`);
const regexArrayToString = array => Array.isArray(array) ? array.map(regex => regex.toString()).join('\n') : String(array);
log("info", "屏蔽词列表:", regexArrayToString(regexArray));
log("info", `屏蔽词列表: ${regexArrayToString(regexArray)}`);
// 过滤列表
const filteredDanmus = danmus.filter(item => {
@@ -1459,12 +1598,12 @@ function convertToDanmakuJson(contents, platform) {
});
// 按n分钟内去重
log("info", "去重分钟数:", groupMinute);
log("info", `去重分钟数: ${groupMinute}`);
const groupedDanmus = groupDanmusByMinute(filteredDanmus, groupMinute);
log("info", "danmus_original:", danmus.length);
log("info", "danmus_filter:", filteredDanmus.length);
log("info", "danmus_group:", groupedDanmus.length);
log("info", `danmus_original: ${danmus.length}`);
log("info", `danmus_filter: ${filteredDanmus.length}`);
log("info", `danmus_group: ${groupedDanmus.length}`);
// 输出前五条弹幕
log("info", "Top 5 danmus:", JSON.stringify(groupedDanmus.slice(0, 5), null, 2));
return groupedDanmus;
@@ -2266,7 +2405,7 @@ async function fetchTencentVideo(inputUrl) {
vid = lastPart.split('.')[0]; // 去除文件扩展名
}
log("info", "vid:", vid);
log("info", `vid: ${vid}`);
// 获取页面标题
let res;
@@ -2285,7 +2424,7 @@ async function fetchTencentVideo(inputUrl) {
// 使用正则表达式提取 <title> 标签内容
const titleMatch = res.data.match(/<title[^>]*>(.*?)<\/title>/i);
const title = titleMatch ? titleMatch[1].split("_")[0] : "未知标题";
log("info", "标题:", title);
log("info", `标题: ${title}`);
// 获取弹幕基础数据
try {
@@ -2320,7 +2459,7 @@ async function fetchTencentVideo(inputUrl) {
);
}
log("info", "弹幕分段数量:", promises.length);
log("info", `弹幕分段数量: ${promises.length}`);
// 解析弹幕数据
let contents = [];
@@ -2400,7 +2539,7 @@ async function fetchIqiyi(inputUrl) {
return [];
}
tvid = idMatch[1];
log("info", "tvid:", tvid);
log("info", `tvid: ${tvid}`);
// 获取 tvid 的解码信息
const decodeUrl = `${api_decode_base}${tvid}?platformId=3&modeCode=intl&langCode=sg`;
@@ -2412,7 +2551,7 @@ async function fetchIqiyi(inputUrl) {
});
const data = typeof res.data === "string" ? JSON.parse(res.data) : res.data;
tvid = data.data.toString();
log("info", "解码后 tvid:", tvid);
log("info", `解码后 tvid: ${tvid}`);
} catch (error) {
log("error", "请求解码信息失败:", error);
return [];
@@ -2434,7 +2573,7 @@ async function fetchIqiyi(inputUrl) {
duration = videoInfo.durationSec;
albumid = videoInfo.albumId;
categoryid = videoInfo.channelId || videoInfo.categoryId;
log("info", "标题:", title, "时长:", duration);
log("info", `标题: ${title}, 时长: ${duration}`);
} catch (error) {
log("error", "请求视频基础信息失败:", error);
return [];
@@ -2442,7 +2581,7 @@ async function fetchIqiyi(inputUrl) {
// 计算弹幕分段数量(每5分钟一个分段)
const page = Math.ceil(duration / (60 * 5));
log("info", "弹幕分段数量:", page);
log("info", `弹幕分段数量: ${page}`);
// 构建弹幕请求
const promises = [];
@@ -2550,7 +2689,7 @@ async function fetchMangoTV(inputUrl) {
const cid = path[path.length - 2];
const vid = path[path.length - 1].split(".")[0];
log("info", "cid:", cid, "vid:", vid);
log("info", `cid: ${cid}, vid: ${vid}`);
// 获取页面标题和视频时长
let res;
@@ -2570,7 +2709,7 @@ async function fetchMangoTV(inputUrl) {
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("info", "标题:", title);
log("info", `标题: ${title}`);
// 计算弹幕分段请求
const promises = [];
@@ -2601,7 +2740,7 @@ async function fetchMangoTV(inputUrl) {
return [];
}
log("info", "弹幕分段数量:", promises.length);
log("info", `弹幕分段数量: ${promises.length}`);
// 默认颜色值
const DEFAULT_COLOR_INT = -1;
@@ -2724,7 +2863,7 @@ async function fetchBilibili(inputUrl) {
}
}
}
log("info", "p: ", p);
log("info", `p: ${p}`);
let videoInfoUrl;
if (inputUrl.includes("BV")) {
@@ -2843,7 +2982,7 @@ async function fetchBilibili(inputUrl) {
// 计算视频的分片数量
const maxLen = Math.floor(duration / 360) + 1;
log("info", "maxLen: ", maxLen);
log("info", `maxLen: ${maxLen}`);
const segmentList = [];
for (let i = 0; i < maxLen; i += 1) {
@@ -2936,7 +3075,7 @@ async function fetchYouku(inputUrl) {
}
const video_id = path[path.length - 1].split(".")[0].slice(3);
log("info", "video_id:", video_id);
log("info", `video_id: ${video_id}`);
// 获取页面标题和视频时长
let res;
@@ -2957,7 +3096,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("info", "标题:", title, "时长:", duration);
log("info", `标题: ${title}, 时长: ${duration}`);
// 获取 cna 和 tk_enc
let cna, _m_h5_tk_enc, _m_h5_tk;
@@ -2971,14 +3110,14 @@ async function fetchYouku(inputUrl) {
},
allow_redirects: false
});
log("info", "cnaRes: ", cnaRes);
log("info", "cnaRes.headers: ", cnaRes.headers);
log("info", `cnaRes: ${JSON.stringify(cnaRes)}`);
log("info", `cnaRes.headers: ${JSON.stringify(cnaRes.headers)}`);
const etag = cnaRes.headers["etag"] || cnaRes.headers["Etag"];
log("info", "etag: ", etag);
log("info", `etag: ${etag}`);
// const match = cnaRes.headers["set-cookie"].match(/cna=([^;]+)/);
// cna = match ? match[1] : null;
cna = etag.replace(/^"|"$/g, '');
log("info", "cna: ", cna);
log("info", `cna: ${cna}`);
let tkEncRes;
while (!tkEncRes) {
@@ -2990,10 +3129,10 @@ async function fetchYouku(inputUrl) {
allow_redirects: false
});
}
log("info", "tkEncRes: ", tkEncRes);
log("info", "tkEncRes.headers: ", tkEncRes.headers);
log("info", `tkEncRes: ${JSON.stringify(tkEncRes)}`);
log("info", `tkEncRes.headers: ${JSON.stringify(tkEncRes.headers)}`);
const tkEncSetCookie = tkEncRes.headers["set-cookie"] || tkEncRes.headers["Set-Cookie"];
log("info", "tkEncSetCookie: ", tkEncSetCookie);
log("info", `tkEncSetCookie: ${tkEncSetCookie}`);
// 获取 _m_h5_tk_enc
const tkEncMatch = tkEncSetCookie.match(/_m_h5_tk_enc=([^;]+)/);
@@ -3003,8 +3142,8 @@ async function fetchYouku(inputUrl) {
const tkH5Match = tkEncSetCookie.match(/_m_h5_tk=([^;]+)/);
_m_h5_tk = tkH5Match ? tkH5Match[1] : null;
log("info", "_m_h5_tk_enc:", _m_h5_tk_enc);
log("info", "_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 [];
@@ -3088,7 +3227,7 @@ async function fetchYouku(inputUrl) {
const queryString = buildQueryString(params);
const url = `${api_danmaku}?${queryString}`;
log("info", "piece_url: ", url);
log("info", `piece_url: ${url}`);
const response = await httpPost(url, buildQueryString({ data: data }), {
headers: {
@@ -4223,6 +4362,13 @@ async function getBahamutComments(pid, progressCallback=null){
// =====================
function log(level, ...args) {
// 根据日志级别决定是否输出
const levels = { error: 0, warn: 1, info: 2 };
const currentLevelValue = levels[logLevel] !== undefined ? levels[logLevel] : 1;
if ((levels[level] || 0) > currentLevelValue) {
return; // 日志级别不符合,不输出
}
const message = args
.map((arg) => (typeof arg === "object" ? JSON.stringify(arg) : arg))
.join(" ");
@@ -4390,7 +4536,7 @@ async function handleVodAnimes(animesVod, curAnimes, key) {
type: anime.type_name,
typeDescription: anime.type_name,
imageUrl: anime.vod_pic,
startDate: `${anime.vod_year}-01-01T00:00:00`,
startDate: generateValidStartDate(anime.vod_year),
episodeCount: links.length,
rating: 0,
isFavorited: true,
@@ -4454,7 +4600,7 @@ async function handle360Animes(animes360, curAnimes) {
type: anime.cat_name,
typeDescription: anime.cat_name,
imageUrl: anime.cover,
startDate: `${anime.year}-01-01T00:00:00`,
startDate: generateValidStartDate(anime.year),
episodeCount: links.length,
rating: 0,
isFavorited: true,
@@ -4492,7 +4638,7 @@ async function handleRenrenAnimes(animesRenren, queryTitle, curAnimes) {
type: anime.type,
typeDescription: anime.type,
imageUrl: anime.imageUrl,
startDate: `${anime.year}-01-01T00:00:00`,
startDate: generateValidStartDate(anime.year),
episodeCount: links.length,
rating: 0,
isFavorited: true,
@@ -4541,7 +4687,7 @@ async function handleHanjutvAnimes(animesHanjutv, queryTitle, curAnimes) {
type: getCategory(detail.category),
typeDescription: getCategory(detail.category),
imageUrl: anime.image.thumb,
startDate: `${new Date(anime.updateTime).getFullYear()}-01-01T00:00:00`,
startDate: generateValidStartDate(new Date(anime.updateTime).getFullYear()),
episodeCount: links.length,
rating: detail.rank,
isFavorited: true,
@@ -4585,7 +4731,7 @@ async function handleBahamutAnimes(animesBahamut, queryTitle, curAnimes) {
type: "动漫",
typeDescription: "动漫",
imageUrl: anime.cover,
startDate: `${new Date(epData.anime.seasonStart).getFullYear()}-01-01T00:00:00`,
startDate: generateValidStartDate(new Date(epData.anime.seasonStart).getFullYear()),
episodeCount: links.length,
rating: detail.rating,
isFavorited: true,
@@ -4633,7 +4779,7 @@ async function handleTencentAnimes(animesTencent, queryTitle, curAnimes) {
type: anime.type,
typeDescription: anime.type,
imageUrl: anime.imageUrl,
startDate: `${anime.year}-01-01T00:00:00`,
startDate: generateValidStartDate(anime.year),
episodeCount: links.length,
rating: 0,
isFavorited: true,
@@ -4810,7 +4956,6 @@ async function searchAnime(url) {
// 只有当过滤后还有有效剧集时才保留该动漫
if (episodesList.length > 0) {
anime.episodes = episodesList;
validAnimes.push(anime);
}
}
@@ -5412,6 +5557,8 @@ async function getCommentByUrl(req) {
}
async function handleRequest(req, env, deployPlatform, clientIp) {
logLevel = resolveLogLevel(env); // 每次请求动态获取,确保热更新环境变量后也能生效
envs["logLevel"] = logLevel;
token = resolveToken(env); // 每次请求动态获取,确保热更新环境变量后也能生效
envs["token"] = encryptStr(token);
otherServer = resolveOtherServer(env);
@@ -5442,6 +5589,8 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
envs["proxyUrl"] = proxyUrl;
searchCacheMinutes = resolveSearchCacheMinutes(env);
envs["searchCacheMinutes"] = searchCacheMinutes;
commentCacheMinutes = resolveCommentCacheMinutes(env);
envs["commentCacheMinutes"] = commentCacheMinutes;
redisUrl = resolveRedisUrl(env);
envs["redisUrl"] = encryptStr(redisUrl);
redisToken = resolveRedisToken(env);
@@ -5596,6 +5745,9 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
const currentTime = Date.now();
const oneMinute = 60 * 1000; // 1分钟 = 60000 毫秒
// 清理所有过期的 IP 记录
cleanupExpiredIPs(currentTime);
// 检查该 IP 地址的历史请求
if (!requestHistory.has(clientIp)) {
// 如果该 IP 地址没有请求历史,初始化一个空队列
@@ -5609,7 +5761,7 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
// 如果最近的请求数量大于等于配置的限制次数,则限制请求
if (recentRequests.length >= rateLimitMaxRequests) {
// 清理过期记录(如果为空则删除键,避免内存泄漏)
// 更新请求历史(清理过期记录)
if (recentRequests.length === 0) {
requestHistory.delete(clientIp);
} else {
@@ -5626,7 +5778,12 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
recentRequests.push(currentTime);
// 更新该 IP 地址的请求历史
requestHistory.set(clientIp, recentRequests);
if (recentRequests.length === 0) {
// 如果没有最近的请求,删除该 IP 的记录以避免内存泄漏
requestHistory.delete(clientIp);
} else {
requestHistory.set(clientIp, recentRequests);
}
log("info", `[Rate Limit] Request counted for IP: ${clientIp}, count: ${recentRequests.length}/${rateLimitMaxRequests}`);
}