增加GROUP_MINUTE环境变量,合并去重分钟数,表示按n分钟分组后对弹幕合并去重,默认为1,最大值为30,0表示不去重 (#38)

* 增加GROUP_MINUTE环境变量,合并去重分钟数,表示按n分钟分组后对弹幕合并去重,默认为1,最大值为30,0表示不去重

* 将allowedPlatforms和vodAllowedPlatforms提取成全局变量
This commit is contained in:
huangxd- authored and GitHub committed 2025-10-03 19:51:01 +08:00
1 parent 478afcf18b
commit f671fa758f
2 files changed
+128 -61

No files matched your search

+2 -1
View File
@@ -30,7 +30,7 @@ LogVar 弹幕 API 服务器
## 功能
- **API 接口**:
- `GET /api/v2/search/anime?keyword=${queryTitle}`:根据关键字搜索动漫。
- `POST /api/v2/match`:根据关键字匹配动漫,用于自动匹配。
- `POST /api/v2/match`:根据关键字匹配动漫,用于自动匹配。(目前已支持在match接口中通过@语法动态指定平台优先级,如`赴山海 S01E28 @qiyi`)
- `GET /api/v2/search/episodes`:根据关键词搜索所有匹配的剧集信息。
- `GET /api/v2/bangumi/:animeId`:获取指定动漫的详细信息。
- `GET /api/v2/comment/:commentId?withRelated=true&chConvert=1`:获取指定弹幕评论,支持返回相关评论和字符转换。
@@ -227,6 +227,7 @@ Settings > Functions > Advanced Setting > Function Region 切换为 Hong Kong,
| PLATFORM_ORDER | 【可选】自动匹配优选平台,按顺序优先返回指定平台弹幕,默认为空,即返回第一个满足条件的平台,示例:`bilibili1,qq`,表示如果有b站的播放源,则优先返回b站的弹幕,否则就返回腾讯的弹幕,两者都没有,则返回第一个满足条件的平台;当前可选择的平台字段有 `qiyi, bilibili1, imgo, youku, qq, renren, hanjutv` |
| EPISODE_TITLE_FILTER | 【可选】剧集标题正则过滤,按正则关键字对剧集或综艺的集标题进行过滤,适用于过滤一些预告或综艺非正式集,默认值如下 |
| BLOCKED_WORDS | 【可选】弹幕屏蔽词列表,默认为空,示例如下 |
| GROUP_MINUTE | 【可选】合并去重分钟数,表示按n分钟分组后对弹幕合并去重,默认为1,最大值为30,0表示不去重 |
```regex
# EPISODE_TITLE_FILTER 默认值
+126 -60
View File
@@ -1,6 +1,6 @@
// 全局状态(Cloudflare 和 Vercel 都可能重用实例)
// ⚠️ 不是持久化存储,每次冷启动会丢失
const VERSION = "1.2.2";
const VERSION = "1.2.3";
let animes = [];
let episodeIds = [];
let episodeNum = 10001; // 全局变量,用于自增 ID
@@ -9,7 +9,8 @@ let episodeNum = 10001; // 全局变量,用于自增 ID
const logBuffer = [];
const MAX_LOGS = 500;
const MAX_ANIMES = 100;
const allowedPlatforms = ["qiyi", "bilibili1", "imgo", "youku", "qq"];
const vodAllowedPlatforms = ["qiyi", "bilibili1", "imgo", "youku", "qq"];
const allowedPlatforms = ["qiyi", "bilibili1", "imgo", "youku", "qq", "renren", "hanjutv"];
// =====================
// 环境变量处理
@@ -112,14 +113,11 @@ function resolvePlatformOrder(env) {
platformOrder = process.env.PLATFORM_ORDER; // Vercel / Node
}
// 解析并校验 platformOrder
const _allowedPlatforms = ["qiyi", "bilibili1", "imgo", "youku", "qq", "renren", "hanjutv"];
// 转换为数组并去除空格,过滤无效项
const orderArr = platformOrder
.split(',')
.map(s => s.trim()) // 去除空格
.filter(s => _allowedPlatforms.includes(s)); // 只保留有效来源
.filter(s => allowedPlatforms.includes(s)); // 只保留有效来源
// 如果没有有效的来源,使用默认顺序
if (orderArr.length === 0) {
@@ -188,6 +186,22 @@ function resolveBlockedWords(env) {
return DEFAULT_BLOCKED_WORDS;
}
// 分钟内合并去重(默认 1,最大值30,0表示不去重)
const DEFAULT_GROUP_MINUTE = 1;
let groupMinute = DEFAULT_GROUP_MINUTE;
function resolveGroupMinute(env) {
if (env && env.GROUP_MINUTE) {
const n = parseInt(env.GROUP_MINUTE, 10);
if (!Number.isNaN(n) && n > 0) return Math.min(n, 30);
}
if (typeof process !== "undefined" && process.env?.GROUP_MINUTE) {
const n = parseInt(process.env.GROUP_MINUTE, 10);
if (!Number.isNaN(n) && n > 0) return Math.min(n, 30);
}
return Math.min(DEFAULT_GROUP_MINUTE, 30);
}
// =====================
// 数据结构处理函数
// =====================
@@ -574,6 +588,69 @@ function printFirst200Chars(data) {
log("log", dataToPrint.slice(0, 200)); // 打印前200个字符
}
function groupDanmusByMinute(filteredDanmus, n) {
// 如果 n 为 0,直接返回原始数据
if (n === 0) {
return filteredDanmus.map(danmu => ({
...danmu,
t: danmu.t !== undefined ? danmu.t : parseFloat(danmu.p.split(',')[0])
}));
}
// 按 n 分钟分组
const groupedByMinute = filteredDanmus.reduce((acc, danmu) => {
// 获取时间:优先使用 t 字段,如果没有则使用 p 的第一个值
const time = danmu.t !== undefined ? danmu.t : parseFloat(danmu.p.split(',')[0]);
// 计算分组(每 n 分钟一组,向下取整)
const group = Math.floor(time / (n * 60));
// 初始化分组
if (!acc[group]) {
acc[group] = [];
}
// 添加到对应分组
acc[group].push({ ...danmu, t: time });
return acc;
}, {});
// 处理每组的弹幕
const result = Object.keys(groupedByMinute).map(group => {
const danmus = groupedByMinute[group];
// 按消息内容分组
const groupedByMessage = danmus.reduce((acc, danmu) => {
const message = danmu.m.split(' X')[0]; // 提取原始消息(去除 Xn 后缀)
if (!acc[message]) {
acc[message] = {
count: 0,
earliestT: danmu.t,
cid: danmu.cid,
p: danmu.p
};
}
acc[message].count += 1;
// 更新最早时间
acc[message].earliestT = Math.min(acc[message].earliestT, danmu.t);
return acc;
}, {});
// 转换为结果格式
return Object.keys(groupedByMessage).map(message => {
const data = groupedByMessage[message];
return {
cid: data.cid,
p: data.p,
m: data.count > 1 ? `${message} X${data.count}` : message,
t: data.earliestT
};
});
});
// 展平结果并按时间排序
return result.flat().sort((a, b) => a.t - b.t);
}
function convertToDanmakuJson(contents, platform) {
let danmus = [];
let cidCounter = 1;
@@ -633,12 +710,21 @@ function convertToDanmakuJson(contents, platform) {
}
// 处理 XML 解析后的格式
const pValues = item.p.split(",");
attributes = [
parseFloat(pValues[0]).toFixed(2),
pValues[1] || 0,
pValues[3] || 16777215,
`[${platform}]`
].join(",");
if (pValues.length === 4) {
attributes = [
parseFloat(pValues[0]).toFixed(2),
pValues[1] || 0,
pValues[2] || 16777215,
`[${platform}]`
].join(",");
} else {
attributes = [
parseFloat(pValues[0]).toFixed(2),
pValues[1] || 0,
pValues[3] || 16777215,
`[${platform}]`
].join(",");
}
m = item.m;
}
@@ -670,11 +756,16 @@ function convertToDanmakuJson(contents, platform) {
return !regexArray.some(regex => regex.test(item.m)); // 针对 `m` 字段进行匹配
});
// 按n分钟内去重
log("log", "去重分钟数:", groupMinute);
const groupedDanmus = groupDanmusByMinute(filteredDanmus, groupMinute);
log("log", "danmus_original:", danmus.length);
log("log", "danmus:", filteredDanmus.length);
log("log", "danmus_filter:", filteredDanmus.length);
log("log", "danmus_group:", groupedDanmus.length);
// 输出前五条弹幕
log("log", "Top 5 danmus:", JSON.stringify(filteredDanmus.slice(0, 5), null, 2));
return filteredDanmus;
log("log", "Top 5 danmus:", JSON.stringify(groupedDanmus.slice(0, 5), null, 2));
return groupedDanmus;
}
function buildQueryString(params) {
@@ -1036,9 +1127,6 @@ function normalizePlatformName(inputPlatform) {
const input = inputPlatform.trim();
// 支持的平台名称:["qiyi", "bilibili1", "imgo", "youku", "qq", "renren", "hanjutv"]
const allowedPlatforms = ["qiyi", "bilibili1", "imgo", "youku", "qq", "renren", "hanjutv"];
// 直接返回输入的平台名称(如果有效)
if (allowedPlatforms.includes(input)) {
return input;
@@ -1055,7 +1143,6 @@ function createDynamicPlatformOrder(preferredPlatform) {
}
// 验证平台是否有效
const allowedPlatforms = ["qiyi", "bilibili1", "imgo", "youku", "qq", "renren", "hanjutv"];
if (!allowedPlatforms.includes(preferredPlatform)) {
log("warn", `Invalid platform: ${preferredPlatform}, using default order`);
return [...platformOrderArr];
@@ -2648,35 +2735,17 @@ function parseRRSPPFields(pField) {
return { timestamp, mode, size, color, userId, contentId };
}
function formatComments(items) {
const unique = {};
for(const it of items){
const text = String(it.d||"");
const meta = parseRRSPPFields(it.p);
if(!unique[meta.contentId]) unique[meta.contentId] = { content: text, ...meta };
}
const grouped = {};
for(const c of Object.values(unique)){
if(!grouped[c.content]) grouped[c.content] = [];
grouped[c.content].push(c);
}
const processed = [];
for(const [content, group] of Object.entries(grouped)){
if(group.length===1) processed.push(group[0]);
else{
const first = group.reduce((a,b)=>a.timestamp<b.timestamp?a:b);
processed.push({...first, content:`${first.content} X${group.length}`});
}
}
return processed.map(c=>({
cid: Number(c.contentId),
p: `${c.timestamp.toFixed(2)},${c.mode},${c.color},[renren]`,
m: c.content,
t: c.timestamp
}));
function formatRenrenComments(items) {
return items.map(item => {
const text = String(item.d || "");
const meta = parseRRSPPFields(item.p);
return {
cid: Number(meta.contentId),
p: `${meta.timestamp.toFixed(2)},${meta.mode},${meta.color},[renren]`,
m: text,
t: meta.timestamp
};
});
}
async function getRenRenComments(episodeId, progressCallback=null){
@@ -2685,12 +2754,10 @@ async function getRenRenComments(episodeId, progressCallback=null){
const raw = await fetchEpisodeDanmu(episodeId);
if(progressCallback) await progressCallback(85,`原始弹幕 ${raw.length} 条,正在规范化`);
log("log", `原始弹幕 ${raw.length} 条,正在规范化`);
const formatted = formatComments(raw);
const formatted = formatRenrenComments(raw);
if(progressCallback) await progressCallback(100,`弹幕处理完成,共 ${formatted.length} 条`);
log("log", `弹幕处理完成,共 ${formatted.length} 条`);
// 输出前五条弹幕
log("log", "Top 5 danmus:", JSON.stringify(formatted.slice(0, 5), null, 2));
return formatted;
return convertToDanmakuJson(formatted, "renren");
}
// ---------------------
@@ -2868,9 +2935,7 @@ async function getHanjutvComments(pid, progressCallback=null){
const formatted = formatHanjutvComments(raw);
if(progressCallback) await progressCallback(100,`弹幕处理完成,共 ${formatted.length} 条`);
log("log", `弹幕处理完成,共 ${formatted.length} 条`);
// 输出前五条弹幕
log("log", "Top 5 danmus:", JSON.stringify(formatted.slice(0, 5), null, 2));
return formatted;
return convertToDanmakuJson(formatted, "hanjutv");
}
// =====================
@@ -3013,7 +3078,7 @@ async function handleVodAnimes(animesVod, curAnimes) {
const vodPlayUrlList = anime.vod_play_url.split("$$$");
const validIndices = vodPlayFromList
.map((item, index) => allowedPlatforms.includes(item) ? index : -1)
.map((item, index) => vodAllowedPlatforms.includes(item) ? index : -1)
.filter(index => index !== -1);
let links = [];
@@ -3064,7 +3129,7 @@ async function handle360Animes(animes360, curAnimes) {
let links = [];
if (anime.cat_name === "电影") {
for (const key of Object.keys(anime.playlinks)) {
if (allowedPlatforms.includes(key)) {
if (vodAllowedPlatforms.includes(key)) {
links.push({
"name": key,
"url": anime.playlinks[key],
@@ -3073,7 +3138,7 @@ async function handle360Animes(animes360, curAnimes) {
}
}
} else if (anime.cat_name === "电视剧" || anime.cat_name === "动漫") {
if (allowedPlatforms.includes(anime.seriesSite)) {
if (vodAllowedPlatforms.includes(anime.seriesSite)) {
for (let i = 0; i < anime.seriesPlaylinks.length; i++) {
const item = anime.seriesPlaylinks[i];
links.push({
@@ -3086,7 +3151,7 @@ async function handle360Animes(animes360, curAnimes) {
} else if (anime.cat_name === "综艺") {
const zongyiLinks = await Promise.all(
Object.keys(anime.playlinks_year).map(async (site) => {
if (allowedPlatforms.includes(site)) {
if (vodAllowedPlatforms.includes(site)) {
const yearLinks = await Promise.all(
anime.playlinks_year[site].map(async (year) => {
return await get360Zongyi(anime.id, site, year);
@@ -3678,7 +3743,7 @@ async function getComment(path) {
}
// 如果弹幕为空,则请求第三方弹幕服务器作为兜底
if (danmus.length === 0) {
if (danmus.length === 0 && urlPattern.test(url)) {
danmus = await fetchOtherServer(url);
}
@@ -3695,6 +3760,7 @@ async function handleRequest(req, env) {
platformOrderArr = resolvePlatformOrder(env);
episodeTitleFilter = resolveEpisodeTitleFilter(env);
blockedWords = resolveBlockedWords(env);
groupMinute = resolveGroupMinute(env);
const url = new URL(req.url);
let path = url.pathname;