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})")
pip install requests
export SPECSTREAM_API_KEY=...
python roofers.py
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,
})
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)
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.