# Google Maps Lead Generation & Contact Scraper (`fanndev/google-maps-lead-generation-scraper`) Actor

Build B2B lead lists from Google Maps: name, category, address, phone, website, opening hours and GPS coordinates for any search in any city - then auto-enrich each lead with emails and social accounts from its website. Export to JSON, CSV, Excel, NDJSON. No API key.

- **URL**: https://apify.com/fanndev/google-maps-lead-generation-scraper.md
- **Developed by:** [Faisal Ahdan naufal](https://apify.com/fanndev) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.25 / 1,000 results

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

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

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
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.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## 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.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Google Maps Lead Generation & Contact Scraper

Build B2B outreach lists from Google Maps. Search any category in any city and
get the business name, category, address, phone, website, opening hours and GPS
coordinates — then **automatically enrich each lead with the email addresses and
social accounts found on its own website**.

No login, no Google API key, no browser. Pure HTTP.

***

### What you get per lead

| Field | Notes |
| --- | --- |
| `name`, `categories`, `primaryCategory` | Business name and every category Google assigns |
| `phone`, `phoneE164`, `phoneLocal` | `phoneE164` is dial-ready (`+61282967351`) |
| `website`, `websiteDomain` | As listed on Google |
| **`emails`** | Found on the business website — Google never exposes these |
| **`socialProfiles`** | Instagram, Facebook, LinkedIn, YouTube, TikTok, WhatsApp, Telegram, X |
| `address`, `addressParts`, `region` | Single-line plus split components |
| `latitude`, `longitude` | Real GPS coordinates |
| `openingHours` | Opening hours plus `openNow` and `openingHoursComplete` — see the note below |
| `rating`, `reviewsCount`, `reviewsUrl` | Social-proof signals for prioritising outreach |
| `placeId`, `featureId`, `googleMapsUrl` | Stable identifiers for de-duplication |
| `timezone` | IANA zone — useful for deciding when to call |

***

### Quick start

```json
{
  "searchQueries": ["dentist", "dental clinic"],
  "areaName": "jakarta",
  "maxResultsPerQuery": 200,
  "enrichFromWebsite": true,
  "requireEmail": false,
  "exportFormats": ["csv", "excel"]
}
```

#### Targeting a location

A search query always needs a location, otherwise Google picks one from the exit
IP and results are not reproducible. Three ways, in increasing precision:

| Input | Use when |
| --- | --- |
| `areaName: "jakarta"` | Quickest. Built-in cities and countries. |
| `centerLatitude` + `centerLongitude` + `radiusKm` | "Everything within 5 km of this point." |
| `boundingBox: [south, west, north, east]` | Exact area, anywhere on earth. |

Areas are **tiled** into multiple search points for coverage, then every result
is filtered against your exact area using the lead's own GPS coordinates — so a
3 km radius really means 3 km. Verified: a 0.6 km radius returned 20 leads from
40 found, all within 0.489 km.

`tileSizeKm` controls thoroughness: smaller tiles find more businesses and cost
more requests. Left empty, it is chosen from the size of your area.

#### Scraping specific businesses

Skip search entirely and pass `placeUrls` (any Google Maps URL, including
`maps.app.goo.gl` short links) or `placeIds` (a Place ID, a numeric CID, or a raw
feature ID). You can combine this with searching in the same run.

#### A note on opening hours and review counts

Google serves two different richnesses of record and picks between them by
client. **The Apify platform usually receives the reduced one**, which has no
review count and only today's opening hours. Measured on the platform:

| With `fetchPlaceDetails` | Result |
| --- | --- |
| off | `reviewsCount` 0/25, hours 1 day |
| on | `reviewsCount` **15/15**, `reviewsUrl` 15/15, descriptions 13/15, hours still 1 day for 14/15 |

So `fetchPlaceDetails: true` reliably recovers **review counts, review URLs and
descriptions** at one extra request per lead. The **full 7-day schedule often
cannot be recovered from a datacenter IP at all** — that is Google's behaviour,
not a parsing gap, and every lead carries `openingHoursComplete` so you can tell
which schedules are partial. Running from a non-datacenter IP returns the full
week; a residential proxy is not a workaround here (see Anti-bot and proxy).

The run logs a warning when it detects reduced results and the option is off.

#### Narrowing the list

`requirePhone`, `requireWebsite`, `requireEmail` and `minRating` drop leads that
are not worth an outreach slot. `requireEmail` is applied after enrichment, so it
needs `enrichFromWebsite` switched on.

***

### How enrichment works

For each lead with a website: fetch the homepage, extract emails, social handles
and `tel:` numbers. If the homepage has no email, follow **one** same-domain
contact/about link and try there. One hop — this is enrichment, not a crawl.

Two details that separate usable output from noise:

- **Share widgets are filtered out.** Almost every site embeds a Facebook share
  button, so a naive scraper reports that every business has a Facebook page
  called `sharer`. Known widget paths are rejected.
- **Failures are recorded, not thrown.** A dead domain, expired certificate or
  403 lands in `enrichmentStatus` (`ok`, `ok_contact_page`, `no_website`,
  `http_403`, `fetch_failed:…`) and the lead still reaches your dataset with its
  Maps data intact.

Typical yield on a 40-lead run: 24 had a website, 17 gave social accounts, 12
gave an email. Most businesses simply do not publish an email — `requireEmail`
lets you keep only the ones that do.

***

### Anti-bot and proxy

Reconnaissance found **no WAF** on these endpoints: ten TLS fingerprints all
returned 200 from a plain connection.

**Do not use residential proxy here.** This is the opposite of the usual advice,
and it was measured rather than assumed:

| Egress | Result |
| --- | --- |
| No proxy (the default) | works |
| Apify `RESIDENTIAL` | **fails** — Google answers with a consent/CAPTCHA interstitial |

Google polices residential pools far harder than datacenter ranges because those
IPs are widely abused. With no WAF to defeat, the residential IP buys nothing and
costs you the run. The proxy is **off by default**; enable one only to spread
load at high volume, and leave the group list empty so Apify picks a group your
plan actually has — naming a group your account lacks (a free plan has no
`DATACENTER`) fails the run before it starts.

The client impersonates Chrome 150 via `curl_cffi`, warms the Maps cookie jar
once per session, retries with exponential backoff, and rotates TLS profile on
403/429/503.

***

### Limits worth knowing

1. **Results per query are finite.** Google stops serving pages well before the
   true number of businesses in a large area. Coverage comes from tiling (more
   search points), not from paging one search forever. Lower `tileSizeKm` to dig
   deeper into a dense city.
2. **Most businesses have no email anywhere.** Enrichment can only find what the
   website publishes.
3. **Plus codes** are not present in Google's search records and are emitted as
   null rather than guessed.
4. **Category names and opening-hours wording follow `language`.** Set
   `language` and `region` to the market you are targeting.
5. **`reviewsCount` is Google's lifetime total**, not a count of anything this
   actor scraped.

***

### Output shape

Records follow the portfolio envelope — `_input`, `_source`, `_scrapedAt`,
`recordType` — with `_error` / `_errorDetail` on failures. Split a run by
`recordType`: `LEAD` or `ERROR`. An input that fails to resolve produces an
`ERROR` row rather than vanishing, so inputs always reconcile against outputs.

Exports (`exportFormats`) additionally write `leads.csv`, `leads.xlsx`,
`leads.json` and `leads.ndjson` to the key-value store. CSV and Excel columns are
ordered for outreach — name, category, phone, email, website first — and nested
values are flattened (`socialProfiles.instagram`).

***

### Development

```bash
pip install -r requirements.txt
python test_errors.py     # offline: parsing, geo, enrichment, exporters
python test_local.py      # live end-to-end run
```

`test_local.py` reads `_input.json` when present, otherwise uses its built-in
default input. See [CRAWLING\_METHOD.md](CRAWLING_METHOD.md) for the endpoint
reconnaissance and the two parsing traps that produce silently wrong data.

# Actor input Schema

## `searchQueries` (type: `array`):

What to look for, e.g. 'dentist', 'coffee shop', 'law firm'. Each query is run across the location you set below. A location is required when you use queries.

## `areaName` (type: `string`):

Easiest way to set a location: a built-in city or country, e.g. 'jakarta', 'bali', 'singapore', 'london', 'new york', 'indonesia'. For anywhere else, use a centre point with a radius, or a bounding box.

## `centerLatitude` (type: `string`):

Latitude of the centre point. Use with centre longitude and radius.

## `centerLongitude` (type: `string`):

Longitude of the centre point.

## `radiusKm` (type: `string`):

Search this many kilometres around the centre point. Results are also filtered to this exact radius using each lead's real coordinates.

## `boundingBox` (type: `array`):

Most precise option. The box is tiled into multiple search points for coverage, then results are filtered to the box using each lead's real coordinates.

## `tileSizeKm` (type: `integer`):

How far apart the search points are placed inside your area. Smaller = more thorough and more requests. Leave empty to pick automatically from the area size.

## `maxResultsPerQuery` (type: `integer`):

Stop each query after this many unique businesses. Google returns 20 per request.

## `placeUrls` (type: `array`):

Optional. Scrape named businesses directly instead of (or as well as) searching. Paste Google Maps URLs; short links are followed automatically.

## `placeIds` (type: `array`):

Optional. Accepts a Place ID (ChIJ...), a numeric CID, or a raw feature ID (0x...:0x...).

## `enrichFromWebsite` (type: `boolean`):

Visit each lead's website and extract email addresses and social media accounts. This is what makes the leads outreach-ready - Google never exposes an email.

## `followContactPage` (type: `boolean`):

When a homepage has no email, follow one same-domain contact/about link and try there. One extra request per lead, only when needed.

## `enrichmentConcurrency` (type: `integer`):

How many business websites to fetch at once.

## `fetchPlaceDetails` (type: `boolean`):

Google returns two richnesses of record and chooses by client; the Apify platform usually gets the reduced one, which has no review count and only today's opening hours. Switch this on to fetch each lead's detail page: it reliably recovers review counts, review URLs and descriptions. The full 7-day schedule often cannot be recovered from a datacenter IP at all - check openingHoursComplete on each lead. Costs one extra request per lead.

## `requirePhone` (type: `boolean`):

Drop businesses with no phone number. Useful when your outreach is cold-calling rather than email.

## `requireWebsite` (type: `boolean`):

Drop businesses with no website. Also the ones enrichment can actually find an email for, so pair it with enrichment.

## `requireEmail` (type: `boolean`):

Applied after enrichment. Needs 'Enrich from the business website' switched on, otherwise nothing has an email and every lead is dropped.

## `minRating` (type: `string`):

Only keep businesses rated at or above this, e.g. 4.0. Leave empty for no rating filter.

## `exportFormats` (type: `array`):

Also write the leads to the key-value store in these formats. The Apify dataset is always produced regardless.

## `language` (type: `string`):

Google interface language. Affects category names and opening-hours wording.

## `region` (type: `string`):

Two-letter country code biasing Google's results, e.g. US, ID, GB. Set this to the country you are targeting.

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

Off by default, and that is the recommended setting: these Google endpoints have no WAF, so the platform's own IP works and costs nothing. Enable a proxy only to spread load across IPs at high volume, and leave the group list empty so Apify picks a group your plan has. RESIDENTIAL is NOT recommended for Google - it is commonly answered with a consent or CAPTCHA interstitial, which makes searches return nothing.

## Actor input object example

```json
{
  "searchQueries": [
    "dentist"
  ],
  "areaName": "jakarta",
  "maxResultsPerQuery": 100,
  "enrichFromWebsite": true,
  "followContactPage": true,
  "enrichmentConcurrency": 6,
  "fetchPlaceDetails": false,
  "requirePhone": false,
  "requireWebsite": false,
  "requireEmail": false,
  "exportFormats": [],
  "language": "en",
  "region": "US",
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

## `results` (type: `string`):

Every business lead and error record produced by this run.

# 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 = {
    "searchQueries": [
        "dentist"
    ],
    "areaName": "jakarta"
};

// Run the Actor and wait for it to finish
const run = await client.actor("fanndev/google-maps-lead-generation-scraper").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 = {
    "searchQueries": ["dentist"],
    "areaName": "jakarta",
}

# Run the Actor and wait for it to finish
run = client.actor("fanndev/google-maps-lead-generation-scraper").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 '{
  "searchQueries": [
    "dentist"
  ],
  "areaName": "jakarta"
}' |
apify call fanndev/google-maps-lead-generation-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,fanndev/google-maps-lead-generation-scraper"
        }
    }
}
```

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/Hy2H28X3TxBJ6XkLM/builds/oQzcW7KqnJ4u4Iajc/openapi.json
