# Google Maps Scraper: Reviews, Hours & Change Monitoring (`farside/gmaps-lead-radar`) Actor

Scrape Google Maps businesses with reviews, weekly opening hours and popular times in one run. Watchlist + diff engine detects phone, website, hours, rating and new-review changes; webhook alerts. Every field carries provenance and confidence. No Google account needed.

- **URL**: https://apify.com/farside/gmaps-lead-radar.md
- **Developed by:** [Farside](https://apify.com/farside) (community)
- **Categories:** Lead generation, Automation, AI
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $3.00 / 1,000 business profile captureds

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

## Google Maps Lead Radar

Extract complete Google Maps business profiles — phone, website, weekly opening
hours, popular times and reviews — for any city or search term, for lead
generation, market research or local competitor tracking. Turn on change
monitoring and it watches your target list for you: new reviews, changed phone
numbers, moved addresses, rating swings — delivered as events, optionally to
your webhook.

### What you get

- **Complete profiles** — name, structured address, phone (E.164), website,
  categories, rating, review count, weekly opening hours, popular times,
  coordinates, timezone, photo URLs.
- **Reviews with exact timestamps** — separate records per review (ID, stars,
  text, author, publication time), freshest first.
- **Change monitoring** — baseline + diff engine with 12 event types
  (`phone.changed`, `hours.changed`, `review.new`, `status.changed`, ...),
  webhook delivery included.
- **Per-field provenance** — every field records which capture layer produced
  it and when, so you always know how fresh a phone number is.

### Try it in 30 seconds

```json
{
  "searchTerms": ["pet shop"],
  "location": "Kuala Lumpur",
  "countryCode": "my",
  "maxPlaces": 12,
  "maxReviewsPerPlace": 8
}
```

A place costs **$0.003** all-inclusive — profile, reviews, weekly hours,
popular times, photo URLs, the whole record. Monitoring adds $0.0015 per place
per check. 1,000 places = $3. Sample record:

```json
{
  "type": "place",
  "name": "Dhani Pet Store",
  "addressLine": "38, 40, Jalan Setia Tropika 1/1, Setia Tropika, Johor Bahru",
  "phone": "07-244 4013",
  "rating": 4.7,
  "reviewCount": 103,
  "openingHoursWeekly": [{ "weekday": "Monday", "text": "9 am–8 pm", "closed": false }],
  "tier": "T3-reviews",
  "capturedAt": "2026-09-01T02:50:31+00:00"
}
```

Read on for the full input reference, output schema, tier ladder and honest
limitations.

### Input

Minimal:

```json
{
  "searchTerms": ["coffee shop"],
  "location": "Boulder, CO",
  "maxPlaces": 20
}
```

Specific places, skipping discovery:

```json
{
  "placeIds": [
    "ChIJ1S_Bov20RIYRe7MiR8tYawY",
    "ChIJsdcDulXva4cReMhGKCUCClU"
  ],
  "maxReviewsPerPlace": 20
}
```

Fast, profiles only (no browser, so no weekly hours or popular times):

```json
{
  "searchTerms": ["dentist"],
  "location": "Portland, OR",
  "maxPlaces": 50,
  "captureReviews": false,
  "browserEnrichment": false
}
```

| Field | Default | Meaning |
|---|---|---|
| `searchTerms` | — | Terms to search for. Combined with `location`. |
| `location` | — | City/region to scope the search to. |
| `placeIds` | — | Capture these exact places and skip discovery. |
| `maxPlaces` | 20 | Upper bound on places captured. |
| `captureReviews` | true | Emit review items. Needs a browser. |
| `maxReviewsPerPlace` | 10 | Reviews per place. |
| `browserEnrichment` | true | Use a browser for weekly hours and popular times. |
| `language` | `en` | Google interface language. |
| `countryCode` | `us` | Country for the search and the proxy. |
| `proxyUrl` | — | Override the platform residential proxy. |
| `headlessBrowser` | false | See limitations — headless loses reviews. |
| `simulateFriction` | `none` | Test hook for the tier ladder. |

### Output

Place item (abridged):

```json
{
  "type": "place",
  "placeId": "ChIJsdcDulXva4cReMhGKCUCClU",
  "featureId": "0x876bef55ba03d7b1:0x550a02252846c878",
  "name": "O.W.L Coffee Roasters",
  "addressLine": "2355 30th St, Boulder, CO 80301",
  "addressParts": {
    "neighborhood": "Central Boulder",
    "street": "2355 30th St",
    "city": "Boulder",
    "postalCode": "80301",
    "state": "Colorado",
    "countryCode": "US"
  },
  "latitude": 40.024478,
  "longitude": -105.2541672,
  "phone": "(720) 790-1077",
  "phoneE164": "+17207901077",
  "website": "https://www.owlcoffeeroasters.com/",
  "categories": ["Coffee shop"],
  "rating": 4.9,
  "reviewCount": 47,
  "openingHoursWeekly": [
    { "weekday": "Monday", "text": "8 am–5 pm", "closed": false }
  ],
  "popularTimes": [[{ "hour": 7, "busyPercent": 15 }]],
  "photos": [{ "url": "https://lh3.googleusercontent.com/..." }],
  "timezone": "America/Denver",
  "tier": "T3-reviews",
  "capturedAt": "2026-09-01T03:17:31+00:00",
  "fieldProvenance": {
    "phone": {
      "tier": "T2-detail",
      "layer": "http-detail",
      "capturedAt": "2026-09-01T03:17:31+00:00"
    }
  }
}
```

Review item:

```json
{
  "type": "review",
  "placeId": "ChIJsdcDulXva4cReMhGKCUCClU",
  "reviewId": "Ci9DQUlRQUNvZENodHljRjlvT2xSYVVVeE9WbTFvY2pJeE1qazJTMWRVVDJwRU1uYxAB",
  "rating": 5,
  "text": "Great spot with lovely seasonal offerings...",
  "authorName": "Kat L",
  "authorUrl": "https://www.google.com/maps/contrib/1035539.../reviews",
  "publishedAt": "2026-07-04T18:20:22+00:00",
  "capturedAt": "2026-09-01T03:17:31+00:00"
}
```

Filter the dataset on `type` to separate places, reviews and the run report.

### Capture tiers

Fields are produced by different layers, and the report tells you which.

| Tier | Source | Fields |
|---|---|---|
| `T0-identity` | input only | placeId |
| `T1-search` | HTTP search | name, address, coordinates, rating, categories |
| `T2-detail` | HTTP place detail | phone, website, full address parts, timezone |
| `T3-reviews` | browser | weekly opening hours, popular times, reviews |

T1 and T2 need no browser and are fast. T3 needs a real browser and dominates
run time, which is why `browserEnrichment` and `captureReviews` are separate
switches.

### Change monitoring

Set `watchlistMode: true` and each run compares what it captured against the
stored baseline for the same place, emits one event per change, then updates
the baseline. Point it at a `placeIds` list or a standing search and schedule
it.

Events are dataset items with `"type": "changeEvent"`:
`place.baseline`, `name.changed`, `phone.changed`, `website.changed`,
`address.changed`, `status.changed`, `rating.changed`, `reviewCount.changed`,
`hours.changed`, `categories.changed`, `review.new`, `photos.new`.

```json
{
  "searchTerms": ["dentist"],
  "location": "Portland, OR",
  "watchlistMode": true,
  "webhookUrl": "https://example.com/hooks/maps"
}
```

Set `webhookUrl` and events are also POSTed there, batched up to 50 per
request. Delivery is best-effort — three attempts, then the run logs it and
carries on; events are in the dataset either way.

Full event reference and the noise-suppression rules: `docs/MONITORING.md`.

### Pricing

Pay per event. You are billed for results, not for runtime.

| Event | Price | Charged |
|---|---|---|
| `result-place` | **$0.003** | once per place captured |
| `monitor-check` | **$0.0015** | once per monitored place, per check |

**A place is all-inclusive at $0.003.** That one charge covers the full
profile, its reviews, weekly opening hours, popular times and photo URLs.
Review items are pushed as separate dataset records for convenience, but they
are not billed separately — a place with 100 reviews costs the same $0.003 as
one with none. The run report is free.

**Monitoring is billed per place, per check** — not per change detected. A
check on a place that turned out to be unchanged still costs $0.0015, because
the check is the work being paid for; conversely a place that gained twelve new
reviews since the last run is still a single $0.0015 check, not twelve charges.
Runs with `watchlistMode: false` are never billed for monitoring.

Worked examples:

| Run | Cost |
|---|---|
| 100 places, profiles + reviews | 100 x $0.003 = **$0.30** |
| 500 places, profiles only (`captureReviews: false`) | 500 x $0.003 = **$1.50** |
| Daily monitoring of 200 places, 30 days | 200 x 30 x ($0.003 + $0.0015) = **$27.00** |

Note that a monitoring run also re-captures each place, so it is billed for
both the place and the check. Nothing is charged for a place that fails to
capture: failures are reported inside the run report rather than pushed as
result records, so you are never billed for a result you did not receive.

### Long runs and the time budget

Every Apify run has a hard timeout (default **1 hour** — visible and
adjustable under *Run options* in the Console). A run that hits it is killed
by the platform mid-work: partial data, no report, status `TIMED-OUT`.

This actor avoids that by watching the clock itself. It reads the run's
actual kill time from the platform and stops capturing a safe margin
(~2.5 minutes) before it, so the run always ends `SUCCEEDED` with:

- every place captured so far, complete and billed normally — never a
  half-captured record;
- the run report, with `timeBudgetReached: true` so your integration can
  detect the early stop, and a log line saying how many places were done
  (`time budget reached — stopped after N/M places`);
- a checkpoint of what was captured. **Rerun the actor with the same input
  and it continues where it stopped**, skipping (and not re-billing) the
  places it already delivered.

As the deadline nears, the actor also stops starting browser captures it can
no longer finish: the last places before the stop may come back without
reviews, weekly hours or popular times (the report's
`browserSkippedForTimeBudget` flag and per-field provenance show exactly
which). If a run stops because it hit your maximum charge instead, the report
says `chargeLimitReached: true` and the same rerun-to-continue story applies.

Want large runs to finish in one go? Raise the **run timeout** in the
Console's run options (or via the API's `timeout` parameter) — the actor
derives its budget from whatever timeout the run actually has. Otherwise,
just rerun with the same input until the work list is done; the checkpoint
makes reruns cumulative rather than repetitive.

