AutoPodAutoPod

AI引用インパクトの測定:アナリティクス、ログ、パネルスクレイピング

•12 分で読めます
音声記事
AI引用インパクトの測定:アナリティクス、ログ、パネルスクレイピング
0:00—:—
AI引用インパクトの測定:アナリティクス、ログ、パネルスクレイピング

AI引用インパクトの測定:アナリティクス、ログ、パネルスクレイピング

はじめに

AI駆動の発見は、現在、いくつかの測定レイヤーにまたがっています。人々はAIの回答で引用された企業を目にし、有用なリファラーなしでリンクをクリックしたり、キャンペーンデータを削除するブラウザ経由でアクセスしたり、クリックせずに回答を読んだりするかもしれません。その一方で、AIクローラーは人間ではない訪問者として企業のページをリクエストすることもあります。

したがって、有用な測定システムは以下の4つの問いを区別する必要があります。

  1. サイトがクロールされたか、または取得されたか?
  2. サイトがAIの回答で引用されたか?
  3. その回答から人がサイトを訪問したか?
  4. その訪問は有意義なエンゲージメントや収益を生み出したか?

2026年9月22日現在、ファーストパーティのレポート機能は改善されつつあります。Google Search Consoleは、AI概要とAIモードに関する生成AIパフォーマンスレポートを提供しており、これにはインプレッション、ページ、国、デバイス、日付が含まれます。このレポートは2026年8月31日に全世界で利用可能になりました。 Googleの生成AIパフォーマンスレポート (support.google.com)

Microsoftもまた、Bing Webmaster ToolsでAIパフォーマンスレポートを提供しています。このパブリックプレビューでは、サポートされているMicrosoft AI体験全体での合計引用数、引用されたページ、根拠となったクエリ、引用トレンドが報告されます。 Bing Webmaster Tools 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. 測定フレームワーク

5つの測定レイヤーを使用します。

レイヤー主な質問推奨されるデータ
アクセスAIシステムがページをリクエストしたか?ウェブサーバーログ、ファイアウォールログ
可視性ページが表示されたか、または引用されたか?Google Search Console、Bing Webmaster Tools、回答パネルの観察
獲得AIサーフェスから人が訪れたか?リファラー、キャンペーンパラメーター、短縮リンクのクリック、アナリティクス
エンゲージメント訪問者は有意義なインタラクションをしたか?アナリティクスイベント、ページビュー、エンゲージメント時間
価値訪問は望ましい結果を生み出したか?サインアップ、リード、購入、購読

推奨される信頼できる情報源の階層

各質問に対して、利用可能な最も強力なシグナルを使用します。

  1. インプレッションと引用についてはファーストパーティプラットフォームレポート。
  2. プロンプトレベルの可視性と競合比較については回答パネルの観察。
  3. 制御されたリンククリックについてはリダイレクトログ。
  4. セッション、エンゲージメント、コンバージョンについてはウェブアナリティクス。
  5. クローラーおよびフェッチャーのアクティビティについてはサーバーログ。
  6. 直接的な証拠がない場合にのみモデル化された推定。

すべてのシグナルを単一の数値にまとめる場合でも、情報源と信頼レベルを保持してください。


3. コアデータモデル

生データと派生メトリクスのための別々のテーブルを持つデータウェアハウスを作成します。

panel_runs

各プロンプトとサーフェスチェックにつき1行。

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

回答パネルで観察された各リンクにつき1行。

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

制御されたリンクリクエストごとに1行。

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{引用シェア}_{topic}

\frac{\text{ターゲットドメインの引用発生数}} {\text{すべての引用発生数}} ]

例:

  • 「プロジェクト管理ソフトウェア」に関する合計100件の引用が観察された
  • 12件の引用が自社のドメインを指している
  • トピック別引用シェア = 12%

このメトリクスは、回答が自社サイトを少なくとも一度含んだかどうかではなく、情報源のシェアを測定します。

引用カバレッジ

回答レベルでの存在には、2番目のメトリクスを使用します。

[ \text{引用カバレッジ}_{topic}

\frac{\text{ターゲット引用を含む回答実行数}} {\text{パネルが存在する回答実行数}} ]

