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.
What we are building
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 API
python-dotenv → load API keys
pandas → 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
query
engine
location
gl
hl
device
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_results
organic
results
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.csv
ranking_snapshot_2026-01-02.csv
ranking_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
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()
Run it:
python rank_monitor.py
You should get a file like:
ranking_snapshot_2026-01-01.csv
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
Now you can answer:
Which keywords rank?
Which keywords do not rank?
Which URLs are ranking?
Which pages need work?
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()
Run it:
python compare_rankings.py
You get:
ranking_changes.csv
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
The position_change logic is:
old position 8 → new position 4 = +4 improvement
old position 4 → new position 8 = -4 decline
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
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()
]
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
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
is almost useless without location.
You can track different locations by looping over them.
Create locations.txt:
United States
United Kingdom
Canada
Australia
Or city names if your SERP API supports them:
New York
Los Angeles
London
Sydney
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()
]
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
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?
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,
)
Then you can query history:
SELECT keyword, target_domain, date, position
FROM ranking_snapshots
WHERE target_domain = 'example.com'
ORDER BY keyword, date;
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
Add a daily job:
0 9 * * * cd /path/to/project && /usr/bin/python3 rank_monitor.py
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
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?
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.
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
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
Once that loop works, you can build almost anything on top of it.
`
Top comments (0)