# Google Maps Email Extractor & Dead Website Checker (`northvane/google-maps-email-extractor-website-checker`) Actor

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.

- **URL**: https://apify.com/northvane/google-maps-email-extractor-website-checker.md
- **Developed by:** [Northvane](https://apify.com/northvane) (community)
- **Categories:** Lead generation, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.60 / 1,000 business rows

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Google Maps Email Extractor & Dead Website Checker

**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

1. **Search terms + location** – `nail salon`, `roofing contractor`, `dentist` … centred on `San 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.
2. **Websites to check** – paste URLs or bare domains.
3. **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 carries `foundOn` (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), or `NOT_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

1. **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`.
2. **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_FETCHER` so you can send them through a browser-based pass instead of writing them off.
3. **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

1. Run any Maps or directory scraper – for example `compass/crawler-google-places` – and note its run's default **dataset ID**.
2. Run this Actor with that dataset in **Dataset from another Actor**. Leave **Website column name** as `website` (it also tries `url` and `domain` automatically) and set **Business name column** to `title` so the checker can flag pages that never mention the business.
3. 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

```js
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

```json
{
  "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:

```json
{ "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.

# Actor input Schema

## `searchTerms` (type: `array`):

What to search on Google Maps, one per line — e.g. `dentist`, `nail salon`, `roofing contractor`. Each place found is checked and enriched. Needs a location below. Leave empty to only check URLs or a dataset.

## `location` (type: `string`):

City, region or address the search is centred on, e.g. `San Antonio, TX` or `Manchester, UK`. Resolved on Google Maps itself.

## `maxPlacesPerSearch` (type: `integer`):

Google returns results in pages of 20; 60–120 covers a city district, 300+ a whole city. Filtered-out places are not billed.

## `zoom` (type: `integer`):

Map zoom the search is issued at. 13 ≈ a district (default), 11 ≈ a whole city, 15 ≈ a neighbourhood.

## `withoutWebsiteOnly` (type: `boolean`):

Keep only places where Google Maps lists no website — the classic web-design / agency lead list. Free filter.

## `minRating` (type: `number`):

Drop places rated below this (0 = off). Free filter.

## `maxRating` (type: `number`):

Drop places rated above this (0 = off) — e.g. 3.9 to find businesses that need reputation help. Free filter.

## `startUrls` (type: `array`):

Website URLs to check directly, one per line. Bare domains are fine (`example.com`). Use this instead of, or as well as, a Maps search.

## `datasetId` (type: `string`):

ID of a dataset produced by a Google Maps scraper, directory scraper or any Actor. Every row that has a website column is checked and the original row is passed through under `sourceRow`.

## `urlField` (type: `string`):

Which field of the dataset rows holds the website URL. `website` matches Google Maps scrapers; `url` and `domain` are also tried automatically.

## `nameField` (type: `string`):

If the dataset rows carry a business name in this field, the checker flags pages that never mention it (a sign the domain was repurposed).

## `extractContacts` (type: `boolean`):

Turn off for a pure alive/dead check (faster, fewer pages fetched).

## `followContactPages` (type: `boolean`):

When the homepage publishes no address, fetch up to `maxContactPagesPerSite` internal pages that look like contact, about, impressum or legal pages.

## `maxContactPagesPerSite` (type: `integer`):

Upper bound on contact-style pages fetched per website. 2 recovers most published addresses; 0 disables the second pass.

## `checkEmailMx` (type: `boolean`):

Adds `mxValid` / `mxHost` to every email found — a cheap deliverability signal that catches addresses on domains that cannot receive mail. Not a mailbox-level verification.

## `maxConcurrency` (type: `integer`):

How many websites are checked in parallel. 20 is safe for lists of mixed domains.

## `timeoutSecs` (type: `integer`):

A site that does not answer within this window counts as DEAD (timeout). Raise it for slow hosting regions.

## `maxItems` (type: `integer`):

Stop after this many rows. 0 = check everything. Useful for a cheap sample run before the full list.

## `passThroughFields` (type: `boolean`):

Keep every field of the source row under `sourceRow`, so the output is your list, enriched — not a separate list to join back.

## `proxyConfiguration` (type: `object`):

Off by default — plain business websites do not need a proxy and it only adds cost. Turn on Apify datacenter proxy if many rows come back with 403/429 flags.

## Actor input object example

```json
{
  "searchTerms": [
    "nail salon"
  ],
  "location": "San Antonio, TX",
  "maxPlacesPerSearch": 60,
  "zoom": 13,
  "withoutWebsiteOnly": false,
  "minRating": 0,
  "maxRating": 0,
  "startUrls": [
    {
      "url": "https://www.apify.com"
    },
    {
      "url": "https://this-domain-definitely-does-not-exist-4f2a9.com"
    }
  ],
  "urlField": "website",
  "nameField": "title",
  "extractContacts": true,
  "followContactPages": true,
  "maxContactPagesPerSite": 2,
  "checkEmailMx": true,
  "maxConcurrency": 20,
  "timeoutSecs": 20,
  "maxItems": 0,
  "passThroughFields": true,
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

## `businesses` (type: `string`):

One row per business: Google Maps place data, website verdict (alive / parked / for sale / redirected / dead / JS-only / no website), and every published email, phone, social profile and booking link with the page it was found on.

## `leads` (type: `string`):

Just the rows where a published email address was found.

## `summary` (type: `string`):

Counts by website status and contact class, emails found, and how many addresses were quarantined as shared-widget contamination.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "searchTerms": [
        "nail salon"
    ],
    "location": "San Antonio, TX",
    "startUrls": [
        {
            "url": "https://www.apify.com"
        },
        {
            "url": "https://this-domain-definitely-does-not-exist-4f2a9.com"
        }
    ],
    "proxyConfiguration": {
        "useApifyProxy": false
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("northvane/google-maps-email-extractor-website-checker").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "searchTerms": ["nail salon"],
    "location": "San Antonio, TX",
    "startUrls": [
        { "url": "https://www.apify.com" },
        { "url": "https://this-domain-definitely-does-not-exist-4f2a9.com" },
    ],
    "proxyConfiguration": { "useApifyProxy": False },
}

# Run the Actor and wait for it to finish
run = client.actor("northvane/google-maps-email-extractor-website-checker").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "searchTerms": [
    "nail salon"
  ],
  "location": "San Antonio, TX",
  "startUrls": [
    {
      "url": "https://www.apify.com"
    },
    {
      "url": "https://this-domain-definitely-does-not-exist-4f2a9.com"
    }
  ],
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}' |
apify call northvane/google-maps-email-extractor-website-checker --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,northvane/google-maps-email-extractor-website-checker"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/iHmngNKkqLUfbCxPS/builds/LB8aAL09zIdUemnjF/openapi.json
