The sales team copied listings off two portals by hand. By the time a listing reached their sheet, a competing broker had usually already called the owner, and in this market the first caller wins.
Five Playwright workers in Docker on Azure VMs poll only the latest-ads pages every five minutes. Redis deduplicates on URL, Pydantic normalises each listing into a typed record, and Postgres stores it for the sales queue.
The decision that made it last
The first version parsed CSS selectors, which is how most scrapers are written and why most scrapers break: one redesign and every rule is wrong.
Switching to the Schema.org JSON-LD already embedded in the listing pages changed the economics of the whole thing. That metadata is structured for search engines, and it barely moves when the visual layout does. Two years in production at a 99.2% success rate is that decision, not the crawler.
The rest of the design
Poll narrowly. Only the latest-ads pages, every five minutes. There is no reason to crawl a whole portal to find what was posted since the last run.
Deduplicate in Redis. URL-level, so a listing that appears on both portals reaches the sales queue once.
Validate at the boundary. Pydantic turns each listing into a typed record before it is allowed into Postgres, so a portal changing a field breaks loudly at the edge rather than quietly downstream.
What it changed commercially
Manual entry disappeared, and being first to call an owner roughly tripled conversion on hot leads. The engineering is unremarkable; the timing is the product.