feat: 支持更多类型的弹幕输出 (#382)

* feat: 添加基于 @dan-uni/dan-any 的多弹幕格式输出支持,并优化弹幕数据格式化响应

* feat: 更新 README 关于支持多种弹幕格式输出的内容,并添加环境变量配置选项
This commit is contained in:
Rinne authored and GitHub committed 2026-07-26 20:59:36 +08:00
1 parent a8147a9811
commit 57e693f3d5
6 files changed
+82 -31

No files matched your search

+5 -4
View File
@@ -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)
+3 -2
View File
@@ -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'), // 搜索的剧名标题自动繁转简
+12
View File
@@ -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
];
+31 -13
View File
@@ -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, '&gt;');
}
// 根据格式参数返回弹幕数据(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);
+23 -12
View File
@@ -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;
+8
View File
@@ -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"
}
}
}