Skip to content

Commit 2e66f38

Browse files
committed
create-extractor: convert to a scheduled gcec task, and retire fetch-page.yml
The auto-implement-extractor pipeline moves from the legacy label-wired routine (dev/routines/create-extractor/) to a gcec pack task on the per-repo scheduler, split by what each stage is actually the right place for: - precondition (task.mjs) — pure code over the `issues` signal: is any open extractor-request eligible? No I/O, no writes, no body parsing. Almost every hour answers no and costs nothing. An `extractor-in-progress` claim label keeps that correct across hours, since preprocessing runs before (and independently of) the dispatch issue's at-most-one-open guard and would otherwise re-scaffold a live request. - preprocessing (prepare.mjs) — everything deterministic, Action-side: triage, closing deny/allow/duplicate with their canned messages, claim, branch, scaffold, offline baseline, the ScraperAPI page fetch, push, and the draft PR. All of it needs issue bodies, GitHub writes, or a network fetch — none of which a precondition may do. It requests the agent only when a real page recorded and an extract() is left to write. - agent (task.md) — the judgment only, roughly halving the prose: read the page, write extract(), fill the case from a real run, verify, deliver. The four-script/JSON-blob hand-off is gone; postconditions.sh now derives its names from the branch instead of taking five env vars. fetch-page.yml is deleted. It existed solely because an agent session cannot hold SCRAPER_API_KEY, forcing a dispatch → poll → pull round-trip for one curl; preprocessing runs inside Actions, and the task declares `agent_preprocessing_secrets: ['SCRAPER_API_KEY']`. Its other caller — the hand-dispatched add-live-case / gardening flow — becomes a new agentless `record-page` task: a committed `.url` with no sibling `.html` is the whole request, and the task records it and opens the PR. Both tasks fetch through one shared scraperapi.mjs, so the project keeps a single page-fetching surface. The vendored mount carries the engine files this depends on (the secrets contract); the stamp is deliberately left where it is, so the next baselining still converges the mount to canon head normally. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016NgBmWQmxLFPybrhSTz8i3
1 parent 1f66505 commit 2e66f38

53 files changed

Lines changed: 1338 additions & 614 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claudinite/local/packs/gcec/RULES.md

Lines changed: 29 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ The project's standing working rules — injected at session start while this pa
44
is declared. Activity-scoped procedures live in this pack's skills
55
(snapshot-approval, merge-and-ci, testing-guide, add-live-case) and surface on
66
demand; the extractor-automation domain's standing rules are the "Extractor
7-
pipeline" section below, and its two routines live under `dev/routines/`.
7+
pipeline" section below, and its three scheduled tasks live under `tasks/`.
88

99
## Working rules
1010

@@ -137,32 +137,35 @@ classification: the `Release` stub (`chrome-extension-release.yml`) is
137137
unattended and already covered — the reporters fire inside the vendored
138138
create-package/publish/daily workflows, keyed per operation, with per-repo
139139
values in `.github/release.config`; `test.yml` is attended PR CI — no reporter;
140-
`fetch-page.yml` is attended by its dispatcher (see the extractor pipeline
141-
section below); a **new** unattended workflow adds a failure job per the canon
142-
action header's recipe.
140+
`claudinite-scheduler.yml` is the vendored scheduler, which converges its own
141+
failures to `needs-human` issues; a **new** unattended workflow adds a failure job
142+
per the canon action header's recipe.
143143

144144
## Extractor pipeline
145145

