# Google Maps Reviews & Photos Scraper (`prodiger/google-maps-scraper`) Actor

Two-mode Google Maps scraper. Pass search queries to discover candidate places, or Place IDs / CIDs / FIDs / short URLs / full URLs to extract reviews + Photos-tab images per place. Generic review fields: rating, date, owner response, translation, reviewer profile, helpful votes. Pay-per-event.

- **URL**: https://apify.com/prodiger/google-maps-scraper.md
- **Developed by:** [Arnas](https://apify.com/prodiger) (community)
- **Categories:** Travel, Automation, AI
- **Stats:** 21 total users, 2 monthly users, 100.0% runs succeeded, 1 bookmarks
- **User rating**: No ratings yet

## Pricing

Pay per event

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?

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 Scraper

> Two-mode Google Maps scraper. **Shallow mode** turns search queries into candidate places (with stable Place IDs); **deep mode** extracts reviews + Photos-tab images for given Place IDs.

**Status:** private v0.1. Not yet on Apify Store.

### What does Google Maps Scraper do?

This actor extracts public Google Maps data in two stages so callers can chain a discovery pass with a deep-extraction pass without reimplementing the join. It uses **PlaywrightCrawler** (Chromium) under the hood with residential-proxy default, session rotation, fingerprint randomization, and `CONSENT` cookie pre-seeding to handle the EU/UK consent wall.

- **Shallow mode** — pass `searchStringsArray` (e.g., `["Paradise Hyderabad"]`) and the actor returns one record per candidate place per query: Place ID, CID, name, address, Maps URL, lat/lng, rating, review count, categories, plus phone / website / hours when surfaced on the search result card.
- **Deep mode** — pass `placeIds[]` containing any mix of: canonical Place IDs (`ChIJ...`), CIDs (numeric), FID hex pairs (`0x...:0x...`), short URLs (`goo.gl/maps/...`, `maps.app.goo.gl/...`), or full `/maps/place/...` URLs. The actor normalizes each to a canonical Place ID, then per place emits one record per review (with reviewer info, rating, date, owner response, photos when present, and the originally-translated text when the review was non-English). Optionally also emits a per-place Photos-tab bundle.

### Why use Google Maps Scraper?

- **Two-stage pipeline** decouples discovery from extraction — pick which candidates to deep-scrape instead of paying for full reviews on every search match.
- **Generic field set** — return whatever Maps surfaces: rating, publishedAt, owner response, detected language, reviewer profile URL, reviewer review count, helpful votes, original-vs-translated text. No per-field knobs to fight with.
- **Host-allowlisted output** — `reviewUrl` and every `reviewImageUrls[]` entry is guaranteed to be on `*.google.com` / `*.googleusercontent.com` / `*.ggpht.com` over HTTPS. Tile-rendering UIs that hard-reject other hosts can trust the contract.
- **Identifier flexibility** — paste any of five common ID forms; the actor normalizes them. No "what's a CID" homework for callers.
- **Failure visibility** — every record carries a `status` enum (`ok | place_not_found | consent_wall | rate_limited | partial | parse_error`). A run-summary record at end-of-run aggregates counts plus `consecutiveBlocksAtEnd` for detecting session-wide regressions.

### How to use Google Maps Scraper

1. **Open the Apify Console** for this actor (private v0.1; share access first).
2. **For discovery (Mode A):** paste search queries into `searchStringsArray`. Adjust `maxPlacesPerQuery` if any single query is expected to match many places.
3. **For extraction (Mode B):** paste Place IDs / CIDs / FIDs / short URLs / full URLs into `placeIds[]`. Adjust `maxReviews`, `reviewsSort`, and toggle `includePlacePhotos` / `onlyWithPhotos` as needed.
4. **Run.** The dataset will contain records with a `recordType` discriminator (`place`, `review`, `place_photos`, `run_summary`); filter or split downstream.

> Note: pass `searchStringsArray` **or** `placeIds[]`, not both. Both populated is a validation error.

### Input

See the **Input** tab for the full schema and live editor. Key fields:

```jsonc
{
  "searchStringsArray": ["Paradise Hyderabad"],   // OR
  "placeIds": ["ChIJN1t_tDeuEmsRUsoyG83frY4"],    // mutually exclusive
  "maxReviews": 50,
  "reviewsSort": "mostRelevant",                  // mostRelevant | newest | highestRating | lowestRating
  "language": "en",                               // sets hl param; does NOT filter reviews
  "onlyWithPhotos": false,
  "includePlacePhotos": false,
  "maxPlacesPerQuery": 5,
  "maxPlacePhotos": 200,
  "maxReviewsScanned": 0,                         // 0 = auto (maxReviews × 10)
  "maxConcurrency": 2,
  "requestTimeoutSecs": 45,
  "proxyConfiguration": { "useApifyProxy": true, "apifyProxyGroups": ["RESIDENTIAL"] }
}
```

### Output

The dataset is heterogeneous — every record carries a `recordType` discriminator. Filter by type or split downstream.

```jsonc
// recordType: "place" — emitted in shallow mode (one per candidate per query)
{
  "recordType": "place",
  "scrapedAt": "2026-05-04T12:00:00Z",
  "status": "ok",
  "searchString": "Paradise Hyderabad",
  "placeId": "ChIJ...",
  "cid": "15402384604550917548",
  "name": "Paradise Restaurant",
  "address": "...",
  "googleMapsUrl": "https://www.google.com/maps/place/...",
  "latitude": 17.4435,
  "longitude": 78.4869,
  "rating": 4.2,
  "reviewCount": 43210,
  "categories": ["Biryani restaurant", "Indian restaurant"],
  "phone": "+91 ...",
  "website": "https://.../",
  "hours": null
}

// recordType: "review" — emitted in deep mode (one per review)
{
  "recordType": "review",
  "scrapedAt": "2026-05-04T12:00:00Z",
  "status": "ok",
  "placeId": "ChIJ...",
  "reviewUrl": "https://www.google.com/maps/contrib/.../reviews/...",
  "reviewImageUrls": [
    { "url": "https://lh3.googleusercontent.com/...=w400-h300-k-no",
      "originalUrl": "https://lh3.googleusercontent.com/...=s0" }
  ],
  "text": "Great biryani.",
  "name": "A. Reviewer",
  "rating": 5,
  "publishedAt": "2025-12-01T00:00:00Z",
  "ownerResponse": { "text": "...", "publishedAt": "2025-12-02T00:00:00Z" },
  "detectedLanguage": "en",
  "originalText": null,
  "originalLanguage": null,
  "reviewerProfileUrl": "https://www.google.com/maps/contrib/...",
  "reviewerReviewCount": 87,
  "helpfulVotes": 12
}

// recordType: "place_photos" — emitted when includePlacePhotos=true (one per place)
{
  "recordType": "place_photos",
  "scrapedAt": "2026-05-04T12:00:00Z",
  "status": "ok",
  "placeId": "ChIJ...",
  "photoUrls": [
    { "url": "https://lh3...=w400-h300-k-no/", "originalUrl": "https://lh3...=s0/",
      "uploaderName": "...", "uploadedAt": null, "category": "By owner" }
  ]
}

// recordType: "run_summary" — emitted exactly once at end-of-run
{
  "recordType": "run_summary",
  "scrapedAt": "2026-05-04T12:34:56Z",
  "runOutcome": "normal",
  "placesAttempted": 10,
  "placesOk": 8,
  "placesNotFound": 1,
  "consentWallHits": 1,
  "rateLimitHits": 0,
  "reviewsEmitted": 234,
  "photosEmitted": 0,
  "consecutiveBlocksAtEnd": 0
}
```

You can download the dataset in JSON, CSV, Excel, or HTML.

### Data table

| Field | Type | When emitted | Notes |
|---|---|---|---|
| `recordType` | enum | always | `place` / `review` / `place_photos` / `run_summary` |
| `placeId` | string | place / review / place\_photos | Canonical `ChIJ...` form |
| `reviewUrl` | string | review | https + Google host (allowlisted) |
| `reviewImageUrls` | array | review (when photos present) | https + Google host (allowlisted) |
| `text` / `name` | string | review | Used as caption / author by tile-rendering consumers |
| `rating` | int | review when present | 1–5 |
| `originalText` / `originalLanguage` | string | review when translated | Pre-translation form |
| `status` | enum | every non-summary record | `ok` / `place_not_found` / `consent_wall` / `rate_limited` / `partial` / `parse_error` |

### Pricing / cost estimation

Private v0.1 — no PPE configured. Cost on Apify is the platform compute cost (Chromium + residential proxy). Expect 30–60s per place for review scraping at default knobs; budget accordingly.

### Tips

- Default `maxConcurrency: 2` is conservative — Google rate-limits aggressively. Bump only if you have a residential proxy budget for it.
- Set `language` to control which language Google's UI labels render in (does NOT filter reviews). For mixed-language places, leave at default `en`.
- For places known to have many photos, set `includePlacePhotos: true` and tune `maxPlacePhotos` (default 200, max 2000).
- If the run-summary's `consecutiveBlocksAtEnd` is ≥3, treat it as a session-wide regression signal — re-queue from a fresh run rather than tweaking concurrency.

### FAQ, disclaimers, support

- **Is this allowed?** Scraping public Maps pages is a contested area; this actor is for **private v0.1 use only**. Public Store publish is deferred until a separate legal review (ToS, GDPR transparency obligations, abuse limits) is complete.
- **GDPR.** Personal data (reviewer names, photos, profile URLs) flows through this actor. Persistence, lawful-basis assessment (Art. 6), transparency obligations toward data subjects (Art. 14), and erasure paths (Art. 17) are the **caller's** responsibility — this actor produces a transient dataset and does not persist anything.
- **Limitations.** Maps DOM evolves; per-field extraction may degrade silently. Watch the `status: 'partial'` rate and the run-summary aggregate.

# Actor input Schema

## `searchStringsArray` (type: `array`):

Free-form Google Maps search queries (e.g. 'Paradise Hyderabad'). Each query is resolved to up to `maxPlacesPerQuery` candidate places with stable Place IDs. Mutually exclusive with `placeIds`.

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

Place identifiers in any of: canonical Place ID (ChIJ...), CID (numeric), FID hex pair (0x...:0x...), short URL (goo.gl/maps/..., maps.app.goo.gl/...), full /maps/place/... URL. The actor normalizes each to a canonical Place ID before scraping. Mutually exclusive with `searchStringsArray`.

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

Per-place cap on emitted review records. Counted post-filter (after `onlyWithPhotos`), so the cap matches what the caller sees.

## `reviewsSort` (type: `string`):

Sort order applied to the reviews tab before scraping.

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

Maps UI locale (sets the `hl` query parameter). Does NOT filter reviews by language — reviews from any language are returned. `detectedLanguage` and `originalLanguage` annotate each review's language.

## `onlyWithPhotos` (type: `boolean`):

When true, drop text-only reviews from the output. `maxReviews` then counts only photo-bearing reviews; the actor scrolls past more raw reviews to find them, bounded by `maxReviewsScanned`.

## `includePlacePhotos` (type: `boolean`):

When true (deep mode), additionally emit one `recordType: 'place_photos'` record per place containing Photos-tab images (mixed owner-uploaded + visitor-uploaded).

## `maxPlacesPerQuery` (type: `integer`):

Cap on candidate places returned per search query. Use higher values when a query is expected to match many branches (e.g. chain restaurants).

## `maxPlacePhotos` (type: `integer`):

Per-place cap when `includePlacePhotos` is true. Maps lazy-renders the photo grid via scroll; this caps scroll iterations.

## `maxReviewsScanned` (type: `integer`):

Upper bound on how many raw reviews the scroll loop visits before stopping, even if `maxReviews` (post-filter) hasn't been reached. 0 = auto (computed as `maxReviews × 10`).

## `maxConcurrency` (type: `integer`):

Conservative default; Google rate-limits aggressively. Capped at 5 per the per-actor AGENTS.md guidance for browser-based crawlers.

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

Wall-clock timeout for one page navigation + scroll/parse cycle.

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

RESIDENTIAL is mandatory; Google's anti-bot blocks datacenter IPs aggressively.

## Actor input object example

```json
{
  "searchStringsArray": [
    "Paradise Hyderabad"
  ],
  "maxReviews": 50,
  "reviewsSort": "mostRelevant",
  "language": "en",
  "onlyWithPhotos": false,
  "includePlacePhotos": false,
  "maxPlacesPerQuery": 5,
  "maxPlacePhotos": 200,
  "maxReviewsScanned": 0,
  "maxConcurrency": 2,
  "requestTimeoutSecs": 45,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}
```

# Actor output Schema

## `dataset` (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 = {
    "searchStringsArray": [
        "Paradise Hyderabad"
    ],
    "proxyConfiguration": {
        "useApifyProxy": true,
        "apifyProxyGroups": [
            "RESIDENTIAL"
        ]
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("prodiger/google-maps-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 = {
    "searchStringsArray": ["Paradise Hyderabad"],
    "proxyConfiguration": {
        "useApifyProxy": True,
        "apifyProxyGroups": ["RESIDENTIAL"],
    },
}

# Run the Actor and wait for it to finish
run = client.actor("prodiger/google-maps-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 '{
  "searchStringsArray": [
    "Paradise Hyderabad"
  ],
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}' |
apify call prodiger/google-maps-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,prodiger/google-maps-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/dXodJniZcQEzpb1jh/builds/zKbPf7FXYcy9yRD7R/openapi.json
