# Google Maps Reputation & Competitor Review Monitor (`aafholdings/google-maps-reputation-competitor-review-monitor`) Actor

Monitor your business and competitors on Google Maps. Detect rating movement, review-count changes, new negative reviews, and recurring reputation trends across scheduled runs.

- **URL**: https://apify.com/aafholdings/google-maps-reputation-competitor-review-monitor.md
- **Developed by:** [Ed Wheeler](https://apify.com/aafholdings) (community)
- **Categories:** Business, Marketing, Automation
- **Stats:** 1 total users, 0 monthly users, 0.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$20.00 / 1,000 successful location checks

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

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

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

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

## How to integrate an Actor?

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

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

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

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

# README

## Google Maps Reputation & Competitor Review Monitor

An Apify Actor that watches Google Maps locations over repeated runs and reports what
changed: new reviews, rating changes, and review-count movement. Track your own
business and competitors side by side so you can see reputation movement relative to
the market, not just in isolation.

This is a **standard Apify Actor** (TypeScript + Crawlee + Playwright). It is not an
MCP server and does not run in Standby/web-server mode — it runs to completion and
writes its results to the Actor's dataset and key-value store, the same as any other
scheduled Actor.

### What it does

1. You give it a list of Google Maps locations (your own business and/or competitors).
2. Each run it visits every location with a headless browser, reads the current name,
   star rating, review count, and a bounded sample of recent reviews.
3. It compares that to the snapshot it saved last time (per location, persisted in a
   named Apify key-value store) and reports:
   - whether this is the **first observation** for that location (no prior snapshot),
   - a **rating change** and what it changed from,
   - the **review-count delta** since the last run (a simple velocity signal — delta
     per monitored interval),
   - which reviews are **new** since the last run, and which of those are negative
     (3 stars or fewer), so a sudden run of bad reviews doesn't get buried.
4. It writes one dataset item per location per run, and a run summary (own vs.
   competitor average rating, totals for new/negative reviews, rating changes) to the
   key-value store under `RUN_SUMMARY`.

Run it on an Apify schedule (e.g. daily) and the dataset becomes a time series of your
reputation and your competitors', without anyone manually re-exporting review data.

### Input

See `.actor/input_schema.json` for the full schema. Summary:

| Field | Type | Default | Notes |
|---|---|---|---|
| `locations` | array (required) | — | 1–20 entries. Each has `url` (a `google.com`/`maps.app.goo.gl` Google Maps URL — place page, or a `maps/place/?q=...` search/place-id URL), optional `label`, and `role` (`"own"` or `"competitor"`, default `"own"`). |
| `maxReviewsPerLocation` | integer | 10 | 1–50. Bounds how many recent reviews are read and stored per location. |
| `maxConcurrency` | integer | 2 | 1–4. Browser pages run concurrently; kept low by default to be gentle on Google Maps. |
| `navigationTimeoutSecs` | integer | 45 | 10–90. Per-page navigation timeout. |

Only `https://` URLs on `google.com`-family hosts under a Maps path are accepted;
anything else is rejected before any browser is launched (this input is untrusted —
it may be a customer's own URL or garbage, and it never reaches a shell or gets
`eval`'d).

#### Example input

```json
{
  "locations": [
    { "url": "https://www.google.com/maps/place/?q=place_id:ChIJ...", "label": "My Pizzeria — Main St", "role": "own" },
    { "url": "https://www.google.com/maps/place/?q=place_id:ChIJ...", "label": "Competitor Pizzeria", "role": "competitor" }
  ],
  "maxReviewsPerLocation": 15,
  "maxConcurrency": 2
}
```

### Output

**Dataset** — one item per location per run (schema in `.actor/dataset_schema.json`):

```json
{
  "locationKey": "a1b2c3...",
  "label": "My Pizzeria — Main St",
  "role": "own",
  "sourceUrl": "https://www.google.com/maps/place/?q=place_id:ChIJ...",
  "status": "ok",
  "observedAt": "2026-10-01T12:00:00.000Z",
  "name": "My Pizzeria",
  "rating": 4.6,
  "reviewCount": 214,
  "ratingChanged": true,
  "reviewCountDelta": 3,
  "newReviewCount": 3,
  "newNegativeReviewCount": 1,
  "newReviews": [ { "id": "...", "author": "...", "rating": 2, "relativeDate": "2 days ago", "text": "..." } ]
}
```

`status` is one of:

- `"ok"` — full data was read.
- `"partial"` — some data was read (e.g. the name/rating) but another part (e.g. the
  review list) could not be confirmed this run; see the item's `error` field for why.
  This happens for real, documented reasons — see **Acquisition reliability** below —
  it is never a guess dressed up as a result.
- `"failed"` — nothing usable was read for this location this run (e.g. navigation
  timeout, or the location couldn't be found). Other locations in the same run are
  unaffected.

**Key-value store** — `RUN_SUMMARY` holds a single object per run:

```json
{
  "status": "ok",
  "requestedLocations": 4,
  "successfulLocations": 4,
  "partialLocations": 0,
  "failedLocations": 0,
  "ownLocations": 1,
  "competitorLocations": 3,
  "newReviews": 5,
  "newNegativeReviews": 1,
  "ratingChanges": 1,
  "reviewCountDeltaTotal": 8,
  "ownAverageRating": 4.6,
  "competitorAverageRating": 4.1
}
```

### State and scheduling

Prior snapshots are kept in a **named** key-value store
(`google-maps-reputation-monitor-state`), separate from the run's default store, so
they survive from one scheduled run to the next. The first run for any given location
always reports `firstObservation: true` and no changes — there is nothing to diff
against yet, and the Actor will not fabricate a "change" for a baseline. Every run
after that compares against what was actually saved last time.

This means the Actor works naturally with **Apify Schedules**: point a schedule at it
with the same input and it will build up a real change history. Running it twice in a
row with no schedule in between is also safe — see **Idempotency** below.

### Acquisition reliability (read this before relying on review counts)

Google serves a GDPR consent interstitial (`consent.google.com`) to EU-geolocated
traffic before any Maps content is reachable at all. This Actor detects and submits
that consent form automatically (matched by stable hidden-field names, not by
locale-dependent button text) as part of every navigation.

Beyond that, **this was directly observed while building this Actor and is the single
biggest reliability risk in the product**: Google Maps does not consistently serve the
same amount of detail for the same URL. The same place URL, hit moments apart with a
fresh browser session, sometimes returns the full panel (name, rating, review count,
and reviews) and sometimes returns a stripped-down "limited view" (a visible "You're
seeing a limited view of Google Maps — sign in for the full experience" banner, no
review count, no review list). This is not a parsing bug and not something a different
CSS selector fixes — it is Google varying what it serves to a given session.

Rather than guess or silently drop data, this Actor:

- extracts every field independently and never invents a value it couldn't confirm,
- detects the "limited view" banner explicitly and reports it as the reason
  (`limited_view_served_by_google`) when it's the cause,
- marks the location `"partial"` (with the specific missing fields listed) rather than
  `"ok"` whenever anything had to be skipped,
- never silently retries into fabricating a better-looking result — a `"partial"` or
  `"failed"` status is the honest output, by design.

**No paid third-party Maps API was substituted in to paper over this.** The free,
direct-navigation path above is what's shipped; its real failure mode is documented
here instead of hidden. If you need guaranteed 100%-complete review counts on every
single run, that would require Google's paid Places API, which this Actor deliberately
does not require you to pay for or configure.

Verify it yourself: `npm run smoke:live` runs this Actor's actual acquisition code
(not a mock) against a real, public Google Maps place page and prints exactly what was
extracted and which warnings fired.

### Idempotency and retries

- Transient page/navigation failures are retried per-location (bounded retries); a
  retry re-visits that one location, it does not re-run the whole Actor or reprocess
  locations that already succeeded.
- A location is only ever counted once per run toward pay-per-event charging (see
  below) — an internal retry on the same location cannot double-charge, because the
  charge only fires once a location has produced a usable result, guarded by a
  per-run, per-location set.
- Running the Actor twice in a row with the same input and no intervening changes on
  Google's side produces the same diff result both times (no new reviews, no rating
  change) — the state store de-duplicates by content, not by run count.

### Resource bounds

Every dimension that could otherwise grow unboundedly from untrusted input is capped:
locations per run (1–20), reviews read per location (1–50), browser concurrency (1–4),
per-page navigation timeout (10–90s), and the request handler's own timeout. Browser
automation always runs headless with no extra permissions requested.

### Pricing (proposed, not applied)

This Actor is designed for Apify's **Pay Per Event** model but ships unpriced — see
[`docs/PRICING.md`](docs/PRICING.md) for the proposed event, rationale, and
double-charge safeguards. No payout configuration, KYC, or terms acceptance has been
completed, and the Actor will not be published by this build step.

### Support and limitations

- Google Maps' DOM, consent flow, and "limited view" behavior can change at any time;
  this Actor has no dependency on an undocumented private API, only on the public page
  that any signed-out browser gets, so it degrades (`partial`/`failed`) rather than
  breaking silently when Google changes something.
- It reads what's visible on a standard signed-out Google Maps page. It cannot see
  reviews Google itself has filtered, and very large locations' full review history is
  intentionally not scraped in bulk — this is a reputation *monitor*, not a bulk
  review exporter.
- It does not post, reply to, or otherwise write anything back to Google Maps.

### Local development

```bash
npm install
npm run typecheck
npm run lint
npm test
npm run smoke:live   # hits real Google Maps once, proves acquisition works
npm run start:dev    # runs the Actor locally (reads ./storage, needs an INPUT)
```

# Actor input Schema

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

Add your business and competitors. Use Google Maps place URLs or maps.app.goo.gl links.

## `maxReviewsPerLocation` (type: `integer`):

Maximum number of recent visible reviews to inspect and store for each location.

## `maxConcurrency` (type: `integer`):

Maximum number of Google Maps pages processed at the same time. Lower values are gentler and more reliable.

## `navigationTimeoutSecs` (type: `integer`):

Maximum time in seconds to wait for each Google Maps page navigation.

## Actor input object example

```json
{
  "maxReviewsPerLocation": 10,
  "maxConcurrency": 2,
  "navigationTimeoutSecs": 45
}
```

# Actor output Schema

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

Per-location Google Maps monitoring results, including rating movement, review-count changes, new reviews, and negative-review events when available.

## `runSummary` (type: `string`):

Run-level summary stored under RUN\_SUMMARY in the default key-value store.

# 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 = {};

// Run the Actor and wait for it to finish
const run = await client.actor("aafholdings/google-maps-reputation-competitor-review-monitor").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 = {}

# Run the Actor and wait for it to finish
run = client.actor("aafholdings/google-maps-reputation-competitor-review-monitor").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 '{}' |
apify call aafholdings/google-maps-reputation-competitor-review-monitor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,aafholdings/google-maps-reputation-competitor-review-monitor"
        }
    }
}
```

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/VLZHiVGw9kz9w8cLn/builds/ehmPAJ3nZ6vaeyUwg/openapi.json
