Skip to content

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Google Maps Scraper

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"]
}

Use case

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.

Requirements

  • A Scrapeless API key — create a free account
  • Python 3.9+ (standard library only — no pip install needed) or Node.js 18.3+ (no dependencies)

Setup

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_here

Both implementations read SCRAPELESS_API_KEY (or SCRAPELESS_KEY) from the environment. Nothing is written to disk unless you pass --out or --csv.

Run it

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.csv

Node.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.

Expected output

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.

How it works

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:

  1. num is ignored. Every request returns 20 places regardless. Extra pages come from the start offset (start: 20, start: 40, …).
  2. 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 in phone or phone_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.
  3. Sporadic 400s. The same query can return 400 once and 200 on the next attempt. Requests retry up to 4 times with linear backoff on 400/408/429/5xx.

Field coverage

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.

Troubleshooting

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.

Project structure

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

Legal

Scrape public data responsibly and review the terms of the sites you target. Background reading: Is scraping Google Maps legal?

Related

License

MIT — see LICENSE.

About

Extract Google Maps and local business results as structured JSON via the Scrapeless Scraper API — ratings, reviews, phone numbers, and hours for local SEO and lead generation.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages