feat(danmaku): protocol package scaffold with signature, hash, xml
This commit is contained in:
parent
63e760ba36
commit
517b4b1ef6
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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>");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unescapeXml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&/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);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": false,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"composite": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["node"],
|
||||||
|
"paths": {}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
10
yarn.lock
10
yarn.lock
|
|
@ -221,6 +221,16 @@ __metadata:
|
||||||
languageName: unknown
|
languageName: unknown
|
||||||
linkType: soft
|
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":
|
"@app/dao@workspace:*, @app/dao@workspace:packages/dao":
|
||||||
version: 0.0.0-use.local
|
version: 0.0.0-use.local
|
||||||
resolution: "@app/dao@workspace:packages/dao"
|
resolution: "@app/dao@workspace:packages/dao"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue