diff --git a/README.md b/README.md index f230c26..c9347b4 100644 --- a/README.md +++ b/README.md @@ -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 默认值 diff --git a/danmu_api/worker.js b/danmu_api/worker.js index ef25207..b5ccac0 100644 --- a/danmu_api/worker.js +++ b/danmu_api/worker.js @@ -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({ - 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;