Local SEO rank tracking is different from normal SEO rank tracking.
For normal SEO, you usually track whether a domain ranks for a keyword.
For local SEO, you often need to track whether a business appears in Google Maps or local search results for a specific keyword and location.
That means your tracker needs to care about things like:
keyword
city
business name
business website
ranking position
address
rating
review count
phone number
Google Maps URL
A search like this:
dentist near me
can return different results in Austin, Chicago, Miami, or Brooklyn.
So if you only track the keyword and ignore location, your report is basically spreadsheet astrology.
In this tutorial, we will build a simple local SEO rank tracker with Python.
The workflow looks like this:
keywords + locations
→ SERP API request
→ local business results
→ match target business
→ save daily snapshot
→ compare ranking changes
This is useful for:
local SEO reports
Google Maps rank tracking
agency dashboards
multi-location businesses
competitor visibility monitoring
local business audits
What we are building
We will build a Python script that:
- Reads target businesses from a CSV file
- Reads local SEO keywords from another CSV file
- Calls a SERP API for each keyword and location
- Extracts Google Maps or local results
- Normalizes business fields
- Finds the target business in the results
- Saves a daily ranking snapshot
- Compares the latest snapshot with the previous one
The final output will look like this:
snapshot_date,keyword,search_location,target_name,target_website,found,position,matched_name,matched_website,rating,review_count,address
2026-01-01,dentist near me,Austin TX,Example Dental,exampledental.com,true,3,Example Dental Clinic,https://exampledental.com,4.8,231,123 Main St
Not glamorous. Very useful. A rare pairing in software.
Why use a SERP API?
You could try scraping Google Maps manually.
You could also spend your weekend fighting dynamic rendering, bot detection, changing HTML, location settings, and missing fields.
A SERP API gives you structured search result data. Start a 7-day free trial now>>
Instead of parsing the page yourself, you send a request like:
keyword = "dentist near me"
location = "Austin TX"
And get local results back as JSON.
A local result might include:
business name
position
address
phone
website
rating
review count
category
coordinates
place ID
Different providers use different response field names, so we will write the parser defensively.
The goal is not to depend on one exact response shape.
The goal is to build a workflow you can adapt.
Install dependencies
Create a new project folder and install the packages:
pip install requests python-dotenv pandas
We will use:
requests → call the SERP API
python-dotenv → load API keys
pandas → read and write CSV files
Create a .env file
Create a .env file:
SERP_API_KEY=your_api_key
SERP_API_URL=https://your-serp-api-endpoint.example.com/search
Different providers use different endpoints and parameter names.
Some use:
q
query
engine
location
gl
hl
device
type
search_type
That is normal. API parameter naming is where consistency goes to quietly die.
Create the target businesses file
Create a file called targets.csv.
target_name,target_website
Example Dental,exampledental.com
Austin Smile Clinic,austinsmileclinic.com
The tracker will try to match local results by:
business name
website domain
Website matching is usually more stable than name matching.
Business names can vary:
Example Dental
Example Dental Clinic
Example Dental - Austin
Domains are less dramatic, which is nice. Software needs fewer dramatic things.
Create the keywords file
Create a file called keywords.csv.
keyword,location
dentist near me,Austin TX
emergency dentist,Austin TX
cosmetic dentist,Austin TX
family dentist,Austin TX
dental implants,Austin TX
Each row is one local search.
You can add more cities later:
keyword,location
dentist near me,Dallas TX
dentist near me,Houston TX
dentist near me,Chicago IL
Start small.
Five keywords and one city are enough to test the workflow.
Step 1: Load settings and CSV files
Create a file called local_rank_tracker.py.
import os
import re
import time
import glob
import requests
import pandas as pd
from datetime import date
from urllib.parse import urlparse
from dotenv import load_dotenv
load_dotenv()
SERP_API_KEY = os.getenv("SERP_API_KEY")
SERP_API_URL = os.getenv("SERP_API_URL")
def validate_settings():
if not SERP_API_KEY:
raise ValueError("Missing SERP_API_KEY")
if not SERP_API_URL:
raise ValueError("Missing SERP_API_URL")
def load_targets(filename="targets.csv"):
df = pd.read_csv(filename)
required_columns = {"target_name", "target_website"}
missing_columns = required_columns - set(df.columns)
if missing_columns:
raise ValueError(f"Missing columns in {filename}: {missing_columns}")
return df.to_dict("records")
def load_keywords(filename="keywords.csv"):
df = pd.read_csv(filename)
required_columns = {"keyword", "location"}
missing_columns = required_columns - set(df.columns)
if missing_columns:
raise ValueError(f"Missing columns in {filename}: {missing_columns}")
return df.to_dict("records")
This gives us two clean inputs:
targets → businesses we want to track
keywords → local searches we want to run
Step 2: Normalize domains and text
Business websites can appear in different forms:
example.com
www.example.com
https://example.com/
https://www.example.com/services?utm_source=google
We need to normalize them.
def clean_text(value):
if value is None:
return ""
value = str(value)
value = re.sub(r"\s+", " ", value)
return value.strip()
def normalize_domain(value):
if not value:
return ""
value = str(value).strip().lower()
if not value.startswith("http"):
value = "https://" + value
try:
parsed = urlparse(value)
domain = parsed.netloc.lower()
if domain.startswith("www."):
domain = domain[4:]
return domain
except Exception:
value = value.replace("https://", "")
value = value.replace("http://", "")
value = value.replace("www.", "")
value = value.split("/")[0]
return value.lower().strip()
def normalize_name(value):
value = clean_text(value).lower()
value = re.sub(r"[^a-z0-9\s]", " ", value)
value = re.sub(r"\s+", " ", value)
return value.strip()
We will use domains for stronger matching and names as a fallback.
Step 3: Call the SERP API
Now write the function that calls local search results.
def fetch_local_results(keyword, location, language="en"):
params = {
"api_key": SERP_API_KEY,
"engine": "google_maps",
"q": keyword,
"location": location,
"language": language,
"output": "json",
}
response = requests.get(
SERP_API_URL,
params=params,
timeout=30,
)
response.raise_for_status()
return response.json()
Your provider might use a different engine name.
You may need:
google_maps
google_local
local_results
maps
Change only this function if your provider uses different parameters.
The rest of the tracker can stay the same.
That is the entire point of keeping API-specific logic in one place instead of sprinkling it everywhere like cursed confetti.
Step 4: Extract local results
Different APIs may use different keys for local results.
Common examples:
local_results
maps_results
places_results
local_pack
results
Let’s support several common shapes.
def get_local_items(data):
possible_keys = [
"local_results",
"maps_results",
"places_results",
"local_pack",
"results",
]
for key in possible_keys:
value = data.get(key)
if isinstance(value, list):
return value
serp = data.get("serp", {})
if isinstance(serp, dict):
for key in possible_keys:
value = serp.get(key)
if isinstance(value, list):
return value
return []
This makes the code easier to adapt across providers.
Step 5: Normalize one local business result
Now convert a raw result into a consistent format.
def normalize_review_count(value):
if value is None:
return ""
value = str(value)
value = value.replace(",", "")
match = re.search(r"\d+", value)
if not match:
return ""
return int(match.group(0))
def normalize_rating(value):
if value is None:
return ""
try:
return float(value)
except Exception:
match = re.search(r"\d+(\.\d+)?", str(value))
if match:
return float(match.group(0))
return ""
def normalize_local_result(item, index):
name = clean_text(
item.get("title")
or item.get("name")
or item.get("business_name")
or ""
)
website = (
item.get("website")
or item.get("link")
or item.get("url")
or item.get("site")
or ""
)
address = clean_text(
item.get("address")
or item.get("location")
or item.get("full_address")
or ""
)
phone = clean_text(
item.get("phone")
or item.get("phone_number")
or ""
)
category = clean_text(
item.get("type")
or item.get("category")
or ""
)
rating = normalize_rating(
item.get("rating")
or item.get("stars")
or ""
)
review_count = normalize_review_count(
item.get("reviews")
or item.get("review_count")
or item.get("reviews_count")
or ""
)
maps_url = (
item.get("maps_url")
or item.get("link")
or item.get("place_link")
or ""
)
position = (
item.get("position")
or item.get("rank")
or index
)
return {
"position": position,
"name": name,
"normalized_name": normalize_name(name),
"website": website,
"domain": normalize_domain(website),
"address": address,
"phone": phone,
"category": category,
"rating": rating,
"review_count": review_count,
"maps_url": maps_url,
}
Now every local result has the same fields.
That is what makes the ranking snapshot useful.
Step 6: Match the target business
Now we need to check whether a target business appears in the local results.
First, domain matching:
def domain_matches(result_domain, target_domain):
result_domain = normalize_domain(result_domain)
target_domain = normalize_domain(target_domain)
if not result_domain or not target_domain:
return False
return (
result_domain == target_domain
or result_domain.endswith("." + target_domain)
)
Then name matching:
def name_matches(result_name, target_name):
result_name = normalize_name(result_name)
target_name = normalize_name(target_name)
if not result_name or not target_name:
return False
if result_name == target_name:
return True
if target_name in result_name:
return True
if result_name in target_name:
return True
return False
Now combine them:
def find_target_business(local_results, target):
target_name = target["target_name"]
target_website = target["target_website"]
target_domain = normalize_domain(target_website)
for result in local_results:
if domain_matches(result["domain"], target_domain):
return result, "domain"
for result in local_results:
if name_matches(result["name"], target_name):
return result, "name"
return None, ""
Domain matching comes first because it is usually more reliable.
Name matching is useful when a local result does not include a website.
Step 7: Track one keyword and location
Now combine everything for one search.
def track_keyword_for_target(keyword_row, target, language="en"):
keyword = keyword_row["keyword"]
location = keyword_row["location"]
data = fetch_local_results(
keyword=keyword,
location=location,
language=language,
)
raw_items = get_local_items(data)
local_results = [
normalize_local_result(item, index=index)
for index, item in enumerate(raw_items, start=1)
]
matched_result, match_type = find_target_business(local_results, target)
if matched_result:
return {
"snapshot_date": date.today().isoformat(),
"keyword": keyword,
"search_location": location,
"target_name": target["target_name"],
"target_website": target["target_website"],
"target_domain": normalize_domain(target["target_website"]),
"found": True,
"position": matched_result["position"],
"match_type": match_type,
"matched_name": matched_result["name"],
"matched_website": matched_result["website"],
"matched_domain": matched_result["domain"],
"rating": matched_result["rating"],
"review_count": matched_result["review_count"],
"address": matched_result["address"],
"phone": matched_result["phone"],
"category": matched_result["category"],
"maps_url": matched_result["maps_url"],
"result_count": len(local_results),
}
return {
"snapshot_date": date.today().isoformat(),
"keyword": keyword,
"search_location": location,
"target_name": target["target_name"],
"target_website": target["target_website"],
"target_domain": normalize_domain(target["target_website"]),
"found": False,
"position": "",
"match_type": "",
"matched_name": "",
"matched_website": "",
"matched_domain": "",
"rating": "",
"review_count": "",
"address": "",
"phone": "",
"category": "",
"maps_url": "",
"result_count": len(local_results),
}
This returns one row for one target business, keyword, and location.
Step 8: Track all targets and keywords
Now loop through all businesses and all keywords.
def track_all(targets, keywords, language="en", delay=1):
rows = []
for target in targets:
for keyword_row in keywords:
keyword = keyword_row["keyword"]
location = keyword_row["location"]
print(
f"Tracking: {target['target_name']} | {keyword} | {location}"
)
try:
row = track_keyword_for_target(
keyword_row=keyword_row,
target=target,
language=language,
)
rows.append(row)
if row["found"]:
print(f"Found at position {row['position']}")
else:
print("Not found")
except Exception as exc:
print(f"Error: {exc}")
rows.append({
"snapshot_date": date.today().isoformat(),
"keyword": keyword,
"search_location": location,
"target_name": target["target_name"],
"target_website": target["target_website"],
"target_domain": normalize_domain(target["target_website"]),
"found": False,
"position": "",
"match_type": "",
"matched_name": "",
"matched_website": "",
"matched_domain": "",
"rating": "",
"review_count": "",
"address": "",
"phone": "",
"category": "",
"maps_url": "",
"result_count": "",
"error": str(exc),
})
time.sleep(delay)
return rows
The delay helps avoid sending requests too aggressively.
Automation should be reliable, not a caffeinated raccoon with an API key.
Step 9: Save the snapshot
Save each run as a dated CSV file.
def save_snapshot(rows):
today = date.today().isoformat()
filename = f"local_seo_rankings_{today}.csv"
df = pd.DataFrame(rows)
df.to_csv(filename, index=False)
print(f"Saved snapshot: {filename}")
print(f"Rows saved: {len(df)}")
return filename
Now every run creates a file like:
local_seo_rankings_2026-01-01.csv
Step 10: Full script
Here is the full script.
import os
import re
import time
import glob
import requests
import pandas as pd
from datetime import date
from urllib.parse import urlparse
from dotenv import load_dotenv
load_dotenv()
SERP_API_KEY = os.getenv("SERP_API_KEY")
SERP_API_URL = os.getenv("SERP_API_URL")
def validate_settings():
if not SERP_API_KEY:
raise ValueError("Missing SERP_API_KEY")
if not SERP_API_URL:
raise ValueError("Missing SERP_API_URL")
def load_targets(filename="targets.csv"):
df = pd.read_csv(filename)
required_columns = {"target_name", "target_website"}
missing_columns = required_columns - set(df.columns)
if missing_columns:
raise ValueError(f"Missing columns in {filename}: {missing_columns}")
return df.to_dict("records")
def load_keywords(filename="keywords.csv"):
df = pd.read_csv(filename)
required_columns = {"keyword", "location"}
missing_columns = required_columns - set(df.columns)
if missing_columns:
raise ValueError(f"Missing columns in {filename}: {missing_columns}")
return df.to_dict("records")
def clean_text(value):
if value is None:
return ""
value = str(value)
value = re.sub(r"\s+", " ", value)
return value.strip()
def normalize_domain(value):
if not value:
return ""
value = str(value).strip().lower()
if not value.startswith("http"):
value = "https://" + value
try:
parsed = urlparse(value)
domain = parsed.netloc.lower()
if domain.startswith("www."):
domain = domain[4:]
return domain
except Exception:
value = value.replace("https://", "")
value = value.replace("http://", "")
value = value.replace("www.", "")
value = value.split("/")[0]
return value.lower().strip()
def normalize_name(value):
value = clean_text(value).lower()
value = re.sub(r"[^a-z0-9\s]", " ", value)
value = re.sub(r"\s+", " ", value)
return value.strip()
def fetch_local_results(keyword, location, language="en"):
params = {
"api_key": SERP_API_KEY,
"engine": "google_maps",
"q": keyword,
"location": location,
"language": language,
"output": "json",
}
response = requests.get(
SERP_API_URL,
params=params,
timeout=30,
)
response.raise_for_status()
return response.json()
def get_local_items(data):
possible_keys = [
"local_results",
"maps_results",
"places_results",
"local_pack",
"results",
]
for key in possible_keys:
value = data.get(key)
if isinstance(value, list):
return value
serp = data.get("serp", {})
if isinstance(serp, dict):
for key in possible_keys:
value = serp.get(key)
if isinstance(value, list):
return value
return []
def normalize_review_count(value):
if value is None:
return ""
value = str(value)
value = value.replace(",", "")
match = re.search(r"\d+", value)
if not match:
return ""
return int(match.group(0))
def normalize_rating(value):
if value is None:
return ""
try:
return float(value)
except Exception:
match = re.search(r"\d+(\.\d+)?", str(value))
if match:
return float(match.group(0))
return ""
def normalize_local_result(item, index):
name = clean_text(
item.get("title")
or item.get("name")
or item.get("business_name")
or ""
)
website = (
item.get("website")
or item.get("link")
or item.get("url")
or item.get("site")
or ""
)
address = clean_text(
item.get("address")
or item.get("location")
or item.get("full_address")
or ""
)
phone = clean_text(
item.get("phone")
or item.get("phone_number")
or ""
)
category = clean_text(
item.get("type")
or item.get("category")
or ""
)
rating = normalize_rating(
item.get("rating")
or item.get("stars")
or ""
)
review_count = normalize_review_count(
item.get("reviews")
or item.get("review_count")
or item.get("reviews_count")
or ""
)
maps_url = (
item.get("maps_url")
or item.get("link")
or item.get("place_link")
or ""
)
position = (
item.get("position")
or item.get("rank")
or index
)
return {
"position": position,
"name": name,
"normalized_name": normalize_name(name),
"website": website,
"domain": normalize_domain(website),
"address": address,
"phone": phone,
"category": category,
"rating": rating,
"review_count": review_count,
"maps_url": maps_url,
}
def domain_matches(result_domain, target_domain):
result_domain = normalize_domain(result_domain)
target_domain = normalize_domain(target_domain)
if not result_domain or not target_domain:
return False
return (
result_domain == target_domain
or result_domain.endswith("." + target_domain)
)
def name_matches(result_name, target_name):
result_name = normalize_name(result_name)
target_name = normalize_name(target_name)
if not result_name or not target_name:
return False
if result_name == target_name:
return True
if target_name in result_name:
return True
if result_name in target_name:
return True
return False
def find_target_business(local_results, target):
target_name = target["target_name"]
target_website = target["target_website"]
target_domain = normalize_domain(target_website)
for result in local_results:
if domain_matches(result["domain"], target_domain):
return result, "domain"
for result in local_results:
if name_matches(result["name"], target_name):
return result, "name"
return None, ""
def track_keyword_for_target(keyword_row, target, language="en"):
keyword = keyword_row["keyword"]
location = keyword_row["location"]
data = fetch_local_results(
keyword=keyword,
location=location,
language=language,
)
raw_items = get_local_items(data)
local_results = [
normalize_local_result(item, index=index)
for index, item in enumerate(raw_items, start=1)
]
matched_result, match_type = find_target_business(local_results, target)
if matched_result:
return {
"snapshot_date": date.today().isoformat(),
"keyword": keyword,
"search_location": location,
"target_name": target["target_name"],
"target_website": target["target_website"],
"target_domain": normalize_domain(target["target_website"]),
"found": True,
"position": matched_result["position"],
"match_type": match_type,
"matched_name": matched_result["name"],
"matched_website": matched_result["website"],
"matched_domain": matched_result["domain"],
"rating": matched_result["rating"],
"review_count": matched_result["review_count"],
"address": matched_result["address"],
"phone": matched_result["phone"],
"category": matched_result["category"],
"maps_url": matched_result["maps_url"],
"result_count": len(local_results),
}
return {
"snapshot_date": date.today().isoformat(),
"keyword": keyword,
"search_location": location,
"target_name": target["target_name"],
"target_website": target["target_website"],
"target_domain": normalize_domain(target["target_website"]),
"found": False,
"position": "",
"match_type": "",
"matched_name": "",
"matched_website": "",
"matched_domain": "",
"rating": "",
"review_count": "",
"address": "",
"phone": "",
"category": "",
"maps_url": "",
"result_count": len(local_results),
}
def track_all(targets, keywords, language="en", delay=1):
rows = []
for target in targets:
for keyword_row in keywords:
keyword = keyword_row["keyword"]
location = keyword_row["location"]
print(
f"Tracking: {target['target_name']} | {keyword} | {location}"
)
try:
row = track_keyword_for_target(
keyword_row=keyword_row,
target=target,
language=language,
)
rows.append(row)
if row["found"]:
print(f"Found at position {row['position']}")
else:
print("Not found")
except Exception as exc:
print(f"Error: {exc}")
rows.append({
"snapshot_date": date.today().isoformat(),
"keyword": keyword,
"search_location": location,
"target_name": target["target_name"],
"target_website": target["target_website"],
"target_domain": normalize_domain(target["target_website"]),
"found": False,
"position": "",
"match_type": "",
"matched_name": "",
"matched_website": "",
"matched_domain": "",
"rating": "",
"review_count": "",
"address": "",
"phone": "",
"category": "",
"maps_url": "",
"result_count": "",
"error": str(exc),
})
time.sleep(delay)
return rows
def save_snapshot(rows):
today = date.today().isoformat()
filename = f"local_seo_rankings_{today}.csv"
df = pd.DataFrame(rows)
df.to_csv(filename, index=False)
print(f"Saved snapshot: {filename}")
print(f"Rows saved: {len(df)}")
return filename
def main():
validate_settings()
targets = load_targets("targets.csv")
keywords = load_keywords("keywords.csv")
rows = track_all(
targets=targets,
keywords=keywords,
language="en",
delay=1,
)
save_snapshot(rows)
if __name__ == "__main__":
main()
Run it:
python local_rank_tracker.py
You should get a CSV file like:
local_seo_rankings_2026-01-01.csv
Step 11: Compare ranking changes
A single snapshot is useful.
But rank tracking becomes much more useful when you compare snapshots over time.
Create another file:
compare_local_rankings.py
import glob
import pandas as pd
def normalize_position(value):
if pd.isna(value) or value == "":
return None
try:
return int(value)
except Exception:
return None
def make_key(row):
return "|".join([
str(row["keyword"]),
str(row["search_location"]),
str(row["target_domain"]),
])
def load_latest_snapshots():
files = sorted(glob.glob("local_seo_rankings_*.csv"))
if len(files) < 2:
raise ValueError("Need at least two snapshot files to compare")
previous_file = files[-2]
current_file = files[-1]
previous_df = pd.read_csv(previous_file)
current_df = pd.read_csv(current_file)
return previous_file, current_file, previous_df, current_df
def compare_snapshots(previous_df, current_df):
previous_rows = {
make_key(row): row
for _, row in previous_df.iterrows()
}
comparison_rows = []
for _, current in current_df.iterrows():
key = make_key(current)
previous = previous_rows.get(key)
current_position = normalize_position(current.get("position"))
if previous is not None:
previous_position = normalize_position(previous.get("position"))
else:
previous_position = None
if previous_position is None and current_position is None:
change_type = "not_found"
position_change = ""
elif previous_position is None and current_position is not None:
change_type = "new_ranking"
position_change = ""
elif previous_position is not None and current_position is None:
change_type = "lost_ranking"
position_change = ""
else:
position_change = previous_position - current_position
if position_change > 0:
change_type = "up"
elif position_change < 0:
change_type = "down"
else:
change_type = "same"
comparison_rows.append({
"keyword": current["keyword"],
"search_location": current["search_location"],
"target_name": current["target_name"],
"target_domain": current["target_domain"],
"previous_position": previous_position,
"current_position": current_position,
"position_change": position_change,
"change_type": change_type,
"current_url": current.get("maps_url", ""),
"current_address": current.get("address", ""),
})
return pd.DataFrame(comparison_rows)
def main():
previous_file, current_file, previous_df, current_df = load_latest_snapshots()
comparison_df = compare_snapshots(previous_df, current_df)
output_file = "local_seo_ranking_changes.csv"
comparison_df.to_csv(output_file, index=False)
print(f"Previous snapshot: {previous_file}")
print(f"Current snapshot: {current_file}")
print(f"Saved comparison: {output_file}")
print("\nSummary:")
print(comparison_df["change_type"].value_counts())
if __name__ == "__main__":
main()
Run it after you have at least two snapshot files:
python compare_local_rankings.py
This creates:
local_seo_ranking_changes.csv
Example output:
change_type
same 12
up 4
down 3
new_ranking 2
lost_ranking 1
Now you can see whether a business moved up, dropped, appeared, or disappeared.
That is where the tracker becomes useful.
What to track next
Once the basic tracker works, you can add more fields.
For local SEO, useful fields include:
rating
review count
address
phone number
business category
Google Maps URL
place ID
coordinates
opening hours
These fields help answer questions like:
Are higher-rated businesses ranking better?
Do businesses with more reviews appear more often?
Which competitors appear across multiple keywords?
Did a business change its address or phone number?
Position is important.
But the surrounding business data often explains why the ranking might matter.
Add competitor tracking
Instead of tracking only one target business, add competitor businesses to targets.csv.
Example:
target_name,target_website
Example Dental,exampledental.com
Competitor Dental,competitordental.com
Another Smile Clinic,anothersmileclinic.com
Now the script will track each business across the same keyword and location set.
This lets you compare local visibility across competitors.
A useful report might show:
keyword
location
target business position
competitor positions
top ranking business
This is useful for agencies, multi-location businesses, and local SEO audits.
Add multi-location tracking
Local search changes by location.
So do not only track one city.
Expand keywords.csv:
keyword,location
dentist near me,Austin TX
dentist near me,Dallas TX
dentist near me,Houston TX
emergency dentist,Austin TX
emergency dentist,Dallas TX
emergency dentist,Houston TX
Now your tracker can show where the business performs well and where it is weak.
This is especially useful for:
franchises
clinics
law firms
restaurants
schools
service businesses
multi-location brands
Schedule the tracker
You can run the script manually, but rank tracking is more useful when scheduled.
On macOS or Linux, use cron:
crontab -e
Run every Monday at 8 AM:
0 8 * * 1 /usr/bin/python3 /path/to/local_rank_tracker.py
Then run the comparison script:
15 8 * * 1 /usr/bin/python3 /path/to/compare_local_rankings.py
For GitHub Actions, create:
.github/workflows/local-rank-tracker.yml
name: Local SEO Rank Tracker
on:
schedule:
- cron: "0 8 * * 1"
workflow_dispatch:
jobs:
track:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install requests python-dotenv pandas
- run: python local_rank_tracker.py
env:
SERP_API_KEY: ${{ secrets.SERP_API_KEY }}
SERP_API_URL: ${{ secrets.SERP_API_URL }}
For production, you probably want to save snapshots to:
PostgreSQL
SQLite
BigQuery
S3
Google Sheets
Airtable
CSV is fine for the first version.
CSV is not a personality. Do not marry it.
Common mistakes
Ignoring location
Local SEO rankings depend on location.
Always store the search location with each snapshot.
Matching only by business name
Business names can vary.
Use website domain, place ID, or another stable identifier when available.
Tracking too many keywords too soon
Start small.
Make sure the data is clean before scaling.
Comparing different search settings
Do not compare desktop results with mobile results unless you store device type.
Do not compare Austin results with Dallas results.
That is not analysis. That is a spreadsheet doing cosplay.
Not saving historical snapshots
Rank tracking depends on history.
If you only save the latest result, you cannot measure change.
Treating one ranking as the whole story
A position change matters more when you know:
which keyword changed
which location changed
which competitor moved
which URL or Maps listing appeared
Context matters.
Annoying, but true.
Provider note
This tutorial uses a generic SERP API format so the workflow is easy to adapt.
You can use any provider that returns Google Maps or local search results as structured JSON.
When choosing a provider, test whether it returns:
business name
ranking position
website
address
phone
rating
review count
category
place ID or Maps URL
location-aware results
Talordata, SerpApi, SearchAPI, DataForSEO, Bright Data, and other SERP API providers can all fit this workflow depending on your needs.
The important thing is not the homepage.
The important thing is whether the response body gives you clean local ranking data.
Your rank tracker does not run on marketing copy.
It runs on JSON.
Final thoughts
A useful local SEO rank tracker does not need to start as a full SaaS product.
Start with:
targets.csv
keywords.csv
SERP API request
local result parser
business matching
daily snapshot
ranking comparison
Then improve it with:
competitor tracking
multi-location tracking
scheduled runs
Google Sheets reports
Slack alerts
database storage
review count analysis
LLM-generated summaries
The core idea is simple:
keyword + location + target business → ranking position over time
That is the foundation of local SEO tracking.
Once you have that, you can stop manually checking Google Maps like it is a sacred ritual and start working with actual data.
Top comments (0)