Field note · pipeline anatomy

The scrape gate

Every morning an AI agent scrapes housing listings across the Ogden metro and commits the result straight to this site. Nobody reviews it first. Models are good at reading messy listing pages and bad at being exact, so the work is split: the model only reads, code owns every rule about the data, and a gate refuses any run that looks like corruption rather than a market move. This is the anatomy of that split, with every scrape since Jun 7 replayed through the gate.

Sep 27, 2026
Last scrape
Oct 2
shipped by GitHub Actions
Seen this week
1,175
188 rentals · 987 for sale
Scrapes replayed
75
22 would have corrupted the data
Days scraped, always-on
40 of 42
vs 36 of 76 on a laptop

01 · The split

The model reads. Code decides.

Rent comes from a parser that reads the listing JSON rent.com embeds in its pages, the same way every time. For-sale homes are still read by the model, from Zillow and Redfin pages that change shape without notice. Either way the model hands over a flat file of rows, and that is all it is trusted to produce.

Everything after that is code. The merge owns the keys, the price history and each listing’s lifecycle: a listing no scrape has seen for 7 days ages to missing. The gate decides whether the result may ship: it runs in the push script before anything is committed, and again in CI on every push to main.

Where does the model stop and the code start?

SOURCESEXTRACTCODE RULESGATEPUBLICrent.comlisting JSON in the pageZillow · Redfinpages that change shapescrape-rent.mjsa parser: same way every timeClaude reads the pagethe model’s only job: rowshousing-merge.mjskeys · price history · lifecyclehousing-validate.mjsred: nothing is committedthe siteVercel redeploys on each pushfreshness checkfiles an issue on a stale day

The teal box is the only place the model works. Everything else is plain code, and a red gate means nothing is committed.

02 · The replay

Reliable isn’t the same as right

Until August the scrape ran on a laptop: a launchd job, a Cowork task, sometimes by hand. It shipped on 36 of 76 days, and once went 13 days without a run. Since Aug 22 it has run on GitHub Actions from a fresh clone, and shipped on 40 of 42 days.

That fixed reliability, not correctness. Replayed through the gate, 5 of the 41 runs Actions shipped would have been refused for corrupting the data, and 27 more for a quieter fault covered below. On Sep 20 one run added 158 duplicates: houses the catalogue already had, back under a second address string. Nothing noticed.

Which daily scrapes would the gate have refused?

