DEV Community

Cover image for How to build a lead list of every roofer in a city, past the Google Maps cap
Tarek Etman for SpecStream

Posted on AI-assisted

How to build a lead list of every roofer in a city, past the Google Maps cap

Search Google Maps for "roofers in Columbus, Ohio" and scroll. The list stops after roughly 120 places, sorted the way Google likes: the big outfits with 400 reviews first. Those are the roofers who already have an agency.

The ones worth calling are further down: rated 5.0 with eight reviews, small enough that the owner picks up. Nobody scrolls to result 121, and Google made sure you can't.

So here's the list I wanted:

Roofers in Columbus, Ohio, rated 4.5 or better with fewer than 20 Google reviews, with their phone number and website

And here's how to get every match as a CSV with about 80 lines of Python, then the same thing as an n8n workflow and as a question to your agent.

The script

You need an API key from specstream.ai (under Agents & API). New accounts get $10 of work free, no card.

import csv
import os
import time
import uuid

import requests

API = "https://api.specstream.ai"
REQUEST = (
    "Roofers in Columbus, Ohio, rated 4.5 or better with fewer than 20 "
    "Google reviews, with their phone number and website"
)
HOW_MANY = 25
MAX_CENTS = 1000  # never approve more than $10

session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['SPECSTREAM_API_KEY']}"


def call(method, path, **kwargs):
    for attempt in range(3):  # retries are safe: the approval below carries a fixed key
        response = session.request(method, API + path, timeout=60, **kwargs)
        if response.status_code < 500:
            break
        time.sleep(5)
    response.raise_for_status()
    return response.json()["data"]


# 1. Read the sentence. Free.
reading = call("POST", "/v1/parse", json={"q": REQUEST, "locale": "en-US"})
print("Read as:", reading["canonical_sentence"])

# 2. Get the price. Also free.
quote = call("POST", "/v1/capacity/quotes", json={
    "ast_hash": reading["ast_hash"],
    "operation": "find",
    "requested_units": HOW_MANY,
})
print(f"Costs at most ${quote['maximum_cents'] / 100:.2f}")
if quote["maximum_cents"] > MAX_CENTS:
    raise SystemExit("Over budget. Ask for fewer.")

# 3. Approve that price. The only call that spends.
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": f"roofers-{uuid.uuid4()}",
})
find_id = run["find_id"]

# 4. Wait for the run to end, then read every page of rows.
while call("GET", f"/v1/finds/{find_id}/rows", params={"limit": 1})["status"] in (
    "queued", "planning", "running"
):
    time.sleep(15)

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

# 5. One CSV row per roofer, with the page it came from.
skip = {"matches", "open"}  # workspace buttons, not facts
with open("roofers-columbus.csv", "w", newline="") as f:
    out = csv.writer(f)
    for i, row in enumerate(rows):
        cells = [c for c in row["cells"] if c["column_id"] not in skip]
        if i == 0:
            out.writerow([c["label"] for c in cells] + ["Source"])
        source = next((c["provenance"]["source_url"] for c in cells if c.get("provenance")), "")
        out.writerow(["" if c["absence"] else c["display"] for c in cells] + [source])

print(f"{len(rows)} roofers in roofers-columbus.csv (list {find_id})")
Enter fullscreen mode Exit fullscreen mode
pip install requests
export SPECSTREAM_API_KEY=...
python roofers.py
Enter fullscreen mode Exit fullscreen mode

What came back

The run took about two minutes. The quote said $7.50 at most: 30¢ a roofer, because every row carries a rating, a review count, a phone and a website. A plain list with just phone and website is 10¢ a row; 25 HVAC companies in Columbus quoted at $2.50.

Ten of the 25 rows:

