# Google Maps Change Monitor 📍 Track listings, pay per change (`eszetael_lab/google-maps-scraper`) Actor

✅ Watch Google Maps listings and get ONLY what moved — rating, reviews, phone, website, hours, closures — with the old value next to the new one. ✅ Unchanged places are not delivered and not charged. ✅ Also does one-off extraction of the full business record.

- **URL**: https://apify.com/eszetael\_lab/google-maps-scraper.md
- **Developed by:** [Radosław Szal](https://apify.com/eszetael_lab) (community)
- **Categories:** Lead generation, Automation
- **Stats:** 2 total users, 1 monthly users, 95.5% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

Pay per event

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

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

## What's an Apify Actor?

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

## How to integrate an Actor?

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

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

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

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

# README

## Google Maps Change Monitor — track listings, pay per change

> **Unofficial.** This Actor is not affiliated with, endorsed by, or sponsored by Google LLC.
> "Google Maps" is a trademark of its respective owner and is used here only to describe what this
> Actor reads. We are an independent developer and this tool is provided as-is.

**Watch a list of businesses and get only what moved.** Run it once to take a baseline, then on a
schedule. Every later run delivers the places whose rating, review count, phone, website, opening
hours or status changed — with the old value next to the new one. **Unchanged places are not
delivered and not charged**, so watching 300 businesses daily costs what the movement costs, not
300 charges every morning.

| run on the same watch list | places checked | delivered & charged | unchanged — free |
|---|---|---|---|
| first run (takes the baseline) | 39 | 39 | — |
| a later run | 37 | 20 | 17 |

*Measured on a live watch list of Denver dentists, 31 August 2026. The first run always delivers
everything, because there is nothing yet to compare against.*

**A changed record tells you what moved:**

```json
{
  "name": "Warsaw Dental Center",
  "changeType": "changed",
  "changed": { "rating": { "from": 4.8, "to": 4.6 },
               "reviewCount": { "from": 312, "to": 340 } },
  "ratingChange": -0.2,
  "newReviews": 28,
  "phone": "+48 22 542 18 04",
  "website": "https://warsawdentalcenter.pl/",
  "googleMapsUrl": "https://www.google.com/maps/place/..."
}
```

**A business that closed is the loudest signal this list carries** — it arrives as a change with
`"closed": true`, even if you asked to skip closed businesses, because "keep them off my list" and
"tell me when mine shuts down" are two different requests.

**It also does plain one-off extraction.** Leave `onlyChanged` off and you get the full business
record: name, category, address, phone, website, rating, review count, opening hours, coordinates,
`placeId`.

### Three things this one does that the others do not

#### 1. Every record tells you whether the list ran out

`listComplete: false` means you are **not** looking at the whole list — never that there were no
more businesses. The run status tells you which of the two reasons applied, because they are not
the same thing and the difference changes what you should do:

- `LIST NOT EXHAUSTED for: …` — Google still had results we could not page through.
- `TRUNCATED BY YOUR LIMIT for: …` — we found the whole list and handed you part of it, because
  `maxItemsPerTerm` or `maxItems` said so. Raising the limit gets the rest; there is nothing
  further to discover at Google.

This is not a detail. A market analysis built on 120 records is worth something if 120 is all there
is, and worth nothing if it is where the budget ran out. Without this field the two look identical.

#### 2. The same business is one record, not two

Google returns duplicates **inside a single response** — measured on 2026-08-11, forty result cards
contained thirty-six distinct businesses. Across several searches the overlap is larger still.

We merge on the Google Place ID, never on the name: one city holds five places called *Starbucks*,
and merging by name would quietly delete four of them. A record we cannot identify is delivered
with `deduplicated: false` rather than merged into something it might not be.

#### 3. Filters are free

Ask for *open only, rated 4+, with a website* and the price does not move. A filter is not an
add-on — it is how you tell us what you want, and charging for it would penalise you for saving us
work. You are billed for delivered businesses and nothing else: not for results a filter removed,
not for merged duplicates, not for a run that found nothing.

Compare before you assume this is normal: the leading Google Maps Actor charges **$0.001 per place
per filter applied**, so three filters raise the bill by 75% above its headline price.

### Two fields that need a word of explanation

**`reviewCount` is often `null`, and that never means "no reviews."** Google shows the review
count next to the rating for some results in the search list and not for others — measured on
2026-08-14: one card in twenty-two carried it, and waiting longer did not change that. Opening
each business page would get it, at one extra page load per place; this Actor deliberately does
not do that, because it would multiply your cost and your run time for a field you may not need.
When the count is there, you get it. When it is not, the field is `null` — never `0`.

**`isSponsored: true` marks Google's paid placements.** They come mixed in with ordinary results,
and the same business can appear twice: once as an advert and once normally. On the advert the
"website" is a Google click-tracking address rather than the company's own site, so we leave
`website` empty there rather than hand you a tracking link. Filter on this field if you want only
organic results.

### Search terms: name the country

`dentist in Warsaw` returns dentists in Warsaw, Poland **and** in Warsaw, Indiana, mixed together —
we measured it. Google has no idea which one you meant and neither do we. Write
`dentist in Warsaw, Poland`, or put the area once in the **Area** field and let it apply to every
term.

Every record carries the `searchTerm` it came from, so wrong-city results are visible in the data
rather than hidden in an average.

### Input

| field | meaning |
|---|---|
| `searchTerms` | one or more searches, exactly as you would type them into Google Maps |
| `searchArea` | appended to every term, so you write the city once |
| `maxItems` | hard cap for the whole run — also your cost ceiling |
| `maxItemsPerTerm` | keeps one broad search from eating the entire budget |
| `minRating` | free filter; places with no rating are excluded when set |
| `skipClosedBusinesses` | free filter; closed businesses do still appear in Google Maps |
| `onlyWithWebsite` | free filter |
| `language` | affects category names and how opening hours are worded |
| `includeContacts` | on by default; off leaves phone and website out and skips the contact charge |
| `onlyChanged` | watch mode: deliver and charge only what moved since your last run |
| `watchLabel` | names the watch list, so two schedules do not overwrite each other's memory |

Give `language` a plain code (`pl`, `de`, `es`) and we pick the matching region for you. A full
code works too if it is a real one — `pt-BR`, `en-GB`, `es-MX`. A combination that does not exist,
like `pl-US`, is **not** passed through: the browser would silently fall back to US English and you
would be filtering categories you never asked for. We substitute the language's own region and say
so in the log.

### Watch mode: pay for what moved, not for the same list again

This is the reason to put this Actor on a schedule, and the one thing the alternatives do not do.

Set `onlyChanged: true` and give the run a `watchLabel`. The first run hands you the full list and
remembers it. Every run after that compares what it finds against that memory and delivers **only
the places that actually changed** — with the previous value next to the new one, so you do not
have to keep your own history.

| you get | when |
|---|---|
| `changeType: "new"` | a place we had never seen on this watch list |
| `changeType: "changed"` + `changed` | rating, review count, phone, website, hours or status moved |
| `changeType: "missing"` | a place that was on the list before and is not in the current results |
| nothing | the place is exactly as it was — **not delivered, not charged** |

Watching 300 restaurants daily therefore costs what the movement costs — usually a few percent of
the list — instead of 300 charges every morning.

**Three things we say out loud, because they decide whether you can trust the alert:**

- **`missing` does not mean "closed".** It means the place was not in the results we saw. Google
  reshuffles rankings. Every `missing` record carries that caveat in the record itself.
- **If part of the list was not searched** — a term timed out, hit a consent wall, or your
  `maxItems` ran out — we do **not** report disappearances at all that run, and the run card says
  so. Absence of evidence is not evidence of absence.
- **A place removed by your own filters is not silently swallowed.** If a watched place changed but
  your `minRating` or `onlyWithWebsite` filter removed it, the run card tells you how many.

Two schedules watching different lists need two different `watchLabel` values, otherwise they
overwrite each other's memory and everything looks new.

### What it costs

Two charges, and you can switch the second one off.

| charged event | when | price on the free plan |
|---|---|---|
| **Scraped place** | every business actually delivered to your dataset | $0.005 |
| **Contact included** | on top, and only when that place really has a phone number or a website | $0.004 |

Both drop with your Apify plan — down to $0.0008 and $0.0007 on the Diamond tier.

**If you do not need contacts, switch off `includeContacts`.** The phone and website fields are
then left out of the record entirely and the second charge never happens: you pay $0.005 per
place, full stop.

Nothing is charged for results a filter removed, for duplicates merged into one record, for a run
that found nothing, or for a business we handed you after failing to save the run state.

**The whole-run ceiling is `maxItems`.** With contacts on, `maxItems: 500` cannot cost more than
500 × ($0.005 + $0.004) = **$4.50** on the free plan — and less if some places have no phone or
website, because those are not charged the second event. With contacts off it cannot exceed
500 × $0.005 = **$2.50**. Set `maxTotalChargeUsd` on the run if you want a hard cap in dollars.

### Limits, stated up front

- **Reviews and reviewer names are out of scope.** They are personal data and copyrighted text at
  the same time, and this Actor does not touch them.
- **No email harvesting from business websites.** The `website` field is the address Google shows;
  we do not visit it.
- **We only use the paths Google's `robots.txt` allows** (`/maps/search/`, `/maps/place/`). The
  cheaper internal endpoint that other tools call sits under `Disallow: /search`, and we do not
  build that address. This costs us speed and we accept it.
- **A residential proxy is required.** Google does not serve Maps to datacentre addresses. The
  default already selects one.
- **No "scrape a whole city" mode**, and there will not be one. This Actor runs *your* query.
- **A business whose name looks like a street number can land in `address`.** When Google gives us
  neither a category nor a separate address line for a card — only the name — we decide which field
  it belongs to by shape. `Topiel 11` is an address; `7-Eleven` is not, and we get that right. But
  `24/7 Fitness` has the shape of a Polish house number (`4/6`) and will be read as an address.
  Every rule we tried that fixed this broke a real address somewhere else, so we are telling you
  instead of guessing better. It affects only cards where Google published nothing but the name.
- **Google's consent screen can stop a run, and we will say so.** Outside the EU, Google sometimes
  puts a cookie-consent wall in front of Maps. We click it through five different selectors, two of
  which are language-independent, but we cannot promise all 23 supported languages. When the wall
  is there and none of our selectors matched it, the run returns **zero places, charges you
  nothing**, and the log says plainly that this is our bug — not "no such businesses". Retry with a
  different proxy country (`proxyConfiguration.apifyProxyCountry`) and tell us which language it
  was; that is the one thing we cannot test without hitting the wall ourselves.

### If it worked for you

Ratings are the one thing we cannot build ourselves, and on Apify they feed the quality score that
decides whether anyone finds this Actor at all. If it did the job, a rating takes a few seconds
and genuinely changes what happens to it.

If it did **not** do the job, the issue tab is more useful to us than a low rating with no detail —
tell us the search that failed and what you expected, and it becomes a test case in the next
release.

# Actor input Schema

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

One or more searches, exactly as you would type them into Google Maps. Add a city and country — 'dentist in Warsaw' alone also returns Warsaw, Indiana.

## `searchArea` (type: `string`):

Appended to every search term, so you do not have to repeat the city in each one. Example: 'Krakow, Poland'.

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

Hard cap and your cost ceiling. When it stops the run early, every record says so in listComplete.

## `maxItemsPerTerm` (type: `integer`):

Keeps one broad search from eating the whole budget. Defaults to the run-wide cap.

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

Two-letter code passed to Google (hl). Affects category names and opening-hours wording.

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

Free filter. Decimals allowed (4.5). Places with no rating at all are excluded when this is set.

## `skipClosedBusinesses` (type: `boolean`):

Free filter. Closed businesses still appear in Google Maps results.

## `onlyWithWebsite` (type: `boolean`):

Free filter.

## `includeContacts` (type: `boolean`):

Phone number and website are what turn a listing into a lead, so they are charged as a separate event on top of the place itself — and only when the place actually has one. Switch this off and those two fields are left out of the record entirely and never charged.

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

Residential proxy is required — Google blocks datacentre addresses on Maps.

## `onlyChanged` (type: `boolean`):

Turn this on and schedule the Actor: you get only the places whose rating, review count, business status, phone or website moved — with the previous value next to the new one. Places that did not change are not delivered and not charged.

## `watchLabel` (type: `string`):

Name for this watch list, so two schedules watching different areas do not overwrite each other's memory.

## Actor input object example

```json
{
  "searchTerms": [
    "coffee shop in Krakow, Poland",
    "dentist in Warsaw, Poland"
  ],
  "maxItems": 20,
  "language": "en",
  "skipClosedBusinesses": true,
  "onlyWithWebsite": false,
  "includeContacts": true,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  },
  "onlyChanged": false,
  "watchLabel": "default"
}
```

# Actor output Schema

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

Every place delivered: name, category, rating, reviews, address, phone, website, hours — and whether the list was exhausted.

## `contacts` (type: `string`):

Name, phone, website, address, category — nothing else.

## `coordinates` (type: `string`):

Name with latitude, longitude, Place ID and the Google Maps link.

# 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": [
        "dentist in Warsaw, Poland"
    ],
    "maxItems": 20
};

// Run the Actor and wait for it to finish
const run = await client.actor("eszetael_lab/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 = {
    "searchTerms": ["dentist in Warsaw, Poland"],
    "maxItems": 20,
}

# Run the Actor and wait for it to finish
run = client.actor("eszetael_lab/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 '{
  "searchTerms": [
    "dentist in Warsaw, Poland"
  ],
  "maxItems": 20
}' |
apify call eszetael_lab/google-maps-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,eszetael_lab/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/3mRU3J5fFX7miuLV3/builds/r92kaHg5XakBr3I5c/openapi.json
