AI 인용 영향 측정: 분석, 로그 및 패널 스크랩
서론
AI 기반 탐색은 이제 여러 측정 계층으로 나뉘어 있습니다. 사용자는 AI 답변에서 언급된 회사를 볼 수도 있고, 유용한 리퍼러가 없는 링크를 클릭할 수도 있고, 캠페인 데이터를 제거하는 브라우저를 통해 방문할 수도 있으며, 클릭하지 않고 답변을 읽을 수도 있습니다. 한편, AI 크롤러는 사람 방문자를 나타내지 않고 회사의 페이지를 요청할 수도 있습니다.
따라서 유용한 측정 시스템은 네 가지 질문을 구분해야 합니다.
- 사이트가 크롤링되거나 검색되었는가?
- 사이트가 AI 답변에 인용되었는가?
- 해당 답변을 통해 사람이 사이트를 방문했는가?
- 그 방문이 의미 있는 참여나 수익을 창출했는가?
2026년 9월 22일 현재, 자체 보고 기능이 개선되고 있습니다. Google Search Console은 AI 오버뷰(AI Overviews) 및 **AI 모드(AI Mode)**에 대한 생성형 AI 성과 보고서를 제공하며, 여기에는 노출수, 페이지, 국가, 기기, 날짜가 포함됩니다. 이 보고서는 2026년 8월 31일에 전 세계적으로 제공되기 시작했습니다. Google의 생성형 AI 성과 보고서 (support.google.com)
Microsoft 또한 Bing 웹마스터 도구에서 AI 성과 보고서를 제공합니다. 이 공개 미리보기는 지원되는 Microsoft AI 경험 전반에 걸쳐 총 인용수, 인용된 페이지, 근거 쿼리, 인용 추세를 보고합니다. Bing 웹마스터 도구 AI 성과 (blogs.bing.com)
이러한 도구들은 각 표면(surface)에 대해 신뢰할 수 있는 것으로 간주되어야 하지만, 완전한 교차 어시스턴트 측정 시스템을 구축하지는 못합니다. 권장되는 접근 방식은 자체 보고서, 서버 로그, 제어된 추적 링크, 분석 내보내기 및 예약된 답변 패널 관찰을 결합하는 것입니다.
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. 측정 프레임워크
다섯 가지 측정 계층을 사용합니다.
| Layer | Main question | Recommended data |
|---|---|---|
| 액세스 | AI 시스템이 페이지를 요청했는가? | 웹 서버 로그, 방화벽 로그 |
| 가시성 | 페이지가 표시되거나 인용되었는가? | Google Search Console, Bing 웹마스터 도구, 답변 패널 관찰 |
| 획득 | AI 표면을 통해 사람이 방문했는가? | 리퍼러, 캠페인 매개변수, 짧은 링크 클릭, 분석 |
| 참여 | 방문자가 의미 있게 상호작용한가? | 분석 이벤트, 페이지뷰, 참여 시간 |
| 가치 | 방문이 원하는 결과를 산출했는가? | 가입, 리드, 구매, 구독 |
권장되는 진실의 원천 계층 구조
각 질문에 대해 가장 강력한 신호를 사용하세요.
- 노출수 및 인용을 위한 자체 플랫폼 보고서.
- 프롬프트 수준 가시성 및 경쟁사 비교를 위한 답변 패널 관찰.
- 제어된 링크 클릭을 위한 리디렉션 로그.
- 세션, 참여 및 전환을 위한 웹 분석.
- 크롤러 및 페처 활동을 위한 서버 로그.
- 직접적인 증거를 사용할 수 없는 경우에만 모델링된 추정치.
출처와 신뢰 수준을 보존하지 않고 모든 신호를 하나의 숫자로 결합하지 마세요.
3. 핵심 데이터 모델
원시 관찰 및 파생 메트릭을 위한 별도의 테이블을 사용하여 데이터 웨어하우스를 생성합니다.
panel_runs
각 프롬프트 및 표면 확인에 대해 한 행.
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
답변 패널에서 관찰된 각 링크에 대해 한 행.
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}} \]
인용 적용 범위는 비기술적인 청중이 이해하기 더 쉬운 경우가 많습니다.
“저희 사이트는 이 주제에 대한 관찰된 AI 답변의 34%에 나타났습니다.”
인용 위치
답변 내 인용 위치를 기록합니다.
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 검색 기능은 일반적으로 별도의 범용 “AI 오버뷰 봇” 대신 기존 Google 검색 크롤링 제어를 사용합니다. 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
대상은 다음과 같은 캠페인 매개변수를 포함해야 합니다.
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 지침을 준수합니다.
- CAPTCHA, 로그인 장벽, 속도 제한 또는 봇 보호를 우회하지 마세요.
- 작고 고정된 프롬프트 세트를 사용하세요.
- 낮은 빈도로 실행하세요.
- 결과를 감사할 수 있도록 응답과 타임스탬프를 저장하세요.
- 제공업체가 챌린지 또는 액세스 거부 응답을 반환하면 중지하세요.
Robots Exclusion Protocol은 크롤러 선호 메커니즘이지 보안 시스템이 아닙니다. 보호된 콘텐츠에 대한 액세스를 승인하지 않습니다. RFC 9309: Robots Exclusion Protocol (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 모드 응답이 서로 다른 시스템을 사용하고 관련 검색 또는 “쿼리 팬아웃(query fan-out)”을 사용할 수 있으므로 달라질 수 있다고 언급합니다. 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) + "\
") 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 panel_not_present panel_present_no_target panel_present_target_cited blocked_or_challenged collection_error
샘플링 편향 제어
예약된 스크래핑은 다음의 영향을 받습니다.
- 위치
- 기기
- 언어
- 시간
- 개인화
- 브라우저 상태
- 프롬프트 문구
고정된 핵심 프롬프트 세트와 순환하는 탐색적 프롬프트 세트를 사용하세요. 보고서에서는 두 그룹을 분리하여 유지합니다.
잘못된 정밀도 피하기
작은 주제 그룹에 대해 신뢰 구간 또는 최소 샘플 크기를 보고하세요. 세 개의 관찰만 있는 주제는 300개의 관찰을 포함하는 주제와 직접 비교해서는 안 됩니다.
동종 비교
표면을 비교할 때 다음을 일정하게 유지하세요.
- 프롬프트
- 로케일
- 국가
- 기기
- 수집 시간
- 대상 도메인 규칙
- 인용 파서 버전
11. 권장되는 배포 계획
1주차: 계측
- 구조화된 서버 로그를 구성합니다.
- 봇 레지스트리를 생성합니다.
- 단축 서비스 도메인을 생성합니다.
- 캠페인 명명 규칙을 추가합니다.
- 주제 분류 체계를 정의합니다.
- Google Analytics 4 BigQuery 내보내기를 활성화합니다.
2주차: 제어된 링크
- 추적되는 요약 5~10개를 게시합니다.
- 다양한 주제 및 콘텐츠 버전에 대한 링크를 생성합니다.
- 분석 세션에 대해 리디렉션 로그를 검증합니다.
- 개인 정보가 URL이나 분석 매개변수에 포함되지 않도록 확인합니다.
3주차: 패널 관찰
- 프롬프트 매니페스트를 생성합니다.
- 두 개의 표면과 20~50개의 안정적인 프롬프트로 시작합니다.
- 하루에 한 번 실행합니다.
- 스크린샷과 HTML을 저장합니다.
- 인용 부재와 수집 실패를 별도로 측정합니다.
4주차: 대시보드 및 거버넌스
- 주제 및 표면 대시보드를 구축합니다.
- 크롤러 상태 알림을 추가합니다.
- 신뢰 규칙을 정의합니다.
- 자체 보고서를 패널 관찰과 비교합니다.
- 데이터 보존, 액세스 및 삭제 정책을 문서화합니다.
결론
AI 인용 측정은 단일 리퍼럴 보고서가 아닌 다중 신호 귀속 시스템으로 구축되어야 합니다.
가장 신뢰할 수 있는 프레임워크는 다음을 결합합니다.
- Google 및 Bing 자체 가시성 보고서
- 크롤러 및 페처 활동을 위한 서버 로그
- 제어된 클릭 측정을 위한 자체 짧은 링크
- 예약되고 규정을 준수하는 답변 패널 관찰
- 하위 가치를 위한 분석 및 전환 데이터
가장 중요한 세 가지 측정항목은 다음과 같습니다.
- 주제별 인용 점유율 — 관찰된 출처 공간 중 귀하의 도메인에 속하는 비율.
- 어시스턴트 리퍼럴 비율 — AI 표면에 귀속될 수 있는 사람 트래픽의 양.
- 인용 후 참여 — AI를 통해 방문한 사람들이 의미 있는 행동을 취하는지 여부.
시스템은 모든 숫자가 표면, 기간, 분모, 귀속 방법 및 신뢰 수준을 포함할 때 유용해집니다. 이러한 원칙은 크롤러 히트를 사용자로 오해하거나, 스크랩된 답변을 노출수로 오해하거나, 인용을 비즈니스 결과로 오해하는 것을 방지합니다.
Auto