# Google Maps Reviews Scraper — The Complete Review List (`thenetaji/google-maps-reviews-scraper`) Actor

The complete review list for a Google Maps business, not a sample of it. A Maps link or a place id returns every review newest first: text, star rating, posted and edited dates, the reviewer, attached photos and the owner reply. Star-only ratings are in there, so the total agrees with the page.

- **URL**: https://apify.com/thenetaji/google-maps-reviews-scraper.md
- **Developed by:** [The Netaji](https://apify.com/thenetaji) (community)
- **Categories:** Travel, Business, Marketing
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.48 / 1,000 reviews

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/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 Reviews Scraper

Every review a Google Maps business has, in one dataset, rather than the first few pages of them. A
Maps link or a place id goes in. Each review comes back newest first with its text, its star rating,
when it was posted and when it was last edited, who wrote it, any photos they attached, and the
business owner's public reply.

Star-only ratings are part of that list. Google counts them in the total it prints on a place's own
page, so keeping them is what makes the number in the dataset agree with the number a reader can see
for themselves: the largest place measured returned 5,950 reviews against the 5,950 Maps displays,
with no duplicate `review_id` anywhere in them.

### What every row carries

| Field | What it holds |
| --- | --- |
| `review_id` | Google's own id for the review, and the key to deduplicate on |
| `place_cid` | The place the review belongs to, joining to a business row's `cid` |
| `stars` | The rating given, 1 to 5 |
| `text` | The review body, or `null` on a star-only rating |
| `language` | The language Google detected the text to be in |
| `created_at`, `updated_at` | Posted and last-edited instants, UTC |
| `created_at_us`, `updated_at_us` | The same instants as Google's own microsecond integers |
| `relative_time` | Google's own wording of the review's age, as Maps shows it |
| `author_name`, `author_id`, `author_profile_url`, `author_avatar` | The reviewer |
| `author_review_count`, `author_photo_count`, `author_local_guide_level` | The reviewer's own contribution statistics |
| `review_photos` | Photos attached to the review, each with its link and pixel size |
| `owner_response_text`, `owner_response_at` | The business owner's public reply |
| `place_ref` | The place Google itself stamps on the review, used to verify the walk |

Switching on **Add business details to every review** adds `place_name`, `place_address`,
`place_categories`, `place_rating`, `place_phone` and `place_website` to every row of that place.

### Accepted input

`places` is required and takes one or more Google Maps places. A place is either Google's own
identifier in `0x…:0x…` form or a Maps link containing one; both are accepted, they may be mixed in
one list, and a link whose colon arrived percent-encoded from a copy-paste still resolves. A place
listed twice under two spellings is walked once.

`maxItems` caps how many rows the run saves and defaults to 100. Setting it to `0` removes the cap,
in which case the run continues to the end of the place's reviews.

`cursor` resumes a walk that stopped at the cap. It is described under *Resuming a capped run*.

`enrichPlaceDetails` defaults to `false`. When enabled, the business behind the reviews is looked up
once per place and its `place_name`, `place_address`, `place_categories`, `place_rating`,
`place_phone` and `place_website` are stamped onto every review row of that place. The lookup is one
extra call per place rather than per review, and it is billed once per place, only after it succeeds.

```json
{
  "places": ["0x808587fb77f5f64d:0x25d564f177f99946"],
  "maxItems": 100,
  "enrichPlaceDetails": false
}
```

### Response fields

```json
{
  "place_cid": "0x808587fb77f5f64d:0x25d564f177f99946",
  "review_id": "ChZDSUhNMG9nS0VJQ0FnSURfeGV5RVh3EAE",
  "stars": 5,
  "text": "Best espresso in the neighbourhood. Tiny room, huge care.",
  "language": "en",
  "created_at": "2025-05-14T12:00:00.000Z",
  "updated_at": "2025-05-14T12:00:00.000Z",
  "created_at_us": 1747224000000000,
  "updated_at_us": 1747224000000000,
  "relative_time": "3 months ago",
  "author_name": "Alex Rivera",
  "author_id": "112233445566778899000",
  "author_profile_url": "https://www.google.com/maps/contrib/112233445566778899000",
  "author_avatar": "https://lh3.googleusercontent.com/a/example=s120-c-rp-mo-br100",
  "author_review_count": 87,
  "author_photo_count": 412,
  "author_local_guide_level": 7,
  "review_photos": null,
  "owner_response_text": null,
  "owner_response_at": null,
  "place_ref": "0x0:0x25d564f177f99946"
}
```

Fields Google states nothing for are returned as null rather than omitted, so every row has the same
shape. `review_id` is Google's own identifier for the review and is the key to deduplicate on across
runs. `place_cid` is the place the row belongs to and joins to a business row's `cid`, which matters
when several places are walked in one run.

### Resuming a capped run

When a run stops because `maxItems` was reached and the place has more reviews, the run log prints a
continue token. Passing that token back as `cursor`, with the same single place in `places`, resumes
the walk where it stopped.

A token belongs to the place it was issued for, so it is passed back exactly as it was printed,
including the part before the `|`. A token sent with a different place is refused before a request is
spent, and a token cannot be sent alongside more than one place. The reason is in the questions
below.

### Questions

**How is "every review" checked, rather than assumed?**
Against the number Google Maps prints on the place's own page, which is independent of anything this
Actor can see. Four places were walked to the end and compared: 338 against a displayed 338, 1,722
against 1,723, 1,782 against 1,782, and 5,950 against 5,950. Across those 492 pages there were no
duplicate `review_id` values. The one missing review on the second place has no explanation beyond a
review being removed between the page being read and the count being taken.

**Why is `text` null on some rows?**
Because that row is a star-only rating rather than a broken row. It carries `stars`, an author and a
timestamp, and nothing else; 25 of 100 consecutive rows measured were of this kind, and across a
corpus they run to roughly 25 to 40 per cent of reviews. Maps counts them in the total it prints, so
filtering them out and then reporting a total produces a number that disagrees with the place's own
page. Filtering them for text analysis is reasonable; filtering them and then counting is not, and
the same applies to an average rating computed from the rest, because star-only rows are not a random
sample of opinions.

**Why is there no sort option?**
Because every order except newest returns a strict subset. Walked once per sort on the same
338-review place, newest reached 338, most relevant reached 323, highest rating reached 143 and
lowest rating reached 143. The union of all four is exactly 338, which is the newest set with nothing
in it that newest did not already return. A sort option would therefore let a run lose 57 per cent of
a place's reviews while every page still looked healthy. Sorting rows that have already arrived costs
nothing.

**What happens if a continue token is used with a different place?**
Nothing, because the pair is refused. That check exists because Google does not make it: a token
replayed against another place answers with THAT place's reviews, starting partway down its list.
The result is a full page of well-formed rows and the wrong answer to the question asked, which is
harder to notice than an error would be.

**Why did a run return fewer reviews than the place claims to have?**
Either `maxItems` capped it, in which case the run log carries a continue token, or the place has
fewer reviews than the number being compared against. A short page is not the end of a walk: the last
page of the 338-review place held 18 rows and no continue token, so a run ends when Google declines to
issue a token rather than when a page comes back thin.

**Why was the first request slow when the rest were fast?**
The reviews path needs a moment to get ready after an idle period, and that preparation can take
around 35 seconds. It is then amortised over hundreds of requests, so a run that pages deeply pays it
once. A run of a single small place pays it and then finishes, which makes the fixed cost look like
the whole cost.

**Can reviews be filtered by rating or date at the source?**
No. Google's own filters are the sort orders above, and each of them truncates the corpus rather than
filtering it. The run returns everything and leaves filtering to the dataset, where `stars` and
`created_at` are both present on every row.

**What happens when several places are listed?**
Each place is walked to its own end before the next one starts, and each keeps its own continue
token; tokens are never carried across places. `maxItems` is one shared budget across the whole run,
so a first place with thousands of reviews can consume it before the second place is reached.

**What happens on a place nobody has reviewed?**
The run finishes with zero rows rather than failing. An empty dataset here means the place has no
reviews, not that the request failed.

**Why is there no likes or "helpful" count on a review?**
Because nothing in the payload is one. The single unexplained per-row integer that could have been it
behaves the wrong way for a helpfulness signal: Google's most-relevant ranking surfaces its LOW values
rather than its high ones, with means of 17.6 against 33.9 on one place and 26.4 against 41.9 on
another, and it sits inside the author block where an author statistic belongs. It is not published
under a name it has not earned.

**Why is `relative_time` sometimes at odds with the timestamps?**
Because it is the string Maps itself shows, computed at the moment the page is read. It ages inside a
stored copy while `created_at` and `updated_at` do not, and it disagrees with them by design on an
edited review. It is there for display; sorting belongs on `created_at`.

**Why are the timestamps published twice?**
`created_at_us` and `updated_at_us` are Google's own integers, in microseconds since the epoch,
republished exactly as they arrive. `created_at` and `updated_at` are the same instants as UTC date
strings, for tools that cannot sort an integer of that size. The unit stays in the field name because
a microsecond timestamp read as milliseconds lands in the year 57,000 and looks like a successful
parse.

**Why does the output not carry a review count for the place?**
Because the integer that looks like one is wrong. Measured against the count Maps displays it read
1,324 where Maps said 1,723, and 10,337 where Maps said 5,950, so it is not published anywhere in this
family. Counting the rows this Actor returns is the reliable way to reach that number.

**Does a complete walk cost more than a partial one?**
Yes, and star-only ratings are the reason it is worth saying. They are saved and billed like any other
row, and they are a quarter to two-fifths of a corpus, so a place walked to the end costs noticeably
more than the same place walked halfway. That is the trade behind the completeness claim.

### Related Actors

[Google Maps Scraper](https://apify.com/thenetaji/google-maps-scraper) searches an area and returns
complete business records, each carrying the `cid` this Actor takes as a place. It is the way to
build a list of places to read reviews for.

[Google Maps Place Scraper](https://apify.com/thenetaji/google-maps-place-scraper) returns one
business record per link or identifier, for cases where the business is wanted without its reviews.

# Actor input Schema

## `places` (type: `array`):

One or more Google Maps places. Paste a Maps link, or Google's own place id in `0x…:0x…` form — both work, and you can mix them. Every row the **Google Maps Scraper** saves publishes that id as `cid`, so a harvest feeds straight in here.

## `maxItems` (type: `integer`):

Caps how many rows the run saves. Set 0 for no cap, in which case the run continues until the source runs out. The default is 100 and it applies whether or not the field is touched, so a harvest meant to be larger than that has to raise it or clear it.

## `cursor` (type: `string`):

Resume a review walk that stopped at **Max results**. The run prints a continue token when it stops early — paste it back here, with the same single place, to carry on where it left off. A token belongs to the place it was issued for and is refused against any other one.

## `enrichPlaceDetails` (type: `boolean`):

Look the business up once per place and stamp its name, address, categories, rating, phone and website onto every review row. One extra request per place — not per review — and billed per place, only after the lookup succeeds. Leave it off when you already know which place you asked for.

## Actor input object example

```json
{
  "places": [
    "0x808587fb77f5f64d:0x25d564f177f99946"
  ],
  "maxItems": 20,
  "cursor": "0x808587fb77f5f64d:0x25d564f177f99946|CAESY0NBRVFBUm9VUTJoa1p",
  "enrichPlaceDetails": false
}
```

# Actor output Schema

## `dataset` (type: `string`):

All records scraped 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 = {
    "places": [
        "0x808587fb77f5f64d:0x25d564f177f99946"
    ],
    "maxItems": 20
};

// Run the Actor and wait for it to finish
const run = await client.actor("thenetaji/google-maps-reviews-scraper").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 = {
    "places": ["0x808587fb77f5f64d:0x25d564f177f99946"],
    "maxItems": 20,
}

# Run the Actor and wait for it to finish
run = client.actor("thenetaji/google-maps-reviews-scraper").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 '{
  "places": [
    "0x808587fb77f5f64d:0x25d564f177f99946"
  ],
  "maxItems": 20
}' |
apify call thenetaji/google-maps-reviews-scraper --silent --output-dataset

```

## MCP server setup

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

```

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/HAyPWkdOo6Mtf60IC/builds/pJwVNtQ9wg1EVp5eh/openapi.json