Locally (`apify run`) there is no platform timeout and the budget is off by
default; export `GMAPS_TIME_BUDGET_SECS=<seconds>` to impose one — that is
also how the acceptance test exercises this behaviour:

```bash
GMAPS_TIME_BUDGET_SECS=75 xvfb-run -a npx apify run --purge
.venv/bin/python -m tests.verify_time_budget
```

### Limitations

Read these before relying on the output.

- **Anti-bot is an arms race.** This reads the same endpoints Google's own web
  client uses. Google changes them without notice. When capture degrades, the
  parsing layers are isolated (see `docs/CANARY.md` and `MAINTENANCE.md`) so
  one field breaks rather than the whole run — but breakage will happen.
- **Completeness is relative to the listing.** Many places have no popular
  times, no phone, or no website. A field missing from the output usually means
  Google does not publish it for that place. The run report measures
  completeness per field so you can tell the difference.
- **Reviews need a real browser, and it cannot run headless.** Google serves
  headless Chromium a reduced view of Maps with no reviews pane. The actor runs
  Chromium headed against a virtual display. Setting `headlessBrowser: true`
  will lose reviews, weekly hours and popular times.
- **Reviews are a sample, not the full history.** Review pagination is the most
  fragile part of Maps scraping. `maxReviewsPerPlace` is a ceiling, not a
  promise; expect the most recent reviews, and fewer than requested on some
  places.
