feat(danmaku): protocol package scaffold with signature, hash, xml

This commit is contained in:
noelorin 2026-09-26 18:54:55 +08:00
parent 63e760ba36
commit 517b4b1ef6
12 changed files with 262 additions and 0 deletions

View File

@ -0,0 +1,25 @@
{
"name": "@app/danmaku",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsc --project tsconfig.build.json",
"typecheck": "tsc --noEmit",
"lint": "biome check .",
"test": "vitest run"
},
"devDependencies": {
"@types/node": "^22.15.29",
"typescript": "^7.0.2",
"vitest": "^5.0.1"
}
}

View File

@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { headMd5, matchFileName } from "./hash.js";
describe("headMd5", () => {
it("md5 of sample", () => {
expect(headMd5(Buffer.from("abc"))).toBe("900150983cd24fb0d6963f7d28e17f72");
});
});
describe("matchFileName", () => {
it("strips folder and extension", () => {
expect(matchFileName("a/b/[Sub] Show - 01.mkv")).toBe("[Sub] Show - 01");
});
it("keeps dotfiles without extension strip", () => {
expect(matchFileName("a/.hidden")).toBe(".hidden");
});
});

View File

@ -0,0 +1,15 @@
import { createHash } from "node:crypto";
export const HASH_HEAD_BYTES = 16 * 1024 * 1024;
/** 文件头部 MD5(样本应已截到 16MB 内) */
export function headMd5(sample: Buffer): string {
return createHash("md5").update(sample).digest("hex");
}
/** 官方 match 要求 fileName 不含文件夹路径与扩展名;点开头的隐藏文件不算扩展名 */
export function matchFileName(path: string): string {
const base = path.split("/").pop() ?? path;
const dot = base.lastIndexOf(".");
return dot > 0 ? base.slice(0, dot) : base;
}

View File

@ -0,0 +1,4 @@
export { generateOpenSignature } from "./signature.js";
export { headMd5, matchFileName, HASH_HEAD_BYTES } from "./hash.js";
export { openCommentsToXml, parseDanmakuXml, serializeDanmakuXml } from "./xml.js";
export type { DanmakuComment, MatchCandidate } from "./types.js";

View File

@ -0,0 +1,14 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import { generateOpenSignature } from "./signature.js";
describe("generateOpenSignature", () => {
it("base64(sha256(AppId + Timestamp + Path + AppSecret))", () => {
// 固定向量:与现 danmaku.service 算法一致
const sig = generateOpenSignature("app1", 1700000000, "/api/v2/match", "sec");
const expectB64 = createHash("sha256")
.update(`app1${1700000000}/api/v2/matchsec`)
.digest("base64");
expect(sig).toBe(expectB64);
});
});

View File

@ -0,0 +1,11 @@
import { createHash } from "node:crypto";
/** 官方签名:base64(sha256(AppId + Timestamp + Path + AppSecret)) */
export function generateOpenSignature(
appId: string,
timestamp: number,
path: string,
appSecret: string,
): string {
return createHash("sha256").update(`${appId}${timestamp}${path}${appSecret}`).digest("base64");
}

View File

@ -0,0 +1,19 @@
export type DanmakuComment = {
time: number;
mode: number;
size: number;
color: number;
timestamp: number;
pool: number;
uid: string;
rowId: string;
text: string;
};
export type MatchCandidate = {
episodeId: number;
animeId: number | null;
animeTitle: string | null;
episodeTitle: string | null;
imageUrl: string | null;
};

View File

@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { openCommentsToXml, parseDanmakuXml, serializeDanmakuXml } from "./xml.js";
describe("xml round-trip", () => {
it("parse then serialize preserves time/mode/text", () => {
const xml = openCommentsToXml([{ p: "1.5,1,25,16777215,0,0,u,1", m: "你好<hello>" }]);
const list = parseDanmakuXml(xml);
expect(list).toHaveLength(1);
expect(list[0]?.time).toBeCloseTo(1.5);
expect(list[0]?.mode).toBe(1);
expect(list[0]?.text).toBe("你好<hello>");
const again = parseDanmakuXml(serializeDanmakuXml(list));
expect(again[0]?.text).toBe("你好<hello>");
});
});

