DEV Community

Cecilia Hill
Cecilia Hill

Posted on

How to Build a Local SEO Rank Tracker with Python

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
Enter fullscreen mode Exit fullscreen mode

A search like this:

dentist near me
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This is useful for:

local SEO reports
Google Maps rank tracking
agency dashboards
multi-location businesses
competitor visibility monitoring
local business audits
Enter fullscreen mode Exit fullscreen mode

What we are building

We will build a Python script that:

  1. Reads target businesses from a CSV file
  2. Reads local SEO keywords from another CSV file
  3. Calls a SERP API for each keyword and location
  4. Extracts Google Maps or local results
  5. Normalizes business fields
  6. Finds the target business in the results
  7. Saves a daily ranking snapshot
  8. 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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

We will use:

requests → call the SERP API
python-dotenv → load API keys
pandas → read and write CSV files
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Different providers use different endpoints and parameter names.

Some use:

q
query
engine
location
gl
hl
device
type
search_type
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The tracker will try to match local results by:

business name
website domain
Enter fullscreen mode Exit fullscreen mode

Website matching is usually more stable than name matching.

Business names can vary:

Example Dental
Example Dental Clinic
Example Dental - Austin
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

This gives us two clean inputs:

targets → businesses we want to track
keywords → local searches we want to run
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

Your provider might use a different engine name.

You may need:

google_maps
google_local
local_results
maps
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 []
Enter fullscreen mode Exit fullscreen mode

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,
    }
Enter fullscreen mode Exit fullscreen mode

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)
    )
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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, ""
Enter fullscreen mode Exit fullscreen mode

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),
    }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now every run creates a file like:

local_seo_rankings_2026-01-01.csv
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

Run it:

python local_rank_tracker.py
Enter fullscreen mode Exit fullscreen mode

You should get a CSV file like:

local_seo_rankings_2026-01-01.csv
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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()
Enter fullscreen mode Exit fullscreen mode

Run it after you have at least two snapshot files:

python compare_local_rankings.py
Enter fullscreen mode Exit fullscreen mode

This creates:

local_seo_ranking_changes.csv
Enter fullscreen mode Exit fullscreen mode

Example output:

change_type
same            12
up               4
down             3
new_ranking      2
lost_ranking     1
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Run every Monday at 8 AM:

0 8 * * 1 /usr/bin/python3 /path/to/local_rank_tracker.py
Enter fullscreen mode Exit fullscreen mode

Then run the comparison script:

15 8 * * 1 /usr/bin/python3 /path/to/compare_local_rankings.py
Enter fullscreen mode Exit fullscreen mode

For GitHub Actions, create:

.github/workflows/local-rank-tracker.yml
Enter fullscreen mode Exit fullscreen mode
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 }}
Enter fullscreen mode Exit fullscreen mode

For production, you probably want to save snapshots to:

PostgreSQL
SQLite
BigQuery
S3
Google Sheets
Airtable
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then improve it with:

competitor tracking
multi-location tracking
scheduled runs
Google Sheets reports
Slack alerts
database storage
review count analysis
LLM-generated summaries
Enter fullscreen mode Exit fullscreen mode

The core idea is simple:

keyword + location + target business → ranking position over time
Enter fullscreen mode Exit fullscreen mode

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)