Business Rating Reviews Phone Website
Blue Sky Roofing Columbus 5.0 1 (380) 286-0409 blueskyroofing.com
Ohio Metal Roofing 5.0 5 (740) 751-8131 metalroofohio.com
Columbus Commercial Roofing 5.0 7 (614) 392-8717 columbuscommercialroofingco.net
MasterRoof Contracting Columbus 5.0 8 (380) 444-9147 masterroofcontracting.com
Rain Dog Roofing of Columbus 5.0 12 (614) 330-7887 raindogroofing.com
Top Roofing & Windows 4.9 15 (614) 310-4521 toproofingwindows.com
RX Roofing 5.0 15 (614) 569-6977 rxroofing.org
Walker Roofing & Construction 5.0 16 (614) 802-5428 walkerroofingandconstruction.com
Ace Roofing 4.9 17 (614) 457-1111 roofingace.com
Grants Roofing Company 5.0 17 (614) 905-2183 grantsroofingohio.com

Every one of the 25 is rated 4.6 or better with between 1 and 17 reviews. The CSV also has the street address where Maps lists one, and a Source column with the page each row was read from, so you can click any row and see it for yourself.

No emails in this list, because I didn't ask for them. Add "and their email" to the sentence and you get the ones each roofer published. Where a business didn't publish one, the cell stays empty. Nothing is guessed.

Why this gets past the cap

The 120-place limit belongs to a single Maps search. SpecStream doesn't run one search. It keeps looking where local businesses are listed until it has the number you asked for, up to 10,000, and every row it hands back fits the whole sentence. Those are the only rows you pay for.

That changes what the sentence can be. "Roofers in Columbus" is a search. "Roofers in Columbus rated 4.5 or better with fewer than 20 reviews" is a list nobody sells, and it's the one an agency wants.

Swap the sentence and the script still works:

  • "HVAC companies in Columbus, Ohio, with their phone number and website"
  • "Plumbers in Dayton, Ohio, with their phone number and website"
  • "Web design agencies in Austin, Texas, with their website, phone number and email"

Keep it current

A list of businesses with fewer than 20 reviews goes stale on its own: some of them hit 20. And new roofers open every month. To get only the new ones, quote an extend on the list you already have:

parent = call("GET", f"/v1/finds/{find_id}")
more = call("POST", "/v1/capacity/quotes", json={
    "ast_hash": parent["ast_hash"],
    "operation": "extend",
    "parent_find_id": find_id,
    "requested_units": 25,
})
Enter fullscreen mode Exit fullscreen mode

Approve it the same way. You're billed only for businesses the list doesn't hold yet. When we asked for 5 more web design agencies in Austin on a list of 10, all 5 were new. To have this happen on its own, open the list in the workspace and turn on a schedule.

Without code: n8n

The same five steps are an n8n workflow: a form for the trade and the city, the price check against your maximum, the wait loop, and a Google Sheet at the end. It uses only core nodes (HTTP Request, Code, Google Sheets), so it runs on n8n Cloud. Import it from the SpecStream templates on n8n.io.

Or ask your agent

SpecStream is also a remote MCP server at https://api.specstream.ai/mcp (OAuth, or your API key as a Bearer token). Add it to Claude, ChatGPT, Cursor or VS Code and ask in plain words:

Every roofer in Columbus rated 4.5 or better with fewer than 20 Google reviews, with phone and website.

The agent reads the request back, shows you the price and waits for your yes before anything runs. The MCP guide has the setup for each client.

What it costs

10¢, 30¢ or $1 a match, depending on how much each row has to carry, and you see the maximum before you approve it. A run that finds nothing is free. The $10 you start with covers both lists above, to the cent. Rates are on the pricing page.

If you build lists for local outreach: what's the condition that makes a lead worth calling for you? Reviews, opening date, no website, a specific neighborhood? Tell me in the comments and I'll run one.

We build SpecStream. The rows above are from a real run on October 15, 2026 (list fnd_0j95yw0w8ypsqqhvj3k9dern02).

Top comments (1)

Collapse
 
kevinhall profile image
Kevin Hall •

Rated 4.5+ with under 20 reviews is a smart cut for "the owner picks up". Two cheap filters I'd add on top: the date of the latest review, since a handful of 5-star reviews all from 2022 is often a shop that went quiet, and repeated phone numbers across rows. Franchises and lead-gen sites put one number on many listings, and nobody local answers those.