A keyword rank tracker is three loops: ask a search API where your domain ranks for a keyword, write the answer down with today's date, then compare dates. That's the whole product. Charts, alerts and exports are just different queries over that one table. Here is a minimal working version in Python — about a hundred lines, storing to SQLite.
What I actually needed
I track a short list of keywords for a small site. What I want is one number per keyword per day, kept forever, plus a way to see what moved this week. I don't open most of the features in hosted trackers, and I don't want another monthly subscription for something a cron job can do. If that sounds like you, read on.
Architecture
The data source is a SERP API: one POST request per keyword returns the full Google result page as JSON. I'm using SerpBase for this — the search endpoint takes a query plus hl, gl, device and page, and returns an organic array. Request format, auth and the response fields are all in the SerpBase search API documentation. Two facts that shape the design:
- Auth is an
X-API-Keyheader on a POST request with a JSON body. - A successful search request costs 1 credit, so a 100-keyword daily check is 100 credits a day. Failed requests get refunded automatically.
Step 1: fetch the SERP and find your domain
import time
import requests
API_URL = "https://api.serpbase.dev/google/search"
API_KEY = "YOUR_API_KEY"
DOMAIN = "example.com" # the site you track
PAGES = 2 # how deep to look
def fetch(keyword: str, page: int) -> dict:
resp = requests.post(
API_URL,
headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
json={"q": keyword, "hl": "en", "gl": "us", "page": page, "device": "default"},
timeout=30,
)
resp.raise_for_status()
return resp.json()
def find_position(keyword: str):
for page in range(1, PAGES + 1): # page is 1-based
data = fetch(keyword, page)
for item in data.get("organic", []):
if DOMAIN in item.get("link", ""):
return {"position": item.get("position", item.get("rank")),
"page": page, "url": item.get("link")}
time.sleep(1.5) # stay polite between pages
return None
Organic items come with rank, position, title, link and snippet (some also carry date or sitelinks). The code logs position and falls back to rank, and keeps the matched URL so you can eyeball anything odd later. Returning None when the domain isn't found matters: "not in top 20" is data, not an error.
Step 2: store every check
import sqlite3
db = sqlite3.connect("ranks.db")
db.execute("""
CREATE TABLE IF NOT EXISTS rank_history (
checked_at TEXT, keyword TEXT, gl TEXT, device TEXT,
position INTEGER, page INTEGER, url TEXT
)
""")
def record(keyword: str, result):
db.execute(
"INSERT INTO rank_history VALUES (datetime('now'), ?, 'us', 'default', ?, ?, ?)",
(keyword,
result["position"] if result else None,
result["page"] if result else None,
result["url"] if result else None),
)
db.commit()
SQLite keeps this single-file and dependency-free. If you outgrow it, the table maps one-to-one onto Postgres.
Step 3: the daily loop
keywords = [l.strip() for l in open("keywords.txt", encoding="utf-8") if l.strip()]
for kw in keywords:
record(kw, find_position(kw))
time.sleep(1.5)
Point cron (or Task Scheduler) at this once a day. Keep the keyword list stable — a rank tracker only becomes useful when the trend is comparable across weeks.
On rate limits: the docs map error 1029 RATE_LIMITED to exceeding QPS or concurrency limits. The simple defense is what the code does — sleep between requests and retry once on a non-200 response. With a few hundred keywords there's no reason to push the QPS ceiling anyway.
Step 4: the weekly movers report
from collections import defaultdict
history = defaultdict(list)
for kw, ts, pos in db.execute(
"SELECT keyword, checked_at, position FROM rank_history ORDER BY keyword, checked_at"
):
history[kw].append((ts, pos))
movers = []
for kw, points in history.items():
if len(points) >= 2 and None not in (points[-1][1], points[-2][1]):
movers.append((points[-1][1] - points[-2][1], kw, points[-2][1], points[-1][1]))
for delta, kw, old, new in sorted(movers):
print(f"{kw:40s} {old:>3} -> {new:>3} ({delta:+d})")
print("negative delta = moved up")
Example rows (illustration, not real data):
python requests tutorial 12 -> 8 (-4)
sqlite date functions 31 -> 29 (-2)
serp api comparison 4 -> 4 (+0)
This little report is the part I actually read. Everything else in the database exists to feed it.
Where to take it next
-
deviceacceptsdefault,pcormobile(search endpoint only) — mobile vs desktop ranks diverge more than people expect. -
glswitches the country (defaults tous) — useful if you sell in more than one market. - Positions beyond 20: raise
PAGESto 3; each extra page is one more credit per keyword.
FAQ
How often should I check rankings? Once a day is enough for most sites; positions wobble within a day and the daily sample keeps the trend readable. Checking hourly multiplies your credit spend without adding decisions.
Do I need to go past page 1? If you're normally in the top 10, one page would do — I check two so a drop to position 15 still lands in the table as a number instead of a NULL.
What does this cost to run? The search endpoint bills 1 credit per successful request (images and maps endpoints bill 2). 100 keywords × 2 pages daily = 200 credits a day; the response's credits_charged field tells you what each request actually charged.
Point it at your own keyword list and let it run for two weeks before judging — the movers report is where the useful surprises show up. All the parameters used above (hl, gl, device, page) are documented in the link in the architecture section.
Top comments (0)