Se hai mai provato a costruire un Google Rank Tracker in Python con proxy residenziali, sai quanto sia frustrante: dopo 20 richieste Google restituisce CAPTCHA, la paginazione si rompe, e le posizioni oscillano del 30% da un giorno all'altro. Questa guida mostra come costruire un tracker production-ready che supera i blocchi di Google, usa fingerprinting TLS realistico e memorizza storico posizioni in SQLite.
Google ha rimosso il parametro num=100 a settembre 2025, rendendo la paginazione SERP obbligatoria. In combinazione con fingerprinting TLS/JA3-JA4 e scoring reputazione IP, questo rende i proxy datacenter quasi inutilizzabili per il rank tracking. La soluzione: proxy residenziali con geo-targeting città, sticky session per keyword, e un client HTTP che impersona Chrome.
Costruire un Google Rank Tracker in Python: il contesto tecnico
Un rank tracker Google deve risolvere tre problemi simultanei:
- Raccolta SERP: scaricare i risultati organici per una keyword in un dato paese e device.
- Parsing posizioni: estrarre la posizione del dominio target dai risultati, saltando annunci e SERP features.
- Storico temporale: memorizzare snapshot giornalieri per rilevare trend e volatilità.
Il problema esiste perché Google non offre un'API pubblica gratuita per SERP. L'API Custom Search di Google costa $5 per 1.000 query e non replica esattamente i risultati organici. Per volumi elevati o tracking multi-keyword, lo scraping resta l'unica via economica.
Google ha rimosso num=100 a settembre 2025: ora la SERP mostra 10 risultati per pagina, e devi paginare con start=0,10,20,…,90 per coprire i primi 100. Ogni pagina aggiuntiva aumenta il rischio di CAPTCHA e richiede un IP pulito.
Perché gli snapshot giornalieri battono i controlli one-off
Un controllo one-off ti dice la posizione attuale, ma non la volatilità. Una keyword che passa dalla posizione 3 alla 12 e poi torna a 4 in 3 giorni segnala instabilità che un singolo snapshot nasconde. Con snapshot giornalieri puoi calcolare:
- Posizione media su 7/30 giorni.
- Deviazione standard per rilevare volatilità anomala.
- Trend con regressione lineare semplice.
Per un'agenzia SEO con 500 keyword, 1 snapshot/giorno = 15.000 richieste/mese. A questo volume, la qualità del proxy determina il successo o il fallimento.
Modello dati: keyword, dominio, paese, device, posizione, timestamp
Il modello dati deve essere semplice ma sufficiente per analisi storica. Una singola tabella SQLite basta per la maggior parte dei casi:
import sqlite3
DB_PATH = "rank_tracker.db"
def init_db(db_path: str = DB_PATH) -> None:
conn = sqlite3.connect(db_path)
conn.execute("""
CREATE TABLE IF NOT EXISTS rankings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
keyword TEXT NOT NULL,
target_domain TEXT NOT NULL,
country TEXT NOT NULL DEFAULT 'US',
device TEXT NOT NULL DEFAULT 'desktop',
position INTEGER,
page INTEGER,
result_url TEXT,
captured_at TEXT NOT NULL,
UNIQUE(keyword, target_domain, country, device, captured_at)
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_keyword_date
ON rankings(keyword, captured_at)
""")
conn.commit()
conn.close()
def insert_ranking(db_path: str, keyword: str, domain: str,
country: str, device: str, position: int,
page: int, url: str, captured_at: str) -> None:
conn = sqlite3.connect(db_path)
conn.execute("""
INSERT OR REPLACE INTO rankings
(keyword, target_domain, country, device, position, page, result_url, captured_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (keyword, domain, country, device, position, page, url, captured_at))
conn.commit()
conn.close()
La chiave UNIQUE su (keyword, target_domain, country, device, captured_at) evita duplicati se lo stesso job gira due volte. L'indice su (keyword, captured_at) rende le query di storico veloci anche con milioni di righe.
Perché i proxy residenziali sono obbligatori nel 2026
Google usa tre livelli di difesa anti-bot:
- Fingerprinting TLS/JA3-JA4: il server Google analizza il handshake TLS del client. JA3 genera un hash univoco basato su cipher suites, estensioni e curve. Le librerie Python standard (urllib3, requests) hanno fingerprint diversi da Chrome e vengono marcate come sospette.
- Scoring reputazione IP: ogni IP ha un punteggio basato su ASN, storico abuso, tipo (datacenter vs residenziale). Range datacenter noti (AWS, DigitalOcean) hanno reputazione bassa.
- Rate limiting comportamentale: troppe richieste dallo stesso IP in poco tempo innescano CAPTCHA o soft-ban temporanei.
I proxy residenziali risolvono il secondo problema: usano IP di ISP reali (Comcast, AT&T, Vodafone) con reputazione alta. Combinati con curl_cffi che impersona Chrome a livello TLS, superano i primi due livelli. Il terzo livello si gestisce con rotazione IP e sticky session.
Geo-targeting città e sticky session
Per il rank tracking, il geo-targeting è critico: i risultati Google a New York differiscono da quelli a Chicago per keyword locali. ProxyHat supporta geo-targeting paese e città nel username:
| Parametro | Formato username | Caso d'uso |
|---|---|---|
| Paese | user-country-US | Tracking nazionale |
| Paese + città | user-country-US-region-illinois-city-chicago | Tracking locale |
| Sticky session | user-sid-abc123 | Mantiene stesso IP per paginazione |
| Combinato | user-country-US-region-illinois-city-chicago-sid-abc123 | Tracking locale + IP stabile |
La sticky session è essenziale per il rank tracking: se l'IP cambia a metà paginazione (da start=0 a start=90), Google può mostrare risultati diversi perché il nuovo IP ha un'altra geo-locazione. Con -sid-abc123, ProxyHat mantiene lo stesso IP per tutta la durata della sessione.
Esempio pratico: curl_cffi + ProxyHat per rank tracking
Installazione delle dipendenze:
pip install curl_cffi beautifulsoup4 lxml
pip install proxyhat # SDK opzionale
1. Test rapido con curl
# Test proxy residenziale ProxyHat con curl
curl -x "http://user-country-US-region-illinois-city-chicago-sid-rank001:pass@gate.proxyhat.com:8080" \
"https://www.google.com/search?q=python+tutorial&num=10&start=0" \
-H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" \
--compressed
2. Fetch SERP con curl_cffi e impersonazione Chrome
from curl_cffi import requests as cffi_requests
from urllib.parse import quote_plus
import time
def build_proxy_url(country: str, region: str, city: str, session_id: str) -> str:
"""Costruisce l'URL proxy ProxyHat con geo-targeting e sticky session."""
username = f"user-country-{country}-region-{region}-city-{city}-sid-{session_id}"
return f"http://{username}:pass@gate.proxyhat.com:8080"
def fetch_serp_page(keyword: str, start: int, country: str = "US",
region: str = "illinois", city: str = "chicago", session_id: str = "kw1") -> str:
"""Scarica una pagina SERP (10 risultati) con curl_cffi impersonando Chrome."""
proxy_url = build_proxy_url(country, region, city, session_id)
q = quote_plus(keyword)
url = f"https://www.google.com/search?q={q}&num=10&start={start}&hl=en&gl={country.lower()}"
response = cffi_requests.get(
url,
impersonate="chrome",
proxies={"http": proxy_url, "https": proxy_url},
timeout=30,
)
response.raise_for_status()
return response.text
# Esempio: scarica prima pagina per "python tutorial"
html = fetch_serp_page("python tutorial", start=0, country="US", region="illinois", city="chicago", session_id="py001")
print(f"HTML length: {len(html)} bytes")
Il parametro impersonate="chrome" fa sì che curl_cffi replichi il fingerprint TLS di Chrome 120+, incluse cipher suites e estensioni. Questo inganna il controllo JA3/JA4 di Google.
3. Parsing posizioni organiche con BeautifulSoup
from bs4 import BeautifulSoup
import re
def parse_organic_results(html: str, target_domain: str) -> dict:
"""Estrae risultati organici e trova la posizione del dominio target.
Salta annunci (sponsor), SERP features (people also ask, maps, video).
Restituisce {'position': int|None, 'page': int, 'url': str|None}.
"""
soup = BeautifulSoup(html, "lxml")
# Selettore CSS per risultati organici: div.g con href che inizia con /url o http
organic_divs = soup.select("div.g")
position = None
result_url = None
rank_counter = 0
for div in organic_divs:
link = div.select_one("a[href]")
if not link:
continue
href = link.get("href", "")
# Salta ads (href contiene googleads o google.com/aclk)
if "googleads" in href or "/aclk" in href:
continue
# Salta SERP features (video, maps, images)
if "google.com/maps" in href or "youtube.com/watch" in href:
continue
rank_counter += 1
if target_domain in href:
position = rank_counter
result_url = href
break
return {
"position": position,
"page": (position - 1) // 10 + 1 if position else None,
"url": result_url,
}
# Esempio: trova posizione di python.org per "python tutorial"
result = parse_organic_results(html, "python.org")
print(f"Position: {result['position']}, Page: {result['page']}")
4. Paginazione top-100 con sticky session
from datetime import datetime, timezone
def track_keyword(keyword: str, target_domain: str, country: str = "US",
city: str = "chicago", max_results: int = 100) -> dict:
"""Traccia una keyword paginando fino a max_results (10 pagine da 10 risultati).
Usa una sticky session per keyword per mantenere lo stesso IP.
"""
session_id = f"kw-{hash(keyword) % 100000}"
found_position = None
found_url = None
found_page = None
for start in range(0, max_results, 10):
try:
html = fetch_serp_page(keyword, start, country, city, session_id)
result = parse_organic_results(html, target_domain)
if result["position"]:
# Aggiungi offset della pagina corrente
found_position = result["position"] + start
found_url = result["url"]
found_page = start // 10 + 1
break
time.sleep(2) # Delay tra pagine per evitare rate limit
except Exception as e:
print(f"Errore a start={start}: {e}")
continue
captured_at = datetime.now(timezone.utc).isoformat()
return {
"keyword": keyword,
"target_domain": target_domain,
"country": country,
"device": "desktop",
"position": found_position,
"page": found_page,
"result_url": found_url,
"captured_at": captured_at,
}
# Tracking completo: salva in SQLite
ranking = track_keyword("python tutorial", "python.org", country="US", city="chicago")
insert_ranking(DB_PATH, **ranking)
print(f"Saved: {ranking['position']} at {ranking['captured_at']}")
5. Uso del ProxyHat SDK per rotazione automatica
from proxyhat import ProxyClient
import time
# Inizializza client ProxyHat
client = ProxyClient(username="user", password="pass")
def fetch_with_sdk(keyword: str, country: str, city: str, session_id: str) -> str:
"""Fetch SERP usando il ProxyHat SDK per rotazione IP automatica."""
proxy = client.get_proxy(
proxy_type="residential",
country=country,
city=city,
session_id=session_id,
)
proxy_url = f"http://{proxy.username}:{proxy.password}@{proxy.host}:{proxy.port}"
q = quote_plus(keyword)
url = f"https://www.google.com/search?q={q}&num=10&hl=en&gl={country.lower()}"
response = cffi_requests.get(
url,
impersonate="chrome",
proxies={"http": proxy_url, "https": proxy_url},
timeout=30,
)
response.raise_for_status()
return response.text
# Rotazione IP automatica per keyword diverse
keywords = ["python tutorial", "flask docs", "django tutorial"]
for kw in keywords:
sid = f"sess-{kw.replace(' ', '-')}"
html = fetch_with_sdk(kw, "US", "chicago", sid)
result = parse_organic_results(html, "python.org")
print(f"{kw}: position={result['position']}")
time.sleep(3)
Production hardening: retry, CAPTCHA, concorrenza
Un rank tracker production-ready deve gestire fallimenti gracefully. Ecco i pattern essenziali:
Retry con exponential backoff
import time
import random
from curl_cffi import requests as cffi_requests
def fetch_with_retry(url: str, proxy_url: str, max_retries: int = 3) -> str:
"""Fetch con retry esponenziale e jitter.
Ritenta su errori di rete e 429/503.
"""
for attempt in range(max_retries):
try:
response = cffi_requests.get(
url,
impersonate="chrome",
proxies={"http": proxy_url, "https": proxy_url},
timeout=30,
)
if response.status_code == 200:
return response.text
elif response.status_code in (429, 503):
wait = (2 ** attempt) + random.uniform(0, 1)
print(f"Rate limited, waiting {wait:.1f}s...")
time.sleep(wait)
else:
response.raise_for_status()
except Exception as e:
wait = (2 ** attempt) + random.uniform(0, 1)
print(f"Attempt {attempt+1} failed: {e}, retry in {wait:.1f}s")
time.sleep(wait)
raise RuntimeError(f"Failed after {max_retries} retries")
Rilevamento CAPTCHA
def detect_captcha(html: str) -> bool:
"""Rileva CAPTCHA Google nella risposta HTML."""
captcha_signals = [
"Our systems have detected unusual traffic",
"unusual traffic from your computer network",
"recaptcha",
"g-recaptcha",
"sorry/index",
]
html_lower = html.lower()
return any(signal.lower() in html_lower for signal in captcha_signals)
def fetch_serp_safe(keyword: str, start: int, country: str, city: str,
session_id: str) -> str:
"""Fetch con rilevamento CAPTCHA e pausa prolungata."""
proxy_url = build_proxy_url(country, city, session_id)
q = quote_plus(keyword)
url = f"https://www.google.com/search?q={q}&num=10&start={start}&hl=en&gl={country.lower()}"
html = fetch_with_retry(url, proxy_url)
if detect_captcha(html):
print("CAPTCHA detected, pausing 120s...")
time.sleep(120)
# Cambia session per ottenere nuovo IP
new_sid = f"{session_id}-retry-{int(time.time())}"
proxy_url = build_proxy_url(country, city, new_sid)
html = fetch_with_retry(url, proxy_url)
if detect_captcha(html):
raise RuntimeError("CAPTCHA persistente, blocca keyword")
return html
Concorrenza limitata con ThreadPoolExecutor
from concurrent.futures import ThreadPoolExecutor, as_completed
import threading
# Limite globale per non saturare i proxy
global_semaphore = threading.Semaphore(5)
def track_keyword_safe(keyword: str, domain: str, country: str, city: str) -> dict:
"""Tracking con semaforo per limitare concorrenza a 5 richieste simultanee."""
with global_semaphore:
try:
ranking = track_keyword(keyword, domain, country, city)
insert_ranking(DB_PATH, **ranking)
return ranking
except Exception as e:
print(f"Failed tracking '{keyword}': {e}")
return None
def batch_track(keywords: list, domain: str, country: str = "US",
city: str = "chicago", max_workers: int = 5) -> list:
"""Batch tracking con concorrenza limitata."""
results = []
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(track_keyword_safe, kw, domain, country, city): kw
for kw in keywords
}
for future in as_completed(futures):
result = future.result()
if result:
results.append(result)
return results
# Esempio: traccia 20 keyword con 5 worker simultanei
keywords = ["python tutorial", "flask tutorial", "django tutorial", "fastapi guide"]
results = batch_track(keywords, "python.org", max_workers=5)
print(f"Tracked {len(results)}/{len(keywords)} keywords")
Smoothing volatilità rank
import statistics
def get_rank_history(db_path: str, keyword: str, domain: str, days: int = 7) -> list:
"""Recupera storico posizioni degli ultimi N giorni."""
conn = sqlite3.connect(db_path)
rows = conn.execute("""
SELECT position, captured_at FROM rankings
WHERE keyword = ? AND target_domain = ?
ORDER BY captured_at DESC
LIMIT ?
""", (keyword, domain, days)).fetchall()
conn.close()
return [r[0] for r in rows if r[0] is not None]
def compute_rank_metrics(positions: list) -> dict:
"""Calcola media, deviazione standard, trend."""
if not positions:
return {"avg": None, "stdev": None, "trend": None}
avg = statistics.mean(positions)
stdev = statistics.stdev(positions) if len(positions) > 1 else 0.0
# Trend semplice: differenza tra primo e ultimo
trend = positions[-1] - positions[0] if len(positions) > 1 else 0
return {
"avg": round(avg, 1),
"stdev": round(stdev, 2),
"trend": trend, # negativo = miglioramento (posizione più bassa = meglio)
}
# Esempio: analizza volatilità di una keyword
history = get_rank_history(DB_PATH, "python tutorial", "python.org", days=7)
metrics = compute_rank_metrics(history)
print(f"Avg: {metrics['avg']}, StDev: {metrics['stdev']}, Trend: {metrics['trend']}")
Una deviazione standard superiore a 3.0 indica volatilità anomala: potrebbe significare che Google sta testando SERP diverse o che il dominio è in una zona di transizione. In questi casi, conviene aumentare la frequenza di tracking a 2 volte al giorno.
Etica e limiti nel rank tracking
Lo scraping SERP esiste in un'area grigia. Linee guida pratiche:
- Traccia il tuo dominio: monitorare i propri ranking è legittimo e non viola ToS di Google nella maggior parte dei casi.
- Rispetta
robots.txt: Google permette lo scraping di/searchma con rate limit. Controlla sempre robots.txt prima di avviare un job. - Volume ragionevole: per poche keyword (sotto 50), considera l'API Custom Search di Google a $5 per 1.000 query. È più affidabile e non rischia ban.
- GDPR e CCPA: se memorizzi dati SERP che contengono informazioni personali (snippet con nomi), assicurati di avere una base legale e cancellare i dati dopo il periodo necessario.
- Rate limits: mantieni almeno 2-3 secondi tra richieste per keyword e non superare 10 richieste simultanee per pool proxy.
Per volumi enterprise (oltre 10.000 keyword/giorno), valuta provider SERP API specializzati come SerpAPI o DataForSEO: costano di più ma gestiscono proxy, parsing e compliance internamente.
ProxyHat: setup e configurazione
ProxyHat offre proxy residenziali con geo-targeting paese e città, sticky session, e rotazione automatica. Per il rank tracking Google, la configurazione consigliata è:
- Tipo proxy: residenziale (reputazione IP alta, supera fingerprinting).
- Geo-targeting: paese + città per tracking locale accurato.
- Sticky session: una session per keyword, TTL 30 minuti.
- Porta HTTP: 8080 (default), SOCKS5 su 1080 per uso specifico.
Consulta la documentazione ProxyHat per dettagli su pool disponibili, limiti di concorrenza e best practice. Verifica i piani su /it/pricing e le location disponibili su /it/locations.
Per use case correlati, vedi /it/use-cases/web-scraping e /it/use-cases/serp-tracking.





