增加GROUP_MINUTE环境变量,合并去重分钟数,表示按n分钟分组后对弹幕合并去重,默认为1,最大值为30,0表示不去重 (#38)
* 增加GROUP_MINUTE环境变量,合并去重分钟数,表示按n分钟分组后对弹幕合并去重,默认为1,最大值为30,0表示不去重 * 将allowedPlatforms和vodAllowedPlatforms提取成全局变量
This commit is contained in:
1 parent
478afcf18b
commit
f671fa758f
2 files changed
+128
-61
No files matched your search
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user