Måling av AI-sitatpåvirkning: Analyse, logger og panelskraping
Introduksjon
AI-drevet oppdagelse er nå delt inn i flere målingslag. En person kan se et selskap sitert i et AI-svar, klikke på en lenke uten nyttig henvisning, besøke via en nettleser som fjerner kampanjedata, eller lese et svar uten å klikke i det hele tatt. I mellomtiden kan en AI-crawler be om selskapets side uten å representere en menneskelig besøkende.
Et nyttig målesystem må derfor skille fire spørsmål:
- Ble nettstedet crawlet eller hentet?
- Ble nettstedet sitert i et AI-svar?
- Besøkte en person nettstedet fra det svaret?
- Førte det besøket til meningsfullt engasjement eller inntekt?
Per 22. september 2026 forbedres førstepartsrapporteringen. Google Search Console tilbyr en ytelsesrapport for generativ AI for AI-oversikter og AI-modus, inkludert visninger, sider, land, enheter og datoer. Rapporten ble tilgjengelig over hele verden den 31. august 2026. Googles ytelsesrapport for generativ AI (support.google.com)
Microsoft tilbyr også en AI-ytelsesrapport i Bing Webmaster Tools. Dets offentlige forhåndsvisning rapporterer totale sitater, siterte sider, grunnleggende søk og sitattrender på tvers av støttede Microsoft AI-opplevelser. Bing Webmaster Tools AI-ytelse (blogs.bing.com)
Disse verktøyene bør behandles som autoritative for sine egne overflater, men de skaper ikke et komplett målesystem på tvers av assistenter. Den anbefalte tilnærmingen er å kombinere førstepartsrapporter, serverlogger, kontrollerte sporingslenker, eksport fra analyseverktøy og planlagte observasjoner av svarpaneler.
1. Hvorfor måling av AI-henvisninger er vanskelig
Tradisjonell webanalyse avhenger i stor grad av Referer-headeren og kampanjeparametere. Begge kan mangle eller være ufullstendige.
HTTP Referer-headeren kan inneholde den forrige siden, bare opprinnelsen til forrige nettsted, eller ingen verdi i det hele tatt. Nettleserens henvisningspolicyer kan bevisst begrense eller fjerne denne informasjonen. MDN Referrer-Policy dokumentasjon (developer.mozilla.org)
AI-systemer skaper ytterligere komplikasjoner:
- En assistent kan lenke til en side uten å sende en gjenkjennelig henvisning.
- En assistent kan bruke en videresendende eller omskrevet URL.
- En assistent kan hente innhold servertjenestebasert før den viser et svar.
- En AI-crawler kan besøke en side uten at noe menneske ser den.
- Svaret kan sitere en kanonisk URL i stedet for URL-en som opprinnelig ble hentet.
- Svaret kan endre seg avhengig av plassering, enhet, språk, tid eller personalisering.
For eksempel skiller OpenAI mellom OAI-SearchBot for søkefunn, GPTBot for mulig trening, og ChatGPT-User for brukerinitierte handlinger. Perplexity identifiserer separat PerplexityBot og Perplexity-User. Anthropic identifiserer ClaudeBot, Claude-SearchBot, og Claude-User. Disse forespørslene bør ikke kombineres til ett generisk «AI-trafikk»-tall. OpenAI crawler-dokumentasjon (openai.com) Perplexity crawler-dokumentasjon (docs.perplexity.ai) Anthropic crawler-dokumentasjon (support.anthropic.com)
Den sentrale regelen er:
Crawleraktivitet er ikke henvisningstrafikk, siteringssynlighet er ikke trafikk, og trafikk er ikke forretningspåvirkning.
2. Målerammen
Bruk fem målingslag.
| Lag | Hovedspørsmål | Anbefalt data |
|---|---|---|
| Tilgang | Ba et AI-system om siden? | Webserverlogger, brannmurlogger |
| Synlighet | Ble siden vist eller sitert? | Google Search Console, Bing Webmaster Tools, observasjoner av svarpaneler |
| Oppkjøp | Kom en person fra en AI-overflate? | Henvisning, kampanjeparametere, klikk på korte lenker, analyser |
| Engasjement | Interagerte den besøkende meningsfullt? | Analysehendelser, sidevisninger, engasjementstid |
| Verdi | Ga besøket et ønsket resultat? | Registreringer, leads, kjøp, abonnementer |
Anbefalt hierarki for kildedata
Bruk det sterkeste tilgjengelige signalet for hvert spørsmål:
- Førsteparts plattformrapporter for visninger og sitater.
- Observasjoner av svarpaneler for synlighet på ledetekstnivå og konkurransesammenligninger.
- Videresendingslogger for kontrollerte lenkeklikk.
- Webanalyse for økter, engasjement og konverteringer.
- Serverlogger for crawler- og henteraktivitet.
- Modellbaserte estimater kun når direkte bevis er utilgjengelige.
Ikke kombiner alle signaler til ett enkelt tall uten å bevare kilden og konfidensnivået.
3. Kjernedatamodell
Opprett et datalager med separate tabeller for råobservasjoner og avledede beregninger.
panel_runs
Én rad for hver ledetekst og overflatesjekk.
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 rad for hver lenke observert 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
Aggregert serverloggdata.
text date bot_name bot_role path requests successful_requests error_requests unique_ip_count
shortener_clicks
Én rad for hver kontrollert lenkeforespørsel.
text click_id occurred_at slug surface topic prompt_id asset_id referrer_host user_agent_family ip_hmac destination_path
ai_sessions
En normalisert analysetabell.
text session_id date surface topic prompt_id landing_page attribution_method confidence engaged engagement_time_seconds key_event revenue
Feltet confidence er viktig. Anbefalte verdier er:
- A — Direkte: kontrollert kortlenkeklikk eller klarert kampanjeparameter.
- B — Sterk: kjent assistenthenvisning eller bekreftet bruker-hentingsforespørsel.
- C — Sannsynlig: kampanje- eller landingssidebevis uten en fullstendig henvisning.
- D — Modellert: utledet fra timing, panelobservasjoner eller aggregerte endringer.
Rapporter A og B separat fra C og D.
4. Metrikkdefinisjoner
Sitatandel per emne
Sitatandel per emne måler hvor ofte et måldomene vises blant kildene observert for et emne.
[ \text{Sitatandel}_{emne}
\frac{\text{Forekomster av sitater fra måldomenet}} {\text{Alle observerte sitatforekomster}} ]
Eksempel:
- 100 totale sitater observert for «prosjektstyringsprogramvare»
- 12 sitater peker til ditt domene
- Sitatandel per emne = 12%
Denne beregningen måler kildeandel, ikke om et svar inneholdt nettstedet ditt minst én gang.
Sitatdekning
Bruk en annen beregning for tilstedeværelse på svarnivå:
[ \text{Sitatdekning}_{emne}
\frac{\text{Svar som inneholder et målsitat}} {\text{Svar der et panel var til stede}} ]
Sitatdekning er ofte lettere for ikke-tekniske målgrupper å forstå:
«Nettstedet vårt dukket opp i 34 % av observerte AI-svar for dette emnet.»
Sitatposisjon
Registrer posisjonen til et sitat i svaret:
1= første observerte kilde2= andre observerte kilde3= tredje observerte kilde
Fordi AI-grensesnitt ofte grupperer eller omorganiserer kilder, behandle posisjon som en beskrivende observasjon, ikke en universell rangering.
Assistenthenvisningsrate
Assistenthenvisningsrate måler andelen menneskelige nettstedsøkter som kan tilskrives en AI-overflate.
[ \text{Assistenthenvisningsrate}
\frac{\text{AI-tilskrevne menneskelige økter}} {\text{Alle menneskelige økter}} ]
Rapporter minst to versjoner:
text Bekreftet Assistenthenvisningsrate = AI-økter med A-konfidens / alle menneskelige økter
Utvidet Assistenthenvisningsrate = AI-økter med A + B + C-konfidens / alle menneskelige økter
Den bekreftede versjonen er mer konservativ. Den utvidede versjonen er nyttig for trendanalyse, men bør ikke presenteres som fullt observert trafikk.
Ikke bruk panelkjøringer som visninger
Hvis du skraper 500 ledetekster, betyr det ikke at 500 personer så disse svarene. Skraperen din måler et kontrollert utvalg av svarstater. Bruk førsteparts plattformvisningsrapporter når de er tilgjengelige.
Engasjement etter sitering
Engasjement etter sitering måler om personer som kom via en AI-sitering, utførte en meningsfull handling.
[ \text{Engasjement etter sitering}
\frac{\text{Engasjerte AI-økter}} {\text{AI-tilskrevne økter}} ]
For Google Analytics 4 varer en engasjert økt mer enn 10 sekunder, inkluderer en nøkkelhendelse eller inkluderer minst to side- eller skjermvisninger. Google Analytics dokumentasjon for engasjementsrate (support.google.com)
Rapporter også:
text Engasjementstid etter sitering = median engasjerte sekunder per AI-økt
Konverteringsrate etter sitering = AI-økter med en nøkkelhendelse / AI-tilskrevne økter
Inntekt per økt etter sitering = inntekt fra AI-økter / AI-tilskrevne økter
Median engasjementstid er vanligvis mer stabil enn gjennomsnittlig engasjementstid fordi et lite antall svært lange økter kan forvrenge gjennomsnittet.
Ytterligere nyttige beregninger
AI Crawler suksessrate
[ \text{Crawler suksessrate}
\frac{\text{AI crawler-forespørsler som returnerer 2xx eller 3xx}} {\text{AI crawler-forespørsler}} ]
Bryt dette ned etter bot og bane. En høy sitatnedgang kombinert med en økende crawler-feilrate kan indikere et problem med hosting, brannmur eller robots-konfigurasjon.
Sitat-til-klikk-rate
For kontrollerte observasjoner av svarpaneler:
[ \text{Observert sitat-til-klikk-rate}
\frac{\text{Bekreftede klikk på korte lenker}} {\text{Observerte målsitater}} ]
Dette er ikke en sann brukergjennomklikkingsrate med mindre nevneren representerer faktiske brukerimpressions. Merk det tydelig som en observert operasjonell beregning.
Sitatfriskhet
Mål hvor ofte den siterte URL-en gjenspeiler den nåværende sideversjonen.
text Sitatfriskhet = siterte sider med nåværende innholdshash / siterte sider
Dette er nyttig for dokumentasjon, priser, produkt- og policy-sider som endres ofte.
5. Serverlogganalyse
Konfigurer strukturerte tilgangslogger
Strukturerte logger er enklere å analysere enn tradisjonelle mellomromsseparerte logger.
Eksempel Nginx-konfigurasjon:
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;
Lagre loggfilene på et begrenset sted. Ikke send rå IP-adresser til en generell analysetabell med mindre du har en klar operasjonell grunn.
Identifiser AI-forespørselstyper
Skill som et minimum:
text OpenAI:
- OAI-SearchBot
- ChatGPT-User
- GPTBot
Anthropic:
- Claude-SearchBot
- Claude-User
- ClaudeBot
Perplexity:
- PerplexityBot
- Perplexity-User
Søkemotorer:
- Googlebot
- Bingbot
Googles AI-søkefunksjoner bruker generelt eksisterende Google Søk-crawling-kontroller i stedet for en egen universell «AI Overview bot». Google opplyser at Googlebot kontrollerer tilgangen for Søk, inkludert AI-funksjoner. Google AI-funksjoner og nettsteder (developers.google.com)
User-agent-strenger kan forfalskes. For operasjonelle beslutninger, kombiner user-agent-matching med leverandørpubliserte IP-områder eller verifisert omvendt DNS der det er tilgjengelig. OpenAI og Perplexity publiserer IP-informasjon for sine relevante crawlere og fetchere. (openai.com)
Reproduserbar loggoppsummering
Lagre 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])
Kjø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 produserer operasjonelle bevis om hentingsaktivitet, ikke menneskelig trafikk.
6. Egendefinerte lenkeforkortere for AI-vendte sammendrag
En førsteparts lenkeforkorter gir et kontrollert målepunkt når du publiserer sammendrag eller kildemateriale som kan refereres av AI-systemer.
Bruk et domene du kontrollerer, for eksempel:
text https://go.example.com/ai-docs
Destinasjonen bør inkludere kampanjeparametere som:
text utm_source=perplexity utm_medium=ai_answer utm_campaign=product_documentation utm_content=pricing_summary_v3
Google anbefaler kampanjeparametere for å identifisere trafikkilder og kampanjer. Google Analytics dokumentasjon for kampanje-URL (support.google.com)
Anbefalte lenkefelt
text surface topic prompt_id asset_id content_version destination_path
Ikke legg personlig informasjon, e-postadresser eller brukersøk til lenken. Kampanjeidentifikatorer skal beskrive det publiserte elementet, ikke identifisere en person.
Viktige begrensninger
En kort lenke er en kalibreringsenhet, ikke en universell løsning:
- Assistenten kan omskrive destinasjons-URL-en.
- Assistenten kan sitere den kanoniske URL-en i stedet.
- Lenken kan vises, men aldri klikkes på.
- En nettleser eller personverktøy kan fjerne henvisningen.
- Noen systemer kan hente destinasjonen server-side.
- En kort lenke måler ikke brukere som husker merkevaren og returnerer senere.
Ikke bruk en forkorter for å skjule destinasjonen, omgå sikkerhetssystemer eller manipulere sitater. Bruk en klar videresending til en relevant, kanonisk side.
Minimal førsteparts forkorter
Følgende eksempel bruker FastAPI og SQLite. Beskytt lenkeopprettingsendepunktet med autentisering før du bruker det i produksjon.
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)
Kjør den med:
bash pip install fastapi uvicorn uvicorn shortener:app --host 0.0.0.0 --port 8080
Bruk en 302-videresending for målingslenker slik at nettlesere og mellomliggende systemer er mindre sannsynlige til å cache videresendingen permanent.
7. Planlagt skraping av AI-svarpaneler
Panelskraping er nyttig for å svare på spørsmål som førstepartsanalyse kanskje ikke avslører:
- Hvilke konkurrerende kilder siteres?
- Hvilken side siteres for en spesifikk ledetekst?
- Endres svaret etter land eller enhet?
- Dukker sitatet opp i svaret, kildelisten, eller begge deler?
- Hvor stabilt er svaret over tid?
- Siterer assistenten en kort lenke eller den kanoniske URL-en?
Imidlertid må skraping gjøres forsiktig.
Regler for ansvarlig datainnsamling
- Foretrekk en offisiell API, lisensiert søkedataleverandør eller førstepartsrapport.
- Gå gjennom leverandørens vilkår før du automatiserer forespørsler.
- Respekter robots-instruksjoner der det er aktuelt.
- Ikke omgå CAPTCHAer, påloggingsbarrierer, hastighetsbegrensninger eller bot-beskyttelser.
- Bruk et lite, fastsett ledetekstsett.
- Kjør med lav frekvens.
- Lagre responsen og tidsstempelet slik at resultatene kan revideres.
- Stopp når en leverandør returnerer en utfordring eller tilgang nektet-respons.
Robots Exclusion Protocol er en crawler-preferansemekanisme, ikke et sikkerhetssystem. Den autoriserer ikke tilgang til beskyttet innhold. RFC 9309: Robots Exclusion Protocol (rfc-editor.org)
Bygg et ledetekstmanifest
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 ledetekstene stabile. Hvis ledetekstene endres, blir trendsammenligninger mindre pålitelige.
Lagre også:
- land
- språk
- enhetstype
- nettleserversjon
- ledetekstversjon
- innholdsversjon
- innsamlingstid i UTC
Google bemerker at AI Overview og AI Mode-svar kan variere fordi de bruker forskjellige systemer og kan bruke relaterte søk eller «query fan-out». Google AI-funksjoner og nettstedsdokumentasjon (developers.google.com)
Playwright innsamlingsskript
Følgende skript er en generell forskningsramme. Det registrerer den gjengitte siden, skjermbilde, sidelinker og svartekst. Det omgår bevisst ikke utfordringer eller forsøker å omgå tilgangskontroller.
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"))
Installer og kjør:
bash pip install playwright playwright install chromium python scrape_panels.py
Planlegg den med en lås slik at to kjøringer ikke kan overlappe:
cron
15 3 * * * flock -n /tmp/ai-panel.lock
/usr/bin/python3 /opt/ai-measure/scrape_panels.py
Forbedre nøyaktigheten med overflateadaptere
Fallback-skriptet registrerer lenker på sidenivå. Dette er nyttig for oppdagelse, men kan inkludere vanlige søkeresultatlenker. For produksjonsbruk:
- Bruk en lisensiert svarpanel-API når tilgjengelig.
- Opprett en separat velgeradapter for hver overflate.
- Lagre velgerversjonen.
- Lagre rå HTML og et skjermbilde.
- Merk hver post med
link_scope = "panel_selector"ellerlink_scope = "page_fallback".
Beregn kun sitatandel fra panel_selector-data når du pålitelig kan identifisere panelet. Hold fallback-observasjoner i en egen utforskende rapport.
8. Google Analytics og BigQuery-integrasjon
Google Analytics 4 kan eksportere rå hendelsesdata til BigQuery. Daglige eksportfiler gir stabile tabeller som events_YYYYMMDD; strømmingseksport er nyttig for nesten sanntidsovervåking, men kan være ufullstendig til den daglige tabellen er ferdigstilt. Google Analytics BigQuery-eksport (support.google.com)
En forenklet øktspørring kan se slik ut:
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 implementasjon, legg til surface, topic og prompt_id til kampanjestrukturen eller koble dem fra tabellen for kortlenkeklikk.
Ikke opprett en dimensjon for høy kardinalitet i analyse for hver enkelt bruker eller økt. Google advarer om at for mange egendefinerte dimensjoner med høy kardinalitet kan redusere rapporteringskvaliteten. Google Analytics egendefinerte dimensjoner og beregninger (support.google.com)
9. Dashboard-design
Et praktisk dashboard bør inneholde fem visninger.
Sammendrag for ledelsen
Vis:
- Sitatandel per emne
- Sitatdekning per emne
- Bekreftet assistenthenvisningsrate
- Utvidet assistenthenvisningsrate
- Engasjement etter sitering
- AI-crawler suksessrate
- AI-tilskrevne nøkkelhendelser eller inntekt
Trend for sitatandel
Linjediagram:
text Dato × Emne × Overflate
Vis begge:
- forekomster av sitater fra måldomenet
- alle observerte forekomster av sitater
Assistenttrafikktrakt
text Observerte sitater ↓ Kontrollerte lenkeklikk ↓ AI-tilskrevne økter ↓ Engasjerte AI-økter ↓ Nøkkelhendelser
Hvert trinn skal vise sin attribusjonstrygghet.
Crawler-helse
Tabell etter bot:
text bot_name requests successful_requests error_rate unique_ip_count top_paths
Varsle når:
- feilraten overstiger 2 prosent
- en bot plutselig forsvinner
- en tidligere tilgjengelig innholdsseksjon returnerer 403 eller 429
- crawlerforespørsler konsentrerer seg om en utilsiktet privat bane
Innholdsmulighetstabell
Ranger sider og emner etter:
text høy sitatandel + lav henvisningsrate høye visninger + lav sitatdekning høyt engasjement + lav sitatsynlighet høye crawlerfeil + høy forretningsviktighet
Disse kombinasjonene gir vanligvis bedre beslutninger enn å se kun på trafikkvolum.
Minimalt Streamlit-dashboard
Lagre 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("Sitatandel per emne") st.dataframe(citation_share, use_container_width=True) st.bar_chart( citation_share.set_index("topic")["citation_share"] )
with col2: st.subheader("Sitatdekning per emne") st.dataframe(coverage, use_container_width=True) st.bar_chart( coverage.set_index("topic")["citation_coverage"] )
st.subheader("Sitatposisjon") 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("Daglige observasjoner") 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) )
Kjør den med:
bash pip install streamlit pandas streamlit run dashboard.py
For produksjon, last inn normaliserte tabeller i DuckDB, BigQuery, Snowflake eller PostgreSQL i stedet for å lese råfiler direkte.
10. Kvalitetskontroller
Et målesystem kan produsere presise, men misvisende tall. Legg til disse kontrollene.
Skill roboter fra mennesker
Tell aldri OAI-SearchBot, GPTBot, ClaudeBot eller PerplexityBot som menneskelige økter. Dette er signaler for crawling eller henting.
Bevar rådata
For hver panelkjøring, behold:
- rå HTML
- skjermbilde
- svartekst
- tidsstempel
- nettleser- og samlerversjon
- ledetekst og lokalisering
- svarhash
Dette gjør det mulig å forklare hvorfor en beregning endret seg.
Spor panelmangel
Et manglende AI-panel er ikke det samme som et svar uten din sitering.
Bruk:
text panel_not_present panel_present_no_target panel_present_target_cited blocked_or_challenged collection_error
Kontroller utvalgsskjevhet
En planlagt skraping påvirkes av:
- sted
- enhet
- språk
- tid
- personalisering
- nettleserstatus
- ledetekstformulering
Bruk et fast kjernesett med ledetekster og et roterende sett med utforskende ledetekster. Hold de to gruppene adskilt i rapporter.
Unngå falsk presisjon
Rapporter konfidensintervaller eller minimum utvalgsstørrelser for små emnegrupper. Et emne med tre observasjoner bør ikke sammenlignes direkte med et emne som inneholder 300 observasjoner.
Sammenlign like med like
Når du sammenligner overflater, hold konstant:
- ledetekst
- lokalisering
- land
- enhet
- innsamlingstime
- måldomene-regler
- sitatparserversjon
11. Anbefalt utrullingsplan
Uke 1: Instrumentering
- Konfigurer strukturerte serverlogger.
- Opprett bot-registeret.
- Opprett forkortelsesdomenet.
- Legg til regler for kampanjenavn.
- Definer emnetaksonomien.
- Aktiver Google Analytics 4 BigQuery-eksport.
Uke 2: Kontrollerte lenker
- Publiser fem til ti sporede sammendrag.
- Opprett lenker for ulike emner og innholdsversjoner.
- Valider videresendingslogger mot analysesesjoner.
- Bekreft at ingen personlig informasjon kommer inn i URL-er eller analyseparametere.
Uke 3: Panelobservasjoner
- Opprett et ledetekstmanifest.
- Start med to overflater og 20 til 50 stabile ledetekster.
- Kjør én gang per dag.
- Lagre skjermbilder og HTML.
- Mål innsamlingsfeil separat fra manglende sitering.
Uke 4: Dashboard og styring
- Bygg emne- og overflate-dashboards.
- Legg til crawler-helsevarsler.
- Definer konfidensregler.
- Sammenlign førstepartsrapporter med panelobservasjoner.
- Dokumenter retningslinjer for oppbevaring, tilgang og sletting.
Konklusjon
Måling av AI-sitater bør bygges som et system for multisignalattribuering, ikke som en enkelt henvisningsrapport.
Den mest pålitelige rammen kombinerer:
- Google og Bing førsteparts synlighetsrapporter
- serverlogger for crawler- og henteraktivitet
- førsteparts korte lenker for kontrollert klikkmåling
- planlagte, kompatible observasjoner av svarpaneler
- analyse- og konverteringsdata for nedstrøms verdi
De tre viktigste beregningene er:
- Sitatandel per emne — hvor mye av det observerte kildeområdet som tilhører ditt domene.
- Assistenthenvisningsrate — hvor mye menneskelig trafikk som kan tilskrives AI-overflater.
- Engasjement etter sitering — om AI-henviste besøkende utfører meningsfulle handlinger.
Systemet blir nyttig når hvert tall inkluderer dets overflate, tidsperiode, nevner, attribusjonsmetode og konfidensnivå. Denne disiplinen forhindrer at crawler-treff forveksles med brukere, skrapte svar forveksles med visninger, og sitater forveksles med forretningsresultater.
Auto