From 967ef58503ba1792b08c811ace7e410b8af662e6 Mon Sep 17 00:00:00 2001 From: noelorin Date: Sat, 26 Sep 2026 19:15:10 +0800 Subject: [PATCH] feat(danmaku): match/comment client, filter, high-confidence rank --- packages/danmaku/src/client.test.ts | 52 ++++++++++++++++ packages/danmaku/src/client.ts | 97 +++++++++++++++++++++++++++++ packages/danmaku/src/filter.test.ts | 53 ++++++++++++++++ packages/danmaku/src/filter.ts | 25 ++++++++ packages/danmaku/src/index.ts | 14 +++++ packages/danmaku/src/match.test.ts | 32 ++++++++++ packages/danmaku/src/match.ts | 65 +++++++++++++++++++ 7 files changed, 338 insertions(+) create mode 100644 packages/danmaku/src/client.test.ts create mode 100644 packages/danmaku/src/client.ts create mode 100644 packages/danmaku/src/filter.test.ts create mode 100644 packages/danmaku/src/filter.ts create mode 100644 packages/danmaku/src/match.test.ts create mode 100644 packages/danmaku/src/match.ts diff --git a/packages/danmaku/src/client.test.ts b/packages/danmaku/src/client.test.ts new file mode 100644 index 0000000..ab0df62 --- /dev/null +++ b/packages/danmaku/src/client.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { hasOpenCredentials, openApiBase, openHeaders, readOpenCredentials } from "./client.js"; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +describe("readOpenCredentials", () => { + it("reads from injected env", () => { + expect( + readOpenCredentials({ OPEN_DANMAKU_APP_ID: "id", OPEN_DANMAKU_APP_SECRET: "s" }), + ).toEqual({ + appId: "id", + appSecret: "s", + }); + }); + it("returns null when missing", () => { + expect(readOpenCredentials({})).toBeNull(); + expect(readOpenCredentials({ OPEN_DANMAKU_APP_ID: "id" })).toBeNull(); + }); + it("defaults to process.env", () => { + vi.stubEnv("OPEN_DANMAKU_APP_ID", "pid"); + vi.stubEnv("OPEN_DANMAKU_APP_SECRET", "ps"); + expect(readOpenCredentials()).toEqual({ appId: "pid", appSecret: "ps" }); + expect(hasOpenCredentials()).toBe(true); + }); +}); + +describe("openApiBase", () => { + it("strips trailing slashes", () => { + vi.stubEnv("OPEN_DANMAKU_API_BASE", "https://example.test///"); + expect(openApiBase()).toBe("https://example.test"); + }); + it("defaults to dandanplay api", () => { + vi.stubEnv("OPEN_DANMAKU_API_BASE", undefined); + expect(openApiBase()).toBe("https://api.dandanplay.net"); + }); +}); + +describe("openHeaders", () => { + it("signs request when credentials present", () => { + const h = openHeaders("/api/v2/match", { appId: "id", appSecret: "s" }); + expect(h["Content-Type"]).toBe("application/json"); + expect(h["X-AppId"]).toBe("id"); + expect(h["X-Timestamp"]).toMatch(/^\d+$/); + expect(h["X-Signature"]).toBeTruthy(); + }); + it("only content-type without credentials", () => { + expect(openHeaders("/api/v2/match", null)).toEqual({ "Content-Type": "application/json" }); + }); +}); diff --git a/packages/danmaku/src/client.ts b/packages/danmaku/src/client.ts new file mode 100644 index 0000000..c8cedad --- /dev/null +++ b/packages/danmaku/src/client.ts @@ -0,0 +1,97 @@ +import { parseMatchOutcome, type OpenMatchOutcome } from "./match.js"; +import { generateOpenSignature } from "./signature.js"; +import { openCommentsToXml } from "./xml.js"; + +export type OpenCredentials = { appId: string; appSecret: string }; + +export function openApiBase(): string { + return (process.env["OPEN_DANMAKU_API_BASE"] ?? "https://api.dandanplay.net").replace( + /\/+$/, + "", + ); +} + +export function readOpenCredentials(env = process.env): OpenCredentials | null { + const appId = env["OPEN_DANMAKU_APP_ID"]; + const appSecret = env["OPEN_DANMAKU_APP_SECRET"]; + if (!appId || !appSecret) return null; + return { appId, appSecret }; +} + +export function hasOpenCredentials(): boolean { + return readOpenCredentials() !== null; +} + +export function openHeaders(path: string, creds: OpenCredentials | null): Record { + const headers: Record = { "Content-Type": "application/json" }; + if (!creds) return headers; + const timestamp = Math.floor(Date.now() / 1000); + return { + ...headers, + "X-AppId": creds.appId, + "X-Timestamp": String(timestamp), + "X-Signature": generateOpenSignature(creds.appId, timestamp, path, creds.appSecret), + }; +} + +export async function openFetchJson( + path: string, + init?: { method?: "POST"; body?: string } | undefined, +): Promise<{ ok: true; json: unknown } | { ok: false; status: number | null; error: string }> { + const creds = readOpenCredentials(); + try { + const res = await fetch(`${openApiBase()}${path}`, { + method: init?.method ?? "GET", + headers: openHeaders(path, creds), + ...(init?.body !== undefined ? { body: init.body } : {}), + }); + if (!res.ok) + return { ok: false, status: res.status, error: `弹弹play API HTTP ${res.status}` }; + const json: unknown = await res.json(); + const base = json as { + success?: boolean; + errorCode?: number; + errorMessage?: string | null; + errorDetail?: string | null; + }; + if (base.success === false || (base.errorCode ?? 0) !== 0) { + return { + ok: false, + status: res.status, + error: base.errorMessage || base.errorDetail || "弹弹play API 调用失败", + }; + } + return { ok: true, json }; + } catch (e) { + return { + ok: false, + status: null, + error: e instanceof Error ? e.message : "弹弹play API 网络错误", + }; + } +} + +export async function openMatch( + fileName: string, + fileSize: number, + fileHash: string | null, +): Promise { + const res = await openFetchJson("/api/v2/match", { + method: "POST", + body: JSON.stringify({ + fileName, + fileHash: fileHash ?? "", + fileSize, + matchMode: fileHash ? "hashAndFileName" : "fileNameOnly", + }), + }); + if (!res.ok) return { ok: false, errorMessage: res.error, isMatched: false, candidates: [] }; + return { ok: true, errorMessage: null, ...parseMatchOutcome(res.json) }; +} + +export async function openCommentXml(episodeId: number): Promise { + const res = await openFetchJson(`/api/v2/comment/${episodeId}?withRelated=true`); + if (!res.ok) return null; + const body = res.json as { comments?: Array> }; + return openCommentsToXml(body.comments ?? []); +} diff --git a/packages/danmaku/src/filter.test.ts b/packages/danmaku/src/filter.test.ts new file mode 100644 index 0000000..c31d71a --- /dev/null +++ b/packages/danmaku/src/filter.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { filterComments, modeToKind } from "./filter.js"; +import type { DanmakuComment } from "./types.js"; + +const c = (text: string, mode: number): DanmakuComment => ({ + time: 0, + mode, + size: 25, + color: 16777215, + timestamp: 0, + pool: 0, + uid: "u", + rowId: "1", + text, +}); + +describe("filterComments", () => { + it("drops keyword hits", () => { + const out = filterComments([c("好番", 1), c("剧透狗", 1)], { + blockKeywords: ["剧透"], + blockTypes: [], + }); + expect(out.map((x) => x.text)).toEqual(["好番"]); + }); + it("drops blocked types (1 scroll, 5 top, 4 bottom)", () => { + const out = filterComments([c("a", 1), c("b", 5), c("c", 4)], { + blockKeywords: [], + blockTypes: ["top"], + }); + expect(out.map((x) => x.mode)).toEqual([1, 4]); + }); +}); + +describe("modeToKind", () => { + it("maps modes per dandanplay convention", () => { + expect(modeToKind(1)).toBe("scroll"); + expect(modeToKind(2)).toBe("scroll"); + expect(modeToKind(3)).toBe("scroll"); + expect(modeToKind(6)).toBe("scroll"); + expect(modeToKind(4)).toBe("bottom"); + expect(modeToKind(5)).toBe("top"); + expect(modeToKind(7)).toBe("other"); + expect(modeToKind(9)).toBe("other"); + }); + + it("keeps other-kind comments even when blockTypes is full", () => { + const out = filterComments([c("code", 9), c("x", 7)], { + blockKeywords: [], + blockTypes: ["scroll", "top", "bottom"], + }); + expect(out.map((x) => x.mode)).toEqual([9, 7]); + }); +}); diff --git a/packages/danmaku/src/filter.ts b/packages/danmaku/src/filter.ts new file mode 100644 index 0000000..e5976cd --- /dev/null +++ b/packages/danmaku/src/filter.ts @@ -0,0 +1,25 @@ +import type { DanmakuComment } from "./types.js"; + +export type FilterOptions = { + blockKeywords: string[]; + blockTypes: Array<"scroll" | "top" | "bottom">; +}; + +/** mode:1/2/3=滚动 4=底部 5=顶部 6=逆向 7=特殊 9=代码 */ +export function modeToKind(mode: number): "scroll" | "top" | "bottom" | "other" { + if (mode === 4) return "bottom"; + if (mode === 5) return "top"; + if (mode === 1 || mode === 2 || mode === 3 || mode === 6) return "scroll"; + return "other"; +} + +export function filterComments(list: DanmakuComment[], opts: FilterOptions): DanmakuComment[] { + const kinds = new Set(opts.blockTypes); + const kws = opts.blockKeywords.map((k) => k.trim()).filter(Boolean); + return list.filter((c) => { + const kind = modeToKind(c.mode); + if (kind !== "other" && kinds.has(kind)) return false; + if (kws.some((k) => c.text.includes(k))) return false; + return true; + }); +} diff --git a/packages/danmaku/src/index.ts b/packages/danmaku/src/index.ts index 62a6502..7a438a6 100644 --- a/packages/danmaku/src/index.ts +++ b/packages/danmaku/src/index.ts @@ -1,4 +1,18 @@ export { generateOpenSignature } from "./signature.js"; export { headMd5, matchFileName, HASH_HEAD_BYTES } from "./hash.js"; export { openCommentsToXml, parseDanmakuXml, serializeDanmakuXml } from "./xml.js"; +export { filterComments, modeToKind } from "./filter.js"; +export type { FilterOptions } from "./filter.js"; +export { parseMatchOutcome, rankCandidates } from "./match.js"; +export type { OpenMatchOutcome } from "./match.js"; +export { + hasOpenCredentials, + openApiBase, + openCommentXml, + openFetchJson, + openHeaders, + openMatch, + readOpenCredentials, +} from "./client.js"; +export type { OpenCredentials } from "./client.js"; export type { DanmakuComment, MatchCandidate } from "./types.js"; diff --git a/packages/danmaku/src/match.test.ts b/packages/danmaku/src/match.test.ts new file mode 100644 index 0000000..13b6b47 --- /dev/null +++ b/packages/danmaku/src/match.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { parseMatchOutcome, rankCandidates } from "./match.js"; + +describe("parseMatchOutcome", () => { + it("maps matches and drops bad episodeId", () => { + const r = parseMatchOutcome({ + isMatched: true, + matches: [{ episodeId: 12, animeTitle: "A", episodeTitle: "01" }, { episodeId: "x" }], + }); + expect(r.isMatched).toBe(true); + expect(r.candidates).toHaveLength(1); + expect(r.candidates[0]?.episodeId).toBe(12); + }); +}); + +describe("rankCandidates", () => { + it("single candidate is high confidence", () => { + const r = rankCandidates([ + { episodeId: 1, animeId: 1, animeTitle: "A", episodeTitle: "01", imageUrl: null }, + ]); + expect(r.highConfidence).toBe(true); + expect(r.candidates).toHaveLength(1); + }); + it("close scores are not high confidence", () => { + const list = [ + { episodeId: 1, animeId: 1, animeTitle: "Show", episodeTitle: "01", imageUrl: null }, + { episodeId: 2, animeId: 1, animeTitle: "Show", episodeTitle: "01v2", imageUrl: null }, + ]; + const r = rankCandidates(list); + expect(r.highConfidence).toBe(false); + }); +}); diff --git a/packages/danmaku/src/match.ts b/packages/danmaku/src/match.ts new file mode 100644 index 0000000..67d4b0f --- /dev/null +++ b/packages/danmaku/src/match.ts @@ -0,0 +1,65 @@ +import type { MatchCandidate } from "./types.js"; + +export type OpenMatchOutcome = { + ok: boolean; + errorMessage: string | null; + isMatched: boolean; + candidates: MatchCandidate[]; +}; + +type RawMatch = { + episodeId?: number | string; + animeId?: number | string; + animeTitle?: string | null; + episodeTitle?: string | null; + imageUrl?: string | null; +}; + +export function parseMatchOutcome(json: unknown): { + isMatched: boolean; + candidates: MatchCandidate[]; +} { + const body = json as { isMatched?: boolean; matches?: RawMatch[] }; + const candidates: MatchCandidate[] = []; + for (const m of body.matches ?? []) { + const episodeId = + typeof m.episodeId === "number" + ? m.episodeId + : Number.parseInt(String(m.episodeId ?? ""), 10); + if (!Number.isFinite(episodeId)) continue; + const animeId = + typeof m.animeId === "number" + ? m.animeId + : Number.parseInt(String(m.animeId ?? ""), 10); + candidates.push({ + episodeId, + animeId: Number.isFinite(animeId) ? animeId : null, + animeTitle: m.animeTitle ?? null, + episodeTitle: m.episodeTitle ?? null, + imageUrl: m.imageUrl ?? null, + }); + } + return { isMatched: body.isMatched === true, candidates }; +} + +/** 高置信:仅 1 条,或按「标题/集号重合度」打分后最高分领先第二名 ≥ lead */ +export function rankCandidates( + candidates: MatchCandidate[], + opts?: { lead?: number } | undefined, +): { candidates: MatchCandidate[]; highConfidence: boolean } { + const lead = opts?.lead ?? 0.15; + if (candidates.length === 0) return { candidates: [], highConfidence: false }; + if (candidates.length === 1) return { candidates, highConfidence: true }; + const scored = candidates + .map((c) => ({ + c, + score: + (c.animeTitle ? 1 : 0) * 0.5 + (c.episodeTitle ? 0.5 : 0) + (c.imageUrl ? 0.1 : 0), + })) + .sort((a, b) => b.score - a.score); + const top = scored[0]; + const second = scored[1]; + if (!top) return { candidates, highConfidence: false }; + const high = second ? top.score - second.score >= lead : true; + return { candidates: scored.map((s) => s.c), highConfidence: high }; +}