feat: add fgo to games.

This commit is contained in:
Lucas Winther
2026-08-18 02:43:56 +02:00
parent 94ecf9ccbb
commit 5cc46a0c9a
8 changed files with 8188 additions and 2 deletions
+163 -2
View File
@@ -1,5 +1,5 @@
import { eventId, type GachaEvent } from "../../shared/schema.ts";
import { parseOrdinalDateTimeRange } from "../dates.ts";
import { parseMonthDayRange, parseOpenRange, parseOrdinalDateTimeRange } from "../dates.ts";
import { text } from "../html.ts";
import type { ParseContext } from "../adapters/types.ts";
import { inferType } from "./game8.ts";
@@ -59,6 +59,9 @@ const EDIT_SECTION = /<span\b[^>]*class="[^"]*mw-editsection[^"]*"[^>]*>[\s\S]*?
*/
const ARTICLE_LINK = /<a\b[^>]*href="(\/wiki\/(?!Special:)[^"#?]+)"/i;
const FGO_TABLE = /<table\b[^>]*id="(\d{2})(\d{4})"[^>]*class="[^"]*wikitable"[^>]*>([\s\S]*?)<\/table>/gi;
const FGO_ROW_BLOCK = /<th\b[^>]*colspan="2"[^>]*>([\s\S]*?)<\/th>[\s\S]*?<td\b[^>]*>([\s\S]*?)<\/td>\s*<td\b[^>]*>([\s\S]*?)<\/td>/gi;
/** The rendered HTML inside an `action=parse` response, or null. */
export function renderedHtml(body: string): string | null {
let payload: unknown;
@@ -69,9 +72,155 @@ export function renderedHtml(body: string): string | null {
}
const html = (payload as { parse?: { text?: unknown } } | null)?.parse?.text;
if (typeof html === "object" && html !== null && '*' in html) {
return (html as any)['*'] as string;
}
return typeof html === "string" ? html : null;
}
function parseFgoEventsPage(rendered: string, ctx: ParseContext): GachaEvent[] {
const flat = rendered.replace(EDIT_SECTION, "").replace(/\s+/g, " ");
const nowMs = Date.parse(ctx.now);
const out: GachaEvent[] = [];
const ONGOING_BLOCK = /<h2\b[^>]*>[\s\S]*?<a\b[^>]*href="(\/wiki\/(?!Special:)[^"#?]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?<\/h2>[\s\S]*?<b>Duration:<\/b>\s*([\s\S]*?)(?:<\/p>|<br\s*\/?>)/gi;
for (const match of flat.matchAll(ONGOING_BLOCK)) {
const href = match[1] ?? "";
const titleHtml = match[2] ?? "";
const title = text(titleHtml).trim();
if (!title || /permanent/i.test(title)) continue;
let rawDate = text(match[3] ?? "").replace(/&#160;/g, " ").replace(/~/g, "-").replace(/JST/i, "").trim();
if (!rawDate) continue;
rawDate = rawDate.replace(/(,\s*\d{4})\s*-/, " -");
const parts = rawDate.split(/[-–—]/).map((s) => s.trim());
let start: any = null;
let end: any = null;
if (parts.length === 1 && parts[0]) {
const openMatch = parseOpenRange(parts[0]);
if (openMatch) {
start = openMatch.start;
end = null;
}
} else if (parts.length >= 2 && parts[0] && parts[1]) {
const rangeMatch = parseMonthDayRange(rawDate);
if (rangeMatch) {
start = rangeMatch.start;
end = rangeMatch.end;
}
}
if (!start) continue;
if (end && end.iso <= start.iso) continue;
const endMs = end ? Date.parse(end.iso) : Date.parse(start.iso) + 86400000;
if (endMs < nowMs) continue;
out.push({
id: eventId(ctx.game, title, start.iso),
game: ctx.game,
title,
type: inferType(title),
summary: null,
startsAt: start.iso,
startPrecision: start.precision,
endsAt: end ? end.iso : null,
endPrecision: end ? end.precision : "unknown",
regionScoped: false,
regionEnds: null,
sourceUrl: new URL(href, ctx.sourceUrl).toString(),
sourceId: ctx.sourceId,
status: "published",
confidence: 0.95,
extractionMethod: "parser",
version: 1,
firstSeenAt: ctx.now,
updatedAt: ctx.now,
});
}
for (const tableMatch of flat.matchAll(FGO_TABLE)) {
const tableYear = parseInt(tableMatch[2]!, 10);
const innerHtml = tableMatch[3] ?? "";
for (const blockMatch of innerHtml.matchAll(FGO_ROW_BLOCK)) {
const thHtml = blockMatch[1] ?? "";
const imgTdHtml = blockMatch[2] ?? "";
const dateTdHtml = blockMatch[3] ?? "";
const title = text(thHtml).trim();
if (!title) continue;
let rawDate = text(dateTdHtml).replace(/&#160;/g, " ").replace(/~/g, "-").trim();
if (!rawDate) continue;
const parts = rawDate.split(/[-–—]/).map((s) => s.trim());
let start: any = null;
let end: any = null;
if (parts.length === 1 && parts[0]) {
const openMatch = parseOpenRange(parts[0] + ", " + tableYear);
if (openMatch) {
start = openMatch.start;
end = null;
}
} else if (parts.length >= 2 && parts[0] && parts[1]) {
const mStart = /([A-Za-z]+)/.exec(parts[0])?.[1]?.toLowerCase() || "";
const mEnd = /([A-Za-z]+)/.exec(parts[1])?.[1]?.toLowerCase() || "";
const monthsList = ["jan","feb","mar","apr","may","jun","jul","aug","sep","oct","nov","dec"];
const startM = monthsList.findIndex((x) => mStart.startsWith(x));
const endM = monthsList.findIndex((x) => mEnd.startsWith(x));
let endYear = tableYear;
if (startM > -1 && endM > -1 && startM > endM) {
endYear = tableYear + 1;
}
const rangeStr = parts[0] + " - " + parts[1] + ", " + endYear;
const rangeMatch = parseMonthDayRange(rangeStr);
if (rangeMatch) {
start = rangeMatch.start;
end = rangeMatch.end;
}
}
if (!start) continue;
if (/permanent/i.test(title)) continue;
if (end && end.iso <= start.iso) continue;
const endMs = end ? Date.parse(end.iso) : Date.parse(start.iso) + 86400000;
if (endMs < nowMs) continue;
const href = ARTICLE_LINK.exec(thHtml)?.[1] || ARTICLE_LINK.exec(imgTdHtml)?.[1];
out.push({
id: eventId(ctx.game, title, start.iso),
game: ctx.game,
title,
type: inferType(title),
summary: null,
startsAt: start.iso,
startPrecision: start.precision,
endsAt: end ? end.iso : null,
endPrecision: end ? end.precision : "unknown",
regionScoped: false,
regionEnds: null,
sourceUrl: href === undefined ? ctx.sourceUrl : new URL(href, ctx.sourceUrl).toString(),
sourceId: ctx.sourceId,
status: "published",
confidence: 0.95,
extractionMethod: "parser",
version: 1,
firstSeenAt: ctx.now,
updatedAt: ctx.now,
});
}
}
return out;
}
export function parseFandomEventsPage(
body: string,
ctx: ParseContext,
@@ -79,6 +228,16 @@ export function parseFandomEventsPage(
const rendered = renderedHtml(body);
if (rendered === null) return [];
// Branch for FGO
if (/<table\b[^>]*id="\d{6}"[^>]*class="[^"]*wikitable"/i.test(rendered)) {
const events = parseFgoEventsPage(rendered, ctx);
return events.sort((a, b) =>
a.startsAt === b.startsAt
? a.id.localeCompare(b.id)
: a.startsAt.localeCompare(b.startsAt),
);
}
const flat = rendered.replace(EDIT_SECTION, "").replace(/\s+/g, " ");
const nowMs = Date.parse(ctx.now);
const out: GachaEvent[] = [];
@@ -177,7 +336,9 @@ export const fandomParser: SourceParser = {
// the Cloudflare interstitial the plain page serves would all land here.
const rendered = renderedHtml(body);
if (rendered === null) return false;
return /class="[^"]*wikitable/.test(rendered) && /Time Period/i.test(rendered);
const isStandard = /class="[^"]*wikitable/.test(rendered) && /Time Period/i.test(rendered);
const isFgo = /<table\b[^>]*id="\d{6}"[^>]*class="[^"]*wikitable"/i.test(rendered);
return isStandard || isFgo;
},
parse: parseFandomEventsPage,
};