How to Build a Local SEO Intelligence Dashboard With ScrapeBadger

Local search intelligence has a data completeness problem that most dashboards do not address.
The standard local SEO tracking setup monitors a business's own metrics: their star rating, their review count, their local pack position for a handful of target keywords. This tells you where you are. It does not tell you why you are there, whether you are gaining or losing ground relative to competitors, which competitor is accelerating in your category, or what review themes are driving ranking changes.
A local SEO intelligence dashboard worth building tracks all of that — your position, your competitors' positions, the review patterns that predict ranking changes before they show up in your rank tracker, and the sentiment signals that tell you where operational investment will produce the most local search return.
46% of all Google searches have local intent. 76% of people who search for something local on a smartphone visit a related business within 24 hours. For any business competing in local search — whether a single-location restaurant, a multi-location healthcare provider, or an agency managing dozens of client profiles — the intelligence this guide builds is directly connected to revenue.
ScrapeBadger's Google Maps API provides the data layer: place search, place detail, reviews, and posts from any Google Maps listing in any geography. This guide builds the full dashboard pipeline.
What the Dashboard Needs to Track
Before code, define the intelligence requirements. A local SEO dashboard should answer:
Position tracking. Where does each tracked business appear in Google Maps search results for target category queries in target locations? How has this changed over the past 30 and 90 days?
Competitive landscape. Who are the top three to five competitors appearing in the local pack for our target queries? What are their ratings, review counts, and review velocities compared to ours?
Review intelligence. What are the trending themes in our recent reviews? What are our competitors' recurring positive and negative themes? Are there quality signals we are winning or losing on?
Review velocity. How many reviews per week or month are we and our key competitors receiving? Accelerating review velocity is a leading indicator of sales momentum and a known ranking signal.
Ranking factor signals. Sterling Sky's 2025 study showed ranking improvements correlated with review count increases at specific thresholds — jumping from 9 to 10 reviews produced a noticeable ranking lift for primary keywords. Tracking progression toward known threshold effects enables proactive review acquisition strategy.
Setup
bash
pip install httpx sqlalchemy textblob python-dotenv asyncio aiofilesenv
SCRAPEBADGER_API_KEY=your_key_hereStep 1: Data Models
python
# models.py
from dataclasses import dataclass, field
from typing import Optional
from datetime import datetime
@dataclass
class PlaceRecord:
"""A Google Maps business listing snapshot."""
place_id: str
name: str
address: str
category: str
rating: Optional[float]
review_count: int
phone: Optional[str]
website: Optional[str]
latitude: float
longitude: float
is_open_now: Optional[bool]
price_level: Optional[int]
scraped_at: str
search_query: str # The query that surfaced this result
search_position: int # Rank in search results (1 = first result)
@dataclass
class ReviewRecord:
"""A single Google Maps review."""
place_id: str
review_id: str
author: str
rating: int # 1-5
text: str
published_at: str
sentiment: Optional[str] = None # "positive", "neutral", "negative"
sentiment_score: Optional[float] = None
key_themes: Optional[list] = field(default_factory=list)
scraped_at: str = ""
@dataclass
class DashboardMetrics:
"""Aggregated intelligence for a single place."""
place_id: str
place_name: str
# Current state
current_rating: float
current_review_count: int
current_position: int # In local search results
# Velocity (last 30 days)
new_reviews_30d: int
avg_rating_30d: float
rating_trend: str # "improving", "stable", "declining"
# Review intelligence
top_positive_themes: list
top_negative_themes: list
owner_response_rate: float
# Competitor comparison
vs_avg_competitor_rating: float # Our rating - avg competitor rating
vs_avg_competitor_reviews: int # Our count - avg competitor count
rank_in_category: int # Position among all tracked businessesStep 2: The ScrapeBadger Maps Collection Layer
python
# maps_collector.py
import httpx
import asyncio
import os
import random
from typing import Optional
from models import PlaceRecord, ReviewRecord
from datetime import datetime
API_KEY = os.environ["SCRAPEBADGER_API_KEY"]
BASE_URL = "https://api.scrapebadger.com/v1"
HEADERS = {"X-API-Key": API_KEY}
async def search_local_businesses(
client: httpx.AsyncClient,
query: str,
location: str = None,
max_results: int = 20,
) -> list[PlaceRecord]:
"""
Search Google Maps for businesses matching a query in a location.
Returns ranked list of places with full details.
"""
try:
search_query = f"{query} {location}" if location else query
response = await client.get(
f"{BASE_URL}/google/maps/search",
params={
"query": search_query,
"limit": max_results,
},
timeout=30.0,
)
response.raise_for_status()
data = response.json()
places = []
for i, place in enumerate(data.get("results", []), 1):
geo = place.get("gps_coordinates", {})
places.append(PlaceRecord(
place_id=place.get("place_id", ""),
name=place.get("title", ""),
address=place.get("address", ""),
category=place.get("type", ""),
rating=place.get("rating"),
review_count=place.get("reviews", 0) or 0,
phone=place.get("phone"),
website=place.get("website"),
latitude=geo.get("latitude", 0),
longitude=geo.get("longitude", 0),
is_open_now=place.get("open_state") == "Open",
price_level=place.get("price_level"),
scraped_at=datetime.utcnow().isoformat(),
search_query=search_query,
search_position=i,
))
return places
except Exception as e:
print(f"Error searching '{query}' in '{location}': {e}")
return []
async def fetch_place_reviews(
client: httpx.AsyncClient,
place_id: str,
sort: str = "newest", # newest, highest, lowest, relevant
limit: int = 50,
) -> list[ReviewRecord]:
"""
Fetch reviews for a specific Google Maps place.
Use sort='newest' for velocity monitoring.
Use sort='lowest' for negative sentiment analysis.
"""
try:
response = await client.get(
f"{BASE_URL}/google/maps/reviews",
params={
"place_id": place_id,
"sort_by": sort,
"limit": limit,
},
timeout=30.0,
)
response.raise_for_status()
data = response.json()
reviews = []
for review in data.get("reviews", []):
reviews.append(ReviewRecord(
place_id=place_id,
review_id=review.get("review_id", ""),
author=review.get("username", "Anonymous"),
rating=review.get("rating", 3),
text=review.get("snippet", "") or review.get("text", ""),
published_at=review.get("date", ""),
scraped_at=datetime.utcnow().isoformat(),
))
return reviews
except Exception as e:
print(f"Error fetching reviews for {place_id}: {e}")
return []
async def collect_category_intelligence(
category_query: str,
locations: list[str],
max_concurrent: int = 5,
) -> dict:
"""
Collect competitive intelligence for a category across multiple locations.
Returns all businesses found, ranked by position.
"""
semaphore = asyncio.Semaphore(max_concurrent)
all_places = {}
async with httpx.AsyncClient(headers=HEADERS) as client:
async def bounded_search(location: str) -> tuple[str, list]:
async with semaphore:
await asyncio.sleep(random.uniform(0.5, 1.5))
places = await search_local_businesses(
client, category_query, location
)
print(f" {location}: {len(places)} businesses found")
return location, places
results = await asyncio.gather(
*[bounded_search(loc) for loc in locations]
)
for location, places in results:
all_places[location] = places
return all_placesStep 3: Database Storage
python
# dashboard_database.py
from sqlalchemy import (
create_engine, Column, Integer, Float, String,
DateTime, Boolean, Text, Index
)
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from datetime import datetime, timedelta
Base = declarative_base()
engine = create_engine("sqlite:///local_seo_dashboard.db")
Session = sessionmaker(bind=engine)
class PlaceSnapshot(Base):
__tablename__ = "place_snapshots"
id = Column(Integer, primary_key=True)
place_id = Column(String, nullable=False, index=True)
name = Column(String)
address = Column(String)
category = Column(String)
rating = Column(Float)
review_count = Column(Integer)
search_query = Column(String)
search_position = Column(Integer)
is_tracked = Column(Boolean, default=False) # Is this our business?
scraped_at = Column(DateTime, default=datetime.utcnow, index=True)
class ReviewSnapshot(Base):
__tablename__ = "review_snapshots"
id = Column(Integer, primary_key=True)
place_id = Column(String, nullable=False, index=True)
review_id = Column(String, unique=True)
author = Column(String)
rating = Column(Integer)
text = Column(Text)
sentiment = Column(String)
sentiment_score = Column(Float)
published_at = Column(String)
scraped_at = Column(DateTime, default=datetime.utcnow, index=True)
Base.metadata.create_all(engine)
class LocalSEODatabase:
def save_place_snapshot(self, place, is_tracked: bool = False):
with Session() as session:
snap = PlaceSnapshot(
place_id=place.place_id,
name=place.name,
address=place.address,
category=place.category,
rating=place.rating,
review_count=place.review_count,
search_query=place.search_query,
search_position=place.search_position,
is_tracked=is_tracked,
scraped_at=datetime.utcnow(),
)
session.add(snap)
session.commit()
def save_reviews(self, reviews: list):
with Session() as session:
for review in reviews:
existing = session.query(ReviewSnapshot).filter_by(
review_id=review.review_id
).first()
if not existing:
session.add(ReviewSnapshot(
place_id=review.place_id,
review_id=review.review_id,
author=review.author,
rating=review.rating,
text=review.text,
sentiment=review.sentiment,
sentiment_score=review.sentiment_score,
published_at=review.published_at,
scraped_at=datetime.utcnow(),
))
session.commit()
def get_position_trend(self, place_id: str, days: int = 30) -> list:
cutoff = datetime.utcnow() - timedelta(days=days)
with Session() as session:
return (
session.query(PlaceSnapshot.scraped_at, PlaceSnapshot.search_position, PlaceSnapshot.rating)
.filter(PlaceSnapshot.place_id == place_id, PlaceSnapshot.scraped_at >= cutoff)
.order_by(PlaceSnapshot.scraped_at.asc())
.all()
)
def get_review_velocity(self, place_id: str, days: int = 30) -> int:
cutoff = datetime.utcnow() - timedelta(days=days)
with Session() as session:
return (
session.query(ReviewSnapshot)
.filter(ReviewSnapshot.place_id == place_id, ReviewSnapshot.scraped_at >= cutoff)
.count()
)
def get_competitor_ranking(self, search_query: str, limit_days: int = 7) -> list:
"""Get current competitive landscape for a search query."""
cutoff = datetime.utcnow() - timedelta(days=limit_days)
with Session() as session:
return (
session.query(PlaceSnapshot)
.filter(PlaceSnapshot.search_query.contains(search_query), PlaceSnapshot.scraped_at >= cutoff)
.order_by(PlaceSnapshot.search_position.asc())
.limit(20)
.all()
)Step 4: Review Intelligence — Sentiment and Theme Extraction
python
# review_intelligence.py
from textblob import TextBlob
from collections import Counter
import re
from models import ReviewRecord
POSITIVE_SERVICE_SIGNALS = {
"fast", "quick", "friendly", "professional", "helpful",
"clean", "great service", "recommend", "excellent", "outstanding",
"amazing", "fantastic", "efficient",
}
NEGATIVE_SERVICE_SIGNALS = {
"slow", "rude", "dirty", "unfriendly", "unhelpful",
"disappointing", "terrible", "avoid", "never again",
"unprofessional", "worst", "horrible",
}
LOCAL_SEO_KEYWORDS = [
"parking", "location", "hours", "price", "value",
"wait", "staff", "service", "food", "quality",
"atmosphere", "clean", "noisy", "convenient",
]
def analyse_review(review: ReviewRecord) -> ReviewRecord:
"""Score sentiment and extract themes from a review."""
if not review.text or len(review.text.strip()) < 10:
review.sentiment = "neutral"
review.sentiment_score = 0.0
return review
# TextBlob base sentiment
blob = TextBlob(review.text)
base_score = blob.sentiment.polarity
text_lower = review.text.lower()
# Boost from known signals
boost = 0.0
for signal in POSITIVE_SERVICE_SIGNALS:
if signal in text_lower:
boost += 0.15
for signal in NEGATIVE_SERVICE_SIGNALS:
if signal in text_lower:
boost -= 0.15
# Rating adjustment (star rating is strong signal)
if review.rating == 5:
boost += 0.2
elif review.rating == 1:
boost -= 0.2
elif review.rating == 2:
boost -= 0.1
final_score = max(-1.0, min(1.0, base_score + boost))
if final_score > 0.15:
sentiment = "positive"
elif final_score < -0.15:
sentiment = "negative"
else:
sentiment = "neutral"
# Extract mentioned themes
themes = [kw for kw in LOCAL_SEO_KEYWORDS if kw in text_lower]
review.sentiment = sentiment
review.sentiment_score = round(final_score, 3)
review.key_themes = themes
return review
def extract_competitive_themes(
reviews: list[ReviewRecord],
min_mentions: int = 3,
) -> dict:
"""
Extract what a competitor is being praised or criticised for.
Returns ranked lists of positive and negative themes.
"""
positive_themes = Counter()
negative_themes = Counter()
for review in reviews:
if not review.sentiment:
review = analyse_review(review)
if review.sentiment == "positive":
for theme in (review.key_themes or []):
positive_themes[theme] += 1
elif review.sentiment == "negative":
for theme in (review.key_themes or []):
negative_themes[theme] += 1
return {
"positive": [
(theme, count)
for theme, count in positive_themes.most_common(10)
if count >= min_mentions
],
"negative": [
(theme, count)
for theme, count in negative_themes.most_common(10)
if count >= min_mentions
],
}Step 5: The Dashboard Report
python
# dashboard_report.py
import json
from datetime import datetime, timedelta
from dashboard_database import LocalSEODatabase
from review_intelligence import extract_competitive_themes, analyse_review
db = LocalSEODatabase()
def generate_dashboard_report(
your_place_id: str,
competitor_place_ids: list[str],
search_query: str,
output_path: str = "local_seo_report.json",
) -> dict:
"""
Generate a full local SEO intelligence report.
Compares your position against competitors across all tracked dimensions.
"""
report = {
"generated_at": datetime.utcnow().isoformat(),
"search_query": search_query,
"your_business": {},
"competitors": [],
"competitive_summary": {},
}
# Your business metrics
your_trend = db.get_position_trend(your_place_id, days=90)
your_velocity = db.get_review_velocity(your_place_id, days=30)
if your_trend:
latest = your_trend[-1]
oldest = your_trend[0]
position_change = oldest[1] - latest[1] # Positive = improved
rating_change = (latest[2] or 0) - (oldest[2] or 0)
else:
position_change = 0
rating_change = 0
latest = (None, None, None)
report["your_business"] = {
"place_id": your_place_id,
"current_position": latest[1],
"current_rating": latest[2],
"position_change_90d": position_change,
"rating_change_90d": round(rating_change, 2),
"new_reviews_30d": your_velocity,
"position_trend": "improving" if position_change > 0 else "declining" if position_change < 0 else "stable",
}
# Competitor metrics
competitor_ratings = []
competitor_velocities = []
for cid in competitor_place_ids:
trend = db.get_position_trend(cid, days=30)
velocity = db.get_review_velocity(cid, days=30)
if trend:
latest_c = trend[-1]
competitor_ratings.append(latest_c[2] or 0)
competitor_velocities.append(velocity)
report["competitors"].append({
"place_id": cid,
"current_position": latest_c[1],
"current_rating": latest_c[2],
"new_reviews_30d": velocity,
})
# Competitive summary
avg_comp_rating = sum(competitor_ratings) / len(competitor_ratings) if competitor_ratings else 0
avg_comp_velocity = sum(competitor_velocities) / len(competitor_velocities) if competitor_velocities else 0
report["competitive_summary"] = {
"your_rating_vs_competitors": round(
(latest[2] or 0) - avg_comp_rating, 2
),
"your_velocity_vs_competitors": round(
your_velocity - avg_comp_velocity, 1
),
"avg_competitor_rating": round(avg_comp_rating, 2),
"avg_competitor_review_velocity_30d": round(avg_comp_velocity, 1),
"competitive_position": "ahead" if (
(latest[2] or 0) > avg_comp_rating and
your_velocity >= avg_comp_velocity
) else "behind",
}
with open(output_path, "w") as f:
json.dump(report, f, indent=2)
_print_report_summary(report)
return report
def _print_report_summary(report: dict) -> None:
your = report["your_business"]
summary = report["competitive_summary"]
print(f"\n{'='*55}")
print(f"LOCAL SEO INTELLIGENCE REPORT")
print(f"Query: {report['search_query']}")
print(f"{'='*55}")
print(f"\nYOUR POSITION: #{your.get('current_position', 'N/A')}")
print(f"Rating: {your.get('current_rating', 'N/A')} ★")
print(f"New reviews (30d): {your.get('new_reviews_30d', 0)}")
print(f"Position trend: {your.get('position_trend', 'unknown')}")
print(f"\nVS COMPETITORS:")
print(f" Rating gap: {summary.get('your_rating_vs_competitors', 0):+.2f} ★")
print(f" Velocity gap: {summary.get('your_velocity_vs_competitors', 0):+.1f} reviews/mo")
print(f" Overall: {summary.get('competitive_position', 'unknown').upper()}")Step 6: Running the Pipeline
python
# main_local_seo.py
import asyncio
import sys
from maps_collector import collect_category_intelligence, fetch_place_reviews
from review_intelligence import analyse_review
from dashboard_database import LocalSEODatabase
from dashboard_report import generate_dashboard_report
import httpx
import os
API_KEY = os.environ["SCRAPEBADGER_API_KEY"]
HEADERS = {"X-API-Key": API_KEY}
db = LocalSEODatabase()
async def run_weekly_collection(
category_query: str,
locations: list[str],
your_place_id: str,
competitor_place_ids: list[str],
):
"""Full weekly collection and report cycle."""
print(f"Collecting data for '{category_query}' in {len(locations)} locations...")
# Collect competitive landscape
landscape = await collect_category_intelligence(
category_query, locations
)
# Save all place snapshots
for location, places in landscape.items():
for place in places:
is_tracked = place.place_id == your_place_id
db.save_place_snapshot(place, is_tracked=is_tracked)
# Collect reviews for all tracked places
all_place_ids = [your_place_id] + competitor_place_ids
async with httpx.AsyncClient(headers=HEADERS) as client:
for place_id in all_place_ids:
print(f"Fetching reviews for {place_id}...")
reviews = await fetch_place_reviews(client, place_id, sort="newest", limit=30)
# Score sentiment before saving
scored = [analyse_review(r) for r in reviews]
db.save_reviews(scored)
import asyncio
await asyncio.sleep(0.5)
# Generate report
report = generate_dashboard_report(
your_place_id=your_place_id,
competitor_place_ids=competitor_place_ids,
search_query=category_query,
)
return report
if __name__ == "__main__":
# Configure your monitoring
CATEGORY = "coffee shop"
LOCATIONS = ["London Bridge, London", "Shoreditch, London", "Canary Wharf, London"]
YOUR_PLACE_ID = "your_google_place_id" # From Maps search
COMPETITOR_IDS = ["comp_1_place_id", "comp_2_place_id"]
asyncio.run(run_weekly_collection(
CATEGORY, LOCATIONS, YOUR_PLACE_ID, COMPETITOR_IDS
))What the Dashboard Reveals Over Time
The intelligence value of this pipeline compounds with each collection cycle. After one week you have a competitive landscape snapshot. After four weeks you have position trends for your business and all competitors. After three months you have the data to answer the questions that matter for local SEO investment decisions.
Which review themes correlate with position improvements? Businesses that receive high-volume positive reviews mentioning "clean" and "friendly" in a specific category may show stronger ranking improvements than those receiving high-volume positive reviews mentioning "value" and "cheap." The theme-to-position correlation data is only visible with longitudinal collection.
Where is the competitive gap widest? A competitor gaining two new reviews per week while you gain one is building a compounding advantage over time. In the Whitespark 2026 report, the quality of unstructured citations was identified as the fourth most important factor for AI search visibility — and review text quality is part of that signal. The velocity gap, visible in the dashboard, tells you where review acquisition investment is most urgently needed.
Which locations are most contested? Multi-location businesses or agencies managing multiple clients need to know which locations are gaining ground and which are under competitive pressure. The per-location breakdown in the collection pipeline makes this visible at the geography level rather than as an aggregate average.
ScrapeBadger's Google Maps API covers place search, place detail, reviews, and business posts. The platform's zero-credits-for-failures policy — covered in the data quality article on the ScrapeBadger blog — means failed requests during collection cycles do not inflate the credit cost of weekly monitoring runs. Full documentation at docs.scrapebadger.com. Free trial at scrapebadger.com/google-maps-scraper — 1,000 credits, no credit card.

Written by
Thomas Shultz
Thomas Shultz is the Head of Data at ScrapeBadger, working on public web data, scraping infrastructure, and data reliability. He writes about real-world scraping, data pipelines, and turning unstructured web data into usable signals.
Ready to get started?
Join thousands of developers using ScrapeBadger for their data needs.