衡量AI引用影响力:分析、日志和面板抓取
引言
AI驱动的发现现在分散在多个衡量层级。一个人可能在AI回答中看到一家公司被引用,点击一个没有有用引荐来源的链接,通过剥离营销活动数据的浏览器访问,或者根本不点击就阅读答案。与此同时,AI爬虫可能会请求公司的页面,但这并不代表有人类访客。
因此,一个有用的衡量系统必须区分四个问题:
- 网站是否被抓取或检索?
- 网站是否在AI回答中被引用?
- 用户是否从该AI回答访问了网站?
- 该访问是否产生了有意义的互动或收入?
截至 2026年9月22日,第一方报告正在改进。Google Search Console为 AI概览 和 AI模式 提供了一份生成式AI表现报告,包括展示次数、页面、国家/地区、设备和日期。该报告于 2026年8月31日 全球可用。Google的生成式AI表现报告 (support.google.com)
微软也在Bing网站管理员工具中提供了一份 AI表现 报告。其公开预览版报告了受支持的微软AI体验中的总引用量、被引用页面、基础查询和引用趋势。Bing网站管理员工具AI表现报告 (blogs.bing.com)
这些工具应被视为其各自界面的权威来源,但它们并未建立一个完整的跨助手衡量系统。推荐的方法是结合 第一方报告、服务器日志、受控跟踪链接、分析导出和计划性答案面板观察。
1. AI引荐衡量为何困难
传统的网络分析严重依赖 Referer 头部和营销活动参数。两者都可能缺失或不完整。
HTTP Referer 头部可能包含前一个页面、仅前一个网站来源,或者完全没有值。浏览器引荐来源策略可以有意地限制或删除这些信息。MDN Referrer-Policy 文档 (developer.mozilla.org)
AI系统带来了额外的复杂性:
- 助手可能会链接到一个页面,而不发送可识别的引荐来源。
- 助手可能会使用重定向或重写的URL。
- 助手可能会在显示答案之前在服务器端抓取内容。
- AI爬虫可能会访问一个页面,而没有任何人类看到它。
- 答案可能引用规范URL,而不是最初检索到的URL。
- 答案可能根据位置、设备、语言、时间或个性化设置而变化。
例如,OpenAI区分用于搜索发现的OAI-SearchBot、可能用于训练的GPTBot和用户发起的行为的ChatGPT-User。Perplexity单独识别PerplexityBot和Perplexity-User。Anthropic识别ClaudeBot、Claude-SearchBot和Claude-User。这些请求不应合并为一个通用的“AI流量”数字。OpenAI爬虫文档 (openai.com) Perplexity爬虫文档 (docs.perplexity.ai) Anthropic爬虫文档 (support.anthropic.com)
核心规则是:
爬虫活动不是引荐流量,引用可见性不是流量,流量也不是业务影响。
2. 衡量框架
使用五个衡量层级。
| 层级 | 主要问题 | 推荐数据 |
|---|---|---|
| 访问 | AI系统是否请求了该页面? | Web服务器日志、防火墙日志 |
| 可见性 | 页面是否被展示或引用? | Google Search Console、Bing网站管理员工具、答案面板观察 |
| 获取 | 用户是否从AI界面抵达? | 引荐来源、营销活动参数、短链接点击、分析数据 |
| 互动 | 访客是否进行了有意义的互动? | 分析事件、页面浏览量、互动时长 |
| 价值 | 访问是否产生了预期结果? | 注册、潜在客户、购买、订阅 |
推荐的真实数据源层级
对每个问题使用最强的可用信号:
- 用于展示和引用的第一方平台报告。
- 用于提示级别可见性和竞争比较的答案面板观察。
- 用于受控链接点击的重定向日志。
- 用于会话、互动和转化的网络分析。
- 用于爬虫和抓取器活动的服务器日志。
- 仅在没有直接证据时使用模型估算。
在不保留来源和置信水平的情况下,不要将所有信号合并为一个单一数字。
3. 核心数据模型
创建一个数据仓库,其中包含用于原始观察和派生指标的独立表。
panel_runs
每个提示和界面检查对应一行。
text run_id observed_at surface topic prompt_id query_text locale country device pane l_present pane l_type answer_hash raw_html_path screenshot_path collector_version status
panel_citations
答案面板中观察到的每个链接对应一行。
text run_id surface topic prompt_id cited_url cited_domain citation_position link_scope target_domain target_cited
ai_crawler_requests
聚合的服务器日志数据。
text date bot_name bot_role path requests successful_requests error_requests unique_ip_count
shortener_clicks
每个受控链接请求对应一行。
text click_id occurred_at slug surface topic prompt_id asset_id referrer_host user_agent_family ip_hmac destination_path
ai_sessions
规范化的分析表。
text session_id date surface topic prompt_id landing_page attribution_method confidence engaged engagement_time_seconds key_event revenue
confidence 字段很重要。推荐值为:
- A — 直接: 受控短链接点击或可信的营销活动参数。
- B — 强: 已知助手引荐来源或经验证的用户抓取请求。
- C — 可能: 没有完整引荐来源的营销活动或着陆页证据。
- D — 模型: 从时间、面板观察或聚合变化推断。
将 A和B与C和D分开报告。
4. 指标定义
按主题的引用份额
按主题的引用份额衡量目标域名在某个主题的观察来源中出现的频率。
[ \text{Citation Share}_{topic}
\frac{\text{Target-domain citation occurrences}} {\text{All citation occurrences}} ]
示例:
- 针对“项目管理软件”观察到总共100次引用
- 12次引用指向您的域名
- 按主题的引用份额 = 12%
该指标衡量的是来源份额,而不是答案是否至少包含您的网站一次。
引用覆盖率
使用第二个指标来衡量答案层级的存在:
[ \text{Citation Coverage}_{topic}
\frac{\text{Answer runs containing a target citation}} {\text{Answer runs where a panel was present}} ]
引用覆盖率对于非技术受众来说通常更容易理解:
“我们的网站出现在该主题34%的AI回答中。”
引用位置
记录引用在答案中的位置:
1= 第一个观察到的来源2= 第二个观察到的来源3= 第三个观察到的来源
由于AI界面通常会对来源进行分组或重新排序,因此应将位置视为描述性观察,而非普遍排名。
助手引荐率
助手引荐率衡量可以归因于AI界面的网站人类会话的比例。
[ \text{Assistant Referral Rate}
\frac{\text{AI-attributed human sessions}} {\text{All human sessions}} ]
至少报告两个版本:
text Confirmed Assistant Referral Rate = A-confidence AI sessions / all human sessions
Expanded Assistant Referral Rate = A + B + C-confidence AI sessions / all human sessions
确认版本更保守。扩展版本有助于趋势分析,但不应作为完全观察到的流量呈现。
不要将面板运行视为展示
如果您抓取了500个提示,这不意味着有500人看到了这些答案。您的抓取工具衡量的是答案状态的一个受控样本。当第一方平台展示报告可用时,请使用它们。
引用后互动
引用后互动衡量通过AI引用抵达的用户是否采取了有意义的行动。
[ \text{Post-Citation Engagement}
\frac{\text{Engaged AI sessions}} {\text{AI-attributed sessions}} ]
对于Google Analytics 4,一个有互动的会话持续时间超过10秒,包含一个关键事件,或包含至少两次页面或屏幕浏览。Google Analytics互动率文档 (support.google.com)
另请报告:
text Post-Citation Engagement Time = median engaged seconds per AI session
Post-Citation Conversion Rate = AI sessions with a key event / AI-attributed sessions
Post-Citation Revenue per Session = revenue from AI sessions / AI-attributed sessions
中位互动时间通常比平均互动时间更稳定,因为少数非常长的会话可能会扭曲平均值。
其他有用指标
AI爬虫成功率
[ \text{Crawler Success Rate}
\frac{\text{AI crawler requests returning 2xx or 3xx}} {\text{AI crawler requests}} ]
按爬虫和路径进行细分。引用量大幅下降同时爬虫错误率上升可能表明存在托管、防火墙或robots配置问题。
引用到点击率
对于受控答案面板观察:
[ \text{Observed Citation-to-Click Rate}
\frac{\text{Confirmed short-link clicks}} {\text{Observed target citations}} ]
这不是一个真正的用户点击率,除非分母代表实际的用户展示。应将其明确标记为观察到的操作指标。
引用新鲜度
衡量被引用的URL反映当前页面版本的频率。
text Citation Freshness = cited pages with current content hash / cited pages
这对于频繁变化的文档、定价、产品和政策页面很有用。
5. 服务器日志分析
配置结构化访问日志
结构化日志比传统的空格分隔日志更容易分析。
Nginx配置示例:
nginx log_format ai_json escape=json '{' '"ts":"$time_iso8601",' '"remote_addr":"$remote_addr",' '"request":"$request",' '"status":$status,' '"bytes":$body_bytes_sent,' '"referer":"$http_referer",' '"user_agent":"$http_user_agent",' '"host":"$host"' '}';
access_log /var/log/nginx/ai-access.jsonl ai_json;
将日志文件存储在受限位置。除非有明确的操作原因,否则不要将原始IP地址发送到通用分析表。
识别AI请求类型
至少应区分:
text OpenAI:
- OAI-SearchBot
- ChatGPT-User
- GPTBot
Anthropic:
- Claude-SearchBot
- Claude-User
- ClaudeBot
Perplexity:
- PerplexityBot
- Perplexity-User
Search engines:
- Googlebot
- Bingbot
Google的AI搜索功能通常使用现有的Google搜索抓取控制,而不是独立的通用“AI概览机器人”。Google声明Googlebot控制搜索(包括AI功能)的访问。Google AI功能和网站 (developers.google.com)
用户代理字符串可以伪造。对于操作决策,应将用户代理匹配与提供商发布的IP范围或可用的经验证的反向DNS结合使用。OpenAI和Perplexity公布了其相关爬虫和抓取工具的IP信息。(openai.com)
可复现的日志汇总器
将以下内容保存为 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])
运行方式:
bash
HMAC_SECRET='a-long-random-secret'
python ai_log_summary.py /var/log/nginx/ai-access.jsonl \
data/ai_crawler_daily.csv
这提供了关于检索活动的操作证据,而不是人类流量。
6. 面向AI摘要的自定义短链接工具
当您发布可能被AI系统引用的摘要或源材料时,第一方短链接工具提供了一个受控的衡量点。
使用您控制的域名,例如:
text https://go.example.com/ai-docs
目标URL应包含营销活动参数,例如:
text utm_source=perplexity utm_medium=ai_answer utm_campaign=product_documentation utm_content=pricing_summary_v3
Google推荐使用营销活动参数来识别流量来源和营销活动。Google Analytics营销活动URL文档 (support.google.com)
推荐的链接字段
text surface topic prompt_id asset_id content_version destination_path
不要在链接中放置个人信息、电子邮件地址或用户搜索查询。营销活动标识符应描述已发布的资产,而不是识别个人。
重要限制
短链接是一种校准设备,而非通用解决方案:
- 助手可能会重写目标URL。
- 助手可能会引用规范URL。
- 链接可能被显示但从未被点击。
- 浏览器或隐私工具可能会删除引荐来源。
- 某些系统可能会在服务器端抓取目标内容。
- 短链接无法衡量记住品牌并稍后返回的用户。
不要使用短链接工具来隐藏目标地址、绕过安全系统或操纵引用。使用明确的重定向到相关、规范的页面。
最小化第一方短链接工具
以下示例使用 FastAPI 和 SQLite。在生产环境中使用之前,请使用身份验证保护链接创建端点。
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)
运行方式:
bash pip install fastapi uvicorn uvicorn shortener:app --host 0.0.0.0 --port 8080
对衡量链接使用 302 重定向,以便浏览器和中间系统不太可能永久缓存重定向。
7. 定期抓取AI答案面板
面板抓取对于回答第一方分析可能无法揭示的问题很有用:
- 引用了哪些竞争来源?
- 特定提示引用了哪个页面?
- 答案是否因国家或设备而异?
- 引用是出现在答案中、来源列表中,还是两者都出现?
- 答案随时间变化的稳定性如何?
- 助手是引用短链接还是规范URL?
然而,抓取必须谨慎进行。
负责任收集的规则
- 优先选择官方API、许可的搜索数据提供商或第一方报告。
- 在自动化请求之前,请审查提供商的条款。
- 在适用情况下,尊重robots指令。
- 不要绕过验证码、登录屏障、速率限制或机器人保护。
- 使用少量固定的提示集。
- 以低频率运行。
- 保存响应和时间戳,以便可以审计结果。
- 当提供商返回质询或拒绝访问响应时停止。
Robots排除协议是一种爬虫偏好机制,而非安全系统。它不授权访问受保护内容。RFC 9309: Robots排除协议 (rfc-editor.org)
构建提示清单
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
保持提示稳定。如果提示发生变化,趋势比较的可靠性就会降低。
另请存储:
- 国家
- 语言
- 设备类型
- 浏览器版本
- 提示版本
- 内容版本
- UTC时间收集时间
Google指出,AI概览和AI模式的响应可能会有所不同,因为它们使用不同的系统,并且可能会使用相关搜索或“查询扩散”。Google AI功能和网站文档 (developers.google.com)
Playwright采集脚本
以下脚本是一个通用研究工具。它记录渲染的页面、屏幕截图、页面链接和答案文本。它故意不绕过挑战或试图规避访问控制。
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"))
安装并运行:
bash pip install playwright playwright install chromium python scrape_panels.py
使用锁进行调度,以防止两次运行重叠:
cron
15 3 * * * flock -n /tmp/ai-panel.lock
/usr/bin/python3 /opt/ai-measure/scrape_panels.py
使用界面适配器提高准确性
后备脚本记录页面级链接。这对于发现很有用,但可能包含普通的搜索结果链接。对于生产用途:
- 在可用时使用许可的答案面板API。
- 为每个界面创建一个单独的选择器适配器。
- 存储选择器版本。
- 存储原始HTML和屏幕截图。
- 将每条记录标记为
link_scope = "panel_selector"或link_scope = "page_fallback"。
只有当您能够可靠地识别面板时,才从 panel_selector 数据计算引用份额。将后备观察数据保存在单独的探索性报告中。
8. Google Analytics与BigQuery集成
Google Analytics 4可以将原始事件数据导出到BigQuery。每日导出提供稳定的表,例如 events_YYYYMMDD;流式导出对于近实时监控很有用,但在每日表最终确定之前可能不完整。Google Analytics BigQuery导出 (support.google.com)
简化的会话查询可能如下所示:
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;
对于成熟的实现,将 surface、topic 和 prompt_id 添加到营销活动结构中,或者从短链接点击表中联接它们。
不要为每个单独的用户或会话创建高基数的分析维度。Google警告说,过多的高基数自定义维度可能会降低报告质量。Google Analytics自定义维度和指标 (support.google.com)
9. 仪表盘设计
一个实用的仪表盘应包含五个视图。
执行摘要
显示:
- 按主题的引用份额
- 按主题的引用覆盖率
- 确认的助手引荐率
- 扩展的助手引荐率
- 引用后互动
- AI爬虫成功率
- AI归因的关键事件或收入
引用份额趋势
折线图:
text Date × Topic × Surface
同时显示:
- 目标域名引用出现次数
- 所有观察到的引用出现次数
助手流量漏斗
text Observed citations ↓ Controlled-link clicks ↓ AI-attributed sessions ↓ Engaged AI sessions ↓ Key events
每个阶段都应显示其归因置信度。
爬虫健康状况
按爬虫划分的表格:
text bot_name requests successful_requests error_rate unique_ip_count top_paths
当出现以下情况时发出警报:
- 错误率超过2%
- 某个机器人突然消失
- 之前可访问的内容部分返回403或429
- 爬虫请求集中在意外的私人路径上
内容机会表
按以下方式对页面和主题进行排名:
text high citation share + low referral rate high impressions + low citation coverage high engagement + low citation visibility high crawler errors + high business importance
这些组合通常比仅查看流量量能带来更好的决策。
最小化Streamlit仪表盘
保存为 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) )
运行方式:
bash pip install streamlit pandas streamlit run dashboard.py
对于生产环境,应将规范化后的表加载到DuckDB、BigQuery、Snowflake或PostgreSQL中,而不是直接读取原始文件。
10. 质量控制
衡量系统可能产生看起来精确但具有误导性的数字。添加这些控制。
将机器人与人类区分开
绝不要将 OAI-SearchBot、GPTBot、ClaudeBot 或 PerplexityBot 计为人类会话。这些是爬虫或检索信号。
保存原始证据
对于每次面板运行,保留:
- 原始HTML
- 屏幕截图
- 答案文本
- 时间戳
- 浏览器和收集器版本
- 提示和语言环境
- 答案哈希
这使得解释指标变化的原因成为可能。
跟踪面板缺失
缺失的AI面板与没有您的引用的答案不同。
使用:
text pane l_not_present pane l_present_no_target pane l_present_target_cited blocked_or_challenged collection_error
控制抽样偏差
定期抓取受以下因素影响:
- 位置
- 设备
- 语言
- 时间
- 个性化
- 浏览器状态
- 提示措辞
使用一组固定的核心提示和一组轮换的探索性提示。在报告中将这两组分开。
避免虚假精确
对于小型主题组,报告置信区间或最小样本量。包含三次观察的主题不应直接与包含300次观察的主题进行比较。
同类比较
比较不同界面时,保持以下因素不变:
- 提示
- 语言环境
- 国家
- 设备
- 收集小时
- 目标域名规则
- 引用解析器版本
11. 推荐的实施计划
第1周:工具化
- 配置结构化服务器日志。
- 创建机器人注册表。
- 创建短链接域名。
- 添加营销活动命名规则。
- 定义主题分类。
- 启用Google Analytics 4 BigQuery导出。
第2周:受控链接
- 发布五到十个跟踪摘要。
- 为不同主题和内容版本创建链接。
- 根据分析会话验证重定向日志。
- 确认没有个人信息进入URL或分析参数。
第3周:面板观察
- 创建提示清单。
- 从两个界面和20到50个稳定提示开始。
- 每天运行一次。
- 保存屏幕截图和HTML。
- 将收集失败与引用缺失分开衡量。
第4周:仪表盘和治理
- 构建主题和界面仪表盘。
- 添加爬虫健康警报。
- 定义置信度规则。
- 将第一方报告与面板观察结果进行比较。
- 记录保留、访问和删除策略。
结论
AI引用衡量应构建为多信号归因系统,而不是单一的引荐报告。
最可靠的框架结合了:
- Google和Bing第一方可见性报告
- 用于爬虫和抓取活动的服务器日志
- 用于受控点击衡量的第一方短链接
- 定期、合规的答案面板观察
- 用于下游价值的分析和转化数据
最重要的三个指标是:
- 按主题的引用份额 — 在观察到的来源空间中,您的域名占多少比例。
- 助手引荐率 — 有多少人类流量可以归因于AI界面。
- 引用后互动 — AI引荐的访客是否采取了有意义的行动。
当每个数字都包含其界面、时间段、分母、归因方法和置信水平时,该系统才变得有用。这种严谨性可以防止爬虫点击被误认为是用户,抓取的答案被误认为是展示,以及引用被误认为是业务结果。
Auto