Extract Google Maps / local business results as structured JSON — business name, category, rating, review count, price level, phone, and opening hours — using the Scrapeless Scraper API.
No headless browser, no proxy rotation, no CAPTCHA handling on your side. One HTTP POST returns the parsed local pack.
python3 python/maps_scraper.py "plumbers in Austin, TX" --pages 2{
"position": 1,
"title": "Radiant Plumbing, Air Conditioning, & Electrical",
"type": "Plumber",
"rating": 4.8,
"reviews": 18000,
"price": "$",
"phone": "(512) 690-4935",
"phone_note": null,
"hours": "Open 24 hours",
"address": "25+ years in business · Austin, TX",
"extensions": ["Onsite services"]
}The Google local pack is the highest-intent business directory on the web: the businesses that rank for "<service> in <city>" are the ones winning that market's local search. Teams use this data to:
- Local lead generation — build a prospect list of businesses in a category and city, with phone numbers and review counts as qualification signals.
- Local SEO tracking — record which competitors hold local pack positions for your target queries, and how their rating and review volume move over time.
- Market research — measure how saturated a service category is in a metro, and the rating distribution of the incumbents.
- Review-volume benchmarking — compare your review count against the businesses ranking above you.
Scraping Google Maps directly means rendering JavaScript, rotating IPs, and solving challenges. This repo skips that: Scrapeless returns the parsed local pack over a single API call.
- A Scrapeless API key — create a free account
- Python 3.9+ (standard library only — no
pip installneeded) or Node.js 18.3+ (no dependencies)
git clone https://github.com/<owner>/google-maps-scraper.git
cd google-maps-scraper
cp .env.example .env # then put your key in .env
export SCRAPELESS_API_KEY=your_key_hereBoth implementations read SCRAPELESS_API_KEY (or SCRAPELESS_KEY) from the environment. Nothing is written to disk unless you pass --out or --csv.
Python:
python3 python/maps_scraper.py "plumbers in Austin, TX"
python3 python/maps_scraper.py "coffee shops in San Francisco" --pages 3 \
--out results/coffee.json --csv results/coffee.csvNode.js:
node nodejs/maps-scraper.mjs "plumbers in Austin, TX" --pages 2 --out results/plumbers.json| Flag | Meaning |
|---|---|
--pages N |
Number of pages to fetch. 20 results per page. |
--out FILE |
Write JSON to a file instead of stdout. |
--csv FILE |
Also write a flat CSV (Python only). |
Progress goes to stderr, data to stdout — so python maps_scraper.py "..." > out.json works cleanly in a pipeline.
Committed real runs, generated by the code in this repo:
| File | Query | Places |
|---|---|---|
results/plumbers-austin-tx.json |
plumbers in Austin, TX (2 pages) |
37 |
results/plumbers-austin-tx.csv |
same query, flat CSV | 37 |
results/coffee-san-francisco.json |
coffee shops in San Francisco (1 page) |
19 |
Counts differ per run because pages overlap by a variable amount. Verification on 2026-08-18 covered the committed Python two-page JSON/CSV fixtures at 37 unique places, the committed one-page fixture at 19 unique places, and a separate live Node two-page run at 39 unique places.
The Google local pack is reached through the scraper.google.search actor with tbm=lcl:
{ "actor": "scraper.google.search", "input": { "q": "plumbers in Austin, TX", "tbm": "lcl" } }Three behaviors this client handles, all confirmed against the live API:
numis ignored. Every request returns 20 places regardless. Extra pages come from thestartoffset (start: 20,start: 40, …).- Pages overlap. Consecutive pages repeat a row or two, and a single page can contain a duplicate. Records are deduplicated on
title + trimmed contact value, so the same raw contact string collapses whether it ends up inphoneorphone_note. Two pages therefore yield fewer than 40 unique places — measured at 37 unique places in the committed Python two-page fixture and 39 in a separate live Node two-page run on 2026-08-18. Treat the page size as 20 and the union as variable. - Sporadic
400s. The same query can return400once and200on the next attempt. Requests retry up to 4 times with linear backoff on400/408/429/5xx.
Populated: position, title, type, rating, reviews, reviews_original, price, phone, phone_note, hours, address, extensions.
phone is populated but is not always a phone number. Some records carry phone-shaped values there, while others carry opening hours ("Closes 6 PM", "Opens 7 AM") or a service label ("Online estimates"). This client keeps only locale-neutral phone-shaped values in phone, including values written with Unicode decimal digits, and routes anything else to phone_note, so a CRM import never receives opening hours in a phone column. The Python CSV keeps its legacy column positions and appends phone_note at the end: position,title,type,rating,reviews,price,phone,hours,address,extensions,phone_note. Validate before dialing or writing.
Not populated for local results — the actor returns these keys but leaves them empty, so this client drops them rather than shipping blank columns: place_id, place_id_search, lsig, thumbnail. All four were empty in 20 of 20 records on 2026-08-18.
gps_coordinates is worse than empty. It arrives as {"latitude": 0, "longitude": 0} in every record, so it survives a truthiness or null check and then places every business on Null Island. This client drops it for that reason. If you need coordinates or a stable place ID, geocode title + address downstream.
Two field notes: address is the local pack's snippet line, so it mixes tenure and locality ("25+ years in business · Austin, TX") rather than being a clean postal address; and extensions sometimes carries a review quote instead of an attribute. Several fields arrive padded with a leading space — every string is trimmed here, including inside extensions.
| Symptom | Cause and fix |
|---|---|
SCRAPELESS_API_KEY is not set |
Export the key, or copy .env.example to .env and load it. |
HTTP 401 |
Key is wrong or revoked. Check it in the dashboard. |
Failed after 4 attempts with 400 |
Rephrase the query (add or drop the city qualifier). Persistent 400s on a query that reads fine usually mean the local pack has no results for it. |
no results, stopping on page 1 |
The query has no local pack. Queries need commercial local intent — "plumbers in Austin, TX" works, "plumbing" alone often does not. |
Fewer places than pages × 20 |
Expected — duplicates across pages are dropped. |
google-maps-scraper/
├── python/maps_scraper.py # Python client (stdlib only)
├── nodejs/maps-scraper.mjs # Node client (no dependencies)
├── results/ # committed output from real runs
├── .env.example
└── LICENSE
Scrape public data responsibly and review the terms of the sites you target. Background reading: Is scraping Google Maps legal?
- Product: Scraping API · Deep SERP API
- Guides: Google Maps lead generation · Scrape Google Maps with MCP · Best Google Maps extractors
- SDKs: Python · Node.js
MIT — see LICENSE.