# Google Maps Reviews & Sentiment Extractor (`fanndev/google-maps-reviews-sentiment-extractor`) Actor

Extract every Google Maps review for a place - rating, date, reviewer, like count, photos and the owner's reply - and score each one positive / neutral / negative with built-in sentiment analysis. Feed it Maps URLs, Place IDs or CIDs; export to JSON, CSV, Excel or NDJSON. No login, no API key.

- **URL**: https://apify.com/fanndev/google-maps-reviews-sentiment-extractor.md
- **Developed by:** [Faisal Ahdan naufal](https://apify.com/fanndev) (community)
- **Stats:** 3 total users, 2 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.30 / 1,000 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/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 Reviews & Sentiment Extractor

> **Correction (2026-09-16):** the claim below that free-text place search is closed is **wrong**. The sibling actor `google-maps-lead-generation-scraper` searches Google Maps successfully; the `pb` used here was malformed (it omitted the `!7i20!10b1!12m6…` result-request tail, so Google returned search metadata with no result list). This actor still takes place identifiers only, but the limitation is a design choice, not a platform restriction.

Pull every public Google Maps review for a place — rating, date, reviewer, like
count, photos and the **owner's reply** — and get each one scored positive,
neutral or negative automatically. Export to JSON, CSV, Excel or NDJSON.

No login, no Google API key, no browser. Pure HTTP.

***

### What you get per review

| Field | Notes |
| --- | --- |
| `rating` | 1–5 stars |
| `text` / `textTranslated` | Original text plus Google's translation when it has one |
| `publishedAt` | Real UTC timestamp, not just "2 months ago" (`relativeTime` has that too) |
| `reviewer` | Name, profile URL, avatar, total reviews, total photos, Local Guide flag |
| `likesCount` | How many people found the review helpful |
| `ownerResponse` | The business reply — text and when it was posted |
| `photos` | Photos attached to the review |
| `attributes` | Google's structured answers, e.g. "When did you visit?" → "Weekday" |
| `sentiment` | `label`, `score` (−1…1), `method`, and `ratingTextConflict` |
| `permalink` | Direct link to that single review |

Each place also produces one **PLACE** summary row: sentiment counts and
percentages, average rating, rating histogram, owner-response rate, total likes,
and a count of rating/text conflicts.

***

### Input

Supply places as **Google Maps URLs, Place IDs, CIDs, or feature IDs**:

```json
{
  "placeUrls": [
    { "url": "https://www.google.com/maps/place/Eiffel+Tower/@48.8583701,2.2944813,17z/data=!4m6!3m5!1s0x47e66e2964e34e2d:0x8ddca9ee380ef7e0!8m2!3d48.8583701!4d2.2944813" },
    { "url": "https://www.google.com/maps?cid=3292831917685799941" },
    { "url": "https://maps.app.goo.gl/xxxxxxxx" }
  ],
  "placeIds": ["ChIJrTLr-GyuEmsRBfy61i59si0", "0x6b12ae37b47f5b37:0x8eaddfcd1b32ca52"],
  "mode": "reviews",
  "sortBy": "newest",
  "maxReviewsPerPlace": 300,
  "exportFormats": ["csv", "excel"]
}
```

> **A free-text search query is not accepted.** Google closed every HTTP-only
> place-search surface, so this actor works from place identifiers. Paste the URL
> from your browser's address bar and you are done. See
> [Limitations](#limitations-read-before-buying).

#### Modes

| Mode | Emits | Use it for |
| --- | --- | --- |
| `reviews` *(default)* | Every review + a PLACE summary | Full extraction and analysis |
| `summary_only` | PLACE summary only, one page fetched | Cheap sentiment snapshot across many places |
| `owner_responses` | Only reviews the business replied to | Auditing reply rate and reply quality |

#### Useful options

| Option | Default | Notes |
| --- | --- | --- |
| `sortBy` | `newest` | `newest` is the only order that reliably walks the whole list. `relevant` returns a mixed-rating sample. |
| `maxReviewsPerPlace` | 100 | Google serves 60 on page 1, then 20 per page. |
| `minRating` / `maxRating` | — | Set both to 1 and 2 to isolate complaints. |
| `onlyWithOwnerResponse` | `false` | Same filter as `owner_responses` mode. |
| `language` / `region` | `en` / `US` | `hl` and `gl`. Also picks the translation target language. |
| `exportFormats` | `[]` | `json`, `csv`, `excel`, `ndjson` → written to the key-value store. |

***

### How the sentiment scoring works

Each review is scored by blending **VADER** text polarity with the **star
rating**, because neither alone is reliable:

- the rating is coarse — 3-star reviews are often strongly opinionated, and
  5-star reviews frequently contain real complaints;
- the text lexicon has no opinion at all on rating-only reviews, which are a
  large share of Google Maps data.

The default blend is 60% text / 40% rating, and `method` records which signals a
given row actually used:

| `method` | Meaning |
| --- | --- |
| `text+rating` | English review text blended with the rating |
| `translated+rating` | Non-English review, scored via Google's English translation |
| `rating-only` | No text, or a language with no translation available |
| `none` | Neither text nor rating — scored neutral, never dropped |

**Sarcasm handling.** A lexicon cannot detect sarcasm: *"Highly thrilling
experience 😡"* on a 1-star cancellation complaint scores **+0.94** on text
alone. When a confident text polarity contradicts an extreme rating (1, 2 or 5
stars), the rating is weighted to 75% — the reviewer's own star verdict is the
better ground truth for satisfaction. That review correctly lands at **−0.54,
negative**. Those rows are flagged with `sentiment.ratingTextConflict: true`, and
counted per place as `ratingTextConflicts` — they are usually the most
interesting reviews in the set, so they are surfaced rather than just corrected.

Scoring runs inside the actor: no API key, no per-review cost, no data sent
anywhere.

***

### Limitations (read before buying)

These are properties of what Google still exposes publicly, verified rather than
assumed. Full evidence in [CRAWLING\_METHOD.md](CRAWLING_METHOD.md).

1. **No free-text place search.** Google's Maps search is JS-only for
   non-browser clients and every legacy search endpoint now 404s. Supply place
   identifiers. Unrecognised input gets a clear error, never a wrong place.
2. **Place name and coordinates come from your URL, not from Google.** Google's
   resolver returns `null` for the name and the same default world-view viewport
   for every place. Paste a full `/maps/place/<Name>/...` URL and both are
   captured; supply a bare Place ID and both are `null`.
3. **Geographic filters filter, they do not discover.** Because of (1) and (2),
   `boundingBox` / `radiusKm` / `areaName` narrow the places *you* supply to
   those inside an area — they cannot find new places in it. A place with no
   coordinates is kept with a warning by default, or skipped with a `SKIPPED`
   record if you set `onMissingCoordinates: "skip"`. Nothing is dropped silently.
4. **Reviews per place are finite.** Google stops serving pages well before the
   lifetime review count of large places; the actor stops when the cursor runs
   out and reports how many it got.
5. **`averageRating` is the average of what was scraped**, not Google's lifetime
   average for the business.
6. **Sentiment is English-centric.** Non-English reviews are scored through
   Google's translation where available, and fall back to rating-only otherwise.
   `method` always tells you which happened.

***

### Anti-bot and proxy

Reconnaissance found **no WAF** on this endpoint: ten TLS fingerprints across
five Google country hosts all returned 200 from a plain residential connection.

**Do not use residential proxy here.** This is the opposite of the usual advice
and it was measured, not assumed:

| Egress | Result |
| --- | --- |
| Plain home ISP, no proxy | works |
| Apify platform, no proxy *(the default)* | works — 42 records in 7s |
| Apify platform, `RESIDENTIAL` | **fails** — Google answers the place resolver with an interstitial instead of the page |

Google polices residential proxy pools far harder than datacenter ranges,
because those IPs are widely abused. Since these endpoints have no WAF, the
residential IP buys nothing and costs you the run.

**The proxy is therefore off by default** and the actor runs on the platform's
own IP. Enable one only to spread load across IPs at high volume, and leave the
group list empty so Apify picks a group your plan actually has — naming a group
your account lacks (a free plan has no `DATACENTER`) fails the run at input
validation before it starts:

```json
{ "proxyConfiguration": { "useApifyProxy": true } }
```

The client impersonates Chrome 150 via `curl_cffi`, warms the Maps cookie jar
once per session, retries with exponential backoff, and rotates TLS profile and
cookie jar on 403/429/503.

***

### Output shape

Records follow the portfolio envelope — `_input`, `_source`, `_scrapedAt`,
`recordType` — with `_error` / `_errorDetail` on failures and `_warning` on
skips. Split a run by `recordType`: `PLACE`, `REVIEW`, `SKIPPED`, `ERROR`.

A place that fails to resolve produces an `ERROR` row rather than vanishing, so
you can always reconcile inputs against outputs.

***

### Development

```bash
pip install -r requirements.txt
python test_errors.py     # offline checks: parsing, geo, sentiment, exporters
python test_local.py      # live end-to-end run against Google
```

`test_local.py` reads `_input.json` when present, otherwise uses its built-in
default input.

# Actor input Schema

## `placeUrls` (type: `array`):

Google Maps URLs to scrape. Open a place in Google Maps and copy the URL from the address bar. Short links (maps.app.goo.gl) are followed automatically. NOTE: a free-text search query is NOT accepted here - Google no longer exposes place search to non-browser clients, so this actor works from place identifiers only.

## `placeIds` (type: `array`):

Alternative to URLs. Accepts a Place ID (ChIJ...), a numeric CID, or a raw feature ID (0x...:0x...). One per line.

## `mode` (type: `string`):

reviews = every review plus a per-place summary row (default). summary\_only = one page of reviews, emit only the aggregated sentiment summary (fast and cheap). owner\_responses = only reviews the business replied to, for response-rate analysis.

## `maxReviewsPerPlace` (type: `integer`):

Stop after this many reviews per place. Google serves up to 60 on the first page and 20 per page after that.

## `sortBy` (type: `string`):

Google applies this server-side. 'relevant' returns a mixed-rating sample; 'newest' is the only order that reliably walks the whole list.

## `minRating` (type: `integer`):

Keep only reviews rated at or above this value (1-5).

## `maxRating` (type: `integer`):

Keep only reviews rated at or below this value (1-5). Combine with the minimum to isolate, say, 1-2 star complaints.

## `onlyWithOwnerResponse` (type: `boolean`):

Keep only reviews the business has replied to.

## `boundingBox` (type: `array`):

Keep only places inside this box. Requires the input URL to carry coordinates (the !3d/!4d or @lat,lng part of a Maps URL) - see the README on geographic filtering.

## `centerLatitude` (type: `string`):

Latitude of the centre point, used with centre longitude and radius.

## `centerLongitude` (type: `string`):

Longitude of the centre point.

## `radiusKm` (type: `string`):

Keep only places within this many kilometres of the centre point.

## `areaName` (type: `string`):

Shorthand for a built-in bounding box, e.g. 'jakarta', 'bali', 'london', 'indonesia'. For anywhere else use an explicit bounding box.

## `onMissingCoordinates` (type: `string`):

How to treat a place whose input URL carries no coordinates while a geographic filter is active. 'keep' scrapes it anyway and logs a warning; 'skip' emits a SKIPPED record instead.

## `exportFormats` (type: `array`):

Also write the run's records to the key-value store in these formats. The Apify dataset is always produced regardless.

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

Google interface language. Also decides which language Google translates reviews into.

## `region` (type: `string`):

Two-letter country code biasing Google's response, e.g. US, ID, GB.

## `pageSize` (type: `integer`):

Reviews requested on the first page. Google caps this at 60 and always returns 20 per page afterwards.

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

Off by default, and that is the recommended setting. These endpoints have no WAF, so the platform's own IP works fine and costs nothing. Enable a proxy only to spread load across IPs at high volume, and leave the group list empty so Apify picks a group your plan has. RESIDENTIAL is NOT recommended here: Google polices residential proxy pools heavily and answers them with a consent or CAPTCHA interstitial, which makes places fail to resolve.

## Actor input object example

```json
{
  "placeUrls": [
    {
      "url": "https://www.google.com/maps/place/?q=place_id:ChIJrTLr-GyuEmsRBfy61i59si0"
    }
  ],
  "placeIds": [
    "ChIJLU7jZClu5kcR4PcOOO6p3I0"
  ],
  "mode": "reviews",
  "maxReviewsPerPlace": 100,
  "sortBy": "newest",
  "onlyWithOwnerResponse": false,
  "onMissingCoordinates": "keep",
  "exportFormats": [],
  "language": "en",
  "region": "US",
  "pageSize": 50,
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

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

Every review, place summary and error record produced by this run.

# 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 = {
    "placeUrls": [
        {
            "url": "https://www.google.com/maps/place/?q=place_id:ChIJrTLr-GyuEmsRBfy61i59si0"
        }
    ],
    "placeIds": [
        "ChIJLU7jZClu5kcR4PcOOO6p3I0"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("fanndev/google-maps-reviews-sentiment-extractor").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 = {
    "placeUrls": [{ "url": "https://www.google.com/maps/place/?q=place_id:ChIJrTLr-GyuEmsRBfy61i59si0" }],
    "placeIds": ["ChIJLU7jZClu5kcR4PcOOO6p3I0"],
}

# Run the Actor and wait for it to finish
run = client.actor("fanndev/google-maps-reviews-sentiment-extractor").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 '{
  "placeUrls": [
    {
      "url": "https://www.google.com/maps/place/?q=place_id:ChIJrTLr-GyuEmsRBfy61i59si0"
    }
  ],
  "placeIds": [
    "ChIJLU7jZClu5kcR4PcOOO6p3I0"
  ]
}' |
apify call fanndev/google-maps-reviews-sentiment-extractor --silent --output-dataset

```

## MCP server setup

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

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/lXwlzBgx7UTUrZAAp/builds/eoLFHWfvnOq6u1bWi/openapi.json
