feat: 重构VOD服务器配置,支持多服务器并发查询。添加通过视频URL直接获取弹幕的API接口 。 (#46)

* feat: 重构VOD服务器配置,支持多服务器并发查询

- 移除 VOD_SERVER 和 VOD_SERVER2 双服务器配置
- 新增 VOD_SERVERS 支持无限个 VOD 服务器
- 配置格式: 名称@URL,名称@URL,...
- 使用 Promise.allSettled 并发查询所有 VOD 服务器
- 单个服务器失败不影响其他服务器
- 调整默认源排序: 360 优先于 vod
- 添加 dotenv 支持本地开发
- 新增 .env.example 环境变量配置示例

Breaking Changes:
- 环境变量 VOD_SERVER 和 VOD_SERVER2 已废弃
- 请使用 VOD_SERVERS 替代
- 默认 VOD 服务器从 https://gctf.tfdh.top 改为 https://www.caiji.cyou

* feat: 添加通过视频URL直接获取弹幕的API接口

- 新增 POST /api/v2/comment/by-url 接口,支持通过视频URL直接获取弹幕
- 新增 getCommentByUrl 函数处理URL推送请求
- 支持 B站、腾讯、爱奇艺、优酷、芒果TV 等主流平台
- 优化 getComment 函数的平台判断逻辑(改为 if-else if)

* Update README.md

* 更新版本号 修正文档错误示例
This commit is contained in:
woleigedouer authored and GitHub committed 2025-10-14 18:51:00 +08:00
1 parent bc320de259
commit 694e6b9bf8
5 files changed
+258 -51

No files matched your search

+84
View File
@@ -0,0 +1,84 @@
# 弹幕 API 环境变量配置示例
# 复制此文件为 .env 并根据需要修改配置
# ==================== 基础配置 ====================
# API 访问令牌(用于鉴权)
# 默认值:87654321
TOKEN=87654321
# 其他服务器地址(第三方弹幕服务器)
# 默认值:https://api.danmu.icu
OTHER_SERVER=https://api.danmu.icu
# ==================== VOD 配置 ====================
# VOD 服务器配置(支持多服务器)
# 格式:名称@URL,名称@URL,...
# 示例:VOD_SERVERS=vod1@https://api1.com,vod2@https://api2.com,vod3@https://api3.com
# 如果不指定名称,会自动生成 vod-1, vod-2 等名称
# 默认值:vod@https://www.caiji.cyou
VOD_SERVERS=vod@https://www.caiji.cyou
# ==================== Bilibili 配置 ====================
# Bilibili Cookie(用于访问需要登录的内容)
# 默认值:空(不使用 Cookie)
# BILIBILI_COOKIE=
# ==================== 优酷配置 ====================
# 优酷并发数(最大:16)
# 默认值:8
YOUKU_CONCURRENCY=8
# ==================== 源排序配置 ====================
# 数据源查询顺序(可选值:360, vod, renren, hanjutv, bahamut)
# 默认值:360,vod,renren,hanjutv
# 注意:bahamut 仅在 Cloudflare 和 Vercel 平台自动启用
SOURCE_ORDER=360,vod,renren,hanjutv
# ==================== 平台排序配置 ====================
# 平台优先级排序(用于自动匹配)
# 默认值:空(自动匹配)
# 可选值:youku,bilibili,iqiyi,qq,mgtv
# PLATFORM_ORDER=
# ==================== 剧集标题过滤 ====================
# 剧集标题过滤正则表达式(用于过滤花絮、预告等非正片内容)
# 默认值:包含大量关键词的复杂正则表达式
# 如需自定义,请参考代码中的 DEFAULT_EPISODE_TITLE_FILTER
# EPISODE_TITLE_FILTER=
# ==================== 屏蔽词配置 ====================
# 屏蔽词列表(用于过滤弹幕内容)
# 默认值:空
# BLOCKED_WORDS=
# ==================== 弹幕去重配置 ====================
# 分钟内合并去重(最大值:30,0 表示不去重)
# 默认值:1
GROUP_MINUTE=1
# ==================== 代理配置 ====================
# 代理地址(用于访问受限资源)
# 默认值:空(不使用代理)
# 格式:http://proxy-server:port 或 https://proxy-server:port
# PROXY_URL=
# ==================== Redis 配置(Upstash)====================
# Upstash Redis REST URL(用于缓存)
# 默认值:空(不使用 Redis)
# UPSTASH_REDIS_REST_URL=
# Upstash Redis REST Token
# 默认值:空
# UPSTASH_REDIS_REST_TOKEN=
+5 -4
View File
@@ -34,6 +34,7 @@ LogVar 弹幕 API 服务器
- `GET /api/v2/search/episodes`:根据关键词搜索所有匹配的剧集信息。
- `GET /api/v2/bangumi/:animeId`:获取指定动漫的详细信息。
- `GET /api/v2/comment/:commentId?withRelated=true&chConvert=1`:获取指定弹幕评论,支持返回相关评论和字符转换。
- `POST /api/v2/comment/by-url`:通过视频URL直接获取弹幕。
- `GET /api/logs`:获取最近的日志(最多 500 行,格式为 `[时间戳] 级别: 消息`)。
- **日志记录**:捕获 `console.log`(info 级别)和 `console.error`(error 级别),JSON 内容格式化输出。
- **部署支持**:支持本地运行、Docker 容器化、Vercel 一键部署、Cloudflare 一键部署和 Docker 一键启动。
@@ -73,10 +74,11 @@ LogVar 弹幕 API 服务器
使用 Postman 或 curl 测试:
- `GET http://{ip}:9321/87654321`
- `GET http://{ip}:9321/87654321/api/v2/search/anime?keyword=生万物`
- `POST http://{ip}:9321/87654321/api/v2/api/v2/match`
- `POST http://{ip}:9321/87654321/api/v2/match`
- `GET http://{ip}:9321/87654321/api/v2/search/episodes?anime=生万物`
- `GET http://{ip}:9321/87654321/api/v2/bangumi/1`
- `GET http://{ip}:9321/87654321/api/v2/comment/1?withRelated=true&chConvert=1`
- `POST http://{ip}:9321/87654321/api/v2/comment/by-url`
- `GET http://{ip}:9321/87654321/api/logs`
## 使用 Docker 运行
@@ -224,8 +226,7 @@ LogVar 弹幕 API 服务器
| ----------- | ----------- |
| TOKEN | 【可选】自定义用户token,不填默认为`87654321` |
| OTHER_SERVER | 【可选】兜底第三方弹幕服务器,不填默认为`https://api.danmu.icu` |
| VOD_SERVER | 【可选】vod查询站点,不填默认为`https://gctf.tfdh.top` |
| VOD_SERVER2 | 【可选】vod2查询站点,如果想开启vod2源,请先填写VOD_SERVER2源地址,如`https://www.caiji.cyou`、`https://zy.xmm.hk`,并在SOURCE_ORDER环境变量中增加`vod2` |
| VOD_SERVERS | 【可选】VOD服务器列表,支持多个服务器并发查询,格式:`名称@URL,名称@URL,...`,示例:`vod@https://www.caiji.cyou,vod2@https://zy.xmm.hk`,不填默认为`vod@https://www.caiji.cyou` |
| 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` |
@@ -301,7 +302,7 @@ danmu_api/
- 运行 Docker 容器时,需通过 `-e TOKEN=87654321` 传递 `TOKEN` 环境变量。
- cloudflare貌似被哔风控了。
- 如果想更换兜底第三方弹幕服务器,请添加环境变量`OTHER_SERVER`,示例`https://api.danmu.icu`。
- 如果想更换vod站点,请添加环境变量`VOD_SERVER`,示例`https://www.caiji.cyou`。
- 如果想更换vod站点,请添加环境变量`VOD_SERVERS`,示例`vod@https://www.caiji.cyou,vod2@https://zy.xmm.hk`(支持多个服务器并发查询)。
- 推荐vercel部署,cloudflare/edgeone/claw不稳定,当然最稳定还是自己本地docker部署最佳。
- /api/v2/comment接口1分钟内同一IP只能请求三次。
+9
View File
@@ -1,4 +1,13 @@
// server.js - 智能服务器启动器:根据 Node.js 环境自动选择最优启动模式
// 加载 .env 文件中的环境变量(本地开发时使用)
try {
require('dotenv').config();
console.log('[server] .env file loaded successfully');
} catch (e) {
console.log('[server] dotenv not available or .env file not found, using system environment variables');
}
// 导入 ES module 兼容层(始终加载,但内部会根据需要启用)
require('./esm-shim');
+159 -47
View File
@@ -1,6 +1,6 @@
// 全局状态(Cloudflare 和 Vercel 都可能重用实例)
// ⚠️ 不是持久化存储,每次冷启动会丢失
const VERSION = "1.3.1";
const VERSION = "1.3.2";
let animes = [];
let episodeIds = [];
let episodeNum = 10001; // 全局变量,用于自增 ID
@@ -43,22 +43,39 @@ function resolveOtherServer(env) {
return DEFAULT_OTHER_SERVER;
}
const DEFAULT_VOD_SERVER = "https://gctf.tfdh.top"; // 默认 vod站点
let vodServer = DEFAULT_VOD_SERVER;
const DEFAULT_VOD_SERVERS = "vod@https://www.caiji.cyou"; // 默认 vod站点配置,格式:名称@URL,名称@URL
let vodServers = [];
function resolveVodServer(env) {
if (env && env.VOD_SERVER) return env.VOD_SERVER; // Cloudflare Workers
if (typeof process !== "undefined" && process.env?.VOD_SERVER) return process.env.VOD_SERVER; // Vercel / Node
return DEFAULT_VOD_SERVER;
}
function resolveVodServers(env) {
// 获取配置字符串
let vodServersConfig = DEFAULT_VOD_SERVERS;
if (env && env.VOD_SERVERS) {
vodServersConfig = env.VOD_SERVERS; // Cloudflare Workers
} else if (typeof process !== "undefined" && process.env?.VOD_SERVERS) {
vodServersConfig = process.env.VOD_SERVERS; // Vercel / Node
}
const DEFAULT_VOD_SERVER2 = ""; // 默认 vod站点2
let vodServer2 = DEFAULT_VOD_SERVER2;
// 解析配置:支持 "名称@URL,名称@URL" 格式
if (!vodServersConfig || vodServersConfig.trim() === "") {
return [];
}
function resolveVodServer2(env) {
if (env && env.VOD_SERVER2) return env.VOD_SERVER2; // Cloudflare Workers
if (typeof process !== "undefined" && process.env?.VOD_SERVER2) return process.env.VOD_SERVER2; // Vercel / Node
return DEFAULT_VOD_SERVER2;
const servers = vodServersConfig
.split(',')
.map(s => s.trim())
.filter(s => s.length > 0)
.map((item, index) => {
if (item.includes('@')) {
const [name, url] = item.split('@').map(s => s.trim());
return { name: name || `vod-${index + 1}`, url };
} else {
// 没有 @ 符号,自动生成名称
return { name: `vod-${index + 1}`, url: item };
}
})
.filter(server => server.url && server.url.length > 0); // 过滤掉空 URL
return servers;
}
const DEFAULT_BILIBILI_COOKIE = ""; // 默认 bilibili cookie
@@ -87,7 +104,7 @@ function resolveYoukuConcurrency(env) {
return Math.min(DEFAULT_YOUKU_CONCURRENCY, 16);
}
const DEFAULT_SOURCE_ORDER = "vod,360,renren,hanjutv"; // 默认 源排序
const DEFAULT_SOURCE_ORDER = "360,vod,renren,hanjutv"; // 默认 源排序
let sourceOrderArr = [];
function resolveSourceOrder(env, deployPlatform) {
@@ -103,8 +120,8 @@ function resolveSourceOrder(env, deployPlatform) {
sourceOrder = process.env.SOURCE_ORDER; // Vercel / Node
}
// 解析并校验 sourceOrder
const allowedSources = ['vod', 'vod2', '360', 'renren', "hanjutv", "bahamut"];
// 解析并校验 sourceOrder(移除 vod2,因为已合并到 vod)
const allowedSources = ['360', 'vod', 'renren', "hanjutv", "bahamut"];
// 转换为数组并去除空格,过滤无效项
const orderArr = sourceOrder
@@ -848,7 +865,7 @@ async function get360Zongyi(title, entId, site, year) {
}
// 查询vod站点影片信息
async function getVodAnimes(title, server) {
async function getVodAnimes(title, server, serverName) {
try {
const response = await httpGet(
`${server}/api.php/provide/vod/?ac=detail&wd=${title}&pg=1`,
@@ -861,23 +878,42 @@ async function getVodAnimes(title, server) {
);
// 检查 response.data.list 是否存在且长度大于 0
if (response && response.data && response.data.list && response.data.list.length > 0) {
log("info", `请求 ${vodServer} 成功`);
log("info", `请求 ${serverName}(${server}) 成功`);
const data = response.data;
log("info", "vod response: ↓↓↓");
log("info", `${serverName} response: ↓↓↓`);
printFirst200Chars(data);
return data.list;
return { serverName, list: data.list };
} else {
log("info", `请求 ${vodServer} 成功,但 response.data.list 为空`);
return [];
log("info", `请求 ${serverName}(${server}) 成功,但 response.data.list 为空`);
return { serverName, list: [] };
}
} catch (error) {
log("error", `请求 ${vodServer} 失败:`, {
log("error", `请求 ${serverName}(${server}) 失败:`, {
message: error.message,
name: error.name,
stack: error.stack,
});
return { serverName, list: [] };
}
}
// 查询所有vod站点影片信息(并发查询)
async function getVodAnimesFromAllServers(title, servers) {
if (!servers || servers.length === 0) {
return [];
}
// 并发查询所有服务器,使用 allSettled 确保单个服务器失败不影响其他服务器
const promises = servers.map(server =>
getVodAnimes(title, server.url, server.name)
);
const results = await Promise.allSettled(promises);
// 过滤出成功的结果,即使某些服务器失败也不影响其他服务器
return results
.filter(result => result.status === 'fulfilled')
.map(result => result.value);
}
// =====================
@@ -3792,9 +3828,8 @@ async function searchAnime(url) {
// 根据 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);
if (source === "360") return get360Animes(queryTitle);
if (source === "vod") return getVodAnimesFromAllServers(queryTitle, vodServers);
if (source === "renren") return renrenSearch(queryTitle);
if (source === "hanjutv") return hanjutvSearch(queryTitle);
if (source === "bahamut") return bahamutSearch(traditionalized(queryTitle));
@@ -3812,19 +3847,22 @@ async function searchAnime(url) {
});
// 解构出返回的结果
const { vod: animesVod, vod2: animesVod2, 360: animes360, renren: animesRenren, hanjutv: animesHanjutv, bahamut: animesBahamut } = resultData;
const { vod: animesVodResults, 360: animes360, renren: animesRenren, hanjutv: animesHanjutv, bahamut: animesBahamut } = resultData;
// 按顺序处理每个来源的结果
for (const key of sourceOrderArr) {
if (key === 'vod') {
// 等待处理Vod来源
await handleVodAnimes(animesVod, curAnimes, key);
} else if (key === 'vod2') {
// 等待处理Vod2来源
await handleVodAnimes(animesVod2, curAnimes, key);
} else if (key === '360') {
if (key === '360') {
// 等待处理360来源
await handle360Animes(animes360, curAnimes);
} else if (key === 'vod') {
// 等待处理Vod来源(遍历所有VOD服务器的结果)
if (animesVodResults && Array.isArray(animesVodResults)) {
for (const vodResult of animesVodResults) {
if (vodResult && vodResult.list && vodResult.list.length > 0) {
await handleVodAnimes(vodResult.list, curAnimes, vodResult.serverName);
}
}
}
} else if (key === 'renren') {
// 等待处理Renren来源
await handleRenrenAnimes(animesRenren, queryTitle, curAnimes);
@@ -4261,17 +4299,13 @@ async function getComment(path) {
let danmus = [];
if (url.includes('.qq.com')) {
danmus = await fetchTencentVideo(url);
}
if (url.includes('.iqiyi.com')) {
} else if (url.includes('.iqiyi.com')) {
danmus = await fetchIqiyi(url);
}
if (url.includes('.mgtv.com')) {
} else if (url.includes('.mgtv.com')) {
danmus = await fetchMangoTV(url);
}
if (url.includes('.bilibili.com')) {
} else if (url.includes('.bilibili.com')) {
danmus = await fetchBilibili(url);
}
if (url.includes('.youku.com')) {
} else if (url.includes('.youku.com')) {
danmus = await fetchYouku(url);
}
@@ -4301,15 +4335,88 @@ async function getComment(path) {
return jsonResponse({ count: danmus.length, comments: danmus });
}
// Extracted function for POST /api/v2/comment/by-url
async function getCommentByUrl(req) {
try {
// 获取请求体
const body = await req.json();
// 验证请求体是否有效
if (!body || !body.videoUrl) {
log("error", "Missing videoUrl parameter in request body");
return jsonResponse(
{ errorCode: 400, success: false, errorMessage: "Missing videoUrl parameter", count: 0, comments: [] },
400
);
}
const videoUrl = body.videoUrl.trim();
// 验证URL格式
if (!videoUrl.startsWith('http')) {
log("error", "Invalid videoUrl format");
return jsonResponse(
{ errorCode: 400, success: false, errorMessage: "Invalid videoUrl format", count: 0, comments: [] },
400
);
}
log("info", `Processing comment request for URL: ${videoUrl}`);
// 处理优酷302场景
let url = videoUrl;
if (url.includes("youku.com/video?vid")) {
url = convertYoukuUrl(url);
}
log("info", "开始从本地请求弹幕...", url);
let danmus = [];
// 根据URL域名判断平台并获取弹幕
if (url.includes('.qq.com')) {
danmus = await fetchTencentVideo(url);
} else if (url.includes('.iqiyi.com')) {
danmus = await fetchIqiyi(url);
} else if (url.includes('.mgtv.com')) {
danmus = await fetchMangoTV(url);
} else if (url.includes('.bilibili.com') || url.includes('b23.tv')) {
danmus = await fetchBilibili(url);
} else if (url.includes('.youku.com')) {
danmus = await fetchYouku(url);
} else {
// 如果不是已知平台,尝试第三方弹幕服务器
const urlPattern = /^(https?:\/\/)?([\w.-]+)\.([a-z]{2,})(\/.*)?$/i;
if (urlPattern.test(url)) {
danmus = await fetchOtherServer(url);
}
}
log("info", `Successfully fetched ${danmus.length} comments from URL`);
return jsonResponse({
errorCode: 0,
success: true,
errorMessage: "",
count: danmus.length,
comments: danmus
});
} catch (error) {
// 处理 JSON 解析错误或其他异常
log("error", `Failed to process comment by URL request: ${error.message}`);
return jsonResponse(
{ errorCode: 500, success: false, errorMessage: "Internal server error", count: 0, comments: [] },
500
);
}
}
async function handleRequest(req, env, deployPlatform, clientIp) {
token = resolveToken(env); // 每次请求动态获取,确保热更新环境变量后也能生效
envs["token"] = encryptStr(token);
otherServer = resolveOtherServer(env);
envs["otherServer"] = otherServer;
vodServer = resolveVodServer(env);
envs["vodServer"] = vodServer;
vodServer2 = resolveVodServer2(env);
envs["vodServer2"] = vodServer2;
vodServers = resolveVodServers(env);
envs["vodServers"] = vodServers.map(s => `${s.name}@${s.url}`).join(',');
bilibliCookie = resolveBilibiliCookie(env);
envs["bilibliCookie"] = encryptStr(bilibliCookie);
youkuConcurrency = resolveYoukuConcurrency(env);
@@ -4446,6 +4553,11 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
return getBangumi(path);
}
// POST /api/v2/comment/by-url
if (path === "/api/v2/comment/by-url" && method === "POST") {
return getCommentByUrl(req);
}
// GET /api/v2/comment/:commentId
if (path.startsWith("/api/v2/comment/") && method === "GET") {
// 获取当前时间戳(单位:毫秒)
@@ -4529,7 +4641,7 @@ export async function vercelHandler(req, res) {
}
// 为了测试导出 handleRequest
export { handleRequest, searchAnime, searchEpisodes, matchAnime, getBangumi, getComment, fetchTencentVideo, fetchIqiyi,
export { handleRequest, searchAnime, searchEpisodes, matchAnime, getBangumi, getComment, getCommentByUrl, fetchTencentVideo, fetchIqiyi,
fetchMangoTV, fetchBilibili, fetchYouku, fetchOtherServer, httpGet, httpPost, hanjutvSearch, getHanjutvEpisodes,
getHanjutvComments, getHanjutvDetail, bahamutSearch, getBahamutEpisodes, getBahamutComments, pingRedis, getRedisKey,
setRedisKey, setRedisKeyWithExpiry};
+1
View File
@@ -9,6 +9,7 @@
"author": "",
"license": "ISC",
"dependencies": {
"dotenv": "^16.4.7",
"esbuild": "^0.25.10",
"https-proxy-agent": "^7.0.6",
"node-fetch": "^3.3.2"