Jun 7JulAugSepOctLaptop · launchd, Cowork, by handGitHub ActionsScrapedRentBuyJun 7, 2026: scraped by the laptop (2 runs)Jun 7, 2026 · rent: refused (6 double-priced)Jun 7, 2026 · buy: passesJun 8, 2026: scraped by the laptopJun 8, 2026 · buy: refused (1 stale date)Jun 9, 2026: scraped by the laptopJun 9, 2026 · rent: passesJun 9, 2026 · buy: refused (26 vanished, 16 duplicates)Jun 10, 2026: scraped by the laptopJun 10, 2026 · rent: refused (25 vanished)Jun 10, 2026 · buy: refused (23 vanished, 1 duplicates)Jun 11, 2026: no scrape shippedJun 12, 2026: scraped by the laptopJun 12, 2026 · rent: refused (22 histories rewritten, 15 vanished)Jun 12, 2026 · buy: refused (182 vanished, 4 duplicates)Jun 13, 2026: no scrape shippedJun 14, 2026: no scrape shippedJun 15, 2026: no scrape shippedJun 16, 2026: no scrape shippedJun 17, 2026: scraped by the laptopJun 17, 2026 · rent: refused (14 vanished)Jun 17, 2026 · buy: refused (30 vanished)Jun 18, 2026: scraped by the laptopJun 18, 2026 · rent: passesJun 18, 2026 · buy: passesJun 19, 2026: no scrape shippedJun 20, 2026: no scrape shippedJun 21, 2026: scraped by the laptopJun 21, 2026 · rent: passesJun 21, 2026 · buy: passesJun 22, 2026: scraped by the laptopJun 22, 2026 · rent: refused (33 vanished)Jun 22, 2026 · buy: passesJun 23, 2026: scraped by the laptopJun 23, 2026 · rent: refused (38 vanished)Jun 23, 2026 · buy: passesJun 24, 2026: scraped by the laptopJun 24, 2026 · rent: passesJun 24, 2026 · buy: passesJun 25, 2026: scraped by the laptopJun 25, 2026 · rent: refused (28 vanished)Jun 25, 2026 · buy: refused (107 vanished, 1 duplicates)Jun 26, 2026: scraped by the laptopJun 26, 2026 · rent: refused (42 vanished)Jun 26, 2026 · buy: passesJun 27, 2026: scraped by the laptopJun 27, 2026 · rent: refused (25 vanished)Jun 27, 2026 · buy: passesJun 28, 2026: scraped by the laptopJun 28, 2026 · rent: passesJun 28, 2026 · buy: passesJun 29, 2026: scraped by the laptopJun 29, 2026 · rent: refused (6 histories rewritten)Jun 29, 2026 · buy: passesJun 30, 2026: scraped by the laptopJun 30, 2026 · rent: passesJun 30, 2026 · buy: passesJul 1, 2026: scraped by the laptopJul 1, 2026 · rent: passesJul 1, 2026 · buy: passesJul 2, 2026: scraped by the laptopJul 2, 2026 · rent: refused (3 duplicates)Jul 2, 2026 · buy: refused (173 stale “active”)Jul 3, 2026: scraped by the laptopJul 3, 2026 · rent: passesJul 3, 2026 · buy: refused (2 stale “active”)Jul 4, 2026: scraped by the laptopJul 4, 2026 · rent: passesJul 4, 2026 · buy: passesJul 5, 2026: scraped by the laptopJul 5, 2026 · rent: passesJul 5, 2026 · buy: passesJul 6, 2026: scraped by the laptopJul 6, 2026 · rent: passesJul 6, 2026 · buy: passesJul 7, 2026: scraped by the laptopJul 7, 2026 · rent: refused (10 stale “active”)Jul 7, 2026 · buy: refused (37 stale “active”)Jul 8, 2026: scraped by the laptopJul 8, 2026 · rent: refused (2 stale “active”)Jul 8, 2026 · buy: passesJul 9, 2026: scraped by the laptopJul 9, 2026 · rent: refused (27 stale “active”)Jul 9, 2026 · buy: passesJul 10, 2026: scraped by the laptopJul 10, 2026 · rent: refused (5 stale “active”)Jul 10, 2026 · buy: passesJul 11, 2026: no scrape shippedJul 12, 2026: scraped by the laptopJul 12, 2026 · rent: refused (24 stale “active”)Jul 12, 2026 · buy: passesJul 13, 2026: scraped by the laptopJul 13, 2026 · rent: refused (11 stale “active”)Jul 13, 2026 · buy: passesJul 14, 2026: no scrape shippedJul 15, 2026: scraped by the laptopJul 15, 2026 · rent: refused (12 stale “active”, 2 duplicates)Jul 15, 2026 · buy: passesJul 16, 2026: scraped by the laptopJul 16, 2026 · rent: passesJul 16, 2026 · buy: passesJul 17, 2026: no scrape shippedJul 18, 2026: scraped by the laptopJul 18, 2026 · rent: refused (9 stale “active”)Jul 18, 2026 · buy: passesJul 19, 2026: no scrape shippedJul 20, 2026: scraped by the laptopJul 20, 2026 · rent: refused (3 stale “active”)Jul 20, 2026 · buy: refused (1 stale “active”)Jul 21, 2026: no scrape shippedJul 22, 2026: no scrape shippedJul 23, 2026: no scrape shippedJul 24, 2026: no scrape shippedJul 25, 2026: no scrape shippedJul 26, 2026: no scrape shippedJul 27, 2026: scraped by the laptopJul 27, 2026 · buy: refused (81 stale “active”, 2 duplicates)Jul 28, 2026: no scrape shippedJul 29, 2026: no scrape shippedJul 30, 2026: scraped by the laptopJul 30, 2026 · rent: refused (140 stale “active”, 4 duplicates)Jul 30, 2026 · buy: refused (115 duplicates)Jul 31, 2026: no scrape shippedAug 1, 2026: no scrape shippedAug 2, 2026: no scrape shippedAug 3, 2026: no scrape shippedAug 4, 2026: no scrape shippedAug 5, 2026: no scrape shippedAug 6, 2026: no scrape shippedAug 7, 2026: no scrape shippedAug 8, 2026: no scrape shippedAug 9, 2026: no scrape shippedAug 10, 2026: no scrape shippedAug 11, 2026: no scrape shippedAug 12, 2026: no scrape shippedAug 13, 2026: scraped by the laptopAug 13, 2026 · rent: refused (28 stale “active”)Aug 13, 2026 · buy: refused (585 stale “active”, 81 duplicates)Aug 14, 2026: no scrape shippedAug 15, 2026: no scrape shippedAug 16, 2026: no scrape shippedAug 17, 2026: no scrape shippedAug 18, 2026: no scrape shippedAug 19, 2026: no scrape shippedAug 20, 2026: no scrape shippedAug 21, 2026: no scrape shippedAug 22, 2026: scraped by GitHub ActionsAug 22, 2026 · rent: refused (11 stale “active”)Aug 22, 2026 · buy: refused (153 stale “active”, 1 duplicates)Aug 23, 2026: scraped by GitHub ActionsAug 23, 2026 · rent: passesAug 23, 2026 · buy: passesAug 24, 2026: scraped by GitHub ActionsAug 24, 2026 · rent: passesAug 24, 2026 · buy: passesAug 25, 2026: scraped by GitHub ActionsAug 25, 2026 · rent: passesAug 25, 2026 · buy: passesAug 26, 2026: scraped by GitHub ActionsAug 26, 2026 · rent: passesAug 26, 2026 · buy: passesAug 27, 2026: scraped by GitHub ActionsAug 27, 2026 · rent: passesAug 27, 2026 · buy: passesAug 28, 2026: scraped by GitHub ActionsAug 28, 2026 · rent: passesAug 28, 2026 · buy: passesAug 29, 2026: scraped by GitHub ActionsAug 29, 2026 · rent: refused (5 stale “active”)Aug 29, 2026 · buy: refused (5 stale “active”)Aug 30, 2026: scraped by GitHub ActionsAug 30, 2026 · rent: passesAug 30, 2026 · buy: refused (5 stale “active”)Aug 31, 2026: scraped by GitHub ActionsAug 31, 2026 · rent: refused (4 stale “active”)Aug 31, 2026 · buy: refused (21 stale “active”)Sep 1, 2026: scraped by GitHub ActionsSep 1, 2026 · rent: refused (2 stale “active”)Sep 1, 2026 · buy: refused (8 stale “active”)Sep 2, 2026: scraped by GitHub ActionsSep 2, 2026 · rent: refused (2 stale “active”)Sep 2, 2026 · buy: refused (17 stale “active”)Sep 3, 2026: scraped by GitHub ActionsSep 3, 2026 · rent: refused (3 stale “active”)Sep 3, 2026 · buy: refused (12 stale “active”)Sep 4, 2026: scraped by GitHub ActionsSep 4, 2026 · rent: refused (2 stale “active”)Sep 4, 2026 · buy: refused (7 stale “active”)Sep 5, 2026: scraped by GitHub Actions (2 runs)Sep 5, 2026 · rent: refused (5 stale “active”)Sep 5, 2026 · buy: refused (7 stale “active”)Sep 6, 2026: scraped by GitHub ActionsSep 6, 2026 · rent: refused (1 stale “active”)Sep 6, 2026 · buy: refused (14 stale “active”)Sep 7, 2026: scraped by GitHub ActionsSep 7, 2026 · rent: refused (1 stale “active”)Sep 7, 2026 · buy: refused (15 stale “active”)Sep 8, 2026: scraped by GitHub ActionsSep 8, 2026 · rent: refused (1 stale “active”)Sep 8, 2026 · buy: refused (14 stale “active”)Sep 9, 2026: scraped by GitHub ActionsSep 9, 2026 · rent: refused (3 stale “active”)Sep 9, 2026 · buy: refused (18 stale “active”)Sep 10, 2026: scraped by GitHub ActionsSep 10, 2026 · rent: refused (5 stale “active”)Sep 10, 2026 · buy: refused (14 stale “active”)Sep 11, 2026: scraped by GitHub ActionsSep 11, 2026 · rent: refused (2 stale “active”)Sep 11, 2026 · buy: refused (60 stale “active”, 1 duplicates)Sep 12, 2026: scraped by GitHub ActionsSep 12, 2026 · rent: refused (3 stale “active”, 1 duplicates)Sep 12, 2026 · buy: refused (5 stale “active”)Sep 13, 2026: scraped by GitHub ActionsSep 13, 2026 · rent: refused (2 stale “active”)Sep 13, 2026 · buy: refused (3 stale “active”)Sep 14, 2026: scraped by GitHub ActionsSep 14, 2026 · rent: passesSep 14, 2026 · buy: refused (6 stale “active”)Sep 15, 2026: scraped by GitHub ActionsSep 15, 2026 · rent: refused (1 stale “active”)Sep 15, 2026 · buy: refused (14 stale “active”)Sep 16, 2026: scraped by GitHub ActionsSep 16, 2026 · rent: refused (2 stale “active”)Sep 16, 2026 · buy: refused (14 stale “active”)Sep 17, 2026: scraped by GitHub ActionsSep 17, 2026 · rent: passesSep 17, 2026 · buy: refused (16 stale “active”)Sep 18, 2026: scraped by GitHub ActionsSep 18, 2026 · rent: refused (2 stale “active”)Sep 18, 2026 · buy: refused (9 stale “active”)Sep 19, 2026: scraped by GitHub ActionsSep 19, 2026 · rent: refused (2 stale “active”)Sep 19, 2026 · buy: refused (6 stale “active”)Sep 20, 2026: scraped by GitHub ActionsSep 20, 2026 · rent: refused (4 stale “active”)Sep 20, 2026 · buy: refused (4 stale “active”, 158 duplicates)Sep 21, 2026: scraped by GitHub ActionsSep 21, 2026 · rent: refused (1 stale “active”)Sep 21, 2026 · buy: refused (11 stale “active”, 1 duplicates)Sep 22, 2026: scraped by GitHub ActionsSep 22, 2026 · rent: refused (6 stale “active”)Sep 22, 2026 · buy: refused (8 stale “active”)Sep 23, 2026: scraped by GitHub ActionsSep 23, 2026 · rent: refused (4 stale “active”)Sep 23, 2026 · buy: refused (20 stale “active”)Sep 24, 2026: scraped by GitHub ActionsSep 24, 2026 · rent: refused (2 stale “active”)Sep 24, 2026 · buy: refused (11 stale “active”)Sep 25, 2026: scraped by GitHub ActionsSep 25, 2026 · rent: refused (4 stale “active”)Sep 26, 2026: scraped by GitHub ActionsSep 26, 2026 · rent: passesSep 26, 2026 · buy: refused (24 stale “active”)Sep 27, 2026: scraped by GitHub ActionsSep 27, 2026 · rent: refused (3 stale “active”)Sep 28, 2026: no scrape shippedSep 29, 2026: scraped by GitHub ActionsSep 29, 2026 · rent: passesSep 30, 2026: scraped by GitHub ActionsSep 30, 2026 · rent: passesOct 1, 2026: no scrape shippedOct 2, 2026: scraped by GitHub ActionsOct 2, 2026 · rent: passes182 vanished107 vanished115 duplicates81 duplicates158 duplicatesgate live

