目次13

「AI経由でサイトが参照されている」という数字を、8日ごとに取って眺めている。ChatGPT-User が何件、GPTBot が何件、と User-Agent ごとに並べた表で、これが増えていれば AI に拾われていると読んでいた。

その読み方をやめた。今週の集計では、記事を実際に取得したリクエストが468件、認証情報スキャンが991件だった。スキャンが本物の2倍を超えていて、しかも GPTBot を名乗るリクエストのうち Cloudflare が OpenAI だと確認できたのは13%しかない。UA を見て数えていたものは、AI の関心ではなく攻撃側の増減だった。

User-Agent は自己申告なので、検証結果と突き合わせる

AI クローラーのアクセスを数えるなら、UA の名乗りだけで集計してはいけない。UA はリクエストヘッダなので、GPTBot/1.2 と書けば誰でもそう名乗れる。

Cloudflare を通しているなら、判定の材料が2つ手に入る。ひとつは verifiedBotCategory で、Cloudflare が IP と逆引きで検証した結果が入る(空文字なら未検証)。もうひとつは、リクエストされたパスが自分のサイトマップに実在するかどうか。この2軸で分けると「検証済みの bot が実在ページを取った件数」だけが残る。それが数えるべき数値です。

前提 — Cloudflare 無料プランと静的サイト

対象は Cloudflare Pages に置いた静的サイト(Astro)で、ゾーンは無料プラン。取得は GraphQL Analytics API を Python から叩いている。

使うもの用途無料プランで可否
httpRequestsAdaptiveGroupsリクエストの集計可(1リクエストにつき最大1日)
verifiedBotCategorybot の検証結果
clientAsn / botClass送信元 ASN / bot 分類権限外
requests + pandas取得と集計

保持期間は無料プランだと短く、日別の粒度で遡れるのは1週間ほど。過去を後から掘り直せないので、スナップショットを定期的に取って別ファイルに残す前提で組んでいる。

同じ UA が、検証済みと未検証に割れる

verifiedBotCategory を dimension に足すだけで、名乗りと実体の乖離が出る。8日分の実測がこれ。

名乗っている UA検証済み未検証検証率
ChatGPT-User211(AI Assistant)33639%
Amazonbot135(AI Crawler)48922%
ClaudeBot133(AI Crawler)12651%
OAI-SearchBot61(Search Engine Crawler)13232%
GPTBot19(AI Crawler)12313%
meta-externalagent209(AI Crawler)0100%
Applebot56(AI Search)0100%
PerplexityBot01440%
Perplexity-User03110%

同じ UA 文字列が2つの行に割れているのが要点で、ChatGPT-User を名乗る547リクエストのうち、Cloudflare が OpenAI の IP レンジから来たと確認できたのは211件だけだった。

meta-externalagentApplebot は検証率100%で、偽装が1件も混ざっていない。名乗りの数字がそのまま使えるのはこの2つだけだった。逆に Perplexity 系は455リクエストが全て未検証で、本物が1件も観測されていない。

クエリはこれだけ。

QUERY_BY_UA_VERIFIED = """
query ($zoneTag: String!, $since: Time!, $until: Time!) {
  viewer {
    zones(filter: { zoneTag: $zoneTag }) {
      httpRequestsAdaptiveGroups(
        limit: 5000
        filter: { datetime_geq: $since, datetime_leq: $until }
        orderBy: [count_DESC]
      ) {
        count
        dimensions { userAgent verifiedBotCategory }
      }
    }
  }
}
"""

無料プランは1リクエストで最大1日しか取れないので、期間は呼び出し側で割る。古すぎる日は保持期間を過ぎてエラーになるため、失敗した日はスキップして続ける。

