Build Your Own Rank Tracker With Python and Proxies
Build your own rank tracker in Python: a keyword CSV, SERP fetches through rotating proxies or the Scraping API, Postgres history and a weekly movement report.
When you build your own rank tracker, a few hundred keywords checked every morning stop being a line item that grows with each new client. The parts are not exotic: a keyword file, a SERP fetch that survives Google's defences, a parser that finds your domain, a table that remembers yesterday, and a report that tells you what moved. This guide wires all of them into one Python pipeline running on SparkProxy's rotating proxies with the Scraping API as a fallback, and puts a realistic price on 1,000 keywords a day.
What You're Building and Where Each Part Runs
The tracker is five small Python modules and one Postgres database. Data moves in one direction:
keywords.csv
-> load_keywords() validate, dedupe, normalise
-> check_keyword() one worker per keyword, pages 1..N
-> fetch via gateway.sparkproxy.io:11000 (Random Rotation)
-> classify() ok | consent | captcha | js_wall | empty | http_error
-> on failure: escalate to the Scraping API
-> find_position() first organic result on your domain
-> rank_checks table one row per keyword per day
-> weekly_report() gainers, losers, entries, drops, URL switches
Everything runs on one small Linux box or a laptop that stays on. Nothing here needs a queue, a browser farm or Kubernetes. At 300 keywords and depth 20, that's 600 page fetches; at roughly 8 seconds each including the deliberate pause, eight workers finish in about ten minutes.
Versions used while writing (read from each project's own release page on 2026-10-30): Python 3.10 or newer, psycopg 3.3.6, beautifulsoup4 4.15.0, lxml 6.1.3, requests 2.34.2, and PostgreSQL 18 (current minor 18.6). Install with pip install "psycopg[binary]" beautifulsoup4 lxml requests.
The Keyword File
Keep the list in a CSV you can edit in a spreadsheet and commit to git. One row is one thing you want a position for: a keyword, in a market, for a domain.
keyword,country,lang,domain,depth
build your own rank tracker,us,en,sparkproxy.io,20
rotating proxies,us,en,sparkproxy.io,30
rotating proxies,gb,en,sparkproxy.io,20
proxy server einrichten,de,de,sparkproxy.io,20
depth is how far down you look. Google no longer reliably honours &num=100, so each block of ten results is its own request; the background is in our guide to scraping Google search results. Depth 20 means two requests per keyword, depth 30 means three. Most freelancers need 20: a client rarely cares whether they sit at 34 or 47.
The loader normalises whitespace and case, so "Rotating Proxies" and "rotating proxies" don't become two rows with two separate histories:
# keywords.py
import csv
from dataclasses import dataclass
@dataclass(frozen=True)
class Keyword:
keyword: str
country: str # ISO alpha-2, lowercase: us, gb, de
lang: str # Google hl value: en, de
domain: str # tracked site, no www.
depth: int # 10, 20 or 30 results deep
def load_keywords(path: str) -> list[Keyword]:
seen, out = set(), []
with open(path, newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
kw = Keyword(
keyword=" ".join(row["keyword"].split()).lower(),
country=row["country"].strip().lower(),
lang=(row.get("lang") or "en").strip().lower(),
domain=row["domain"].strip().lower().removeprefix("www."),
depth=int(row.get("depth") or 20),
)
if kw.depth not in (10, 20, 30):
raise ValueError(f"depth must be 10, 20 or 30: {row}")
key = (kw.keyword, kw.country, kw.lang, kw.domain)
if key not in seen:
seen.add(key)
out.append(kw)
return out
Fail loudly on a bad row. A typo that silently becomes depth 0 shows up three weeks later as a keyword with no history, which is far more annoying to debug than a stack trace on the first run.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
Proxy Settings for a Daily SERP Job
SparkProxy's pools are rotating datacenter IPs behind one gateway: 1M+ premium IPs across 80+ countries, HTTP/HTTPS and SOCKS5, user:pass or IP-whitelist auth, with unlimited bandwidth on every plan. For a rank tracker, these are the settings that matter.
| Setting | What to use | Why |
|---|---|---|
| Rotation | Random Rotation, `gateway.sparkproxy.io:11000` | Every request leaves from a different IP, so no single address accumulates your whole keyword list |
| Sticky Sessions | Port `11002`, only for multi-step flows | Holds one exit IP for 5 minutes; useful when a cookie set on one request must be replayed from the same address |
| Country | `user-country-us` style username, matching the `gl` you send | An exit in the wrong country returns that country's SERP, whatever the URL says |
| Protocol | HTTP/HTTPS on 11000, SOCKS5 on 13000 | `requests` speaks HTTP proxies natively; SOCKS5 needs `requests[socks]` |
| Workers | 4 to 10 | Your plan's thread count is a ceiling, not a target |
| Auth | user:pass on a laptop, IP whitelist on a fixed server | Starter allows 5 whitelisted IPs, so a home connection with a changing IP is easier on user:pass |
The worker count is where people burn their IP reputation. A 100-thread plan does not mean 100 parallel Google queries is wise: Google scores behaviour per IP over time, and the arithmetic of per-IP pacing is covered in proxies for SERP scraping at scale. At a few hundred keywords a day you're nowhere near the limits that article worries about, which is exactly why there's no reason to rush.
If you're still deciding whether datacenter IPs suit Google at all, datacenter proxies for SEO rank tracking makes that case. This build hedges the risk differently: it starts on the cheap flat-rate route and only spends Scraping API credits on the keywords that actually fail.
Fetching Result Pages Through the Gateway
One requests.Session per keyword means cookies Google sets on page one ride along to page two, even though Random Rotation gives page two a fresh IP. The session also keeps the final URL after redirects, which turns out to be the most reliable consent-page signal you have.
# fetch_proxy.py
import urllib.parse
import requests
PROXY_USER = "your-username" # from the SparkProxy dashboard
PROXY_PASS = "your-password"
GATEWAY = "gateway.sparkproxy.io"
RANDOM_PORT = 11000
HEADERS = {
"User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"),
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
}
def serp_url(q: str, country: str, lang: str, start: int = 0) -> str:
params = {"q": q, "hl": lang, "gl": country}
if start:
params["start"] = start
return "https://www.google.com/search?" + urllib.parse.urlencode(params)
def new_session(country: str, lang: str) -> requests.Session:
proxy = (f"http://{PROXY_USER}-country-{country}:{PROXY_PASS}"
f"@{GATEWAY}:{RANDOM_PORT}")
s = requests.Session()
s.headers.update(HEADERS)
s.headers["Accept-Language"] = f"{lang},en;q=0.8"
s.proxies = {"http": proxy, "https": proxy}
return s
def fetch_page_via_proxy(session: requests.Session, url: str, timeout: int = 20):
r = session.get(url, timeout=timeout)
return r.status_code, r.url, r.text
Copy the exact username format from your dashboard; user-country-us is the pattern SparkProxy shows on its own site, and the -country-xx suffix is what pins the exit. Keep gl, hl, the exit country and Accept-Language telling the same story. A German keyword sent with hl=de from a US exit with an English Accept-Language header gets you a page that matches no real user.
The Scraping API as a Fallback Route
Some keywords will keep failing on plain requests. Instead of building a headless browser fleet for them, hand those to the SparkProxy Scraping API. It lives at https://scrape.sparkproxy.io/api/v1 and authenticates with an X-API-Key header. Four parameters do the work here:
render_jsdefaults totrue(5 credits). Setrender_js=falsefor a plain fetch at 1 credit.country_codepins the exit country for +5 credits.proxy_type=premiumroutes through the premium residential pool: 10 credits without JS, 25 with JS.json_response=truereturns an envelope withjob_id,status_code,duration_ms,credits_usedandbody. The body is base64.
That last point bites people. Search a base64 string for "unusual traffic" and you'll never find it, so every challenge page sails through as a valid SERP. Decode first, then classify. And don't reach for format=json thinking it means the envelope: format only converts page content (html, md, mdx, json, screenshot, pdf).
# fetch_api.py
import base64
import json
import requests
API = "https://scrape.sparkproxy.io/api/v1"
API_KEY = "sk-YOUR_API_KEY"
class ApiThrottled(Exception):
def __init__(self, wait: float):
super().__init__(f"throttled, retry in {wait}s")
self.wait = wait
def fetch_via_api(url, country, render_js=False, premium=False, scenario=None):
params = {
"url": url,
"render_js": "true" if render_js else "false",
"country_code": country.upper(),
"json_response": "true",
"tag": "rank-tracker",
}
if premium:
params["proxy_type"] = "premium"
if scenario:
params["js_scenario"] = json.dumps(scenario) # needs render_js=true
r = requests.get(API, headers={"X-API-Key": API_KEY}, params=params, timeout=600)
if r.status_code == 429:
raise ApiThrottled(float(r.json().get("retry_after_seconds", 5)))
if r.status_code in (500, 503, 530):
return None, "", 0 # failed job; the docs refund credits on 500 and 530
r.raise_for_status()
env = r.json()
html = base64.b64decode(env.get("body") or "").decode("utf-8", "replace")
return env.get("status_code"), html, int(env.get("credits_used") or 0)
The 600-second timeout is not a typo. The docs describe a 90-second navigation timeout on the first attempt plus up to three automatic server-side retries at 120 and 180 seconds before a job returns 530. A 30-second client timeout abandons jobs that are still working. A 429 body carries retry_after_seconds and the limit you hit (rate or concurrency), so the runner sleeps for exactly that long and doesn't count it as a failed attempt.
Leave extract_rules out of this pipeline. It replaces the response with an extracted object, so it can't be combined with json_response, and you'd lose the raw HTML you need to tell a consent page from a real result.
Consent Pages, Captchas and Other Fake Results
Google rarely fails loudly. A blocked request usually comes back as a 200 with a page that is not a results page, and a tracker that doesn't check will record "not in top 20" for every keyword on a bad morning. That's how a client gets an alarming email about a ranking collapse that never happened.
Classify every page before you parse it. These are the strings and URLs to look for, and the honest answer on whether a proxy helps:
| Verdict | What you receive | Telltale | Can a new proxy IP fix it? |
|---|---|---|---|
| `captcha` | Interstitial, often `200`, sometimes `429` | final URL contains `/sorry/`; text "Our systems have detected unusual traffic from your computer network" | Yes. A fresh IP is the right first retry |
| `consent` | "Before you continue to Google" | redirect to `consent.google.com` | No. Every IP in that country gets it until a consent choice is stored |
| `js_wall` | A near-empty page asking for JavaScript | `enablejs`, or "Please click here if you are not redirected within a few seconds" | No. The client needs to execute JavaScript, so escalate to `render_js=true` |
| `empty` | Valid layout, zero organic results | no result blocks on page one of a common query | Sometimes. Retry once, then flag it instead of recording a position |
| `http_error` | `5xx`, timeouts, proxy connect errors | status or exception | Usually. Retry on a new IP |
# classify.py
from enum import Enum
class Verdict(str, Enum):
OK = "ok"
CONSENT = "consent"
CAPTCHA = "captcha"
JS_WALL = "js_wall"
EMPTY = "empty"
HTTP_ERROR = "http_error"
CAPTCHA_MARKERS = ("unusual traffic from your computer network", "g-recaptcha",
'id="captcha-form"')
CONSENT_MARKERS = ("before you continue to google", "consent.google.")
JS_MARKERS = ("enablejs", "please click here if you are not redirected")
def classify(status, final_url, html, organic_count):
if status is None:
return Verdict.HTTP_ERROR
low, url = html.lower(), (final_url or "").lower()
if status == 429 or "/sorry/" in url or any(m in low for m in CAPTCHA_MARKERS):
return Verdict.CAPTCHA
if organic_count == 0 and ("consent.google." in url
or any(m in low for m in CONSENT_MARKERS)):
return Verdict.CONSENT
if organic_count == 0 and any(m in low for m in JS_MARKERS):
return Verdict.JS_WALL
if status != 200:
return Verdict.HTTP_ERROR
return Verdict.EMPTY if organic_count == 0 else Verdict.OK
The consent and JavaScript checks only fire when no organic results were found, because a real European SERP can link to consent settings in its footer. Google rewrites these pages without notice, so save every non-ok page to disk with the keyword and timestamp in the filename. When a new interstitial shows up, you'll have the exact HTML to write the next marker from. The broader pattern of detecting soft blocks is in how to detect when your scraper is blocked.
The consent page deserves its own plan because rotating harder doesn't touch it. The trigger is the visitor's location plus a missing stored choice, not IP reputation. The fallback route answers it with a short js_scenario that clicks the reject button and waits for the results to load:
# classify.py (continued)
CONSENT_SCENARIO = {"instructions": [
{"evaluate": "[...document.querySelectorAll('button')]"
".find(b => /reject all/i.test(b.innerText))?.click()"},
{"wait": 3000},
]}
That costs 5 extra credits on top of a rendered request, and it matches on English button text, which is one more reason to keep hl=en for EU markets where you can. If you track in German with hl=de, change the pattern to the German label your saved consent page actually shows.
Finding Your Domain's Position
Google's class names change constantly, so anchor on structure: an organic title is an
A Postgres Schema That Separates Unranked From Unmeasured
The most common bug in homemade trackers is a single nullable position column. NULL then means two opposite things: "we checked and you're not in the top 20" and "we never got a valid page". Charted, both look like a drop. Store the verdict next to the position and the ambiguity disappears.
CREATE TABLE keywords (
id bigserial PRIMARY KEY,
keyword text NOT NULL,
country char(2) NOT NULL,
lang text NOT NULL,
domain text NOT NULL,
depth smallint NOT NULL DEFAULT 20,
active boolean NOT NULL DEFAULT true,
UNIQUE (keyword, country, lang, domain)
);
CREATE TABLE rank_checks (
keyword_id bigint NOT NULL REFERENCES keywords(id),
checked_on date NOT NULL,
verdict text NOT NULL, -- ok, consent, captcha, js_wall, empty, http_error
position smallint, -- NULL with verdict 'ok' = not within depth
url text, -- which of your pages ranked
route text NOT NULL, -- proxy, api, api_js, api_consent, api_premium
attempts smallint NOT NULL,
credits integer NOT NULL DEFAULT 0,
fetched_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (keyword_id, checked_on)
);
CREATE INDEX rank_checks_day ON rank_checks (checked_on);
The upsert below makes the daily job safe to run twice. Its WHERE clause means a second pass can overwrite a failed row but never a good one, so a 09:00 retry run only touches the keywords that broke at 06:00.
# db.py
import psycopg
DSN = "postgresql://tracker:change-me@localhost:5432/ranks"
def sync_keywords(conn, keywords):
ids = {}
with conn.cursor() as cur:
for k in keywords:
cur.execute(
"""INSERT INTO keywords (keyword, country, lang, domain, depth)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (keyword, country, lang, domain)
DO UPDATE SET depth = EXCLUDED.depth, active = true
RETURNING id""",
(k.keyword, k.country, k.lang, k.domain, k.depth),
)
ids[k] = cur.fetchone()[0]
conn.commit()
return ids
def save_check(conn, keyword_id, day, verdict, position, url, route, attempts, credits):
conn.execute(
"""INSERT INTO rank_checks
(keyword_id, checked_on, verdict, position, url, route, attempts, credits)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (keyword_id, checked_on) DO UPDATE SET
verdict = EXCLUDED.verdict, position = EXCLUDED.position,
url = EXCLUDED.url, route = EXCLUDED.route,
attempts = rank_checks.attempts + EXCLUDED.attempts,
credits = rank_checks.credits + EXCLUDED.credits,
fetched_at = now()
WHERE rank_checks.verdict <> 'ok'""",
(keyword_id, day, verdict, position, url, route, attempts, credits),
)
conn.commit()
The attempts and credits columns accumulate across passes, so a monthly SUM(credits) grouped by keyword shows you which terms are expensive to measure. Those are the ones to move to weekly checks. For wider storage trade-offs (files versus SQLite versus a server database), see how to store scraped data.
The Daily Run: Retry Ladder and Scheduling
Each keyword climbs a ladder from cheapest to most expensive and stops at the first rung that returns a real results page. The verdict decides the next rung: a captcha earns another proxy attempt on a new IP, a consent page or JavaScript wall skips straight past the routes that can't fix it.
| Rung | Route | Cost per page | Entered when |
|---|---|---|---|
| 1 | Gateway, Random Rotation (up to 3 tries) | flat (plan) | always first |
| 2 | API, `render_js=false` | 6 credits | proxy tries exhausted on captcha, empty or HTTP errors |
| 3 | API, `render_js=true` | 10 credits | `js_wall`, or rung 2 failed |
| 4 | API, `render_js=true` + consent scenario | 15 credits | `consent` on any rung |
| 5 | API, `proxy_type=premium` with JS | 30 credits | rung 3 failed |
Credit figures include the +5 for country_code, which every rung sends.
# run_daily.py
import datetime as dt
import random
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
import psycopg
import requests
from classify import CONSENT_SCENARIO, Verdict, classify
from db import DSN, save_check, sync_keywords
from fetch_api import ApiThrottled, fetch_via_api
from fetch_proxy import fetch_page_via_proxy, new_session, serp_url
from keywords import load_keywords
from parse import find_position, organic_results
ROUTES = {
"api": {"render_js": False},
"api_js": {"render_js": True},
"api_consent": {"render_js": True, "scenario": CONSENT_SCENARIO},
"api_premium": {"render_js": True, "premium": True},
}
ORDER = ["proxy", "api", "api_js", "api_premium"]
MAX_ATTEMPTS = 6
def attempt(route, kw):
results, credits = [], 0
session = new_session(kw.country, kw.lang) if route == "proxy" else None
for start in range(0, kw.depth, 10):
url = serp_url(kw.keyword, kw.country, kw.lang, start)
if session is not None:
status, final_url, html = fetch_page_via_proxy(session, url)
else:
status, html, used = fetch_via_api(url, kw.country, **ROUTES[route])
final_url, credits = None, credits + used
page = organic_results(html)
verdict = classify(status, final_url, html, len(page))
if verdict is Verdict.EMPTY and start > 0:
break # the SERP simply ran out of results
if verdict is not Verdict.OK:
return verdict, results, credits
results.extend(u for u in page if u not in results)
time.sleep(random.uniform(3, 8))
return Verdict.OK, results, credits
def next_route(route, verdict, tries_here):
if verdict is Verdict.CONSENT:
return None if route == "api_consent" else "api_consent"
if verdict is Verdict.JS_WALL and route in ("proxy", "api"):
return "api_js"
if route == "proxy" and tries_here < 3:
return "proxy"
if route == "api_consent":
return None
i = ORDER.index(route)
return ORDER[i + 1] if i + 1 < len(ORDER) else None
def check_keyword(kw):
route, last, tries_here, attempts, credits = "proxy", "proxy", 0, 0, 0
verdict, results = Verdict.HTTP_ERROR, []
while route is not None and attempts < MAX_ATTEMPTS:
attempts, tries_here, last = attempts + 1, tries_here + 1, route
try:
verdict, results, used = attempt(route, kw)
except ApiThrottled as t:
time.sleep(t.wait) # a throttle is not a failed attempt
attempts, tries_here = attempts - 1, tries_here - 1
continue
except requests.RequestException:
verdict, results, used = Verdict.HTTP_ERROR, [], 0
credits += used
if verdict is Verdict.OK:
break
nxt = next_route(route, verdict, tries_here)
tries_here = tries_here if nxt == route else 0
route = nxt
if verdict is Verdict.OK:
position, url = find_position(results, kw.domain)
else:
position, url = None, None
return kw, verdict, position, url, last, attempts, credits
def main(csv_path="keywords.csv", workers=8):
today = dt.date.today()
keywords = load_keywords(csv_path)
with psycopg.connect(DSN) as conn:
ids = sync_keywords(conn, keywords)
done = {row[0] for row in conn.execute(
"SELECT keyword_id FROM rank_checks WHERE checked_on = %s AND verdict = 'ok'",
(today,))}
todo = [k for k in keywords if ids[k] not in done]
with ThreadPoolExecutor(max_workers=workers) as pool:
for fut in as_completed([pool.submit(check_keyword, k) for k in todo]):
kw, verdict, pos, url, route, n, cr = fut.result()
save_check(conn, ids[kw], today, verdict.value, pos, url, route, n, cr)
print(f"{verdict.value:<10} {pos or '-':>4} {route:<11} {kw.keyword}")
if __name__ == "__main__":
main()
Workers only fetch; the main thread does every database write, so one connection is enough and you never share a psycopg connection across threads. The skip list at the top of main is what makes the second pass cheap: keywords that already have an ok row today aren't fetched again.
Schedule two passes. The first does the work; the second mops up whatever failed, after Google has had a few hours to forget about any IPs that got flagged.
# crontab -e (server clock in UTC)
15 6 * * * cd /opt/rank-tracker && .venv/bin/python run_daily.py >> logs/daily.log 2>&1
15 9 * * * cd /opt/rank-tracker && .venv/bin/python run_daily.py >> logs/daily.log 2>&1
30 7 * * 1 cd /opt/rank-tracker && .venv/bin/python report.py > reports/$(date +\%F).md
Keep the run time fixed. Rankings shift through the day, and a check that drifts from 06:15 to 14:00 adds noise you'll later mistake for movement. Locking, alerting and systemd timers as an alternative to cron are covered in how to schedule and automate web scrapers.
The Weekly Movement Report
Comparing today with exactly seven days ago is noisy. One odd morning on either side produces a big swing that isn't there the next day. Compare the median of the last three good checks with the median of three good checks a week earlier instead, and treat "not in top N" as depth plus one so entries and drops still produce a number.
-- weekly.sql
WITH windowed AS (
SELECT c.keyword_id,
CASE WHEN c.checked_on > %(today)s - 3 THEN 'now' ELSE 'then' END AS bucket,
COALESCE(c.position, k.depth + 1) AS pos,
c.url
FROM rank_checks c
JOIN keywords k ON k.id = c.keyword_id
WHERE c.verdict = 'ok'
AND (c.checked_on > %(today)s - 3
OR c.checked_on BETWEEN %(today)s - 9 AND %(today)s - 7)
), summary AS (
SELECT keyword_id, bucket,
percentile_disc(0.5) WITHIN GROUP (ORDER BY pos) AS pos,
mode() WITHIN GROUP (ORDER BY url) AS url
FROM windowed
GROUP BY keyword_id, bucket
)
SELECT k.keyword, k.country, t.pos AS was, n.pos AS now,
t.pos - n.pos AS gain,
n.url IS DISTINCT FROM t.url AS url_changed,
k.depth
FROM summary n
JOIN summary t ON t.keyword_id = n.keyword_id AND t.bucket = 'then'
JOIN keywords k ON k.id = n.keyword_id
WHERE n.bucket = 'now'
ORDER BY gain DESC;
Because the query only reads verdict = 'ok' rows, a morning of captchas lowers the sample size instead of inventing a drop. A keyword with no good checks in either window just doesn't appear, which is the honest outcome.
# report.py
import datetime as dt
from pathlib import Path
import psycopg
from db import DSN
REPORT_SQL = Path("weekly.sql").read_text(encoding="utf-8")
def label(pos: int, depth: int) -> str:
return "-" if pos > depth else str(pos)
def weekly_report(today=None, threshold=3):
today = today or dt.date.today()
with psycopg.connect(DSN) as conn:
rows = conn.execute(REPORT_SQL, {"today": today}).fetchall()
groups = {"Entered": [], "Dropped out": [], "Gained": [], "Lost": [],
"Ranking URL changed": []}
for kw, country, was, now, gain, url_changed, depth in rows:
line = f"| {kw} | {country} | {label(was, depth)} | {label(now, depth)} | {gain:+d} |"
if was > depth >= now:
groups["Entered"].append(line)
elif now > depth >= was:
groups["Dropped out"].append(line)
elif gain >= threshold:
groups["Gained"].append(line)
elif gain <= -threshold:
groups["Lost"].append(line)
if url_changed and now <= depth and was <= depth:
groups["Ranking URL changed"].append(line)
out = [f"Rank movement, week to {today:%Y-%m-%d}", ""]
for title, lines in groups.items():
if lines:
out += [f"{title} ({len(lines)})", "",
"| Keyword | Country | Was | Now | Change |",
"|---|---|---|---|---|", *lines, ""]
return "\n".join(out)
if __name__ == "__main__":
print(weekly_report())
The "Ranking URL changed" group is the one clients don't get from most dashboards. When the ranking page flips from a guide to a category page, the position may barely move, but it often means two of their pages are competing for one query. It's a content conversation, and you bring the evidence.
Cost per 1,000 Keywords per Day
Prices below are in US dollars, excluding VAT, read from sparkproxy.io on 2026-10-30. Proxy plans are packs billed by threads, not by traffic, and bandwidth is unlimited: Starter $75/month (100 threads), Core $140 (250), Boost $240 (500), Plus $440 (1,000). Longer terms take 5%, 10% or 15% off for 3, 6 or 12 months, and there's a 24-hour free trial with no credit card. Scraping API plans are credit packs: Starter $49/month for 250K credits, Growth $99 for 1M, Pro $249 for 3M, Scale $599 for 8M, and new accounts get 1,000 free credits.
At depth 20, 1,000 keywords is 2,000 page fetches a day, or about 60,000 a month.
| Route for every keyword | Per page | Credits per month | Cheapest plan that covers it | Per day |
|---|---|---|---|---|
| Gateway only | flat | none | Proxy Starter, $75 | $2.50 |
| API, `render_js=false` + `country_code` | 6 credits | 360,000 | API Growth, $99 | $3.30 |
| API, `render_js=true` + `country_code` | 10 credits | 600,000 | API Growth, $99 | $3.30 |
| API, premium + JS + `country_code` | 30 credits | 1,800,000 | API Pro, $249 | $8.30 |
Nobody should run everything on the bottom row. The ladder's real cost is the proxy plan plus credits for the keywords that escalate, and that's easy to estimate: every 1% of keywords that land on rung 3 adds 10 keywords ร 2 pages ร 10 credits = 200 credits a day, or 6,000 a month. Even if a fifth of your list escalated every single day, that's 120,000 credits a month, inside API Starter. So the working budget for 1,000 keywords is roughly $75 + $49 = $124 a month, and a freelancer with 300 keywords pays the same $75 for proxies with a fraction of the credit use.
Two things change the maths. On the flat-rate gateway, a retry costs time, never money, which is why the ladder spends its retries there first. On the API, the docs refund credits for failed jobs (500 and 530), but they don't promise that a challenge page delivered with a 200 counts as a failure, so budget as if it's billed and keep the classifier in front of every escalation. Track the real numbers from the credits column rather than trusting this table: SELECT date_trunc('month', checked_on), SUM(credits) FROM rank_checks GROUP BY 1.
Free data is worth wiring in too. The Search Console Search Analytics API reports a position field documented as "Average position in search results" for your own verified properties. It's an average across impressions, not a point-in-time check from one location, and it can't see competitors, so it complements the tracker rather than replacing it. When the two disagree by a lot for weeks, look at your location settings first.
Frequently asked questions
FAQ
For 1,000 keywords checked daily to depth 20, budget around $124 a month at prices read on 2026-10-30: a $75 SparkProxy Starter proxy plan for the main route and a $49 Scraping API Starter pack for fallbacks. Hosting is whatever small server you already have. The bigger cost is the few hours to set it up and the occasional parser fix when Google changes its markup.
The thread count is rarely the limit. 1,000 keywords at depth 20 is 2,000 requests a day, under two per minute on average, against a Starter plan's 100 threads. What caps you is how hard you push Google per IP, so scale workers slowly and watch the captcha share in your verdict column.
Your browser carries your location, language, search history and signed-in state, and the tracker carries none of that. Check that the proxy exit country, gl, hl and Accept-Language all match the market, and remember the tracker counts organic results only, while you may be counting a featured snippet or local pack as a position.
Not by default. Start every keyword on a plain fetch and escalate to render_js=true only when the classifier sees a JavaScript wall or a plain fetch keeps failing. Rendering costs 5 credits on the Scraping API versus 1 for a plain fetch, so making it the exception keeps the bill down.
Detect it first: a redirect to consent.google.com or the "Before you continue to Google" text with zero organic results. A new IP won't help because every visitor from that location sees it. Send those keywords to the Scraping API with render_js=true and a js_scenario that clicks the reject button, then parse the page that loads.
Partly. Search Console gives an average position for queries where your verified site already earned impressions, at no cost. It can't track competitors, keywords you don't yet rank for, or a fixed location on a fixed day, which is what clients usually ask for. Use it as a cross-check on your own tracker's numbers.
Get 20% off your first month
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Save up to 15% more on quarterly, half-yearly and yearly plans
Related articles
How to See Google Ads in Other Countries Without Clicking
How to see Google ads in other countries: Ads Preview first, then a clean browser behind a country proxy, no clicks, and a daily script that logs ad copy.
Crawlee Proxy Configuration: Rotation That Keeps Sessions
Crawlee proxy setup with proxyUrls, newUrlFunction and tieredProxyUrls: how SessionPool binds a proxy, and how to stop the session-retired loop on login crawls.
Squid cache_peer Upstream Proxy With Authentication
Set up a Squid cache_peer upstream proxy with login=user:pass, never_direct and LAN ACLs so tools without proxy auth can use a paid proxy. Fix TCP_MISS/503.
