DEV Community

Bugrahan Saka
Bugrahan Saka

Posted on Originally published at scrape.do

Google Maps Scraper in Python: Leads with Phones, Websites and Hours from Any Search

Google Maps is the biggest local business directory there is: search "dentist" or "coffee shop" anywhere and you get every business with its phone, website, address, rating, review count and opening hours. For a sales team that is a lead list waiting to be exported. Here is a Python scraper that turns any Maps search into a CSV of leads, then pulls place details and reviews, and the three things that break the usual approach.

Why requests.get on google.com/maps gets you nothing

import requests

r = requests.get("https://www.google.com/maps/search/coffee+shop/@30.2672,-97.7431,14z")
print("Dear Diary Coffee" in r.text)  # False
Enter fullscreen mode Exit fullscreen mode

Google Maps is a JavaScript app. Fetched from a US IP, that search page is 211 KB of HTML with not one business name in it: the result list is drawn later by scripts that call Google's internal endpoints.

Those endpoints are the second trap. /search?tbm=map and /maps/preview/place answer with an anti-XSSI prefix ()]}') followed by a deeply nested protobuf-over-JSON array: no field names, just nulls and positions that shift between responses. Scrapers built on array indices break quietly.

The third is volume. Send enough requests from one IP and Google answers with consent walls, CAPTCHAs and rate limits. And the reviews endpoint most old tutorials use, listentitiesreviews, has been retired.

The fix: an API that returns the parsed JSON

The Scrape.do Google Maps API makes the request from a residential IP and returns clean JSON. One search call gives 20 places:

import requests

TOKEN = "<your_token>"
API = "https://api.scrape.do/plugin/google/maps"


def maps(endpoint, **params):
    params["token"] = TOKEN
    response = requests.get(f"{API}/{endpoint}", params=params, timeout=90)
    response.raise_for_status()
    return response.json()


data = maps("search", q="coffee shop", ll="@30.2672,-97.7431,14z", gl="us", hl="en")
first = data["local_results"][0]
print(first["title"], first["rating"], first["reviews"], first["phone"], first["website"])
Enter fullscreen mode Exit fullscreen mode

Every place comes back with title, type and types, rating, reviews, phone, website, address, gps_coordinates, operating_hours (one entry per weekday), open_state, and three IDs: place_id, data_id and data_cid. Keep the IDs, they feed the next two calls.

ll is the map centre in the same @lat,lng,zoom form as a Maps URL. Leave it out and put the place in the query instead ("coffee shop in Austin, TX").

Paginate to the 120-place ceiling

start moves 20 places at a time. Google Maps stops at about 120 results for one query (6 pages), so the loop ends on a short page or when a page brings nothing new:

def search(query, lat, lng, pages=6):
    places, seen = [], set()
    for page in range(pages):
        data = maps("search", q=query, ll=f"@{lat},{lng},14z", gl="us", hl="en", start=page * 20)
        results = data.get("local_results", [])
        fresh = [r for r in results if r["place_id"] not in seen]
        for r in fresh:
            seen.add(r["place_id"])
            places.append(r)
        if len(results) < 20 or not fresh:
            break
    return places
Enter fullscreen mode Exit fullscreen mode

Run live for "coffee shop" around downtown Austin, this returned 107 places in 6 calls, 81 of them with a phone number.

To cover a whole city, run the same query around several centre points (and related categories like "cafe" or "espresso bar") and dedupe by place_id.

Write the leads to CSV

import csv

DAYS = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]


def hours(place):
    days = place.get("operating_hours") or {}
    return "; ".join(f"{d[:3].title()}: {days[d]}" for d in DAYS if d in days)


places = search("coffee shop", 30.2672, -97.7431)

with open("leads.csv", "w", newline="", encoding="utf-8") as f:
    writer = csv.writer(f)
    writer.writerow(["name", "category", "rating", "reviews", "phone", "website", "address", "hours", "lat", "lng", "place_id", "data_id"])
    for p in places:
        gps = p.get("gps_coordinates", {})
        writer.writerow([p.get("title"), p.get("type"), p.get("rating"), p.get("reviews"), p.get("phone"),
                         p.get("website"), p.get("address"), hours(p), gps.get("latitude"), gps.get("longitude"),
                         p.get("place_id"), p.get("data_id")])
Enter fullscreen mode Exit fullscreen mode

Place details and the rating breakdown

The place endpoint takes a place_id (or data_cid) and adds what the list view leaves out: the plus code, the count of 1 to 5 star reviews, attributes like accessibility and service options, and a few review snippets.

place = maps("place", place_id="ChIJ_UbNVbC1RIYRTavD-VMmvZA")["place_results"]
print(place["title"], place["plus_code"])
print({row["stars"]: row["amount"] for row in place["rating_summary"]})
# Dear Diary Coffee 77FH+PW Austin, Texas
# {1: 16, 2: 6, 3: 14, 4: 41, 5: 604}
Enter fullscreen mode Exit fullscreen mode

One catch: in a search result type is a string, but in a place lookup it is the whole list of categories. Read it accordingly.

Reviews, with owner replies

Reviews take the data_id and come about 10 at a time. Each page returns a next_page_token for the next one:

def reviews(data_id, pages=3, sort_by="newest"):
    out, token = [], None
    for _ in range(pages):
        data = maps("reviews", data_id=data_id, sort_by=sort_by, next_page_token=token)
        for r in data.get("reviews", []):
            out.append({
                "rating": r.get("rating"),
                "date": r.get("iso_date"),
                "text": r.get("snippet") or r.get("extracted_snippet", {}).get("original"),
                "owner_reply": (r.get("response") or {}).get("snippet"),
            })
        token = data.get("pagination", {}).get("next_page_token")
        if not token:
            break
    return out
Enter fullscreen mode Exit fullscreen mode

sort_by takes newest, highest_rating or lowest_rating. The first page also lists the topics Google pulled out of the reviews ("vegan food", 151 mentions), and each review says whether the owner replied: on the test place, 14 of the 20 newest had a reply. Sorting by lowest_rating and checking owner_reply is a quick way to find businesses that ignore unhappy customers, which is a good opener for an agency pitch.

Skip the boilerplate

The same code, with pagination, grids of centre points, deduping, retries and CSV export, is a package:

pip install google-maps-leads-scraper
export SCRAPEDO_TOKEN=<your_token>
google-maps-leads-scraper search "dentist" --ll 30.2672,-97.7431 --ll 30.3072,-97.7559 --pages 6 -f csv -o dentists.csv
google-maps-leads-scraper reviews --data-id 0x8644b5b055cd46fd:0x90bd2653f9c3ab4d --sort lowest_rating --pages 2
Enter fullscreen mode Exit fullscreen mode

Source: github.com/bugrahansss/google-maps-leads-scraper. There is also a no-code version on Apify (Google Maps Leads Scraper) and a browser demo on Hugging Face.

Use it responsibly

Business listings are public, but reviews carry the names of the people who wrote them. Keep only what you need, follow the privacy rules that apply to you (GDPR, CCPA), and respect opt-outs before you contact anyone.

Run it at scale with Scrape.do

Every call is 10 credits and a search page holds 20 places, so 1,000 leads cost about 500 credits: no proxies, no browser, no protobuf parsing. Get 1000 free credits and start scraping with Scrape.do.

Full walkthrough on the Scrape.do blog, including the raw Python approach without the API: Google Maps Scraper: How to Scrape Google Maps for Leads

Top comments (0)