- **Search results cap out.** A single query returns roughly 120 results at
  most, and repeated runs of the same query return slightly different sets.
  Results are deduplicated on `placeId`. To cover an area properly, split it
  into several narrower queries.
- **Datacenter IPs are usually blocked** for Maps search. On the platform the
  actor requests a residential proxy automatically. The browser tier gets its
  own session-pinned proxy so a page and the requests it triggers share one
  exit IP.
- **Do not set an EU `countryCode`.** An EU-country proxy lands on Google's
  consent wall, and the run captures nothing (the log says
  `consent wall: proxy country needs changing`). To scrape European places,
  leave `countryCode` at `us` and put the city in `location` — for example
  `{"searchTerms": ["pet shop"], "location": "Berlin, Germany"}`. Profiles,
  reviews and hours all work that way.
- **European places are more sensitive to which proxy IP is drawn.** Google
  serves some exit IPs a reduced view of Maps with no reviews pane, and it does
  so far more readily for EU listings than for US or APAC ones. The actor
  detects that view, reloads, and then rotates to a fresh proxy session (up to
  twice per run) before giving up on a place. Even so, an EU run can come back
  with fewer reviews than the same run against a US city, and a run that draws
  a bad address early may return profiles with no reviews or weekly hours at
  all. Re-running usually clears it. `fieldProvenance` and the run report tell
  you which places were affected rather than leaving you to guess.
- **No enrichment beyond Google Maps.** No email discovery or verification, no
  social profiles, no decision-maker identification, no third-party data. What
  Google Maps shows is what you get.
- **Closed-business detection is the least tested field.** `status.changed`
  relies on spotting Google's own closure labels; absence is read as
  operational. The diff logic is verified, but not against a live
  permanently-closed listing.
- **Photos are URLs only.** No binaries are downloaded. Google's photo URLs are
  not guaranteed to be stable indefinitely.

