Skip to the page

Google Maps stops at 120 results: how to build the full lead list

SpecStream

Lead lists · · 7 min read

A Google Maps search shows about 120 places, however many businesses match. The Places API is tighter: Text Search returns at most 60 results across all pages. To get every business in a city, split the area into boxes, search each box, cut any box that comes back full into four, and merge the results by place ID. The Python for that is below.

The sweep still leaves you without emails and without any condition a category can't express. The last part of this post gets the same list, with those, from one request.

Where the two limits come from

Where you search Most results per search Said by
Google Maps in the browser About 120 Apify's blog: "The Google Maps interface itself shows only about 120 listings per search."
Places API, Text Search (New) 60 across all pages, 20 a page Google's docs: "Text Search (New) returns a maximum of 60 results across all pages, although this limit is subject to change."

The ceiling is per search, not per city. "Dentist" searched over all of Austin and "dentist" searched over one neighborhood return different places. So the fix is to make every search small enough that it never reaches the ceiling, and to know when one did.

The grid sweep, in Python

This uses the Places API (New). It searches a rectangle. When the rectangle returns the full 60, it cuts it into four and searches each quarter. A box that returns fewer than 60 has given you every place Google will show for it.

import os
import requests

URL = "https://places.googleapis.com/v1/places:searchText"
HEADERS = {
    "X-Goog-Api-Key": os.environ["GOOGLE_MAPS_API_KEY"],
    # Only the fields named here come back. nextPageToken is needed for pages 2 and 3.
    "X-Goog-FieldMask": "places.id,places.displayName,places.formattedAddress,"
    "places.nationalPhoneNumber,places.websiteUri,places.rating,nextPageToken",
}
FULL = 60  # Text Search never returns more than this for one search


def search_box(query, low, high):
    """Every place one search returns inside a rectangle: up to 3 pages of 20."""
    body = {
        "textQuery": query,
        "pageSize": 20,
        "locationRestriction": {"rectangle": {
            "low": {"latitude": low[0], "longitude": low[1]},    # southwest corner
            "high": {"latitude": high[0], "longitude": high[1]},  # northeast corner
        }},
    }
    found = []
    while True:
        reply = requests.post(URL, headers=HEADERS, json=body)
        reply.raise_for_status()
        page = reply.json()
        found += page.get("places", [])
        token = page.get("nextPageToken")
        if not token:
            return found
        body["pageToken"] = token  # the rest of the body stays the same


def sweep(query, low, high, places, depth=0):
    found = search_box(query, low, high)
    if len(found) < FULL or depth >= 8:
        for place in found:
            places[place["id"]] = place  # one entry per place ID, however many boxes return it
        return
    mid = ((low[0] + high[0]) / 2, (low[1] + high[1]) / 2)
    quarters = [
        (low, mid),                                      # southwest
        ((low[0], mid[1]), (mid[0], high[1])),           # southeast
        ((mid[0], low[1]), (high[0], mid[1])),           # northwest
        (mid, high),                                     # northeast
    ]
    for q_low, q_high in quarters:
        sweep(query, q_low, q_high, places, depth + 1)


places = {}
sweep("dentist", (30.10, -97.93), (30.52, -97.56), places)  # a box around Austin, TX
print(len(places), "places")

What to know before you run it:

  • Pages 2 and 3. Google describes pageToken as: "Specifies the nextPageToken from the response body of the previous page." Send the same body again with only the token added.
  • Merge on the place ID, never the name. Chains share a name across locations, and a place on the line between two boxes can come back from both.
  • The field mask sets the price. Phone, website and rating are fields of the Text Search Enterprise SKU. Google lists it at 1,000 free calls a month, then $35.00 per 1,000 calls. Each full box costs three calls before it is split, so a dense city runs to hundreds of calls.
  • The depth cap. A box that is still full at depth 8 is under 200 meters wide. The script keeps its 60 and moves on. Lower the cap to spend less, raise it for downtown blocks.

What the sweep still leaves to you

  • Email. The Places API has no email field. You read each business's own site to find one.
  • Conditions a category can't hold. "Takes online bookings", "has three or more locations", "hiring a hygienist" are facts on the business's site or its job posts, not filters on the map.
  • Where each value came from. A sheet of 900 rows is hard to act on when nobody can open the page behind row 412.

