0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AIクローラーのUAは名乗るだけ — 検証済みだけ数えたら本物468件・偽装991件だった

0
Posted at

自分のサイトに GPTBot や ChatGPT-User がどれくらい来ているかを、User-Agent 別に数えて眺めていました。増えていれば AI に拾われている、という読み方です。

その数え方をやめました。8日分を検証結果つきで分け直したら、記事を実際に取得したリクエストは468件で、.env などを狙う認証情報スキャンが991件でした。GPTBot を名乗るリクエストのうち、Cloudflare が OpenAI だと検証できたのは13%だけです。

Cloudflare 無料プランで偽装を分ける方法と、実測の数字をまとめます。

User-Agent はヘッダなので、名乗りは証拠にならない

GPTBot/1.2 と書けば誰でもそう名乗れます。なので UA 別の集計表は「主張の一覧」であって、実体の一覧ではありません。

Cloudflare を通しているなら、突き合わせる材料が2つあります。

  1. verifiedBotCategory — Cloudflare が IP と逆引きで検証した結果。空文字なら未検証
  2. リクエストされたパスがサイトマップに実在するか — 記事の消費と、robots.txt 等のクロール作業を分けるため

この2軸で残るのが「検証済みの bot が実在ページを取った件数」で、これだけが報告に使える数字になります。

取得クエリ(無料プランで通る)

httpRequestsAdaptiveGroups の dimensions に userAgentverifiedBotCategory を並べるだけです。clientAsnbotClass は有料プランの権限ですが、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-externalagentApplebot の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件だった

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?