146-
Standing rules for the extractor-automation domain — the create-extractor routine
147-
(an `extractor-request` issue → a PR adding site support), still under
148-
[`dev/routines/`](../../../../dev/routines/), and the weekly
149-
fallback-extractor-improvements task, now a gcec pack task under
150-
[`tasks/`](tasks/fallback-extractor-improvements/) (read a spec only when working
146+
Standing rules for the extractor-automation domain — the three gcec pack
147+
[`tasks/`](tasks/): **create-extractor** (an `extractor-request` issue → a PR
148+
adding site support), **record-page** (records a committed `.url`'s cached page),
149+
and the weekly **fallback-extractor-improvements** (read a spec only when working
151150
on that pipeline). Adding a cached live case is the
152151
[add-live-case](skills/add-live-case/SKILL.md) skill.
153152

154-
- **All page fetching is delegated to ScraperAPI via the fetch-page workflow**
155-
(`.github/workflows/fetch-page.yml`): a bare curl through ScraperAPI's
156-
residential proxy with `render=true`, so a single-page app records post-render
157-
HTML with real data. Bot-blocking from CI/sandbox IPs is the portable rule
158-
maintained in the canon; here the escape hatch is the `SCRAPER_API_KEY`
159-
**GitHub Actions secret** — a page is recorded by dispatching that workflow (the
160-
create-extractor routine does this in its step 4), never by a local fetch (this
161-
sandbox is bot-blocked). ScraperAPI is the whole fetching surface — swap the
162-
vendor in that one workflow if it underperforms. The aid for a flaky SPA render
163-
is the **`Wait-for selector`** a source request can carry
153+
- **All page fetching goes through [`scraperapi.mjs`](scraperapi.mjs), from a
154+
task's preprocessing worker and nowhere else.** A rendered fetch through
155+
ScraperAPI's residential proxy (`render=true`, so a single-page app records
156+
post-render HTML with real data). Bot-blocking from CI/sandbox IPs is the
157+
portable rule maintained in the canon; here the escape hatch is the
158+
`SCRAPER_API_KEY` **GitHub Actions secret**, which reaches a worker because its
159+
task declares `agent_preprocessing_secrets: ['SCRAPER_API_KEY']` and reaches
160+
nothing else — never a local fetch (this sandbox is bot-blocked), and never an
161+
agent session (which holds no repo secrets). **Never re-introduce a workflow
162+
whose only job is to hold that secret for an agent**: `fetch-page.yml` was
163+
exactly that, and the dispatch/poll/pull round-trip it forced on the agent is
164+
what preprocessing removed. ScraperAPI is the whole fetching surface — swap the
165+
vendor in that one module if it underperforms. The aid for a flaky SPA render is
166+
the **`Wait-for selector`** a source request can carry
164167
(`extension/events-popup/derive-wait-selector.js`, a source-request tool, NOT an
165-
event extractor, #603), passed to the workflow as `wait_for_selector`.
168+
event extractor, #603), passed through as `wait_for_selector`.
166169
- **Facebook can't be a cached live case** — a hard HTTP 400 even through the
167170
proxy, so its extraction stays unit-tests-only
168171
(`extension-test/event-extractors/extraction.test.js`).
@@ -179,11 +182,12 @@ on that pipeline). Adding a cached live case is the
179182
from `extract-unsupported.js`'s `pageUsesDayFirstDates`), mirroring
180183
`derive-timezone.js`'s locale read; unambiguous dates (a part > 12) and the
181184
`.` / `-` separators are always day-first regardless (#686).
182-
- **`fetch-page.yml` is attended by its dispatcher** (the workflow-failure
183-
classification above): the create-extractor routine dispatches it, polls the
184-
run, and on failure labels the issue `extractor-blocked-needs-human` — a red run
185-
reaches a human through the routine, not the Actions list, so the workflow
186-
carries no failure reporter (and being dispatch-only it never runs unwatched).
185+
- **An unrecordable page is a dead end, not a failed run.** When a fetch can't
186+
produce a page (bot wall, dead URL, empty render), create-extractor's
187+
preprocessing labels the request `extractor-blocked-needs-human` with the reason
188+
and exits **0** — a task failure would converge to a `needs-human` dispatch
189+
issue as well, duplicating the signal and implying the pipeline broke when it
190+
correctly declined. Same for record-page: the pages that did record still land.
187191
- **The fallback-coverage gate is a high-watermark over a changing case set.** It
188192
ratchets up on an unchanged case set and re-anchors when the set changes,
189193
compared over the cases the runs **share** — so adding an extractor never fails
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Recording an event page through ScraperAPI — the project's ONE page-fetching
2+
// surface (the gcec RULES.md extractor-pipeline rule). Lives at the pack root
3+
// rather than inside a task because two tasks need it: `create-extractor` records
4+
// the page for a new request, and `record-page` records any case whose `.url` has
5+
// no committed `.html` yet. Lifting it to their common ancestor keeps one
6+
// implementation of "how this project fetches a page" — swap the vendor here if
7+
// ScraperAPI underperforms, and both tasks follow.
8+
//
9+
// Only usable from a task's `agent_preprocessing` worker: it needs
10+
// SCRAPER_API_KEY, which reaches a worker through the task's declared
11+
// `agent_preprocessing_secrets` and reaches nothing else (an executor session
12+
// holds no repo secrets). This sandbox is bot-blocked, so a direct fetch from a
13+
// session would fail even with a key.
14+
15+
import { mkdirSync, writeFileSync, statSync } from 'node:fs';
16+
import { dirname } from 'node:path';
17+
18+
// The ScraperAPI request URL for a page. `render=true` executes the page's JS so a
19+
// single-page app records real content instead of an empty shell; the optional
20+
// wait-for selector (the extension derives it from the live page, #603) makes the
21+
// snapshot wait for real content to appear first. Both are url-encoded, so their
22+
// own &/?/# can never leak as sibling ScraperAPI params.
23+
export function scraperUrl(key, pageUrl, waitSelector) {
24+
const params = new URLSearchParams({ api_key: key, render: 'true', url: pageUrl });
25+
if (waitSelector) params.set('wait_for_selector', waitSelector);
26+
return `https://api.scraperapi.com/?${params}`;
27+
}
28+
29+
// Record `pageUrl` to `outPath`. Returns the byte count written, or throws with a
30+
// message written to land verbatim on a GitHub issue — the caller's job is to
31+
// decide whether an unfetchable page is a dead end (hand it to a human) or a
32+
// failure, not to re-diagnose it.
33+
export async function recordPage(key, { url, waitSelector }, outPath, { timeoutMs = 180_000 } = {}) {
34+
const res = await fetch(scraperUrl(key, url, waitSelector), { signal: AbortSignal.timeout(timeoutMs) });
35+
if (!res.ok) throw new Error(`ScraperAPI returned HTTP ${res.status} for ${url}`);
36+
const html = await res.text();
37+
if (!html.trim()) throw new Error(`ScraperAPI returned an empty document for ${url}`);
38+
mkdirSync(dirname(outPath), { recursive: true });
39+
writeFileSync(outPath, html);
40+
return statSync(outPath).size;
41+
}

.claudinite/local/packs/gcec/skills/add-live-case/SKILL.md

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,29 +17,37 @@ New cached HTML can't be fetched from this sandbox (bot-blocked — see the
1717
gcec pack's RULES.md, "Extractor pipeline" section), so record the page *before* writing the
1818
case and read its exact `expected` off the committed file instead of guessing:
1919

20-
1. Commit one new file — but **not** the case file yet:
20+
1. Land one new file on `main` — but **not** the case file yet:
2121
`dev/requirements/extractor/data/server-fetched/<name>.url` — plain text,
2222
just the event page URL. This file stays for good: it is the **single source
23-
of truth** for the page's URL (used by the fetch workflow and by
24-
`live.test.js`), so the URL is **not** repeated in the case file.
25-
2. Push the branch, then **dispatch `.github/workflows/fetch-page.yml`** on it
26-
(Actions tab / API) with inputs `case_name=<name>` and `url=<the event URL>`
27-
(plus `wait_for_selector` when the request carries one). It records the page
28-
via ScraperAPI and commits `<name>.html` back to the branch with
29-
`[skip ci]`; `test:live` stays green because no case asserts it yet. The
30-
workflow is dispatched **per page, never a bulk refresh**, and its commit
31-
only touches `data/**`, which `test.yml`'s `paths-ignore` skips.
32-
3. Pull, then add `dev/requirements/extractor/expected/<name>.json` (same
33-
`<name>`, just `description` + `expected`, no `url`) and run
34-
`npm run test:live` — it now runs against the local cached HTML, so its
35-
output gives you the exact `expected` to paste in. Commit and push.
23+
of truth** for the page's URL (it is what the recorder fetches and what
24+
`live.test.js` sets the DOM origin from), so the URL is **not** repeated in
25+
the case file.
26+
2. Wait for the **`record-page` task** to record it. There is nothing to
27+
dispatch: that task runs on the hourly scheduler, and a committed `.url` with
28+
no sibling `.html` *is* the request. It fetches the page through ScraperAPI
29+
(whose secret only a task's preprocessing worker can reach — see the gcec
30+
pack's RULES.md, "Extractor pipeline") and opens a **Record cached event
31+
pages** PR carrying `<name>.html`. Merge it. `test:live` stays green
32+
throughout, because no case asserts the page yet.
33+
3. Add `dev/requirements/extractor/expected/<name>.json` (same `<name>`, just
34+
`description` + `expected`, no `url`) and run `npm run test:live` — it now
35+
runs against the local cached HTML, so its output gives you the exact
36+
`expected` to paste in. Commit and push.
37+
38+
For a **new site** (or a new behavior on a supported one), you usually don't need
39+
this at all: file an `extractor-request` issue with the event URL and the
40+
`create-extractor` task does the whole thing — records the page, scaffolds the
41+
case, writes the extractor, and opens the PR.
3642

3743
`expected.events` is the **complete, exact** array the extractor produces —
3844
deep-equal on `title`, `start`, `end`, `location`, `ctz`, and `details`, no
3945
matchers, array length included (one event for an ordinary page, several for a
4046
listing/series page). See `live.test.js`'s header for how each field derives.
4147

42-
**Gardening:** when an event page is eventually taken down, point
43-
`<name>.url` at a newer event and refresh its cached HTML the same way. Until a
44-
cached HTML file exists for a case, `test:live` (and the Tests workflow) fails
45-
with `Missing cached HTML for "<name>"`.
48+
**Gardening:** when an event page is eventually taken down, point `<name>.url` at
49+
a newer event **and delete the stale `<name>.html`** in the same commit — that is
50+
the whole re-record request; `record-page` fetches the new page on its next run.
51+
Until a cached HTML file exists for a case, `test:live` (and the Tests workflow)
52+
fails with `Missing cached HTML for "<name>"`, so land the refresh PR before (or
53+
with) the case's updated `expected`.

.claudinite/local/packs/gcec/skills/testing-guide/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ matchers, array length included. The page URL lives **only** in
5353
and the test both read it; never in the case file). Tests run offline against
5454
the committed cached HTML, loaded into a DOM at the `.url`'s URL so hostname
5555
detection behaves exactly as in Chrome. `test.yml` never fetches — recording
56-
goes through the fetch-page workflow (see add-live-case).
56+
goes through the record-page task (see add-live-case).
5757

