Misurare l'Impatto delle Citazioni AI: Analisi, Log e Scrape dei Pannelli
Introduzione
La scoperta basata sull'IA è ora suddivisa su diversi livelli di misurazione. Una persona può vedere un'azienda citata in una risposta AI, cliccare su un link senza un referrer utile, visitare tramite un browser che rimuove i dati della campagna, o leggere una risposta senza cliccare affatto. Nel frattempo, un crawler AI può richiedere la pagina dell'azienda senza rappresentare un visitatore umano.
Un sistema di misurazione utile deve quindi separare quattro domande:
- Il sito è stato scansionato o recuperato?
- Il sito è stato citato in una risposta AI?
- Una persona ha visitato il sito da quella risposta?
- Quella visita ha prodotto un coinvolgimento significativo o un guadagno?
A partire dal 22 settembre 2026, il reporting di prima parte sta migliorando. Google Search Console fornisce un rapporto sulle prestazioni dell'AI Generativa per le Panoramiche AI e la Modalità AI, inclusi impressioni, pagine, paesi, dispositivi e date. Il rapporto è diventato disponibile in tutto il mondo il 31 agosto 2026. Rapporto sulle prestazioni dell'AI Generativa di Google (support.google.com)
Anche Microsoft fornisce un rapporto sulle Performance AI in Bing Webmaster Tools. La sua anteprima pubblica riporta il totale delle citazioni, le pagine citate, le query di riferimento e le tendenze delle citazioni attraverso le esperienze AI di Microsoft supportate. Performance AI di Bing Webmaster Tools (blogs.bing.com)
Questi strumenti dovrebbero essere trattati come autorevoli per le loro superfici, ma non creano un sistema di misurazione completo tra assistenti. L'approccio raccomandato è combinare rapporti di prima parte, log del server, link di tracciamento controllati, esportazioni di analytics e osservazioni programmate dei pannelli di risposta.
1. Perché la Misurazione del Riferimento AI è Difficile
L'analisi web tradizionale dipende fortemente dall'header Referer e dai parametri della campagna. Entrambi possono essere mancanti o incompleti.
L'header HTTP Referer può contenere la pagina precedente, solo l'origine del sito web precedente, o nessun valore affatto. Le politiche di referrer dei browser possono intenzionalmente limitare o rimuovere tali informazioni. Documentazione Referrer-Policy MDN (developer.mozilla.org)
I sistemi AI creano ulteriori complicazioni:
- Un assistente può linkare a una pagina senza inviare un referrer riconoscibile.
- Un assistente può utilizzare un URL di reindirizzamento o riscritto.
- Un assistente può recuperare i contenuti lato server prima di mostrare una risposta.
- Un crawler AI può visitare una pagina senza che nessun umano la veda.
- La risposta può citare un URL canonico invece dell'URL originariamente recuperato.
- La risposta può cambiare a seconda della posizione, del dispositivo, della lingua, dell'ora o della personalizzazione.
Ad esempio, OpenAI distingue tra OAI-SearchBot per la scoperta di ricerca, GPTBot per un possibile uso di training e ChatGPT-User per azioni avviate dall'utente. Perplexity identifica separatamente PerplexityBot e Perplexity-User. Anthropic identifica ClaudeBot, Claude-SearchBot e Claude-User. Queste richieste non dovrebbero essere combinate in un unico numero generico di “traffico AI”. Documentazione crawler OpenAI (openai.com) Documentazione crawler Perplexity (docs.perplexity.ai) Documentazione crawler Anthropic (support.anthropic.com)
La regola centrale è:
L'attività del crawler non è traffico di riferimento, la visibilità della citazione non è traffico, e il traffico non è impatto commerciale.
2. Il Framework di Misurazione
Utilizzare cinque livelli di misurazione.
| Livello | Domanda principale | Dati raccomandati |
|---|---|---|
| Accesso | Un sistema AI ha richiesto la pagina? | Log del server web, log del firewall |
| Visibilità | La pagina è stata mostrata o citata? | Google Search Console, Bing Webmaster Tools, osservazioni del pannello di risposta |
| Acquisizione | Una persona è arrivata da una superficie AI? | Referrer, parametri della campagna, clic su short-link, analytics |
| Coinvolgimento | Il visitatore ha interagito in modo significativo? | Eventi di analytics, visualizzazioni di pagina, tempo di coinvolgimento |
| Valore | La visita ha prodotto un risultato desiderato? | Iscrizioni, lead, acquisti, abbonamenti |
Gerarchia raccomandata delle fonti di verità
Utilizzare il segnale più forte disponibile per ogni domanda:
- Rapporti della piattaforma di prima parte per impressioni e citazioni.
- Osservazioni del pannello di risposta per la visibilità a livello di prompt e confronti competitivi.
- Log di reindirizzamento per clic su link controllati.
- Web analytics per sessioni, coinvolgimento e conversioni.
- Log del server per attività di crawler e fetcher.
- Stime modellate solo quando la prova diretta non è disponibile.
Non combinare tutti i segnali in un unico numero senza preservare la fonte e il livello di confidenza.
3. Modello Dati Principale
Creare un data warehouse con tabelle separate per osservazioni grezze e metriche derivate.
panel_runs
Una riga per ogni prompt e controllo della superficie.
text run_id observed_at surface topic prompt_id query_text locale country device panel_present panel_type answer_hash raw_html_path screenshot_path collector_version status
panel_citations
Una riga per ogni link osservato in un pannello di risposta.
text run_id surface topic prompt_id cited_url cited_domain citation_position link_scope target_domain target_cited
ai_crawler_requests
Dati aggregati dai log del server.
text date bot_name bot_role path requests successful_requests error_requests unique_ip_count
shortener_clicks
Una riga per ogni richiesta di link controllato.
text click_id occurred_at slug surface topic prompt_id asset_id referrer_host user_agent_family ip_hmac destination_path
ai_sessions
Una tabella di analytics normalizzata.
text session_id date surface topic prompt_id landing_page attribution_method confidence engaged engagement_time_seconds key_event revenue
Il campo confidence è importante. I valori raccomandati sono:
- A — Diretta: clic su short-link controllato o parametro di campagna fidato.
- B — Forte: referrer dell'assistente noto o richiesta di fetch utente verificata.
- C — Probabile: evidenza di campagna o landing page senza un referrer completo.
- D — Modellata: dedotta da tempistiche, osservazioni del pannello o cambiamenti aggregati.
Riportare A e B separatamente da C e D.
4. Definizioni delle Metriche
Quota di Citazioni per Argomento
La Quota di Citazioni per Argomento misura la frequenza con cui un dominio target appare tra le fonti osservate per un argomento.
[ \text{Citation Share}_{topic}
\frac{\text{Target-domain citation occurrences}} {\text{All citation occurrences}} ]
Esempio:
- 100 citazioni totali osservate per “software di gestione progetti”
- 12 citazioni puntano al tuo dominio
- Quota di Citazioni per Argomento = 12%
Questa metrica misura la quota di fonte, non se una risposta ha contenuto il tuo sito almeno una volta.
Copertura delle Citazioni
Utilizzare una seconda metrica per la presenza a livello di risposta:
[ \text{Citation Coverage}_{topic}
\frac{\text{Answer runs containing a target citation}} {\text{Answer runs where a panel was present}} ]
La Copertura delle Citazioni è spesso più facile da capire per il pubblico non tecnico:
“Il nostro sito è apparso nel 34% delle risposte AI osservate per questo argomento.”
Posizione della Citazione
Registrare la posizione di una citazione all'interno della risposta:
1= prima fonte osservata2= seconda fonte osservata3= terza fonte osservata
Poiché le interfacce AI spesso raggruppano o riordinano le fonti, trattare la posizione come un'osservazione descrittiva, non come una classifica universale.
Tasso di Riferimento dell'Assistente
Il Tasso di Riferimento dell'Assistente misura la proporzione di sessioni umane sul sito che possono essere attribuite a una superficie AI.
[ \text{Assistant Referral Rate}
\frac{\text{AI-attributed human sessions}} {\text{All human sessions}} ]
Riportare almeno due versioni:
text Confirmed Assistant Referral Rate = A-confidence AI sessions / all human sessions
Expanded Assistant Referral Rate = A + B + C-confidence AI sessions / all human sessions
La versione confermata è più conservativa. La versione espansa è utile per l'analisi delle tendenze ma non dovrebbe essere presentata come traffico completamente osservato.
Non usare le esecuzioni del pannello come impressioni
Se fai lo scraping di 500 prompt, ciò non significa che 500 persone abbiano visto quelle risposte. Il tuo scraper misura un campione controllato di stati di risposta. Utilizzare i rapporti di impressioni della piattaforma di prima parte quando disponibili.
Coinvolgimento Post-Citazione
Il Coinvolgimento Post-Citazione misura se le persone arrivate tramite una citazione AI hanno intrapreso un'azione significativa.
[ \text{Post-Citation Engagement}
\frac{\text{Engaged AI sessions}} {\text{AI-attributed sessions}} ]
Per Google Analytics 4, una sessione coinvolta dura più di 10 secondi, include un evento chiave o include almeno due visualizzazioni di pagina o schermata. Documentazione sul tasso di coinvolgimento di Google Analytics (support.google.com)
Riportare anche:
text Post-Citation Engagement Time = median engaged seconds per AI session
Post-Citation Conversion Rate = AI sessions with a key event / AI-attributed sessions
Post-Citation Revenue per Session = revenue from AI sessions / AI-attributed sessions
Il tempo di coinvolgimento mediano è solitamente più stabile del tempo di coinvolgimento medio perché un piccolo numero di sessioni molto lunghe può distorcere la media.
Ulteriori Metriche Utili
Tasso di Successo del Crawler AI
[ \text{Crawler Success Rate}
\frac{\text{AI crawler requests returning 2xx or 3xx}} {\text{AI crawler requests}} ]
Scomporre questo dato per bot e percorso. Un elevato calo delle citazioni combinato con un tasso di errore crescente del crawler potrebbe indicare un problema di hosting, firewall o configurazione dei robot.
Tasso di Citazione-a-Clic
Per osservazioni controllate del pannello di risposta:
[ \text{Observed Citation-to-Click Rate}
\frac{\text{Confirmed short-link clicks}} {\text{Observed target citations}} ]
Questo non è un vero tasso di clic dell'utente a meno che il denominatore non rappresenti impressioni utente effettive. Etichettarlo chiaramente come metrica operativa osservata.
Freschezza della Citazione
Misurare quanto spesso l'URL citato riflette la versione corrente della pagina.
text Citation Freshness = cited pages with current content hash / cited pages
Questo è utile per pagine di documentazione, prezzi, prodotti e politiche che cambiano frequentemente.
5. Analisi dei Log del Server
Configurare i Log di Accesso Strutturati
I log strutturati sono più facili da analizzare rispetto ai tradizionali log separati da spazi.
Esempio di configurazione Nginx:
nginx log_format ai_json escape=json '{' '"ts":"$time_iso8601",' '"remote_addr":"$remote_addr",' '"request":"$request",' '"status":$status,' '"bytes":$body_bytes_sent,' '"referer":"$http_referer",' '"user_agent":"$http_user_agent",' '"host":"$host"' '}';
access_log /var/log/nginx/ai-access.jsonl ai_json;
Memorizzare i file di log in una posizione limitata. Non inviare indirizzi IP grezzi a una tabella di analytics generica a meno che non si abbia un chiaro motivo operativo.
Identificare i Tipi di Richieste AI
Al minimo, separare:
text OpenAI:
- OAI-SearchBot
- ChatGPT-User
- GPTBot
Anthropic:
- Claude-SearchBot
- Claude-User
- ClaudeBot
Perplexity:
- PerplexityBot
- Perplexity-User
Search engines:
- Googlebot
- Bingbot
Le funzionalità di Ricerca AI di Google utilizzano generalmente i controlli di crawling di Google Search esistenti anziché un “bot di Panoramica AI” universale separato. Google afferma che Googlebot controlla l'accesso per la Ricerca, incluse le funzionalità AI. Funzionalità e siti web AI di Google (developers.google.com)
Le stringhe user-agent possono essere falsificate. Per decisioni operative, combinare la corrispondenza dell'user-agent con gli intervalli IP pubblicati dal fornitore o il DNS inverso verificato dove disponibile. OpenAI e Perplexity pubblicano informazioni IP per i loro crawler e fetcher pertinenti. (openai.com)
Summarizzatore di Log Riproducibile
Salvare quanto segue come ai_log_summary.py.
python #!/usr/bin/env python3
import csv import hashlib import hmac import json import os import re import sys from collections import Counter, defaultdict from datetime import datetime from urllib.parse import urlsplit
BOT_PATTERNS = { "OAI-SearchBot": ("openai", re.compile(r"\bOAI-SearchBot\b", re.I)), "ChatGPT-User": ("openai_user", re.compile(r"\bChatGPT-User\b", re.I)), "GPTBot": ("openai_training", re.compile(r"\bGPTBot\b", re.I)), "Claude-SearchBot": ("anthropic", re.compile(r"\bClaude-SearchBot\b", re.I)), "Claude-User": ("anthropic_user", re.compile(r"\bClaude-User\b", re.I)), "ClaudeBot": ("anthropic_training", re.compile(r"\bClaudeBot\b", re.I)), "PerplexityBot": ("perplexity", re.compile(r"\bPerplexityBot\b", re.I)), "Perplexity-User": ("perplexity_user", re.compile(r"\bPerplexity-User\b", re.I)), "Googlebot": ("google_search", re.compile(r"\bGooglebot\b", re.I)), "Bingbot": ("bing_search", re.compile(r"\bbingbot\b", re.I)), }
SECRET = os.environ.get("HMAC_SECRET", "replace-this-in-production").encode()
def hmac_ip(ip): return hmac.new(SECRET, ip.encode(), hashlib.sha256).hexdigest()[:16]
def classify_bot(user_agent): for bot_name, (role, pattern) in BOT_PATTERNS.items(): if pattern.search(user_agent or ""): return bot_name, role return None, None
def parse_path(request_line): try: parts = request_line.split() return urlsplit(parts[1]).path except Exception: return "/"
def main(input_path): totals = defaultdict(lambda: { "requests": 0, "success": 0, "errors": 0, "ips": set(), "paths": Counter(), })
with open(input_path, "r", encoding="utf-8") as source:
for line in source:
try:
record = json.loads(line)
except json.JSONDecodeError:
continue
bot_name, bot_role = classify_bot(record.get("user_agent", ""))
if not bot_name:
continue
timestamp = record.get("ts", "")
date = datetime.fromisoformat(
timestamp.replace("Z", "+00:00")
).date().isoformat()
status = int(record.get("status", 0))
path = parse_path(record.get("request", ""))
key = (date, bot_name, bot_role)
totals[key]["requests"] += 1
totals[key]["ips"].add(hmac_ip(record.get("remote_addr", "")))
totals[key]["paths"][path] += 1
if 200 <= status < 400:
totals[key]["success"] += 1
else:
totals[key]["errors"] += 1
writer = csv.writer(sys.stdout)
writer.writerow([
"date",
"bot_name",
"bot_role",
"requests",
"successful_requests",
"error_requests",
"unique_ip_count",
"top_paths",
])
for (date, bot_name, bot_role), values in sorted(totals.items()):
top_paths = values["paths"].most_common(10)
writer.writerow([
date,
bot_name,
bot_role,
values["requests"],
values["success"],
values["errors"],
len(values["ips"]),
json.dumps(top_paths),
])
if name == "main": if len(sys.argv) != 2: print("Usage: python ai_log_summary.py /path/to/access.jsonl") sys.exit(1)
main(sys.argv[1])
Eseguirlo con:
bash
HMAC_SECRET='a-long-random-secret'
python ai_log_summary.py /var/log/nginx/ai-access.jsonl \
data/ai_crawler_daily.csv
Questo produce prove operative sull'attività di recupero, non sul traffico umano.
6. Accorciatori di Link Personalizzati per Riepiloghi Rivolti all'AI
Un accorciatore di link di prima parte fornisce un punto di misurazione controllato quando si pubblicano riepiloghi o materiali di origine che possono essere referenziati dai sistemi AI.
Utilizzare un dominio che si controlla, come:
text https://go.example.com/ai-docs
La destinazione dovrebbe includere parametri della campagna come:
text utm_source=perplexity utm_medium=ai_answer utm_campaign=product_documentation utm_content=pricing_summary_v3
Google raccomanda i parametri della campagna per identificare le fonti di traffico e le campagne. Documentazione URL delle campagne di Google Analytics (support.google.com)
Campi link raccomandati
text surface topic prompt_id asset_id content_version destination_path
Non inserire informazioni personali, indirizzi email o query di ricerca dell'utente nel link. Gli identificatori della campagna dovrebbero descrivere l'asset pubblicato, non identificare una persona.
Limitazioni importanti
Un short link è un dispositivo di calibrazione, non una soluzione universale:
- L'assistente può riscrivere l'URL di destinazione.
- L'assistente può citare l'URL canonico invece.
- Il link potrebbe essere visualizzato ma mai cliccato.
- Un browser o uno strumento per la privacy potrebbe rimuovere il referrer.
- Alcuni sistemi potrebbero recuperare la destinazione lato server.
- Un short link non misura gli utenti che ricordano il brand e tornano in seguito.
Non utilizzare un accorciatore per nascondere la destinazione, bypassare sistemi di sicurezza o manipolare citazioni. Utilizzare un reindirizzamento chiaro a una pagina rilevante e canonica.
Accorciatore di Prima Parte Minimale
L'esempio seguente utilizza FastAPI e SQLite. Proteggere l'endpoint di creazione dei link con autenticazione prima di usarlo in produzione.
python
shortener.py
import hashlib import hmac import os import secrets import sqlite3 from datetime import datetime, timezone from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from fastapi import FastAPI, HTTPException, Request from fastapi.responses import RedirectResponse from pydantic import BaseModel
DB_PATH = os.environ.get("SHORTENER_DB", "shortener.db") HMAC_SECRET = os.environ.get( "HMAC_SECRET", "replace-this-in-production" ).encode()
ALLOWED_HOST = "www.example.com"
app = FastAPI()
def db(): connection = sqlite3.connect(DB_PATH) connection.row_factory = sqlite3.Row return connection
def init_db(): connection = db() connection.execute(""" CREATE TABLE IF NOT EXISTS links ( slug TEXT PRIMARY KEY, destination TEXT NOT NULL, surface TEXT NOT NULL, topic TEXT, prompt_id TEXT, asset_id TEXT, created_at TEXT NOT NULL ) """) connection.execute(""" CREATE TABLE IF NOT EXISTS clicks ( id INTEGER PRIMARY KEY AUTOINCREMENT, occurred_at TEXT NOT NULL, slug TEXT NOT NULL, surface TEXT, topic TEXT, prompt_id TEXT, referrer_host TEXT, user_agent TEXT, ip_hmac TEXT ) """) connection.commit() connection.close()
def append_query(url, params): parsed = urlsplit(url) query = dict(parse_qsl(parsed.query, keep_blank_values=True)) query.update(params) return urlunsplit(( parsed.scheme, parsed.netloc, parsed.path, urlencode(query), parsed.fragment, ))
def hash_ip(ip): return hmac.new( HMAC_SECRET, (ip or "").encode(), hashlib.sha256 ).hexdigest()[:16]
class LinkRequest(BaseModel): destination_path: str surface: str topic: str | None = None prompt_id: str | None = None asset_id: str | None = None
@app.on_event("startup") def startup(): init_db()
@app.post("/links") def create_link(payload: LinkRequest): destination = f"https://{ALLOWED_HOST}{payload.destination_path}"
if not payload.destination_path.startswith("/"):
raise HTTPException(400, "destination_path must be a site-relative path")
slug = secrets.token_urlsafe(7).replace("_", "-").replace("/", "-")
target = append_query(destination, {
"utm_source": payload.surface,
"utm_medium": "ai_answer",
"utm_campaign": "citation_measurement",
"utm_content": payload.asset_id or "unspecified",
})
connection = db()
connection.execute("""
INSERT INTO links
(slug, destination, surface, topic, prompt_id, asset_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (
slug,
target,
payload.surface,
payload.topic,
payload.prompt_id,
payload.asset_id,
datetime.now(timezone.utc).isoformat(),
))
connection.commit()
connection.close()
return {"slug": slug, "url": f"https://go.example.com/{slug}"}
@app.get("/{slug}") def redirect(slug: str, request: Request): connection = db() row = connection.execute( "SELECT * FROM links WHERE slug = ?", (slug,) ).fetchone()
if not row:
connection.close()
raise HTTPException(404, "Unknown link")
referrer = request.headers.get("referer", "")
referrer_host = urlsplit(referrer).hostname or ""
connection.execute("""
INSERT INTO clicks
(occurred_at, slug, surface, topic, prompt_id,
referrer_host, user_agent, ip_hmac)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (
datetime.now(timezone.utc).isoformat(),
slug,
row["surface"],
row["topic"],
row["prompt_id"],
referrer_host,
request.headers.get("user-agent", "")[:300],
hash_ip(request.client.host if request.client else ""),
))
connection.commit()
connection.close()
return RedirectResponse(row["destination"], status_code=302)
Eseguirlo con:
bash pip install fastapi uvicorn uvicorn shortener:app --host 0.0.0.0 --port 8080
Utilizzare un reindirizzamento 302 per i link di misurazione in modo che i browser e i sistemi intermedi abbiano meno probabilità di memorizzare in cache il reindirizzamento in modo permanente.
7. Scraping Programmato dei Pannelli di Risposta AI
Lo scraping dei pannelli è utile per rispondere a domande che l'analisi di prima parte potrebbe non esporre:
- Quali fonti concorrenti sono citate?
- Quale pagina è citata per un prompt specifico?
- La risposta cambia per paese o dispositivo?
- La citazione appare nella risposta, nell'elenco delle fonti o in entrambi?
- Quanto è stabile la risposta nel tempo?
- L'assistente cita un short link o l'URL canonico?
Tuttavia, lo scraping deve essere fatto con attenzione.
Regole per una raccolta responsabile
- Preferire un'API ufficiale, un fornitore di dati di ricerca con licenza o un rapporto di prima parte.
- Esaminare i termini del fornitore prima di automatizzare le richieste.
- Rispettare le istruzioni dei robot dove applicabile.
- Non bypassare CAPTCHA, barriere di login, limiti di frequenza o protezioni anti-bot.
- Utilizzare un set di prompt piccolo e fisso.
- Eseguire a bassa frequenza.
- Salvare la risposta e il timestamp in modo che i risultati possano essere verificati.
- Interrompere quando un fornitore restituisce una sfida o una risposta di accesso negato.
Il Protocollo di Esclusione Robot è un meccanismo di preferenza per i crawler, non un sistema di sicurezza. Non autorizza l'accesso a contenuti protetti. RFC 9309: Protocollo di Esclusione Robot (rfc-editor.org)
Costruire un Manifest dei Prompt
csv prompt_id,topic,query,locale,surface,target_domain pm_001,project-management,"best project management software for remote teams",en-US,google,example.com pm_001,project-management,"best project management software for remote teams",en-US,bing,example.com pm_002,analytics,"how to measure AI referral traffic",en-US,google,example.com
Mantenere i prompt stabili. Se i prompt cambiano, i confronti delle tendenze diventano meno affidabili.
Memorizzare anche:
- paese
- lingua
- tipo di dispositivo
- versione del browser
- versione del prompt
- versione del contenuto
- ora di raccolta in UTC
Google osserva che le risposte di Panoramica AI e Modalità AI possono variare perché utilizzano sistemi diversi e possono utilizzare ricerche correlate o “query fan-out”. Documentazione funzionalità e siti web AI di Google (developers.google.com)
Script di Raccolta Playwright
Lo script seguente è un harness di ricerca generico. Registra la pagina renderizzata, lo screenshot, i link della pagina e il testo della risposta. Intenzionalmente non bypassa le sfide né tenta di aggirare i controlli di accesso.
python
scrape_panels.py
import asyncio import csv import hashlib import json import os import re from datetime import datetime, timezone from pathlib import Path from urllib.parse import quote_plus, urljoin, urlsplit
from playwright.async_api import async_playwright
DATA_DIR = Path("data/panels") DATA_DIR.mkdir(parents=True, exist_ok=True)
SURFACE_URLS = { "google": "https://www.google.com/search?q={query}&hl=en&gl=us", "bing": "https://www.bing.com/search?q={query}&setlang=en-us", }
PANEL_MARKERS = { "google": ["AI Overview", "AI Mode"], "bing": ["Copilot", "Generative search"], }
BLOCK_MARKERS = [ "captcha", "unusual traffic", "verify you are human", "access denied", ]
def clean_text(value): return re.sub(r"\s+", " ", value or "").strip()
def host_matches(url, target_domain): host = (urlsplit(url).hostname or "").lower() target = target_domain.lower().lstrip("www.") return host == target or host.endswith("." + target)
def page_hash(text): normalized = clean_text(text).encode("utf-8") return hashlib.sha256(normalized).hexdigest()
async def collect_one(page, row): surface = row["surface"] query = row["query"] target_domain = row["target_domain"]
search_url = SURFACE_URLS[surface].format(
query=quote_plus(query)
)
await page.goto(
search_url,
wait_until="domcontentloaded",
timeout=45_000,
)
await page.wait_for_timeout(2500)
text = await page.locator("body").inner_text()
lowered = text.lower()
if any(marker in lowered for marker in BLOCK_MARKERS):
return {
**row,
"observed_at": datetime.now(timezone.utc).isoformat(),
"status": "blocked_or_challenged",
"panel_present": False,
"links": [],
}
links = await page.locator("a").evaluate_all("""
anchors => anchors.map(a => ({
href: a.href || "",
text: (a.innerText || "").trim()
})).filter(x => x.href)
""")
panel_present = any(
marker.lower() in lowered
for marker in PANEL_MARKERS.get(surface, [])
)
observed_at = datetime.now(timezone.utc)
run_id = hashlib.sha256(
f"{surface}|{row['prompt_id']}|{observed_at.isoformat()}".encode()
).hexdigest()[:20]
html_path = DATA_DIR / f"{run_id}.html"
screenshot_path = DATA_DIR / f"{run_id}.png"
await page.screenshot(path=str(screenshot_path), full_page=True)
html_path.write_text(await page.content(), encoding="utf-8")
output_links = []
for position, link in enumerate(links, start=1):
output_links.append({
"href": urljoin(search_url, link["href"]),
"text": clean_text(link["text"])[:300],
"position": position,
"target_cited": host_matches(
link["href"],
target_domain
),
})
return {
**row,
"run_id": run_id,
"observed_at": observed_at.isoformat(),
"status": "ok",
"panel_present": panel_present,
"answer_hash": page_hash(text),
"answer_text": clean_text(text)[:50_000],
"html_path": str(html_path),
"screenshot_path": str(screenshot_path),
"link_scope": "page_fallback",
"links": output_links,
}
async def main(manifest_path): rows = [] with open(manifest_path, newline="", encoding="utf-8") as source: rows = list(csv.DictReader(source))
output_path = DATA_DIR / (
datetime.now(timezone.utc).strftime("%Y-%m-%d") + ".jsonl"
)
async with async_playwright() as playwright:
browser = await playwright.chromium.launch(headless=True)
context = await browser.new_context(
locale="en-US",
timezone_id="UTC",
)
page = await context.new_page()
with open(output_path, "w", encoding="utf-8") as output:
for row in rows:
try:
result = await collect_one(page, row)
except Exception as exc:
result = {
**row,
"observed_at": datetime.now(timezone.utc).isoformat(),
"status": f"error:{type(exc).__name__}",
"panel_present": False,
"links": [],
}
output.write(json.dumps(result) + "\n")
output.flush()
# Keep request volume low.
await asyncio.sleep(8)
await browser.close()
if name == "main": asyncio.run(main("prompts.csv"))
Installare ed eseguire:
bash pip install playwright playwright install chromium python scrape_panels.py
Pianificarlo con un blocco in modo che due esecuzioni non possano sovrapporsi:
cron
15 3 * * * flock -n /tmp/ai-panel.lock
/usr/bin/python3 /opt/ai-measure/scrape_panels.py
Migliorare l'accuratezza con gli adattatori di superficie
Lo script di fallback registra i link a livello di pagina. Ciò è utile per la scoperta ma potrebbe includere link di risultati di ricerca ordinari. Per l'uso in produzione:
- Utilizzare un'API di pannello di risposta con licenza quando disponibile.
- Creare un adattatore di selezione separato per ogni superficie.
- Memorizzare la versione del selettore.
- Memorizzare l'HTML grezzo e uno screenshot.
- Contrassegnare ogni record con
link_scope = "panel_selector"olink_scope = "page_fallback".
Calcolare la Quota di Citazione solo dai dati panel_selector quando si può identificare il pannello in modo affidabile. Mantenere le osservazioni di fallback in un rapporto esplorativo separato.
8. Integrazione Google Analytics e BigQuery
Google Analytics 4 può esportare dati di eventi grezzi a BigQuery. Le esportazioni giornaliere forniscono tabelle stabili come events_YYYYMMDD; le esportazioni in streaming sono utili per il monitoraggio quasi in tempo reale ma possono essere incomplete fino a quando la tabella giornaliera non è finalizzata. Esportazione BigQuery di Google Analytics (support.google.com)
Una query di sessione semplificata potrebbe assomigliare a questa:
sql WITH page_events AS ( SELECT event_date, user_pseudo_id,
(
SELECT value.int_value
FROM UNNEST(event_params)
WHERE key = 'ga_session_id'
) AS session_id,
(
SELECT value.string_value
FROM UNNEST(event_params)
WHERE key = 'page_location'
) AS page_location,
(
SELECT value.string_value
FROM UNNEST(event_params)
WHERE key = 'session_engaged'
) AS session_engaged,
event_name
FROM PROJECT.analytics_PROPERTY.events_*
WHERE _TABLE_SUFFIX BETWEEN '20260901' AND '20260921'
),
session_rollup AS ( SELECT event_date, user_pseudo_id, session_id,
MAX(
CASE
WHEN REGEXP_CONTAINS(
LOWER(page_location),
r'[?&]utm_medium=ai_answer'
)
THEN 1 ELSE 0
END
) AS ai_session,
MAX(
CASE
WHEN session_engaged = '1'
THEN 1 ELSE 0
END
) AS engaged_session,
MAX(
CASE
WHEN event_name IN ('purchase', 'generate_lead', 'sign_up')
THEN 1 ELSE 0
END
) AS key_event
FROM page_events GROUP BY event_date, user_pseudo_id, session_id )
SELECT event_date, COUNTIF(ai_session = 1) AS ai_sessions, COUNTIF(ai_session = 1 AND engaged_session = 1) AS engaged_ai_sessions, COUNTIF(ai_session = 1 AND key_event = 1) AS ai_key_events, SAFE_DIVIDE( COUNTIF(ai_session = 1 AND engaged_session = 1), COUNTIF(ai_session = 1) ) AS post_citation_engagement_rate FROM session_rollup GROUP BY event_date ORDER BY event_date;
Per un'implementazione matura, aggiungere surface, topic e prompt_id alla struttura della campagna o unirli dalla tabella dei clic dell'accorciatore.
Non creare una dimensione di analytics ad alta cardinalità per ogni singolo utente o sessione. Google avverte che dimensioni personalizzate ad alta cardinalità eccessive possono ridurre la qualità dei rapporti. Dimensioni e metriche personalizzate di Google Analytics (support.google.com)
9. Design della Dashboard
Una dashboard pratica dovrebbe contenere cinque viste.
Riepilogo Esecutivo
Visualizzare:
- Quota di Citazioni per Argomento
- Copertura delle Citazioni per Argomento
- Tasso di Riferimento dell'Assistente Confermato
- Tasso di Riferimento dell'Assistente Espanso
- Coinvolgimento Post-Citazione
- Tasso di successo del crawler AI
- Eventi chiave o entrate attribuite all'AI
Tendenza della Quota di Citazioni
Grafico a linee:
text Date × Topic × Surface
Mostrare entrambi:
- occorrenze di citazioni del dominio target
- tutte le occorrenze di citazioni osservate
Funnel di Traffico dell'Assistente
text Observed citations ↓ Controlled-link clicks ↓ AI-attributed sessions ↓ Engaged AI sessions ↓ Key events
Ogni fase dovrebbe mostrare la sua confidenza di attribuzione.
Salute del Crawler
Tabella per bot:
text bot_name requests successful_requests error_rate unique_ip_count top_paths
Avvisare quando:
- il tasso di errore supera il 2 percento
- un bot scompare improvvisamente
- una sezione di contenuto precedentemente accessibile restituisce 403 o 429
- le richieste del crawler si concentrano su un percorso privato non intenzionale
Tabella Opportunità Contenuti
Classificare pagine e argomenti per:
text high citation share + low referral rate high impressions + low citation coverage high engagement + low citation visibility high crawler errors + high business importance
Queste combinazioni producono solitamente decisioni migliori rispetto all'analisi del solo volume di traffico.
Dashboard Streamlit Minimale
Salvare come dashboard.py.
python import glob import json from pathlib import Path
import pandas as pd import streamlit as st
st.set_page_config( page_title="AI Citation Impact", layout="wide", )
@st.cache_data(ttl=300) def load_panel_rows(): rows = []
for filename in glob.glob("data/panels/*.jsonl"):
with open(filename, encoding="utf-8") as source:
for line in source:
record = json.loads(line)
run_id = record.get("run_id")
for link in record.get("links", []):
hostname = link.get("href", "").split("/")[2]
rows.append({
"run_id": run_id,
"observed_at": record.get("observed_at"),
"surface": record.get("surface"),
"topic": record.get("topic"),
"prompt_id": record.get("prompt_id"),
"panel_present": record.get("panel_present"),
"target_domain": record.get("target_domain"),
"cited_domain": hostname,
"target_cited": link.get("target_cited", False),
"position": link.get("position"),
"link_scope": record.get("link_scope"),
})
return pd.DataFrame(rows)
panels = load_panel_rows()
st.title("AI Citation Impact Dashboard")
if panels.empty: st.warning("No panel observations found.") st.stop()
panels["date"] = pd.to_datetime( panels["observed_at"], errors="coerce" ).dt.date
citation_share = ( panels.groupby("topic") .apply( lambda group: group["target_cited"].sum() / max(len(group), 1) ) .reset_index(name="citation_share") )
coverage = ( panels.groupby(["topic", "run_id"], dropna=False) .agg( panel_present=("panel_present", "max"), target_cited=("target_cited", "max"), ) .reset_index() )
coverage = ( coverage[coverage["panel_present"] == True] .groupby("topic") .apply(lambda group: group["target_cited"].mean()) .reset_index(name="citation_coverage") )
col1, col2 = st.columns(2)
with col1: st.subheader("Citation Share by Topic") st.dataframe(citation_share, use_container_width=True) st.bar_chart( citation_share.set_index("topic")["citation_share"] )
with col2: st.subheader("Citation Coverage by Topic") st.dataframe(coverage, use_container_width=True) st.bar_chart( coverage.set_index("topic")["citation_coverage"] )
st.subheader("Citation Position") st.dataframe( panels.groupby(["topic", "surface"], dropna=False) .agg( citations=("target_cited", "sum"), median_position=("position", "median"), observations=("run_id", "nunique"), ) .reset_index(), use_container_width=True, )
st.subheader("Daily Observations") daily = ( panels.groupby(["date", "surface"], dropna=False) .agg( citations=("target_cited", "sum"), observed_links=("cited_domain", "count"), ) .reset_index() )
st.line_chart( daily.pivot( index="date", columns="surface", values="citations" ).fillna(0) )
Eseguirlo con:
bash pip install streamlit pandas streamlit run dashboard.py
Per la produzione, caricare le tabelle normalizzate in DuckDB, BigQuery, Snowflake o PostgreSQL invece di leggere i file grezzi direttamente.
10. Controlli di Qualità
Un sistema di misurazione può produrre numeri apparentemente precisi ma fuorvianti. Aggiungere questi controlli.
Separare i bot dalle persone
Non contare mai OAI-SearchBot, GPTBot, ClaudeBot o PerplexityBot come sessioni umane. Questi sono segnali di crawler o di recupero.
Preservare le prove grezze
Per ogni esecuzione del pannello, conservare:
- HTML grezzo
- screenshot
- testo della risposta
- timestamp
- versione del browser e del raccoglitore
- prompt e locale
- hash della risposta
Ciò rende possibile spiegare perché una metrica è cambiata.
Tracciare l'assenza del pannello
Un pannello AI mancante non è lo stesso di una risposta senza la tua citazione.
Utilizzare:
text panel_not_present panel_present_no_target panel_present_target_cited blocked_or_challenged collection_error
Controllare il bias di campionamento
Uno scraping programmato è influenzato da:
- posizione
- dispositivo
- lingua
- tempo
- personalizzazione
- stato del browser
- formulazione del prompt
Utilizzare un set di prompt fisso e un set rotante di prompt esplorativi. Mantenere i due gruppi separati nei rapporti.
Evitare la falsa precisione
Riportare intervalli di confidenza o dimensioni minime del campione per piccoli gruppi di argomenti. Un argomento con tre osservazioni non dovrebbe essere confrontato direttamente con un argomento che contiene 300 osservazioni.
Confrontare simili con simili
Quando si confrontano le superfici, mantenere costanti:
- prompt
- locale
- paese
- dispositivo
- ora di raccolta
- regole del dominio target
- versione del parser delle citazioni
11. Piano di Implementazione Raccomandato
Settimana 1: Strumentazione
- Configurare i log del server strutturati.
- Creare il registro dei bot.
- Creare il dominio dell'accorciatore.
- Aggiungere le regole di denominazione delle campagne.
- Definire la tassonomia degli argomenti.
- Abilitare l'esportazione di Google Analytics 4 in BigQuery.
Settimana 2: Link controllati
- Pubblicare da cinque a dieci riepiloghi tracciati.
- Creare link per diversi argomenti e versioni di contenuto.
- Convalidare i log di reindirizzamento rispetto alle sessioni di analytics.
- Confermare che nessuna informazione personale venga inserita in URL o parametri di analytics.
Settimana 3: Osservazioni del pannello
- Creare un manifest dei prompt.
- Iniziare con due superfici e da 20 a 50 prompt stabili.
- Eseguire una volta al giorno.
- Salvare screenshot e HTML.
- Misurare i fallimenti di raccolta separatamente dall'assenza di citazioni.
Settimana 4: Dashboard e governance
- Costruire dashboard per argomento e superficie.
- Aggiungere avvisi sulla salute del crawler.
- Definire le regole di confidenza.
- Confrontare i rapporti di prima parte con le osservazioni del pannello.
- Documentare le politiche di conservazione, accesso ed eliminazione.
Conclusione
La misurazione delle citazioni AI dovrebbe essere costruita come un sistema di attribuzione multi-segnale, non come un singolo rapporto di riferimento.
Il framework più affidabile combina:
- Rapporti di visibilità di prima parte di Google e Bing
- log del server per l'attività di crawler e fetcher
- short link di prima parte per la misurazione controllata dei clic
- osservazioni programmate e conformi dei pannelli di risposta
- dati di analytics e conversione per il valore a valle
Le tre metriche più importanti sono:
- Quota di Citazioni per Argomento — quanto dello spazio di fonti osservato appartiene al tuo dominio.
- Tasso di Riferimento dell'Assistente — quanto traffico umano può essere attribuito alle superfici AI.
- Coinvolgimento Post-Citazione — se i visitatori riferiti dall'AI intraprendono azioni significative.
Il sistema diventa utile quando ogni numero include la sua superficie, periodo di tempo, denominatore, metodo di attribuzione e livello di confidenza. Questa disciplina impedisce che i hit del crawler vengano scambiati per utenti, le risposte scansionate per impressioni e le citazioni per risultati commerciali.
Auto