DEV Community

Cecilia Hill
Cecilia Hill

Posted on

Build a Simple Keyword Ranking Monitor with Python and a SERP API

At some point, every SEO script starts with a small question:

Where does my site rank for this keyword?
Enter fullscreen mode Exit fullscreen mode

Then the small question grows legs.

You want to track 10 keywords.

Then 100.

Then multiple competitors.

Then different countries.

Then daily changes.

Then someone asks for a CSV report every Monday.

Now the tiny script has become a tiny SEO robot with a clipboard.

In this article, we will build a simple keyword ranking monitor with Python and a SERP API.

The goal is not to build a full SEO platform.

The goal is to build a clean, practical script that can:

keyword list
→ SERP API
→ organic results
→ target domain match
→ ranking snapshot
→ CSV report
Enter fullscreen mode Exit fullscreen mode

You can use this as a starting point for SEO monitoring, competitor tracking, content performance checks, or internal reporting.

What we are building

We will build a Python script that:

  1. Reads keywords from a text file
  2. Calls a SERP API for each keyword
  3. Extracts organic results
  4. Checks whether a target domain appears
  5. Records ranking position
  6. Saves a daily CSV snapshot
  7. Compares today’s ranking with a previous snapshot

The final output will look like this:

date,keyword,target_domain,found,position,url,title
2026-01-01,best serp api,example.com,true,3,https://example.com/serp-api,Example SERP API Page
Enter fullscreen mode Exit fullscreen mode

That is enough to answer the basic SEO question:

Did my ranking go up, down, disappear, or stay the same?
Enter fullscreen mode Exit fullscreen mode

Why use a SERP API?

You can scrape search result pages yourself.

For a demo, that may work.

But for ranking monitoring, scraping gets annoying quickly.

You need to deal with:

HTML changes
blocked requests
CAPTCHA
location differences
device differences
missing snippets
ads shifting layout
unexpected SERP features
Enter fullscreen mode Exit fullscreen mode

A SERP API gives you structured search result data.

Instead of parsing a search page manually, you call an API and get JSON.

The workflow becomes:

keyword → SERP API → organic_results → ranking check
Enter fullscreen mode Exit fullscreen mode

Much cleaner.

Less duct tape. Fewer midnight gremlins.

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 → save and compare 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
TARGET_DOMAIN=example.com
Enter fullscreen mode Exit fullscreen mode

This tutorial uses a generic SERP API style.

Your provider may use different parameter names, such as:

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

Adjust the request function to match your provider’s docs.

The rest of the ranking logic stays the same.

Create a keyword file

Create keywords.txt:

best serp api
google search api
serp api for ai agents
rank tracking api
local seo rank tracker
Enter fullscreen mode Exit fullscreen mode

Use real keywords from your workflow.

Do not only test easy keywords. Real search results are messy, and messy is where your monitor proves whether it is useful.

Step 1: Load settings

Create a file called rank_monitor.py.

import os
import time
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")
TARGET_DOMAIN = os.getenv("TARGET_DOMAIN")
Enter fullscreen mode Exit fullscreen mode

Now add a quick validation function.

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")

    if not TARGET_DOMAIN:
        raise ValueError("Missing TARGET_DOMAIN")
Enter fullscreen mode Exit fullscreen mode

It is better to fail early than to discover 200 empty rows later.

Empty CSV files have a special talent for wasting afternoons.

Step 2: Load keywords

Add a helper to read keywords from keywords.txt.

def load_keywords(filename="keywords.txt"):
    with open(filename, "r", encoding="utf-8") as file:
        return [
            line.strip()
            for line in file
            if line.strip()
        ]
Enter fullscreen mode Exit fullscreen mode

Keeping keywords outside the code makes the script easier to reuse.

Tomorrow you can test a different keyword set without touching Python.

Step 3: Call the SERP API

Now write the API function.

