Skip to main content
Thirdwatchthirdwatch
Business & local data

Diagnose Missing Emails in a Scraped Local Lead List (2026)

Roughly a quarter of local leads come back with no email. Use fetch_status and http_server_header to triage each bucket and re-run only what is fixable.

Jul 28, 2026 · 8 min read · 1,974 words
See the scraper →

Roughly one business in four in a scraped local lead list comes back without an email address, and that is a real outcome rather than a broken run. The Google Maps Lead Enrichment actor stamps every row with fetch_status, http_server_header, tier_used, pages_fetched and contact_pages so you can separate "we never read this site" from "we read it and there is nothing there." Only the first group is worth re-running. This guide turns those fields into a triage table with one defined action per bucket.

Why "the scraper missed some" is not a single problem

The complaint arrives as one sentence: the lead list has holes. Underneath it are at least four unrelated failure modes with completely different economics.

A business whose domain no longer resolves is dead forever — re-running costs money and returns the same dns_error. A business whose edge protection served an empty shell page is recoverable on the second attempt for a few cents. A business whose site loaded perfectly and simply publishes a phone number instead of an email is not a failure at all; it is a routing decision. And a business with no website on its Maps listing never entered the crawl.

Across 118 real small-business websites measured in the US, UK, India and Australia, the actor found at least one email for 71–75% of businesses, at least one contact channel of any kind for ~95%, and hit unreachable sites 5–11% of the time. The email gap is dominated by verticals, not bugs: US home services land near 55–60% because they deliberately push click-to-call, while wellness and design businesses abroad land near 85–90%.

A separate, quieter cause is obfuscated markup. Sites behind Cloudflare's email address obfuscation replace addresses with an encoded hex string; 7 of 8 such sites in our sample returned zero emails to naive extraction. The actor decodes these, which is worth about six percentage points of hit rate on its own — so if a competing tool reported a bigger hole than this one, that mechanism is usually why.

How does this compare to the alternatives?

The difference between approaches is not whether they can fetch a homepage. It is whether the output tells you why a row is empty. A DIY crawler that returns email: null for a dead domain and email: null for a working site with no published address has thrown away the only information that would let you decide what to re-run. Generic scraping APIs return the raw HTML and leave classification to you, which means you rebuild the taxonomy yourself and maintain it as sites change.

Approach Failure classification Reliability Setup time Maintenance
DIY Python crawler You build it: status codes only, no shell-page detection Breaks on redirects, obfuscated markup, non-HTML endpoints 2–4 days Ongoing: link ranking, extraction filters, MX caching
Generic scraping API None — you get HTML back and classify it yourself Good fetch success, no contact extraction at all 1–2 days of glue code You own every parser
Google Maps Lead Enrichment actor 11 fetch_status values plus http_server_header and tier_used per row Discovery, extraction and DNS validation in one run Minutes Handled upstream; transparent pay-per-result

How to diagnose missing emails in 4 steps

Which rows actually failed, and which ones just have no email?

Start by separating reachability from yield. Pull the dataset and bucket every row on fetch_status and the presence of primary_email. This single cross-tab usually resolves the argument on the spot.

from collections import Counter
from apify_client import ApifyClient

client = ApifyClient("<YOUR_APIFY_TOKEN>")
items = list(client.dataset("<YOUR_DATASET_ID>").iterate_items())

buckets = Counter()
for row in items:
    status = row.get("fetch_status")
    if status == "ok":
        if row.get("primary_email"):
            buckets["ok_with_email"] += 1
        elif row.get("primary_phone") or row.get("social_profiles_count"):
            buckets["ok_contactable_no_email"] += 1
        else:
            buckets["ok_but_empty"] += 1
    else:
        buckets[status] += 1

for name, count in buckets.most_common():
    print(f"{name:28} {count:5}  {count / len(items):.1%}")

In a healthy run, ok_with_email plus ok_contactable_no_email should account for roughly 85–90% of rows. If ok_but_empty is large, your queries are pulling businesses with template sites. If the non-ok statuses dominate, keep reading.

What does each fetch_status value mean, and what should I do about it?

Every non-ok status maps to exactly one action. Pair it with http_server_header, which tells you whether an edge network or the origin server refused you.

ACTION = {
    "blocked_shell":  "RE-RUN with retryBlockedWithProxy=true",
    "http_5xx":       "RE-RUN later, transient origin error",
    "timeout":        "RE-RUN once, then drop",
    "connect_error":  "RE-RUN once, then drop",
    "http_4xx":       "INSPECT http_status: 403 re-run, 404/410 drop",
    "dns_error":      "DROP, domain does not resolve",
    "ssl_error":      "DROP, certificate permanently broken",
    "not_html":       "DROP, website points at a file not a page",
    "social_website":  "ROUTE, facebook_url is already populated",
    "no_website":     "ROUTE to phone outreach, business_phone only",
}

