feat: add wiki.gg as a second Endfield source

First non-Game8 parser, and the first source that states region-scoped ends:
wiki.gg emits ISO timestamps with one timer per server region, so Endfield
events now carry exact times and separate Asia / Americas-Europe ends. That
gap is up to 13 hours, which is what regionEnds was built for.

Two sources for one game also exercised merge for real. It caught both
overlaps — including "Bedazzling Dawnstar" against Game8's "Bedazzling
Dawnstar Sign-In" — and flagged that the two disagree on the end date by 70
hours rather than averaging them into a date neither states.

Matching an appended qualifier needed a prefix test, not a subset test: a
subset would also fuse "Gold Clash" with "Gold Rush Clash Royale".

Also fixes build-feed picking fixtures by game rather than by source, which
handed the wiki.gg page to the Game8 parser once Endfield had two.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-15 01:17:17 +02:00
co-authored by Claude Opus 5
parent f4b133274a
commit dd596b5a43
10 changed files with 1256 additions and 16 deletions
+34 -8
View File
@@ -123,12 +123,36 @@ function isSameEvent(
toleranceHours: number,
): boolean {
if (a.id === b.id) return true;
if (titleSimilarity(a.title, b.title) < titleThreshold) return false;
// Overlap alone misses a source that appends a qualifier: "Bedazzling
// Dawnstar" vs "Bedazzling Dawnstar Sign-In" scores 0.67, well under any
// safe threshold, yet is plainly one event.
const similar =
titleSimilarity(a.title, b.title) >= titleThreshold ||
titleExtends(a.title, b.title);
if (!similar) return false;
// Similar titles are not enough — reruns reuse names. Require the start dates
// to be close before treating two entries as one event.
return hoursBetween(a.startsAt, b.startsAt) <= toleranceHours;
}
/**
* True when one title is the other with words appended — the shape a source
* qualifier actually takes ("Bedazzling Dawnstar" → "… Sign-In").
*
* Deliberately a prefix test, not a subset test. Subset matching would also
* fuse "Gold Clash" with "Gold Rush Clash Royale", which are different events.
* Two words is the floor: a one-word title would swallow half the calendar.
*/
export function titleExtends(a: string, b: string): boolean {
const ta = tokenList(a);
const tb = tokenList(b);
const [small, large] = ta.length <= tb.length ? [ta, tb] : [tb, ta];
if (small.length < 2 || small.length === large.length) return false;
return small.every((word, i) => large[i] === word);
}
function findConflict(
a: GachaEvent,
b: GachaEvent,
@@ -163,12 +187,14 @@ export function titleSimilarity(a: string, b: string): number {
return (2 * shared) / (ta.size + tb.size);
}
function tokenList(title: string): string[] {
return title
.toLowerCase()
.replace(/[^a-z0-9\s]/g, " ")
.split(/\s+/)
.filter((w) => w.length > 0);
}
function tokens(title: string): Set<string> {
return new Set(
title
.toLowerCase()
.replace(/[^a-z0-9\s]/g, " ")
.split(/\s+/)
.filter((w) => w.length > 0),
);
return new Set(tokenList(title));
}