Local SEO agencies, franchise teams and market researchers use Google Maps as a live directory of the businesses in an area: names, addresses, ratings and review counts. That public information answers practical questions, such as how many competitors operate in a district or how a client's rating compares with theirs. Because Maps is assembled in the browser by JavaScript, fetching the URL with a plain HTTP request returns almost nothing useful. This tutorial uses Playwright to drive a real browser, scroll the results and read each listing, with traffic sent through proxies so the session stays stable.
Places API or the public interface?
Google offers the Places API for sanctioned, structured access, and for production or commercial use it is the option to evaluate first. Working with the public Maps interface suits modest research where the API does not fit. Either way, review Google's robots.txt and Terms of Service before you start. Collect only public business information and never personal data about individuals. Do not try to get past CAPTCHAs or logins, keep the request rate gentle with real pauses, and cache results to avoid repeat fetches.
Why a browser, and why a proxy in the right city
Playwright drives Chromium, Firefox or WebKit and holds off reading the page until the elements it needs exist, which is exactly what a JavaScript-built app like Maps requires. The proxy's location matters just as much: Google shapes results around where the searcher appears to be, and a stream of automated searches from one IP soon meets CAPTCHAs. Put the exit in the city you are studying, using residential proxies, and the results describe that area, which is what a local report needs. Our premium proxies add stability for longer runs. Every type is compared on the proxies page, and teams focused on local search should also read proxies for SEO.
Getting Playwright ready
pip install playwright
playwright install chromium
Start the browser behind an authenticated proxy
Proxy details, login included, are part of Playwright's launch options, so an order's host:port address and credentials need no extra tooling:
from playwright.sync_api import sync_playwright
PROXY = {
"server": "http://HOST:PORT",
"username": "USER",
"password": "PASS",
}
def launch(playwright):
browser = playwright.chromium.launch(
headless=True,
proxy=PROXY,
)
context = browser.new_context(
locale="en-US",
viewport={"width": 1280, "height": 900},
)
return browser, context
Run a search
No form filling is needed. The search term goes into the Maps URL with its spaces turned into plus signs, and the function then waits until the results feed exists on the page:
def open_search(context, query):
page = context.new_page()
url = f"https://www.google.com/maps/search/{query.replace(' ', '+')}"
page.goto(url, wait_until="domcontentloaded")
page.wait_for_selector('div[role="feed"]', timeout=30000)
return page
Load the full results list
Results arrive in batches while the side panel is scrolled. The loop jumps to the bottom of the feed, waits, and compares heights; once the height stops changing, every listing has loaded:
import time
def scroll_results(page, max_rounds=15):
feed = page.query_selector('div[role="feed"]')
last_height = 0
for _ in range(max_rounds):
page.evaluate(
"(el) => el.scrollTo(0, el.scrollHeight)", feed
)
time.sleep(2) # let new results load
new_height = page.evaluate("(el) => el.scrollHeight", feed)
if new_height == last_height:
break
last_height = new_height
Read each listing
Inside the feed, each business is a link whose aria-label carries its name, and the rating text sits in the same card. Google revises this markup from time to time, so every field is checked and incomplete entries are dropped:
def extract_listings(page):
results = []
cards = page.query_selector_all('div[role="feed"] a[href*="/maps/place/"]')
for card in cards:
name = card.get_attribute("aria-label")
if not name:
continue
parent = card.evaluate_handle("el => el.parentElement")
rating_el = parent.query_selector('span[role="img"]')
rating = (
rating_el.get_attribute("aria-label") if rating_el else None
)
results.append({
"name": name,
"rating": rating,
"url": card.get_attribute("href"),
})
return results
Run it end to end
import csv
def scrape(query):
with sync_playwright() as pw:
browser, context = launch(pw)
page = open_search(context, query)
scroll_results(page)
data = extract_listings(page)
browser.close()
return data
if __name__ == "__main__":
rows = scrape("coffee shops in Austin")
with open("maps.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=["name", "rating", "url"])
writer.writeheader()
writer.writerows(rows)
print(f"Saved {len(rows)} listings")
The example searches for coffee shops in Austin and saves the name, rating and link of each listing to a CSV file for the team to analyse.
Notes for local SEO and research teams
- Match the proxy's city to the city in the search term; otherwise the ratings and the order of results describe somewhere else.
- Randomise the pauses between scrolls and page loads.
- On bigger jobs, give each search a fresh residential IP, and look up the exit in our my IP tool.
- Read a CAPTCHA as a speed warning and slow the whole job down, rather than retrying straight away.
- Put the endpoints through the proxy checker before each run.
Playwright copes with the dynamic page and residential proxies take care of location, so the build is an afternoon's work for one person. Keep the volume modest and within Google's terms and rate limits, and graduate to the Places API when the tool turns into production work.