5858
## UI snapshots
5959

dev/routines/create-extractor/attach-sample-url.js renamed to .claudinite/local/packs/gcec/tasks/create-extractor/attach-sample-url.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Fold a deferred extractor request's event URL into the LEADER issue's body as
2-
// an extra "sample page" (see 2-triage.js's "sample" disposition
3-
// and routine.md). Two same-host requests can't run the agent
2+
// an extra "sample page" (see triage.js's "sample" disposition, applied by
3+
// prepare.mjs). Two same-host requests can't run the agent
44
// concurrently, but the newer one's URL is a second real event page — useful raw
55
// material for a more robust extractor. So instead of discarding it, the prepare
66
// workflow records it on the leader issue as a checklist item a maintainer (the

dev/routines/create-extractor/case-quality.js renamed to .claudinite/local/packs/gcec/tasks/create-extractor/case-quality.js

File renamed without changes.

dev/routines/create-extractor/extractor-naming.js renamed to .claudinite/local/packs/gcec/tasks/create-extractor/extractor-naming.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
// Deterministic branch / cache-file names for the auto-implement-extractor
2-
// workflow, derived purely from the event URL's host. The workflow does Phase 1
3-
// (branch + cache the page) before the agent runs, so these names can't be the
2+
// task, derived purely from the event URL's host. The task's preprocessing
3+
// branches and records the page before the agent runs, so these names can't be the
44
// agent's judgement call any more — they have to be reproducible from the URL
5-
// alone. Pure (no I/O), so it's unit-tested, and the workflow and triage agree
5+
// alone. Pure (no I/O), so it's unit-tested, and preprocessing and triage agree
66
// on the names.
77
//
88
// slug — host, www-stripped, a common public suffix removed, dots → "-":
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Postcondition for the create-extractor task's AGENT stage (task.md §4): validate
4+
# the agent's work before the draft PR is marked ready for review.
5+
# Exit 0 → the extraction is valid; mark the PR ready (task.md §5).
6+
# Non-zero → do NOT mark it ready. The agent bails: close the PR and hand the
7+
# issue to a human, quoting the printed reason. Reverts nothing — a
8+
# scope violation FAILS the run (the human PR review is the backstop,
9+
# per the pipeline's containment model), rather than silently
10+
# rewriting the agent's work.
11+
#
12+
# Takes NO env. Everything it needs is derivable from the branch preprocessing
13+
# pushed: the case name from the branch, the source from the recorded page's own
14+
# host. (It used to read five env vars the agent threaded through from a triage
15+
# JSON blob — that hand-off is gone now that preprocessing owns the names and
16+
# commits them, and derived-here can't drift from what was actually built.)
17+
18+
set -uo pipefail
19+
cd "$(git rev-parse --show-toplevel)"
20+
TASK_DIR=".claudinite/local/packs/gcec/tasks/create-extractor"
21+
22+
fail() { echo "POSTCONDITION FAILED — do not mark the PR ready, hand the issue to a human: $*" >&2; exit 1; }
23+
24+
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
25+
case "$BRANCH" in
26+
claude/extractor/*) ;;
27+
*) fail "not on a claude/extractor/* branch (on '$BRANCH') — work on the draft PR's head branch" ;;
28+
esac
29+
CASE_NAME="${BRANCH#claude/extractor/}"
30+
CASE_FILE="dev/requirements/extractor/expected/$CASE_NAME.json"
31+
URL_FILE="dev/requirements/extractor/data/server-fetched/$CASE_NAME.url"
32+
[ -f "$CASE_FILE" ] || fail "no case at $CASE_FILE — was this branch scaffolded by the task's preprocessing?"
33+
[ -f "$URL_FILE" ] || fail "no recorded URL at $URL_FILE — was this branch scaffolded by the task's preprocessing?"
34+
35+
# The source that owns this case's host, asked of the sources' real matches() — the
36+
# same authority triage used, so this can't disagree with what was scaffolded.
37+
SRC_BASE="$(node "$TASK_DIR/resolve-source.js" "$(cat "$URL_FILE")" 2>/dev/null)"
38+
[ -n "$SRC_BASE" ] || fail "no source matches this case's host — the scaffold's matches() is missing or was edited"
39+
SRC="extension/event-extractors/custom/$SRC_BASE.js"
40+
41+
# The last commit preprocessing made on this branch (the scaffold, then the page
42+
# record). `git log` is newest-first, so the first match is the baseline the agent's
43+
# work sits on top of; fall back to the newest branch-only commit.
44+
PREP=$(git log --format='%H' --grep='chore: scaffold' --grep='chore: record' origin/main..HEAD 2>/dev/null | head -1)
45+
[ -n "$PREP" ] || PREP=$(git rev-list origin/main..HEAD 2>/dev/null | head -1)
46+
[ -n "$PREP" ] || fail "no preprocessing commit found on the branch — was prepare.mjs run?"
47+
48+
# 1. SCOPE — only the source + the case may differ from what preprocessing left.
49+
# Anything else the agent touched (a shared helper, the load lists, a re-recorded
50+
# page, a new file) fails the run. The recorded page is INSIDE the baseline now
51+
# (preprocessing committed it), so it is no longer an allowed change — a
52+
# re-recorded page is exactly the kind of drift this should catch.
53+
changed="$( { git diff --name-only "$PREP"; git ls-files --others --exclude-standard; } \
54+
| sort -u | sed '/^$/d' )"
55+
offenders="$(printf '%s\n' "$changed" | grep -Fvx -e "$SRC" -e "$CASE_FILE" || true)"
56+
[ -z "$offenders" ] || fail "out-of-scope changes (only $SRC and $CASE_FILE may change):
57+
$(printf ' %s\n' $offenders)"
58+
59+
# 2. QUALITY FLOOR — a deterministic backstop to the agent's bail judgment. The
60+
# case must be a real, presentable event: not 'empty' (nothing extracted) and
61+
# not 'degenerate' (an event with no location — the signature of a listing/tour
62+
# page rather than one specific event).
63+
VERDICT=$(CASE_FILE="$CASE_FILE" node "$TASK_DIR/case-quality.js")
64+
[ "$VERDICT" = "ok" ] || fail "quality floor: the case is '$VERDICT' — no presentable single event to ship"
65+
66+
# 3. RE-VERIFY — never trust the agent; the whole suite must be green.
67+
npm run test:live || fail "test:live is not green"
68+
npm run test:offline || fail "test:offline is not green"
69+
70+
echo "All postconditions passed — mark the draft PR ready for review (task.md §5)."
71+
exit 0

0 commit comments

Comments
 (0)