Scraper Workers

Maps scraper

The Ultron Maps scraper turns a list of search terms and a location into a structured set of places: name, address, phone, website, categories, rating, review count, hours, photo count. It runs on a managed worker platform with residential proxies, Playwright under Crawlee, and a webhook-back contract to Ultron when each run completes.

Updated today

Overview

At a glance
Worker
ultron-gmaps
Runtime
Node 20, Crawlee PlaywrightCrawler
Source
apify-actors/gmaps/
Proxy
Residential, on by default
Default page size
100 results per search
Max page size
500 results per search
Cost
~0.65 USD per 1,000 places without reviews

The Maps scraper is the workhorse of the local-business workflows. The Specter persona's gmaps-leads skill fires it the moment a user asks for businesses in a given category and location; downstream skills enrich and contact the resulting leads. It is the only scraper in the worker set that defaults to residential proxy because Google Maps treats datacentre IPs aggressively.

Input schema

Drive everything from one input object.

apify-actors/gmaps/.actor/actor.jsonjson
1{
2 "search_terms": ["string", "..."], // required
3 "location": "string", // e.g. 'Austin, TX'
4 "lat": "string", // optional, paired with lng
5 "lng": "string",
6 "radius_km": 10, // default 10
7 "max_per_search": 100, // default 100, max 500
8 "include_details": true, // click each panel for phone, website, place_id
9 "include_reviews": false, // sample reviews per place
10 "max_reviews_per_place": 5,
11 "language": "en",
12 "country": "US",
13 "use_residential_proxy": true,
14 "webhook_url": "https://app.51ultron.com/api/apify-webhook?source=gmaps",
15 "webhook_secret": "<shared secret>",
16 "user_id": "<uuid>",
17 "conversation_id": "<uuid>"
18}

Crawl flow

Five steps. Each step is one file under src/.

apify-actors/gmaps/src/main.jsjs
1// 1. Expand every search_term × (location | lat,lng) into a Crawlee request queue
2// 2. Open each search-result URL with PlaywrightCrawler
3// 3. Scroll the left feed until result-count plateaus
4// 4. Either harvest cards from the feed (fast mode) or click each result
5// for the detail panel (full mode - phone, website, place_id, lat/lng, etc.)
6// 5. Optionally fetch a sample of reviews
7// 6. Push the row to the dataset + (if configured) webhook Ultron

The crawler is deliberately patient. Each feed scroll waits for the result counter to stabilise before reading the cards, otherwise the actor races Maps' lazy load and undercounts results. Each detail panel click waits for the phone or website field to appear before reading, otherwise the row goes out half-populated.

Output shape

One row per place. All fields are nullable except name + scraped_at.

dataset-row.jsonjson
1{
2 "place_id": "ChIJ...",
3 "name": "Joe's Coffee",
4 "address": "501 Brazos St, Austin, TX",
5 "lat": 30.2672,
6 "lng": -97.7431,
7 "phone": "+15125551234",
8 "website": "https://joescoffee.example",
9 "categories": ["Coffee shop", "Breakfast restaurant"],
10 "rating": 4.7,
11 "review_count": 312,
12 "hours": { "monday": "07:00-19:00", ... },
13 "photos_count": 28,
14 "reviews_sample": [{ "rating": 5, "text": "...", "author": "..." }],
15 "scraped_at": "2026-05-11T18:42:00Z",
16 "search_term": "coffee shop austin",
17 "user_id": "<uuid>",
18 "conversation_id":"<uuid>"
19}

Proxy and throttling

Residential by default. Jitter between requests.

The crawler ships with a small jitter helper (jitterMs) that adds a random delay between requests inside the same crawl, sized to avoid burst-detection patterns on the maps domain. The proxy pool rotates per request; sticky sessions are not used because the maps frontend re-authenticates the visitor on every navigation anyway.

Fast vs full mode

Fast harvests cards from the feed. Full clicks each one for the detail panel.

ModeTriggered byReturnsSpeed
Fastinclude_details: falsename, address, categories, rating, review_count, photos_countFast (one feed scroll per search)
Fullinclude_details: true (default)All fast fields plus phone, website, place_id, lat, lng, hoursSlower (one detail click per result)

Selector strategy

Selectors are defined in helpers.js with multiple fallbacks per field.

Google rewrites maps' DOM regularly. Each extractor reads from a list of selector candidates per field, picks the first that returns a non-empty value, and logs the picked variant so a future class-name change is visible in run logs before users start seeing missing fields. The actor is clean-room: it uses only public DOM patterns (aria-labels, role attributes), no proprietary reverse-engineered selectors.

Cost

Pricing scales with results, not searches. Reviews are the expensive bit.

ModeApprox cost per 1,000 results
Fast (no details, no reviews)0.30 USD
Full (details, no reviews)0.65 USD
Full + reviews (5 per place)2.00 USD

How to trigger it

From a skill, through the Apify HTTP API.

src/lib/skills/.../gmaps-leads/SKILL.md (excerpt)text
1POST https://api.apify.com/v2/acts/ultron-gmaps/runs?token=<APIFY_TOKEN>
2Content-Type: application/json
3
4{ "search_terms": ["dentist"], "location": "Bucharest, Romania", "max_per_search": 50 }
Note
Skills do not call the Apify API directly. They emit a gmaps-leads capability call which the platform translates into the right Apify run with the user's workspace id and conversation id attached as metadata. Webhooks come back to the platform, not to the skill.

File map

apify-actors/gmaps
Dockerfile
README.md
package.json
src
main.jsinput parsing + crawler loop
helpers.jsselectors + jitter
search.jsfeed scroll + card extraction
place.jsdetail panel extraction
reviews.jsreview sampling
webhook.jsPOST results to Ultron
src/app/api/apify-webhook
route.tsingest gmaps results into the leads table