引用カバレッジは、非技術的な聴衆にとっても理解しやすいことが多いです。

「このトピックに関する観察されたAI回答の34%に当社のサイトが表示されました。」

引用位置

回答内での引用の位置を記録します。

  • 1 = 最初に観察された情報源
  • 2 = 2番目に観察された情報源
  • 3 = 3番目に観察された情報源

AIインターフェースは情報源をグループ化したり再注文したりすることが多いため、位置は普遍的なランキングではなく、記述的な観察として扱ってください。


アシスタントリファラル率

アシスタントリファラル率は、AIサーフェスに起因すると考えられる人間のサイトセッションの割合を測定します。

[ \text{アシスタントリファラル率}

\frac{\text{AIに帰属する人間のセッション数}} {\text{すべての人間のセッション数}} ]

少なくとも2つのバージョンを報告してください。

text 確定アシスタントリファラル率 = A信頼度AIセッション数 / すべての人間のセッション数

拡張アシスタントリファラル率 = A + B + C信頼度AIセッション数 / すべての人間のセッション数

確定版はより保守的です。拡張版はトレンド分析に役立ちますが、完全に観察されたトラフィックとして提示すべきではありません。

パネル実行をインプレッションとして使用しないでください

500のプロンプトをスクレイピングしたとしても、それは500人がそれらの回答を見たことを意味しません。スクレーパーは回答状態の制御されたサンプルを測定します。利用可能な場合は、ファーストパーティプラットフォームのインプレッションレポートを使用してください。


引用後のエンゲージメント

引用後のエンゲージメントは、AI引用を介して到着した人々が有意義な行動をとったかどうかを測定します。

[ \text{引用後のエンゲージメント}

\frac{\text{エンゲージされたAIセッション数}} {\text{AIに帰属するセッション数}} ]

Google Analytics 4では、エンゲージされたセッションは10秒以上続き、主要なイベントを含むか、少なくとも2つのページビューまたはスクリーンビューを含みます。 Googleアナリティクス エンゲージメント率ドキュメント (support.google.com)

また、以下も報告してください。

text 引用後のエンゲージメント時間 = AIセッションあたりのエンゲージメントの中央値(秒)

引用後のコンバージョン率 = 主要イベントを伴うAIセッション数 / AIに帰属するセッション数

引用後のセッションあたりの収益 = AIセッションからの収益 / AIに帰属するセッション数

平均エンゲージメント時間は、ごく少数の非常に長いセッションによって平均値が歪められる可能性があるため、通常、中央値のエンゲージメント時間の方が安定しています。


その他の有用なメトリクス

AIクローラー成功率

[ \text{クローラー成功率}

\frac{\text{2xxまたは3xxを返すAIクローラーリクエスト数}} {\text{AIクローラーリクエスト数}} ]

これをボットとパスごとに分解します。引用の急激な減少とクローラーエラー率の上昇が同時に見られる場合、ホスティング、ファイアウォール、またはrobots設定の問題を示している可能性があります。

引用からクリック率

制御された回答パネルの観察の場合:

[ \text{観察された引用からクリック率}

\frac{\text{確定された短縮リンククリック数}} {\text{観察されたターゲット引用数}} ]

分母が実際のユーザーインプレッションを表していない限り、これは真のユーザーのクリックスルー率ではありません。観察された運用メトリクスとして明確にラベル付けしてください。

引用の鮮度

引用されたURLが現在のページバージョンをどれくらいの頻度で反映しているかを測定します。

text 引用の鮮度 = 現在のコンテンツハッシュを持つ引用されたページ数 / 引用されたページ数

これは、頻繁に変更されるドキュメント、価格設定、製品、およびポリシーページに役立ちます。


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

検索エンジン:

  • 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アナリティクス キャンペーン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を引用するか?

ただし、スクレイピングは慎重に行う必要があります。

