fix(refresh): name assumed hosts on article reads, and keep times on a bare 404

Also corrects the doc sentences the feature made false.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-11-07 02:29:28 +01:00
co-authored by Claude Opus 5.5
parent 24327ea99b
commit 4ee1bf2d2a
8 changed files with 50 additions and 20 deletions
+2 -1
View File
@@ -314,7 +314,8 @@ These come from how gacha games actually schedule things, and they cause most bu
page and an `h4` on Wuthering Waves'. `dedupe` blends it onto the better-dated copy and never
blends a date, so reading both shapes adds information and can never contradict any.
- **Prefer a source that states machine-readable times.** wiki.gg emits ISO timestamps with a timer
per server region, which is the only reason `regionEnds` carries real data anywhere.
per server region. Genshin's articles are the one other place `regionEnds` carries real data, and
those state a server clock rather than a timer (§ Domain rules).
- **Silent drops are the dangerous failure.** A date format the parser does not recognise makes
events vanish with no error. Abbreviated months (`Apr. 29 - May 13, 2026`) and slash-dated open
ends (`09/02/2026 - TBA`) are supported for exactly this reason. When adding a source, compare the
+5 -3
View File
@@ -134,9 +134,11 @@ scripts/
build-feed.ts fixtures → public/data/events.v1.json ✓ built
build-static.ts shell + worker into public/, build-stamped ✓ built
parse-fixture.ts run one adapter offline ✓ built
refresh-sources.ts fetch, cache, rebuild — the only network ✓ built
refresh-sources.ts fetch, cache, rebuild ✓ built
refresh-articles.ts Genshin event articles, for exact ends ✓ built
fixtures/<game>/ checked-in raw HTML + expected parse output
snapshots/ the current page per source, rewritten by refresh
articles/ Genshin event articles' lead sections, rewritten by refresh
```
## Request paths
@@ -214,8 +216,8 @@ changing. Reads are confined to `public/` by resolving the path and checking it
— string-matching `..` is not enough, because encodings and URL normalisation both change what the
string looks like.
`scripts/refresh-sources.ts` (`bun run refresh`) is what fills `snapshots/`, and it is the only code
here that touches the network. `.gitea/workflows/refresh.yml` runs it at 05:27 and 17:27 UTC and
`scripts/refresh-sources.ts` (`bun run refresh`) is what fills `snapshots/`, and with
`scripts/refresh-articles.ts`, which it calls, it is the only code here that touches the network. `.gitea/workflows/refresh.yml` runs it at 05:27 and 17:27 UTC and
commits only when a page actually changed. It stands in for the unbuilt scheduler and enforces the
same conduct in code — the 6h floor, one request, no retries, conditional headers, per-host spacing,
robots failing closed. A source that has failed `BROKEN_AFTER_FAILURES` (3) cycles running is
+5 -3
View File
@@ -754,11 +754,13 @@ keeps it listed, and an unlisted article is deleted.
| Result of a read | Stored article | Row shows |
|---|---|---|
| Network error, non-200 other than 404, robots refusal, or a 200 that is not the API's answer | Kept | The last good time, if any |
| 404 or `missingtitle` | Body deleted; metadata kept for the 6-hour floor | Fallback |
| Network error, any non-200 including 404, robots refusal, or a 200 that is not the API's answer | Kept | The last good time, if any |
| The API's `missingtitle`, which it sends with a 200 | Body deleted; metadata kept for the 6-hour floor | Fallback |
| A real article with no usable End | Overwritten | Fallback, logged but not warned: stubs are ordinary |
None of these touches a source's failure streak, health or broken count.
None of these touches a source's failure streak, health or broken count. A read on an assumed robots
permission names its host in the run's warning like any page does, even when the table itself was
skipped for its interval.
**Parse.** `parseArticleEnd` reads only the `<td data-source="time_end">` cell, in two shapes:
`December 15, 2026 14:59 (Server Time)` gives `regionEnds` at each server's clock, with `endsAt` the
@@ -121,8 +121,8 @@ Approved with this document unless marked as the agent's call.
| Result of a read | Stored article | Row shows |
|---|---|---|
| Network error, 5xx, refused, or robots-blocked: the wiki told us nothing about the article | Kept untouched | The last good time, if any. Due again after the 6-hour floor |
| 404 or a missing-page API error: the article is gone or renamed | Deleted | Fallback |
| Network error, any non-200 including 404, refused, or robots-blocked: the wiki told us nothing about the article | Kept untouched | The last good time, if any. Due again after the 6-hour floor |
| The API's `missingtitle`, sent with a 200: the article is gone or renamed. Changed from "404" in final review: a bare 404 is an edge or outage, and one would wipe every row's time | Deleted | Fallback |
| `200` whose End value is absent or unusable: the article no longer states a time | Overwritten | Fallback. Due again under the no-time rule |
`.meta.json` records the last attempt separately from the last successful read, so the
+10 -1
View File
@@ -26,6 +26,12 @@ export interface RefreshArticlesInput {
log: (line: string) => void;
/** Shared with the source loop, which has usually asked this host already. */
requestedHosts: Set<string>;
/**
* Hosts fetched on a recorded robots permission, shared with the source loop.
* The page may have been skipped this cycle while an article is still read, and
* that warning must not become conditional (AGENTS.md § Scraping conduct).
*/
assumedRobots: Set<string>;
}
export function articleUrl(title: string): string {
@@ -82,6 +88,7 @@ export async function refreshArticles(
await failed(decision.reason);
continue;
}
if (decision.assumedOnForbidden === true) input.assumedRobots.add(HOST);
if (input.requestedHosts.has(HOST)) {
await input.sleep(decision.crawlDelayMs ?? DEFAULT_HOST_GAP_MS);
}
@@ -102,7 +109,9 @@ export async function refreshArticles(
continue;
}
const answer = status === 200 ? answerOf(body) : status === 404 ? "gone" : "unusable";
// Only the API's own `missingtitle` means gone. It says so with a 200, so a
// bare 404 is an edge or an outage, and must not cost every row its time.
const answer = status === 200 ? answerOf(body) : "unusable";
if (answer === "gone") {
if (await store.markGone(title, nowIso)) changed += 1;
warnings.push(`${label}: the wiki says it does not exist`);
+1
View File
@@ -328,6 +328,7 @@ export async function runRefresh(
timeoutMs: options.timeoutMs,
log: options.log,
requestedHosts: cycle.requestedHosts,
assumedRobots: cycle.assumedRobots,
});
summary.articlesChanged = result.changed;
summary.warnings.push(...result.warnings);
+3 -3
View File
@@ -126,9 +126,9 @@ export type EndBearing = Pick<
* precision that boundary was actually stated to.
*
* A `regionEnds` value is read as `exact` whatever `endPrecision` says about the
* fallback: the map only exists because the source published a timer per server
* (wiki.gg does; see AGENTS.md § Working on parsers), so there is a real instant
* in it and nothing left to resolve.
* fallback: the map only exists because the source stated a time per server
* (wiki.gg's timers, Genshin's articles; see AGENTS.md § Working on parsers), so
* there is a real instant in it and nothing left to resolve.
*/
function endBoundary(
event: EndBearing,
+22 -7
View File
@@ -54,6 +54,7 @@ function run(
timeoutMs: 1000,
log: (l) => void logs.push(l),
requestedHosts: new Set(),
assumedRobots: new Set(),
...over,
});
return { done, calls, naps, logs };
@@ -123,7 +124,7 @@ async function expectKept(result: { changed: number; warnings: string[] }) {
expect(m?.lastAttemptAt).toBe(NOW.toISOString());
}
test.each([500, 403, 202])("HTTP %i keeps the body and the time, and warns", async (status) => {
test.each([500, 403, 202, 404])("HTTP %i keeps the body and the time, and warns", async (status) => {
await dueArticle();
await expectKept(await run(() => new Response("nope", { status })).done);
});
@@ -160,12 +161,9 @@ test("robots refusal makes no request, keeps the body, and warns", async () => {
expect(calls.length).toBe(0);
});
test.each([
["404", () => new Response("gone", { status: 404 })],
["a missingtitle API error", () => new Response(MISSING)],
])("%s removes the stored article and warns", async (_name, respond) => {
test("a missingtitle API error removes the stored article and warns", async () => {
await dueArticle();
const result = await run(respond).done;
const result = await run(() => new Response(MISSING)).done;
expect(result.changed).toBe(1);
expect(result.warnings.length).toBe(1);
expect(await articles.readBody(TITLE)).toBeNull();
@@ -174,7 +172,7 @@ test.each([
test("a removed article still honours the 6-hour floor", async () => {
await dueArticle();
await run(() => new Response("gone", { status: 404 })).done;
await run(() => new Response(MISSING)).done;
const { done, calls } = run(() => new Response(WITH_TIME));
await done;
expect(calls.length).toBe(0);
@@ -407,3 +405,20 @@ test("a row kept alive by its stated end stays listed, so its article is not rem
await runRefresh({ ...opts, fetchImpl: async () => new Response("", { status: 304 }) });
expect(await articles.listed()).toEqual([TITLE]);
});
test("an article read on an assumed robots permission names the host, even when the page was skipped", async () => {
// AGENTS.md § Scraping conduct: that warning must not become conditional.
const page = `<row>${ROW}</row>`;
const robots: RobotsGate = {
allows: async () => ({ allowed: true, reason: "assumed", assumedOnForbidden: true }),
};
const { opts, store } = runner(page, () => new Response(WITH_TIME), { robots });
await store.save(FANDOM_ID, {
url: opts.adapters[0]!.url, body: page, etag: null, lastModified: null, at: NOW.toISOString(), eventCount: 1,
});
await store.recordCheck(FANDOM_ID, { at: NOW.toISOString(), status: 200, ok: true });
const summary = await runRefresh(opts);
expect(summary.outcomes[0]?.result).toBe("skipped_interval");
expect(summary.articlesChanged).toBe(1);
expect(summary.assumedRobots).toEqual(["genshin-impact.fandom.com"]);
});