# Google Maps Scraper with Emails (`al_mansouri/verified-maps-harvester`) Actor

Extract Google Maps business listings with website, phone, email and social profiles - and a coverage report that says when an area could not be read exhaustively.

- **URL**: https://apify.com/al\_mansouri/verified-maps-harvester.md
- **Developed by:** [Hussein Al-Mansori](https://apify.com/al_mansouri) (community)
- **Categories:** Lead generation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.80 / 1,000 place scrapeds

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?

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

## Verified Google Maps Harvester

Extract business listings from Google Maps — names, addresses, categories, ratings, review counts,
price bands and opening status — for any city, region, or map area you draw yourself. Optionally
open each place's own page for its **website, phone number and full address**, and read that
website for **email addresses and social profiles**.

**What makes this one different: it tells you when it did not find everything.**

Every scraper hits Google's hard ceiling of roughly 120 results per map view. Most quietly return
what they got and let you assume that was all there was. This one subdivides the area and searches
again — and when an area is still too dense to read exhaustively, it says so in a coverage report
instead of handing you a confident-looking partial list.

### What you get

One row per business.

With `scrapePlaceDetails` and `enrichContacts` turned on, a row also carries the fields Google
does not show in its results list, and the contacts from the business's own website:

```json
{
  "title": "Bwè Coffee - Washington St.",
  "website": "http://bwekafe.com/",
  "phone": "+1 201-683-0045",
  "phoneUnformatted": "+12016830045",
  "street": "1002 Washington St",
  "city": "Hoboken",
  "state": "NJ",
  "postalCode": "07030",
  "countryCode": "United States",
  "emails": [{ "address": "info@bwekafe.com", "kind": "role", "foundIn": "mailto" }],
  "socials": [{ "platform": "instagram", "url": "https://instagram.com/bwekafe" }],
  "reviewsDistribution": { "fiveStar": 17677, "fourStar": 5124 },
  "fieldSources": { "title": "card", "website": "detail", "emails": "website" }
}
```

Every row records where each value came from in `fieldSources` — the results list, the place's own
page, or its website. Nobody else publishes that, and it is what separates "this business has no
website" from "something broke".

A plain run, with neither option on, returns what the results list carries:

```json
{
  "title": "Joe's Pizza Broadway",
  "categoryName": "Pizza",
  "address": "1435 Broadway",
  "location": { "lat": 40.7546795, "lng": -73.9870291 },
  "totalScore": 4.4,
  "reviewsCount": 26457,
  "priceRange": "$10–20",
  "description": "Classic NYC slice shop",
  "openState": "Closed · Opens 10 AM",
  "serviceOptions": ["Dine-in"],
  "placeId": "ChIJifIePKtZwokRVZ-UdRGkZzs",
  "cid": "4280570365733019477",
  "fid": "0x89c259ab3c1ef289:0x3b67a41175949f55",
  "kgmid": "/g/11bw4ws2mt",
  "url": "https://www.google.com/maps/place/...",
  "searchString": "pizza",
  "rank": 1,
  "status": "verified",
  "scrapedAt": "2026-08-11T08:43:22Z"
}
```

Four identifiers come back for every place — `placeId`, `cid`, `fid`, and `kgmid` — so results
join cleanly against the Google Places API, your CRM, or a previous run.

### Why this one

**Real boundaries, not a square around a point.** Ask for "Jersey City, New Jersey" and the area
is looked up on OpenStreetMap, so the search follows the actual administrative outline. Places in
the next town are discarded rather than padding your row count.

**Honest coverage.** Every run writes a `COVERAGE` record saying how many map tiles were read,
whether the area was fully covered, and if not, why. If Google throttled the run or an area was
too dense to exhaust, that is stated plainly.

**Status you can trust.** A row is `verified` only when the listing was read cleanly. If Google's
result formatting changes and details go missing, the row is downgraded to `warning` rather than
published as though nothing happened.

**You draw the area.** Give a place name, or supply GeoJSON — a polygon, several polygons, or a
point with a radius.

### Quick start

```json
{
  "searchStringsArray": ["coffee shop", "bakery"],
  "locationQuery": "Jersey City, New Jersey",
  "maxPlacesPerSearch": 200
}
```

Use search terms that overlap as little as possible. `["restaurant", "bar", "cafe"]` finds far
more than `["restaurant", "restaurants", "chinese restaurant"]`, because near-duplicate terms
return near-identical results and simply cost more.

### Input

| Option | What it does | Default |
|---|---|---|
| `searchStringsArray` | What to look for. One search runs per term. | required |
| `locationQuery` | City, region, or country. Looked up on OpenStreetMap. | — |
| `customGeolocation` | GeoJSON `Polygon`, `MultiPolygon`, or `Point` with `radiusKm`, instead of a name. Longitude comes first. | — |
| `maxPlacesPerSearch` | Stop after this many places per term. | `100` |
| `language` | Two-letter code. Affects categories and opening hours. | `en` |
| `restrictToArea` | Discard places Google returns from outside your area. | `true` |
| `scrapePlaceDetails` | Open each place's own page for website, phone, full address, hours and categories. Google does not show these in its results list. | `false` |
| `enrichContacts` | Read each website for email addresses and social profiles. Turns on the option above, since the website comes from it. | `false` |
| `proxyConfiguration` | Residential addresses reach more results before Google throttles. | Apify Proxy |

Provide either `locationQuery` or `customGeolocation`. Without an area, Google answers from
wherever it guesses you are, which produces a confident dataset for the wrong city.

### Reading the result

- **Dataset** — one row per place. The **Places** view shows the columns most people want; the
  **Location** view shows coordinates and all four identifiers.
- **`COVERAGE`** (Key-value store) — whether the area was read completely, how many tiles were
  searched, and any reason it stopped short. Check this before treating a run as exhaustive.
  Its `worker` field records which runtime served the run; output is identical either way.
- **`SUMMARY`** (Key-value store) — cumulative counts for the run.

### Pricing

| Event | When it applies | Price |
|---|---|---|
| `place-scraped` | A business was returned | **$0.0008** |
| `place-detailed` | Its own page was read too: website, phone, full address, hours | **$0.0020** |
| `place-enriched` | Its website was read as well: emails and social profiles | **$0.0035** |

One event per row, and only the tier actually delivered. A place whose page failed to load is
charged as `place-scraped`, not as something richer. A website that publishes no email address and
no social profile is charged as `place-detailed`, even though reading it cost the same. Failed rows
are never charged at all.

1,000 places costs about **$0.80**; with websites and phone numbers, **$2.00**; with email
addresses too, **$3.50**.

> Prices are set from measured platform cost on real runs, not estimated.

### Limits

| Limit | Value |
|---|---|
| Search terms per run | 20 |
| Places per search term | 10,000 |
| Results per map view (Google's ceiling) | ~120 |
| Area subdivision depth | 4 |
| Map views per search term | 400 |

When an area holds more places than subdivision can reach, the run says so in `COVERAGE` rather
than pretending the list is complete. Searching a smaller area is how you get the rest.

### Errors

| Code | Meaning | Worth retrying? |
|---|---|---|
| `INVALID_INPUT` | The input did not match the schema | No — fix the input |
| `GEOCODE_FAILED` | The location could not be found | No — try a more specific name |
| `UPSTREAM_BLOCKED` | Google throttled the run | Yes, later, or with residential proxies |
| `UPSTREAM_TIMEOUT` | A map view took too long | Yes |
| `PARSE_FAILED` | Google's result formatting was unreadable | Yes |
| `NO_RESULTS` | Nothing matched in that area | No |
| `BUDGET_EXHAUSTED` | The run's charge limit was reached | No — raise the limit |
| `WORKER_UNAVAILABLE` | Our harvesting capacity was briefly unreachable | Yes — nothing was charged |
| `STORAGE_FAILURE` | A result could not be stored, and was not charged | Yes |
| `INTERNAL_ERROR` | Something unexpected | Yes |

### Privacy and lawful use

This Actor does not collect reviews or reviewer profiles, which is where most of the personal data
on Google Maps lives. Ratings are returned only in aggregate — a score and a count, never who gave
them.

**`enrichContacts` is different, and worth understanding before you turn it on.** It reads each
business's own website and returns the addresses published there. Most are role addresses like
`info@` or `sales@`, but some are named individuals, and a named individual's work address is
personal data under the GDPR and similar laws. Every address is labelled `role`, `personal` or
`unknown` in the `kind` field so you can filter on it, and `foundIn` records where on the page it
came from so any claim can be re-checked by hand.

Business contact details are still subject to data-protection law in many jurisdictions. If you
use this data for outreach, you are the data controller: you need a lawful basis, and recipients
need a way to opt out. Marketing rules such as GDPR, CAN-SPAM, and PECR apply to how you use the
output, not to how it was gathered.

### FAQ

**Why did I get fewer places than I asked for?**
Check the `COVERAGE` record. Either the area genuinely holds fewer businesses, or the run stopped
early and it will tell you why. Fewer honest rows beats more padded ones.

**Can I search several cities at once?**
Run the Actor once per city, or supply a `MultiPolygon` covering all of them in
`customGeolocation`.

**Why are some ratings empty?**
New businesses often have no rating yet. The field is null rather than guessed.

**Does it get emails and phone numbers from business websites?**
Yes. `enrichContacts` reads each business's own website and returns the email addresses and social
profiles published there; `scrapePlaceDetails` gets the phone number from the Google listing.
About four sites in ten publish an email address — the rest come back empty rather than guessed,
and `fieldSources` tells you which is which.

**How many places can one run return?**
As many as the area holds, up to your per-term limit. Google shows roughly 120 per map view, so
this subdivides the area and searches again. Where even that is not enough, `COVERAGE` says so.

**Why did a run fail with `WORKER_UNAVAILABLE`?**
Our harvesting capacity was briefly unreachable. Nothing was charged. Retry in a minute.

# Actor input Schema

## `searchStringsArray` (type: `array`):

What to look for, for example "coffee shop" or "dentist". Use terms that overlap as little as possible: "restaurant, bar, cafe" finds far more than "restaurant, restaurants, chinese restaurant".

## `locationQuery` (type: `string`):

The city, region, or country to search, for example "Jersey City, New Jersey". The outline is looked up on OpenStreetMap, so results follow the real administrative boundary rather than a square around a point.

## `customGeolocation` (type: `object`):

A GeoJSON Polygon, MultiPolygon, or Point to search instead of a named location. A Point accepts an extra "radiusKm" value. Note GeoJSON lists longitude before latitude. Use this when the area you want is not a place with a name.

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

Stop after this many places for each search term. Dense areas are subdivided automatically to get past Google's limit of about 120 results per map view.

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

The language Google should answer in, as a two-letter code. Affects category names and opening hours.

## `restrictToArea` (type: `boolean`):

Google returns nearby places from outside the area you asked for. Leave this on to discard them. Turning it off returns more rows, but some will be outside your area.

## `scrapePlaceDetails` (type: `boolean`):

Opens every place's own page to collect its website, phone number, full address, opening hours and categories. These are not shown in Google's results list, so they cannot be collected without it. Roughly triples the time a run takes and is charged at a higher rate per place.

## `enrichContacts` (type: `boolean`):

Visits the website of every place that has one and collects email addresses and social profiles from it. Google never publishes a business email, so this is the only way to get one. Turns on the previous option automatically, since the website address comes from it. Adds roughly a second per place and is charged at the highest rate.

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

Google blocks datacenter addresses far sooner than residential ones. Residential proxies cost more per run but reach more results before being throttled.

## Actor input object example

```json
{
  "searchStringsArray": [
    "coffee shop",
    "bakery"
  ],
  "locationQuery": "Jersey City, New Jersey",
  "maxPlacesPerSearch": 100,
  "language": "en",
  "restrictToArea": true,
  "scrapePlaceDetails": false,
  "enrichContacts": false,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

## `places` (type: `string`):

No description

## `coverage` (type: `string`):

No description

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

No description

# 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 = {
    "searchStringsArray": [
        "coffee shop"
    ],
    "locationQuery": "Jersey City, New Jersey"
};

// Run the Actor and wait for it to finish
const run = await client.actor("al_mansouri/verified-maps-harvester").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 = {
    "searchStringsArray": ["coffee shop"],
    "locationQuery": "Jersey City, New Jersey",
}

# Run the Actor and wait for it to finish
run = client.actor("al_mansouri/verified-maps-harvester").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 '{
  "searchStringsArray": [
    "coffee shop"
  ],
  "locationQuery": "Jersey City, New Jersey"
}' |
apify call al_mansouri/verified-maps-harvester --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,al_mansouri/verified-maps-harvester"
        }
    }
}

```

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/Zd3nVyyNtG0KtjlIH/builds/yZi9jGKzESkgZNo1V/openapi.json
