diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..eac0255 --- /dev/null +++ b/.env.example @@ -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= + diff --git a/README.md b/README.md index a931c00..a2aea3b 100644 --- a/README.md +++ b/README.md @@ -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只能请求三次。 diff --git a/danmu_api/server.js b/danmu_api/server.js index 0043396..2a056ea 100644 --- a/danmu_api/server.js +++ b/danmu_api/server.js @@ -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'); diff --git a/danmu_api/worker.js b/danmu_api/worker.js index 0b8ba2d..497b1c9 100644 --- a/danmu_api/worker.js +++ b/danmu_api/worker.js @@ -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}; diff --git a/package.json b/package.json index 4461db3..08db7d4 100644 --- a/package.json +++ b/package.json @@ -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"