def fetch_rows(token, query, zone_tag, since, until):
    # 期間を1日ずつに分割して取得し、行を合算して返す
    all_rows, cursor = [], since
    one_day = dt.timedelta(days=1)
    while cursor < until:
        win_end = min(cursor + one_day, until)
        variables = {
            "zoneTag": zone_tag,
            "since": cursor.isoformat() + "Z",
            "until": win_end.isoformat() + "Z",
        }
        try:
            all_rows.extend(rows_from(gql(token, query, variables, exit_on_error=False)))
        except RuntimeError as e:
            print(f"    [スキップ] {cursor.date()}{win_end.date()}: {e}")
        cursor = win_end
    return all_rows

存在しない UA を名乗っているものがいる

Google-Extended を名乗るリクエストが123件あった。検証率は0%で、うち70件が .envwp-login のような認証情報スキャンのパスだった。

これは偽装かどうかを推測する必要がない。Google のクローラー一覧に、Google-Extended は HTTP リクエスト用の User-Agent 文字列を持たない、と明記されている。クロール自体は既存の Google の UA で行われ、Google-Extended は robots.txt で AI 学習利用の可否を伝えるための制御用トークンとして使われる。

つまり、この UA を名乗るリクエストは定義上ひとつ残らず Google ではない。同じ発想で、公式が IP レンジを公開している bot は突き合わせができる(OpenAI は gptbot.json を出している)。

自分のサイトでは、この123件は全て 403 で止まっていた。

パスの意図で content と scan を分ける

検証済みかどうかだけでは足りない。robots.txt を取りに来た検証済み bot は、記事を読んでいない。パスを5種類に分ける。

SCAN_PATTERNS = (
    "wp-", ".env", ".git", ".aws", ".svn", ".ssh", "secrets", "credentials",
    "config.json", "service_account", "actuator", "api/auth", "phpinfo",
    ".bak", ".yml", ".yaml", ".php", ".sql", "id_rsa", ".npmrc", ".htpasswd",
)
OPS_PREFIXES = ("/robots.txt", "/sitemap", "/llms.txt", "/favicon", "/rss", "/feed", "/.well-known/")
ASSET_PREFIXES = ("/_astro/", "/images/", "/assets/", "/fonts/", "/cdn-cgi/", "/_image")


def classify_path(path, sitemap_paths):
    # content=実在ページの消費 / ops=クロール作業 / asset=静的ファイル
    # scan=認証情報スキャン / other=実在しないパス
    if not path:
        return "other"
    low = path.lower()
    if any(k in low for k in SCAN_PATTERNS):
        return "scan"
    if low.startswith(OPS_PREFIXES):
        return "ops"
    if low.startswith(ASSET_PREFIXES):
        return "asset"
    if not sitemap_paths:
        return "unknown"   # 実在判定ができないので content とは言えない
    return "content" if (path.rstrip("/") or "/") in sitemap_paths else "other"

content の判定にサイトマップを使っているのが、この分類の肝になっている。404 のステータスで判定する手もあるが、それだとリダイレクトや、実在しないのに 200 を返すパスを拾ってしまう。「自分が公開していると宣言したパスの集合」を真値に置くほうが、サイト側の実態と一致する。

sitemap_paths が空のときに unknown を返しているのも要る。ここを content に倒すと、サイトマップの取得が失敗した日だけ数字が跳ねて、あとから原因を追えなくなる。

flowchart TD
  Req["AIボットを名乗るリクエスト"] --> V{"verifiedBotCategory が空か"}
  V -->|"空 = 未検証"| Fake["偽装として集計から外す"]
  V -->|"検証済み"| P{"パスはサイトマップに実在するか"}
  P -->|"実在"| C["content = 評価すべき数値"]
  P -->|"robots.txt / sitemap"| O["ops = クロール作業"]
  P -->|".env / wp-login"| S["scan = 認証情報スキャン"]

集計結果 — 記事取得468件に対しスキャン991件

2軸を通した8日分の結果がこれ。content の列が実際に評価すべき数値。