for row in items:
    status = row.get("fetch_status")
    if status != "ok":
        print(status, row.get("http_status"), row.get("http_server_header"),
              row.get("website"), "->", ACTION.get(status, "review"))
fetch_status What happened Recoverable Action
ok + no email Site read, no address published anywhere No Route to phone or social
blocked_shell HTTP 200 but an empty shell body Yes Re-run with retry enabled
http_4xx 403 refusal or 404 dead page 403 only Branch on http_status
http_5xx Origin server error Usually Re-run later
timeout / connect_error Host slow or unreachable Sometimes One re-run, then drop
dns_error Domain does not resolve No Drop, business is gone
ssl_error Certificate chain broken No Drop or call instead
not_html URL serves a PDF or binary No Drop
social_website Maps lists a Facebook page as the website N/A Already captured
no_website No website on the listing N/A Phone-only outreach

How do I re-run only the recoverable slice?

Feed the recoverable URLs back in through websites, which skips the Google Maps discovery step entirely so you do not pay to rediscover businesses you already have. Turn on retryBlockedWithProxy for this pass only — it lifts reachability by roughly six percentage points and costs extra bandwidth, which is exactly the trade you want on a small, pre-filtered slice.

RECOVERABLE = {"blocked_shell", "http_5xx", "timeout", "connect_error"}

retry_urls = [
    r["website"] for r in items
    if r.get("fetch_status") in RECOVERABLE and r.get("website")
]
retry_urls += [
    r["website"] for r in items
    if r.get("fetch_status") == "http_4xx" and r.get("http_status") == 403
]

run = client.actor("thirdwatch/google-maps-lead-enrichment").call(run_input={
    "websites": retry_urls,
    "maxResults": len(retry_urls),
    "maxPagesPerSite": 4,
    "concurrency": 8,
    "verifyEmailDomains": True,
    "onlyWithContact": True,
    "retryBlockedWithProxy": True,
})

onlyWithContact: True on the retry pass drops businesses that still yield nothing, so the second run bills only for rows that improved. Check tier_used on the results: http means the plain fetch worked this time, http+tls or http+proxy means the retry path is what rescued the row.

Why does raising crawl depth past four pages not help?

Because the contact information is not deeper — it does not exist. maxPagesPerSite is capped at 4 on purpose. We tested ten pages per site against the businesses that produced no email at four: it recovered 2 of 18, and one of those was a regulator's registration address rather than the business's own inbox, while tripling bandwidth per site. Before you argue with the cap, confirm the budget was actually spent.

starved = [
    r for r in items
    if r.get("fetch_status") == "ok" and not r.get("primary_email")
]

for r in starved[:20]:
    print(r["business_name"],
          "pages:", r.get("pages_fetched"),
          "contact_pages:", len(r.get("contact_pages") or []),
          "bytes:", r.get("bytes_fetched"))

If pages_fetched is 4 and contact_pages already lists a /contact or /impressum URL, the crawler reached the page a human would click and found no address. That is a JavaScript contact form, not a depth problem. If pages_fetched is 1 with an empty contact_pages, the homepage exposed no internal links worth following — usually a single-page site, and again not depth-solvable.

How do I route phone-and-social-only rows instead of discarding them?

Split the dataset into outreach channels rather than filtering to email and throwing the rest away. Roughly 95% of businesses expose at least one channel, so a hard email filter discards a fifth of a list you already paid for.

email_ready, call_ready, social_ready, dead = [], [], [], []

for r in items:
    if r.get("primary_email") and r.get("email_domain_mx_ok"):
        email_ready.append(r)
    elif r.get("primary_phone"):
        call_ready.append(r)
    elif r.get("linkedin_url") or r.get("instagram_url") or r.get("facebook_url"):
        social_ready.append(r)
    else:
        dead.append(r)

print(len(email_ready), len(call_ready), len(social_ready), len(dead))

Note the email_domain_mx_ok guard on the first branch: about 5% of discovered email domains do not resolve at all because they are placeholders shipped with a website theme. Those belong in call_ready, not in your sending tool.

Sample output

Three redacted rows, one per diagnostic bucket. The business fields come from the Maps listing; the contact and diagnostic fields come from reading the website.