78 runs, Jun 7 to Oct 2, 2026: 22 would have corrupted the data (17 on the laptop, 5 on Actions), and 36 more were refused only for stale “active” labels. Before the dashed line each column replays the commit that shipped against the one before it; after it, the gate ran live and a refused run shows as a day without a scrape. Hover a column for its rules.

03 · The checks

Refuse corruption, not the market

A price cut, a listing that disappears, a slow week: that is the market, and the gate lets it through. It refuses what only a broken run produces: history rewritten, listings deleted, a live count that drops more than 15% overnight among listings seen that week (almost always a scrape that missed pages), or a house gaining a second key.

It blocks regressions, not the backlog. A problem already in yesterday’s file only warns, so the gate could ship on day one without first cleaning up every old mistake.

The push that prompted it: on Sep 27 a scheduled task found its checkout parked on a branch whose catalogue was frozen at Sep 20. Merging there would have pushed a week-old file over today’s. The agent stopped on its own. The gate turns that luck into a rule.

Which checks would have fired, and how often?

CheckReplayed runs it refusesRollback
Deleted listingA listing from the last run is gone. Listings only ever age to missing.0
Rewritten historyA past price changed or vanished. Price history is append-only.2 runs · 28
Sightings went downA listing’s count of runs seen fell, which only an older file can do.0
First-seen movedThe day a listing first appeared changed after the fact.0
Stale or backwards dateThe file isn’t stamped today, or its date went backwards.1 run · 1
Two prices, one dayA listing got two price entries for the same day.1 run · 6
Active count collapsedMore than 15% of recently seen listings vanished in one run: a coverage failure, not the market.9 runs · 588
Stale listing still activeA listing no scrape has seen for a week is still marked active.46 runs · 1,754
New duplicateA new key is a property the catalogue already has under another address string.14 runs · 391
Malformed rowA row is missing coordinates or a price, or sits outside the seven cities.0

