Extract local business data from Google Maps into clean CSV/JSON for lead gen, market research, and ops.
Google Maps Scraper collects structured data for places (name, category, rating, reviews, address, phone, website, coordinates, hours, etc.) from search results and place detail pages.
Built for lead generation, territory planning, competitor scans, and list enrichment.
White-hat design: no CAPTCHA/anti-bot bypass included; obey rate limits.
| # | Feature | What It Does | Why It Matters |
|---|---|---|---|
| 1 | Keyword + Geo Targeting | Search by query, city, radius, or polygon | Precise local data pulls |
| 2 | Rich Place Schema | name, category, rating, reviews_count, price_level, address, phone, website, plus_code, lat, lng, hours | Ready for CRM/BI |
| 3 | Pagination & Deep Fetch | Iterates result pages and opens details | Higher completeness |
| 4 | Dedupe by place_id/url | Removes duplicates | Clean lists |
| 5 | CSV/JSON Export | Writes to /output |
Easy imports |
| 6 | Optional Enrichment | Normalize phones, resolve websites, basic email discover (optional) | Better outreach hit-rate |
| 7 | Configurable Throttling | Rate limiting & backoff | Safer scraping |
| 8 | Pluggable Proxies (opt.) | Rotating proxy pool support | Fewer blocks |
- Build B2B lead lists (e.g., “dentists in Austin within 15km”)
- Audit categories/ratings for competitive scans
- Plan door-to-door or inside sales routes using coordinates
- Enrich existing lists with phone/website/hours
import json, pandas as pd, time
from datetime import datetime
from playwright.sync_api import sync_playwright
def scrape(q="dentist", city="Austin,TX", n=20):
with sync_playwright() as pw:
b = pw.chromium.launch(headless=True); p = b.new_page()
p.goto(f"https://www.google.com/maps/search/{q}+in+{city}"); p.wait_for_selector('[role="feed"]')
data=[]; seen=set()
for a in p.query_selector_all('a[href^="https://www.google.com/maps/place"]')[:n]:
url=a.get_attribute("href");
if not url or url in seen: continue; seen.add(url)
p2=b.new_page(); p2.goto(url); tx=lambda s:(e:=p2.query_selector(s)) and e.inner_text().strip()
data.append({"name":a.get_attribute("aria-label") or "", "url":url, "category":tx('[jslog*="breadcrumb"]') or "",
"rating":float((tx('span[aria-label*="stars"]') or "0").split()[0]),
"reviews":int("".join(c for c in (tx('button[aria-label*="reviews"]')or"") if c.isdigit()) or 0),
"address":tx('button[data-item-id="address"]') or "", "phone":tx('button[data-item-id^="phone:tel:"]') or "",
"website":tx('a[data-item-id="authority"]') or "", "scraped_at":datetime.utcnow().isoformat()+"Z"}); p2.close(); time.sleep(.6)
b.close(); return data
if __name__=="__main__": d=scrape(); pd.DataFrame(d).to_csv("places.csv",index=False); json.dump(d,open("places.json","w"),indent=2)- Polygon/geojson targeting
- Sheets & Airtable exporters
- Phone/URL normalization helpers
- Proxy pool + retries
Q: Does this bypass Google’s protections?
A: No. It’s white-hat and throttled.
Q: Will this work for any country?
A: Yes, but fields may vary by locale.
Q: How many results per run?
A: Depends on query density, pagination, and throttling.
MIT — see LICENSE
Questions? Need a custom scraper or integrations?

