增加PLATFORM_ORDER和EPISODE_TITLE_FILTER环境变量 (#27)

* 优化一些小细节:
删除cf worker部署时的默认环境变量,修复每次更新被覆盖的问题
补充README

* 如果sourceOrderArr中没有的源,则也不请求

* 增加PLATFORM_ORDER环境变量,用于指定优选返回某平台弹幕

* 增加EPISODE_TITLE_FILTER环境变量,按正则关键字对剧集或综艺的集标题进行过滤,适用于过滤一些预告或综艺非正式集
This commit is contained in:
huangxd- authored and GitHub committed 2025-09-27 12:23:46 +08:00
1 parent 3a5323b9b7
commit 69362a6c66
3 files changed
+234 -56

No files matched your search

+34 -6
View File
@@ -1,4 +1,10 @@
# LogVar 弹幕 API 服务器
<div align="center">
<img src="https://i.mji.rip/2025/09/27/eedc7b701c0fa5c1f7c175b22f441ad9.jpeg" alt="Clash" width="128" style="border-radius: 16px;" />
</div>
<h2 align="center">
LogVar 弹幕 API 服务器
</h2>
[![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. 如果你播放的影片片名不规范,很可能搜不到,请确保片名规范
### 贡献者
<a href="https://github.com/huangxd-/danmu_api/graphs/contributors">
+199 -45
View File
@@ -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;
+1 -5
View File
@@ -1,7 +1,3 @@
name = "danmu-api"
main = "danmu_api/worker.js"
compatibility_date = "2025-09-13"
# 可选:默认环境变量(运行时可覆盖)
[vars]
TOKEN = "87654321"
compatibility_date = "2025-09-13"