Måling af AI-citatpåvirkning: Analyse, Logs og Panel-scraping
Introduktion
AI-drevet opdagelse er nu fordelt på flere målingslag. En person kan se en virksomhed citeret i et AI-svar, klikke på et link uden en brugbar henviser, besøge via en browser, der fjerner kampagnedata, eller læse et svar uden overhovedet at klikke. I mellemtiden kan en AI-crawler anmode om virksomhedens side uden at repræsentere en menneskelig besøgende.
Et nyttigt målingssystem skal derfor adskille fire spørgsmål:
- Blev webstedet crawlet eller hentet?
- Blev webstedet citeret i et AI-svar?
- Besøgte en person webstedet fra det svar?
- Fremkaldte dette besøg meningsfuldt engagement eller omsætning?
Fra den 22. september 2026 forbedres førstepartsrapporteringen. Google Search Console leverer en Generativ AI-ydeevnerapport for AI-oversigter og AI-tilstand, inklusive visninger, sider, lande, enheder og datoer. Rapporten blev tilgængelig på verdensplan den 31. august 2026. Googles Generative AI-ydeevnerapport (support.google.com)
Microsoft tilbyder også en AI Performance-rapport i Bing Webmaster Tools. Dens offentlige forhåndsvisning rapporterer samlede citater, citerede sider, jordforbindende forespørgsler og citat-trends på tværs af understøttede Microsoft AI-oplevelser. Bing Webmaster Tools AI-ydeevne (blogs.bing.com)
Disse værktøjer bør behandles som autoritative for deres egne overflader, men de skaber ikke et komplet tværassistent-målingssystem. Den anbefalede tilgang er at kombinere førstepartsrapporter, serverlogs, kontrollerede sporingslinks, analyseeksport og planlagte observationer af svarpaneler.
1. Hvorfor måling af AI-henvisninger er svært
Traditionel webanalyse afhænger stærkt af Referer-headeren og kampagneparametre. Begge kan mangle eller være ufuldstændige.
HTTP Referer-headeren kan indeholde den tidligere side, kun den tidligere webstedsoprindelse eller slet ingen værdi. Browserhenvisningspolitikker kan bevidst begrænse eller fjerne disse oplysninger. MDN Referrer-Policy dokumentation (developer.mozilla.org)
AI-systemer skaber yderligere komplikationer:
- En assistent kan linke til en side uden at sende en genkendelig henviser.
- En assistent kan bruge en viderestillings- eller omskrevet URL.
- En assistent kan hente indhold server-side, før et svar vises.
- En AI-crawler kan besøge en side uden at et menneske ser den.
- Svaret kan citere en kanonisk URL i stedet for den URL, der oprindeligt blev hentet.
- Svaret kan ændre sig afhængigt af placering, enhed, sprog, tid eller personalisering.
For eksempel skelner OpenAI mellem OAI-SearchBot til søgeopdagelse, GPTBot til mulig træningsbrug og ChatGPT-User til brugerinitierede handlinger. Perplexity identificerer særskilt PerplexityBot og Perplexity-User. Anthropic identificerer ClaudeBot, Claude-SearchBot og Claude-User. Disse anmodninger bør ikke kombineres til ét generisk "AI-trafik"-nummer. OpenAI crawler-dokumentation (openai.com) Perplexity crawler-dokumentation (docs.perplexity.ai) Anthropic crawler-dokumentation (support.anthropic.com)
Den centrale regel er:
Crawler-aktivitet er ikke henvisningstrafik, synlighed af citater er ikke trafik, og trafik er ikke forretningsmæssig indvirkning.
2. Målingsrammen
Brug fem målingslag.
| Lag | Hovedspørgsmål | Anbefalede data |
|---|---|---|
| Adgang | Anmodede et AI-system om siden? | Webserverlogs, firewall-logs |
| Synlighed | Blev siden vist eller citeret? | Google Search Console, Bing Webmaster Tools, observationer af svarpaneler |
| Anskaffelse | Kom en person fra en AI-overflade? | Henviser, kampagneparametre, klik på korte links, analyse |
| Engagement | Interagerede den besøgende meningsfuldt? | Analysehændelser, sidevisninger, engagementstid |
| Værdi | Fremkaldte besøget et ønsket resultat? | Tilmeldinger, leads, køb, abonnementer |
Anbefalet hierarki for kildesandhed
Brug det stærkeste tilgængelige signal for hvert spørgsmål:
- Førsteparts platformrapporter for visninger og citater.
- Observationer af svarpaneler for synlighed på prompt-niveau og konkurrenceanalyser.
- Viderestillingslogs for kontrollerede linkklik.
- Webanalyse for sessioner, engagement og konverteringer.
- Serverlogs for crawler- og hentningsaktivitet.
- Modellerede estimater kun når direkte bevis ikke er tilgængeligt.
Kombiner ikke alle signaler til et enkelt tal uden at bevare kilden og konfidensniveauet.
3. Kernedatamodellen
Opret et datawarenhouse med separate tabeller for rå observationer og afledte metrics.
panel_runs
Én række for hver prompt- og overfladekontrol.
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
Én række for hvert link observeret i et svarpanel.
text run_id surface topic prompt_id cited_url cited_domain citation_position link_scope target_domain target_cited
ai_crawler_requests
Aggregerede serverlogdata.
text date bot_name bot_role path requests successful_requests error_requests unique_ip_count
shortener_clicks
Én række for hver anmodning om et kontrolleret link.
text click_id occurred_at slug surface topic prompt_id asset_id referrer_host user_agent_family ip_hmac destination_path
ai_sessions
En normaliseret analysetabel.
text session_id date surface topic prompt_id landing_page attribution_method confidence engaged engagement_time_seconds key_event revenue
confidence-feltet er vigtigt. Anbefalede værdier er:
- A — Direkte: kontrolleret short-link-klik eller betroet kampagneparameter.
- B — Stærk: kendt assistenthenviser eller verificeret brugerhentningsanmodning.
- C — Sandsynlig: kampagne- eller landingssidesbevis uden en komplet henviser.
- D — Modelleret: udledt fra timing, panelobservationer eller aggregerede ændringer.
Rapporter A og B separat fra C og D.
4. Metrikdefinitioner
Citatandel efter emne
Citatandel efter emne måler, hvor ofte et måldomæne optræder blandt de kilder, der er observeret for et emne.
\[ \text{Citatandel}_{emne}
\frac{\text{Forekomster af citater fra måldomænet}} {\text{Alle forekomster af citater}} \]
Eksempel:
- 100 samlede citater observeret for "projektstyringssoftware"
- 12 citater peger på dit domæne
- Citatandel efter emne = 12%
Denne metrik måler kildeandel, ikke om et svar indeholdt dit websted mindst én gang.
Citatdækning
Brug en anden metrik for tilstedeværelse på svar-niveau:
\[ \text{Citatdækning}_{emne}
\frac{\text{Svarafviklinger, der indeholder et målcitat}} {\text{Svarafviklinger, hvor et panel var til stede}} \]
Citatdækning er ofte lettere for ikke-tekniske målgrupper at forstå:
“Vores websted optrådte i 34% af observerede AI-svar for dette emne.”
Citatposition
Registrer positionen af et citat inden for svaret:
1= første observerede kilde2= anden observerede kilde3= tredje observerede kilde
Da AI-grænseflader ofte grupperer eller ombestiller kilder, skal position behandles som en beskrivende observation, ikke en universel rangordning.
Assistenthenvisningsrate
Assistenthenvisningsrate måler den andel af menneskelige webstedssessioner, der kan tilskrives en AI-overflade.
\[ \text{Assistenthenvisningsrate}
\frac{\text{AI-tilskrevne menneskelige sessioner}} {\text{Alle menneskelige sessioner}} \]
Rapporter mindst to versioner:
text Bekræftet Assistenthenvisningsrate = AI-sessioner med A-konfidens / alle menneskelige sessioner
Udvidet Assistenthenvisningsrate = AI-sessioner med A + B + C-konfidens / alle menneskelige sessioner
Den bekræftede version er mere konservativ. Den udvidede version er nyttig til trendanalyse, men bør ikke præsenteres som fuldt observeret trafik.
Brug ikke panelkørsler som visninger
Hvis du skraber 500 prompts, betyder det ikke, at 500 mennesker så disse svar. Din scraper måler en kontrolleret prøve af svar-tilstande. Brug førsteparts platformvisningsrapporter, når de er tilgængelige.
Post-citat engagement
Post-citat engagement måler, om personer, der ankom via et AI-citat, foretog en meningsfuld handling.
\[ \text{Post-citat Engagement}
\frac{\text{Engagerede AI-sessioner}} {\text{AI-tilskrevne sessioner}} \]
For Google Analytics 4 varer en engageret session mere end 10 sekunder, inkluderer en nøglebegivenhed eller inkluderer mindst to side- eller skærmvisninger. Google Analytics engagementrate-dokumentation (support.google.com)
Rapporter også:
text Post-citat Engagementstid = median engagerede sekunder pr. AI-session
Post-citat Konverteringsrate = AI-sessioner med en nøglebegivenhed / AI-tilskrevne sessioner
Post-citat Omsætning pr. session = omsætning fra AI-sessioner / AI-tilskrevne sessioner
Median engagementstid er normalt mere stabil end gennemsnitlig engagementstid, fordi et lille antal meget lange sessioner kan fordreje gennemsnittet.
Yderligere nyttige metrics
AI Crawler succesrate
\[ \text{Crawler Succesrate}
\frac{\text{AI crawler-anmodninger, der returnerer 2xx eller 3xx}} {\text{AI crawler-anmodninger}} \]
Opdel dette efter bot og sti. Et stort fald i citater kombineret med en stigende crawler-fejlrate kan indikere et problem med hosting, firewall eller robots-konfiguration.
Citat-til-klik rate
For kontrollerede svarpanelobservationer:
\[ \text{Observeret Citat-til-klik Rate}
\frac{\text{Bekræftede short-link-klik}} {\text{Observerede målcitationer}} \]
Dette er ikke en ægte bruger-klikrate, medmindre nævneren repræsenterer faktiske brugerindtryk. Angiv det tydeligt som en observeret operationel metrik.
Citatfriskhed
Mål hvor ofte den citerede URL afspejler den aktuelle sideversion.
text Citatfriskhed = citerede sider med aktuelt indholds-hash / citerede sider
Dette er nyttigt for dokumentations-, pris-, produkt- og politik-sider, der ofte ændres.
5. Serverloganalyse
Konfigurer strukturerede adgangslogs
Strukturerede logs er lettere at analysere end traditionelle mellemrumsseparerede logs.
Eksempel Nginx-konfiguration:
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;
Gem logfilerne på en begrænset placering. Send ikke rå IP-adresser til en generel analysetabel, medmindre du har en klar operationel grund.
Identificer AI-anmodningstyper
Adskil som minimum:
text OpenAI:
- OAI-SearchBot
- ChatGPT-User
- GPTBot
Anthropic:
- Claude-SearchBot
- Claude-User
- ClaudeBot
Perplexity:
- PerplexityBot
- Perplexity-User
Søgemaskiner:
- Googlebot
- Bingbot
Googles AI Search-funktioner bruger generelt eksisterende Google Search crawling-kontroller snarere end en separat universel "AI Overview bot." Google anfører, at Googlebot kontrollerer adgang til Søgning, inklusive AI-funktioner. Googles AI-funktioner og websteder (developers.google.com)
User-agent strenge kan forfalskes. Til operationelle beslutninger skal du kombinere user-agent matchning med udbyder-udgivne IP-områder eller verificeret reverse DNS, hvor det er tilgængeligt. OpenAI og Perplexity udgiver IP-information for deres relevante crawlere og fetchere. (openai.com)
Reproducerbar log-sammenfatter
Gem følgende som 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])
Kør den med:
bash HMAC_SECRET='a-long-random-secret' \ python ai_log_summary.py /var/log/nginx/ai-access.jsonl \
data/ai_crawler_daily.csv
Dette producerer operationelt bevis om hentningsaktivitet, ikke menneskelig trafik.
6. Brugerdefinerede link-shorteners til AI-vendte oversigter
En førsteparts link-shortener giver et kontrolleret målepunkt, når du udgiver oversigter eller kildemateriale, som kan refereres af AI-systemer.
Brug et domæne, du kontrollerer, såsom:
text https://go.example.com/ai-docs
Destinationen bør inkludere kampagneparametre såsom:
text utm_source=perplexity utm_medium=ai_answer utm_campaign=product_documentation utm_content=pricing_summary_v3
Google anbefaler kampagneparametre til identifikation af trafikkilder og kampagner. Google Analytics kampagne-URL-dokumentation (support.google.com)
Anbefalede linkfelter
text surface topic prompt_id asset_id content_version destination_path
Indsæt ikke personlige oplysninger, e-mailadresser eller bruger-søgeforespørgsler i linket. Kampagneidentifikatorer skal beskrive det udgivne aktiv, ikke identificere en person.
Vigtige begrænsninger
Et kort link er en kalibreringsanordning, ikke en universel løsning:
- Assistenten kan omskrive destinations-URL'en.
- Assistenten kan i stedet citere den kanoniske URL.
- Linket kan vises, men aldrig klikkes.
- En browser eller et privatlivsværktøj kan fjerne henviseren.
- Nogle systemer kan hente destinationen server-side.
- Et kort link måler ikke brugere, der husker mærket og vender tilbage senere.
Brug ikke en shortener til at skjule destinationen, omgå sikkerhedssystemer eller manipulere citater. Brug en klar viderestilling til en relevant, kanonisk side.
Minimal førsteparts Shortener
Følgende eksempel bruger FastAPI og SQLite. Beskyt linkoprettelsesendepunktet med autentifikation, før det bruges i produktion.
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)
Kør den med:
bash pip install fastapi uvicorn uvicorn shortener:app --host 0.0.0.0 --port 8080
Brug en 302 viderestilling for målingslinks, så browsere og mellemliggende systemer er mindre tilbøjelige til at cache viderestillingen permanent.
7. Planlagt scraping af AI-svarpaneler
Panel-scraping er nyttigt til at besvare spørgsmål, som førstepartsanalyse muligvis ikke afslører:
- Hvilke konkurrerende kilder citeres?
- Hvilken side citeres for en specifik prompt?
- Ændrer svaret sig efter land eller enhed?
- Fremgår citatet i svaret, kildelisten eller begge dele?
- Hvor stabilt er svaret over tid?
- Citerer assistenten et kort link eller den kanoniske URL?
Scraping skal dog gøres omhyggeligt.
Regler for ansvarlig indsamling
- Foretræk en officiel API, licenseret søgedataudbyder eller førstepartsrapport.
- Gennemgå udbyderens vilkår, før du automatiserer anmodninger.
- Respekter robots-instruktioner, hvor det er relevant.
- Omgå ikke CAPTCHAs, login-barrierer, rate-limits eller bot-beskyttelse.
- Brug et lille, fast prompt-sæt.
- Kør med lav frekvens.
- Gem svaret og tidsstemplet, så resultater kan revideres.
- Stop, når en udbyder returnerer en udfordring eller adgang nægtet-svar.
Robots Exclusion Protocol er en crawler-præferencemekanisme, ikke et sikkerhedssystem. Den autoriserer ikke adgang til beskyttet indhold. RFC 9309: Robots Exclusion Protocol (rfc-editor.org)
Byg et Prompt Manifest
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
Hold prompts stabile. Hvis prompts ændres, bliver trendsammenligninger mindre pålidelige.
Gem også:
- land
- sprog
- enhedstype
- browserversion
- promptversion
- indholdsversion
- indsamlingstid i UTC
Google bemærker, at AI Overview og AI Mode svar kan variere, fordi de bruger forskellige systemer og kan bruge relaterede søgninger eller "query fan-out". Google AI-funktioner og webstedsdokumentation (developers.google.com)
Playwright indsamlingsskript
Følgende script er en generel forskningsramme. Det registrerer den renderede side, skærmbillede, sidetracks og svartekst. Det omgår bevidst ikke udfordringer eller forsøger at omgå adgangskontroller.
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) + "\
") output.flush()
# Keep request volume low.
await asyncio.sleep(8)
await browser.close()
if name == "main": asyncio.run(main("prompts.csv"))
Installer og kør:
bash pip install playwright playwright install chromium python scrape_panels.py
Planlæg det med en lås, så to kørsler ikke kan overlappe:
cron 15 3 * * * flock -n /tmp/ai-panel.lock \ /usr/bin/python3 /opt/ai-measure/scrape_panels.py
Forbedr nøjagtigheden med overflade-adaptere
Fallback-scriptet registrerer links på sideniveau. Dette er nyttigt til opdagelse, men kan inkludere almindelige søgeresultatlinks. Til produktionsbrug:
- Brug en licenseret svarpanel-API, når den er tilgængelig.
- Opret en separat selector-adapter for hver overflade.
- Gem selector-versionen.
- Gem rå HTML og et skærmbillede.
- Mærk hver post med
link_scope = "panel_selector"ellerlink_scope = "page_fallback".
Beregn kun Citatandel ud fra panel_selector-data, når du pålideligt kan identificere panelet. Hold fallback-observationer i en separat udforskende rapport.
8. Google Analytics og BigQuery-integration
Google Analytics 4 kan eksportere rå hændelsesdata til BigQuery. Daglige eksport giver stabile tabeller som events_ÅÅÅÅMMDD; streaming-eksport er nyttigt til næsten-realtidsovervågning, men kan være ufuldstændige, indtil den daglige tabel er færdiggjort. Google Analytics BigQuery Export (support.google.com)
En forenklet sessionsforespørgsel kunne se sådan ud:
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;
For en moden implementering skal du tilføje surface, topic og prompt_id til kampagnestrukturen eller joine dem fra shortener-kliktabellen.
Opret ikke en analytics-dimension med høj kardinalitet for hver enkelt bruger eller session. Google advarer om, at overdreven brug af brugerdefinerede dimensioner med høj kardinalitet kan reducere rapportkvaliteten. Google Analytics brugerdefinerede dimensioner og metrics (support.google.com)
9. Dashboard-design
Et praktisk dashboard bør indeholde fem visninger.
Oversigt for ledelsen
Vis:
- Citatandel efter emne
- Citatdækning efter emne
- Bekræftet assistenthenvisningsrate
- Udvidet assistenthenvisningsrate
- Post-citat engagement
- AI crawler succesrate
- AI-tilskrevne nøglehændelser eller omsætning
Trend for citatandel
Linjediagram:
text Dato × Emne × Overflade
Vis begge dele:
- forekomster af citater fra måldomænet
- alle observerede forekomster af citater
Assistenttrafiktragt
text Observerede citater ↓ Kontrollerede linkklik ↓ AI-tilskrevne sessioner ↓ Engagerede AI-sessioner ↓ Nøglehændelser
Hvert trin skal vise sin attributionkonfidens.
Crawler-sundhed
Tabel efter bot:
text bot_name requests successful_requests error_rate unique_ip_count top_paths
Advarsel når:
- fejlrate overstiger 2 procent
- en bot pludselig forsvinder
- en tidligere tilgængelig indholdssektion returnerer 403 eller 429
- crawler-anmodninger koncentrerer sig om en utilsigtet privat sti
Tabel over indholdsmuligheder
Rangordn sider og emner efter:
text høj citatandel + lav henvisningsrate høje visninger + lav citatdækning højt engagement + lav citatsynlighed høje crawler-fejl + høj forretningsmæssig betydning
Disse kombinationer producerer normalt bedre beslutninger end kun at se på trafikvolumen.
Minimalt Streamlit Dashboard
Gem som 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) )
Kør den med:
bash pip install streamlit pandas streamlit run dashboard.py
Til produktion skal du indlæse normaliserede tabeller i DuckDB, BigQuery, Snowflake eller PostgreSQL i stedet for at læse råfiler direkte.
10. Kvalitetskontrol
Et målingssystem kan producere præcise, men misvisende tal. Tilføj disse kontroller.
Adskil bots fra mennesker
Tæl aldrig OAI-SearchBot, GPTBot, ClaudeBot eller PerplexityBot som menneskelige sessioner. Disse er crawler- eller hentningssignaler.
Bevar rå beviser
For hver panelafvikling skal du bevare:
- rå HTML
- skærmbillede
- svartekst
- tidsstempel
- browser- og collector-version
- prompt og lokalisering
- svar-hash
Dette gør det muligt at forklare, hvorfor en metrik ændrede sig.
Spor fravær af paneler
Et manglende AI-panel er ikke det samme som et svar uden dit citat.
Brug:
text panel_not_present panel_present_no_target panel_present_target_cited blocked_or_challenged collection_error
Kontroller sampling-bias
En planlagt scraping påvirkes af:
- placering
- enhed
- sprog
- tid
- personalisering
- browser-tilstand
- prompt-formulering
Brug et fast kernesæt af prompts og et roterende sæt af udforskende prompts. Hold de to grupper adskilt i rapporter.
Undgå falsk præcision
Rapporter konfidensintervaller eller minimumsstørrelser for stikprøver for små emnegrupper. Et emne med tre observationer bør ikke sammenlignes direkte med et emne, der indeholder 300 observationer.
Sammenlign ens med ens
Ved sammenligning af overflader skal du holde følgende konstant:
- prompt
- lokalisering
- land
- enhed
- indsamlingstime
- måldomæne-regler
- citatparser-version
11. Anbefalet udrulningsplan
Uge 1: Instrumentering
- Konfigurer strukturerede serverlogs.
- Opret bot-registret.
- Opret shortener-domænet.
- Tilføj regler for kampagnenavngivning.
- Definer emnetaksonomien.
- Aktiver Google Analytics 4 BigQuery-eksport.
Uge 2: Kontrollerede links
- Udgiv fem til ti sporede oversigter.
- Opret links til forskellige emner og indholdsversioner.
- Valider viderestillingslogs mod analysesessioner.
- Bekræft, at ingen personlige oplysninger indgår i URL'er eller analyseparametre.
Uge 3: Panelobservationer
- Opret et prompt-manifest.
- Start med to overflader og 20 til 50 stabile prompts.
- Kør en gang om dagen.
- Gem skærmbilleder og HTML.
- Mål indsamlingsfejl separat fra fravær af citater.
Uge 4: Dashboard og styring
- Byg dashboards for emner og overflader.
- Tilføj crawler-sundhedsadvarsler.
- Definer konfidensregler.
- Sammenlign førstepartsrapporter med panelobservationer.
- Dokumenter opbevarings-, adgangs- og slettepolitikker.
Konklusion
AI-citatmåling bør opbygges som et multi-signal attributionssystem, ikke som en enkelt henvisningsrapport.
Den mest pålidelige ramme kombinerer:
- Google og Bing førsteparts synlighedsrapporter
- serverlogs for crawler- og hentningsaktivitet
- førsteparts short links til kontrolleret klikmåling
- planlagte, kompatible observationer af svarpaneler
- analyse- og konverteringsdata for downstream-værdi
De tre vigtigste metrics er:
- Citatandel efter emne — hvor stor en del af det observerede kildeområde der tilhører dit domæne.
- Assistenthenvisningsrate — hvor meget menneskelig trafik der kan tilskrives AI-overflader.
- Post-citat engagement — om AI-henviste besøgende foretager meningsfulde handlinger.
Systemet bliver nyttigt, når hvert tal inkluderer dets overflade, tidsperiode, nævner, attributionmetode og konfidensniveau. Den disciplin forhindrer crawler-hits i at blive forvekslet med brugere, skrabede svar i at blive forvekslet med visninger og citater i at blive forvekslet med forretningsresultater.
Auto