自分のサイトに GPTBot や ChatGPT-User がどれくらい来ているかを、User-Agent 別に数えて眺めていました。増えていれば AI に拾われている、という読み方です。
その数え方をやめました。8日分を検証結果つきで分け直したら、記事を実際に取得したリクエストは468件で、.env などを狙う認証情報スキャンが991件でした。GPTBot を名乗るリクエストのうち、Cloudflare が OpenAI だと検証できたのは13%だけです。
Cloudflare 無料プランで偽装を分ける方法と、実測の数字をまとめます。
User-Agent はヘッダなので、名乗りは証拠にならない
GPTBot/1.2 と書けば誰でもそう名乗れます。なので UA 別の集計表は「主張の一覧」であって、実体の一覧ではありません。
Cloudflare を通しているなら、突き合わせる材料が2つあります。
-
verifiedBotCategory— Cloudflare が IP と逆引きで検証した結果。空文字なら未検証 - リクエストされたパスがサイトマップに実在するか — 記事の消費と、robots.txt 等のクロール作業を分けるため
この2軸で残るのが「検証済みの bot が実在ページを取った件数」で、これだけが報告に使える数字になります。
取得クエリ(無料プランで通る)
httpRequestsAdaptiveGroups の dimensions に userAgent と verifiedBotCategory を並べるだけです。clientAsn や botClass は有料プランの権限ですが、verifiedBotCategory は無料ゾーンでも取れます。
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 }
}
}
}
}
"""
ハマりどころが2つあります。
- 1リクエストで取れる期間は最大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 が、検証済みと未検証に割れる
dimension を1つ足すだけで、名乗りと実体の乖離が出ます。8日分の実測です。
| 名乗っている UA | 検証済み | 未検証 | 検証率 |
|---|---|---|---|
| ChatGPT-User | 211(AI Assistant) | 336 | 39% |
| Amazonbot | 135(AI Crawler) | 489 | 22% |
| ClaudeBot | 133(AI Crawler) | 126 | 51% |
| OAI-SearchBot | 61(Search Engine Crawler) | 132 | 32% |
| GPTBot | 19(AI Crawler) | 123 | 13% |
| meta-externalagent | 209(AI Crawler) | 0 | 100% |
| Applebot | 56(AI Search) | 0 | 100% |
| PerplexityBot | 0 | 144 | 0% |
| Perplexity-User | 0 | 311 | 0% |
同じ UA 文字列が2行に割れているのが要点です。ChatGPT-User を名乗る547リクエストのうち、OpenAI の IP レンジから来たと確認できたのは211件でした。
素性がきれいなのは meta-externalagent と Applebot の2つだけで、検証率100%・偽装0件。名乗りの数字がそのまま使えるのはここだけです。Perplexity 系は455リクエストが全て未検証でした。
存在しない UA を名乗っているものがいる
Google-Extended を名乗るリクエストが123件ありました。検証率0%、うち70件が認証情報スキャンのパスです。
これは推測が要りません。Google のクローラー一覧に、Google-Extended は HTTP リクエスト用の User-Agent 文字列を持たないと書かれています。クロールは既存の Google の UA で行われ、Google-Extended は robots.txt で AI 学習利用の可否を伝える制御用トークンです。
つまりこの UA を名乗るリクエストは、定義上ひとつ残らず Google ではありません。同じ考え方で、IP レンジを公開している bot は突き合わせができます(OpenAI は gptbot.json を公開しています)。
パスの意図で content と scan を分ける
検証済みかどうかだけでは足りません。robots.txt を取りに来た検証済み bot は、記事を読んでいないからです。
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 に倒すと、サイトマップ取得が失敗した日だけ数字が跳ねて、原因を追えなくなります。
結果: content 468 に対し scan 991
2軸を通した8日分です。
| 名乗り | content | ops | scan | 合計 | 検証率 |
|---|---|---|---|---|---|
| ChatGPT-User | 216 | 0 | 181 | 547 | 39% |
| meta-externalagent | 93 | 9 | 0 | 209 | 100% |
| Amazonbot | 85 | 1 | 284 | 624 | 22% |
| Applebot | 27 | 9 | 0 | 56 | 100% |
| OAI-SearchBot | 17 | 43 | 74 | 193 | 32% |
| PerplexityBot | 13 | 8 | 67 | 144 | 0% |
| GPTBot | 7 | 9 | 72 | 142 | 13% |
| ClaudeBot | 6 | 129 | 70 | 259 | 51% |
| Google-Extended | 0 | 0 | 70 | 123 | 0% |
| Perplexity-User | 0 | 0 | 173 | 311 | 0% |
content 合計468、scan 合計991。ステータス別でも 403 が957件で、200 の705件を上回りました。
ClaudeBot の行も分けて見る価値があります。検証済み133件のうち記事の取得は6件で、129件が robots.txt などでした。「ClaudeBot が259件来ている」と「記事を6件読まれた」は同じデータの別の読み方です。
集計の出力には検証率をそのまま列として残すのがおすすめです。率が急に落ちた系列は、その回は指標として読めないと判断できます。
前回スナップショットとの比較(content 610→468 / scan 357→991 の逆転)、その減少が「関心低下」なのか「偽装の再分類」なのか切り分けられない件、WAF の遮断量が正常取得を超えた話は Aulvem 本家にまとめました → Aulvem|AIクローラーのUAは名乗るだけ — 検証済みだけ数えたら本物468件・偽装991件だった