DEV Community

2x lazymac
2x lazymac

Posted on

How to Scrape Verified B2B Email Leads from Google Maps (2026)

How to Scrape Verified B2B Email Leads from Google Maps (2026)

Most B2B lead lists are garbage — bought from brokers, months old, and riddled with generic info@ addresses that go nowhere. Google Maps is different. It's where businesses actively maintain their contact details because they want customers to find them.

This guide walks through pulling verified B2B email leads from Google Maps: scraping business data, validating emails via MX/SMTP checks, and exporting an outreach-ready CSV.


What We're Building

By the end you'll have a pipeline that:

  1. Searches Google Maps for a target category + location
  2. Pulls name, address, phone, website, rating
  3. Extracts email addresses from business websites
  4. Validates each email (MX record exists + SMTP reachable)
  5. Outputs a clean CSV ready for your outreach tool

Step 1: Scrape Google Maps Business Listings

Google Maps is a JavaScript-heavy app — simple requests + BeautifulSoup won't work. You need a Playwright-based scraper that renders the feed and navigates place pages.

Option A: Run it yourself (Python + Playwright)

pip install playwright crawlee
playwright install chromium
Enter fullscreen mode Exit fullscreen mode
import asyncio
from playwright.async_api import async_playwright

async def scrape_maps(query: str, max_results: int = 50):
    async with async_playwright() as pw:
        browser = await pw.chromium.launch(headless=True)
        page = await browser.new_page()

        url = f"https://www.google.com/maps/search/{query.replace(' ', '+')}"
        await page.goto(url, wait_until="networkidle")

        # Dismiss cookie consent if present
        try:
            await page.click('button:has-text("Accept all")', timeout=3000)
        except Exception:
            pass

        results = []
        feed = page.locator('[role="feed"]')

        while len(results) < max_results:
            items = await page.locator('[role="feed"] > div[jsaction]').all()
            for item in items:
                name_el = item.locator('div.qBF1Pd')
                name = await name_el.inner_text() if await name_el.count() else ""
                if name and name not in [r["name"] for r in results]:
                    results.append({"name": name})
                if len(results) >= max_results:
                    break
            # Scroll to load more
            await feed.evaluate("el => el.scrollTop += 1000")
            await page.wait_for_timeout(800)

        await browser.close()
        return results

asyncio.run(scrape_maps("plumbers Chicago", max_results=30))
Enter fullscreen mode Exit fullscreen mode

This approach works for small batches but hits rate limits fast and needs proxy rotation for anything serious.

Option B: Use a managed scraper

For production volumes or when you don't want to manage headless browsers, use the Google Maps Business Scraper on Apify. It handles consent dialogs, scroll pagination, proxy rotation, and extracts phone + website per listing. Pay-per-result pricing means no wasted compute on failed runs.


Step 2: Extract Emails from Business Websites

A Maps listing gives you a website URL. The actual email is usually on the /contact page, in the footer, or the <head> as structured data.

import httpx
from bs4 import BeautifulSoup
import re

EMAIL_PATTERN = re.compile(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}")

def extract_emails_from_url(url: str) -> list[str]:
    if not url:
        return []

    try:
        resp = httpx.get(url, timeout=10, follow_redirects=True, 
                         headers={"User-Agent": "Mozilla/5.0"})
        resp.raise_for_status()
    except Exception:
        return []

    soup = BeautifulSoup(resp.text, "html.parser")

    # Remove script/style noise
    for tag in soup(["script", "style"]):
        tag.decompose()

    text = soup.get_text(separator=" ")
    emails = list(set(EMAIL_PATTERN.findall(text)))

    # Filter junk
    blocked = {"example.com", "sentry.io", "w3.org", "schema.org"}
    emails = [e for e in emails if not any(b in e for b in blocked)]

    return emails[:5]  # Return top 5 per page
Enter fullscreen mode Exit fullscreen mode

For higher yield, also check /contact, /about, and mailto: links explicitly:

def find_contact_emails(base_url: str) -> list[str]:
    found = []
    paths = ["", "/contact", "/contact-us", "/about"]

    for path in paths:
        url = base_url.rstrip("/") + path
        emails = extract_emails_from_url(url)
        found.extend(emails)
        if found:
            break  # Stop on first hit to avoid hammering

    return list(set(found))
Enter fullscreen mode Exit fullscreen mode

Step 3: Validate Emails (MX + SMTP)

Sending to unvalidated addresses tanks deliverability. Two levels of validation:

Level 1 — MX record check (fast, no SMTP needed)

import dns.resolver  # pip install dnspython

def has_mx_record(email: str) -> bool:
    domain = email.split("@")[-1]
    try:
        answers = dns.resolver.resolve(domain, "MX", lifetime=5)
        return len(answers) > 0
    except Exception:
        return False
