Independent hotels live and die by their price against the competition, but Booking doesn't give you an easy way to monitor a rival's live rate for a given date and group. Agente-Book automates that audit: it returns the best price for the requested number of guests (not the misleading "from $X"), every option, the status, and a timestamped screenshot. It started as an LLM browser-agent and got re-engineered into a deterministic scraper.
Returns the correct price for the requested group — not the global minimum that sleeps fewer people. Counts occupancy icons = room capacity and keeps the rows where capacity ≥ guests.
AVAILABLE, OCCUPIED, CAPTCHA, ERROR with explicit logic, not a model guessing.
Full-page screenshot with timestamp per run, and structured Pydantic output with all_options and matched_options.
navigate(url, dates, guests) if captcha_detected: return "CAPTCHA" if no_availability: return "OCCUPIED" rows = parse("#hprt-table") capable = [f for f in rows if f.capacity >= guests] price = min(capable, key=lambda f: f.value) return "AVAILABLE", price, screenshot
The orchestration is a LangGraph StateGraph. Today it's a single node, but the graph is the architecture: alerts, multi-hotel comparison, and price history come in as new nodes without touching the existing one.
The most useful part of the project: an LLM-agent was the wrong tool for a structured, repeatable extraction.
| Version | Change | Result |
|---|---|---|
| v1.0 | browser-use + ChatOllama (gemma3:27b) | OOM on a single 24 GB GPU (27b + vision > 24 GB VRAM) |
| v1.1 | Switched to gemma3:12b | Worked, but ~10 min/query and prone to looping |
| v2.0 | Direct Playwright, no LLM | ~10s/query, deterministic, 0 VRAM |
| v2.1 | Capacity filter + full-page screenshot | Correct price per group, complete evidence |
Dropping the LLM bought speed, determinism and $0 a query. It also bought coupling. The scraper reads Booking's DOM through fixed selectors — #hprt-table, occupancy cells, price text. The day Booking renames a class, the old LLM agent would have squinted at the page and adapted. This one returns ERROR. That is the honest shape of the trade: this is a scraper for a site you actually watch, not one you leave alone for a year.
What the design does buy is the property that matters when it breaks: it fails loudly. An unexpected DOM produces status="ERROR" carrying the exception detail, and the browser is closed in a finally no matter what failed — never a plausible wrong number reported quietly. For a price you are going to act on, no answer beats a confident bad one.