feat(danmaku): match/comment client, filter, high-confidence rank
This commit is contained in:
parent
b80b1e1fbf
commit
967ef58503
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
@ -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 ?? []);
|
||||
}
|
||||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
Loading…
Reference in New Issue