Bars count replayed runs; the number after the dot is listings caught. A filled dot means the week-old push below trips that check too.

What would the week-old checkout have shipped?

71listings deleted
945price histories rewritten
−7 daysdate stamp goes backwards
Refusedby the gate, both files

The Sep 20 catalogue validated as if pushed over the Sep 27 one: the replay stages the push the agent declined to make.

04 · The quiet one

When “active” stopped meaning on the market

Replaying the history surfaced a failure no single run causes. The merge only aged a listing when a run said it had covered the whole source. The for-sale scrape never reaches every page, so no run ever said so, and nothing aged. By Sep 26, 1,173 of the 2,160 homes marked active hadn’t been seen by any scrape for a week.

The fix is a rule, not a cleanup: a listing no scrape has seen for 7 days ages to missing, even on a partial run, and the gate now refuses any file that still calls one active. The next scrape applies it. Why a week: since the move to Actions, 21 of 25,492 gaps between sightings of a live home ran past 7 days, and a listing aged by mistake comes back the next time it’s seen.

How many “active” listings had no scrape seen for a week?

01,0002,000JulAugSepOct1,173for sale0rentals

Active listings whose last sighting was 7+ days before the catalogue’s own date, after each scrape. The rentals include listings left over from apartments.com, which stopped being a source.

