diff --git a/README.md b/README.md index e28771a..fbce4ac 100644 --- a/README.md +++ b/README.md @@ -62,9 +62,9 @@ LogVar 弹幕 API 服务器 - `GET /danmaku/api/v2/fongmi/danmaku?name={name}&episode={episode}`:兼容FengMi影视api短路径。 - `GET /api/logs`:获取最近的日志(最多 500 行,格式为 `[时间戳] 级别: 消息`)。 - `GET /api/cache/animes`:获取最近的 animes 缓存。 -- **弹幕格式输出**:支持 JSON 和 XML 两种格式输出,通过以下方式配置: - - 环境变量:`DANMU_OUTPUT_FORMAT=json|xml`(默认:json) - - 查询参数:`?format=xml` 或 `?format=json`(优先级最高) +- **弹幕格式输出**:支持 JSON 和 XML 及 [@dan-uni/dan-any](https://github.com/ani-uni/dan-any)支持的全部输出格式 输出,通过以下方式配置: + - 环境变量:`DANMU_OUTPUT_FORMAT=json|xml|artplayer.json|baha.json|bili.xml|danuni.json|danuni.binpb|ddplay.json|dplayer.json|vod.json`(默认:json) + - 查询参数:`?format=xml` 或 `?format=json` ...(优先级最高) - 优先级:查询参数 > 环境变量 > 默认值 - 示例:`GET /api/v2/comment/10001?format=xml` 返回 XML 格式弹幕 - **XML 格式说明**:完全遵循 Bilibili 标准格式,8字段标准弹幕属性 @@ -453,7 +453,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 | CONVERT_COLOR | 【可选】弹幕转换颜色配置,默认为`default`(不转换),`white` 将所有非白色的弹幕颜色转换为纯白色,`color` 将所有白色弹幕转换为随机颜色(包含白色),可选值:`default`、`white`、`color` | | COLOR_POOL | 【可选】自定义颜色池(`CONVERT_COLOR`为`color`时生效),不配置使用默认颜色池(白、红、橙、黄、绿、青、蓝、紫、粉),格式:十进制颜色值逗号分隔,例如:`16711680,65280,255,16776960` | | LIKE_SWITCH | 【可选】弹幕点赞数显示开关,默认为`true`(开启),开启后会在弹幕内容后显示点赞数标记,≥5 才显示,避免低赞干扰 | -| DANMU_OUTPUT_FORMAT | 【可选】弹幕输出格式,默认为`json`,可选值:`json`(JSON格式)、`xml`(XML格式),支持通过查询参数`?format=xml`或`?format=json`覆盖此设置,优先级:查询参数 > 环境变量 > 默认值 | +| DANMU_OUTPUT_FORMAT | 【可选】弹幕输出格式,默认为`json`,可选值:`json`(JSON格式)、`xml`(XML格式)及所有`@dan-uni/dan-any`支持的输出格式,支持通过查询参数`?format=xml`或`?format=json`等覆盖此设置,优先级:查询参数 > 环境变量 > 默认值 | | DANMU_SIMPLIFIED_TRADITIONAL | 【可选】弹幕简繁体转换设置:default(默认不转换)、simplified(繁转简)、traditional(简转繁) | | DANMU_OFFSET | 【可选】弹幕时间偏移配置,用于解决弹幕与视频不同步的问题。格式:剧名:秒(全剧偏移)或 剧名/季:秒(整季偏移)或 剧名/季/集:秒(单集偏移),支持指定来源:剧名@来源:秒 或 剧名/季@来源1&来源2:秒(不指定来源则对所有来源生效),多条用逗号分隔。例如:`overlord/S01:90, re-zero/S02@bilibili:120, re-zero/S02/E03@dandan&bilibili:10`。正数表示弹幕延后(向右),负数表示弹幕提前(向左)。支持百分比模式,在路径/来源末尾添加 `%`,例如:`东方/S03/E02@tencent%:11`,按 `原时间 * (视频时长 + 偏移秒数) / 视频时长` 计算新的弹幕发送时间。 | | UI_THEME | 【可选】管理界面默认主题,默认为 `ocean`。浏览器中选择的主题会保存在本地并优先使用。可选值:`ocean`、`forest`、`graphite`、`berry`、`monochrome`、`sunset`、`aurora`、`lavender`、`mist`、`terminal` | @@ -761,3 +761,4 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 ### 📈项目 Star 数增长趋势 #### Star History [![Star History Chart](https://api.star-history.com/svg?repos=huangxd-/danmu_api&type=Date)](https://www.star-history.com/#huangxd-/danmu_api&Date) + diff --git a/danmu_api/configs/envs.js b/danmu_api/configs/envs.js index 610478b..cbaa505 100644 --- a/danmu_api/configs/envs.js +++ b/danmu_api/configs/envs.js @@ -2,6 +2,7 @@ * 环境变量管理模块 * 提供获取和设置环境变量的函数,支持 Cloudflare Workers 和 Node.js */ +import { danAnyFormats } from '../utils/dan-any.js'; import { parseOffsetRules } from '../utils/offset-util.js'; export class Envs { @@ -652,7 +653,7 @@ export class Envs { 'CONVERT_TOP_BOTTOM_TO_SCROLL': { category: 'danmu', type: 'boolean', description: '顶部/底部弹幕转换为浮动弹幕' }, 'CONVERT_COLOR': { category: 'danmu', type: 'select', options: ['default', 'white', 'color'], description: '弹幕转换颜色配置' }, 'COLOR_POOL': { category: 'danmu', type: 'text', description: '自定义颜色池(CONVERT_COLOR为color时生效),不配置使用默认颜色池,格式:十进制颜色值逗号分隔' }, - 'DANMU_OUTPUT_FORMAT': { category: 'danmu', type: 'select', options: ['json', 'xml'], description: '弹幕输出格式,默认json' }, + 'DANMU_OUTPUT_FORMAT': { category: 'danmu', type: 'select', options: ['json', 'xml', ...danAnyFormats], description: '弹幕输出格式,默认json' }, 'DANMU_PUSH_URL': { category: 'danmu', type: 'text', description: '弹幕推送地址,示例 http://127.0.0.1:9978/action?do=refresh&type=danmaku&path= ' }, 'LIKE_SWITCH': { category: 'danmu', type: 'boolean', description: '弹幕点赞数显示开关,默认开启' }, 'HONGGUO_MERGE_ALL_EPISODES': { category: 'danmu', type: 'boolean', description: '红果短剧合并全集弹幕,默认关闭' }, @@ -724,7 +725,7 @@ export class Envs { convertTopBottomToScroll: this.get('CONVERT_TOP_BOTTOM_TO_SCROLL', false, 'boolean'), // 顶部/底部弹幕转换为浮动弹幕配置(默认 false,禁用转换) convertColor: this.get('CONVERT_COLOR', 'default', 'string'), // 弹幕转换颜色配置,支持 default、white、color(默认 default,禁用转换) colorPool: this.get('COLOR_POOL', '16777215,16777215,16777215,16777215,16777215,16777215,16777215,16777215,16744319,16752762,16774799,9498256,8388564,8900346,14204888,16758465', 'string'), // 自定义颜色池,CONVERT_COLOR为color时生效 - danmuOutputFormat: this.get('DANMU_OUTPUT_FORMAT', 'json', 'string'), // 弹幕输出格式配置(默认 json,可选值:json, xml) + danmuOutputFormat: this.get('DANMU_OUTPUT_FORMAT', 'json', 'string'), // 弹幕输出格式配置(默认 json,可选值:json, xml, ...danAnyFormats) strictTitleMatch: this.get('STRICT_TITLE_MATCH', false, 'boolean'), // 严格标题匹配模式配置(默认 false,宽松模糊匹配) titleToChinese: this.get('TITLE_TO_CHINESE', false, 'boolean'), // 外语标题转换中文开关 animeTitleSimplified: this.get('ANIME_TITLE_SIMPLIFIED', false, 'boolean'), // 搜索的剧名标题自动繁转简 diff --git a/danmu_api/utils/dan-any.js b/danmu_api/utils/dan-any.js new file mode 100644 index 0000000..4f3dca0 --- /dev/null +++ b/danmu_api/utils/dan-any.js @@ -0,0 +1,12 @@ +import { ArtplayerMetadata, BahaMetadata, BiliXmlMetadata, DanuniJsonMetadata, DanuniPbMetadata, DdplayMetadata, DplayerMetadata, VodMetadata } from '@dan-uni/dan-any/adapters'; + +export const danAnyFormats = [ + ArtplayerMetadata.type, + BahaMetadata.type, + BiliXmlMetadata.type, + DanuniJsonMetadata.type, + DanuniPbMetadata.type, + DdplayMetadata.type, + DplayerMetadata.type, + VodMetadata.type +]; \ No newline at end of file diff --git a/danmu_api/utils/danmu-util.js b/danmu_api/utils/danmu-util.js index cb23be0..3db0028 100644 --- a/danmu_api/utils/danmu-util.js +++ b/danmu_api/utils/danmu-util.js @@ -1,7 +1,9 @@ import { globals } from '../configs/globals.js'; import { log } from './log-util.js' -import { jsonResponse, xmlResponse } from "./http-util.js"; +import { binResponse, jsonResponse, xmlResponse } from "./http-util.js"; import { traditionalized } from './zh-util.js'; +import { UniDB } from "@dan-uni/dan-any/core/main/pure"; +import { ArtplayerMetadata, ArtplayerTransformer, BahaMetadata, BahaTransformer, BiliXmlMetadata, BiliXmlTransformerConfigurator, DanuniJsonMetadata, DanuniJsonTransformerConfigurator, DanuniPbMetadata, DanuniPbTransformer, DdplayAdapter, DdplayMetadata, DdplayTransformer, DplayerMetadata, DplayerTransformer, VodMetadata, VodTransformer } from '@dan-uni/dan-any/adapters'; // ===================== // danmu处理相关函数 @@ -28,7 +30,7 @@ export function groupDanmusByMinute(filteredDanmus, n, isMultiSource = false) { const groupedByTime = filteredDanmus.reduce((acc, danmu) => { // 获取时间:优先使用 t 字段,如果没有则使用 p 的第一个值 const time = danmu.t !== undefined ? danmu.t : parseFloat(danmu.p.split(',')[0]); - + // 确定分组键:n=0时使用精确时间(保留2位小数),否则使用分钟索引 const groupKey = n === 0 ? time.toFixed(2) : Math.floor(time / (n * 60)); @@ -64,14 +66,14 @@ export function groupDanmusByMinute(filteredDanmus, n, isMultiSource = false) { acc[message].earliestT = Math.min(acc[message].earliestT, danmu.t); // 合并like字段,如果是undefined则视为0 acc[message].like += (danmu.like !== undefined ? danmu.like : 0); - + // 提取当前弹幕的来源并加入集合中,建立弹幕内容与平台的精确映射 if (danmu.p) { const match = danmu.p.match(/\[([^\]]*)\]$/); if (match && match[1]) { - match[1].split(/[&&]/).forEach(s => { - if (s.trim()) acc[message].sources.add(s.trim()); - }); + match[1].split(/[&&]/).forEach(s => { + if (s.trim()) acc[message].sources.add(s.trim()); + }); } } return acc; @@ -80,11 +82,11 @@ export function groupDanmusByMinute(filteredDanmus, n, isMultiSource = false) { // 转换为结果格式 return Object.keys(groupedByMessage).map(message => { const data = groupedByMessage[message]; - + // 以当前这句弹幕实际跨越的独立平台数作为除数,进行局部精准降噪,保留单平台内真实的重复计数 let localSourceCount = Math.max(1, data.sources.size); let displayCount = Math.round(data.count / localSourceCount); - + if (displayCount < 1) displayCount = 1; // 将收集到的所有真实独立来源重新拼装回 p 属性标签中 @@ -270,15 +272,15 @@ export function convertToDanmakuJson(contents, platform) { // 优先使用弹幕自带的 _sourceLabel(应对合并工具),其次是外部传入的宏观 platform let currentPlatform = item._sourceLabel || platform; - + // 如果存在实时拉取的副源标签,安全追加 if (item.realTimeSource && !currentPlatform.includes(item.realTimeSource)) { - currentPlatform = `${currentPlatform}&${item.realTimeSource}`; + currentPlatform = `${currentPlatform}&${item.realTimeSource}`; } // 在组装字符串时,顺带通过符号检测判定当前是否为多源组合数据 if (!isMultiSource && /[&&]/.test(currentPlatform)) { - isMultiSource = true; + isMultiSource = true; } attributes = [ @@ -488,7 +490,10 @@ function escapeXmlText(str) { .replace(/>/g, '>'); } -// 根据格式参数返回弹幕数据(JSON 或 XML) +const tmp_da_db = new UniDB() +const tmp_da_udb = tmp_da_db.init() + +// 根据格式参数返回弹幕数据 export function formatDanmuResponse(danmuData, queryFormat) { // 确定最终使用的格式:查询参数 > 环境变量 > 默认值 let format = queryFormat || globals.danmuOutputFormat; @@ -496,6 +501,7 @@ export function formatDanmuResponse(danmuData, queryFormat) { log("info", `[system] [danmu] [format] Using format: ${format}`); + // 兼容旧格式转换 if (format === 'xml') { try { const xmlData = convertDanmuToXml(danmuData); @@ -505,7 +511,19 @@ export function formatDanmuResponse(danmuData, queryFormat) { // 转换失败时回退到 JSON return jsonResponse(danmuData); } - } + } else if (format === 'json') return jsonResponse(danmuData); + + // 转换为 @dan-uni/dan-any 支持的格式 + const chunk = tmp_da_udb.makeChunk({ tmp: true }) + chunk.import(DdplayAdapter(danmuData)) + if (format === VodMetadata.type) return jsonResponse(chunk.export(VodTransformer)) + else if (format === BahaMetadata.type) return jsonResponse(chunk.export(BahaTransformer)) + else if (format === DdplayMetadata.type) return jsonResponse(chunk.export(DdplayTransformer)) + else if (format === BiliXmlMetadata.type) return xmlResponse(chunk.export(BiliXmlTransformerConfigurator())) + else if (format === DplayerMetadata.type) return jsonResponse(chunk.export(DplayerTransformer)) + else if (format === DanuniPbMetadata.type) return binResponse(chunk.export(DanuniPbTransformer), 'danuni.binpb') + else if (format === ArtplayerMetadata.type) return jsonResponse(chunk.export(ArtplayerTransformer)) + else if (format === DanuniJsonMetadata.type) return jsonResponse(chunk.export(DanuniJsonTransformerConfigurator())) // 默认返回 JSON return jsonResponse(danmuData); diff --git a/danmu_api/utils/http-util.js b/danmu_api/utils/http-util.js index 86948f2..d4bc40a 100644 --- a/danmu_api/utils/http-util.js +++ b/danmu_api/utils/http-util.js @@ -33,11 +33,11 @@ export function toLogSourceName(sourceKey) { * @returns {Function} 监听器清理闭包 */ function linkSignal(externalSignal, internalController) { - if (!externalSignal) return () => {}; + if (!externalSignal) return () => { }; if (externalSignal.aborted) { internalController.abort(); - return () => {}; + return () => { }; } const abortHandler = () => { @@ -518,7 +518,7 @@ export async function getPageTitle(url) { export function jsonResponse(data, status = 200) { return new Response(JSON.stringify(data), { status, - headers: { + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } @@ -534,13 +534,24 @@ export function xmlResponse(data, status = 200) { // 直接返回 XML 字符串作为 Response 的 body return new Response(data, { status, - headers: { + headers: { "Content-Type": "application/xml", "Access-Control-Allow-Origin": "*" } }); } +export function binResponse(data, filename = "data.bin", status = 200) { + return new Response(data, { + status, + headers: { + "Content-Type": "application/octet-stream", + "Content-Disposition": `attachment; filename="${filename}"`, + "Access-Control-Allow-Origin": "*" + } + }); +} + export function buildQueryString(params, encode = true) { const encodeFn = encode ? encodeURIComponent : (v) => v; let queryString = ''; @@ -698,8 +709,8 @@ export async function httpGetWithStreamCheck(url, options = {}, checkCallback) { const text = await response.text(); clearTimeout(timeoutId); if (checkCallback && !checkCallback(text.slice(0, SNIFF_LIMIT))) { - log("info", `[${currentSource}] [流式请求] 检测到无效数据(回退模式),丢弃结果`); - return null; + log("info", `[${currentSource}] [流式请求] 检测到无效数据(回退模式),丢弃结果`); + return null; } try { return JSON.parse(text); } catch { return text; } } @@ -713,7 +724,7 @@ export async function httpGetWithStreamCheck(url, options = {}, checkCallback) { let stopChecking = false; // 标记是否停止检查 // 流式读取循环 - while(true) { + while (true) { const { done, value } = await reader.read(); if (done) break; @@ -724,7 +735,7 @@ export async function httpGetWithStreamCheck(url, options = {}, checkCallback) { // 1. 数据嗅探逻辑 (仅在前 SNIFF_LIMIT 范围内执行) if (!stopChecking && checkCallback) { // 累积文本 - const chunkText = new TextDecoder("utf-8").decode(value, {stream: true}); + const chunkText = new TextDecoder("utf-8").decode(value, { stream: true }); checkBuffer += chunkText; // 执行回调检查 @@ -738,8 +749,8 @@ export async function httpGetWithStreamCheck(url, options = {}, checkCallback) { // 如果缓冲区超过限制 if (receivedLength > SNIFF_LIMIT) { - stopChecking = true; - checkBuffer = null; // 释放缓冲区内存 + stopChecking = true; + checkBuffer = null; // 释放缓冲区内存 } } @@ -753,7 +764,7 @@ export async function httpGetWithStreamCheck(url, options = {}, checkCallback) { // 2. 拼接完整数据 let chunksAll = new Uint8Array(receivedLength); let position = 0; - for(let chunk of chunks) { + for (let chunk of chunks) { chunksAll.set(chunk, position); position += chunk.length; } @@ -769,7 +780,7 @@ export async function httpGetWithStreamCheck(url, options = {}, checkCallback) { const currentSource = sourceLogContext.getStore() || "system"; clearTimeout(timeoutId); if (error.name === 'AbortError') { - return null; + return null; } log("error", `[${currentSource}] [流式请求] 失败: ${error.message}`); return null; diff --git a/package.json b/package.json index 88a2f56..f709a1e 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "author": "", "license": "ISC", "dependencies": { + "@dan-uni/dan-any": "^2.3.9", "brotli": "^1.3.3", "chokidar": "^4.0.3", "dotenv": "^16.4.7", @@ -21,5 +22,12 @@ "opencc-js": "^1.4.1", "pako": "^2.1.0", "redis": "^5.11.0" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "11.15.1", + "onFail": "download" + } } }