--- name: google-maps-scraper description: Search Google Maps by keyword and area via the Apify Actor arman-bd/google-maps-scraper. Returns one record per business with a stable Google place ID, name, category list, parsed street/city/postalCode/countryCode, coordinates, IANA timezone, phone in E.164 and local form, website and domain, rating, per-day opening hours with machine-comparable intervals, accessibility attributes carrying an availability flag, and a Google Maps URL. Use when a task needs local business lists, geocoded place records that join across runs, or contact discovery filtered by whether a business has a website. Not for reviews, photo galleries, review counts, price levels, email enrichment, or finding a specific known business by name. --- # Google Maps Scraper Apify Actor `arman-bd/google-maps-scraper`. Give it search terms and an area, get one dataset record per unique business. Every record carries `placeId`, which is stable across runs and is the key you join on. No credentials are involved. ## When to use it - Local lead lists: every business of a category in a metro area, with phone and website. - Prospecting for businesses **without** a website, via `websiteFilter`. - Geocoding and enrichment: turning business names into rows with coordinates, a timezone and a stable `placeId`. - Coverage tracking on a schedule: ratings, hours and new entrants over time. ## When not to use it - Reviews or review text. Not returned, at any setting. - `reviewsCount` and `priceLevel`. The keys exist and are always `null`; Google does not publish either in search results. Filtering on them is not possible. - Photo galleries. `imageUrl` is a single thumbnail, not a gallery. - Email addresses or social profiles. Nothing crawls the business website. - Finding one specific business by exact name. This searches a category over an area; a name search returns whatever Google ranks for it, which may not be the one you mean. ## Call it ```js import { ApifyClient } from 'apify-client'; const client = new ApifyClient({ token: process.env.APIFY_TOKEN }); const run = await client.actor('arman-bd/google-maps-scraper').call({ searchTerms: ['specialty coffee'], location: 'Berlin, Germany', radiusKm: 8, maxPlacesPerTerm: 200, gridSize: 2, minRating: 4.0, }); const { items } = await client.dataset(run.defaultDatasetId).listItems(); const summary = await client.keyValueStore(run.defaultKeyValueStoreId).getRecord('RUN_SUMMARY'); ``` One call, no SDK, records straight back: ```bash curl -X POST "https://api.apify.com/v2/acts/arman-bd~google-maps-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \ -H 'content-type: application/json' \ -d '{"searchTerms":["specialty coffee"],"location":"Berlin, Germany","maxPlacesPerTerm":50}' ``` ## Input | Key | Type | Required | Default | Notes | |---|---|---|---|---| | `searchTerms` | array | yes | | Each term is searched independently over the whole area. | | `location` | string | | | Free text, e.g. `Berlin, Germany`. Resolved once to a centre point. | | `latitude` | number | | | Explicit centre. Overrides `location` when paired with `longitude`. | | `longitude` | number | | | Must be given with `latitude`. | | `radiusKm` | number | | `5` | Viewport size. Smaller is denser and more local. | | `maxPlacesPerTerm` | number | | `200` | Cap per term. The main cost control. | | `gridSize` | number | | `1` | 1, 2, 3 or 4. N x N cells, each searched. | | `minRating` | number | | | Also removes unrated places. | | `skipClosed` | boolean | | `false` | Drops permanently and temporarily closed places. | | `websiteFilter` | string | | `all` | `all`, `withWebsite`, `withoutWebsite`. | Either `location` or a `latitude`/`longitude` pair is required. Without one the run fails with a named error rather than returning nothing. ## Output One record per unique place. Every key below is present on every record; `null` means Google does not publish it for that place. | Field | Type | Notes | |---|---|---| | `placeId` | string | `ChIJ...`. Stable across runs. Join on this. | | `cid` | string | Google internal id. | | `fid` | string | Same value as `cid`, kept for compatibility with other tools. | | `kgmid` | string | Knowledge Graph id, `/g/...` or the legacy `/m/...`. | | `title` | string | Business name. | | `categoryName` | string | Primary category. | | `categories` | array | All categories. | | `address` | string | Formatted single line. | | `street` | string | Parsed component, not split from the display string. | | `city` | string | Parsed component. | | `postalCode` | string | Parsed component. | | `countryCode` | string | Two letters. | | `neighborhood` | string | Present on roughly three quarters of places. | | `locality` | string | For example `Berlin, Germany`. | | `latitude` | number | | | `longitude` | number | | | `timezone` | string | IANA, for example `Europe/Berlin`. | | `phone` | string | E.164. Present on roughly 85 percent of places. | | `phoneNational` | string | As displayed locally. | | `website` | string | Present on roughly 93 percent of places. | | `domain` | string | Host of `website`. | | `rating` | number | Always a number, whole ratings included. | | `reviewsCount` | null | Always null. Not published in search results. | | `priceLevel` | null | Always null. Not published in search results. | | `permanentlyClosed` | boolean | | | `temporarilyClosed` | boolean | | | `openingHours` | array | Per day: `day`, `hours` display text, `interval` as hour/minute pairs. | | `attributes` | array | Objects of `group`, `name`, `available`. Read `available`. | | `editorialSummary` | string | Google's one-line blurb. Present on roughly a third of places. | | `bookingLinks` | array | Reservation URLs. Present on roughly a fifth of places. | | `imageUrl` | string | Single thumbnail. | | `url` | string | Google Maps link built from `placeId`. | | `searchTerm` | string | Which input produced the row. | | `gridCell` | string | Which grid cell, or null when `gridSize` is 1. | | `position` | number | Rank within the term. | | `scrapedAt` | string | ISO 8601. | ```json { "placeId": "ChIJnS0AQjdRqEcRJk8cf-B4y1M", "title": "Fauve Coffee Berlin", "categoryName": "Coffee shop", "categories": ["Coffee shop", "Cafe"], "city": "Berlin", "postalCode": "10178", "countryCode": "DE", "latitude": 52.5247417, "longitude": 13.404067, "timezone": "Europe/Berlin", "rating": 4.7, "attributes": [{ "group": "Accessibility", "name": "Wheelchair accessible parking lot", "available": false }] } ``` ## RUN_SUMMARY Written to the run's key-value store under `RUN_SUMMARY`. ```json { "termsSearched": 1, "gridCells": 4, "origin": { "latitude": 52.5173885, "longitude": 13.3951309, "label": "Berlin, Germany" }, "placesSaved": 280, "placesDroppedByFilter": { "minRating": 20, "skipClosed": 0, "websiteFilter": 0 }, "duplicatesCollapsed": 57, "ceilingHit": { "specialty coffee": true }, "malformedFields": {}, "failures": [], "finishedAt": "2026-08-08T21:04:11.000Z" } ``` Check `origin` to confirm the area resolved where you meant. Check `ceilingHit`: when true, the area ran out of matches and raising `maxPlacesPerTerm` will not help, but raising `radiusKm` or `gridSize` may. `malformedFields` is normally empty; entries mean the upstream layout shifted and those fields should be treated as unreliable. ## Behaviour to plan around - **Results are about 90 percent stable between runs.** Google reorders its own results, so roughly one row in ten comes and goes with no underlying change. Diff on `placeId` and treat a single absence as noise, not a closure. - **`attributes` can be negative.** An entry with `available: false` means the place does **not** have that feature. Reading `name` alone inverts the meaning. - **`minRating` also removes unrated places.** Leave it unset to keep them. - **Grid cells overlap.** Roughly a sixth to a third of what a grid fetches is a repeat; duplicates are collapsed and each place is charged once. `duplicatesCollapsed` reports the count. - **How many places a term yields depends on density**, not a fixed cap. A dense term in a large city paginates into the hundreds; a quiet suburb returns a handful. - **Charging is per unique place pushed.** Places removed by a filter and duplicates are not charged. ## Recipes **Agency prospecting: businesses with no website** ```json { "searchTerms": ["dentist"], "location": "Austin, TX", "radiusKm": 15, "gridSize": 2, "websiteFilter": "withoutWebsite" } ``` **Wide city coverage for one category** ```json { "searchTerms": ["restaurant"], "location": "Berlin, Germany", "radiusKm": 20, "gridSize": 3, "maxPlacesPerTerm": 1500 } ``` Then deduplicate on `placeId` yourself if you merge several runs. **Scheduled monitoring** Run daily with a fixed input, keep the dataset, and diff successive runs on `placeId`. Treat a place missing from one run as noise; treat it as a closure only after it is absent from several consecutive runs, or confirm with `permanentlyClosed`. **Geocoding a category into a map layer** ```json { "searchTerms": ["pharmacy"], "location": "Lisbon, Portugal", "radiusKm": 12 } ``` `latitude`, `longitude` and `placeId` are all you need for a join key and a marker.