When did a scrape last see the homes marked active today?

This week987
1–2 weeks78
2–4 weeks205
1–2 months510
2+ months380

For-sale homes marked active as of Sep 26. Only the top row is a claim a scrape backs up; the week rule ages the rest.

05 · The backlog

What the gate can’t fix

The gate stops new damage. It doesn’t repair old damage. 367 homes in the for-sale catalogue are listed more than once: the same house scraped from both Zillow and Redfin, or relabelled from South Ogden to Ogden. That inflates the Home Finder’s live count by about 356. The fix is a one-time dedupe pass, then keying each listing on the site’s own listing ID instead of its address.

The map has the same kind of gap. The merge gives every new listing its city’s center as a placeholder, and nothing replaces it: all 1,721 homes first seen since Jun 30 sit on one of seven points, which puts 83% of the for-sale map on a city center. Geocoding each address once and caching it closes that. Then a placeholder becomes a refusal too.

How many homes are listed more than once?

0250500JulAugSepOct367for sale10rentals

Properties with two or more keys after each scrape: same street, unit, ZIP and bedrooms under different address strings.

What share of map pins sit on a city’s center?

0%25%50%75%100%JulAugSepOct83%for sale64%rentals

Share of every listing ever tracked (live or not) whose coordinates are exactly one of the seven city centers.

The receipt

Every push that ships appends a row to a committed receipt, housing_gate.json, and this page reads it at build time. Right now: last gate run Oct 2, 2026 on GitHub Actions: rent passed, buy unchanged. When the gate refuses, nothing ships, the day shows up as a gap, and the freshness check files an issue that morning.

The split carries past housing. Any team feeding AI-extracted data into numbers people act on, like pipeline, pricing or inventory, can run it the same way: let the model read, make code own the rules, and put a gate between the model and the push.

The scrape runs on Claude inside a GitHub Actions job. The merge, the gate and the replay are plain Node scripts in the site repo, and the listings come from public listing pages.