DEV Community

Jeffrey Turov for Apify

Posted on

Your scraper forgets everything between runs. Here's a review monitor that doesn't.

Your scraper forgets everything between runs. Here's a review monitor that doesn't.

A scraper that pulls Google Maps reviews once is a toy. What businesses actually pay for is a monitor: "tell me the moment a bad review lands." The difference between the two is one unglamorous feature — remembering what you've already seen.

I built Review Radar, an Apify Actor that watches a list of Google Maps places, scrapes recent reviews on a schedule, and posts a Slack alert the moment a new review at or below your star threshold appears. The Slack side uses Apify's MCP connectors, so the Actor never touches a token. Everything below comes from real runs — including the two state bugs that almost shipped.

What we're building

Review Radar:

  1. takes a list of Google Maps place URLs (or a search query),
  2. scrapes the latest reviews of each place with Playwright,
  3. diffs them against a persistent store of already-seen review IDs,
  4. pushes only genuinely new reviews to the dataset, flagged isNew: true, and
  5. posts a Slack alert for each new review at or below your threshold — via an MCP connector, so no Slack token ever enters the Actor's code.

Why Google Maps reviews? Because for hotels, restaurants, and local agencies, a 1-star review answered within an hour is recoverable; the same review discovered three weeks later is a lost customer. Review monitoring is a product businesses already pay monthly for — and the official Google Business API only covers businesses you own, not your competitors or your clients' portfolios.

The three parts that actually matter

  1. Scraping the reviews panel

On a Maps place page, reviews live behind the "Avis"/"Reviews" tab. The extraction selectors that survived contact with production:

  • Review blocks: div[data-review-id] — with a trap I detail below
  • Author: div.d4r55
  • Rating: span.kvMYJc[role="img"] — parse the number from the aria-label ("4 étoiles" / "4 stars")
  • Text: span.wiI7pd
  • Relative date: span.rsqaWe

If no review block is visible on load, click the tab first: button[aria-label*="Avis"] or button[aria-label*="Reviews"]. Then scroll the panel (div.m6QErb.DxyBCb.kA9KIf.dS8AEf) until you have enough blocks. Google Maps requires a residential proxy — datacenter IPs get consent-walled or blocked outright.

  1. State: the difference between a scraper and a monitor

The Actor keeps a named key-value store (review-radar-state) with one record per place: the set of review IDs already seen. Each run:

  • hashes each review to a stable ID (the native data-review-id when present, otherwise a SHA-1 of author+date+text),
  • flags isNew: true only for IDs not in the store,
  • persists the updated set at the end.

Proof from two consecutive real runs on the same restaurant. First run:

Cindy P. | 5★ | isNew=True
Raquel G. Urbano | 4★ | isNew=True
Chri Cou1967 | 5★ | isNew=True
Enter fullscreen mode Exit fullscreen mode

Second run, minutes later, identical reviews:

Cindy P. | 5★ | isNew=False
Raquel G. Urbano | 4★ | isNew=False
Chri Cou1967 | 5★ | isNew=False
Enter fullscreen mode Exit fullscreen mode

That False is the entire product. A scheduled run now only surfaces what changed.

  1. Slack alerts without a token in sight

Same connector model as my previous build: the user authorizes Slack once in Apify Console → Settings → Integrations. The Actor declares the connector in its input schema and receives a connector ID at runtime — never a token:

"slackConnector": {
  "title": "Slack connector (optional)",
  "type": "string",
  "resourceType": "mcpConnector",
  "mcpServers": [
    { "url": "*", "tools": { "required": ["*message*", "*chat*", "*post*", "*send*"], "readOnly": false } }
  ]
}
Enter fullscreen mode Exit fullscreen mode

The mcpServers declaration does double duty: it filters which connectors the picker offers, and the proxy refuses any tool call outside that list. At runtime the Actor lists the connector's actual tools and picks the first one matching post/chat/message/send, then formats the alert:

🚨 Nouvel avis 2★ sur *La Maison Lefèvre* (J. Dupont, il y a 2 jours)
Service décevant, attente de 40 minutes...
https://google.com/maps/place/...
Enter fullscreen mode Exit fullscreen mode

Making the connector input optional ("required": []) was deliberate: without it, the Actor still produces the full dataset — which also makes the Actor testable without touching your Slack workspace.

The two bugs that almost shipped

Bug 1 — key-value store key charset. I keyed place records by the Maps feature ID (0x45d3f1...:0x...). Apify record keys only allow a-zA-Z0-9!-_.'() — the colon is illegal. The Actor scraped everything perfectly, then crashed on the very last line of the run. One regex fixed it, but it's exactly the class of bug that only appears after a full successful scrape:

place_key = re.sub(r"[^a-zA-Z0-9!\-_.'()]", "_", place_key)
Enter fullscreen mode Exit fullscreen mode

Bug 2 — duplicated review blocks. Querying both div[data-review-id] and the legacy div.jftiEf.fontBodyMedium selector returns overlapping containers — the same review twice. My first test dataset had Cindy P. duplicated. Fix: dedupe by review ID inside the scrape loop, before anything reaches the dataset.

What I'd do differently at scale

  • Relative dates ("il y a 2 semaines") don't sort. For precise alerting windows, resolve them against the run date.
  • The Slack tool argument names vary by connector (text, message, content). I inspect the tool's input schema and map fields — brittle but workable until connector schemas stabilize.
  • Photos in reviews aren't extracted yet — for hospitality clients, a photo of a dirty room matters more than the text.

Try it

The Actor is review-radar on my Apify account. Inputs: place URLs (or a search query), reviews per place, star threshold, optional Slack connector. First run reports the existing batch as new; every run after that reports only what changed. Point a daily schedule at it and you have a review monitoring product for the cost of a few compute units.

A note on terms: Google Maps scraping sits uneasily with Google's ToS. Keep volumes polite (a handful of places, daily cadence), use a residential proxy, weigh the risk for production use, and prefer official sources where they cover your need — the Business Profile API works fine for businesses you own, it just can't watch anyone else's.

Top comments (0)