diff --git a/README.md b/README.md index e700661..c78e184 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,10 @@ -# LogVar 弹幕 API 服务器 +
+ Clash +
+ +

+LogVar 弹幕 API 服务器 +

[![GitHub](https://img.shields.io/badge/-GitHub-181717?logo=github)](https://github.com/huangxd-/damnu_api) ![GitHub License](https://img.shields.io/github/license/huangxd-/danmu_api) @@ -6,6 +12,8 @@ [![telegram](https://img.shields.io/static/v1?label=telegram&message=telegram_channel&color=blue)](https://t.me/logvar_danmu_channel) [![telegram](https://img.shields.io/static/v1?label=telegram&message=telegram_group&color=blue)](https://t.me/logvar_danmu_group) +--- + 一个人人都能部署的基于 js 的弹幕 API 服务器,支持爱优腾芒哔人弹幕直接获取,兼容弹弹play的搜索、详情查询和弹幕获取接口,并提供日志记录,支持vercel/cloudflare/docker/claw等部署方式,不用提前下载弹幕,没有nas或小鸡也能一键部署。 本项目仅为个人爱好开发,代码开源。如有任何侵权行为,请联系本人删除。 @@ -191,14 +199,22 @@ Settings > Functions > Advanced Setting > Function Region 切换为 Hong Kong, | VOD_SERVER | 【可选】vod查询站点,不填默认为`https://www.caiji.cyou` | | BILIBILI_COOKIE | 【可选】b站cookie(填入后能抓取完整弹幕),如 `buvid3=E2BCA ... eao6; theme-avatar-tip-show=SHOWED`,请自行通过浏览器或抓包工具抓取 | | YOUKU_CONCURRENCY | 【可选】youku弹幕请求并发数,用于加快youku弹幕请求速度,不填默认为`8`,最高`16` | -| SOURCE_ORDER | 【可选】源排序,用于按源对返回资源的排序(注意:先后顺序会影响自动匹配最终的返回),默认是`vod,360,renren`,表示vod数据排在最前,renren数据排在最后,示例`360,renren`:只返回360数据和renren数据,且360数据靠前 | +| SOURCE_ORDER | 【可选】源排序,用于按源对返回资源的排序(注意:先后顺序会影响自动匹配最终的返回),默认是`vod,360,renren`,表示vod数据排在最前,renren数据排在最后,示例:`360,renren`:只返回360数据和renren数据,且360数据靠前 | +| PLATFORM_ORDER | 【可选】自动匹配优选平台,按顺序优先返回指定平台弹幕,默认为空,即返回第一个满足条件的平台,示例:`bilibili1,qq`,表示如果有b站的播放源,则优先返回b站的弹幕,否则就返回腾讯的弹幕,两者都没有,则返回第一个满足条件的平台;当前可选择的平台字段有 `qiyi, bilibili1, imgo, youku, qq, renren` | +| EPISODE_TITLE_FILTER | 【可选】剧集标题正则过滤,按正则关键字对剧集或综艺的集标题进行过滤,适用于过滤一些预告或综艺非正式集,默认值如下 | + +```regex +// EPISODE_TITLE_FILTER 默认值 +(特别|惊喜|纳凉)?企划|合伙人手记|超前|速览|vlog|reaction|纯享|加更|抢先|抢鲜|预告|花絮|特辑|彩蛋|专访|幕后|直播|未播|衍生|番外|会员|片花|精华|看点|速看|解读|影评|解说|吐槽|盘点|拍摄花絮|制作花絮|幕后花絮|未播花絮|独家花絮|花絮特辑|先导预告|终极预告|正式预告|官方预告|彩蛋片段|删减片段|未播片段|番外彩蛋|精彩片段|精彩看点|精彩回顾|精彩集锦|看点解析|看点预告|NG镜头|NG花絮|番外篇|番外特辑|制作特辑|拍摄特辑|幕后特辑|导演特辑|演员特辑|片尾曲|插曲|主题曲|背景音乐|OST|音乐MV|歌曲MV|前季回顾|剧情回顾|往期回顾|内容总结|剧情盘点|精选合集|剪辑合集|混剪视频|独家专访|演员访谈|导演访谈|主创访谈|媒体采访|发布会采访|抢先看|抢先版|试看版|短剧|精编|会员版|Plus|独家版|特别版|短片|合唱|陪看|MV|高清正片|发布会|.{2,}篇|观察室|上班那点事儿|周top|赛段|直拍|REACTION|VLOG|全纪录 +``` ## 项目结构 ``` danmu_api/ ├── .github/ │ └── workflows/ -│ └── docker-image.yml +│ ├── docker-image.yml +│ └── sync_fork.yml # vercel自动同步配置文件 ├── danmu_api/ │ ├── esm-shim.js # Node.js低版本兼容层 │ ├── server.js # 本地node启动脚本 @@ -206,12 +222,15 @@ danmu_api/ │ ├── worker.test.js # 测试文件 ├── node-functions/ │ ├── [[...path]]..js # edgeone pages 所有路由跳转指向index -│ ├── index.js # edgeone pages 中间处理逻辑 +│ └── index.js # edgeone pages 中间处理逻辑 ├── .gitignore ├── Dockerfile +├── edgeone.json # edgeone pages 配置文件 +├── LICENSE ├── package.json -├── vercel.json ├── README.md +├── vercel.json # vercel 配置文件 +└── wrangler.toml # cloudflare worker 配置文件 ``` ## 注意事项 @@ -226,7 +245,16 @@ danmu_api/ - 推荐vercel和claw部署,cloudflare好像不稳定,当然最稳定还是自己本地docker部署最佳。 ### 关联项目 -[danmu_api 自动同步部署方案 - 永远保持最新版本!实时同步原作者更新](https://github.com/xiaoyao20084321/log-var-danmu-deployment-guide) +[喂饭教程1:danmu_api vercel 自动同步部署方案 - 永远保持最新版本!实时同步原作者更新](https://github.com/xiaoyao20084321/log-var-danmu-deployment-guide) + +[喂饭教程2:logvar弹幕搭建教程(docker/claw)](https://blog.tencentx.de/p/logvar%E5%BC%B9%E5%B9%95%E6%90%AD%E5%BB%BA%E6%95%99%E7%A8%8B%E5%96%82%E9%A5%AD%E7%89%88/) + +### 部署完成后在播放器填写后弹幕未生效自主排查步骤 +以API示例 `http://192.168.1.7:9321/87654321` 为例 +1. 首先确认你的api部署成功 访问 `http://192.168.1.7:9321/87654321` 有json输出 +2. 检查你在播放器的填写是否正确,有无多余空格等 +3. 播放器请求后,查看 `http://192.168.1.7:9321/87654321/api/logs` 日志,看请求是否有报错,比如有用户在自己软路由上搭建,但走了全局代理,导致人人等访问不了,请确保走直连 +4. 如果你播放的影片片名不规范,很可能搜不到,请确保片名规范 ### 贡献者 diff --git a/danmu_api/worker.js b/danmu_api/worker.js index 15cd58a..f14da88 100644 --- a/danmu_api/worker.js +++ b/danmu_api/worker.js @@ -1,6 +1,6 @@ // 全局状态(Cloudflare 和 Vercel 都可能重用实例) // ⚠️ 不是持久化存储,每次冷启动会丢失 -const VERSION = "1.1.2"; +const VERSION = "1.1.3"; let animes = []; let episodeIds = []; let episodeNum = 10001; // 全局变量,用于自增 ID @@ -99,6 +99,76 @@ function resolveSourceOrder(env) { return orderArr; } +const DEFAULT_PLATFORM_ORDER = ""; // 默认 自动匹配优选平台 +let platformOrderArr = []; + +function resolvePlatformOrder(env) { + // 获取环境变量中的 PLATFORM_ORDER 配置 + let platformOrder = DEFAULT_PLATFORM_ORDER; + + if (env && env.PLATFORM_ORDER) { + platformOrder = env.PLATFORM_ORDER; // Cloudflare Workers + } else if (typeof process !== "undefined" && process.env?.PLATFORM_ORDER) { + platformOrder = process.env.PLATFORM_ORDER; // Vercel / Node + } + + // 解析并校验 platformOrder + const allowedPlatforms = ["qiyi", "bilibili1", "imgo", "youku", "qq", "renren"]; + + // 转换为数组并去除空格,过滤无效项 + const orderArr = platformOrder + .split(',') + .map(s => s.trim()) // 去除空格 + .filter(s => allowedPlatforms.includes(s)); // 只保留有效来源 + + // 如果没有有效的来源,使用默认顺序 + if (orderArr.length === 0) { + return DEFAULT_PLATFORM_ORDER.split(',').map(s => s.trim()); + } + + orderArr.push(null); + + return orderArr; +} + +const DEFAULT_EPISODE_TITLE_FILTER = "(特别|惊喜|纳凉)?企划|合伙人手记|超前|速览|vlog|reaction|纯享|加更|抢先|抢鲜|预告|花絮|" + + "特辑|彩蛋|专访|幕后|直播|未播|衍生|番外|会员|片花|精华|看点|速看|解读|影评|解说|吐槽|盘点|拍摄花絮|制作花絮|幕后花絮|未播花絮|独家花絮|" + + "花絮特辑|先导预告|终极预告|正式预告|官方预告|彩蛋片段|删减片段|未播片段|番外彩蛋|精彩片段|精彩看点|精彩回顾|精彩集锦|看点解析|看点预告|" + + "NG镜头|NG花絮|番外篇|番外特辑|制作特辑|拍摄特辑|幕后特辑|导演特辑|演员特辑|片尾曲|插曲|主题曲|背景音乐|OST|音乐MV|歌曲MV|前季回顾|" + + "剧情回顾|往期回顾|内容总结|剧情盘点|精选合集|剪辑合集|混剪视频|独家专访|演员访谈|导演访谈|主创访谈|媒体采访|发布会采访|抢先看|抢先版|" + + "试看版|短剧|精编|会员版|Plus|独家版|特别版|短片|合唱|陪看|MV|高清正片|发布会|.{2,}篇|观察室|上班那点事儿|周top|赛段|直拍|REACTION|" + + "VLOG|全纪录"; // 默认 剧集标题正则过滤 +let episodeTitleFilter; + +// 这里既支持 Cloudflare env,也支持 Node process.env +function resolveEpisodeTitleFilter(env) { + // 获取环境变量中的 PLATFORM_ORDER 配置 + let keywords = DEFAULT_EPISODE_TITLE_FILTER; + + if (env && env.EPISODE_TITLE_FILTER) { + keywords = env.EPISODE_TITLE_FILTER; // Cloudflare Workers + } else if (typeof process !== "undefined" && process.env?.EPISODE_TITLE_FILTER) { + keywords = process.env.EPISODE_TITLE_FILTER; // Vercel / Node + } + + try { + // 尝试构建正则表达式,若失败则抛出异常 + new RegExp(keywords); + } catch (error) { + // 若正则构建失败,回退使用默认值 + log("warn", "Invalid EPISODE_TITLE_FILTER format, using default."); + keywords = DEFAULT_EPISODE_TITLE_FILTER; + } + + // 返回由过滤后的关键字生成的正则表达式 + return new RegExp( + "^" + + "(.*?)" + + "(" + keywords + ")" + // 将关键字加入正则表达式中 + "(.*?)$" + ); +} + // ===================== // 数据结构处理函数 // ===================== @@ -842,6 +912,18 @@ function readLengthDelimited(bytes, offset) { return [slice, end]; } +// 正则表达式:提取【】中的内容 +const extractTitle = (title) => { + const match = title.match(/【(.*?)】/); // 匹配【】中的内容 + return match ? match[1] : null; // 返回方括号中的内容,若没有匹配到,则返回null +}; + +// 提取##中的内容并进行匹配 +const extractEpTitle = (title) => { + const match = title.match(/#(.*?)#/); // 提取#号之间的内容 + return match ? match[1] : null; // 返回#号中的内容,若没有则返回null +}; + // ===================== // 获取腾讯弹幕 // ===================== @@ -2628,7 +2710,7 @@ async function handleVodAnimes(animesVod, curAnimes) { links.push({ "name": count, "url": epInfo[1], - "title": `【${platform}】${anime.vod_name}(${anime.vod_year}) ${epInfo[0]}` + "title": `【${platform}】${anime.vod_name}(${anime.vod_year}) #${epInfo[0]}#` }); } } @@ -2680,7 +2762,7 @@ async function handle360Animes(animes360, curAnimes) { links.push({ "name": i + 1, "url": item.url, - "title": `【${anime.seriesSite}】${anime.titleTxt}(${anime.year}) ${i + 1}` + "title": `【${anime.seriesSite}】${anime.titleTxt}(${anime.year}) #${i + 1}#` }); } } @@ -2739,7 +2821,7 @@ async function handleRenrenAnimes(animesRenren, queryTitle, curAnimes) { links.push({ "name": ep.episodeIndex, "url": ep.episodeId, - "title": `【${ep.provider}】${anime.title}(${anime.year}) ${ep.title}` + "title": `【${ep.provider}】${anime.title}(${anime.year}) #${ep.title}#` }); } @@ -2781,12 +2863,27 @@ async function searchAnime(url) { const curAnimes = []; try { - // 并行查询不同的来源 - const [animesVod, animes360, animesRenren] = await Promise.all([ - getVodAnimes(queryTitle), - get360Animes(queryTitle), - renrenSearch(queryTitle) - ]); + // 根据 sourceOrderArr 动态构建请求数组 + log("log", `Search sourceOrderArr: ${sourceOrderArr}`); + const requestPromises = sourceOrderArr.map(source => { + if (source === "vod") return getVodAnimes(queryTitle); + if (source === "360") return get360Animes(queryTitle); + if (source === "renren") return renrenSearch(queryTitle); + }); + + // 执行所有请求并等待结果 + const results = await Promise.all(requestPromises); + + // 创建一个对象来存储返回的结果 + const resultData = {}; + + // 动态根据 sourceOrderArr 顺序将结果赋值给对应的来源 + sourceOrderArr.forEach((source, index) => { + resultData[source] = results[index]; // 根据顺序赋值 + }); + + // 解构出返回的结果 + const { vod: animesVod, 360: animes360, renren: animesRenren } = resultData; // 按顺序处理每个来源的结果 for (const key of sourceOrderArr) { @@ -2813,6 +2910,80 @@ async function searchAnime(url) { }); } +async function matchAniAndEp(season, episode, searchData, title, req, platform) { + let resAnime; + let resEpisode; + if (season && episode) { + // 判断剧集 + for (const anime of searchData.animes) { + if (anime.animeTitle.includes(title)) { + let originBangumiUrl = new URL(req.url.replace("/match", `bangumi/${anime.bangumiId}`)); + const bangumiRes = await getBangumi(originBangumiUrl.pathname); + const bangumiData = await bangumiRes.json(); + log("info", "判断剧集", bangumiData); + + // 过滤符合条件的 episodes + const filteredEpisodes = bangumiData.bangumi.episodes.filter(episode => { + const filterEp = extractEpTitle(episode.episodeTitle); + return filterEp && !episodeTitleFilter.test(filterEp); // 如果##中的内容匹配正则表达式 + }); + + log("info", "filteredEpisodes", filteredEpisodes); + + if (platform) { + const firstIndex = filteredEpisodes.findIndex(episode => extractTitle(episode.episodeTitle) === platform); + const indexCount = filteredEpisodes.filter(episode => extractTitle(episode.episodeTitle) === platform).length; + if (indexCount > 0 && indexCount >= episode) { + // 先判断season + if (matchSeason(anime, title, season)) { + resEpisode = filteredEpisodes[firstIndex + episode - 1]; + resAnime = anime; + break; + } + } + } else { + if (filteredEpisodes.length >= episode) { + // 先判断season + if (matchSeason(anime, title, season)) { + resEpisode = filteredEpisodes[episode - 1]; + resAnime = anime; + break; + } + } + } + } + } + } else { + // 判断电影 + for (const anime of searchData.animes) { + const animeTitle = anime.animeTitle.split("(")[0].trim(); + if (animeTitle === title) { + let originBangumiUrl = new URL(req.url.replace("/match", `bangumi/${anime.bangumiId}`)); + const bangumiRes = await getBangumi(originBangumiUrl.pathname); + const bangumiData = await bangumiRes.json(); + log("info", bangumiData); + + if (platform) { + const firstIndex = bangumiData.bangumi.episodes.findIndex(episode => extractTitle(episode.episodeTitle) === platform); + const indexCount = bangumiData.bangumi.episodes.filter(episode => extractTitle(episode.episodeTitle) === platform).length; + if (indexCount > 0) { + resEpisode = bangumiData.bangumi.episodes[firstIndex]; + resAnime = anime; + break; + } + } else { + if (bangumiData.bangumi.episodes.length > 0) { + resEpisode = bangumiData.bangumi.episodes[0]; + resAnime = anime; + break; + } + } + } + } + } + return {resEpisode, resAnime}; +} + // Extracted function for POST /api/v2/match async function matchAnime(url, req) { try { @@ -2859,39 +3030,14 @@ async function matchAnime(url, req) { let resAnime; let resEpisode; - if (season && episode) { - // 判断剧集 - for (const anime of searchData.animes) { - if (anime.animeTitle.includes(title)) { - let originBangumiUrl = new URL(req.url.replace("/match", `bangumi/${anime.bangumiId}`)); - const bangumiRes = await getBangumi(originBangumiUrl.pathname); - const bangumiData = await bangumiRes.json(); - log("info", "判断剧集", bangumiData); - if (bangumiData.bangumi.episodes.length >= episode) { - // 先判断season - if (matchSeason(anime, title, season)) { - resEpisode = bangumiData.bangumi.episodes[episode-1]; - resAnime = anime; - break; - } - } - } - } - } else { - // 判断电影 - for (const anime of searchData.animes) { - const animeTitle = anime.animeTitle.split("(")[0].trim(); - if (animeTitle === title) { - let originBangumiUrl = new URL(req.url.replace("/match", `bangumi/${anime.bangumiId}`)); - const bangumiRes = await getBangumi(originBangumiUrl.pathname); - const bangumiData = await bangumiRes.json(); - log("info", bangumiData); - if (bangumiData.bangumi.episodes.length > 0) { - resEpisode = bangumiData.bangumi.episodes[0]; - resAnime = anime; - break; - } - } + log("info", `platformOrderArr: ${platformOrderArr}`); + for (const platform of platformOrderArr) { + const __ret = await matchAniAndEp(season, episode, searchData, title, req, platform); + resEpisode = __ret.resEpisode; + resAnime = __ret.resAnime; + + if (resAnime) { + break; } } @@ -2903,8 +3049,14 @@ async function matchAnime(url, req) { const bangumiData = await bangumiRes.json(); log("info", bangumiData); if (season && episode) { - if (bangumiData.bangumi.episodes.length >= episode) { - resEpisode = bangumiData.bangumi.episodes[episode-1]; + // 过滤符合条件的 episodes + const filteredEpisodes = bangumiData.bangumi.episodes.filter(episode => { + const filterEp = extractEpTitle(episode.episodeTitle); + return filterEp && !episodeTitleFilter.test(filterEp); // 如果##中的内容匹配正则表达式 + }); + + if (filteredEpisodes.length >= episode) { + resEpisode = filteredEpisodes[episode-1]; resAnime = anime; break; } @@ -3154,6 +3306,8 @@ async function handleRequest(req, env) { bilibliCookie = resolveBilibiliCookie(env); youkuConcurrency = resolveYoukuConcurrency(env); sourceOrderArr = resolveSourceOrder(env); + platformOrderArr = resolvePlatformOrder(env); + episodeTitleFilter = resolveEpisodeTitleFilter(env); const url = new URL(req.url); let path = url.pathname; diff --git a/wrangler.toml b/wrangler.toml index 72a4e0c..976c4af 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,7 +1,3 @@ name = "danmu-api" main = "danmu_api/worker.js" -compatibility_date = "2025-09-13" - -# 可选:默认环境变量(运行时可覆盖) -[vars] -TOKEN = "87654321" \ No newline at end of file +compatibility_date = "2025-09-13" \ No newline at end of file