# Google Maps Review Text Sentiment Analyzer (`coregent/google-maps-review-sentiment-analyzer`) Actor

Scrape Google Maps reviews and attach a hybrid sentiment score (star weight + non-AI keyword intensity) to every row. Built for reputation management, competitor benchmarking, and weekly brand-health monitoring at a fraction of LLM-based pricing.

- **URL**: https://apify.com/coregent/google-maps-review-sentiment-analyzer.md
- **Developed by:** [Delowar Munna](https://apify.com/coregent) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.50 / 1,000 scored review results

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?

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 Review Text Sentiment Analyzer

![Google Maps Review Text Sentiment Analyzer](https://raw.githubusercontent.com/coregentdevspace/google-maps-review-sentiment-analyzer-assets/main/thumbnail-google-maps-review-sentiment-analyzer.jpg)

Scrape Google Maps reviews and attach a **hybrid sentiment score** (star weight + non-AI keyword intensity) to every row. Designed for reputation management agencies, local SEO consultants, multi-location brand managers, and B2B sales teams who need fast, cheap brand-health signal without paying LLM-per-review prices.

V1 is deliberately a "first-pass" sentiment tool: no LLM summaries, no historical charting, no image extraction. You get one flat row per review with a calculated sentiment score and label. Reviews are billed only when they are unique and pass your filters, plus Apify's small start fee per run.

### ✨ Why this scraper

- **Hybrid sentiment, no AI** — baseline `(rating / 5)` adjusted by ±0.15 for each "intensity keyword" found in the review text. Catches the "5-star but rude" mismatch as `Mixed` instead of letting it hide behind the rating.
- **Pay-Per-Event** — one event per unique review row pushed to the dataset. See the Pricing tab for the live rate.
- **Full review lists, exact dates** — reviews come from a review API rather than Google's signed-out "limited view", so you get the full list (up to 4,490 per place) in the sort order you choose, each with its exact publish timestamp.
- **Recurring-run friendly** — pass the newest `review_id` from your last run as `lastScrapedId` with `newest` sort. The actor reads a small first batch and stops at that review, so daily and weekly monitoring runs return only what's new.
- **CSV-friendly output** — flat 11-field row, drops cleanly into Sheets/Excel/CRMs.

***

### 🚀 Quick start

The default input: the 100 newest reviews of one place. This is the run that produced the sample output and screenshot below.

```json
{
    "startUrls": [{ "url": "https://www.google.com/maps/place/Empire+State+Building/@40.7484405,-73.9878531,17z/" }],
    "maxReviews": 100,
    "sort": "newest",
    "minStars": 1,
    "customKeywords": [],
    "language": "en"
}
```

#### Weekly brand monitoring with complaint flags

Compare two competing observation decks and flag brand-specific complaint words in `custom_keyword_hits`.

```json
{
    "startUrls": [
        { "url": "https://www.google.com/maps/place/Top+of+the+Rock/@40.7593,-73.9794,17z/" },
        { "url": "https://www.google.com/maps/place/One+World+Observatory/@40.7133,-74.0134,17z/" }
    ],
    "maxReviews": 200,
    "sort": "newest",
    "minStars": 1,
    "customKeywords": ["line", "wait", "ticket", "staff"],
    "language": "en"
}
```

#### Recurring run for one place

Pass the newest `review_id` from your previous run. `lastScrapedId` is a single value for the whole run, so run one place per input when you use it.

```json
{
    "startUrls": [{ "url": "https://www.google.com/maps/place/Empire+State+Building/@40.7484405,-73.9878531,17z/" }],
    "maxReviews": 500,
    "sort": "newest",
    "lastScrapedId": "Ci9DQUlRQUNvZENodHljRjlvT25kU1JsRnFOMUV5VDBObGRqSmpjVmhtUldSeWNVRRAB"
}
```

`startUrls` accepts both the Apify request-list `{ url }` shape and bare strings. Use Google Maps **place** URLs. The actor finds the place by, in order of precision: its CID (`!1s0x…:0x…` in full Maps URLs), its Place ID (`query_place_id=` or `q=place_id:`), or the place name plus the map coordinates in the URL. Search-results URLs (`/maps/search/…`) without a Place ID can't be resolved; open the place and copy its URL instead.

***

### 📦 Output

The dataset has one view: **Reviews with sentiment** — a flat 11-field table.

#### Reviews with sentiment

![Reviews with sentiment table view](https://raw.githubusercontent.com/coregentdevspace/google-maps-review-sentiment-analyzer-assets/main/google-maps-review-sentiment-analyzer-output-reviews-with-sentiment-table-view.png)

#### Output fields (11)

| Field                 | Type    | Description                                                                                       |
| --------------------- | ------- | ------------------------------------------------------------------------------------------------- |
| `review_id`           | String  | Stable Google Maps review identifier. Use it as `lastScrapedId` on your next run.                 |
| `place_url`           | String  | The input Place URL the review came from.                                                         |
| `reviewer_name`       | String  | Reviewer's display name.                                                                          |
| `text`                | String  | Full review text in the reviewer's original language. Empty for star-only reviews.                |
| `rating`              | Integer | Star rating, 1–5.                                                                                 |
| `sentiment_label`     | String  | `Positive`, `Neutral`, `Mixed`, or `Negative`.                                                    |
| `sentiment_score`     | Float   | Hybrid score in `[0.0, 1.0]`, rounded to 2 decimals.                                              |
| `is_responded`        | Boolean | True if the business owner has replied to this review.                                            |
| `published_at`        | String  | Exact ISO 8601 publish timestamp.                                                                 |
| `custom_keyword_hits` | Array   | Distinct `customKeywords` matched in the review text (does **not** affect score). Empty if unset. |
| `scraped_at`          | String  | ISO 8601 timestamp of when the row was extracted.                                                 |

#### Sample records

From the Quick start run (100 newest Empire State Building reviews). A 1-star review whose "rude" lowers the score below the star baseline (0.2 − 0.15):

```json
{
    "review_id": "Ci9DQUlRQUNvZENodHljRjlvT2tOQ1ZGOTBOMWh2WkVnMFNFRllTa2R1UnpaQ1MzYxAB",
    "place_url": "https://www.google.com/maps/place/Empire+State+Building/@40.7484405,-73.9878531,17z/?hl=en",
    "reviewer_name": "Łukasz Wk",
    "text": "Not worth the view at the observation deck and very very unfriendly / rude staff: the black camera man shouting at customers when they want to skip the photo line. … Very unfriendly!!!",
    "rating": 1,
    "sentiment_label": "Negative",
    "sentiment_score": 0.05,
    "is_responded": false,
    "published_at": "2026-10-26T21:40:19.000Z",
    "custom_keyword_hits": [],
    "scraped_at": "2026-10-27T05:38:30.131Z"
}
```

A 4-star review lifted into `Positive` by "friendly" (0.8 + 0.15):

```json
{
    "review_id": "Ci9DQUlRQUNvZENodHljRjlvT201WlpHRjBObFJpVERoMmJtUkNiRXgyUVhCa1NsRRAB",
    "place_url": "https://www.google.com/maps/place/Empire+State+Building/@40.7484405,-73.9878531,17z/?hl=en",
    "reviewer_name": "Dorothy Mendiola",
    "text": "Every transition was smooth and staff was incredibly friendly. What a beautiful, historical experience.",
    "rating": 4,
    "sentiment_label": "Positive",
    "sentiment_score": 0.95,
    "is_responded": false,
    "published_at": "2026-10-26T19:32:26.000Z",
    "custom_keyword_hits": [],
    "scraped_at": "2026-10-27T05:38:30.320Z"
}
```

In that run, 60 of the 100 reviews were star-only (empty `text`), so they are scored on the star baseline alone.

***

### 🎯 Hybrid sentiment engine

Transparent rule-based score (0.0 – 1.0) combining star weight with keyword intensity — no AI, no external API.

**Baseline**: `rating / 5` (e.g. 3 stars → 0.6).

**Adjustments** (only when the review has text):

- **+0.15** per distinct positive intensity word match: *excellent · perfect · amazing · friendly · clean · love*.
- **−0.15** per distinct negative intensity word match: *bad · rude · slow · dirty · overpriced · worst · avoid*.

The score is clamped to `[0.0, 1.0]` and rounded to two decimal places.

**Labels**:

- `Positive` — score above 0.8
- `Neutral` — score 0.4 – 0.8 (so a 4-star review with no keyword hits, at exactly 0.8, is `Neutral`)
- `Negative` — score below 0.4
- `Mixed` — applied as an override when:
  - rating ≥ 4 AND a negative intensity word was found, **or**
  - rating ≤ 2 AND a positive intensity word was found.

Star-only reviews (empty text) skip the keyword adjustment entirely; their label comes straight from the score band.

***

### ⚙️ Filtering

| Filter               | Stage    | Effect                                                                                                                             |
| -------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `minStars`           | Pre-push | Reviews with `rating < minStars` are dropped before charging. Tracked in `reviews_filtered_out`.                                   |
| `lastScrapedId`      | Mid-read | When set, the place stops at that review_id; it and everything after it are skipped. Useful for daily / weekly recurring runs.     |
| Dedup by `review_id` | In-run   | Within a run, duplicate review_ids (e.g. across multiple Place URLs that share reviews) are dropped. Tracked in `reviews_deduped`. |

**Selective filters stop early.** With `minStars` above 1 or a `lastScrapedId`, reviews are read in growing batches instead of all at once. A place stops at the first review past the filter when the sort lines up with it (`newest` + `lastScrapedId`, `highest` + `minStars`). It also stops when most of the reviews read so far fall below `minStars`. A run stops starting new places once it has filtered out far more reviews than it delivered. Every early stop is logged with the place it affected and sets `filter_scan_cap_reached` in the run summary. To get many 5-star reviews from a low-rated place, sort by `highest`.

***

### 💰 Pricing

**Pay-Per-Event**. One event per unique review pushed to the dataset, plus Apify's standard start fee:

| Event           | Charged when                                                                                                                                                   |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `review-result` | Once per unique review that passed `minStars` and was successfully written to the dataset.                                                                     |
| Actor start     | Charged by Apify when a run starts, scaled by the run's memory (Apify's standard start fee). In return, Apify covers the first seconds of every run's compute. |

A run that returns no reviews costs only the small start fee. Live rates are on the actor's **Pricing** tab in the Apify Console; this README deliberately doesn't quote figures, because prices can change. Your bill is the start fee plus `charged_events × price_per_event`. The actor honors the user-configured per-run spending cap (`eventChargeLimitReached`) and stops cleanly when reached.

**Not charged**:

- Reviews dropped by `minStars`.
- Reviews dropped by review_id dedup.
- Reviews dropped by the `lastScrapedId` early-stop.
- Failed dataset pushes.
- Anything after the per-run spending cap is reached.

***

### 📊 Run summary

After each run, a `RUN_SUMMARY` entry is written to the key-value store. The counts for the Quick start run's 100 reviews:

```json
{
    "review_source": "dataforseo",
    "locations_processed": 1,
    "successful_inputs": 1,
    "failed_inputs": 0,
    "reviews_scraped": 100,
    "reviews_filtered_out": 0,
    "reviews_deduped": 0,
    "filter_scan_cap_reached": false,
    "charged_events": 100,
    "avg_sentiment_score": 0.97,
    "sentiment_distribution": { "Positive": 95, "Neutral": 3, "Mixed": 0, "Negative": 2 }
}
```

The summary also has `runtime_seconds`, `scraped_at`, and an `economics` object recording the review API's usage for the run. `charged_events` is the number of reviews actually billed, which equals the rows pushed to the dataset (shown above as it reads for a billed run).

***

### 🔌 Where the reviews come from

Google now shows signed-out visitors a **limited view** of Google Maps: no Reviews tab, and at most a short preview of a few reviews. So this actor reads reviews through the **DataForSEO Google Reviews API**, which returns the full review list in the sort order you choose, with exact publish timestamps. No browser and no proxy are used on that path, and you need no API key of your own.

Places are looked up from your URL in this order of precision: the place's CID (in full Maps URLs, `!1s0x…:0x…`), its Place ID (`query_place_id=` or `q=place_id:`), then the place name plus the map coordinates in the URL. A URL with none of these (for example `/maps/place//@lat,lng`) fails with a clear message.

### 🚦 Proxy policy

The proxy setting only applies to the browser fallback, which the actor uses when the review API is not configured. In that mode Apify Residential is recommended, and Google's limited view still caps each place at a short preview.

***

### 🚧 Limitations (V1)

- No LLM summaries or topic modeling.
- No historical charting / trend lines (V2).
- No image, photo, or "popular times" extraction.
- `published_at` is exact. Only the browser fallback parses "X months ago" into an **approximate** timestamp.
- Per-place cap is **4,490 reviews** (the review API's maximum depth).
- Sentiment lexicons are English-only in V1; reviews in other languages still get a star-baseline score but the keyword adjustment will be a no-op.

***

### 🛠️ Technical notes

- **Stack**: Node.js 22 · Apify SDK 3 · DataForSEO Google Reviews API; Crawlee + Puppeteer (headless Chromium) for the browser fallback.
- **Concurrency**: 3 places at a time on the review API; `maxConcurrency: 5` in the browser fallback.
- **Run summary**: `review_source` says which path ran; `economics` records review-API calls and their cost.
- **Memory**: 1 GB min · 2 GB default · 4 GB max.
- **Network** (browser fallback): image / stylesheet / font / media requests are blocked at the request-interception layer for speed.
- **Diagnostics**: on the first place where the Reviews tab cannot be reached, the actor saves the page HTML and URL to the key-value store as `debug-no-reviews-tab-html` / `debug-no-reviews-scroller-html` / `debug-zero-reviews-html`.

# Actor input Schema

## `startUrls` (type: `array`):

List of Google Maps place URLs to scrape. Each URL becomes one location whose reviews are extracted, scored, and pushed to the dataset. Bare strings or { url } objects are both accepted.

## `maxReviews` (type: `integer`):

Maximum number of reviews to scrape per location. Hard cap is 5000 per place.

## `sort` (type: `string`):

Order in which reviews are loaded from Google Maps before extraction.

## `minStars` (type: `integer`):

Discard reviews below this star rating. Filtered reviews are tracked in the run summary but do not trigger a review-result event charge.

## `customKeywords` (type: `array`):

Optional brand-specific keywords to flag inside review text. Matches are surfaced in the `custom_keyword_hits` output field but do NOT influence the sentiment score.

## `lastScrapedId` (type: `string`):

Optional. When set, the actor stops scrolling for a location as soon as it sees this review_id, saving cost on recurring runs. Leave empty for full pagination.

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

Language code used for Google Maps UI (IETF BCP-47), for example en, es, de. Forwarded as the hl query parameter.

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

Apify Proxy configuration. Apify Residential is REQUIRED for the Reviews tab as it is highly sensitive to IP reputation. Custom proxy URLs are also supported.

## Actor input object example

```json
{
  "startUrls": [
    {
      "url": "https://www.google.com/maps/place/Empire+State+Building/@40.7484405,-73.9878531,17z/"
    }
  ],
  "maxReviews": 100,
  "sort": "newest",
  "minStars": 1,
  "customKeywords": [],
  "lastScrapedId": "",
  "language": "en",
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}
```

# Actor output Schema

## `overview` (type: `string`):

Flat 11-field table of every review row pushed to the dataset, including reviewer, full text, rating, hybrid sentiment score and label, owner-response flag, approximate publish date, and any custom keyword matches.

# 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 = {
    "startUrls": [
        {
            "url": "https://www.google.com/maps/place/Empire+State+Building/@40.7484405,-73.9878531,17z/"
        }
    ],
    "proxyConfiguration": {
        "useApifyProxy": true,
        "apifyProxyGroups": [
            "RESIDENTIAL"
        ]
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("coregent/google-maps-review-sentiment-analyzer").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 = {
    "startUrls": [{ "url": "https://www.google.com/maps/place/Empire+State+Building/@40.7484405,-73.9878531,17z/" }],
    "proxyConfiguration": {
        "useApifyProxy": True,
        "apifyProxyGroups": ["RESIDENTIAL"],
    },
}

# Run the Actor and wait for it to finish
run = client.actor("coregent/google-maps-review-sentiment-analyzer").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 '{
  "startUrls": [
    {
      "url": "https://www.google.com/maps/place/Empire+State+Building/@40.7484405,-73.9878531,17z/"
    }
  ],
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}' |
apify call coregent/google-maps-review-sentiment-analyzer --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,coregent/google-maps-review-sentiment-analyzer"
        }
    }
}
```

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/d2GAJfUDxPTaXgV2B/builds/HqQSk4fdwEUMrc4uN/openapi.json
