# Google Maps Scraper With Lead & Contact Enrichment (`api-empire/google-maps-scraper`) Actor

Google Maps Scraper: Extract business listings with names, addresses, phone numbers, websites, emails, ratings, reviews, categories, and available contact details. Enrich local business leads for prospecting, market research, competitor analysis, and targeted outreach.

- **URL**: https://apify.com/api-empire/google-maps-scraper.md
- **Developed by:** [API Empire](https://apify.com/api-empire) (community)
- **Categories:** Automation, Lead generation, Social media
- **Stats:** 62 total users, 26 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $4.99 / 1,000 results

This Actor is paid per event and usage. You are charged both the fixed price for specific events and for Apify platform usage.

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 Scraper — Places, Emails, Phones and Social Profiles

Google Maps Scraper With Lead & Contact Enrichment turns a list of cities and search terms into outreach-ready business rows. Each row carries the Google Maps place record — name, address parts, rating, review count, category tags, coordinates, opening hours, `place_id` — plus a contact block harvested from the business's own website: emails, `tel:` phone numbers, and profile URLs for seven social platforms. Every row is typed, normalized JSON with the same 29 keys, written into the dataset live. No HTML, no selectors, no parsing. Filter to businesses that have a website — or only to the ones that do not.

### 📇 Where each contact field actually comes from

This is the part most Google Maps lead scrapers gloss over, so it goes first. There are **two completely different data sources** feeding one row, and knowing which is which decides whether this Actor fits your workflow.

| Output field | Source | Exact extraction rule |
| ----- | ----- | ----- |
| `name`, `tags`, `notes`, `avg_rating`, `total_reviews`, `street_address`, `city`, `state`, `zip`, `country_code`, `full_address`, `lat`, `long`, `place_id`, `hours` | **Google Maps listing** | Read straight out of the Maps search payload for that place. No second request. |
| `phone` | **Google Maps listing** | The number Google publishes on the listing. Present whether or not the business has a website. |
| `website` | **Google Maps listing** | The site Google has on file, with a `/url` fragment removed and everything after the first `?` dropped — a bare origin/path, not a tracking link. |
| `has_website` | **Derived** | `true` when `website` is a non-empty string. This is the flag `websiteFilter` acts on. |
| `emails` | **Business website crawl** | Every address matching `[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,24}` in the page text, in `href` / `content` / `data-href` attributes and in the raw HTML, plus every `mailto:` target. Then filtered (rules below), lowercased, deduplicated, sorted. |
| `site_phones` | **Business website crawl** | **Only** from `tel:` links — `tel:([+0-9().\-\s]{5,30})`, whitespace removed. The high-confidence phone list. |
| `phones_uncertain` | **Business website crawl** | A loose phone-shaped regex over the visible page text, minus anything already in `site_phones`. Deliberately separate because it is noisy. |
| `linkedins`, `facebooks`, `instagrams`, `twitters`, `youtubes`, `tiktoks`, `pinterests` | **Business website crawl** | Per-platform URL regexes over text, attributes and raw HTML. Each match normalised to an absolute `https://` URL, trailing punctuation trimmed, deduplicated, sorted. |
| `success` | **Constant** | Always `true`. The Actor never writes a failure row, so this is a schema-compatibility field, not a quality signal. |

#### What the website crawl actually does

When `scrapeContacts` is on and a place has a website, the Actor runs a **shallow crawl of that site — at most 5 pages**:

1. Fetch and parse the homepage. The URL gets an `https://` prefix if the Maps listing stored it without a scheme.
2. Read every `<a href>` on that page, skipping `mailto:`, `tel:`, `javascript:` and `data:`. Resolve the rest to absolute URLs, drop anything on a different host, and deduplicate ignoring query string and fragment.
3. Score each surviving link by how many of these hints appear in the lowercased URL: **contact, kontakt, about, impressum, imprint, team, connect, reach, support, info, legal, privacy**. Links scoring zero are discarded — the crawl never wanders into a blog or a product catalogue.
4. Fetch the **top 4** by score, parse each, and merge everything into one deduplicated, sorted set per field.

Each page gets a 20-second timeout and goes through the escalating proxy ladder described below. If the homepage cannot be fetched at all, the row is still saved — with every contact array empty.

#### How much of your list gets a contact

**It depends entirely on whether the business publishes it.** No hit-rate is quoted anywhere in this README, because the only honest answer is a chain of conditions, and every one of them is a property of the target site, not of the scraper:

- The place must have a website on its Maps listing. Rows where `has_website` is `false` are **never** enriched — there is nothing to crawl.
- The site must be reachable. A homepage that blocks every rung of the proxy ladder yields empty arrays, indistinguishable in the output from a site that simply has no contact details.
- The address, phone or social link must exist **in the HTML** of the homepage or one of the four contact-ish pages. Emails injected by JavaScript after load, rendered as an image, hidden behind a contact form, or scrambled with Cloudflare email obfuscation are not recovered — the Actor reads the HTML it is served, it does not run a browser. A site whose only contact route is a form returns no `emails` at all, which is common and completely normal.

By design the Actor never guesses an address from a pattern like `firstname@domain.com`, never validates deliverability, and never invents a value to fill a column. A missing contact comes back as an empty array, so `emails.length > 0` is a truthful filter.

#### The junk filter on emails

Scraping raw HTML for `@` signs produces garbage, so `emails` is filtered before it reaches you. An address is rejected when:

- It ends in an asset extension — `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.svg`, `.bmp`, `.tif`, `.tiff`, `.css`, `.js`, `.woff`, `.woff2`.
- Its domain ends with one of 21 known CDN, site-builder, error-tracking or placeholder hosts — among them `wixpress.com`, `parastorage.com`, `sentry.io`, `googleapis.com`, `gstatic.com`, `cloudfront.net`, `jsdelivr.net`, `unpkg.com`, `w3.org`, `schema.org`, `example.com`, `yourdomain.com`, `wix.com`, `godaddy.com` and `squarespace.com`.
- Its local part is a retina asset marker (`2x`, `3x`), a hex hash of 16 or more characters (Sentry DSNs), longer than 64 characters, or contains whitespace.

The pattern itself disallows `/` in the local part and requires an alphabetic TLD, so CDN version strings like `react@18.3.1` and coordinate links like `@40.70` never become fake leads.

#### ⚠️ Read this before you use `phones_uncertain`

The two phone columns are different confidence tiers, kept apart on purpose. `site_phones` comes only from `tel:` hyperlinks — a business that wrote `tel:+12125550100` into its markup meant that to be a phone number, so treat it as reliable. `phones_uncertain` is a wide net over page text whose pattern matches **any unbroken run of 6 to 15 digits**, so order numbers, postcodes, prices, VAT numbers and dates land in it. It exists because on some sites it is the only place a number appears at all — but it is a research field, not a dial list.

#### Social profiles: arrays, not single links

Every platform key is an **array**, because a homepage footer routinely links a company page and a founder's personal profile. All matches are kept. Each regex also filters out platform noise: Twitter/X matches are dropped when preceded by `business.`, `help.`, `about.`, `blog.`, `careers.`, `developer.` or `ads.`, and reserved paths such as `login`, `intent`, `share`, `hashtag` and `compose/post` are excluded; Instagram skips `explore`, `_n` and `_u`; Facebook skips its own platform paths and accepts `profile.php?id=…`, `pages/…`, or a 5–51 character vanity name. YouTube matches `youtu.be`, `/watch`, `/embed`, `/user`, `/c`, `/channel` and `/@handle`. TikTok matches `/@handle`, `/video/` and `/trending`. Pinterest matches `/pin/<id>` and board or user paths.

### What is Google Maps Scraper With Lead & Contact Enrichment?

Google Maps Scraper With Lead & Contact Enrichment is an Apify Actor that searches Google Maps by location and keyword — or from Google Maps search URLs you paste in — and returns one flat dataset row per unique business. On top of the standard place record it optionally crawls each business's own website and attaches emails, phone numbers and social profile URLs to the same row, so discovery and enrichment happen in a single run rather than two.

**No Google account, no Google Cloud project, and no API key are involved.** The Actor sends anonymous requests to the public Google Maps search surface and to public business websites. The only credential in play is your Apify token, and only if you start the run through the API instead of the Console.

- Scrape **Google Maps places** — name, address parts, full address, rating, review count, category tags, listing phone, coordinates, `place_id`
- Scrape **opening hours** as structured per-day records, not a single blob of text
- Enrich each business with **website contacts** — `emails`, `site_phones` from `tel:` links, and a separate low-confidence `phones_uncertain` list
- Enrich each business with **social profiles** across seven platforms, with an optional platform allow-list
- Filter by **website presence** before rows are saved — including a `withoutWebsite` prospect mode
- Export as JSON, CSV or Excel. No proxy management, no parsing

### What data does Google Maps Scraper With Lead & Contact Enrichment collect?

Five field groups arrive on one flat row: the Google Maps place, its address and coordinates, its structured opening hours, the contact block crawled from the business website, and the social profile block from that same crawl.

| Data Type | Key Fields | JSON Field Names |
| ----- | ----- | ----- |
| Google Maps place | Business name, category tags, Google's short listing note, rating and review volume, listing phone, website, Google's place identifier | `name`, `tags`, `notes`, `avg_rating`, `total_reviews`, `phone`, `website`, `has_website`, `place_id`, `success` |
| Location and address | Street, city, state, postcode, country code, a joined address string, and map coordinates | `street_address`, `city`, `state`, `zip`, `country_code`, `full_address`, `lat`, `long` |
| Opening hours | One record per day block, with the human-readable range and the 24-hour open/close values Google encodes alongside it | `hours` → `day`, `hours`, `open24Hour`, `close24Hour` |
| Website contacts | Email addresses, `tel:`-link phone numbers, and loosely matched phone-shaped strings kept separately | `emails`, `site_phones`, `phones_uncertain` |
| Social profiles | Profile and page URLs found on the business website, one array per platform | `linkedins`, `facebooks`, `instagrams`, `twitters`, `youtubes`, `tiktoks`, `pinterests` |

> **Two naming traps.** `phone` and `site_phones` are not the same thing: `phone` is what **Google Maps** publishes on the listing, `site_phones` is what the **business's own website** links with `tel:`. They frequently differ — a chain's Maps listing may carry a branch line while the site links head office. Keep both. And `hours` is an array whose entries themselves contain a key called `hours` (the human-readable range), so a naive flatten collides; address the inner value as `hours[i].hours`.

#### Need more lead data for the same outreach list?

Google Maps covers businesses with a physical presence. If you are assembling a contact database across channels, **LinkedIn Phone Email Finder** works the professional side of the same companies, **Facebook Search Scraper WhatsApp Contact Leads** picks up messaging-first markets, and **Instagram Local Phone Leads Scraper** covers storefronts that run on social instead of a site. All of them return contact fields in the same style, so one normalisation step covers the set.

### How does this Actor differ from the official Google Places API?

Google's Places API is a paid, key-authenticated service: you create a Google Cloud project, enable billing, and request documented fields through documented endpoints. This Actor requires none of that — it reads the same public Maps search surface a browser does, and then goes somewhere the Places API deliberately does not: the business's own website.

| Feature | Google Places API | This Actor |
| ----- | ----- | ----- |
| Access | Google Cloud project, API key, billing account enabled | ✅ Apify account — open the Actor, press Start |
| Search input | The request parameters Google documents for its text and nearby search endpoints | Arrays of `locations` × `keywords`, plus Google Maps `/search/` URLs, in one run |
| Business email addresses | Not among the fields Google's Places API documents | ✅ `emails`, crawled from the business's own website |
| Social profile links | Not among the fields Google's Places API documents | ✅ Seven per-platform arrays, with an allow-list |
| Businesses **without** a website | The response carries a website field when Google has one, but there is no server-side "has no website" filter in the request | ✅ `websiteFilter: "withoutWebsite"` — applied before a row is ever saved or charged |
| Quotas, pricing and caching terms | Defined in Google Maps Platform's own documentation and terms — read them for your project | Rows land in your own Apify dataset; you export and keep them |
| Output shape | Google's documented response schema, per requested field mask | One flat row with the same 29 keys every time |

Use the Places API when you need Google's own guarantees, an officially supported contract, and data you intend to display inside a Google-Maps-based product. Use this Actor when what you actually need is a prospect list — including the email addresses and social profiles Google never returns, and the no-website segment Google gives you no way to ask for.

### Why do developers and teams scrape Google Maps for leads?

A Google Maps search result set is a local business directory with reachability attached. Four groups get very different value out of turning it into rows.

#### For lead generation and sales teams

Set `locations` to your territory, `keywords` to the verticals you sell into and `websiteFilter` to `withWebsite`, and every saved row arrives with `emails`, `site_phones` and the social arrays already attached — no second enrichment tool, no second bill. Because `phone` (from Maps) and `site_phones` (from `tel:` links) are separate columns, a dialler list and an email list fall out of the same run without deduplication guesswork. `avg_rating` and `total_reviews` are your prioritisation proxy; `tags` gives you the category to personalise the opening line.

#### For web design, SEO and local marketing agencies

`websiteFilter: "withoutWebsite"` is the reason this Actor exists in its current shape. Set it and the dataset contains **only** businesses whose Google Maps listing has no website on file — a qualified prospect list for anyone selling websites, local SEO or Google Business Profile management. Because the filter runs before the row is written, those runs are cheaper and the export needs no cleanup. The trade-off is inherent: no website means no email enrichment, so you work from `phone`, `full_address`, `avg_rating` and `total_reviews` and reach them by phone or in person.

#### For market researchers and location analysts

Everything returned is public business information — trading name, address, category, rating, review count, coordinates — usable for local market studies without touching gated or personal content. Sweep the same `keywords` across a list of `locations` and you get comparable business-density and rating distributions per city, keyed on a stable `place_id`. Re-run on an Apify schedule and diff `avg_rating` and `total_reviews` per `place_id` to track how a market moves. `has_website` alone is a useful digital-maturity indicator across a category.

#### For AI engineers and developers building data products

Every row is typed JSON with fixed keys, so nothing sits between the Actor and a model. Index `name`, `tags`, `notes`, `full_address` and `hours` into a vector store and an agent can answer "which dentists near this address are open on Saturday" against live data. For a product pipeline, `has_website` and `emails.length` are clean boolean gates: route enriched rows to a CRM sync, no-website rows to a phone queue. Call the Actor over the Apify API from any language, or trigger downstream work with an Apify webhook the moment a run finishes.

### How to scrape Google Maps leads (step by step)

This Actor runs on Apify. Start it from the **Apify Console**, or call it through the **Apify API** with your Apify token. There is no separate signup, no API key of its own, and no other run surface.

1. Open **Google Maps Scraper With Lead & Contact Enrichment** on the Apify Store and click **Try for free**.
2. Fill in **🔎 What to find (keywords)** (`keywords`) — this is the only required input. Add one entry per vertical: `dentists`, `plumbers`, `coworking`.
3. Fill in **📍 Cities, regions, or neighborhoods** (`locations`), or paste Google Maps search links into **🔗 Optional: Google Maps links** (`urls`). You need at least one of the two — with neither, the run ends immediately.
4. Set **🎯 How many places to collect** (`maxResults`), pick a **🌐 Website-presence filter** (`websiteFilter`), and leave **📧 Enrich with website contacts** (`scrapeContacts`) on unless you only want the Maps fields.
5. Click **Start**. Rows appear in the output table live as they are found. Export the dataset as JSON, CSV or Excel, or read it through the Apify API.

Every location is combined with every keyword, so 3 locations × 4 keywords is 12 searches in one run, all deduplicated against a single shared `place_id` set and all counting toward one `maxResults` budget.

#### What to do when Google changes its structure

Nothing on your side. The Actor reads Google Maps' internal search payload rather than rendered page markup, so a visual redesign of the Maps interface generally does not touch it, and each field is read through a bounded index walk that returns `null` instead of raising when Google nests a value differently. The scraper is maintained and the output keys stay the same, so your integrations keep working.

### ⬇️ Input

Eight parameters, read directly from the Actor's input schema. Only `keywords` is required by the schema — but in practice you also need at least one `locations` entry or one `urls` entry, or the run has nothing to search.

| Parameter | Required | Type | Description | Example Value |
| ----- | ----- | ----- | ----- | ----- |
| `keywords` | ✅ Yes | array of strings | What to find — the business types or place descriptions you care about. Every keyword is combined with every location, so adding keywords multiplies your searches. Blank and whitespace-only entries are dropped. Editor is a string list; the Console form prefills `["coffee shops"]`. No default value. | `["dentists", "orthodontists"]` |
| `locations` | No | array of strings | Cities, states, districts, neighbourhoods — anything you would type into a map app. Each is geocoded to a viewport before searching. Blank entries are dropped. Editor is a string list; the Console form prefills `["New York"]`. No default value. Optional only if you supply `urls`. | `["Austin, Texas", "Round Rock, Texas"]` |
| `urls` | No | array | Google Maps search links, if you already have the exact view you want. Accepts plain strings or `{"url": "…"}` objects. Editor is a string list; prefilled empty. No default value. Processed **after** the locations × keywords sweep, and only if `maxResults` has not already been reached. | `["https://www.google.com/maps/search/coffee+shops/@30.2672,-97.7431,13z"]` |
| `maxResults` | No | integer | Total unique places saved across all locations, keywords and URLs combined — duplicates are merged, not counted twice. Minimum `1`, maximum `10000`, **default `20`**. | `200` |
| `scrapeContacts` | No | boolean | On: shallow-crawl each business website (homepage plus up to 4 contact/about/impressum pages) for emails, `tel:` phones and social links. Off: Google Maps fields only, and the contact arrays come back empty. **Default `true`**. | `true` |
| `websiteFilter` | No | string (select) | Website-presence filter, applied before a row is saved. `allPlaces` — no filter. `withWebsite` — only businesses that have a site, so every row is enrichable. `withoutWebsite` — only businesses with no site on their listing. **Default `"allPlaces"`**. Any value outside these three is silently treated as `allPlaces`. | `"withoutWebsite"` |
| `scrapeSocialMediaProfiles` | No | object (JSON) | Allow-list of which social platforms to emit. Set the platforms you want to `true`. Recognised keys: `linkedins`, `facebooks`, `instagrams`, `twitters`, `youtubes`, `tiktoks`, `pinterests` — the singular forms `linkedin`, `facebook`, `instagram`, `twitter`, `x`, `youtube`, `tiktok`, `pinterest` are accepted as aliases. **Default `{}`** — an empty object emits every platform found. Non-allowed platforms are returned as empty arrays, never removed as keys. | `{"linkedins": true, "instagrams": true}` |
| `proxyConfiguration` | No | object (proxy editor) | Apify Proxy settings. Prefilled `{"useApifyProxy": false}` — the Actor starts on a direct connection and escalates automatically if Google pushes back. Turn it on for heavier runs or restrictive networks. When on, the Actor starts on residential if `RESIDENTIAL` is listed in `apifyProxyGroups`, otherwise on datacenter. | `{"useApifyProxy": true, "apifyProxyGroups": ["RESIDENTIAL"]}` |

#### Behaviour worth knowing before you configure it

- **`websiteFilter` never errors.** A typo like `"withwebsite"` is silently coerced to `allPlaces` and the run proceeds unfiltered. Check the value, the run log states the filter it settled on.
- **Setting every platform to `false` is the same as leaving the object empty.** The allow-list is built from keys whose value is truthy; if that set comes out empty, the Actor falls back to emitting all platforms. To suppress socials entirely, turn `scrapeContacts` off — there is no "no socials" setting.
- **`scrapeContacts: true` with `websiteFilter: "withoutWebsite"` enriches nothing.** The two are mutually exclusive by definition: rows in that mode have no website to crawl, so the website crawler is not even initialised for the run. Contact arrays come back empty.
- **`urls` only run if budget is left.** The locations × keywords sweep goes first. If it fills `maxResults`, your URLs are never opened. Put URLs in a run of their own if they matter.
- **Use `/search/` Maps URLs.** A link that only carries `@lat,lng` coordinates has no query for the Actor to extract, and the raw URL string ends up being used as the search term — which finds nothing. A URL of the form `https://www.google.com/maps/search/<query>/@lat,lng,zoom` gives the Actor both the query and the viewport.
- **Geocoding takes the first match.** Locations are resolved through OpenStreetMap's Nominatim service with `limit=1`. Ambiguous names resolve to whichever entry ranks first, so write `Springfield, Illinois` rather than `Springfield`. The run log prints the resolved centre coordinates and country — check them on the first run of a new territory.

#### Example input

```json
{
  "locations": ["Austin, Texas", "Round Rock, Texas"],
  "keywords": ["dentists", "orthodontists"],
  "urls": [],
  "maxResults": 200,
  "scrapeContacts": true,
  "websiteFilter": "withWebsite",
  "scrapeSocialMediaProfiles": {
    "linkedins": true,
    "facebooks": true,
    "instagrams": true
  },
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": ["RESIDENTIAL"]
  }
}
```

#### No-website prospect list

```json
{
  "locations": ["Manchester, UK"],
  "keywords": ["plumbers", "electricians", "roofers"],
  "maxResults": 500,
  "scrapeContacts": false,
  "websiteFilter": "withoutWebsite",
  "proxyConfiguration": { "useApifyProxy": true }
}
```

**Common pitfall:** leaving `maxResults` at its default of `20` while supplying several locations and keywords. The budget is a **total across the whole run**, not a per-search figure — 3 locations × 4 keywords with `maxResults: 20` stops after 20 rows, most likely inside the very first search. Size the budget to the number of combinations you asked for.

### ⬆️ Output

Every place is one flat dataset row with the same **29 keys**, run after run. Contact and social fields are always present as arrays — empty when nothing was found, never missing — so downstream code never needs a presence check. Google Maps fields return `null` when Google does not publish the value. Export as JSON, CSV or Excel, or read the dataset through the Apify API.

The dataset contains **only place rows**. The Actor pushes no summary, accounting or error records, so there is no filter expression you need to strip anything out — every item you see is a business. Run-level information (areas resolved, tiles completed, enrichment counts, failures) goes to the run log. A merged copy of everything collected is also written to the run's default Key-Value Store as **`maps.json`**.

Useful filters on the export: `has_website == true` for enrichable businesses, `emails.length > 0` for rows that actually produced an address, `site_phones.length > 0` for high-confidence dial targets.

#### Scraped place with website enrichment

```json
{
  "street_address": "1201 W 6th St",
  "city": "Austin",
  "zip": "78703",
  "state": "TX",
  "country_code": "US",
  "full_address": "1201 W 6th St Austin TX 78703 US",
  "website": "https://westsixthdental.com/",
  "avg_rating": 4.8,
  "total_reviews": 412,
  "name": "West Sixth Dental",
  "tags": ["Dentist", "Cosmetic dentist", "Dental clinic"],
  "notes": "Modern dental practice offering same-day crowns.",
  "place_id": "0x8644b50e1f0a2c9d:0x7f3b21c4a95e8d10",
  "phone": "+1 512-555-0142",
  "lat": 30.271129,
  "long": -97.756418,
  "hours": [
    { "day": "Monday", "hours": "8 AM-5 PM", "open24Hour": "0800", "close24Hour": "1700" },
    { "day": "Tuesday", "hours": "8 AM-5 PM", "open24Hour": "0800", "close24Hour": "1700" },
    { "day": "Friday", "hours": "8 AM-2 PM", "open24Hour": "0800", "close24Hour": "1400" },
    { "day": "Saturday", "hours": "Closed", "open24Hour": null, "close24Hour": null }
  ],
  "has_website": true,
  "emails": ["hello@westsixthdental.com", "scheduling@westsixthdental.com"],
  "site_phones": ["+15125550142", "+15125550188"],
  "phones_uncertain": ["512 555 0142", "78703"],
  "linkedins": ["https://linkedin.com/company/west-sixth-dental"],
  "facebooks": ["https://facebook.com/westsixthdental"],
  "instagrams": ["https://instagram.com/westsixthdental"],
  "twitters": [],
  "youtubes": ["https://youtube.com/@westsixthdental"],
  "tiktoks": [],
  "pinterests": [],
  "success": true
}
```

Note `phones_uncertain` in that example: `78703` is the postcode, matched because it is a run of digits in the page text. That is exactly the noise the separate column exists to isolate.

#### Scraped place with no website — the `withoutWebsite` prospect row

Same 29 keys, same types. The Maps half of the row is unchanged — name, address parts, `phone`, `avg_rating`, `total_reviews`, `place_id`, coordinates and `hours` are all populated exactly as above. Only the website half differs, and this is the row `websiteFilter: "withoutWebsite"` keeps and `withWebsite` drops:

```json
{
  "name": "South Austin Plumbing Co",
  "phone": "+1 512-555-0119",
  "full_address": "4402 Menchaca Rd Austin TX 78745 US",
  "website": "",
  "has_website": false,
  "emails": [],
  "site_phones": [],
  "phones_uncertain": [],
  "linkedins": [],
  "facebooks": [],
  "instagrams": [],
  "twitters": [],
  "youtubes": [],
  "tiktoks": [],
  "pinterests": [],
  "success": true
}
```

#### Field notes that will save you a debugging session

- **`full_address` is space-joined, not comma-joined.** It is built as `street city state zip country_code` with only the outer whitespace trimmed, so a missing middle component leaves a double space inside the string. For clean formatting, compose it yourself from the five parts — all of them are on the row.
- **`website` has its query string removed.** Everything after the first `?` is dropped, which kills tracking parameters but also loses a listing URL that genuinely depends on a query string.
- **`avg_rating` and `total_reviews` are both `null`** when Google publishes no rating. New listings frequently have neither.
- **`hours` can be an empty array** when Google publishes no opening hours. Individual entries can carry `"hours": null` when a day block has no readable range; `open24Hour` and `close24Hour` are Google's own encoded values mirroring the readable string.
- **`place_id` is Google's `0x…:0x…` hexadecimal identifier** and the deduplication key for the run. It is stable across runs, so it is the right join key for change tracking.
- **`tags` and `notes` can be `null`.** `tags` is Google's category list; `notes` is the short descriptive line Google sometimes attaches, absent far more often than present.

### How does Google Maps Scraper With Lead & Contact Enrichment compare to other Google Maps scrapers?

Google Maps is the most crowded category on the Apify Store, so the useful comparison is not "does it scrape Maps" — they all do. It is what happens to the row after Google hands it over.

| Feature | This Actor | Generic alternative |
| ----- | ----- | ----- |
| Businesses **without** a website | ✅ `websiteFilter: "withoutWebsite"` produces a no-website prospect list, filtered before rows are saved | Returns everything; you filter for empty website columns in a spreadsheet afterwards, and pay for the rows you throw away |
| Search input shape | ✅ Arrays of `locations` × `keywords` combined into one deduplicated run, plus Maps URLs | Commonly a single search-query string, or one location string per run |
| Phone confidence | ✅ Three separate columns — `phone` (Maps), `site_phones` (`tel:` links only), `phones_uncertain` (loose text match) | A single merged phone field, with regex noise mixed into real numbers |
| Social profile output | ✅ Seven per-platform **arrays**, all matches kept, plus a platform allow-list | Commonly one URL per platform, extra profiles discarded |
| Row schema | ✅ Same 29 keys on every row; contacts are empty arrays, never absent keys | Fields commonly omitted when empty, so consumers need presence checks |

Three specific listings, all checked on the Apify Store on **2026-07-25 — none of this is measured here, it is what their own listings state**:

- **`george.the.developer/google-maps-lead-intel`** — its listing advertises email deliverability validation and a 0–100 lead score, from a documented input of a single `searchQuery` string plus `maxResults` (documented range 1–100) and `minRating`. Its documented output nests socials as one `socialProfiles` object with a single URL per platform.
- **`scraplib/google-maps-lead-generation`** — its listing documents three inputs (`searchTerms`, one `location` string, `maxResults`) and an output with one `socialLinks` object holding a single optional URL per platform.
- **`scrapemint/google-maps-scraper`** — its listing documents review text and reviewer details per place, plus optional website enrichment producing emails and person names with job titles, and states "First 2 places per run are free."

**What this Actor deliberately does not do:** it does not validate email deliverability, compute a lead score, or scrape review text and reviewer profiles. If deliverability validation is the feature you are buying, `google-maps-lead-intel` advertises it and this Actor does not; if review content is the feature, `scrapemint/google-maps-scraper` advertises it and this Actor does not. What this one does that none of the three listings documents is produce a **no-website prospect segment**, filtered before rows are written, from a multi-location multi-keyword sweep, with phone confidence kept in separate columns.

If you are building an AI agent or a RAG pipeline, the row-schema line is the decision-maker — parsing HTML inside an agent loop is a reliability failure mode, not a feature. If you are building an outreach list, the first line is: filtering before rows are written keeps the dataset clean and the run cheap.

### How many results can you scrape with Google Maps Scraper With Lead & Contact Enrichment?

`maxResults` caps the number of **unique places saved to the dataset**, with a minimum of `1`, a maximum of `10000`, and a default of `20`. It is a total across every location, keyword and URL in the run — not a per-search figure — and duplicates are merged against a shared `place_id` set rather than counted twice.

**There is no page-by-page pagination.** Coverage comes from a geographic grid instead. Each location is geocoded to a bounding box, and the Actor lays a square grid of search points over that box, issuing one Google Maps search per point. Grid density scales with how many results are still outstanding:

| Results still needed | Grid | Search points per keyword |
| ----- | ----- | ----- |
| Up to 50 | 6 × 6 | 36 |
| 51 – 100 | 8 × 8 | 64 |
| 101 – 200 | 10 × 10 | 100 |
| Above 200 | 12 × 12 | 144 |

Every point requests up to 120 places from Google, all points for a keyword are dispatched concurrently, and results stream into the dataset as tiles complete. Once the budget is met the remaining tile requests are cancelled immediately rather than being paid for and discarded. Grid density is chosen from the **remaining** need, so later keyword/location pairs in a large run use progressively smaller grids as the budget fills.

Two practical ceilings follow. A very large `maxResults` over a small area exhausts what Google returns for that viewport before it exhausts your budget — the run finishes short, which is correct behaviour, not a failure. And a wide area such as a whole state resolves to a large bounding box that a 6 × 6 grid samples coarsely; for dense metro coverage, list neighbourhoods as separate `locations` entries rather than relying on one broad name.

### How does the Actor handle Google's anti-bot defences and website blocking?

Two different targets, two independent escalation ladders. Neither requires any configuration from you.

**Google Maps requests.** Each request carries a desktop Chrome user agent and a Google referer; the connection pool is capped at 12 concurrent sockets, with a 30-second total timeout and up to 3 attempts spaced one second apart. A response counts as blocked on status `429`, `403` or `503`, or when the body matches `unusual traffic`, `sorry.*automated`, `verify.*you are not a robot`, `detected.*traffic`, `captcha` or `rate limit`. On a block the Actor climbs one rung — **direct → Apify datacenter proxy → Apify residential proxy** — and once it reaches residential it stays there for the rest of the run. Enabling Apify Proxy in the input simply starts the run higher up: residential when `RESIDENTIAL` is in your `apifyProxyGroups`, datacenter otherwise.

**Business website crawls.** These use a separate HTTP client with **browser-accurate TLS and HTTP/2 impersonation** (Chrome fingerprint, HTTP/3 enabled), so an ordinary business site behind a WAF sees a normal browser handshake instead of a Python client. The same three rungs apply with their own state: blocked means status `401`, `403`, `407`, `429` or any 5xx, or `/sorry/`, `unusual traffic` or `captcha` in the first 2000 characters. Residential is retried up to 3 times with exponential backoff plus jitter before the fetch is abandoned, and proxied rungs rotate the exit IP on every request. The ratchet only goes up, never back down.

When a site defeats all three rungs the row is still saved with empty contact arrays and the run continues — one business's WAF never fails your run.

### 💳 Pricing and what gets charged

This Actor uses Apify's **pay-per-event** pricing with a single event: **`row_result`**, charged once for each place row written to the dataset.

**You are not charged for:**

- Places filtered out by `websiteFilter` — the filter runs before the push, so a `withoutWebsite` run never pays for the businesses that have a site.
- Duplicate places already seen in the run — deduplication on `place_id` also happens before the push.
- Grid tiles that return nothing, Google requests that get blocked and retried, and geocoding lookups including ones that fail to resolve.
- Website crawls, whether they succeed, come back empty, or get blocked — enrichment rides along with its row, there is no separate enrichment event.
- The `maps.json` copy written to the Key-Value Store.

Because the dataset contains nothing but place rows, there is no accounting or error record to exclude and no filter expression to apply before counting your leads. One dataset item equals one charged `row_result`.

The nuance worth planning around: **the charge is for the row, not for the enrichment work behind it.** A row that produced no email costs the same as one that produced three. If you only want rows that converted into a contact, filter the export on `emails.length > 0` — but the row was charged when it was saved. Runs also consume standard Apify platform usage (compute units, plus proxy traffic if you enable Apify Proxy), billed by Apify in the usual way.

### Is it legal to scrape Google Maps?

Scraping publicly listed business information is broadly treated as permissible where the data is accessible without authentication, and this Actor collects only what an anonymous visitor already sees: public Google Maps listings and the public pages of business websites. No login, no cookie, no account.

Most of what comes back is **business record data** — trading name, street address, category, opening hours, rating, review count, coordinates. That is commercial information about an entity, not personal data, and the framework that applies to it is contractual and database-related: Google's terms of service, and local database or unfair-competition rules covering systematic reuse of a compiled directory.

The contact block needs a second look. A generic mailbox such as `info@` or `hello@` is a business contact point. An address of the form `firstname.lastname@` identifies a person, and in that case **GDPR, CCPA and equivalent regimes do apply** to your storage and use of that row — you need a lawful basis, and outreach to it falls under the marketing rules of the recipient's jurisdiction. The same applies to a personal LinkedIn or Instagram profile that appears in a social array alongside the company page.

Consult legal counsel for commercial use cases involving bulk personal data.

### ❓ Frequently asked questions

#### Does this Actor work without a Google account or a Google API key?

Yes. No Google account, no Google Cloud project, no Places API key, no cookie and no session are used. The Actor sends anonymous requests to Google's public Maps search surface and to public business websites. The only credential involved is your Apify token, and only if you start the run through the Apify API rather than clicking Start in the Console.

#### How often is the scraped data updated?

Every run fetches live. Nothing is cached between runs — Maps searches, geocoding lookups and website crawls are all requested fresh, so a row reflects what Google and the business site published at the moment your run touched them. The only reuse within a run is the `place_id` deduplication set. Use Apify's scheduler for a recurring refresh, and diff on `place_id` to track what changed.

#### What happens when a business has no website, or its website cannot be crawled?

The row is still saved and still complete. If `has_website` is `false` there is nothing to crawl, so `emails`, `site_phones`, `phones_uncertain` and all seven social arrays come back empty — which is the entire point of the `withoutWebsite` prospect mode. If the business has a site but the homepage cannot be fetched through any rung of the proxy ladder, the output is identical: empty arrays, `has_website: true`, and a warning in the run log naming the site. Nothing partial or invented is written either way.

#### Can I get emails that a business does not publish on its website?

No, and the Actor does not pretend otherwise. It extracts only addresses physically present in the HTML of the homepage or one of up to four contact/about/impressum pages. It does not guess patterns like `firstname@domain.com`, query a third-party enrichment database, run JavaScript to reveal lazily-injected addresses, decode Cloudflare email obfuscation, or read text out of images. A business whose only contact route is a web form returns an empty `emails` array — accurately.

#### Am I charged for places that get filtered out or deduplicated?

No. The `row_result` event is charged on the push, and both the `websiteFilter` check and `place_id` deduplication run before it. A `withoutWebsite` run does not pay for businesses that have a site, and a business found by three different keywords is charged once. Blocked requests, failed geocodes, empty grid tiles and website crawls are never charged.

#### Does this Actor work for AI agent workflows and LLM pipelines?

Yes. It is a standard HTTP-triggered Apify Actor, so LangChain, CrewAI, n8n, or a hand-written tool definition can start a run through the Apify API and read typed JSON back with no parsing step. Every row has the same 29 keys, so a tool schema written once stays valid. `has_website` and the length of `emails` are clean boolean gates for agent routing, and `name`, `tags`, `notes`, `full_address` and `hours` index straight into a vector store.

#### How does the Actor handle Google's anti-bot system?

With an escalating proxy ladder — direct, then Apify datacenter, then Apify residential, sticky once reached — driven by block detection on both HTTP status (`429`, `403`, `503`) and body content (`unusual traffic`, automated-query notices, robot verification prompts, `captcha`, `rate limit`). Requests carry a desktop Chrome user agent and a Google referer, concurrency is capped at 12 sockets, and each URL gets up to 3 attempts. The website crawler runs its own ladder with Chrome TLS and HTTP/2 impersonation.

#### How does it compare to other Google Maps scrapers on the Store?

Checked on the Apify Store on 2026-07-25, taken from their own listings rather than measured here: `george.the.developer/google-maps-lead-intel` advertises email validation and a 0–100 lead score from a single `searchQuery` input; `scraplib/google-maps-lead-generation` documents `searchTerms` plus one `location` string and one social URL per platform; `scrapemint/google-maps-scraper` documents review text and reviewer details plus optional website enrichment. This Actor's differences are structural: a `withoutWebsite` prospect filter applied before rows are saved, multi-location × multi-keyword input in one deduplicated run, three phone confidence columns, and socials as arrays with a platform allow-list. It has no lead score and no email validation.

#### Can I use it without managing proxies?

Yes. Leave `proxyConfiguration` at its default and the Actor starts on a direct connection, detects blocking on both ladders, and escalates to Apify datacenter and then Apify residential proxies on its own — creating sessions, rotating exit IPs on proxied rungs, and holding residential once it gets there. Enabling Apify Proxy in the input simply changes the starting rung: residential when `RESIDENTIAL` is in `apifyProxyGroups`, datacenter otherwise.

#### Does it return data in a format LLMs can use directly?

Yes. Typed, normalized JSON with stable field names on every run — no HTML, no selectors, no parsing. Contact and social fields are always arrays, empty rather than absent, so a model or a downstream schema never encounters a missing key. Pass a row straight into an LLM context window, index it into a vector store, or hand it to an agent tool.

#### What happens when Google changes its structure or blocks the scraper?

The scraper is maintained, and because it reads Google Maps' internal search payload rather than rendered markup, a visual redesign of the Maps interface generally does not affect it. Individual fields are read through a bounded index walk that returns `null` instead of raising when a value moves, so a partial change degrades one field rather than breaking the run. Your field names and types do not change on your end.

### Integrate Google Maps Scraper With Lead & Contact Enrichment and automate your workflow

The Actor works with any language or tool that can send an HTTP request to the Apify API.

#### REST API integration

```python
from apify_client import ApifyClient

client = ApifyClient("<YOUR_APIFY_TOKEN>")

run = client.actor("<YOUR_USERNAME>/google-maps-scraper-with-lead-contact-enrichment").call(run_input={
    "locations": ["Austin, Texas", "Round Rock, Texas"],
    "keywords": ["dentists", "orthodontists"],
    "maxResults": 200,
    "scrapeContacts": True,
    "websiteFilter": "withWebsite",
    "proxyConfiguration": {"useApifyProxy": True, "apifyProxyGroups": ["RESIDENTIAL"]},
})

for place in client.dataset(run["defaultDatasetId"]).iterate_items():
    email = place["emails"][0] if place["emails"] else None
    print(place["name"], place["full_address"], place["phone"], email)
```

Works in Python, Node.js, Go, Ruby, cURL.

#### Automation platforms (n8n, Make, LangChain)

In **n8n**, use the Apify node — or an HTTP Request node pointed at the Apify run endpoint with your token — and pass the same JSON input shown above; an IF node on `has_website` splits enrichable leads from no-website prospects, and a Set node flattens `emails[0]` into a single column for a mail-merge step. In **Make**, the Apify module supports run-and-wait, so a weekly territory sweep feeds a Google Sheets, Airtable, HubSpot or Pipedrive module directly through an Iterator over the dataset items. In **LangChain**, wrap the Apify run call as a tool and hand the rows to the model unchanged — they are already typed JSON with fixed keys. Apify **schedules** handle recurring runs, and Apify **webhooks** fire your own endpoint when a run finishes, so a CRM sync starts without polling.

### Related scrapers

| Scraper Name | What it extracts |
| ----- | ----- |
| LinkedIn Phone Email Finder | Phone and email contact points for LinkedIn profiles |
| Facebook Search Scraper WhatsApp Contact Leads | Facebook search results with WhatsApp contact leads |
| Instagram Local Phone Leads Scraper | Local business Instagram profiles with published phone contacts |
| Instagram Profile Bio Contact Extractor | Contact details published in Instagram profile bios |
| Realtor.com Agent Scraper With Phone & Social Leads | Real estate agent records with phone and social contact points |
| Alibaba Scraper With Supplier Contact Leads | Supplier listings with email, phone and website enrichment |
| TikTok Profile Scraper With Bio Link Contact Leads | TikTok profiles with bio links and contact leads |

### 💬 Your feedback

Found a bug, or missing a field that Google Maps publishes but the output does not carry? We want to know — open an issue on the Actor's **Issues** tab and it will be looked at. Reports that include the exact input JSON you ran, and the business name or `place_id` of a row that came out wrong, are the fastest to reproduce and fix. Requests for additional contact sources or extra social platforms are welcome too.

# Actor input Schema

## `locations` (type: `array`):

🏙️ Examples: New York, Texas, Downtown Chicago — mix and match for bulk coverage.

## `keywords` (type: `array`):

☕ Describe the businesses or places you care about — coffee shops, dentists, coworking, hotels near airport. Add many keywords to multiply your searches.

## `urls` (type: `array`):

🌐 Bulk-friendly list of map links.

## `maxResults` (type: `integer`):

🔢 Total unique places across all locations, keywords, and URLs (duplicates merged). Example: maxResults=50. Default is 20.

## `scrapeContacts` (type: `boolean`):

✅ On: shallow-crawl the homepage plus contact/about/impressum pages and extract emails, site phones and social profile links. ⬜ Off: Google Maps data only (faster). Default is true.

## `websiteFilter` (type: `string`):

Keep only the leads you want. allPlaces: no filter. withWebsite: only businesses that have a site (enrichable). withoutWebsite: only businesses missing a site — prime prospects for web/SEO services. Default is allPlaces.

## `scrapeSocialMediaProfiles` (type: `object`):

Optional allow-list of which social platforms to emit per place. Set the platforms you want to true, e.g. {"linkedins": true, "instagrams": true}. Leave empty ({}) to emit every social profile found. Keys: linkedins, facebooks, instagrams, twitters, youtubes, tiktoks, pinterests.

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

🎛️ Optional Apify Proxy — leave off for simple runs; turn on for heavier jobs or picky networks.

## Actor input object example

```json
{
  "locations": [
    "New York"
  ],
  "keywords": [
    "coffee shops"
  ],
  "urls": [],
  "maxResults": 20,
  "scrapeContacts": true,
  "websiteFilter": "allPlaces",
  "scrapeSocialMediaProfiles": {},
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

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

All scraped items in the Actor's default dataset.

## `mapsSnapshot` (type: `string`):

A single JSON record with the complete set of places and enriched contacts, saved to the key-value store once the run finishes.

# 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 = {
    "locations": [
        "New York"
    ],
    "keywords": [
        "coffee shops"
    ],
    "urls": [],
    "scrapeSocialMediaProfiles": {},
    "proxyConfiguration": {
        "useApifyProxy": false
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("api-empire/google-maps-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 = {
    "locations": ["New York"],
    "keywords": ["coffee shops"],
    "urls": [],
    "scrapeSocialMediaProfiles": {},
    "proxyConfiguration": { "useApifyProxy": False },
}

# Run the Actor and wait for it to finish
run = client.actor("api-empire/google-maps-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 '{
  "locations": [
    "New York"
  ],
  "keywords": [
    "coffee shops"
  ],
  "urls": [],
  "scrapeSocialMediaProfiles": {},
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}' |
apify call api-empire/google-maps-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,api-empire/google-maps-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/YWhQuS80RQmFSSYlY/builds/bjj8gXSz9gM6yO6HZ/openapi.json
