# R4 — The Listing Reader
**Read-date of every figure on this page: 2026-11-10Z.**

## What it is for
You have a page with prices on it, and a spreadsheet you keep filling in by hand.
R4 turns the page into a CSV you can trust a month later, because **every row
carries the evidence of where it came from**:

| column | what it holds |
|---|---|
| `title` | the listing's own title, whitespace and entities cleaned |
| `price_raw` | exactly what the page printed (`CA$89.99`, `1.299,00 €`, `See price in cart`) |
| `price_cents` | that price as an **integer number of cents** (`8999`) — no float, no rounding drift |
| `currency` | read from the page (`USD`, `CAD`, `EUR`…), not assumed |
| `url` | the listing's own address, relative links resolved |
| `read_date` | **when the row was read**, ISO 8601 UTC (`2026-11-10T13:31:07Z`) |
| `source_hash` | sha256 of **the raw bytes of that one row** on the page |

That last column is the reason to use this instead of copy-paste. Re-read the page
in three weeks: a row whose `source_hash` still matches is a row the shop did not
touch; a row whose hash changed has a new price, a new title, or a new listing.
A number in your spreadsheet can be traced back to the exact pixels it came from.

## What it refuses to do (this is the product, not a limitation)
1. **It will not invent a price.** A tile reading `See price in cart`, or `0.00`,
   or a figure with three decimal places, is **rejected and named** on stderr.
   In the shipped fixture, 9 tiles are read and **7 rows come out, 2 refused**:
   one had no price to read, one had no link, so its row could never be re-checked.
2. **It will not fetch a live page without your authorisation.** A `http(s)://`
   input exits with code 2 and prints the flag it needs
   (`--i-have-checked-robots-and-am-authorized`), **before opening any socket**.
   Reading a local file needs no flag and makes no request at all.
3. **It will not hoard.** `--max-rows` (default 500) is a hard ceiling, and when
   it bites the script says `CAP HIT, more were not stored` instead of quietly
   stopping.

## The two rules it enforces on a live read
- **robots.txt is read before the page**, and the check is real: user-agent groups,
  longest-path match, `Allow` beating `Disallow` at the same length, an empty
  `Disallow:` meaning "everything", and a `Crawl-delay:` raising the floor. It is a
  plain function over the robots text, which is why it can be tested — see
  `tests/test_r4_scrape.py`, 58 tests, all offline.
- **One request per second, minimum.** The limiter's clock is injectable, so the
  1.0 s floor is asserted by a test, not promised in a README.

## Your commands
```bash
cd recipes/r4
python3 scrape_listings.py --input fixtures/listings.html --base-url https://giftmart.example
python3 scrape_listings.py --input fixtures/listings.json --out rows.csv
python3 scrape_listings.py -i your_page.html -f html --item-class product --price-class cost
```
Exit codes: `0` ok · `2` usage or live-without-the-flag · `3` robots.txt denied ·
`4` input unreadable · `5` rows read but none keepable.

Feed its output to **R5** (`recipes/r5/`) to rank what you read by price per real
unit. `fixtures/` is a fictional shop on `.example` (RFC 2606 — it can never
resolve to a real store); its prices are placeholders, **not market data**.
