feat(danmaku): match/comment client, filter, high-confidence rank

This commit is contained in:
noelorin 2026-09-26 19:15:10 +08:00
parent b80b1e1fbf
commit 967ef58503
7 changed files with 338 additions and 0 deletions

View File

@ -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" });
});
});

View File

@ -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<string, string> {
const headers: Record<string, string> = { "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<OpenMatchOutcome> {
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<string | null> {
const res = await openFetchJson(`/api/v2/comment/${episodeId}?withRelated=true`);
if (!res.ok) return null;
const body = res.json as { comments?: Array<Record<string, unknown>> };
return openCommentsToXml(body.comments ?? []);
}

View File

@ -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]);
});
});

View File

@ -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;
});
}

View File

@ -1,4 +1,18 @@
export { generateOpenSignature } from "./signature.js"; export { generateOpenSignature } from "./signature.js";
export { headMd5, matchFileName, HASH_HEAD_BYTES } from "./hash.js"; export { headMd5, matchFileName, HASH_HEAD_BYTES } from "./hash.js";
export { openCommentsToXml, parseDanmakuXml, serializeDanmakuXml } from "./xml.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"; export type { DanmakuComment, MatchCandidate } from "./types.js";

View File

@ -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);
});
});

View File

@ -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 };
}