[
  {
    "business_name": "Austin Plumbing",
    "website": "https://austinplumbing.com/",
    "final_url": "https://www.austinplumbing.com/",
    "primary_email": "service@austinplumbing.com",
    "primary_email_source": "cfemail",
    "email_domain": "austinplumbing.com",
    "email_is_freemail": false,
    "email_domain_mx_ok": true,
    "email_mx_provider": "microsoft365",
    "primary_phone": "+15129004663",
    "social_profiles_count": 7,
    "contact_pages": ["https://www.austinplumbing.com/contact-us/"],
    "fetch_status": "ok",
    "http_status": 200,
    "http_server_header": "cloudflare",
    "tier_used": "http",
    "pages_fetched": 4,
    "has_contact": true
  },
  {
    "business_name": "Northside HVAC Co",
    "website": "http://northsidehvac.example/",
    "primary_email": null,
    "primary_phone": "+15125550142",
    "instagram_url": "https://www.instagram.com/northsidehvac/",
    "social_profiles_count": 2,
    "contact_pages": ["http://northsidehvac.example/contact/"],
    "fetch_status": "ok",
    "http_status": 200,
    "http_server_header": "LiteSpeed",
    "tier_used": "http",
    "pages_fetched": 4,
    "bytes_fetched": 288104,
    "has_contact": true
  },
  {
    "business_name": "Riverbend Dental",
    "website": "https://riverbenddental.example/",
    "primary_email": null,
    "primary_phone": "+15125550188",
    "fetch_status": "blocked_shell",
    "http_status": 200,
    "http_server_header": "cloudflare",
    "tier_used": "http",
    "pages_fetched": 1,
    "bytes_fetched": 1483,
    "has_contact": true
  }
]

Row one is a clean hit, and primary_email_source: "cfemail" records that the address came out of obfuscated markup — a row a simpler tool would have reported as empty. Row two is the honest miss: four pages read, a real /contact/ page among them, no address published, phone and Instagram still usable. Row three is the recoverable case — bytes_fetched of 1,483 against an HTTP 200 is the shell-page signature, which is why status is decided on payload size rather than status code.

Common pitfalls

Treating every empty email as a retry candidate. Re-running dns_error, ssl_error and not_html rows bills you again for an outcome that cannot change. Filter to the recoverable set first.

Assuming MX validation means deliverability. email_domain_mx_ok is domain-level. It proves the domain accepts mail; it does not prove info@ exists. Mailbox confirmation needs an SMTP conversation this actor deliberately does not attempt.

Filtering out free-mail addresses automatically. About one in six small businesses uses a Gmail or Yahoo address as its genuine business contact. email_is_freemail is a flag, not a verdict.

Raising maxResults on one broad query. A single Maps query saturates near 120 businesses. Past that you are re-paging the same places. Split by suburb or sub-category instead — the city-by-city coverage guide covers the non-overlapping partition.

Benchmarking one vertical against another. A 58% email rate on US plumbers and an 88% rate on Melbourne yoga studios are both normal. Set expectations per vertical before you promise a number to anyone.

Each of these is visible in the output rather than hidden: fetch_status, http_server_header, tier_used, pages_fetched and email_domain_mx_ok exist specifically so a run can be audited after the fact instead of argued about.

Related use cases

Frequently asked questions

Why does my lead scraper find no email addresses for some businesses?

Three distinct causes, and they need different fixes: the website was never read (blocked, dead DNS, broken certificate), the website was read but publishes no email at all, or the business has no website on its Google Maps listing. The fetch_status field tells you which.

Will crawling more pages per site recover the missing emails?

Almost never. Testing ten pages per site on businesses that produced no email at four pages recovered 2 of 18, and one of those was a regulator's address rather than the business's. Yield saturates at four pages while bandwidth triples.

What does fetch_status blocked_shell mean?

The site returned HTTP 200 but the body was an empty shell page rather than real markup, so edge protection served a placeholder instead of content. This is the one bucket that genuinely responds to a re-run with the blocked-site retry enabled.

Should I delete rows that have a phone number but no email?

No. About a quarter of successfully read sites publish only a phone number and a JavaScript contact form, and roughly 95% of businesses expose at least one contact channel. Route those rows to calling or social outreach instead of discarding them.

Does email_domain_mx_ok mean the address is deliverable?

It means the domain has MX records and can receive mail, which filters out theme placeholder domains. It does not prove the specific mailbox exists. Mailbox-level confirmation requires an SMTP conversation and a dedicated verification vendor.

Related

Try it yourself

100 free credits, no credit card.

About 30 real searches. Add the MCP to Claude or Cursor in two minutes.