diff --git a/packages/danmaku/package.json b/packages/danmaku/package.json new file mode 100644 index 0000000..4cb1b01 --- /dev/null +++ b/packages/danmaku/package.json @@ -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" + } +} diff --git a/packages/danmaku/src/hash.test.ts b/packages/danmaku/src/hash.test.ts new file mode 100644 index 0000000..f0a171a --- /dev/null +++ b/packages/danmaku/src/hash.test.ts @@ -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"); + }); +}); diff --git a/packages/danmaku/src/hash.ts b/packages/danmaku/src/hash.ts new file mode 100644 index 0000000..fbcd0b5 --- /dev/null +++ b/packages/danmaku/src/hash.ts @@ -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; +} diff --git a/packages/danmaku/src/index.ts b/packages/danmaku/src/index.ts new file mode 100644 index 0000000..62a6502 --- /dev/null +++ b/packages/danmaku/src/index.ts @@ -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"; diff --git a/packages/danmaku/src/signature.test.ts b/packages/danmaku/src/signature.test.ts new file mode 100644 index 0000000..47501b3 --- /dev/null +++ b/packages/danmaku/src/signature.test.ts @@ -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); + }); +}); diff --git a/packages/danmaku/src/signature.ts b/packages/danmaku/src/signature.ts new file mode 100644 index 0000000..209eda8 --- /dev/null +++ b/packages/danmaku/src/signature.ts @@ -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"); +} diff --git a/packages/danmaku/src/types.ts b/packages/danmaku/src/types.ts new file mode 100644 index 0000000..b058d1c --- /dev/null +++ b/packages/danmaku/src/types.ts @@ -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; +}; diff --git a/packages/danmaku/src/xml.test.ts b/packages/danmaku/src/xml.test.ts new file mode 100644 index 0000000..008e6e1 --- /dev/null +++ b/packages/danmaku/src/xml.test.ts @@ -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: "你好" }]); + 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("你好"); + const again = parseDanmakuXml(serializeDanmakuXml(list)); + expect(again[0]?.text).toBe("你好"); + }); +}); diff --git a/packages/danmaku/src/xml.ts b/packages/danmaku/src/xml.ts new file mode 100644 index 0000000..0560e5b --- /dev/null +++ b/packages/danmaku/src/xml.ts @@ -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, """); +} + +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(`text`) */ +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 `${escapeXml(c.text)}`; +} + +function wrapXml(nodes: string[]): string { + return `${nodes.join("")}`; +} + +export function parseDanmakuXml(xml: string): DanmakuComment[] { + const list: DanmakuComment[] = []; + for (const m of xml.matchAll(/([\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); +} diff --git a/packages/danmaku/tsconfig.build.json b/packages/danmaku/tsconfig.build.json new file mode 100644 index 0000000..f6a099b --- /dev/null +++ b/packages/danmaku/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "./dist", + "rootDir": "./src", + "composite": true + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/danmaku/tsconfig.json b/packages/danmaku/tsconfig.json new file mode 100644 index 0000000..f83471c --- /dev/null +++ b/packages/danmaku/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": ["node"], + "paths": {} + }, + "include": ["src"] +} diff --git a/yarn.lock b/yarn.lock index b9dacdc..75cfa7c 100644 --- a/yarn.lock +++ b/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"