DEV Community

Aurelix Solutions
Aurelix Solutions

Posted on

Build a local lead list from Google Maps, then add company emails

Google Maps is where most local businesses keep their most up-to-date public details: name, category, address, phone, website, hours. What it doesn't have is email. If you sell to local businesses (roofers, dentists, restaurants, agencies), the usual workflow is to pull a list from Maps and then visit every website to find a contact address. That second step is where the hours go.

This post automates both steps in Python and joins them on the one field they share: the business website.

Disclosure: I'm Aurelix, and I built the two Apify Actors used here. They're paid tools; the Cost section has the prices.

How the two tools fit together

  1. Search Maps by term and area. "dentist" in "Calgary AB". Big cities need splitting: a single Maps search returns a limited number of places, so use neighbourhoods or more specific terms for coverage.
  2. Keep the website. It's the bridge to step 3. Normalise it to a bare domain (https://www.example.com/locations/x becomes example.com) so chains with many locations collapse to one company.
  3. The Contact Page Scraper reads the company's own contact pages. The homepage, contact, about, team and imprint pages. It collects role mailboxes (info@, sales@), phone numbers and company social links, and keeps the page URL each one came from so you can check it.
  4. Join back on domain and write one row per place.

The important design decision is in step 3: the tool uses only what the business published. It never guesses firstname@domain patterns or pings mail servers to "verify" addresses. Guessed emails bounce, hurt your sender reputation, and are a poor basis for B2B outreach under laws like CASL and GDPR.

Step 1: Businesses from Google Maps

The Google Maps Business Scraper Actor takes:

  • searchTerms (required): what you'd type in Maps, e.g. ["dentist", "roofing"].
  • locations: cities or areas, e.g. ["Calgary AB", "Austin, TX"]. Each term is searched in each location.
  • maxResultsPerSearch: up to 300 per term and location (default 60).
  • language: en, fr, es or de, for categories and hours.

One record (lists shortened):

{
  "place_id": "ChIJ243-C381K4gRHMKWCGU86iM",
  "cid": "2587947340511232540",
  "name": "Black+Blue Toronto",
  "category": "Restaurant",
  "categories": ["Restaurant", "Brunch restaurant", "Event venue", "Fine dining restaurant", "Lounge bar"],
  "address": "130 King St W, Toronto, ON M5X 2A2, Canada",
  "street": "130 King St W",
  "neighborhood": "Old Toronto",
  "city": "Toronto",
  "region": "Ontario",
  "postal_code": "M5X 2A2",
  "country_code": "CA",
  "phone": "+16473688283",
  "phone_display": "+1 647-368-8283",
  "website": "https://blackandbluesteakhouse.ca/toronto-home/",
  "rating": 4.8,
  "review_count": 6748,
  "hours": [{"day": "Monday", "hours": "11:30 AM–12 AM"}],
  "latitude": 43.648099599999995,
  "longitude": -79.3831247,
  "timezone": "America/Toronto",
  "google_maps_url": "https://www.google.com/maps/place/?q=place_id:ChIJ243-C381K4gRHMKWCGU86iM",
  "search_term": "restaurant",
  "search_location": "Toronto, ON",
  "source_url": "https://www.google.com/maps/place/?q=place_id:ChIJ243-C381K4gRHMKWCGU86iM",
  "scraped_at": "2026-11-04T14:24:30Z"
}
Enter fullscreen mode Exit fullscreen mode

The website field is what links the two tools.

Step 2: Contacts from company websites

The Contact Details Scraper Actor takes domains (a bare domain or a URL), plus maxPagesPerDomain (1 to 25, default 8), includePeople and maxPeoplePerCompany. It reads the homepage and the contact, about, team, leadership and imprint pages each site links to, in English and French.

One record (lists shortened):

{
  "domain": "lerners.ca",
  "website": "https://lerners.ca/",
  "company_name": "Lerners Lawyers",
  "primary_email": "london@lerners.ca",
  "primary_phone": "+18887421113",
  "address": "85 Dufferin Ave., Box 2335, London, ON N6A 4G4",
  "linkedin_url": "https://www.linkedin.com/company/lerners-llp",
  "facebook_url": "https://www.facebook.com/LernersLLP",
  "x_url": null,
  "instagram_url": "https://www.instagram.com/lernerslawyers",
  "youtube_url": "https://www.youtube.com/@LernersLLP",
  "emails": [
    {"email": "london@lerners.ca", "source_url": "https://lerners.ca/"},
    {"email": "toronto@lerners.ca", "source_url": "https://lerners.ca/"}
  ],
  "phones": [
    {"number": "+18887421113", "display": "(888) 742-1113", "type": "phone", "source_url": "https://lerners.ca/"}
  ],
  "addresses": [
    {"full": "85 Dufferin Ave., Box 2335, London, ON N6A 4G4", "postal_code": "N6A 4G4", "region": "ON", "country": "CA", "source_url": "https://lerners.ca/contact"}
  ],
  "social_profiles": [
    {"network": "facebook", "url": "https://www.facebook.com/LernersLLP", "source_url": "https://lerners.ca/"}
  ],
  "people": [],
  "people_count": 0,
  "pages_crawled": ["https://lerners.ca/", "https://lerners.ca/lawyers"],
  "input": "https://lerners.ca/",
  "source_url": "https://lerners.ca/",
  "scraped_at": "2026-11-04T11:26:08Z"
}
Enter fullscreen mode Exit fullscreen mode

The pipeline

pip install apify-client
Enter fullscreen mode Exit fullscreen mode
import csv
import os
from urllib.parse import urlparse

from apify_client import ApifyClient

