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
|
||||
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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue