Build a Simple Keyword Ranking Monitor with Python and a SERP API
What We Are Building
At some point, every SEO script starts with a small question: Where does my site rank for this keyword? 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
You can use this as a starting point for SEO monitoring, competitor tracking, content performance checks, or internal reporting.
We will build a Python script that:
- Reads keywords from a text file
- Calls a SERP API for each keyword
- Extracts organic results
- Checks whether a target domain appears
- Records ranking position
- Saves a daily CSV snapshot
- 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
That is enough to answer the basic SEO question: Did my ranking go up, down, disappear, or stay the same?
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
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
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
We will use:
requests→ call the SERP APIpython-dotenv→ load API keyspandas→ save and compare 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
TARGET_DOMAIN=example.com
This tutorial uses a generic SERP API style. Your provider may use different parameter names, such as:
q/queryenginelocationglhldevice
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
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")
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")
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()]
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()
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_resultsorganicresults
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 []
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 "",
}
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
should match:
example.com
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
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)
This allows subdomains to match too. For example: blog.example.com can match example.com. If you only want exact domain matching, replace the return line with:
return result_domain == target_domain
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": "",
}
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),
}
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
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
You will get files like:
ranking_snapshot_2026-01-01.csvranking_snapshot_2026-01-02.csvranking_snapshot_2026-01-03.csv
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
Comments
No comments yet. Start the discussion.