Enter fullscreen mode Exit fullscreen mode

Level 2 — SMTP reachability check (slower but reliable)

import smtplib
import socket

def smtp_verify(email: str, from_addr: str = "verify@yourdomain.com") -> bool:
    domain = email.split("@")[-1]
    try:
        mx = dns.resolver.resolve(domain, "MX", lifetime=5)
        mx_host = sorted(mx, key=lambda r: r.preference)[0].exchange.to_text()

        with smtplib.SMTP(mx_host, 25, timeout=10) as smtp:
            smtp.helo("yourdomain.com")
            smtp.mail(from_addr)
            code, _ = smtp.rcpt(email)
            return code == 250
    except (smtplib.SMTPException, socket.error, Exception):
        return False
Enter fullscreen mode Exit fullscreen mode

Note: Some mail servers block SMTP probing (250 on all addresses). MX check alone catches the obvious invalids — typos, defunct domains, disposable services.


Step 4: Score Business Fit

Not all leads are equal. A 4.8-star plumber with 200+ reviews who actively maintains their Google profile is a different prospect from a 2.1-star shop with one review from 2019.

def agency_fit_score(business: dict) -> int:
    """Score 0-100 indicating how likely the business needs marketing help."""
    score = 0

    rating = float(business.get("rating", 0))
    review_count = int(business.get("review_count", 0))
    has_website = bool(business.get("website"))
    has_email = bool(business.get("email"))

    # Good rating = established business worth reaching
    if rating >= 4.0:
        score += 30
    elif rating >= 3.0:
        score += 15

    # Review count signals activity
    if review_count >= 50:
        score += 25
    elif review_count >= 10:
        score += 15

    # Has web presence = open to digital
    if has_website:
        score += 20

    # Has findable email = reachable
    if has_email:
        score += 25

    return min(score, 100)
Enter fullscreen mode Exit fullscreen mode

Step 5: Assemble the CSV

import csv
from pathlib import Path

def export_leads(leads: list[dict], output_path: str = "leads.csv") -> None:
    fieldnames = [
        "name", "category", "address", "phone", "website",
        "email", "rating", "review_count", "fit_score",
        "email_verified_mx", "email_verified_smtp"
    ]

    with open(output_path, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
        writer.writeheader()
        writer.writerows(leads)

    print(f"Exported {len(leads)} leads → {output_path}")
Enter fullscreen mode Exit fullscreen mode

Run the full pipeline:

async def main():
    query = "HVAC contractors Boston"

    # 1. Scrape listings
    raw = await scrape_maps(query, max_results=100)

    enriched = []
    for biz in raw:
        # 2. Extract email
        emails = find_contact_emails(biz.get("website", ""))
        email = emails[0] if emails else ""

        # 3. Validate
        mx_ok = has_mx_record(email) if email else False
        smtp_ok = smtp_verify(email) if mx_ok else False

        # 4. Score
        biz["email"] = email
        biz["email_verified_mx"] = mx_ok
        biz["email_verified_smtp"] = smtp_ok
        biz["fit_score"] = agency_fit_score(biz)

        enriched.append(biz)

    # Filter to only verified leads
    verified = [b for b in enriched if b["email_verified_mx"]]
    print(f"{len(verified)}/{len(enriched)} leads have verified emails")

    export_leads(verified)

asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

What You Get

A typical run on a single city + category (say "roofing contractors Dallas") returns:

  • 80-150 raw listings
  • 40-70% have a findable website
  • 20-40% yield an extractable email
  • 60-80% of those pass MX verification

That's roughly 15-50 verified B2B leads per search query, ready for Apollo, Instantly, or a plain smtp cold outreach script.


Skip the Setup

If you'd rather not manage Playwright, proxies, and DNS libraries, the Google Maps Business Scraper on Apify handles the heavy lifting. Input a search query and location, get back structured JSON with business details including extracted emails. Pay per result — no subscription.

For a ready-made CSV of 500 verified local leads in your target niche, there's also a done-for-you option on Gumroad ($39, delivered within 24h).

The full open-source pipeline is on GitHub: google-maps-lead-scraper


Common Pitfalls

Rate limiting: Google Maps throttles repeated scraping from the same IP. Rotate proxies or use Apify's built-in residential proxy pool.

JavaScript-only email rendering: Some sites load email via JS after page load. You need Playwright (not just requests) to catch these. A page.wait_for_selector('a[href^="mailto:"]') before extracting helps.

Catch-all SMTP servers: Some mail servers return 250 for any address. This is hard to detect without sending a real message. MX verification + bounce rate monitoring in your sending tool is the practical floor.

GDPR/CAN-SPAM: In the EU, B2B outreach to business emails found publicly is generally permitted under legitimate interest, but always include an unsubscribe path and don't spam.

Top comments (0)