# Google Maps Competitor Change Tracker (`kayhermes/google-maps-competitor-change-tracker`) Actor

Compare structured Google Maps listing snapshots or persist a monitor baseline to detect explainable competitor changes, review velocity, closures, category moves, profile updates, and new or removed listings.

- **URL**: https://apify.com/kayhermes/google-maps-competitor-change-tracker.md
- **Developed by:** [Khoa Nguyen](https://apify.com/kayhermes) (community)
- **Categories:** Social media
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$10.00 / 1,000 qualified competitor change results

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/platform/actors/running/actors-in-store#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 Competitor Change Tracker

Turn two structured Google Maps listing snapshots into explainable competitor alerts, or run in monitor mode and compare each new snapshot with a baseline stored in Apify Key-Value Store.

This Actor is designed for local SEO agencies, franchise operators, multi-location brands, and competitive-intelligence teams. It does **not** scrape Google Maps directly. It accepts listing data from an existing compliant data source, normalizes common scraper/export formats, safely matches entities, detects meaningful changes, scores impact, and publishes an agency-ready report.

### What it detects

- New and removed competitors
- Business status changes, including temporary and permanent closure
- Rating, review-count, and review-velocity movement
- Primary and secondary category changes
- Opening-hours changes
- Name, address, phone, website, and coordinate changes
- Price-level and claimed-profile changes
- Photo-count, description, attributes, and service-option changes
- Booking, ordering, and menu URL changes

Each alert contains the before/after evidence, matching method, confidence, field-level explanations, impact score, severity, and a recommended action.

### Two-minute start

The default input runs fully offline and requires no credentials:

```json
{
  "runMode": "demo"
}
```

The demo uses realistic bundled fixtures but sends them through the same normalization, validation, deduplication, matching, diff, scoring, filtering, dataset, and report pipeline used by live inputs.

Expected default output:

- Non-empty default dataset with qualified alert records
- `OUTPUT` summary in the default Key-Value Store
- `REPORT.md` human-readable report
- `NORMALIZED_CURRENT` normalized snapshot
- `MATCH_DIAGNOSTICS` data-quality and matching evidence

### Input modes

#### Compare two snapshots

Provide two inline arrays:

```json
{
  "runMode": "compare",
  "previousObservedAt": "2026-06-01T00:00:00Z",
  "currentObservedAt": "2026-07-01T00:00:00Z",
  "previousSnapshot": [
    {
      "placeId": "ChIJ-example-1",
      "name": "Alpha Dental",
      "address": "1 Main Street",
      "rating": 4.4,
      "reviewCount": 120
    }
  ],
  "currentSnapshot": [
    {
      "placeId": "ChIJ-example-1",
      "name": "Alpha Dental",
      "address": "1 Main Street",
      "rating": 4.6,
      "reviewCount": 146,
      "bookingUrl": "https://alpha-dental.example/book"
    }
  ]
}
```

You can instead provide public HTTPS URLs with `previousSnapshotUrl` and `currentSnapshotUrl`. URL sources can return JSON arrays, objects containing `items`, `results`, or `data`, JSONL/NDJSON, or CSV.

#### Monitor a recurring baseline

First run:

```json
{
  "runMode": "monitor",
  "baselineKey": "COMPETITOR_DETROIT_DENTISTS",
  "currentSnapshot": [
    {
      "placeId": "ChIJ-example-1",
      "name": "Alpha Dental",
      "rating": 4.6,
      "reviewCount": 146
    }
  ]
}
```

The default `firstRunBehavior` is `store-and-exit`, so the first run creates the baseline without generating false “new competitor” alerts. Later runs load that state, compare it with the new snapshot, and update the baseline only after processing and persistence complete successfully. When the SDK reports that the pay-per-event limit was reached, the baseline is not advanced, preventing silent loss of undelivered changes.

Use different `baselineKey` values to maintain independent markets, brands, or campaigns.

### Supported source fields

The normalizer accepts common aliases rather than requiring one vendor-specific export. Examples include:

| Canonical field | Accepted examples |
|---|---|
| Place ID | `placeId`, `place_id`, `googlePlaceId` |
| CID | `cid`, `googleCid` |
| Name | `name`, `title`, `businessName`, `companyName` |
| Address | `address`, `fullAddress`, `formattedAddress`, `streetAddress` |
| Rating | `rating`, `totalScore`, `stars`, `averageRating` |
| Reviews | `reviewCount`, `reviewsCount`, `userRatingCount`, `numberOfReviews` |
| Phone | `phone`, `phoneNumber`, `telephone`, `phoneUnformatted` |
| Website | `website`, `websiteUrl`, `url`, `companyUrl` |
| Coordinates | top-level latitude/longitude or nested `location`/`coordinates` |
| Hours | object or array forms under `hours`, `openingHours`, `regularOpeningHours`, `workingHours` |
| Timestamp | `observedAt`, `scrapedAt`, `timestamp`, `capturedAt` |

Rows missing a business name or containing invalid ratings, review counts, or coordinates are isolated as row errors. The run fails only when the configured invalid-row ratio is exceeded.

### Entity matching

Matching is one-to-one and evidence-based:

1. Exact Google Place ID
2. Exact Google CID
3. Strong normalized keys such as phone, domain, and name/address fingerprint
4. Candidate-blocked fuzzy matching using business-name tokens, address similarity, and geographic distance
5. Ambiguity rejection when two candidates are too close in score

A listing with a different explicit Place ID is never fuzzy-matched to another explicit Place ID. A shared chain website alone cannot match distant branches. Exact IDs are strongly recommended for the most reliable monitoring.

### Output record

A qualified dataset item contains:

```json
{
  "recordType": "alert",
  "schemaVersion": 3,
  "alertId": "alert:...",
  "entityId": "place:ChIJ-example-1",
  "changeType": "UPDATED",
  "competitorName": "Alpha Dental",
  "severity": "high",
  "impactScore": 78,
  "confidence": 0.71,
  "headline": "Alpha Dental changed reviewCount, rating and bookingUrl (high)",
  "recommendedAction": "Compare review acquisition velocity and investigate the movement.",
  "match": {},
  "changes": [],
  "metrics": {},
  "competitor": {
    "previous": {},
    "current": {}
  },
  "sourceUrl": null,
  "detectedAt": "2026-07-01T00:00:00.000Z",
  "provenance": {}
}
```

See `sample-output/dataset.json` for complete records generated from the bundled fixtures.

### Pay-per-event behavior

The primary custom event is:

```text
google-maps-competitor-change-tracker-result
```

One custom event is passed to `Actor.pushData()` for each alert that survives validation, matching, thresholds, severity filtering, and result limits. Invalid-row error items, when enabled, are pushed without the custom event. Pricing still has to be configured in Apify Console; `pricing.example.json` is deployment documentation, not proof that pricing is active.

Roadmap pricing:

- Launch: $10 per 1,000 qualified results
- Target: $15 per 1,000 qualified results

### Local development

Requirements: Node.js 22 or newer.

```bash
npm ci
npm run typecheck
npm test
npm run samples
npm run validate
FORCE_LOCAL_RUNTIME=1 npm start
```

The local fallback writes Apify-compatible artifacts under `storage/`. On Apify, the Actor imports and uses the official `apify` SDK.

### Safety and reliability

- Strict TypeScript with `noUncheckedIndexedAccess`
- Record and response-size limits
- Bounded retry count with exponential backoff and jitter
- Per-request timeout
- HTTPS-only URL imports
- Credential-bearing, localhost, private, and reserved targets rejected
- Redirect destinations revalidated before follow-up
- Deterministic deduplication, alert IDs, sorting, and matching
- No browser automation, login bypass, CAPTCHA bypass, or anti-bot evasion
- No API key required for default Store QA
- Baseline state is not advanced after a reported billing limit

### Limitations

- The Actor analyzes structured snapshots; it does not collect Google Maps pages itself.
- Review velocity requires meaningful snapshot timestamps.
- Without Place ID or CID, matching is probabilistic and may reject ambiguous pairs rather than risk a false match.
- A missing listing means “not present in this snapshot,” not guaranteed business closure. Verify important removals.
- Scheduling, notifications, and webhooks are configured through Apify Platform around the Actor; they are not embedded notification providers.
- URL import allows public data only. Private network sources should be imported inline through an authorized upstream workflow.

### Project files

- `ARCHITECTURE.md` — product and engineering design
- `STORE_LISTING.md` — publish-ready Store copy
- `PRICING.md` — event strategy and economics
- `DEPLOYMENT.md` — cloud deployment checklist
- `sample-output/` — output generated by the real pipeline
- `BUILD_REPORT.md`, `VALIDATION.md` — generated build evidence in the final package

### License

Apache-2.0. See `LICENSE`.

# Actor input Schema

## `runMode` (type: `string`):

Demo compares bundled offline fixtures. Compare evaluates two supplied snapshots. Monitor loads the previous baseline from the default key-value store and updates it after a successful run.

## `previousSnapshot` (type: `array`):

Optional array of historical Google Maps listing objects. Used in compare mode. Common fields from major Google Maps scrapers are normalized automatically.

## `currentSnapshot` (type: `array`):

Optional array of current Google Maps listing objects. Used in compare or monitor mode.

## `previousSnapshotUrl` (type: `string`):

Optional public HTTPS URL returning JSON, JSONL/NDJSON, or CSV. Private-network and credential-bearing URLs are rejected.

## `currentSnapshotUrl` (type: `string`):

Optional public HTTPS URL returning JSON, JSONL/NDJSON, or CSV.

## `urlFormat` (type: `string`):

Auto detects from content type and extension. Set explicitly when the source does not send a useful content type.

## `previousObservedAt` (type: `string`):

Optional ISO date-time used when historical rows do not include observedAt/scrapedAt/timestamp. Enables review velocity calculations Runtime validation requires a valid ISO 8601 date-time.

## `currentObservedAt` (type: `string`):

Optional ISO date-time used when current rows do not include a timestamp Runtime validation requires a valid ISO 8601 date-time.

## `baselineKey` (type: `string`):

Key used in the default key-value store for monitor state. Change it to maintain independent competitor sets.

## `updateBaseline` (type: `boolean`):

When enabled, monitor mode replaces the saved baseline only after comparison and result persistence succeed.

## `firstRunBehavior` (type: `string`):

Choose whether the first run only stores a baseline or emits every current listing as a new competitor.

## `matchingStrategy` (type: `string`):

Safe uses exact identifiers and strong keys. Balanced adds name/address/geo evidence. Aggressive lowers the threshold but still rejects ambiguous matches.

## `minimumMatchConfidence` (type: `number`):

Base confidence required for a fuzzy match. Safe/aggressive strategy applies a small additional adjustment.

## `ambiguityMargin` (type: `number`):

Reject a fuzzy match when the best and runner-up scores are closer than this margin.

## `maximumMatchDistanceMeters` (type: `integer`):

Maximum distance for non-exact matching unless a strong phone match exists.

## `minimumSeverity` (type: `string`):

Only alerts at or above this severity are written as qualified results.

## `minimumRatingDelta` (type: `number`):

Ignore smaller average-rating movements.

## `minimumReviewCountDelta` (type: `integer`):

Absolute review-count movement required unless the percentage threshold is met.

## `minimumReviewPercentDelta` (type: `number`):

Percentage review-count movement required unless the absolute threshold is met.

## `minimumPhotoCountDelta` (type: `integer`):

Ignore smaller photo-count movements.

## `maximumResults` (type: `integer`):

Hard cap on sorted alert results. Highest-severity and highest-impact alerts are kept first.

## `maxRecords` (type: `integer`):

Reject unexpectedly large inputs before expensive processing.

## `maxInvalidRecordRatio` (type: `number`):

Fail the run if invalid rows exceed this fraction of a snapshot.

## `requestTimeoutSecs` (type: `integer`):

Timeout applied to each public snapshot URL request attempt.

## `maxRetries` (type: `integer`):

Retries transient network, HTTP 408/425/429, and 5xx failures with exponential backoff and jitter.

## `maxResponseBytes` (type: `integer`):

Reject responses larger than this byte limit.

## `emitErrorItems` (type: `boolean`):

Off by default. Invalid-row details always remain in OUTPUT diagnostics; enable this for downstream row-level remediation. Error items are not charged with the custom result event.

## `includeDiagnostics` (type: `boolean`):

Include matching, invalid-row, duplicate, filtering, and truncation diagnostics in OUTPUT.

## Actor input object example

```json
{
  "runMode": "demo",
  "urlFormat": "auto",
  "baselineKey": "COMPETITOR_BASELINE",
  "updateBaseline": true,
  "firstRunBehavior": "store-and-exit",
  "matchingStrategy": "balanced",
  "minimumMatchConfidence": 0.72,
  "ambiguityMargin": 0.08,
  "maximumMatchDistanceMeters": 5000,
  "minimumSeverity": "low",
  "minimumRatingDelta": 0.1,
  "minimumReviewCountDelta": 3,
  "minimumReviewPercentDelta": 2,
  "minimumPhotoCountDelta": 5,
  "maximumResults": 1000,
  "maxRecords": 25000,
  "maxInvalidRecordRatio": 0.1,
  "requestTimeoutSecs": 30,
  "maxRetries": 3,
  "maxResponseBytes": 25000000,
  "emitErrorItems": false,
  "includeDiagnostics": true
}
```

# Actor output Schema

## `alerts` (type: `string`):

No description

## `evidence` (type: `string`):

No description

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

No description

## `report` (type: `string`):

No description

## `normalizedCurrent` (type: `string`):

No description

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

// Run the Actor and wait for it to finish
const run = await client.actor("kayhermes/google-maps-competitor-change-tracker").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("kayhermes/google-maps-competitor-change-tracker").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 kayhermes/google-maps-competitor-change-tracker --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,kayhermes/google-maps-competitor-change-tracker"
        }
    }
}

```

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/sUfyI60k0Y2kt9DU0/builds/LDDrvdUnumneV3wG3/openapi.json
