feat(sources): ZZZ's City Fund pass windows from Fandom

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-16 06:22:05 +02:00
co-authored by Claude Opus 5.5
parent 9f8150fda7
commit a6a8efa661
7 changed files with 69 additions and 1 deletions
+9
View File
@@ -90,6 +90,7 @@ Two things this method cannot tell you, and both matter:
| Genshin Impact (Fandom) | `genshin-impact.fandom.com/api.php?…page=Event` | `fandom`, fifth template | yes (via `api.php`) | **BUILT** 2026-09-12 — adds 11 events Game8 missed; Game8 priority preserves localStorage IDs |
| Star Rail — Nameless Honor | `honkai-star-rail.fandom.com/api.php?…page=Nameless_Honor` | new (`fandomtable`) | yes (via `api.php`) | **BUILT** 2026-10-16 — the pass window as one `other` event, day precision |
| Genshin — Miliastra Pass | `genshin-impact.fandom.com/api.php?…page=Miliastra_Pass` | existing (`fandomtable`) | yes (via `api.php`) | **BUILT** 2026-10-16 — the live pass run as one `other` event, day precision |
| ZZZ — New Eridu City Fund | `zenless-zone-zero.fandom.com/api.php?…page=New_Eridu_City_Fund` | existing (`fandomtable`) | yes (via `api.php`) | **BUILT** 2026-10-16 — the live fund run as one `other` event, day precision |
| hololive Dreams | `holodori.wiki/wiki/Events` (still) | `holodoriwiki` | yes, but nothing new to fetch | **LAPSED** 2026-10-14 — NamuWiki is behind a Cloudflare challenge, the official site posts no events, Game8 has no page |
---
@@ -878,6 +879,14 @@ passed. A `fetched` with more than zero events means the wiki is back.
**Conduct:** the `RobotsCache` with the real User-Agent got `403` (the interstitial challenge) for `robots.txt` on 2026-10-16, so this host rests on the person-recorded reading for `genshin-fandom-events` and the standing `--assume-robots-on-403` decision. The API answered `200` to the real User-Agent for the single fixture fetch.
## 19. ZZZ — New Eridu City Fund window (Fandom) — BUILT 2026-10-16
**Source:** `https://zenless-zone-zero.fandom.com/api.php?action=parse&page=New_Eridu_City_Fund&prop=text&formatversion=2&format=json` (page `https://zenless-zone-zero.fandom.com/wiki/New_Eridu_City_Fund`), source id `zzz-fandom-city-fund`. Third pass-window source; no parser change beyond one `PASS_SOURCES` entry (constant title).
**What it is:** the first table on the page, `Image | Name | Start | End | Version`, 20 rows (1.0, 2024-07-04, to 3.2, 2026-09-09 to 2026-10-19; counted independently of the parser). Each row not ended by `ctx.now` becomes one `other` event titled `New Eridu City Fund`, day precision. At capture (now 2026-10-16) one row is not ended (3.2) and one event is emitted. The second table (`Language | Official Name`) is not a pass table and is ignored.
**Conduct:** the `RobotsCache` gets `403` (the interstitial challenge) for this host's `robots.txt`. The repository owner read it in a browser on 2026-10-16: the `User-agent: *` group allows `/api.php?`, `/api.php?action=` and `/api.php?*&action=` and disallows only `/wiki/Special:`, `/wiki/User_talk:`, `/wiki/Template:`, `/wiki/Template_talk:`, `/wiki/Help:`, `/wiki/User:`, `/wiki/UserProfile:` (and language variants); `Disallow: /` applies only to named crawlers (SemrushBot, serpstatbot, GPTBot, ImagesiftBot, CCBot, OAI-SearchBot, ClaudeBot, GrokBot, xAI-SearchBot, xAI-Grok, GrokApp, xAI-Bot); `ias_crawler` has `Crawl-delay: 1`. With the standing `--assume-robots-on-403` decision that covers this source. The API answered `200` to the real User-Agent for the single fixture fetch.
## What every one of these costs, beyond the source
Adding a game is never only a `SOURCES` entry:
@@ -0,0 +1,23 @@
[
{
"id": "zzz:new-eridu-city-fund:2026-09-09",
"game": "zzz",
"title": "New Eridu City Fund",
"type": "other",
"summary": null,
"startsAt": "2026-09-09T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-10-19T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://zenless-zone-zero.fandom.com/wiki/New_Eridu_City_Fund/2026-09-09",
"sourceId": "zzz-fandom-city-fund",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-10-16T00:00:00.000Z",
"updatedAt": "2026-10-16T00:00:00.000Z"
}
]
File diff suppressed because one or more lines are too long
+26
View File
@@ -158,6 +158,32 @@ const SOURCES: SourceSpec[] = [
url: "https://genshin-impact.fandom.com/api.php?action=parse&page=Miliastra_Pass&prop=text&formatversion=2&format=json",
parserId: "fandomtable",
},
{
id: "zzz-fandom-city-fund",
game: "zzz",
// The API, as every Fandom source (AGENTS.md § Fandom). This host's
// robots.txt answers our client (`RobotsCache`, the real User-Agent) with
// 403, the interstitial challenge. The repository owner read
// https://zenless-zone-zero.fandom.com/robots.txt in a browser on
// 2026-10-16: the `User-agent: *` group has `Allow: /api.php?`,
// `Allow: /api.php?action=` and `Allow: /api.php?*&action=`, then
// `Disallow:` only for /wiki/Special:, /wiki/User_talk:, /wiki/Template:,
// /wiki/Template_talk:, /wiki/Help:, /wiki/User: and /wiki/UserProfile:
// (and their language variants); no `Disallow: /` for `*`. `Disallow: /`
// applies only to named crawlers (SemrushBot, serpstatbot, GPTBot,
// ImagesiftBot, CCBot, OAI-SearchBot, ClaudeBot, GrokBot, xAI-SearchBot,
// xAI-Grok, GrokApp, xAI-Bot), none of which we are; `ias_crawler` has
// `Crawl-delay: 1`. That reading and the standing `--assume-robots-on-403`
// decision (scripts/refresh-sources.ts, AGENTS.md § Scraping conduct) are
// what cover this source. The API path answered 200 to the real
// User-Agent for the single fixture fetch; no browser-shaped headers.
//
// A pass history: the `fandomtable` reader finds the `Name | Start | End |
// Version` table by heading; the constant title comes from `PASS_SOURCES`
// in `parsers/fandomTable.ts`, keyed by this id.
url: "https://zenless-zone-zero.fandom.com/api.php?action=parse&page=New_Eridu_City_Fund&prop=text&formatversion=2&format=json",
parserId: "fandomtable",
},
{
id: "r1999-fandom-events",
game: "r1999",
+5
View File
@@ -58,6 +58,11 @@ export const PASS_SOURCES: Readonly<Record<string, PassSource>> = {
pass: "Miliastra Pass",
title: (rowName) => `Miliastra Pass: ${rowName}`,
},
"zzz-fandom-city-fund": {
// One fund, one name; the Name cell is the run's own label, so a constant.
pass: "New Eridu City Fund",
title: () => "New Eridu City Fund",
},
};
/** The columns a pass table is recognised by, as normalised heading text. */
+3 -1
View File
@@ -111,7 +111,7 @@ describe("fandomtable canParse across every fixture", () => {
expect(files.length).toBeGreaterThan(20);
});
test("true on the two pass-history fixtures only", async () => {
test("true on the three pass-history fixtures only", async () => {
const accepted: string[] = [];
for (const f of files) {
if (fandomTableParser.canParse(await Bun.file(f).text())) accepted.push(f);
@@ -119,6 +119,7 @@ describe("fandomtable canParse across every fixture", () => {
expect(accepted).toEqual([
"fixtures/genshin/fandom-miliastra-pass-2026-10-16.html",
FIXTURE,
"fixtures/zzz/fandom-city-fund-2026-10-16.html",
]);
});
@@ -126,6 +127,7 @@ describe("fandomtable canParse across every fixture", () => {
for (const f of [
FIXTURE,
"fixtures/genshin/fandom-miliastra-pass-2026-10-16.html",
"fixtures/zzz/fandom-city-fund-2026-10-16.html",
]) {
const text = await Bun.file(f).text();
const claimants = PARSERS.filter((p) => p.canParse(text)).map((p) => p.id);
+2
View File
@@ -75,6 +75,8 @@ const CASES: Array<{ adapter: Adapter; fixture: string; now?: string }> = [
{ adapter: adapter("hsr-fandom-nameless-honor"), fixture: "fixtures/hsr/fandom-nameless-honor-2026-10-16", now: "2026-10-16T00:00:00.000Z" },
// Genshin's Miliastra Pass history (same reader).
{ adapter: adapter("genshin-fandom-miliastra-pass"), fixture: "fixtures/genshin/fandom-miliastra-pass-2026-10-16", now: "2026-10-16T00:00:00.000Z" },
// ZZZ's New Eridu City Fund history (same reader).
{ adapter: adapter("zzz-fandom-city-fund"), fixture: "fixtures/zzz/fandom-city-fund-2026-10-16", now: "2026-10-16T00:00:00.000Z" },
];
async function runAdapter(adapter: Adapter, fixture: string, now: string = NOW) {