名乗りcontentopsscan合計検証率
ChatGPT-User216018154739%
meta-externalagent9390209100%
Amazonbot85128462422%
Applebot279056100%
OAI-SearchBot17437419332%
PerplexityBot138671440%
GPTBot797214213%
ClaudeBot61297025951%
Google-Extended00701230%
Perplexity-User001733110%

content の合計は468、scan は991。ステータス別でも 403 が957件で 200 の705件を上回っていて、WAF が弾いている量が正常取得の量を超えた。

前回(6日前)は content 610 / scan 357 だったので、この8日で逆転している。UA 別の合計だけを見ていたら「AI クローラーのアクセスが増えた」と読むところだった。

ClaudeBot の行も分けておく価値がある。検証済み133件のうち記事の取得は6件で、129件が robots.txt などのクロール作業だった。「ClaudeBot が259件来ている」と「記事を6件読まれた」は、同じデータの別の読み方になる。

分けたあとに残る、切り分けられないもの

この2軸で偽装は落とせるが、それでも言い切れないことがある。

content が 610 から 468 に減ったのは、AI 側の関心が落ちたのか、それとも従来 content に混ざっていた偽装が今回 scan に再分類されただけなのか、この1点では分からない。GPTBot は content が85から7に落ちているが、検証率も77%から13%に落ちているので、「取得が減った」と「偽装に埋もれた」が同時に起きている。

なので集計の出力には検証率をそのまま残して、率が急に落ちた系列は「今回は指標として読めない」と扱う運用にした。数字を1つに丸めず、母数と検証率を並べて置いておくほうが、あとから誤読しない。

まとめ

AI クローラーの数字を扱うなら、最初に UA を疑ったほうがいい。verifiedBotCategory を dimension に1つ足すだけで名乗りと実体が割れるし、割れ方を見れば「その系列が今どのくらい信用できるか」も同時に分かる。

意外だったのは、素性がきれいなのが meta-externalagentApplebot の2つだけだったこと。偽装される bot ほど有名で、有名な bot ほど数字が使えない状態になっている。名前を見て「大手だから信頼できる系列」と考えるのは筋が通らなかった。

同じ「まず測る」の話はクロス投稿は重複コンテンツかを実測した記事にも書いた。AI に読ませる側の整備はllms.txt を多言語で生成する記事にまとめてある。

よくある質問

AIクローラーのアクセスを User-Agent で数えてはいけないのはなぜ?

User-Agent はリクエストヘッダなので、GPTBot や ChatGPT-User と書けば誰でもそう名乗れます。実測では ChatGPT-User の547リクエストのうち Cloudflare が検証できたのは211で、残り336は逆引きで OpenAI と確認できませんでした。Amazonbot は624のうち検証済み135。UA だけで集計すると、この未検証分が「AI経由の関心が増えた」という数字になります。

Cloudflare の無料プランで偽装を分けられる?

分けられます。GraphQL Analytics API の httpRequestsAdaptiveGroups で dimensions に userAgentverifiedBotCategory を指定すると、Cloudflare が IP と逆引きで検証した結果が返ります。値が空文字なら未検証です。clientAsnbotClass は有料プランの権限になりますが、verifiedBotCategory は無料ゾーンでも取れます。ただし1リクエストで取れる期間が最大1日なので、期間はループで分割します。

Google-Extended を名乗るリクエストはどう扱う?

全て偽装として扱えます。Google の公式ドキュメントに Google-Extended は HTTP リクエスト用の User-Agent 文字列を持たないと明記されていて、これは robots.txt の制御用トークンです。実測では Google-Extended を名乗る123リクエストがあり、検証率0%、うち70件が認証情報スキャンのパスでした。

検証済みのリクエストなら全部「記事を読まれた」と数えていい?

分けたほうが正確です。検証済みでも robots.txt / sitemap.xml / llms.txt の取得はクロール作業で、記事の消費ではありません。ClaudeBot は検証済み133のうち記事取得が6件で、129件が robots.txt 等でした。パスがサイトマップに実在するかで contentops を分けると、実際に評価すべき数値だけが残ります。