← Work · 02 / Pipelines · outbound · Oyelabs

Verified or Empty

Turns a map search into contactable leads, or leaves the contact field blank.

[ N ]
businesses crawled
[ N ]%
of extracted emails correct, n = 50 hand-checked
0
cross-tenant rows
Next.js 14 · TypeScript · Supabase · n8n · SerpAPI · Groq · OSRM · Gmail SMTPOyelabs2025[ repo ]

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.

Lead detail panel, score breakdown open The moment that sells it: the score badge beside its breakdown, every signal row showing what was found and what was not. Let the empty rows be as readable as the filled ones. One frame, no annotation.
Seven signals, two of them empty, and a score that accounts for both.
01

The problem

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.

02

What changed

BeforeAfter
ContactAny address found on the pageRanked, then checked for a live MX record
Missing contactClosest guessField left empty
RankingReading the list top to bottom0–100, computed in Postgres
TenancyFiltered in application codeOne policy across eight tables
Long workInside the requestOutside 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.

03

How it works

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.

04

Architecture

  1. Search. A term and a bounded map area go to SerpAPI. Results are keyed on place_id so a repeat search updates rather than duplicates.
  2. Crawl. Three pages per business, two user agents each, sitemap fallback on a hard refusal. The outcome is recorded as a crawl_status rather than thrown away.
  3. Rank. Candidates are noise-filtered, then ordered by role prefix, domain match, and locality. Ordering happens before validation so the check is spent on the best candidate.
  4. Verify. A live MX lookup on the winner's domain. The result is stored as a value, not a boolean, so "no MX" and "check failed" stay distinguishable.
  5. Score. An IMMUTABLE Postgres function computes 0–100 across reachability, depth, legitimacy, and personalization, surfaced through a view so the number cannot drift between client and server.
  6. Draft. Groq writes per channel and tone from the lead's own fields, under a prompt that forbids invention.
The funnel from candidates to accepted address Show the discard as an actual discard: candidates entering, the noise filter and the MX gate visibly dropping them, and the empty-field outcome drawn as a real branch rather than a dead end. Emphasise the width lost between input and output.
Most of what the crawler finds does not survive it.
05

The decision

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.

06

What's next

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.