client = ApifyClient(os.environ["APIFY_TOKEN"])

MAPS_INPUT = {
    "searchTerms": ["dentist", "roofing"],
    "locations": ["Calgary AB", "Austin, TX"],
    "maxResultsPerSearch": 60,
    "language": "en",
}

COLUMNS = [
    "name", "category", "city", "address", "phone", "website", "rating", "review_count",
    "primary_email", "all_emails", "linkedin_url", "facebook_url", "contact_source",
    "google_maps_url",
]


def run_actor(actor_id, run_input):
    run = client.actor(actor_id).call(run_input=run_input)
    return list(client.dataset(run["defaultDatasetId"]).iterate_items())


def domain_of(url):
    if not url:
        return None
    if "://" not in url:
        url = "https://" + url
    host = (urlparse(url).hostname or "").lower()
    if host.startswith("www."):
        host = host[4:]
    return host or None


def main():
    places = run_actor("aurelix/google-maps-scraper", MAPS_INPUT)
    print(f"{len(places)} places from Google Maps")

    domains = sorted({d for d in (domain_of(p.get("website")) for p in places) if d})
    print(f"{len(domains)} unique websites to check")

    contacts = {}
    if domains:
        contact_input = {
            "domains": domains,
            "maxPagesPerDomain": 8,
            "includePeople": False,
        }
        for rec in run_actor("aurelix/contact-details-scraper", contact_input):
            key = domain_of(rec.get("domain")) or domain_of(rec.get("website"))
            if key:
                contacts[key] = rec

    with open("leads.csv", "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=COLUMNS)
        writer.writeheader()
        for p in places:
            c = contacts.get(domain_of(p.get("website")), {})
            emails = [e.get("email") for e in c.get("emails") or [] if e.get("email")]
            writer.writerow({
                "name": p.get("name"),
                "category": p.get("category"),
                "city": p.get("city"),
                "address": p.get("address"),
                "phone": p.get("phone"),
                "website": p.get("website"),
                "rating": p.get("rating"),
                "review_count": p.get("review_count"),
                "primary_email": c.get("primary_email"),
                "all_emails": "; ".join(emails),
                "linkedin_url": c.get("linkedin_url"),
                "facebook_url": c.get("facebook_url"),
                "contact_source": c.get("source_url"),
                "google_maps_url": p.get("google_maps_url"),
            })

    with_email = sum(
        1 for p in places
        if contacts.get(domain_of(p.get("website")), {}).get("primary_email")
    )
    print(f"leads.csv written; {with_email} of {len(places)} places have a published email")


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Notes on the choices:

  • Deduplicating domains before step 2 matters for chains: ten locations of one franchise share one website, and you only want to crawl and pay for it once.
  • includePeople: False keeps this a company-level list. If you want named staff from team pages, set it to True; each person then appears in people with name, title, seniority, function, work_email, work_phone, linkedin_url, twitter_url and source_url, and is charged separately.
  • Places without a website stay in the CSV with their phone number. For many local trades, the phone is the better channel anyway.
  • Ranking leads. rating and review_count are a decent proxy for how established a business is. Sort on them if you need to work through the list in order.

Limits

  • Maps coverage: up to 300 places per search term and location, in Google's ranking order, and fewer when Google lists fewer. Split large cities into neighbourhoods or use more specific terms.
  • Maps fields: rating, review count, website and hours are empty when the business has none on Maps. Places whose address Google hides have no address or coordinates. Review text and reviewer names are never collected.
  • category is the category Google lists first for your search, which can differ from the business's main category; all of them are in categories.
  • Emails are only what the site publishes. Nothing is guessed or verified. Every address the site publishes is returned; an address on a person's card that spells their name appears inside that person instead.
  • No JavaScript rendering in the contact step. Sites that show contact details only through JavaScript (some Wix, Webflow and app-style sites) return nothing for those details, and those companies aren't charged.
  • robots.txt is always obeyed and can't be turned off, at no more than two requests per second per site.
  • Address parsing is tuned for Canadian and US formats.
  • Use it for B2B only. Contact businesses with relevant, individual messages, identify yourself, honour opt-outs, and follow the anti-spam law that applies to you (CASL, CAN-SPAM, GDPR). Both Actors honour removal requests through a suppression list.

Why these tools

  • Google Maps Business Scraper: $0.85 per 1,000 places, less than half what the leading Google Maps tools on the Apify Store charge.
  • Google Maps Business Scraper: Website emails and socials at $1.50 per 1,000, charged only when found.
  • Google Maps Business Scraper: Many locations in one run and an "only new places since last run" mode. It doesn't cover review text or rating and category filters.
  • Contact Details Scraper: One price per company ($3.00 per 1,000, up to 25 pages each), so a site with many pages costs the same as a small one.
  • Contact Details Scraper: Team members with title, seniority and function, a source URL on every email, phone and person, and a removal-request suppression list.

Cost

At the time of writing, from the README pricing sections:

  • Google Maps Business Scraper: $0.85 per 1,000 places on the free plan, $0.75 on paid Apify plans, plus $0.001 per run. Places already returned in the run, suppressed places and failed searches aren't charged.
  • Contact Details Scraper: $3.00 per 1,000 companies on the free plan, $2.10 on paid plans, plus $0.50 per 1,000 people ($0.35 on paid plans) when people are included, and $0.002 per run. Companies with no published contact data, unreachable sites and sites whose robots.txt disallows the crawl aren't charged.

The example above searches 2 terms in 2 cities at 60 places each: at most 240 places ($0.20), then at most 240 domains ($0.72), so about $0.92 on the free plan in the worst case, less after deduplication.

Links

Top comments (0)