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

+
+
+
+LogVar 弹幕 API 服务器
+
[](https://github.com/huangxd-/damnu_api)

@@ -6,6 +12,8 @@
[](https://t.me/logvar_danmu_channel)
[](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