支持本地redis缓存配置 (#213)

* 弹幕点赞数改成镂空♡显示,数量使用下标数字,并增加环境变量LIKE_SWITCH开关该功能,默认开启

* 增加hanjutv日志打印

* 修改说明

* 支持本地redis缓存配置

* 增加render环境的判断

* Revert "增加render环境的判断"

This reverts commit 952257895a2705debf079485c8558969b46f986a.

* 修改注释

* 修复cloudflare不支持redis包安装的问题

* 修复cloudflare不支持redis包安装的问题
This commit is contained in:
huangxd- authored and GitHub committed 2026-03-01 13:46:28 +08:00
1 parent e22f81b5f3
commit 10b2ca89ae
17 files changed
+508 -37

No files matched your search

+7 -3
View File
@@ -20,7 +20,7 @@ LogVar 弹幕 API 服务器
一个人人都能部署的基于 js 的弹幕 API 服务器,支持爱优腾芒哔咪人韩巴狐乐西埋弹幕直接获取,兼容弹弹play的搜索、详情查询和弹幕获取接口规范,并提供日志记录,支持vercel/netlify/edgeone/cloudflare/docker/claw等部署方式,不用提前下载弹幕,没有nas或小鸡也能一键部署。
本项目仅为个人爱好开发,代码开源。如有任何侵权行为,请联系本人删除。
本项目仅为个人学习爱好开发,代码开源。如有任何侵权行为,请联系本人删除。
有问题提issue或 [私信机器人](https://t.me/ddjdd_bot) 都ok。
@@ -67,7 +67,7 @@ LogVar 弹幕 API 服务器
- 搜索结果缓存(可通过 `SEARCH_CACHE_MINUTES` 配置,默认1分钟)
- 弹幕缓存(可通过 `COMMENT_CACHE_MINUTES` 配置,默认5分钟)
- 用户偏好记录(可通过 `MAX_LAST_SELECT_MAP` 配置,默认100条)
- Redis 分布式缓存支持(可选)
- Redis 分布式缓存支持,包括本地redis和upstash redis(可选)
- 本地和Docker部署支持实时保存缓存到文件(挂载.cache目录即可)
- **部署支持**:支持本地运行、Docker 容器化、Vercel 一键部署、Netlify 一键部署、Edgeone 一键部署、Cloudflare 一键部署、Claw部署和 Docker 一键启动。
- **手动选择记忆**:支持记住之前搜索title时手动选择的anime,并在后续的match自动匹配时优选该anime【实验性】。
@@ -389,6 +389,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
| DANMU_LIMIT | 【可选】等间隔采样限制弹幕总数,单位为k,即千:默认 0,表示不限制弹幕数,若改为5,弹幕总数在超过5000的情况下会将弹幕数控制在5000 |
| CONVERT_TOP_BOTTOM_TO_SCROLL | 【可选】是否将顶部和底部弹幕转换为浮动弹幕,默认为`false`(不转换),启用后顶部弹幕(ct=5)和底部弹幕(ct=4)会被转换为浮动弹幕(ct=1),可选值:`true`、`false` |
| CONVERT_COLOR | 【可选】弹幕转换颜色配置,默认为`default`(不转换),`white` 将所有非白色的弹幕颜色转换为纯白色,`color` 将所有白色弹幕转换为随机颜色(包含白色),可选值:`default`、`white`、`color` |
| LIKE_SWITCH | 【可选】弹幕点赞数显示开关,默认为`true`(开启),开启后会在弹幕内容后显示点赞数标记,≥5 才显示,避免低赞干扰 |
| DANMU_OUTPUT_FORMAT | 【可选】弹幕输出格式,默认为`json`,可选值:`json`(JSON格式)、`xml`(XML格式),支持通过查询参数`?format=xml`或`?format=json`覆盖此设置,优先级:查询参数 > 环境变量 > 默认值 |
| DANMU_SIMPLIFIED_TRADITIONAL | 【可选】弹幕简繁体转换设置:default(默认不转换)、simplified(繁转简)、traditional(简转繁) |
| PROXY_URL | 【可选】代理/反代地址,目前只对巴哈姆特、TMDB API、bilibili生效,支持格式:<br> 正常代理:`http://127.0.0.1:7890` <br> 万能反代:`@http://127.0.0.1` <br> 特定反代:`源字段@http://127.0.0.1`,目前支持的字段有:`bahamut,tmdb,bilibili`(bilibili字段会启用阿b的港澳台番剧的搜索与获取)<br> 混合配置/示例:`http://你的代理地址:28233,bahamut@你的巴哈反代地址,tmdb@你的tmdb反代地址,@你的万能反代地址` <br> 优先级:特定反代 > 万能反代 > 正常代理,高优先级覆盖低优先级使用。 <br> (注意:如果巴哈姆特请求不通,会拖慢搜索返回速度,如需使用bahamut源请在SOURCE_ORDER环境变量中手动添加`bahamut`)如果你使用docker部署并且访问不了bahamut源,请配置代理地址或者反代([Netlify反代教程](https://github.com/wan0ge/bahamut-api-proxy));vercel/netlify/cf中理应都自然能联通,不用填写 |
@@ -401,6 +402,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
| 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` |
| LOCAL_REDIS_URL | 【可选】本地Redis连接URL,用于本地缓存存储,适用于docker和本地部署环境,格式:`redis://:password@127.0.0.1:6379/0`,默认为空(不使用本地Redis) |
| DEPLOY_PLATFROM_ACCOUNT | 【可选】部署账号ID,调用部署服务API需要,配置后可使用UI界面配置服务,不同部署平台获取方式可查看 [部署平台环境变量配置指南](https://github.com/huangxd-/danmu_api/tree/main/danmu_api/ui/README.md#部署平台环境变量配置指南) ,docker部署和本地node部署并不需要配置 |
| DEPLOY_PLATFROM_PROJECT | 【可选】部署项目名称,调用部署服务API需要,配置后可使用UI界面配置服务,不同部署平台获取方式可查看 [部署平台环境变量配置指南](https://github.com/huangxd-/danmu_api/tree/main/danmu_api/ui/README.md#部署平台环境变量配置指南) ,docker部署和本地node部署并不需要配置 |
| DEPLOY_PLATFROM_TOKEN | 【可选】部署平台token,调用部署服务API需要,配置后可使用UI界面配置服务,不同部署平台获取方式可查看 [部署平台环境变量配置指南](https://github.com/huangxd-/danmu_api/tree/main/danmu_api/ui/README.md#部署平台环境变量配置指南) ,docker部署和本地node部署并不需要配置 |
@@ -591,6 +593,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
│ ├── hanjutv-util.js # 韩剧tv加解密工具
│ ├── http-util.js # 请求工具
│ ├── imdb-util.js # IMDB API请求工具
│ ├── local-redis-util.js # 本地redis工具
│ ├── log-util.js # 日志工具
│ ├── merge-util.js # 源合并处理工具
│ ├── migu-util.js # 咪咕工具
@@ -622,6 +625,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
- 日志存储在内存中,服务器重启后会清空。
- `/api/logs` 中的 JSON 日志会格式化显示,带缩进以提高可读性。
- 搜索结果和弹幕数据存储在内存中,服务器重启后会清空,可通过配置 `UPSTASH_REDIS_REST_URL` 和 `UPSTASH_REDIS_REST_TOKEN` 启用 Redis 持久化存储。
- 已支持本地redis,可通过配置 `LOCAL_REDIS_URL` 启用,只支持docker和本地部署环境。
- 搜索结果缓存默认时间为 1 分钟,可通过环境变量 `SEARCH_CACHE_MINUTES` 调整(设置为 0 表示不缓存)。
- 确保 `package.json` 中包含 `node-fetch` 依赖。
- 一键部署需要将项目推送到公开的 Git 仓库(如 GitHub),并更新按钮中的仓库地址。
@@ -638,7 +642,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
- TMDB源请求逻辑:search tmdb -> tmdbId -> imdbId -> doubanId -> playUrl;优点:emby通过tmdb刮削,标题通过tmdb搜索,返回的信息可能更加匹配;缺点:链条过长,请求时长5-10s左右,中间一环数据有缺失,就没有返回结果。
- TMDB源在SOURCE_ORDER添加tmdb的同时,需要添加TMDB_API_KEY环境变量
- 弹幕分片下载请求已加入重试机制,重试次数为1次
- 如果同时配置了本地缓存和redis缓存,则以redis缓存优先
- 如果同时配置了本地缓存和upstash redis缓存和本地redis缓存,优先级为本地redis > upstash redis缓存 > 本地缓存
- 弹弹play源用的是第三方接口,感谢开源项目 [danmaku-anywhere](https://github.com/Mr-Quin/danmaku-anywhere)
- 有任何问题,如部署/环境变量配置等,可通过deepwiki对本项目进行提问,链接入口:https://deepwiki.com/huangxd-/danmu_api,其中项目内容一般每周刷新一次
+13
View File
@@ -145,6 +145,12 @@ CONVERT_COLOR=default
# 可选值:default, simplified, traditional
DANMU_SIMPLIFIED_TRADITIONAL=default
# 弹幕点赞数显示开关
# 默认值:true(开启)
# 说明:开启后会在弹幕内容后显示点赞数标记,≥5 才显示,避免低赞干扰
# 可选值:true, false
LIKE_SWITCH=true
# ==================== 弹幕推送配置 ====================
# 弹幕推送地址
@@ -271,6 +277,13 @@ MAX_LAST_SELECT_MAP=100
# 默认值:空
# UPSTASH_REDIS_REST_TOKEN=
# ==================== 本地 Redis 配置 ====================
# 本地 Redis 连接 URL(用于本地缓存存储)
# 格式:redis://:password@127.0.0.1:6379/0
# 默认值:空(不使用本地 Redis)
# LOCAL_REDIS_URL=
# ==================== 部署平台API接口信息 配置 ====================
# 部署账号ID,调用部署服务API需要,配置后可使用UI界面配置服务
+10
View File
@@ -3,6 +3,7 @@ import { getPageTitle, jsonResponse, httpGet } from '../utils/http-util.js';
import { log } from '../utils/log-util.js'
import { simplized } from '../utils/zh-util.js';
import { setRedisKey, updateRedisCaches } from "../utils/redis-util.js";
import { setLocalRedisKey, updateLocalRedisCaches } from "../utils/local-redis-util.js";
import {
setCommentCache, addAnime, findAnimeIdByCommentId, findTitleById, findUrlById, getCommentCache, getPreferAnimeId,
getSearchCache, removeEarliestAnime, setPreferByAnimeId, setSearchCache, storeAnimeIdsToMap, writeCacheToFile,
@@ -201,6 +202,9 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null
if (globals.redisValid && curAnimes.length !== 0) {
await updateRedisCaches();
}
if (globals.localRedisValid && curAnimes.length !== 0) {
await updateLocalRedisCaches();
}
return jsonResponse({
errorCode: 0,
@@ -382,6 +386,9 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null
if (globals.redisValid && curAnimes.length !== 0) {
await updateRedisCaches();
}
if (globals.localRedisValid && curAnimes.length !== 0) {
await updateLocalRedisCaches();
}
// 缓存搜索结果
if (curAnimes.length > 0) {
@@ -1389,6 +1396,9 @@ export async function getComment(path, queryFormat, segmentFlag) {
if (globals.redisValid && animeId) {
setRedisKey('lastSelectMap', globals.lastSelectMap).catch(e => log("error", "Redis set error", e));
}
if (globals.localRedisValid && animeId) {
setLocalRedisKey('lastSelectMap', globals.lastSelectMap);
}
}
// 缓存弹幕结果
+13 -1
View File
@@ -32,6 +32,7 @@ export function handleConfig(hasPermission = false) {
...globals.accessedEnvVars,
localCacheValid: globals.localCacheValid,
redisValid: globals.redisValid,
localRedisValid: globals.localRedisValid,
aiValid: globals.aiValid,
deployPlatform: globals.deployPlatform
};
@@ -76,7 +77,7 @@ export function handleConfig(hasPermission = false) {
hasAdminToken: hasAdminToken, // 添加admin token配置状态
repository: "https://github.com/huangxd-/danmu_api.git",
description: "一个人人都能部署的基于 js 的弹幕 API 服务器,支持爱优腾芒哔咪人韩巴狐乐西埋弹幕直接获取,兼容弹弹play的搜索、详情查询和弹幕获取接口规范,并提供日志记录,支持vercel/netlify/edgeone/cloudflare/docker/claw等部署方式,不用提前下载弹幕,没有nas或小鸡也能一键部署。",
notice: "本项目仅为个人爱好开发,代码开源。如有任何侵权行为,请联系本人删除。有问题提issue或私信机器人都ok,TG MSG ROBOT: [https://t.me/ddjdd_bot]; 推荐加互助群咨询,TG GROUP: [https://t.me/logvar_danmu_group]; 关注频道获取最新更新内容,TG CHANNEL: [https://t.me/logvar_danmu_channel]。"
notice: "本项目仅为个人学习爱好开发,代码开源。如有任何侵权行为,请联系本人删除。有问题提issue或私信机器人都ok,TG MSG ROBOT: [https://t.me/ddjdd_bot]; 推荐加互助群咨询,TG GROUP: [https://t.me/logvar_danmu_group]; 关注频道获取最新更新内容,TG CHANNEL: [https://t.me/logvar_danmu_channel]。"
});
}
@@ -195,6 +196,17 @@ export async function handleClearCache() {
} catch (redisError) {
log("warn", `[server] Redis may not be available: ${redisError.message}`);
}
try {
// 如果本地Redis有效,更新本地Redis缓存
if (globals.localRedisValid) {
const { updateLocalRedisCaches } = await import("../utils/local-redis-util.js");
await updateLocalRedisCaches();
log("info", `[server] LocalRedis cache cleared successfully`);
}
} catch (redisError) {
log("warn", `[server] LocalRedis may not be available: ${redisError.message}`);
}
log("info", `[server] All caches cleared successfully`);
return jsonResponse({ success: true, message: "Cache cleared successfully", clearedItems: {
+4
View File
@@ -401,6 +401,7 @@ export class Envs {
'CONVERT_COLOR': { category: 'danmu', type: 'select', options: ['default', 'white', 'color'], description: '弹幕转换颜色配置' },
'DANMU_OUTPUT_FORMAT': { category: 'danmu', type: 'select', options: ['json', 'xml'], description: '弹幕输出格式,默认json' },
'DANMU_PUSH_URL': { category: 'danmu', type: 'text', description: '弹幕推送地址,示例 http://127.0.0.1:9978/action?do=refresh&type=danmaku&path= ' },
'LIKE_SWITCH': { category: 'danmu', type: 'boolean', description: '弹幕点赞数显示开关,默认开启' },
// 缓存配置
'SEARCH_CACHE_MINUTES': { category: 'cache', type: 'number', description: '搜索结果缓存时间(分钟),默认1', min: 1, max: 120 },
@@ -409,6 +410,7 @@ export class Envs {
'MAX_LAST_SELECT_MAP': { category: 'cache', type: 'number', description: '记住上次选择映射缓存大小限制', min: 10, max: 1000 },
'UPSTASH_REDIS_REST_URL': { category: 'cache', type: 'text', description: 'Upstash Redis请求链接' },
'UPSTASH_REDIS_REST_TOKEN': { category: 'cache', type: 'text', description: 'Upstash Redis访问令牌' },
'LOCAL_REDIS_URL': { category: 'cache', type: 'text', description: '本地 Redis 连接URL,示例:redis://:password@127.0.0.1:6379/0,只支持本地部署和docker部署' },
// 系统配置
'PROXY_URL': { category: 'system', type: 'text', description: '代理/反代地址' },
@@ -443,9 +445,11 @@ export class Envs {
proxyUrl: this.get('PROXY_URL', '', 'string', true), // 代理/反代地址
danmuSimplifiedTraditional: this.get('DANMU_SIMPLIFIED_TRADITIONAL', 'default', 'string'), // 弹幕简繁体转换设置:default(默认不转换)、simplified(繁转简)、traditional(简转繁)
danmuPushUrl: this.get('DANMU_PUSH_URL', '', 'string'), // 代理/反代地址
likeSwitch: this.get('LIKE_SWITCH', true, 'boolean'), // 弹幕点赞数显示开关,默认开启
tmdbApiKey: this.get('TMDB_API_KEY', '', 'string', true), // TMDB API KEY
redisUrl: this.get('UPSTASH_REDIS_REST_URL', '', 'string', true), // upstash redis url
redisToken: this.get('UPSTASH_REDIS_REST_TOKEN', '', 'string', true), // upstash redis url
localRedisUrl: this.get('LOCAL_REDIS_URL', '', 'string', true), // 本地 Redis 连接URL,示例:redis://:password@127.0.0.1:6379/0,只支持本地部署和docker部署
rateLimitMaxRequests: this.get('RATE_LIMIT_MAX_REQUESTS', 3, 'number'), // 限流配置:时间窗口内最大请求次数(默认 3,0表示不限流)
enableAnimeEpisodeFilter: this.get('ENABLE_ANIME_EPISODE_FILTER', false, 'boolean'), // 控制手动搜索的时候是否根据ANIME_TITLE_FILTER进行剧名过滤以及根据EPISODE_TITLE_FILTER进行集标题过滤(默认 false,禁用过滤)
logLevel: this.get('LOG_LEVEL', 'info', 'string'), // 日志级别配置(默认 info,可选值:error, warn, info)
+2 -1
View File
@@ -13,7 +13,7 @@ export const Globals = {
accessedEnvVars: {},
// 静态常量
VERSION: '1.15.2',
VERSION: '1.15.3',
MAX_LOGS: 1000, // 日志存储,最多保存 1000 行
MAX_ANIMES: 100,
MAX_RECORDS: 100, // 请求记录最大数量
@@ -27,6 +27,7 @@ export const Globals = {
localCacheValid: false, // 本地缓存是否生效
localCacheInitialized: false, // 本地缓存是否已初始化
redisValid: false, // redis是否生效
localRedisValid: false, // 本地redis是否生效
aiValid: false, // AI配置是否生效
redisCacheInitialized: false, // redis 缓存是否已初始化
lastSelectMap: new Map(), // 存储查询关键字上次选择的animeId,用于下次match自动匹配时优先选择该anime
+7
View File
@@ -145,6 +145,8 @@ export default class HanjutvSource extends BaseSource {
sidSet.add(sid);
resultList.push(item);
}
const pluckNames = (list) => list?.map(item => item.name) || [];
return {
resultList,
@@ -153,6 +155,10 @@ export default class HanjutvSource extends BaseSource {
s5Matched: s5Matched.length,
webTotal: webCandidates.length,
webMatched: webMatched.length,
s5MatchedList: pluckNames(s5Matched),
s5UnmatchedList: pluckNames(s5Unmatched),
webMatchedList: pluckNames(webMatched),
webUnmatchedList: pluckNames(webUnmatched)
}
};
}
@@ -240,6 +246,7 @@ export default class HanjutvSource extends BaseSource {
return [];
}
log("info", `[Hanjutv] 搜索候选统计 s5MatchedList=${JSON.stringify(stats.s5MatchedList)}, s5UnmatchedList=${JSON.stringify(stats.s5UnmatchedList)}, webMatchedList=${JSON.stringify(stats.webMatchedList)}, webMatchedList=${JSON.stringify(stats.webUnmatchedList)}`);
log("info", `[Hanjutv] 搜索候选统计 s5=${stats.s5Total}(命中${stats.s5Matched}), web=${stats.webTotal}(命中${stats.webMatched})`);
log("info", `[Hanjutv] 搜索找到 ${resultList.length} 个有效结果`);
+8 -1
View File
@@ -392,7 +392,14 @@ function renderValueInput(item) {
if (type === 'boolean') {
// 布尔开关
const checked = value === 'true' || value === true;
// 对于LIKE_SWITCH变量,默认值设为true(开启状态)
let checked;
if (currentKey === 'LIKE_SWITCH' || currentKey === 'REMEMBER_LAST_SELECT') {
// 如果值为空或未定义,LIKE_SWITCH和REMEMBER_LAST_SELECT默认为true(开启)
checked = value === 'true' || value === true || (value === '' || value === undefined || value === null);
} else {
checked = value === 'true' || value === true;
}
container.innerHTML = \`
<label>值</label>
<div class="switch-container">
+1 -1
View File
@@ -305,7 +305,7 @@ export const HTML_TEMPLATE = /* html */ `
<p class="footer-text">
一个人人都能部署的基于 js 的弹幕 API 服务器,支持爱优腾芒哔咪人韩巴狐乐西埋弹幕直接获取,兼容弹弹play的搜索、详情查询和弹幕获取接口规范,并提供日志记录,支持vercel/netlify/edgeone/cloudflare/docker/claw等部署方式,不用提前下载弹幕,没有nas或小鸡也能一键部署。
</p>
<p class="footer-text">本项目仅为个人爱好开发,代码开源。如有任何侵权行为,请联系本人删除。</p>
<p class="footer-text">本项目仅为个人学习爱好开发,代码开源。如有任何侵权行为,请联系本人删除。</p>
<p class="footer-links">
<a href="https://t.me/ddjdd_bot" target="_blank" class="footer-link">💬 TG MSG ROBOT</a>
<a href="https://t.me/logvar_danmu_group" target="_blank" class="footer-link">👥 TG GROUP</a>
+40 -13
View File
@@ -7,6 +7,29 @@ import { traditionalized } from './zh-util.js';
// danmu处理相关函数
// =====================
/**
* 将普通数字转换为下标数字
* @param {string} str 包含数字的字符串
* @returns {string} 转换为下标数字的字符串
*/
function toSubscript(str) {
const subscriptMap = {
'0': '₀',
'1': '₁',
'2': '₂',
'3': '₃',
'4': '₄',
'5': '₅',
'6': '₆',
'7': '₇',
'8': '₈',
'9': '₉',
'.': '․' // 下标点号
};
return str.split('').map(char => subscriptMap[char] || char).join('');
}
/**
* 对弹幕进行分组、去重和计数处理
* @param {Array} filteredDanmus 已过滤屏蔽词的弹幕列表
@@ -112,6 +135,9 @@ export function groupDanmusByMinute(filteredDanmus, n) {
* @returns {Array} 处理后的弹幕列表
*/
export function handleDanmusLike(groupedDanmus) {
if (!globals.likeSwitch) {
return groupedDanmus;
}
return groupedDanmus.map(item => {
// 如果item没有like字段或者like值小于5,则不处理
if (!item.like || item.like < 5) {
@@ -124,23 +150,24 @@ export function handleDanmusLike(groupedDanmus) {
// 确定阈值:特定源中>=100用🔥,其他>=1000用🔥
const threshold = isLowThresholdSource ? 100 : 1000;
const icon = item.like >= threshold ? '🔥' : '❤️';
const icon = item.like >= threshold ? '🔥' : '️♡';
// 格式化点赞数,缩写显示
let formattedLike;
if (item.like >= 10000) {
// 万级别,如 1.2w
formattedLike = (item.like / 10000).toFixed(1) + 'w';
} else if (item.like >= 1000) {
// 千级别,如 1.2k
formattedLike = (item.like / 1000).toFixed(1) + 'k';
} else {
// 百级别及以下,直接显示数字
formattedLike = item.like.toString();
}
// let formattedLike;
// if (item.like >= 10000) {
// // 万级别,如 1.2w
// formattedLike = (item.like / 10000).toFixed(1) + 'w';
// } else if (item.like >= 1000) {
// // 千级别,如 1.2k
// formattedLike = (item.like / 1000).toFixed(1) + 'k';
// } else {
// // 百级别及以下,直接显示数字
// formattedLike = item.like.toString();
// }
const formattedLike = toSubscript(item.like.toString());
// 在弹幕内容m字段后面添加点赞信息
const likeText = ` ${icon} ${formattedLike}`;
const likeText = ` ${icon}${formattedLike}`;
const newM = item.m + likeText;
// 创建新对象,复制原属性,更新m字段,并删除like字段
+313
View File
@@ -0,0 +1,313 @@
import { globals } from '../configs/globals.js';
import { log } from './log-util.js';
import { simpleHash, serializeValue } from "./codec-util.js";
import { createClient } from 'redis';
// =====================
// 本地 Redis 读写请求
// =====================
// 本地 Redis 客户端实例
let localRedisClient = null;
// 创建本地 Redis 客户端
async function createLocalRedisClient() {
// 如果已经存在客户端实例,直接返回
if (localRedisClient) {
return localRedisClient;
}
// 从环境变量获取本地 Redis 配置,默认使用本地连接
const localRedisUrl = globals.localRedisUrl;
try {
log("info", `[local-redis] 正在连接本地 Redis`);
localRedisClient = createClient({
url: localRedisUrl,
socket: {
reconnectStrategy: (retries) => {
if (retries >= 0) {
return new Error('已达到最大重试次数,停止重连');
}
return 1000; // 重试间隔 ms
}
}
});
// 连接错误处理
localRedisClient.on('error', (err) => {
log("error", `[local-redis] 连接错误`);
globals.localRedisValid = false;
});
// 连接成功处理
localRedisClient.on('connect', () => {
log("info", `[local-redis] 连接成功`);
});
await localRedisClient.connect();
globals.localRedisValid = true;
log("info", `[local-redis] Redis 客户端初始化完成`);
return localRedisClient;
} catch (error) {
log("error", `[local-redis] 初始化失败:`, error.message);
globals.localRedisValid = false;
return null;
}
}
// 检查本地 Redis 是否已连接且可用
async function checkLocalRedisConnection() {
if (!localRedisClient) {
return false;
}
try {
// 发送 PING 命令检查连接状态
const result = await localRedisClient.ping();
return result === 'PONG';
} catch (error) {
log("error", `[local-redis] 连接检查失败:`, error.message);
globals.localRedisValid = false;
return false;
}
}
// 获取本地 Redis 键值
export async function getLocalRedisKey(key) {
try {
if (!(await checkLocalRedisConnection())) {
await createLocalRedisClient();
}
if (!localRedisClient) {
throw new Error('本地 Redis 客户端未初始化');
}
const result = await localRedisClient.get(key);
return result;
} catch (error) {
log("error", `[local-redis] GET 请求失败:`, error.message);
return null;
}
}
// 设置本地 Redis 键值
export async function setLocalRedisKey(key, value) {
const serializedValue = serializeValue(key, value);
const currentHash = simpleHash(serializedValue);
// 检查值是否变化
if (globals.lastHashes[key] === currentHash) {
log("info", `[local-redis] 键 ${key} 无变化,跳过 SET 请求`);
return { result: "OK" }; // 模拟成功响应
}
try {
if (!(await checkLocalRedisConnection())) {
await createLocalRedisClient();
}
if (!localRedisClient) {
throw new Error('本地 Redis 客户端未初始化');
}
const result = await localRedisClient.set(key, serializedValue);
globals.lastHashes[key] = currentHash; // 更新哈希值
log("info", `[local-redis] 键 ${key} 更新成功`);
return { result };
} catch (error) {
log("error", `[local-redis] SET 请求失败:`, error.message);
return { result: "ERROR" };
}
}
// 设置带过期时间的本地 Redis 键值
export async function setLocalRedisKeyWithExpiry(key, value, expirySeconds) {
const serializedValue = serializeValue(key, value);
const currentHash = simpleHash(serializedValue);
// 检查值是否变化
if (globals.lastHashes[key] === currentHash) {
log("info", `[local-redis] 键 ${key} 无变化,跳过 SETEX 请求`);
return { result: "OK" }; // 模拟成功响应
}
try {
if (!(await checkLocalRedisConnection())) {
await createLocalRedisClient();
}
if (!localRedisClient) {
throw new Error('本地 Redis 客户端未初始化');
}
const result = await localRedisClient.setEx(key, expirySeconds, serializedValue);
globals.lastHashes[key] = currentHash; // 更新哈希值
log("info", `[local-redis] 键 ${key} 更新成功(带过期时间 ${expirySeconds}s)`);
return { result };
} catch (error) {
log("error", `[local-redis] SETEX 请求失败:`, error.message);
return { result: "ERROR" };
}
}
// 优化后的 getLocalRedisCaches,批量获取所有键
export async function getLocalRedisCaches() {
if (!globals.localCacheInitialized) {
try {
log("info", 'getLocalRedisCaches start.');
if (!(await checkLocalRedisConnection())) {
await createLocalRedisClient();
}
if (!localRedisClient) {
throw new Error('本地 Redis 客户端未初始化');
}
const keys = ['animes', 'episodeIds', 'episodeNum', 'reqRecords', 'lastSelectMap', 'todayReqNum'];
const results = await Promise.all(keys.map(key => getLocalRedisKey(key)));
// 解析结果,按顺序赋值
globals.animes = results[0] ? JSON.parse(results[0]) : globals.animes;
globals.episodeIds = results[1] ? JSON.parse(results[1]) : globals.episodeIds;
globals.episodeNum = results[2] ? JSON.parse(results[2]) : globals.episodeNum;
globals.reqRecords = results[3] ? JSON.parse(results[3]) : globals.reqRecords;
// 恢复 lastSelectMap 并转换为 Map 对象
const lastSelectMapData = results[4] ? JSON.parse(results[4]) : null;
if (lastSelectMapData && typeof lastSelectMapData === 'object') {
globals.lastSelectMap = new Map(Object.entries(lastSelectMapData));
log("info", `Restored lastSelectMap from Local Redis with ${globals.lastSelectMap.size} entries`);
}
globals.todayReqNum = results[5] ? parseInt(results[5], 10) : globals.todayReqNum;
// 更新哈希值
globals.lastHashes.animes = simpleHash(JSON.stringify(globals.animes));
globals.lastHashes.episodeIds = simpleHash(JSON.stringify(globals.episodeIds));
globals.lastHashes.episodeNum = simpleHash(JSON.stringify(globals.episodeNum));
globals.lastHashes.reqRecords = simpleHash(JSON.stringify(globals.reqRecords));
globals.lastHashes.lastSelectMap = simpleHash(JSON.stringify(Object.fromEntries(globals.lastSelectMap)));
globals.lastHashes.todayReqNum = simpleHash(JSON.stringify(globals.todayReqNum));
globals.localCacheInitialized = true;
log("info", 'getLocalRedisCaches completed successfully.');
} catch (error) {
log("error", `getLocalRedisCaches failed: ${error.message}`, error.stack);
globals.localCacheInitialized = true; // 标记为已初始化,避免重复尝试
}
}
}
// 优化后的 updateLocalRedisCaches,仅更新有变化的变量
export async function updateLocalRedisCaches() {
try {
log("info", 'updateLocalRedisCaches start.');
if (!(await checkLocalRedisConnection())) {
await createLocalRedisClient();
}
if (!localRedisClient) {
throw new Error('本地 Redis 客户端未初始化');
}
const updates = [];
// 检查每个变量的哈希值
const variables = [
{ key: 'animes', value: globals.animes },
{ key: 'episodeIds', value: globals.episodeIds },
{ key: 'episodeNum', value: globals.episodeNum },
{ key: 'reqRecords', value: globals.reqRecords },
{ key: 'lastSelectMap', value: globals.lastSelectMap },
{ key: 'todayReqNum', value: globals.todayReqNum }
];
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 !== globals.lastHashes[key]) {
updates.push({ key, value, hash: currentHash });
}
}
// 如果有需要更新的键,执行批量更新
if (updates.length > 0) {
log("info", `Updating ${updates.length} changed keys: ${updates.map(u => u.key).join(', ')}`);
const promises = updates.map(async ({ key, value }) => {
return setLocalRedisKey(key, value);
});
const results = await Promise.all(promises);
// 检查每个操作的结果
let successCount = 0;
let failureCount = 0;
results.forEach((result, index) => {
if (result && result.result === 'OK') {
successCount++;
} else {
failureCount++;
log("warn", `Failed to update Local Redis key: ${updates[index]?.key}, result: ${JSON.stringify(result)}`);
}
});
// 只有在所有操作都成功时才更新哈希值
if (failureCount === 0) {
updates.forEach(({ key, hash }) => {
globals.lastHashes[key] = hash;
});
log("info", `Local Redis update completed successfully: ${successCount} keys updated`);
} else {
log("warn", `Local Redis update partially failed: ${successCount} succeeded, ${failureCount} failed`);
}
} else {
log("info", 'No changes detected, skipping Local Redis update.');
}
} catch (error) {
log("error", `updateLocalRedisCaches failed: ${error.message}`, error.stack);
log("error", `Error details - Name: ${error.name}, Cause: ${error.cause ? error.cause.message : 'N/A'}`);
}
}
// 判断本地 Redis 是否可用
export async function judgeLocalRedisValid(path) {
if (!globals.localRedisValid && globals.localRedisUrl && path !== "/favicon.ico" && path !== "/robots.txt") {
try {
if (!(await checkLocalRedisConnection())) {
await createLocalRedisClient();
}
if (localRedisClient) {
const result = await localRedisClient.ping();
if (result === 'PONG') {
globals.localRedisValid = true;
}
}
} catch (error) {
log("error", `[local-redis] 连接检查失败:`, error.message);
globals.localRedisValid = false;
}
}
}
// 关闭本地 Redis 连接
export async function closeLocalRedisConnection() {
if (localRedisClient) {
try {
await localRedisClient.quit();
localRedisClient = null;
globals.localRedisValid = false;
log("info", '[local-redis] 连接已关闭');
} catch (error) {
log("error", `[local-redis] 关闭连接失败:`, error.message);
}
}
}
+6
View File
@@ -30,6 +30,8 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
globals.deployPlatform = deployPlatform;
if (deployPlatform === "node") {
await judgeLocalCacheValid(path, deployPlatform);
const { judgeLocalRedisValid } = await import("./utils/local-redis-util.js");
await judgeLocalRedisValid(path);
}
await judgeRedisValid(path);
if (!globals.aiValid && globals.aiBaseUrl && globals.aiModel && globals.aiApiKey && path !== "/favicon.ico" && path !== "/robots.txt") {
@@ -71,6 +73,10 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
if (globals.redisValid && path !== "/favicon.ico" && path !== "/robots.txt") {
await getRedisCaches();
}
if (deployPlatform === "node" && globals.localRedisValid && path !== "/favicon.ico" && path !== "/robots.txt") {
const { getLocalRedisCaches } = await import("./utils/local-redis-util.js");
await getLocalRedisCaches();
}
// 检查路径是否包含指定的接口关键字
const targetPaths = [
+48
View File
@@ -7,6 +7,7 @@ import assert from 'node:assert';
import { handleRequest } from './worker.js';
import { extractTitleSeasonEpisode, getBangumi, getComment, searchAnime } from "./apis/dandan-api.js";
import { getRedisKey, pingRedis, setRedisKey, setRedisKeyWithExpiry } from "./utils/redis-util.js";
import { getLocalRedisKey, setLocalRedisKey, setLocalRedisKeyWithExpiry } from "./utils/local-redis-util.js";
import { getImdbepisodes } from "./utils/imdb-util.js";
import { getTMDBChineseTitle, getTmdbJpDetail, searchTmdbTitles } from "./utils/tmdb-util.js";
import { getDoubanDetail, getDoubanInfoByImdbId, searchDoubanTitles } from "./utils/douban-util.js";
@@ -783,3 +784,50 @@ test('worker.js API endpoints', async (t) => {
// assert(res, `Expected res is true, but got ${res}`);
// });
});
// // 测试本地 Redis 功能
// test('local-redis functions', async (t) => {
// // 测试设置和获取本地 Redis 键值
// await t.test('setLocalRedisKey and getLocalRedisKey', async () => {
// try {
// const testKey = 'test_key_local_redis';
// const testValue = 'Hello Local Redis';
// // 设置键值
// const setResult = await setLocalRedisKey(testKey, testValue);
// // 验证设置结果
// assert.ok(setResult.result === 'OK' || setResult.result === 'ERROR',
// `setLocalRedisKey returned valid result: ${JSON.stringify(setResult)}`);
// // 获取键值
// const getResult = await getLocalRedisKey(testKey);
// // 验证获取结果(如果 Redis 不可用,可能返回 null)
// if (getResult !== null) {
// // 如果返回了结果,验证它是否是我们设置的值(可能是序列化的)
// assert.ok(typeof getResult === 'string' || getResult === null,
// `getLocalRedisKey returned expected type: ${typeof getResult}`);
// } else {
// // 如果返回 null,也是可以接受的(表示 Redis 不可用)
// assert.strictEqual(getResult, null, 'getLocalRedisKey returned null when Redis is not available');
// }
// } catch (error) {
// assert.ok(true, `setLocalRedisKey/getLocalRedisKey handled error gracefully: ${error.message}`);
// }
// });
// // 测试设置带过期时间的本地 Redis 键值
// await t.test('setLocalRedisKeyWithExpiry', async () => {
// try {
// const testKey = 'test_expiry_key_local_redis';
// const testValue = 'Temporary Value';
// const expirySeconds = 2; // 2秒过期
// const setResult = await setLocalRedisKeyWithExpiry(testKey, testValue, expirySeconds);
// // 验证设置结果
// assert.ok(setResult.result === 'OK' || setResult.result === 'ERROR',
// `setLocalRedisKeyWithExpiry returned valid result: ${JSON.stringify(setResult)}`);
// } catch (error) {
// assert.ok(true, `setLocalRedisKeyWithExpiry handled error gracefully: ${error.message}`);
// }
// });
// });
+26 -10
View File
@@ -379,6 +379,21 @@ WidgetMetadata = {
},
],
},
{
name: "likeSwitch",
title: "点赞功能开关,默认true",
type: "input",
placeholders: [
{
title: "开启",
value: "true",
},
{
title: "关闭",
value: "false",
},
],
},
// 系统配置
{
@@ -465,7 +480,7 @@ if (typeof window !== 'undefined') {
let globals;
async function initGlobals(sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
platformOrder, episodeTitleFilter, enableAnimeEpisodeFilter, strictTitleMatch, titleMappingTable, animeTitleFilter, animeTitleSimplified, blockedWords, groupMinute,
danmuLimit, danmuSimplifiedTraditional, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey) {
danmuLimit, danmuSimplifiedTraditional, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey, likeSwitch) {
// 将传入的参数设置到环境变量中,以便Globals可以访问它们
const env = {};
@@ -489,6 +504,7 @@ async function initGlobals(sourceOrder, otherServer, customSourceApiUrl, vodServ
if (danmuSimplifiedTraditional !== undefined) env.DANMU_SIMPLIFIED_TRADITIONAL = danmuSimplifiedTraditional;
if (convertTopBottomToScroll !== undefined) env.CONVERT_TOP_BOTTOM_TO_SCROLL = convertTopBottomToScroll;
if (convertColor !== undefined) env.CONVERT_COLOR = convertColor;
if (likeSwitch !== undefined) env.LIKE_SWITCH = likeSwitch;
if (proxyUrl !== undefined) env.PROXY_URL = proxyUrl;
if (tmdbApiKey !== undefined) env.TMDB_API_KEY = tmdbApiKey;
@@ -557,11 +573,11 @@ const PREFIX_URL = "http://localhost:9321"
async function searchDanmu(params) {
const { tmdbId, type, title, season, link, videoUrl, sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
platformOrder, episodeTitleFilter, enableAnimeEpisodeFilter, strictTitleMatch, titleMappingTable, animeTitleFilter, animeTitleSimplified, blockedWords, groupMinute,
danmuLimit, danmuSimplifiedTraditional, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey } = params;
danmuLimit, danmuSimplifiedTraditional, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey, likeSwitch } = params;
await initGlobals(sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
platformOrder, episodeTitleFilter, enableAnimeEpisodeFilter, strictTitleMatch, titleMappingTable, animeTitleFilter, animeTitleSimplified, blockedWords, groupMinute,
danmuLimit, danmuSimplifiedTraditional, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey);
danmuLimit, danmuSimplifiedTraditional, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey, likeSwitch);
let simplifiedTitle = title
// 如果启用了搜索关键字繁转简,则进行转换
@@ -637,11 +653,11 @@ async function searchDanmu(params) {
async function getDetailById(params) {
const { animeId, sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
platformOrder, episodeTitleFilter, enableAnimeEpisodeFilter, strictTitleMatch, titleMappingTable, animeTitleFilter, animeTitleSimplified, blockedWords, groupMinute,
danmuLimit, danmuSimplifiedTraditional, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey } = params;
danmuLimit, danmuSimplifiedTraditional, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey, likeSwitch } = params;
await initGlobals(sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
platformOrder, episodeTitleFilter, enableAnimeEpisodeFilter, strictTitleMatch, titleMappingTable, animeTitleFilter, animeTitleSimplified, blockedWords, groupMinute,
danmuLimit, danmuSimplifiedTraditional, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey);
danmuLimit, danmuSimplifiedTraditional, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey, likeSwitch);
const response = await getBangumi(`${PREFIX_URL}/api/v2/bangumi/${animeId}`);
const resJson = await response.json();
@@ -656,11 +672,11 @@ async function getDetailById(params) {
async function getCommentsById(params) {
const { commentId, link, videoUrl, season, episode, tmdbId, type, title, segmentTime, sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
platformOrder, episodeTitleFilter, enableAnimeEpisodeFilter, strictTitleMatch, titleMappingTable, animeTitleFilter, animeTitleSimplified, blockedWords, groupMinute,
danmuLimit, danmuSimplifiedTraditional, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey } = params;
danmuLimit, danmuSimplifiedTraditional, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey, likeSwitch } = params;
await initGlobals(sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
platformOrder, episodeTitleFilter, enableAnimeEpisodeFilter, strictTitleMatch, titleMappingTable, animeTitleFilter, animeTitleSimplified, blockedWords, groupMinute,
danmuLimit, danmuSimplifiedTraditional, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey);
danmuLimit, danmuSimplifiedTraditional, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey, likeSwitch);
if (commentId) {
const storeKey = season && episode ? `${tmdbId}.${season}.${episode}` : `${tmdbId}`;
@@ -677,7 +693,7 @@ async function getCommentsById(params) {
if (lastCommentId === commentId && segmentList) {
return await getDanmuWithSegmentTime({ segmentTime, tmdbId, season, episode, sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
platformOrder, episodeTitleFilter, enableAnimeEpisodeFilter, strictTitleMatch, titleMappingTable, animeTitleFilter, animeTitleSimplified, blockedWords, groupMinute,
danmuLimit, danmuSimplifiedTraditional, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey })
danmuLimit, danmuSimplifiedTraditional, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey, likeSwitch })
} else {
Widget.storage.remove(storeKey);
Widget.storage.remove(commentIdKey);
@@ -703,11 +719,11 @@ async function getCommentsById(params) {
async function getDanmuWithSegmentTime(params) {
const { segmentTime, tmdbId, season, episode, sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
platformOrder, episodeTitleFilter, enableAnimeEpisodeFilter, strictTitleMatch, titleMappingTable, animeTitleFilter, animeTitleSimplified, blockedWords, groupMinute,
danmuLimit, danmuSimplifiedTraditional, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey } = params;
danmuLimit, danmuSimplifiedTraditional, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey, likeSwitch } = params;
await initGlobals(sourceOrder, otherServer, customSourceApiUrl, vodServers, vodReturnMode, vodRequestTimeout, bilibiliCookie,
platformOrder, episodeTitleFilter, enableAnimeEpisodeFilter, strictTitleMatch, titleMappingTable, animeTitleFilter, animeTitleSimplified, blockedWords, groupMinute,
danmuLimit, danmuSimplifiedTraditional, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey);
danmuLimit, danmuSimplifiedTraditional, convertTopBottomToScroll, convertColor, proxyUrl, tmdbApiKey, likeSwitch);
const storeKey = season && episode ? `${tmdbId}.${season}.${episode}` : `${tmdbId}`;
const segmentList = Widget.storage.get(storeKey);
+6 -5
View File
@@ -225,16 +225,17 @@ async function testNewFlow() {
bilibiliCookie: '',
platformOrder: [],
episodeTitleFilter: '',
enableAnimeEpisodeFilter: false,
strictTitleMatch: false,
enableAnimeEpisodeFilter: 'false',
strictTitleMatch: 'false',
animeTitleFilter: '',
animeTitleSimplified: false,
animeTitleSimplified: 'false',
blockedWords: '',
groupMinute: 1,
danmuLimit: 0,
danmuSimplifiedTraditional: false,
convertTopBottomToScroll: false,
danmuSimplifiedTraditional: 'false',
convertTopBottomToScroll: 'false',
convertColor: 'default',
likeSwitch: 'true',
proxyUrl: '',
tmdbApiKey: '',
};
+2 -1
View File
@@ -15,6 +15,7 @@
"dotenv": "^16.4.7",
"esbuild": "^0.25.10",
"https-proxy-agent": "^7.0.6",
"node-fetch": "^3.3.2"
"node-fetch": "^3.3.2",
"redis": "^5.11.0"
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
name = "danmu-api"
main = "danmu_api/worker.js"
compatibility_date = "2025-09-13"
keep_vars = true
keep_vars = true
compatibility_flags = ["nodejs_compat"]