112
packages/danmaku/src/xml.ts Normal file
View File

@ -0,0 +1,112 @@
import type { DanmakuComment } from "./types.js";
export type OpenComment = {
p?: string | undefined;
m?: string | undefined;
text?: string | undefined;
mode?: number | undefined;
time?: number | undefined;
};
function escapeXml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function unescapeXml(s: string): string {
return s
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, "&");
}
function numOr(v: string | undefined, fallback: number): number {
if (v === undefined || v === "") return fallback;
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
}
/** `p` = time,mode,size,color,timestamp,pool,uid,rowid;缺省 size/color/timestamp/pool=0,mode 非法时回退 1 */
function parseP(p: string): DanmakuComment {
const seg = unescapeXml(p).split(",");
return {
time: numOr(seg[0], Number.NaN),
mode: numOr(seg[1], 1) || 1,
size: numOr(seg[2], 0),
color: numOr(seg[3], 0),
timestamp: numOr(seg[4], 0),
pool: numOr(seg[5], 0),
uid: seg[6] ?? "",
rowId: seg[7] ?? "",
text: "",
};
}
function toComment(c: OpenComment): DanmakuComment | null {
if (typeof c.p === "string") {
const base = parseP(c.p);
base.text = c.m ?? "";
if (!Number.isFinite(base.time) || !base.text) return null;
return base;
}
const time = Number(c.time);
if (!Number.isFinite(time)) return null;
const text = c.text ?? c.m ?? "";
if (!text) return null;
return {
time,
mode: Number(c.mode) || 1,
size: 0,
color: 0,
timestamp: 0,
pool: 0,
uid: "",
rowId: "",
text,
};
}
/** 开放网络 comments → Bilibili 式 XML(`<d p="time,mode,size,color,timestamp,pool,uid,rowid">text</d>`) */
export function openCommentsToXml(comments: OpenComment[]): string {
const nodes: string[] = [];
for (const c of comments) {
const comment = toComment(c);
if (comment) nodes.push(commentNode(comment));
}
return wrapXml(nodes);
}
function commentNode(c: DanmakuComment): string {
const p = `${c.time},${c.mode},${c.size},${c.color},${c.timestamp},${c.pool},${c.uid},${c.rowId}`;
return `<d p="${escapeXml(p)}">${escapeXml(c.text)}</d>`;
}
function wrapXml(nodes: string[]): string {
return `<?xml version="1.0" encoding="UTF-8"?><i>${nodes.join("")}</i>`;
}
export function parseDanmakuXml(xml: string): DanmakuComment[] {
const list: DanmakuComment[] = [];
for (const m of xml.matchAll(/<d p="([^"]*)">([\s\S]*?)<\/d>/g)) {
const p = m[1];
const body = m[2];
if (p === undefined || body === undefined) continue;
const comment = parseP(p);
comment.text = unescapeXml(body);
if (!Number.isFinite(comment.time)) continue;
list.push(comment);
}
return list;
}
/** 完整 8 段 `p` 序列化,保证与解析互为逆操作 */
export function serializeDanmakuXml(list: DanmakuComment[]): string {
const nodes: string[] = [];
for (const c of list) nodes.push(commentNode(c));
return wrapXml(nodes);
}

View File

@ -0,0 +1,11 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "./dist",
"rootDir": "./src",
"composite": true
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"types": ["node"],
"paths": {}
},
"include": ["src"]
}

View File

@ -221,6 +221,16 @@ __metadata:
languageName: unknown
linkType: soft
"@app/danmaku@workspace:packages/danmaku":
version: 0.0.0-use.local
resolution: "@app/danmaku@workspace:packages/danmaku"
dependencies:
"@types/node": "npm:^22.15.29"
typescript: "npm:^7.0.2"
vitest: "npm:^5.0.1"
languageName: unknown
linkType: soft
"@app/dao@workspace:*, @app/dao@workspace:packages/dao":
version: 0.0.0-use.local
resolution: "@app/dao@workspace:packages/dao"