責任ある収集のためのルール

  1. 公式API、ライセンスされた検索データプロバイダー、またはファーストパーティレポートを優先する。
  2. リクエストを自動化する前に、プロバイダーの規約を確認する。
  3. 該当する場合はrobotsの指示を尊重する。
  4. CAPTCHA、ログインバリア、レート制限、またはボット対策を回避しない。
  5. 少数の固定されたプロンプトセットを使用する。
  6. 低い頻度で実行する。
  7. 結果が監査できるように、応答とタイムスタンプを保存する。
  8. プロバイダーがチャレンジまたはアクセス拒否の応答を返したら停止する。

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モードの応答は、異なるシステムを使用し、関連する検索や「クエリのファンアウト」を使用する可能性があるため、異なる場合があると指摘しています。 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

2つの実行が重ならないようにロックをかけてスケジュールします。

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アナリティクスとBigQueryの統合

Googleアナリティクス4は、生のイベントデータをBigQueryにエクスポートできます。日次エクスポートはevents_YYYYMMDDのような安定したテーブルを提供し、ストリーミングエクスポートはほぼリアルタイムの監視に役立ちますが、日次テーブルが確定するまでは不完全である可能性があります。 Googleアナリティクス 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アナリティクス カスタムディメンションと指標 (support.google.com)


9. ダッシュボードのデザイン

実用的なダッシュボードには5つのビューを含めるべきです。

エグゼクティブサマリー

表示項目:

  • トピック別引用シェア
  • トピック別引用カバレッジ
  • 確定アシスタントリファラル率
  • 拡張アシスタントリファラル率
  • 引用後のエンゲージメント
  • AIクローラー成功率
  • AIに帰属する主要イベントまたは収益

引用シェアトレンド

折れ線グラフ:

text 日付 × トピック × サーフェス

両方を表示します。

  • ターゲットドメインの引用発生数
  • 観察されたすべての引用発生数

アシスタントトラフィックファネル

text 観察された引用数 ↓ 制御されたリンクのクリック数 ↓ AIに帰属するセッション数 ↓ エンゲージされたAIセッション数 ↓ 主要イベント数

すべての段階で帰属の信頼度を表示する必要があります。

クローラーヘルス

ボットごとのテーブル:

text bot_name requests successful_requests error_rate unique_ip_count top_paths

以下の場合にアラートを発します。

  • エラー率が2%を超える
  • ボットが突然消える
  • 以前アクセス可能だったコンテンツセクションが403または429を返す
  • クローラーのリクエストが意図しないプライベートパスに集中する

コンテンツ機会テーブル

ページとトピックを以下でランク付けします。

text 引用シェアが高い + リファラル率が低い インプレッションが高い + 引用カバレッジが低い エンゲージメントが高い + 引用可視性が低い クローラーエラーが高い + ビジネス重要度が高い

これらの組み合わせは、トラフィック量のみを見るよりも良い決定につながることが多いです。

最小限の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

サンプリングバイアスを制御する

定期的なスクレイピングは、以下の影響を受けます。

  • 場所
  • デバイス
  • 言語
  • 時間
  • パーソナライゼーション
  • ブラウザの状態
  • プロンプトの文言

固定されたコアプロンプトセットと、ローテーションする探索的プロンプトセットを使用してください。レポートではこの2つのグループを分けてください。

誤った精度を避ける

小さなトピックグループの場合、信頼区間または最小サンプルサイズを報告してください。3つの観察値しかないトピックと300の観察値を含むトピックを直接比較すべきではありません。

同じものと比較する

サーフェスを比較する際は、以下を一定に保ってください。

  • プロンプト
  • ロケール
  • 国
  • デバイス
  • 収集時間
  • ターゲットドメインのルール
  • 引用パーサーのバージョン

11. 推奨される展開計画

第1週:計測器の導入

  • 構造化されたサーバーログを設定する。
  • ボットレジストリを作成する。
  • 短縮URL用ドメインを作成する。
  • キャンペーン命名規則を追加する。
  • トピックの分類法を定義する。
  • Googleアナリティクス4のBigQueryエクスポートを有効にする。

第2週:制御されたリンク

  • トラッキングされた要約を5〜10件公開する。
  • 異なるトピックとコンテンツバージョン用のリンクを作成する。
  • リダイレクトログをアナリティクスセッションと照合して検証する。
  • 個人情報がURLやアナリティクスパラメーターに入らないことを確認する。

