AutoPodAutoPod

Måling af AI-citatpåvirkning: Analyse, Logs og Panel-scraping

•21 min. læsning
Lydartikel
Måling af AI-citatpåvirkning: Analyse, Logs og Panel-scraping
0:00—:—
Måling af AI-citatpåvirkning: Analyse, Logs og Panel-scraping

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:

  1. Blev webstedet crawlet eller hentet?
  2. Blev webstedet citeret i et AI-svar?
  3. Besøgte en person webstedet fra det svar?
  4. 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.

LagHovedspørgsmålAnbefalede data
AdgangAnmodede et AI-system om siden?Webserverlogs, firewall-logs
SynlighedBlev siden vist eller citeret?Google Search Console, Bing Webmaster Tools, observationer af svarpaneler
AnskaffelseKom en person fra en AI-overflade?Henviser, kampagneparametre, klik på korte links, analyse
EngagementInteragerede den besøgende meningsfuldt?Analysehændelser, sidevisninger, engagementstid
VærdiFremkaldte 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:

  1. Førsteparts platformrapporter for visninger og citater.
  2. Observationer af svarpaneler for synlighed på prompt-niveau og konkurrenceanalyser.
  3. Viderestillingslogs for kontrollerede linkklik.
  4. Webanalyse for sessioner, engagement og konverteringer.
  5. Serverlogs for crawler- og hentningsaktivitet.
  6. 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 kilde
  • 2 = anden observerede kilde
  • 3 = 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

  1. Foretræk en officiel API, licenseret søgedataudbyder eller førstepartsrapport.
  2. Gennemgå udbyderens vilkår, før du automatiserer anmodninger.
  3. Respekter robots-instruktioner, hvor det er relevant.
  4. Omgå ikke CAPTCHAs, login-barrierer, rate-limits eller bot-beskyttelse.
  5. Brug et lille, fast prompt-sæt.
  6. Kør med lav frekvens.
  7. Gem svaret og tidsstemplet, så resultater kan revideres.
  8. 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" eller link_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:

  1. Citatandel efter emne — hvor stor en del af det observerede kildeområde der tilhører dit domæne.
  2. Assistenthenvisningsrate — hvor meget menneskelig trafik der kan tilskrives AI-overflader.
  3. 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.

Relaterede artikler

Kan du lide dette indhold?

Tilmeld dig vores nyhedsbrev for at få den nyeste indsigt i content marketing og vækstguider.

Denne artikel er kun til informationsformål. Indhold og strategier kan variere afhængigt af dine specifikke behov.
Måling af AI-citatpåvirkning: Analyse, Logs og Panel-scraping | AutoPod