feat: add fgo to games.
This commit is contained in:
@@ -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(/ /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(/ /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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user