|
|
|
@@ -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,
|
|
|
|
|
};
|
|
|
|
|