AutoPodAutoPod

Måling av AI-sitatpåvirkning: Analyse, logger og panelskraping

•21 min lesing
Lydartikkel
Måling av AI-sitatpåvirkning: Analyse, logger og panelskraping
0:00—:—
Måling av AI-sitatpåvirkning: Analyse, logger og panelskraping

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:

  1. Ble nettstedet crawlet eller hentet?
  2. Ble nettstedet sitert i et AI-svar?
  3. Besøkte en person nettstedet fra det svaret?
  4. 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.

LagHovedspørsmålAnbefalt data
TilgangBa et AI-system om siden?Webserverlogger, brannmurlogger
SynlighetBle siden vist eller sitert?Google Search Console, Bing Webmaster Tools, observasjoner av svarpaneler
OppkjøpKom en person fra en AI-overflate?Henvisning, kampanjeparametere, klikk på korte lenker, analyser
EngasjementInteragerte den besøkende meningsfullt?Analysehendelser, sidevisninger, engasjementstid
VerdiGa besøket et ønsket resultat?Registreringer, leads, kjøp, abonnementer

Anbefalt hierarki for kildedata

Bruk det sterkeste tilgjengelige signalet for hvert spørsmål:

  1. Førsteparts plattformrapporter for visninger og sitater.
  2. Observasjoner av svarpaneler for synlighet på ledetekstnivå og konkurransesammenligninger.
  3. Videresendingslogger for kontrollerte lenkeklikk.
  4. Webanalyse for økter, engasjement og konverteringer.
  5. Serverlogger for crawler- og henteraktivitet.
  6. 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 kilde
  • 2 = andre observerte kilde
  • 3 = 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

  1. Foretrekk en offisiell API, lisensiert søkedataleverandør eller førstepartsrapport.
  2. Gå gjennom leverandørens vilkår før du automatiserer forespørsler.
  3. Respekter robots-instruksjoner der det er aktuelt.
  4. Ikke omgå CAPTCHAer, påloggingsbarrierer, hastighetsbegrensninger eller bot-beskyttelser.
  5. Bruk et lite, fastsett ledetekstsett.
  6. Kjør med lav frekvens.
  7. Lagre responsen og tidsstempelet slik at resultatene kan revideres.
  8. 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" eller link_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:

  1. Sitatandel per emne — hvor mye av det observerte kildeområdet som tilhører ditt domene.
  2. Assistenthenvisningsrate — hvor mye menneskelig trafikk som kan tilskrives AI-overflater.
  3. 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.

Relaterte artikler

Liker du dette innholdet?

Abonner på vårt nyhetsbrev for den nyeste innsikten om innholdsmarkedsføring og vekstguider.

Denne artikkelen er kun til informasjonsformål. Innhold og strategier kan variere basert på dine spesifikke behov.
Måling av AI-sitatpåvirkning: Analyse, logger og panelskraping | AutoPod