DEV Community

dodou
dodou

Posted on

Building a Keyword Rank Tracker on Top of a SERP API

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

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

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

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

Example rows (illustration, not real data):

python requests tutorial                 12 ->   8  (-4)
sqlite date functions                    31 ->  29  (-2)
serp api comparison                       4 ->   4  (+0)
Enter fullscreen mode Exit fullscreen mode

This little report is the part I actually read. Everything else in the database exists to feed it.

Where to take it next

  • device accepts default, pc or mobile (search endpoint only) — mobile vs desktop ranks diverge more than people expect.
  • gl switches the country (defaults to us) — useful if you sell in more than one market.
  • Positions beyond 20: raise PAGES to 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)