第3週:パネル観察

  • プロンプトマニフェストを作成する。
  • 2つのサーフェスと20〜50の安定したプロンプトから開始する。
  • 1日1回実行する。
  • スクリーンショットとHTMLを保存する。
  • 収集失敗を引用の不在とは別に測定する。

第4週:ダッシュボードとガバナンス

  • トピックとサーフェスのダッシュボードを構築する。
  • クローラーヘルスアラートを追加する。
  • 信頼度ルールを定義する。
  • ファーストパーティレポートをパネル観察と比較する。
  • データ保持、アクセス、削除ポリシーを文書化する。

結論

AI引用の測定は、単一のリファラルレポートとしてではなく、マルチシグナルアトリビューションシステムとして構築されるべきです。

最も信頼性の高いフレームワークは、以下を組み合わせています。

  • GoogleおよびBingのファーストパーティの可視性レポート
  • クローラーおよびフェッチャー活動のためのサーバーログ
  • 制御されたクリック測定のためのファーストパーティの短縮リンク
  • スケジュールされた、準拠した回答パネルの観察
  • 下流の価値のためのアナリティクスおよびコンバージョンデータ

最も重要な3つのメトリクスは次のとおりです。

  1. トピック別引用シェア — 観察された情報源空間のうち、自社ドメインがどれだけを占めているか。
  2. アシスタントリファラル率 — AIサーフェスに帰属する人間のトラフィックがどれくらいあるか。
  3. 引用後のエンゲージメント — AI経由で訪れた訪問者が有意義な行動をとったかどうか。

このシステムは、すべての数値にサーフェス、期間、分母、アトリビューション方法、信頼レベルが含まれる場合に有用になります。この規律により、クローラーのヒットをユーザーと誤認したり、スクレイピングされた回答をインプレッションと誤認したり、引用をビジネス結果と誤認したりすることを防ぎます。

関連記事

Perplexityおよびその他の引用アシスタント向け最適化

Perplexityおよびその他の引用アシスタント向け最適化

Perplexityおよびその他の引用アシスタント向け最適化 Kiran Sharma(グロース志向コンテンツストラテジスト)著 2026年10月14日更新 AIアシスタントは単にリンクのリストを表示するだけではありません。彼らはページを選択し、有用な箇所を抽出し、そしてどの情報源を回答に紐付けるか…

記事を読む
アンサーカードとTL;DRボックス:要約はAIの選択を左右するのか?

アンサーカードとTL;DRボックス:要約はAIの選択を左右するのか?

アンサーカードとTL;DRボックス:要約はAIの選択を左右するのか? TL;DRボックスはAIの引用ボタンではありません。これは役立つ回答を見つけやすくし、再利用しやすくするかもしれませんが、それとAIシステムがあなたのページを選択したり引用したりすることを証明するのとは異なります。 この違いは重要…

記事を読む
アンサーパラグラフエンジニアリング:抽出可能で検証可能なスニペットの作成

アンサーパラグラフエンジニアリング:抽出可能で検証可能なスニペットの作成

アンサーパラグラフエンジニアリング:抽出可能で検証可能なスニペットの作成 Kiran Sharma(グロース志向コンテンツストラテジスト)著 優れたコンテンツはビジネスの成長を助けるはずです。しかし、読者、あるいはアンサーエンジンが、そのコンテンツが何を主張し、証拠はどこから来ており、数字が信頼でき…

記事を読む
カノニカル化と重複排除:シグナルの断片化を防ぐ

カノニカル化と重複排除:シグナルの断片化を防ぐ

カノニカル化と重複排除:シグナルの断片化を防ぐ 一つの有用なリソースが、多くの異なるアドレスに存在することがあります。メインサイトの記事、ヘルプサブドメインのコピー、ダウンロード可能なPDF、そして複数の言語版や地域版などです。人間にとっては一つのコンテンツに見えても、検索システムや回答システムにと…

記事を読む

このコンテンツが気に入りましたか?

最新のコンテンツマーケティングのインサイトと成長ガイドを受け取るには、ニュースレターを購読してください。

この記事は情報提供のみを目的としています。コンテンツや戦略は、特定のニーズによって異なる場合があります。
AI引用インパクトの測定:アナリティクス、ログ、パネルスクレイピング | AutoPod