### Compliance

This actor reads publicly visible Google Maps pages. It does not log into any
Google account, does not post reviews, and does not attempt to bypass login
walls. Review text and author names are personal data in many jurisdictions —
if you store or process them, that is your responsibility under the applicable
privacy law, including having a lawful basis and honouring deletion requests.
Scraping of public web data is under active litigation and the legal position
differs by jurisdiction; treat this as engineering documentation, not legal
advice, and take your own.

### Development

```bash
python -m venv .venv && .venv/bin/pip install -r requirements.txt
.venv/bin/python -m playwright install chromium
npm install                       # apify-cli as a dev dependency
xvfb-run -a npx apify run         # needs a virtual display for the browser tier
```

Verification tools:

```bash
## compare captured places against the live rendered page
xvfb-run -a .venv/bin/python -m tests.spotcheck storage/datasets/default 3

## rebuild tests/fixture.json and measure relative completeness
xvfb-run -a .venv/bin/python -m tests.build_fixture
```

When output looks wrong, start with the health probe — it checks every capture
layer and names the file and symbol behind each failure:

```bash
xvfb-run -a .venv/bin/python -m tests.canary --browser
.venv/bin/python -m tests.privacy_scan
```

Documentation map:

| File | What it is for |
|---|---|
| `MAINTENANCE.md` | what breaks first, how to spot it, where to fix it, expected upkeep |
| `docs/CANARY.md` | what was verified about Google's endpoints, and why the architecture is shaped this way |
| `docs/TIERS.md` | the capability ladder and how degradation is proven |
| `docs/MONITORING.md` | change events and the rules that keep the feed quiet |

Read `docs/CANARY.md` before re-deriving anything about Google's endpoints: it
records several approaches that do **not** work, so you do not spend a day
rediscovering them.

# Actor input Schema

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

What to look for, e.g. "coffee shop". Combined with the location below. Leave empty if you supply place IDs instead.

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

City, region or address to search in, e.g. "Austin, TX".

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

Capture these exact places and skip discovery. A place ID looks like ChIJ1S\_Bov20RIYRe7MiR8tYawY.

## `maxPlaces` (type: `integer`):

Upper bound on places captured in this run. Google caps a single query at roughly 120 results.

## `captureReviews` (type: `boolean`):

Reviews need a browser, which is slower and uses more memory. Turn this off for fast profile-only runs.

## `browserEnrichment` (type: `boolean`):

Use a browser to add weekly opening hours and popular times. These fields have no browser-free source. Turn off for the fastest possible profile-only run.

## `maxReviewsPerPlace` (type: `integer`):

Reviews are emitted as separate dataset items referencing the placeId.

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

Google interface language code, e.g. "en".

## `countryCode` (type: `string`):

Two-letter country code used for the search and the proxy, e.g. "us".

## `watchlistMode` (type: `boolean`):

Compare this run against the stored baseline and emit change events instead of only profiles.

## `webhookUrl` (type: `string`):

Optional. Change events are POSTed here as JSON when change monitoring is on.

## `proxyUrl` (type: `string`):

Optional. Overrides the platform residential proxy. Google blocks datacenter IPs for Maps search.

## `headlessBrowser` (type: `boolean`):

Off by default. Google serves headless Chromium a limited view of Maps with no reviews pane.

## `simulateFriction` (type: `string`):

Test hook. Injects a failure so you can verify the actor degrades through its capability tiers instead of dying.

## Actor input object example

```json
{
  "searchTerms": [
    "coffee shop"
  ],
  "location": "Austin, TX",
  "maxPlaces": 20,
  "captureReviews": true,
  "browserEnrichment": true,
  "maxReviewsPerPlace": 10,
  "language": "en",
  "countryCode": "us",
  "watchlistMode": false,
  "headlessBrowser": false,
  "simulateFriction": "none"
}
```

# Actor output Schema

## `dataset` (type: `string`):

Dataset containing business profiles, reviews and change events

# 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": [
        "coffee shop"
    ],
    "location": "Austin, TX"
};

// Run the Actor and wait for it to finish
const run = await client.actor("farside/gmaps-lead-radar").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": ["coffee shop"],
    "location": "Austin, TX",
}

# Run the Actor and wait for it to finish
run = client.actor("farside/gmaps-lead-radar").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": [
    "coffee shop"
  ],
  "location": "Austin, TX"
}' |
apify call farside/gmaps-lead-radar --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,farside/gmaps-lead-radar"
        }
    }
}

```

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/jDyK5WbcWQ7i4tbmv/builds/u9ktWbaCAiwtUe2GO/openapi.json