The same list from one request

SpecStream is the live web data engine for teams and agents. With Deep Search you describe the businesses in plain words, conditions and contacts included, and you get every match on the live web as structured rows. There is no box to size and no per search ceiling to work around: a run keeps working until it reaches the count you asked for, up to four hours on the largest lists.

Source URL and read time on every value.

Four calls with the REST API. The key comes from Agents & API in your workspace and needs the specs:write, finds:write and finds:spend scopes.

import csv, os, time, uuid
import requests

API = "https://api.specstream.ai"
KEY = {"Authorization": f"Bearer {os.environ['SPECSTREAM_API_KEY']}"}


def call(method, path, **kwargs):
    reply = requests.request(method, API + path, headers=KEY, **kwargs)
    reply.raise_for_status()
    return reply.json()["data"]


# 1. Read the request. read_back says how it was understood. Nothing is spent.
reading = call("POST", "/v1/parse", json={
    "q": "Every dentist in Austin, Texas rated 4.5 or higher that takes online bookings, "
         "with the address, phone, website and the email from its own site"
})
print(reading["read_back"])

# 2. Price it. The quote names the most the run can cost. Still nothing is spent.
quote = call("POST", "/v1/capacity/quotes", json={"ast_hash": reading["ast_hash"], "requested_units": 500})
print("Most it can cost: $%.2f" % (quote["maximum_cents"] / 100))

# 3. Approve that maximum. Keep the key: a retry with the same key never buys twice.
run = call("POST", "/v1/capacity/executions", json={
    "quote_id": quote["quote_id"],
    "quote_hash": quote["quote_hash"],
    "confirm_max_cents": quote["maximum_cents"],
    "idempotency_key": str(uuid.uuid4()),
})
find_id = run["find_id"]

# 4. Wait for the run, then read every row.
while call("GET", f"/v1/finds/{find_id}")["status"] in ("queued", "planning", "running"):
    time.sleep(30)

rows, cursor = [], None
while True:
    params = {"limit": 100}
    if cursor:
        params["cursor"] = cursor
    page = call("GET", f"/v1/finds/{find_id}/rows", params=params)
    rows += page["rows"]
    cursor = page.get("next_cursor")
    if not cursor:
        break

# Each value with the page it was read on and when.
with open("dentists.csv", "w", newline="") as f:
    out = csv.writer(f)
    labels = [cell["label"] for cell in rows[0]["cells"]]
    out.writerow([name for label in labels for name in (label, f"{label} source")])
    for row in rows:
        line = []
        for cell in row["cells"]:
            seen = cell.get("provenance") or {}
            line += [cell.get("display") or cell.get("value"), seen.get("source_url")]
        out.writerow(line)

Working in Claude, Cursor or another MCP client instead? Add https://api.specstream.ai/mcp as a server and ask for the list in the chat. Setup for each client.

What a run returns: one sentence, "100 bike shops in Colorado rated 4.5 or better on Google, with phone number and website", returned 91 shops across the state, each with its address, rating, phone, website and source. The list and how it was used.

What it costs

SpecStream shows the most a run can cost before it starts, and nothing runs until you approve that number. Each delivered row is 5¢, 15¢ or $1, depending on how much the row needs. A row that carries a person's email adds 5¢, and a phone adds 15¢. Duplicates and rows a page contradicts are not charged, and a run that finds nothing costs nothing. New accounts start with $10 of credit, no card. Prices.

The two ways side by side

SpecStream Deep Search Grid sweep on the Places API
Results per search Every match, up to the count you ask for 60, so you split boxes until none is full
Email Asked for in the request No email field
Conditions beyond the category Written in the request, with the page that states each one Read each site yourself
Source of each value Source URL and read time on every value None per value
Price 5¢, 15¢ or $1 per delivered row, the maximum shown before it runs $35.00 per 1,000 calls on Text Search Enterprise, after 1,000 free a month
Staying up to date A saved request reruns hourly to yearly and labels each change Run the sweep again

Describe the businesses you need and see the price before anything runs.

More from the blog

Every post