Ingest pipeline
How the ingest works
The ingest script reads a community sale list, sends the plain text to Claude with a structured prompt, and checks every record it gets back against a schema before anything is written. This page shows one sample going through it.
What you are looking at
- The input is a synthetic three-row sample, written by hand. It is not from a real sale list.
- The output came from running the script in mock mode (
MOCK_INGEST=1). Mock mode skips Claude and returns a fixed, hand-written result for this sample. It shows the shape a real model call has to return. It is not a model’s output. - The prompt and the validation rules below are the real ones from the code. The checks on this page run against the real schema at build time.
- The 30 stops on the demo map were not produced by this pipeline. They were written by hand.
1.Input: a sale list
A sale day usually lives in one place, like a blog post. This is the sample HTML the script reads (scripts/__fixtures__/ws-2026-sample.html).
<div class="entry-content">
<h2>Sample sale list (synthetic demo data)</h2>
<p>Saturday, May 9, 2026. Most sales run 9am to 3pm.</p>
<ol>
<li>
<strong>4500 block of California Ave SW</strong>. 9am–3pm.
Categories: furniture, kitchen.
Items: midcentury teak credenza, round oak dining table, KitchenAid mixer,
stack of Pyrex bowls. Notes: cash only. Host: Junction household.
</li>
<li>
<strong>5600 block of California Ave SW</strong>. 8am–2pm.
Categories: books, art supplies.
Items: paperbacks (mystery + sci-fi), acrylic paints, brushes, framed
botanical prints, sketchbooks. Notes: rain or shine. Host: California Ave household.
</li>
<li>
<strong>3800 block of Beach Drive SW</strong>. 9am–4pm.
Categories: sports, kids.
Items: used Trek road bike, inflatable paddleboard, kids life jackets,
Patagonia rain shells, beach toys. Host: anonymous.
</li>
</ol>
</div>The script strips tags, scripts, comments and extra whitespace to keep the prompt short. This is the plain text that would go to Claude:
Sample sale list (synthetic demo data) Saturday, May 9, 2026. Most sales run 9am to 3pm. 4500 block of California Ave SW . 9am–3pm. Categories: furniture, kitchen. Items: midcentury teak credenza, round oak dining table, KitchenAid mixer, stack of Pyrex bowls. Notes: cash only. Host: Junction household. 5600 block of California Ave SW . 8am–2pm. Categories: books, art supplies. Items: paperbacks (mystery + sci-fi), acrylic paints, brushes, framed botanical prints, sketchbooks. Notes: rain or shine. Host: California Ave household. 3800 block of Beach Drive SW . 9am–4pm. Categories: sports, kids. Items: used Trek road bike, inflatable paddleboard, kids life jackets, Patagonia rain shells, beach toys. Host: anonymous.
2.The prompt
The script asks for JSON only, one record per stop, with fixed field rules. It is set to use claude-sonnet-4-6 with a 32,000-token output cap, and it stops with a clear error on a refusal or a truncated reply instead of parsing half a JSON object.
Show the full prompt
You are extracting structured garage-sale listing data from a community
garage-sale list (typically a blog post or PDF). Return ONLY a JSON object
matching this shape: {"listings":[{...}, ...]}.
Per-listing fields:
- id: stable per event, format ws-2026-001 (zero-padded 3-digit suffix)
- eventSlug: always "garage-sale-day-2026"
- address: full street address (include city/state suffix when known)
- lat / lng: always 0 (geocoding is a separate step)
- hours: free-text time window (e.g. '9am-3pm')
- categories: 1-4 from the union (furniture, clothes, kids-baby, tools,
books-media, kitchen, electronics, sports-outdoors, garden-plants,
art-craft, collectibles, free-pile, estate, other)
- items: 1-8 short item descriptions (free text)
- notes: host-provided notes if present (cash only, no early birds, etc.)
- host: a neutral label from the source (e.g. 'multi-family', 'estate
sale', 'Junction household') OR the literal string 'anonymous'. Never
copy a personal name, even when the source gives one.
- hasFreePile: true only when a free-pile is explicitly mentioned
- source: always "ai-ingest"
- sourceLineRef: a stable reference to where in the source this listing
appeared (e.g. 'item-3' or 'paragraph-7')
- confidence: 0.0-1.0 self-rated extraction confidence
- rawSourceText: the verbatim slice of source text for this listing
Rules:
- Do NOT invent listings that aren't in the source.
- Skip headers, table-of-contents entries, and prose that isn't a listing.
- Normalize categories aggressively into the union; map 'antiques' to
'collectibles', 'baby' to 'kids-baby', etc.
- For listings with no explicit host label, use 'anonymous'.
- If the source text is too short or contains no listings, return
{"listings": []}.
Source text:
---
<plain text of the sale list goes here>
---
Return only the JSON object. No prose, no markdown, no code fence.3.Output: one structured record
Each stop comes back as a record like this. The first one, from the mock-mode run:
{
"id": "ws-2026-mock-001",
"eventSlug": "garage-sale-day-2026",
"address": "4500 block of California Ave SW, Seattle, WA",
"lat": 0,
"lng": 0,
"hours": "9am-3pm",
"categories": [
"furniture",
"kitchen"
],
"items": [
"midcentury teak credenza",
"round oak dining table",
"KitchenAid mixer",
"stack of Pyrex bowls"
],
"notes": "cash only",
"host": "Junction household",
"source": "ai-ingest",
"sourceLineRef": "mock-fixture:line-1 (see scripts/__fixtures__/ws-2026-sample.html)",
"rawSourceText": "4500 block of California Ave SW. 9am-3pm. Categories: furniture, kitchen. Items: midcentury teak credenza, round oak dining table, KitchenAid mixer, stack of Pyrex bowls. Notes: cash only. Host: Junction household",
"confidence": 0.95
}The script then splits each record in two. The map data goes to garage-sale-day-2026.ingest.json. The model’s confidence and the verbatim source slice go to a debug sidecar, garage-sale-day-2026.ingest-debug.json, so a person can check what the model did with what the source said. Coordinates start at 0 and are filled in by a separate geocoding step.
4.Validation rules
Every reply is parsed with a Zod schema (lib/listing-schema.ts). If any record fails, the whole run fails and nothing is written.
idis letters, digits,-or_only, so it is safe in a URL and an HTML attribute.addressis at least 5 characters;hoursis not empty.categorieshas 1 to 4 entries, each from a fixed list of 14 (furniture, clothes, kids-baby, tools, and so on).itemshas 1 to 8 entries, none empty.sourcemust be"ai-ingest", andsourceLineRefis required, so every record points back to where it came from.confidence, when present, is between 0 and 1.- The reply must hold at least one listing, and the prompt tells the model never to invent a listing or copy a personal name.
Mock output: passes
3 records checked against the schema at build time.
Bad record: rejected
A hand-written example with 5 problems.
{
"id": "ws 2026 004",
"eventSlug": "garage-sale-day-2026",
"address": "Alki",
"hours": "9am-3pm",
"categories": [
"antiques",
"furniture"
],
"items": [],
"source": "ai-ingest"
}What the schema reports for it:
- id: Invalid string: must match pattern /^[a-zA-Z0-9_-]+$/
- address: Too small: expected string to have >=5 characters
- categories.0: Invalid option: expected one of "furniture"|"clothes"|"kids-baby"|"tools"|"books-media"|"kitchen"|"electronics"|"sports-outdoors"|"garden-plants"|"art-craft"|"collectibles"|"free-pile"|"estate"|"other"
- items: Too small: expected array to have >=1 items
- sourceLineRef: Required field is missing
5.After the ingest
A second script geocodes new addresses with OpenStreetMap Nominatim at one request per second and caches the results in the repo, so a build makes no network calls. The plan is for a person to review the ingest output against the debug sidecar before it goes on a map.