feat(ingest): preserve source content classifications
Keep the sources' Miliastra, battle-pass and TCG distinctions so rotating names do not need a new title table every patch. Classification survives cross-source merges while event IDs, dates and source identities stay fixed. Conflicting classifications are reported instead of selecting a destination.
This commit is contained in:
1 parent
4faf3c9ceb
commit
5576e5ad5c
16 files changed
+207
-3
No files matched your search
+17
-3
@@ -22,6 +22,7 @@ import { recordedSame } from "./aliases.ts";
|
||||
|
||||
export interface MergeResult {
|
||||
events: GachaEvent[];
|
||||
classificationConflicts: Array<{ kept: GachaEvent; rejected: GachaEvent }>;
|
||||
/**
|
||||
* Pairs that look like the same event but disagree on an end date by more
|
||||
* than the tolerance. These are the cases a human should look at; the
|
||||
@@ -67,6 +68,7 @@ export function mergeEvents(
|
||||
|
||||
const kept: GachaEvent[] = [];
|
||||
const conflicts: MergeResult["conflicts"] = [];
|
||||
const classificationConflicts: MergeResult["classificationConflicts"] = [];
|
||||
|
||||
const all = groups.flat();
|
||||
for (const incoming of all) {
|
||||
@@ -89,7 +91,15 @@ export function mergeEvents(
|
||||
incoming.confidence > existing.confidence
|
||||
? ([incoming, existing] as const)
|
||||
: ([existing, incoming] as const);
|
||||
const winner = absorb(ranked, loser);
|
||||
let winner = absorb(ranked, loser);
|
||||
if (["lane", "activity", "family"].some(key => {
|
||||
const a = existing.classification?.[key as "lane" | "activity" | "family"];
|
||||
const b = incoming.classification?.[key as "lane" | "activity" | "family"];
|
||||
return a !== undefined && b !== undefined && a !== b;
|
||||
}) || existing.classification?.unresolved || incoming.classification?.unresolved) {
|
||||
winner = { ...winner, classification: { unresolved: true } };
|
||||
classificationConflicts.push({ kept: winner, rejected: loser });
|
||||
}
|
||||
|
||||
if (conflict !== null) {
|
||||
// Independent sources disagree on when this ends. Do not average, do not
|
||||
@@ -116,7 +126,7 @@ export function mergeEvents(
|
||||
: a.startsAt.localeCompare(b.startsAt),
|
||||
);
|
||||
|
||||
return { events: kept, conflicts };
|
||||
return { events: kept, conflicts, classificationConflicts };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,12 +139,16 @@ export function mergeEvents(
|
||||
* new fusion never needs a hand-edited table to keep a reader's ticks.
|
||||
*/
|
||||
function absorb(winner: GachaEvent, loser: GachaEvent): GachaEvent {
|
||||
// Identity priority must not discard a second source's explicit categories.
|
||||
const classification = { ...loser.classification, ...winner.classification };
|
||||
if (winner.classification?.tags || loser.classification?.tags)
|
||||
classification.tags = [...new Set([...(winner.classification?.tags ?? []), ...(loser.classification?.tags ?? [])])];
|
||||
const ids = [
|
||||
...(winner.mergedIds ?? []),
|
||||
...(loser.id === winner.id ? [] : [loser.id]),
|
||||
...(loser.mergedIds ?? []),
|
||||
].filter((id, i, all) => id !== winner.id && all.indexOf(id) === i);
|
||||
return ids.length === 0 ? winner : { ...winner, mergedIds: ids };
|
||||
return { ...winner, ...(Object.keys(classification).length ? { classification } : {}), ...(ids.length ? { mergedIds: ids } : {}) };
|
||||
}
|
||||
|
||||
function isSameEvent(
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { decodeEntities, text } from "../html.ts";
|
||||
import type { ParseContext } from "../adapters/types.ts";
|
||||
import { inferType } from "./game8.ts";
|
||||
import { classifySourceLabels } from "../../shared/classification.ts";
|
||||
import type { SourceParser } from "./types.ts";
|
||||
|
||||
/**
|
||||
@@ -841,6 +842,7 @@ function parseGenshinEvents(
|
||||
// "Login" — is a far better signal than a Genshin event title, which
|
||||
// is usually a piece of poetry.
|
||||
type: inferType(`${title} ${stated}`),
|
||||
...(Object.keys(classifySourceLabels(stated)).length ? { classification: classifySourceLabels(stated) } : {}),
|
||||
// No description column, and the prose lives on each event's own
|
||||
// article rather than further down this page — so there is nothing to
|
||||
// summarise, and `Type(s)` is a classification rather than a blurb.
|
||||
|
||||
@@ -191,6 +191,7 @@ export function parseFandomHistoryTable(
|
||||
game: ctx.game,
|
||||
title,
|
||||
type: "other",
|
||||
classification: { activity: "battle-pass", family: ctx.sourceId === "hsr-fandom-nameless-honor" ? "nameless-honor" : "city-fund" },
|
||||
summary: null,
|
||||
startsAt: startsAt.iso,
|
||||
startPrecision: startsAt.precision,
|
||||
|
||||
@@ -479,6 +479,7 @@ function toEvent(c: Candidate, ctx: ParseContext): GachaEvent {
|
||||
id: eventId(ctx.game, title, c.start.iso),
|
||||
game: ctx.game,
|
||||
title,
|
||||
...(/^Battle Pass\s*-/i.test(c.title) ? { classification: { activity: "battle-pass" as const, family: "battle-pass" } } : {}),
|
||||
type: inferType(title),
|
||||
summary: c.summary,
|
||||
startsAt: c.start.iso,
|
||||
|
||||
@@ -191,6 +191,7 @@ export function parseFandomVersionTable(
|
||||
game: ctx.game,
|
||||
title: pass.title,
|
||||
type: "other",
|
||||
classification: { activity: "battle-pass", family: "mira-journey" },
|
||||
summary: `Follows version ${row.number} (${row.name}). ${pass.link}`,
|
||||
startsAt: row.start.iso,
|
||||
startPrecision: row.start.precision,
|
||||
|
||||
Reference in new issue
Block a user