def fetch_serp(query, location="United States", language="en"):
    params = {
        "api_key": SERP_API_KEY,
        "engine": "google",
        "q": query,
        "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

This function returns raw JSON.

Keep it separate from the ranking logic.

When something fails, you want to know whether the API call failed or your parser failed.

Step 4: Extract organic results

Different SERP APIs may use slightly different field names.

Common examples include:

organic_results
organic
results
Enter fullscreen mode Exit fullscreen mode

Let’s support a few common shapes.

def get_organic_items(data):
    possible_keys = [
        "organic_results",
        "organic",
        "results",
    ]

    for key in possible_keys:
        value = data.get(key)

        if isinstance(value, list):
            return value

    return []
Enter fullscreen mode Exit fullscreen mode

This small defensive parser makes the script easier to adapt across providers.

Step 5: Normalize organic results

Now normalize each result into one internal format.

def normalize_result(item):
    return {
        "position": item.get("position") or item.get("rank") or "",
        "title": item.get("title") or "",
        "url": item.get("link") or item.get("url") or "",
        "snippet": item.get("snippet") or item.get("description") or "",
    }
Enter fullscreen mode Exit fullscreen mode

Your ranking code should not care whether the API calls the URL field link or url.

Normalize once.

Use clean data everywhere else.

That tiny layer is the broom closet where future bugs go to behave.

Step 6: Extract domains

To check ranking, we need to compare domains.

A search result URL like this:

https://www.example.com/blog/best-serp-api
Enter fullscreen mode Exit fullscreen mode

should match:

example.com
Enter fullscreen mode Exit fullscreen mode

Add a domain helper.

def extract_domain(url):
    if not url:
        return ""

    parsed = urlparse(url)
    domain = parsed.netloc.lower()

    if domain.startswith("www."):
        domain = domain[4:]

    return domain
Enter fullscreen mode Exit fullscreen mode

Now add a domain matching function.

def domain_matches(result_url, target_domain):
    result_domain = extract_domain(result_url)
    target_domain = target_domain.lower().strip()

    if target_domain.startswith("www."):
        target_domain = target_domain[4:]

    return result_domain == target_domain or result_domain.endswith("." + target_domain)
Enter fullscreen mode Exit fullscreen mode

This allows subdomains to match too.

For example:

blog.example.com
Enter fullscreen mode Exit fullscreen mode

can match:

example.com
Enter fullscreen mode Exit fullscreen mode

If you only want exact domain matching, replace the return line with:

return result_domain == target_domain
Enter fullscreen mode Exit fullscreen mode

Step 7: Find the ranking position

Now we can search through organic results and find the first matching URL.

def find_domain_ranking(results, target_domain):
    for result in results:
        url = result["url"]

        if domain_matches(url, target_domain):
            return {
                "found": True,
                "position": result["position"],
                "url": result["url"],
                "title": result["title"],
                "snippet": result["snippet"],
            }

    return {
        "found": False,
        "position": "",
        "url": "",
        "title": "",
        "snippet": "",
    }
Enter fullscreen mode Exit fullscreen mode

This checks whether the target domain appears in the organic results.

If it appears, we save the position.

If not, we mark it as not found.

Step 8: Track one keyword

Now combine the API call, parsing, normalization, and ranking check.

def track_keyword(keyword, target_domain, location="United States", language="en"):
    data = fetch_serp(
        query=keyword,
        location=location,
        language=language,
    )

    organic_items = get_organic_items(data)

    normalized_results = [
        normalize_result(item)
        for item in organic_items
    ]

    ranking = find_domain_ranking(
        results=normalized_results,
        target_domain=target_domain,
    )

    return {
        "date": date.today().isoformat(),
        "keyword": keyword,
        "target_domain": target_domain,
        "location": location,
        "language": language,
        "found": ranking["found"],
        "position": ranking["position"],
        "url": ranking["url"],
        "title": ranking["title"],
        "snippet": ranking["snippet"],
        "result_count": len(normalized_results),
    }
Enter fullscreen mode Exit fullscreen mode

The result_count field is useful.

If the API returns zero organic results, you want to know.

A “not found” result means something different when there were 10 results versus zero results.

Step 9: Track all keywords

Add a loop for all keywords.

def track_keywords(keywords, target_domain, location="United States", language="en", delay=1):
    rows = []

    for keyword in keywords:
        print(f"Tracking keyword: {keyword}")

        try:
            row = track_keyword(
                keyword=keyword,
                target_domain=target_domain,
                location=location,
                language=language,
            )

            rows.append(row)

        except Exception as exc:
            print(f"Failed keyword: {keyword}")
            print(f"Error: {exc}")

            rows.append({
                "date": date.today().isoformat(),
                "keyword": keyword,
                "target_domain": target_domain,
                "location": location,
                "language": language,
                "found": False,
                "position": "",
                "url": "",
                "title": "",
                "snippet": "",
                "result_count": 0,
                "error": str(exc),
            })

        time.sleep(delay)

    return rows
Enter fullscreen mode Exit fullscreen mode

The delay keeps the script from firing requests too aggressively.

Respect rate limits.

A ranking monitor should be boring and polite, not a request cannon in a trench coat.

Step 10: Save a snapshot

Now save the results as a daily CSV.

def save_snapshot(rows):
    today = date.today().isoformat()
    filename = f"ranking_snapshot_{today}.csv"

    df = pd.DataFrame(rows)
    df.to_csv(filename, index=False)

    print(f"Saved snapshot: {filename}")

    return filename
Enter fullscreen mode Exit fullscreen mode

You will get files like:

ranking_snapshot_2026-01-01.csv
ranking_snapshot_2026-01-02.csv
ranking_snapshot_2026-01-03.csv
Enter fullscreen mode Exit fullscreen mode

That gives you a ranking history.

Full script

Here is the complete first version.

import os
import time
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")
TARGET_DOMAIN = os.getenv("TARGET_DOMAIN")


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")

    if not TARGET_DOMAIN:
        raise ValueError("Missing TARGET_DOMAIN")


def load_keywords(filename="keywords.txt"):
    with open(filename, "r", encoding="utf-8") as file:
        return [
            line.strip()
            for line in file
            if line.strip()
        ]


def fetch_serp(query, location="United States", language="en"):
    params = {
        "api_key": SERP_API_KEY,
        "engine": "google",
        "q": query,
        "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_organic_items(data):
    possible_keys = [
        "organic_results",
        "organic",
        "results",
    ]

    for key in possible_keys:
        value = data.get(key)

        if isinstance(value, list):
            return value

    return []


def normalize_result(item):
    return {
        "position": item.get("position") or item.get("rank") or "",
        "title": item.get("title") or "",
        "url": item.get("link") or item.get("url") or "",
        "snippet": item.get("snippet") or item.get("description") or "",
    }


def extract_domain(url):
    if not url:
        return ""

    parsed = urlparse(url)
    domain = parsed.netloc.lower()

    if domain.startswith("www."):
        domain = domain[4:]

    return domain


def domain_matches(result_url, target_domain):
    result_domain = extract_domain(result_url)
    target_domain = target_domain.lower().strip()

    if target_domain.startswith("www."):
        target_domain = target_domain[4:]

    return result_domain == target_domain or result_domain.endswith("." + target_domain)


def find_domain_ranking(results, target_domain):
    for result in results:
        url = result["url"]

        if domain_matches(url, target_domain):
            return {
                "found": True,
                "position": result["position"],
                "url": result["url"],
                "title": result["title"],
                "snippet": result["snippet"],
            }

    return {
        "found": False,
        "position": "",
        "url": "",
        "title": "",
        "snippet": "",
    }


def track_keyword(keyword, target_domain, location="United States", language="en"):
    data = fetch_serp(
        query=keyword,
        location=location,
        language=language,
    )

    organic_items = get_organic_items(data)

    normalized_results = [
        normalize_result(item)
        for item in organic_items
    ]

    ranking = find_domain_ranking(
        results=normalized_results,
        target_domain=target_domain,
    )

    return {
        "date": date.today().isoformat(),
        "keyword": keyword,
        "target_domain": target_domain,
        "location": location,
        "language": language,
        "found": ranking["found"],
        "position": ranking["position"],
        "url": ranking["url"],
        "title": ranking["title"],
        "snippet": ranking["snippet"],
        "result_count": len(normalized_results),
    }


def track_keywords(keywords, target_domain, location="United States", language="en", delay=1):
    rows = []

    for keyword in keywords:
        print(f"Tracking keyword: {keyword}")

        try:
            row = track_keyword(
                keyword=keyword,
                target_domain=target_domain,
                location=location,
                language=language,
            )

            rows.append(row)

        except Exception as exc:
            print(f"Failed keyword: {keyword}")
            print(f"Error: {exc}")

            rows.append({
                "date": date.today().isoformat(),
                "keyword": keyword,
                "target_domain": target_domain,
                "location": location,
                "language": language,
                "found": False,
                "position": "",
                "url": "",
                "title": "",
                "snippet": "",
                "result_count": 0,
                "error": str(exc),
            })

        time.sleep(delay)

    return rows


def save_snapshot(rows):
    today = date.today().isoformat()
    filename = f"ranking_snapshot_{today}.csv"

    df = pd.DataFrame(rows)
    df.to_csv(filename, index=False)

    print(f"Saved snapshot: {filename}")

    return filename


def main():
    validate_settings()

    keywords = load_keywords("keywords.txt")

    rows = track_keywords(
        keywords=keywords,
        target_domain=TARGET_DOMAIN,
        location="United States",
        language="en",
        delay=1,
    )

    save_snapshot(rows)

    print(f"Tracked {len(rows)} keywords.")


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Run it:

python rank_monitor.py
Enter fullscreen mode Exit fullscreen mode

You should get a file like:

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

Example output

Your CSV may look like this:

date,keyword,target_domain,location,language,found,position,url,title,snippet,result_count
2026-01-01,best serp api,example.com,United States,en,true,4,https://example.com/serp-api,Best SERP API,...
2026-01-01,google search api,example.com,United States,en,false,,,,,10
Enter fullscreen mode Exit fullscreen mode

Now you can answer:

Which keywords rank?
Which keywords do not rank?
Which URLs are ranking?
Which pages need work?
Enter fullscreen mode Exit fullscreen mode

This is already useful.

But the real value comes from comparing snapshots.

Compare ranking snapshots

Create a second file called compare_rankings.py.

This script compares two CSV snapshots and calculates ranking changes.

import pandas as pd


def normalize_position(value):
    if pd.isna(value) or value == "":
        return None

    try:
        return int(value)
    except ValueError:
        return None


def compare_snapshots(old_file, new_file):
    old_df = pd.read_csv(old_file)
    new_df = pd.read_csv(new_file)

    merge_keys = [
        "keyword",
        "target_domain",
        "location",
        "language",
    ]

    merged = new_df.merge(
        old_df,
        on=merge_keys,
        how="left",
        suffixes=("_new", "_old"),
    )

    rows = []

    for _, row in merged.iterrows():
        old_position = normalize_position(row.get("position_old"))
        new_position = normalize_position(row.get("position_new"))

        if old_position is None and new_position is None:
            change_type = "not_found"
            position_change = ""

        elif old_position is None and new_position is not None:
            change_type = "new_ranking"
            position_change = ""

        elif old_position is not None and new_position is None:
            change_type = "lost_ranking"
            position_change = ""

        else:
            position_change = old_position - new_position

            if position_change > 0:
                change_type = "up"
            elif position_change < 0:
                change_type = "down"
            else:
                change_type = "same"

        rows.append({
            "keyword": row["keyword"],
            "target_domain": row["target_domain"],
            "location": row["location"],
            "language": row["language"],
            "old_position": old_position,
            "new_position": new_position,
            "position_change": position_change,
            "change_type": change_type,
            "old_url": row.get("url_old", ""),
            "new_url": row.get("url_new", ""),
        })

    return pd.DataFrame(rows)


def main():
    old_file = "ranking_snapshot_2026-01-01.csv"
    new_file = "ranking_snapshot_2026-01-08.csv"

    comparison = compare_snapshots(old_file, new_file)

    comparison.to_csv("ranking_changes.csv", index=False)

    print(comparison)


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Run it:

python compare_rankings.py
Enter fullscreen mode Exit fullscreen mode

You get:

ranking_changes.csv
Enter fullscreen mode Exit fullscreen mode

With rows like:

keyword,old_position,new_position,position_change,change_type
best serp api,8,4,4,up
google search api,,12,,new_ranking
rank tracking api,5,,lost_ranking
Enter fullscreen mode Exit fullscreen mode

The position_change logic is:

old position 8 → new position 4 = +4 improvement
old position 4 → new position 8 = -4 decline
Enter fullscreen mode Exit fullscreen mode

Lower SERP position numbers are better.

So moving from 8 to 4 is an improvement.

Track multiple competitors

A ranking monitor gets more useful when you track competitor domains too.

Create domains.txt:

example.com
competitor-one.com
competitor-two.com
competitor-three.com
Enter fullscreen mode Exit fullscreen mode

Add a loader:

def load_domains(filename="domains.txt"):
    with open(filename, "r", encoding="utf-8") as file:
        return [
            line.strip()
            for line in file
            if line.strip()
        ]
Enter fullscreen mode Exit fullscreen mode

Then adjust your main loop:

def track_keywords_for_domains(keywords, domains, location="United States", language="en", delay=1):
    rows = []

    for keyword in keywords:
        print(f"Fetching SERP once for keyword: {keyword}")

        try:
            data = fetch_serp(
                query=keyword,
                location=location,
                language=language,
            )

            organic_items = get_organic_items(data)

            normalized_results = [
                normalize_result(item)
                for item in organic_items
            ]

            for domain in domains:
                ranking = find_domain_ranking(
                    results=normalized_results,
                    target_domain=domain,
                )

                rows.append({
                    "date": date.today().isoformat(),
                    "keyword": keyword,
                    "target_domain": domain,
                    "location": location,
                    "language": language,
                    "found": ranking["found"],
                    "position": ranking["position"],
                    "url": ranking["url"],
                    "title": ranking["title"],
                    "snippet": ranking["snippet"],
                    "result_count": len(normalized_results),
                })

        except Exception as exc:
            print(f"Failed keyword: {keyword}")
            print(f"Error: {exc}")

        time.sleep(delay)

    return rows
Enter fullscreen mode Exit fullscreen mode

This is better than calling the API once per domain.

You fetch the SERP once per keyword, then check every domain inside that result set.

Tiny efficiency win. The budget goblin gets one less snack.

Track by city or country

Search rankings change by location.

A keyword like:

best dentist
Enter fullscreen mode Exit fullscreen mode

is almost useless without location.

You can track different locations by looping over them.

Create locations.txt:

United States
United Kingdom
Canada
Australia
Enter fullscreen mode Exit fullscreen mode

Or city names if your SERP API supports them:

New York
Los Angeles
London
Sydney
Enter fullscreen mode Exit fullscreen mode

Add a loader:

def load_locations(filename="locations.txt"):
    with open(filename, "r", encoding="utf-8") as file:
        return [
            line.strip()
            for line in file
            if line.strip()
        ]
Enter fullscreen mode Exit fullscreen mode

Then run your tracking function for each location.

def track_all_locations(keywords, target_domain, locations, language="en"):
    all_rows = []

    for location in locations:
        print(f"\nTracking location: {location}")

        rows = track_keywords(
            keywords=keywords,
            target_domain=target_domain,
            location=location,
            language=language,
            delay=1,
        )

        all_rows.extend(rows)

    return all_rows
Enter fullscreen mode Exit fullscreen mode

Now your snapshot can answer:

Where do we rank in the US?
Where do we rank in the UK?
Where did we lose visibility?
Which cities need local content?
Enter fullscreen mode Exit fullscreen mode

Store snapshots in SQLite

CSV is fine at the start.

Once you run this daily, SQLite is cleaner.

import sqlite3


def save_to_sqlite(rows, database="rankings.db"):
    df = pd.DataFrame(rows)

    with sqlite3.connect(database) as connection:
        df.to_sql(
            "ranking_snapshots",
            connection,
            if_exists="append",
            index=False,
        )
Enter fullscreen mode Exit fullscreen mode

Then you can query history:

SELECT keyword, target_domain, date, position
FROM ranking_snapshots
WHERE target_domain = 'example.com'
ORDER BY keyword, date;
Enter fullscreen mode Exit fullscreen mode

This turns your simple monitor into a small ranking database.

Still simple. Much more useful.

Run it daily

On macOS or Linux, you can run it with cron.

Open crontab:

crontab -e
Enter fullscreen mode Exit fullscreen mode

Add a daily job:

0 9 * * * cd /path/to/project && /usr/bin/python3 rank_monitor.py
Enter fullscreen mode Exit fullscreen mode

That runs the script every day at 9 AM.

On Windows, use Task Scheduler.

The boring daily run is where ranking monitoring becomes valuable.

Manual checks are snapshots.

Scheduled checks are history.

What to watch out for

1. Not found does not always mean gone

If your target domain is not found, it may mean:

not in top results
API returned fewer results
location changed
query triggered a different SERP layout
request failed
parser missed the field
Enter fullscreen mode Exit fullscreen mode

Always keep result_count and error fields.

2. Ranking position depends on result type

Organic position is not the same as absolute page position.

Ads, maps, shopping blocks, videos, and People Also Ask can appear above organic results.

For this tutorial, we track organic rankings only.

If you care about local packs or shopping results, write separate parsers.

3. Location matters

Do not compare rankings from different locations as if they are the same.

A ranking in New York is not automatically the same as a ranking in London.

4. Search results move

Do not panic over one-day changes.

Look at trends.

One position movement may be noise.

A steady drop over two weeks deserves attention.

5. Test your actual keywords

Do not build the monitor around clean demo keywords.

Use your real keyword set.

That is where bad assumptions show up.

A note on SERP API providers

Most SERP API providers can support a basic ranking monitor if they return organic results with position, title, and URL.

When testing providers, check:

Does it return clean organic positions?
Does it support the countries or cities you need?
Does it return enough results per query?
Does it include HTML output for debugging?
How often are responses empty?
How easy is the JSON to normalize?
Are failed requests billed?
Enter fullscreen mode Exit fullscreen mode

Disclosure: I work with Talordata. If you test Talordata or any other SERP API, I would use the same rule:

Do not choose from landing pages alone.
Run your real keywords and compare the response body.
Enter fullscreen mode Exit fullscreen mode

For ranking monitors, clean response structure matters more than a pretty homepage.

Final thoughts

A keyword ranking monitor does not have to be complicated.

The basic version is just:

keywords
→ SERP API
→ organic results
→ domain match
→ position snapshot
→ compare over time
Enter fullscreen mode Exit fullscreen mode

Start with one domain and 20 keywords.

Save daily CSV files.

Compare snapshots.

Then add competitors, locations, SQLite, alerts, and dashboards when you actually need them.

The most useful SEO tools often start as small scripts.

This one gives you the core loop:

check rankings
save history
detect changes
Enter fullscreen mode Exit fullscreen mode

Once that loop works, you can build almost anything on top of it.


`
Enter fullscreen mode Exit fullscreen mode

Top comments (0)