๐ŸŽ‰ Premium Proxies ยท 24-Hour Free TrialClaim Now
Use Cases

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.

S SparkProxy 1 28 min read
Share

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.

Free trial

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.

SettingWhat to useWhy
RotationRandom Rotation, `gateway.sparkproxy.io:11000`Every request leaves from a different IP, so no single address accumulates your whole keyword list
Sticky SessionsPort `11002`, only for multi-step flowsHolds 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 sendAn exit in the wrong country returns that country's SERP, whatever the URL says
ProtocolHTTP/HTTPS on 11000, SOCKS5 on 13000`requests` speaks HTTP proxies natively; SOCKS5 needs `requests[socks]`
Workers4 to 10Your plan's thread count is a ceiling, not a target
Authuser:pass on a laptop, IP whitelist on a fixed serverStarter 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_js defaults to true (5 credits). Set render_js=false for a plain fetch at 1 credit.
  • country_code pins the exit country for +5 credits.
  • proxy_type=premium routes through the premium residential pool: 10 credits without JS, 25 with JS.
  • json_response=true returns an envelope with job_id, status_code, duration_ms, credits_used and body. 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.

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.

RungRouteCost per pageEntered when
1Gateway, Random Rotation (up to 3 tries)flat (plan)always first
2API, `render_js=false`6 creditsproxy tries exhausted on captcha, empty or HTTP errors
3API, `render_js=true`10 credits`js_wall`, or rung 2 failed
4API, `render_js=true` + consent scenario15 credits`consent` on any rung
5API, `proxy_type=premium` with JS30 creditsrung 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 keywordPer pageCredits per monthCheapest plan that covers itPer day
Gateway onlyflatnoneProxy Starter, $75$2.50
API, `render_js=false` + `country_code`6 credits360,000API Growth, $99$3.30
API, `render_js=true` + `country_code`10 credits600,000API Growth, $99$3.30
API, premium + JS + `country_code`30 credits1,800,000API 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.

Special Discount ยท 20% off

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

Claim Discount

About the Author

The SparkProxy Technical Team runs SparkProxy's rotating datacenter proxy network and the Scraping API, and builds collection pipelines like this one for SERP monitoring, price tracking and market research. The prices, ports, credit costs and API behaviour in this guide come from sparkproxy.io and the Scraping API documentation as read on 2026-10-30; library versions come from each project's own release page on the same date. Questions about a specific tracking setup can go to support@sparkproxy.io.

Keep reading

Related articles