Part 1
Extract records with a schema
A scraper usually breaks when a site changes its layout, because it reads values from fixed places in the HTML. This scraper fetches the page and reduces it to text, and the model returns every record that fits a Zod schema, which Zod then checks. There are no selectors to break when the layout changes.
Study first
- Learn why selectors break. A selector encodes where a value sits on the page, while a schema encodes what you want.
- Look again at structured output, this time with a list, as in
Output.object({ schema: z.object({ items: z.array(Item) }) }). Usedescribe()on each field to say how a value is written.
Build
- Split
fetchPage(url)out ofreadPageinsrc/lib/web.ts, keeping the guards thatreadPagegot on Day 7. It refuses private addresses, checks each redirect and caps the bytes it reads, and it returns the HTML and the address the page was finally read from. - Write
nextLink(html, base)insrc/lib/scrape.ts. It finds the link a page calls next and resolves it against the page's address. That is a link withrel="next", or a link whose text is next, more, older, › or ». - Write
scrape(url, Item, model, { pages, limit })in the same file, whereItemis the Zod schema for one record. It fetches each page, asks the model for the items in the page's text and validates them, then follows the next link, up topagestimes. - Add
pnpm scrape <url> [--pages n]with a supplier schema of name, hours, services, prices and contact. Each value stays as the page writes it, not reformatted. - Write tests for
nextLinkon the link shapes above, and forscrapeagainst a stubbed fetch and a scripted model that answers by page. Test a 404 as an error too.
Done when
pnpm scrapeon a supplier's page prints 1 record in the schema's shape.- On a listing of suppliers it prints 1 record for each, with
nullfor what a page does not state.
Technical notes
- The page goes to the model as text, not HTML, cut to 12,000 characters by default. The schema and the instruction to copy values exactly as written do the extraction.
- The unit test's scripted model reads the address on the first line of each prompt and answers for that page, so each page gets its own records.
- A page with no matching records gives an empty list, not an error, while a page that is not there is an error from
fetchPage, with its status.

