Turns a map search into contactable leads, or leaves the contact field blank.
Nothing here is measured yet. Precision fills from a hand-check: run enrichment over 50 businesses, open each site, mark each returned email, phone, and owner name correct or not. The zero is structural — the tenant_isolation policy on eight tables — and the two-user acceptance test in the tenancy doc has not been run.
A business name is not a contact. Google Maps returns a name, a rating, a pin, and sometimes a website. It does not return the person who reads the inbox, whether that inbox exists, or whether the address on the contact page belongs to the owner, the web designer who built the site, or a testimonial three scrolls down.
The failure is not that a crawler misses an address. The failure is that a wrong address is indistinguishable from a right one until the bounce returns, and by then it has been sent. Misses cost one lead. Wrong addresses cost sender reputation, which is priced across every lead after it.
No baseline was captured for manual research time before the pipeline existed.
| Before | After | |
|---|---|---|
| Contact | Any address found on the page | Ranked, then checked for a live MX record |
| Missing contact | Closest guess | Field left empty |
| Ranking | Reading the list top to bottom | 0–100, computed in Postgres |
| Tenancy | Filtered in application code | One policy across eight tables |
| Long work | Inside the request | Outside the serverless clock |
Leaving the field empty is what made the score mean anything. A rubric that awards points for a contact is only as honest as the extractor feeding it, and an extractor that always returns something turns a 0–100 into a number that sorts confidently by noise.
A term and a map area go to SerpAPI, and a business comes back keyed on its place_id. If it has a website, three pages are pulled — home, contact, about — each attempted under two user agents, with sitemap.xml as a fallback when every standard path is refused. Machine-readable markup is read first; regex covers the pages that publish none. Candidate addresses are stripped of the roughly forty patterns that are never a person, then ranked by role prefix, match against the site's own domain, and city and state signals in the local part. The top candidate's domain is checked for a live MX record.
A Postgres function scores what survived, Groq drafts the opening from those fields and nothing else, and where no candidate clears the filter the field stays empty and the score never reports the lead as reachable.
A crawler that always returns an address is a crawler that sometimes returns the wrong one.
Take the first email on the page. It is a defensible rule. Most small business sites publish exactly one address and it is usually the right one; coverage is the entire reason a lead list has value; and a bad address seems cheap — one bounce, move on. Every extra filter is a lead someone could have contacted and now cannot.
Instead the pipeline discards aggressively and abstains when nothing survives. Roughly forty noise patterns removed, candidates ranked before validation, a live MX check on the winner, and a name validator that rejects anything not shaped like two or three capitalised words outside a stopword list. The cost is coverage, and it is real: on [ N ]% of businesses with a website the system returns no contact at all, measured over [ N ]. Some of those have a reachable address it declined to trust.
The second-order effect is what justifies it. The 0–100 score is built on those fields, so an extractor that guessed would produce a score that was confidently wrong rather than merely low — and a list sorted by a confidently wrong score is worse than an unsorted one, because it is acted on.
What the numbers revealed — fill after the first hand-check. The likely candidate: whether the misses concentrate in sites that block both user agents, or spread evenly across sites that simply publish no address. Those two findings point at completely different fixes.
Cross-tenant crawl de-duplication, because ten accounts searching one city currently trigger ten crawls of the same businesses. Reply detection, because the pipeline ends at sent and nothing closes the loop. A second model behind Groq, because a rate limit currently ends a run rather than slowing it. [ Confirm before publishing ] The two-user isolation test from the tenancy doc — the policy is written and the acceptance test is unrun.
Not measured: whether any message was answered, whether an extracted address reaches the decision-maker rather than merely a valid mailbox, and whether the 0–100 score predicts a reply at all. The score is a rubric, not a model fit to outcomes, and nothing yet tests it against one.