feat: add fgo to games.
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
[
|
||||
{
|
||||
"id": "fgo:fate-grand-order-fes-2026-chaldea-academy-festival-11th-anniversary-main-quest-c:2026-07-22",
|
||||
"game": "fgo",
|
||||
"title": "Fate/Grand Order Fes. 2026 Chaldea Academy Festival ~11th Anniversary~ Main Quest Completion Support Campaign",
|
||||
"type": "story",
|
||||
"summary": null,
|
||||
"startsAt": "2026-07-22T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-08-31T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://fategrandorder.fandom.com/wiki/Fate/Grand_Order_Fes._2026_Chaldea_Academy_Festival_~11th_Anniversary~_Main_Quest_Completion_Support_Campaign",
|
||||
"sourceId": "fgo-fandom-events",
|
||||
"status": "published",
|
||||
"confidence": 0.95,
|
||||
"extractionMethod": "parser",
|
||||
"version": 1,
|
||||
"firstSeenAt": "2026-08-14T00:00:00.000Z",
|
||||
"updatedAt": "2026-08-14T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "fgo:fate-grand-order-11th-anniversary-summoning-campaign:2026-08-02",
|
||||
"game": "fgo",
|
||||
"title": "Fate/Grand Order ~11th Anniversary~ Summoning Campaign",
|
||||
"type": "other",
|
||||
"summary": null,
|
||||
"startsAt": "2026-08-02T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-08-23T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://fategrandorder.fandom.com/wiki/Fate/Grand_Order_%EF%BD%9E11th_Anniversary%EF%BD%9E_Summoning_Campaign",
|
||||
"sourceId": "fgo-fandom-events",
|
||||
"status": "published",
|
||||
"confidence": 0.95,
|
||||
"extractionMethod": "parser",
|
||||
"version": 1,
|
||||
"firstSeenAt": "2026-08-14T00:00:00.000Z",
|
||||
"updatedAt": "2026-08-14T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "fgo:chaldea-great-southern-sea-showdown-summoning-campaign-1:2026-08-12",
|
||||
"game": "fgo",
|
||||
"title": "Chaldea Great Southern Sea Showdown!/Summoning Campaign 1",
|
||||
"type": "other",
|
||||
"summary": null,
|
||||
"startsAt": "2026-08-12T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-09-02T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://fategrandorder.fandom.com/wiki/Chaldea_Great_Southern_Sea_Showdown!/Summoning_Campaign_1",
|
||||
"sourceId": "fgo-fandom-events",
|
||||
"status": "published",
|
||||
"confidence": 0.95,
|
||||
"extractionMethod": "parser",
|
||||
"version": 1,
|
||||
"firstSeenAt": "2026-08-14T00:00:00.000Z",
|
||||
"updatedAt": "2026-08-14T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "fgo:chaldea-great-southern-sea-showdown-summoning-campaign-male:2026-08-12",
|
||||
"game": "fgo",
|
||||
"title": "Chaldea Great Southern Sea Showdown!/Summoning Campaign (Male)",
|
||||
"type": "other",
|
||||
"summary": null,
|
||||
"startsAt": "2026-08-12T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-09-02T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://fategrandorder.fandom.com/wiki/Chaldea_Great_Southern_Sea_Showdown!/Summoning_Campaign_(Male)",
|
||||
"sourceId": "fgo-fandom-events",
|
||||
"status": "published",
|
||||
"confidence": 0.95,
|
||||
"extractionMethod": "parser",
|
||||
"version": 1,
|
||||
"firstSeenAt": "2026-08-14T00:00:00.000Z",
|
||||
"updatedAt": "2026-08-14T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "fgo:chaldea-great-southern-sea-showdown:2026-08-12",
|
||||
"game": "fgo",
|
||||
"title": "Chaldea Great Southern Sea Showdown!",
|
||||
"type": "other",
|
||||
"summary": null,
|
||||
"startsAt": "2026-08-12T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-09-02T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://fategrandorder.fandom.com/wiki/Chaldea_Great_Southern_Sea_Showdown!",
|
||||
"sourceId": "fgo-fandom-events",
|
||||
"status": "published",
|
||||
"confidence": 0.95,
|
||||
"extractionMethod": "parser",
|
||||
"version": 1,
|
||||
"firstSeenAt": "2026-08-14T00:00:00.000Z",
|
||||
"updatedAt": "2026-08-14T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "fgo:chaldea-great-southern-sea-showdown-summoning-campaign-2:2026-08-14",
|
||||
"game": "fgo",
|
||||
"title": "Chaldea Great Southern Sea Showdown!/Summoning Campaign 2",
|
||||
"type": "other",
|
||||
"summary": null,
|
||||
"startsAt": "2026-08-14T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-09-02T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://fategrandorder.fandom.com/wiki/Chaldea_Great_Southern_Sea_Showdown!/Summoning_Campaign_2",
|
||||
"sourceId": "fgo-fandom-events",
|
||||
"status": "published",
|
||||
"confidence": 0.95,
|
||||
"extractionMethod": "parser",
|
||||
"version": 1,
|
||||
"firstSeenAt": "2026-08-14T00:00:00.000Z",
|
||||
"updatedAt": "2026-08-14T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "fgo:chaldea-great-southern-sea-showdown-summoning-campaign-3:2026-08-14",
|
||||
"game": "fgo",
|
||||
"title": "Chaldea Great Southern Sea Showdown!/Summoning Campaign 3",
|
||||
"type": "other",
|
||||
"summary": null,
|
||||
"startsAt": "2026-08-14T00:00:00.000Z",
|
||||
"startPrecision": "day",
|
||||
"endsAt": "2026-09-02T00:00:00.000Z",
|
||||
"endPrecision": "day",
|
||||
"regionScoped": false,
|
||||
"regionEnds": null,
|
||||
"sourceUrl": "https://fategrandorder.fandom.com/wiki/Chaldea_Great_Southern_Sea_Showdown!/Summoning_Campaign_3",
|
||||
"sourceId": "fgo-fandom-events",
|
||||
"status": "published",
|
||||
"confidence": 0.95,
|
||||
"extractionMethod": "parser",
|
||||
"version": 1,
|
||||
"firstSeenAt": "2026-08-14T00:00:00.000Z",
|
||||
"updatedAt": "2026-08-14T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,12 @@ const SOURCES: SourceSpec[] = [
|
||||
url: "https://bluearchive.wiki/wiki/Events",
|
||||
parserId: "bawiki",
|
||||
},
|
||||
{
|
||||
id: "fgo-fandom-events",
|
||||
game: "fgo",
|
||||
url: "https://fategrandorder.fandom.com/api.php?action=parse&page=Event_List&prop=text&formatversion=2&format=json",
|
||||
parserId: "fandom",
|
||||
},
|
||||
];
|
||||
|
||||
function toAdapter(spec: SourceSpec): Adapter {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -94,6 +94,7 @@ export const GAMES: Record<GameId, GameMeta> = {
|
||||
// has to come from a source that states the clock. Inventing one moves real
|
||||
// readers' day keys, and a wrong guess ticks the wrong box every night.
|
||||
ba: { id: "ba", name: "Blue Archive", short: "Blue Archive", hue: "#3FCBDD" , studio: "Nexon Games", dailyTasks: "Daily missions, AP" },
|
||||
fgo: { id: "fgo", name: "Fate/Grand Order", short: "FGO", hue: "#1D3A8F", studio: "Lasengle", dailyTasks: "Daily missions, AP" },
|
||||
};
|
||||
|
||||
export const GAME_LIST: GameMeta[] = Object.values(GAMES);
|
||||
|
||||
@@ -12,6 +12,7 @@ export const GameId = z.enum([
|
||||
"p5x", // Persona 5: The Phantom X
|
||||
"r1999", // Reverse: 1999
|
||||
"ba", // Blue Archive
|
||||
"fgo", // Fate/Grand Order
|
||||
]);
|
||||
export type GameId = z.infer<typeof GameId>;
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ const CASES: Array<{ adapter: Adapter; fixture: string }> = [
|
||||
// its content type. The fixture is the bytes the fetcher would store.
|
||||
{ adapter: adapter("r1999-fandom-events"), fixture: "fixtures/r1999/fandom-events-2026-08-17" },
|
||||
{ adapter: adapter("ba-bawiki-events"), fixture: "fixtures/ba/bawiki-events-2026-08-17" },
|
||||
{ adapter: adapter("fgo-fandom-events"), fixture: "fixtures/fgo/fandom-events-2026-08-18" },
|
||||
];
|
||||
|
||||
async function runAdapter(adapter: Adapter, fixture: string) {
|
||||
|
||||
Reference in New Issue
Block a user