Here is the problem that makes restaurant availability different from every price-scraping job: a fully booked restaurant returns nothing.
Not an error. Not available: false. An empty slot array, or the venue simply missing from the response. The most valuable state — sold out — has no row to scrape.
Which means you cannot detect it by parsing. You can only detect it by comparing against what you stored last time. The absence is the signal, and a stateless scraper is structurally blind to it.
The unit of state is a cell, not a venue
Availability is a function of three inputs, and they are independent:
key = f"{venue_id}|{date}|{party_size}"
A table for 2 on Friday says nothing about a table for 6 on Friday. Collapse party size and you have thrown away the axis that matters most to anyone tracking demand.
Then the comparison writes itself:
prev = state.get(key)
now = {"slots": len(slots), "prime": prime_count(slots)}
if prev is None: change = "new"
elif prev["slots"] > 0 and now["slots"] == 0: change = "sold_out" # only knowable by diff
elif prev["slots"] == 0 and now["slots"] > 0: change = "reopened" # a cancellation wave
elif abs(now["slots"] - prev["slots"]) > NOISE_FLOOR: change = "moved"
else: change = None
reopened is the one people miss. A venue going from 0 back to 4 slots is a cancellation event — for a demand analyst that is a stronger signal than the original sell-out, because it is rarer and more actionable.
The noise floor is not optional
Slot counts jitter by ±1 constantly — a hold expires, someone abandons a checkout. Without a threshold, your "changes" feed is mostly noise, and if you bill per change you are billing for jitter. That is the fastest route to a one-star review.
NOISE_FLOOR = 1 # ±1 slot is not an event
Pick the floor from the data, not from taste: sample one venue every minute for an hour and look at the distribution of deltas.
Prime time is the real metric
"14 slots available" is a weak number. Fourteen slots at 17:15 and 22:30 is an empty restaurant; two slots at 19:30 is a busy one.
So count the window separately:
PRIME_START_MIN = 18 * 60 + 30 # 18:30
PRIME_END_MIN = 20 * 60 + 59 # 20:59
prime = [s for s in slots if PRIME_START_MIN <= minutes(s) <= PRIME_END_MIN]
prime_sold_out while total slots stay non-zero is the scarcity signal — the restaurant is full when it matters and empty when it does not. That single derived flag is worth more than the raw count it comes from.
Getting in without a login
The public booking API is reachable anonymously with a client key that the web app ships in its own JavaScript bundle. The robust way to find it is not to hardcode it but to harvest it at runtime — and the detail that bit me is that the script src attributes are relative:
urls = [urljoin(str(home.url), s) for s in srcs
if s.endswith(".js") and "resy.com" in urljoin(str(home.url), s)]
urls.sort(key=lambda u: (0 if "/app." in u else 1, len(u)))
Sort the main app bundle first and you find the key on the first fetch instead of the tenth. Keep a known-good fallback, and re-extract once mid-run if you start getting 419 — that status means the key rotated, not that you are blocked.
Running it
Packaged as Resy Availability & Scarcity Monitor: forward-dated availability by date and party size, prime-window counts, first and last seating, seating types, largest table, sold-out and prime-sold-out flags. Read-only — it books nothing, needs no account, and touches no personal data.
Scheduled config ready to clone: New York restaurant availability changes — daily delta.
The five patterns behind the delta engine are in Scraping a price is easy. Knowing it changed is the product.
Tracking availability on a surface where absence is the signal? Reply with the source — the cell key is usually the hard part, and it is worth getting right before you write a parser.
Top comments (0)