Google Maps Email Extractor & Dead Website Checker
Pricing
from $1.60 / 1,000 business rows
Google Maps Email Extractor & Dead Website Checker
Search Google Maps by keyword and city, check every business website (alive, parked, for sale, redirected, dead, JS-only) and extract emails, phones, socials and booking links with the page they were found on. No browser, cheap per row. Also takes URL lists and datasets.
Pricing
from $1.60 / 1,000 business rows
Rating
0.0
(0)
Developer
Northvane
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
3 days ago
Last modified
Categories
Share
Search Google Maps by keyword and city, get back businesses with their emails, phones, socials and booking links – and a verdict on whether each website is actually alive. No browser, so it is cheap and fast: 25 nail salons in San Antonio come back in about 13 seconds with 10 published email addresses, 7 businesses that have no website at all, and 2 whose domains now redirect somewhere else.
It also takes a plain list of website URLs, or the dataset from any other Google Maps, Yelp, directory or company-register scraper, and enriches that instead. Same output either way.
Directory listings outlive the businesses in them. In a real 82-company list read by hand, 11% of the websites were dead, parked, for sale, or serving gambling spam on a domain that used to belong to a dental clinic. Nothing upstream had removed them. This Actor is the step that removes them, in a few minutes, for a fraction of a cent per row.
Three ways to feed it
- Search terms + location –
nail salon,roofing contractor,dentist… centred onSan Antonio, TX,Manchester, UK, or any address Google Maps can resolve. Up to 1,000 places per term, paged from Google's own map endpoint. Free filters: only businesses without a website, minimum rating, maximum rating. Places the filters drop are never billed. - Websites to check – paste URLs or bare domains.
- Dataset from another Actor – the dataset ID of any scraper run that has a website column; every original row is passed through under
sourceRow.
Rows from a Maps search carry the place under place: name, address, lat/lng, rating, category, phone (E.164), place ID, open state, Maps URL. Review counts and "permanently closed" are not in Google's list payload, so they are not claimed.
What it checks
For every website it reports one of these statuses:
| Status | Meaning |
|---|---|
NO_WEBSITE | The business is on Google Maps but lists no website – the classic web-design / marketing lead. Phone comes from Maps. |
ALIVE | Site loads and serves real content. |
JS_ONLY | Site is up but the HTML needs JavaScript to render (React/Next/Webflow shells, bot walls). The business exists; contacts may be invisible to a plain fetch. |
PARKED | Registrar or hosting parking page (GoDaddy lander, Sedo, ParkingCrew, Namecheap…). |
FOR_SALE | Domain marketplace page ("this domain is for sale", HugeDomains, Dan, Afternic…). |
PLACEHOLDER | Host default page, "coming soon", "site not published", suspended account, default nginx/Apache/WordPress. |
EXPIRED | Domain or hosting expired notice. |
REDIRECTED_OFF_DOMAIN | Final URL lives on a different domain – the business moved or was acquired. Reported with the target. |
SUSPICIOUS_CONTENT | The domain now serves gambling / pharma / affiliate spam. A classic sign of an expired domain re-registered by someone else. |
DEAD | DNS not found, connection refused, timeout, 404/410 at the root, or the CDN says the origin is gone. |
ERROR | Transient failure (bare 5xx, TLS problem). Worth re-running these rows once. |
NO_URL | The input row had no usable website value. Never silently dropped. |
usable is true for ALIVE, JS_ONLY and NO_WEBSITE – the rows a buyer should keep. Everything else goes in the Dead & doubtful output view.
What it extracts
On usable sites the Actor reads the homepage and, if no address is published there, up to two contact-style pages the site itself links to (/contact, /about, /impressum, /kontakt, /mentions-legales…). It returns:
- Emails – from
mailto:links, visible text (including[at]/[dot]obfuscation), JSON-LD structured data and Cloudflare email-protection payloads, decoded. Every address carriesfoundOn(which page),source(mailto / text / jsonld / cf-decoded),region(footer / header / body),isRole(info@, hello@…),isFreemail(gmail, outlook…),domainMatch(address domain equals the website domain) and an MX check (mxValid,mxHost) so you know the domain can receive mail at all. primaryEmail– the best single address: same-domain, MX-valid, never a quarantined one.contactClass–EMAIL,FORM_ONLY(contact form or booking link but no address),NEITHER(site up, nothing stranger-facing), orNOT_VISIBLE_TO_FETCHER(JS-only site – not the same as "publishes no address").- Phones (from
tel:links first), socials (Facebook, Instagram, LinkedIn, X, YouTube, TikTok, Pinterest, Yelp, GitHub), booking links (Calendly, cal.com, Acuity, HubSpot Meetings, Booksy, Fresha, Vagaro, TidyCal…),hasContactForm, and tech signals (WordPress, Wix, Squarespace, Webflow, Shopify, GoDaddy builder, GoHighLevel…). pagesFetched– every URL requested for the row with its HTTP status. The audit trail, so a row is still explainable a week later.
Three guards other extractors do not have
- Shared-widget quarantine. Directory and partner pages often render an "other partners" module that carries someone else's email. A naive "first email on the page" rule stamps that address onto a dozen unrelated companies. This Actor ignores related/recommended/partner/testimonial modules when attributing an address, and any address that turns up on three or more unrelated sites is quarantined – reported, flagged, never used as
primaryEmail. - Not visible ≠ not published. A React shell, a Cloudflare challenge, or a 403 does not mean the business publishes no contact. Those rows say
NOT_VISIBLE_TO_FETCHERso you can send them through a browser-based pass instead of writing them off. - Blank beats guessed. No country inferred from a TLD, no scheme invented for a malformed link, no address constructed from a name. If the site published nothing, the field is empty.
How to use it
Search Google Maps (the main use)
Put one or more search terms in Google Maps search terms, a city in Location, pick how many places per term, tick Only businesses without a website if that is the list you want, and run. Each place is checked and enriched; results stream into the dataset as they finish. Four output views: Leads, Overview, Contacts (one row per email with provenance) and Dead & doubtful.
As a standalone website check
Paste website URLs into Websites to check (bare domains are fine) and run.
Chained after another Google Maps scraper
- Run any Maps or directory scraper – for example
compass/crawler-google-places– and note its run's default dataset ID. - Run this Actor with that dataset in Dataset from another Actor. Leave Website column name as
website(it also triesurlanddomainautomatically) and set Business name column totitleso the checker can flag pages that never mention the business. - Every original row is passed through under
sourceRow, so the output is your list, enriched – no join needed.
To make it automatic, add this Actor as an Actor integration on the scraper's run: it starts whenever the scraper finishes and receives the dataset ID.
Via API / MCP
const run = await client.actor('<your-username>/google-maps-email-extractor-website-checker').call({searchTerms: ['roofing contractor'],location: 'Denver, CO',maxPlacesPerSearch: 120,withoutWebsiteOnly: false,});const { items } = await client.dataset(run.defaultDatasetId).listItems();
The Actor is exposed through the Apify MCP server, so an AI agent can call it as a tool in a lead-generation pipeline: scrape → check → outreach.
Input
| Field | Default | Notes |
|---|---|---|
searchTerms / location | – | Google Maps search. Location is resolved on Maps itself. |
maxPlacesPerSearch / zoom | 60 / 13 | Places per term; zoom 11 ≈ whole city, 15 ≈ neighbourhood. |
withoutWebsiteOnly / minRating / maxRating | off | Free filters applied before anything is billed. |
startUrls | – | List of websites. Bare domains accepted. |
datasetId | – | Dataset from another Actor; rows with a website column are checked. |
urlField / nameField | website / title | Which columns to read. |
extractContacts | true | Off = pure alive/dead check. |
followContactPages / maxContactPagesPerSite | true / 2 | Second pass when the homepage publishes no address. |
checkEmailMx | true | MX lookup per email domain. |
maxConcurrency / timeoutSecs | 20 / 20 | Throughput and patience. |
maxItems | 0 (all) | Sample a list before running all of it. |
passThroughFields | true | Keep the original row under sourceRow. |
proxyConfiguration | off | Only needed if many rows come back http_403_blocked. |
Output example
{"url": "https://www.example-dental.com/","name": "Example Dental","status": "ALIVE","usable": true,"contactClass": "EMAIL","primaryEmail": "hello@example-dental.com","emails": [{ "email": "hello@example-dental.com", "foundOn": "homepage", "source": "mailto", "region": "footer","isRole": true, "isFreemail": false, "domainMatch": true, "mxValid": true, "mxHost": "aspmx.l.google.com" }],"phones": ["+15125550100"],"socials": { "instagram": "https://www.instagram.com/exampledental" },"bookingLinks": ["https://www.zocdoc.com/practice/example-dental"],"hasContactForm": true,"techSignals": ["wordpress"],"pagesFetched": [{ "url": "https://www.example-dental.com/", "httpStatus": 200, "label": "homepage" }],"flags": [],"checkedAt": "2026-09-05T02:10:41.512Z","sourceRow": { "title": "Example Dental", "website": "example-dental.com", "phone": "(512) 555-0100" }}
A dead row looks like this:
{ "url": "https://fluxfortify.com/", "status": "PARKED", "usable": false, "statusReason": "matched window.location(?:.href)?\\s*=\\s*[\"']/lander", "contactClass": "DEAD" }
Three output views are provided in the Console: Overview (one row per site), Contacts (one row per address, with provenance), Dead & doubtful (what to drop).
Pricing
Pay per result: you are charged per business row in the output, nothing else – places dropped by your filters are free. A 1,000-row Maps export costs about the price of a coffee to clean, and typically returns 100–150 rows you should not have paid to contact. Speed is around 3 websites per second at the default concurrency, so 1,000 rows finish in 5–6 minutes.
FAQ
Is the MX check an email verification? No. It confirms the address's domain can receive mail, which catches dead domains and typos cheaply. It does not confirm the mailbox exists. Chain a dedicated verifier on the EMAIL rows if you need that.
Why is a site JS_ONLY when it looks fine in my browser? Your browser runs JavaScript; this Actor deliberately does not, which is what makes it cheap. The status tells you the business is there and the contacts need a browser-based pass.
Why did it find info@ but not the owner's personal address? The second pass stops at the first page that publishes any address, to keep cost down. Raise maxContactPagesPerSite if you want deeper coverage.
What about sites behind Cloudflare? Cloudflare-obfuscated addresses (data-cfemail) are decoded without a browser. Cloudflare challenge pages come back as JS_ONLY with an http_403_blocked flag.
Does it respect robots.txt? It fetches one to three public pages per site with a normal browser user-agent and does not crawl. Use it on lists you have the right to contact.
Will Google block it? Single searches from Apify's datacenter IPs work without a proxy today. For sustained high volume (thousands of places per run, many runs a day) turn on Apify residential proxy in proxyConfiguration; the cost is about $0.08 per 1,000 places. If a search is blocked the run logs it and still processes any URLs or dataset rows you gave it.
Related
Works best chained after a Google Maps scraper, a Yelp scraper, or any company-register Actor that returns a website column, and before an email-verification or outreach Actor.