IT技術ブログ

AIクローラーの本物と偽物を見分ける実装。OpenAI・Anthropic・Perplexity・Googleが公開しているIPアドレスの範囲と照らし合わせるモジュールを、Cloudflare Pages Functions向けに書き、実際の範囲と偽のIPでテストした

GPTBotなどを名乗るアクセスが本物かを、各社が公開するIP範囲と照らして見分けるモジュールです。範囲の取り方、IPv4とIPv6の照合、手元で流したテストと結果、記録するときの個人情報の扱いを書きます。

この記事の結論:User-Agentに「GPTBot」や「ClaudeBot」と書いてあっても、本物のクローラーとは限りません。OpenAI・Anthropic・Perplexity・Googleはどれも、クローラーが使うIPアドレスの範囲をJSONで公開しているので、名乗りとアクセス元のIPアドレスを照らし合わせれば見分けられます。この記事では、7つの一覧(合わせて644件の範囲)を1つのファイルにまとめるスクリプトと、IPv4とIPv6の範囲を照らす依存の無いモジュールを書き、手元のNodeのテスト13件と、wrangler pages devでの動作を確かめました。このモジュールは、この記事の時点では、このブログの本番には入れていません。

このブログ(やまやまブログ)では、Cloudflare Pages Functionsのミドルウェアに、AIのクローラーなどの訪問を記録する処理を入れています(ミドルウェアの記事に全文を載せました)。ただ、今の記録はUser-Agentの名乗りで分けているだけなので、「GPTBotを名乗った誰か」と「本物のGPTBot」を区別できません。会社のコラム「robots.txtとは。書き方の例と確かめ方、AIクローラーを止めるべきか」でも、名乗るだけの確認では本物かどうかは分からないと書いています。この記事は、その先の「IPアドレスで確かめる」部分を、コードにした記録です。コードとテストは2026年10月5日に、手元のMacで実際に流しています。

各社が公開しているIPアドレスの一覧

2026年10月5日の時点で、調べた4社はすべて、クローラーのIPアドレスの範囲を公開していました。場所は、各社のクローラーの説明ページに書かれています。

会社 一覧のURL 対象 取得した件数 一覧の作成日時(creationTime)
Google https://developers.google.com/static/crawling/ipranges/common-crawlers.json Googlebotなど一般的なクローラー 317件(IPv4 170・IPv6 147) 2026-10-02
OpenAI https://openai.com/gptbot.json GPTBot 18件 2026-09-22
OpenAI https://openai.com/searchbot.json OAI-SearchBot 39件 2026-01-02
OpenAI https://openai.com/chatgpt-user.json ChatGPT-User 230件 2026-09-25
Anthropic https://claude.com/crawling/bots.json Anthropicのクローラー(3つの名前で共通) 28件 2026-10-02
Perplexity https://www.perplexity.ai/perplexitybot.json PerplexityBot 8件 2025-02-07
Perplexity https://www.perplexity.ai/perplexity-user.json Perplexity-User 4件 2025-10-17

件数はIPv4とIPv6を合わせたもので、Google以外はIPv4だけでした。どの一覧も、prefixes の配列に ipv4Prefix か ipv6Prefix をCIDRの形(20.171.206.0/24 など)で並べた、同じ形のJSONです。

取りに行ったときに気づいたことが3つあります。

  • Googleの一覧のURLが変わっていた。 以前の https://developers.google.com/static/search/apis/ipranges/googlebot.json を取ると、301で common-crawlers.json に転送されました。Googleの今の説明ページも、common-crawlers.json を案内しています。古いURLを書いたままのスクリプトは、転送をたどらないと取れません
  • Perplexityの一覧は、説明ページのURLと実際の置き場所が違う。 説明ページには https://www.perplexity.com/perplexitybot.json と書かれていますが、取ると302で https://www.perplexity.ai/perplexitybot.json に転送されました
  • OpenAIには、もう1つ一覧がある。 広告として出すページの安全を確かめる OAI-AdsBot の https://openai.com/adsbot.json です。AI検索や学習とは目的が違うので、この記事のモジュールには入れていません

Anthropicの一覧について、Anthropicのヘルプには「送信元のIPアドレスがこの一覧にあれば、Anthropicから来たクローラーだ」という趣旨が書かれています。ClaudeBot・Claude-User・Claude-SearchBotで一覧は分かれていないので、3つの名前とも同じ一覧で照らします。なお、AnthropicのヘルプにはUser-Agentの全文は載っておらず、robots.txtに書く名前(ClaudeBot など)だけが書かれていました。

Googleについては、もう1つの方法として、DNSの逆引きと正引きで確かめる方法も案内されています。1回限りの確認ならコマンドラインのツールでDNSを引き、大規模に確かめるなら自動で一覧と照らす、という位置づけです。

名乗りの判定と、範囲の照合は分けて考える

判定は2段に分けています。1段目でUser-Agentから「何を名乗っているか」を決め、2段目で「その会社の一覧に、アクセス元のIPアドレスが入っているか」を見ます。結果は次の3通りです。

User-Agent IPアドレス 判定 記録するか
クローラーを名乗っていない (見ない) null しない
名乗っている 名乗った会社の一覧の中 verified: true(本物) する
名乗っている 名乗った会社の一覧の外 verified: false(名乗りだけ) する

User-Agentを先に見るのは、記録する対象を絞るためです。人のアクセスはここで落ちるので、IPアドレスの照合にも記録にも進みません。

また、照合は「名乗った会社の一覧」だけで行います。たとえばGPTBotを名乗ってAnthropicの範囲から来たアクセスは、どちらの会社のクローラーとしても本物とは言えないので、偽物として扱います。

一覧を1つのファイルにまとめるスクリプト

一覧は、ビルドの前に取ってきて、1つのJSONファイルにまとめます。Pages Functionsの中で毎回取りに行くと、そのたびに外部への通信が増え、取れなかったときの扱いも難しくなるためです。

どれか1つでも取れなかったり、範囲が0件だったりしたら、ファイルを書き出さずに止めます。前回のファイルが残るので、一覧が空のまま本番に出て、すべての訪問が偽物と判定される事故を防げます。

fetch-ranges.mjsjs
// 各社が公開しているクローラーのIP範囲(JSON)を取ってきて、1つのファイル bot-ranges.json にまとめる
// 使い方: node fetch-ranges.mjs   (ビルドの前に流し、できたファイルを Functions から import する)
import { writeFile } from "node:fs/promises";

const SOURCES = {
  google: "https://developers.google.com/static/crawling/ipranges/common-crawlers.json",
  gptbot: "https://openai.com/gptbot.json",
  "oai-searchbot": "https://openai.com/searchbot.json",
  "chatgpt-user": "https://openai.com/chatgpt-user.json",
  anthropic: "https://claude.com/crawling/bots.json",
  perplexitybot: "https://www.perplexity.ai/perplexitybot.json",
  "perplexity-user": "https://www.perplexity.ai/perplexity-user.json",
};

const out = {};
for (const [key, url] of Object.entries(SOURCES)) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`${key}: HTTP ${res.status}(前回のファイルを残すため、書き出さずに止める)`);
  const json = await res.json();
  const prefixes = (json.prefixes ?? []).map((p) => p.ipv4Prefix ?? p.ipv6Prefix).filter(Boolean);
  if (prefixes.length === 0) throw new Error(`${key}: 範囲が0件(形式が変わった可能性)`);
  out[key] = { source: url, creationTime: json.creationTime ?? "", prefixes };
  console.log(`${key.padEnd(16)} ${String(prefixes.length).padStart(4)}件  ${json.creationTime ?? ""}`);
}
await writeFile(new URL("./bot-ranges.json", import.meta.url), JSON.stringify(out));
node fetch-ranges.mjs の出力(2026年10月5日)
google            317件  2026-10-02T14:46:11.000000
gptbot             18件  2026-09-22T02:00:07.000000
oai-searchbot      39件  2026-01-02T11:00:00.000000
chatgpt-user      230件  2026-09-25T18:04:57.257335
anthropic          28件  2026-10-02T00:00:00Z
perplexitybot       8件  2025-02-07T16:56:00.000000
perplexity-user     4件  2025-10-17T10:17:00.000000

できたファイルは13,823バイトでした。

照合するモジュール

IPアドレスとCIDRの範囲を、どちらもBigIntの数に直して、マスクをかけて比べます。IPv4は32ビット、IPv6は128ビットの数になるので、同じ書き方で両方を扱えます。外部のパッケージは使っていないので、Pages Functions(Workers)でもNodeでも、同じファイルがそのまま動きます。

bot-verify.jsjs
// User-Agent で名乗ったクローラーが、その会社の公開しているIP範囲から来ているかを確かめる
// Cloudflare Pages Functions(Workers)でも Node でも、そのまま動く(依存なし)

// 名乗り(User-Agent)と、照合に使う範囲の対応。上から順に見る
export const BOTS = [
  { name: "OAI-SearchBot", ua: /oai-searchbot/i, ranges: ["oai-searchbot"] },
  { name: "ChatGPT-User", ua: /chatgpt-user/i, ranges: ["chatgpt-user"] },
  { name: "GPTBot", ua: /gptbot/i, ranges: ["gptbot"] },
  // Anthropic は ClaudeBot・Claude-User・Claude-SearchBot の3つで1つのリスト
  { name: "Claude-SearchBot", ua: /claude-searchbot/i, ranges: ["anthropic"] },
  { name: "Claude-User", ua: /claude-user/i, ranges: ["anthropic"] },
  { name: "ClaudeBot", ua: /claudebot/i, ranges: ["anthropic"] },
  { name: "Perplexity-User", ua: /perplexity-user/i, ranges: ["perplexity-user"] },
  { name: "PerplexityBot", ua: /perplexitybot/i, ranges: ["perplexitybot"] },
  { name: "Googlebot", ua: /googlebot/i, ranges: ["google"] },
];

// "1.2.3.4" や "2001:db8::1" を [版, BigInt] にする。読めなければ null
export function parseIp(ip) {
  if (typeof ip !== "string") return null;
  ip = ip.trim();
  // IPv4 を IPv6 の形で書いたもの(::ffff:1.2.3.4)は IPv4 として扱う
  const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(ip);
  if (mapped) ip = mapped[1];
  if (/^\d+\.\d+\.\d+\.\d+$/.test(ip)) {
    const p = ip.split(".").map(Number);
    if (p.some((n) => n > 255)) return null;
    return [4, p.reduce((acc, n) => (acc << 8n) + BigInt(n), 0n)];
  }
  if (!ip.includes(":") || !/^[0-9a-f:]+$/i.test(ip)) return null;
  const halves = ip.split("::");
  if (halves.length > 2) return null;
  const head = halves[0] ? halves[0].split(":") : [];
  const tail = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
  const fill = halves.length === 2 ? 8 - head.length - tail.length : 0;
  if (fill < 0 || (halves.length === 1 && head.length !== 8)) return null;
  const groups = [...head, ...Array(fill).fill("0"), ...tail];
  if (groups.some((g) => g.length === 0 || g.length > 4)) return null;
  return [6, groups.reduce((acc, g) => (acc << 16n) + BigInt(parseInt(g, 16)), 0n)];
}

// "20.171.206.0/24" を { v, net, mask } にする
function parseCidr(cidr) {
  const [addr, bitsStr] = cidr.split("/");
  const parsed = parseIp(addr);
  if (!parsed) return null;
  const [v, n] = parsed;
  const total = v === 4 ? 32 : 128;
  const bits = bitsStr === undefined ? total : Number(bitsStr);
  if (!Number.isInteger(bits) || bits < 0 || bits > total) return null;
  const mask = bits === 0 ? 0n : ((1n << BigInt(bits)) - 1n) << BigInt(total - bits);
  return { v, net: n & mask, mask };
}

// bot-ranges.json の形 { key: { prefixes: [...] } } を、照合しやすい形に一度だけ変換する
export function compileRanges(json) {
  const table = {};
  for (const [key, { prefixes }] of Object.entries(json)) {
    table[key] = prefixes.map(parseCidr).filter(Boolean);
  }
  return table;
}

export function ipInRanges(ip, list) {
  const parsed = parseIp(ip);
  if (!parsed || !list) return false;
  const [v, n] = parsed;
  return list.some((r) => r.v === v && (n & r.mask) === r.net);
}

// 戻り値: null(クローラーを名乗っていない)か、{ bot, verified }
// verified: true=公開範囲の中から来た、false=名乗っているのに範囲の外から来た
export function classify(ua, ip, table) {
  const hit = BOTS.find((b) => b.ua.test(ua || ""));
  if (!hit) return null;
  const verified = hit.ranges.some((key) => ipInRanges(ip, table[key]));
  return { bot: hit.name, verified };
}

作りで気をつけた点は4つです。

  • 一覧の変換は1回だけ。 compileRanges で、CIDRの文字列を数とマスクに直したものを作っておき、リクエストごとには比べるだけにしています
  • ::ffff:1.2.3.4 はIPv4として扱う。 IPv4のアドレスをIPv6の形で書いたものです。そのまま比べると、IPv4の一覧に当たらなくなります
  • 読めない値は偽物にする。 空の文字列や 999.1.1.1 のような値でも、例外は投げずに verified: false を返します
  • 名前は上から順に見る。 最初に当たった名前を使います。いまの各社の名前どうしでは、一方が他方の一部になっているものはありませんが、名前を足すときは、長く具体的な名前を先に置くようにしています

実際の範囲と偽のIPアドレスで、手元のNodeでテストした

テストは、Nodeに組み込まれている node:test で書きました。上のスクリプトで実際に取ってきた一覧を読み込み、次のことを確かめます。

  • 7つの一覧の644件すべてについて、範囲の先頭と末尾のアドレスが「本物」になる
  • 名乗りが本物でも、文書の例に使うために予約されたアドレス(IPv4の 192.0.2.0/24・198.51.100.0/24・203.0.113.0/24、IPv6の 2001:db8::/32)から来たら「偽物」になる
  • 別の会社の範囲から来たら「偽物」になる
  • 範囲のすぐ外(1つ前と1つ後)は「偽物」になる
  • クローラーを名乗っていなければ null になる
  • 壊れた入力でも例外を投げない
bot-verify.test.mjsjs
// 実際に取ってきた bot-ranges.json と、偽物のIP(文書用に予約されたアドレス)で確かめる
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { parseIp, compileRanges, ipInRanges, classify } from "./bot-verify.js";

const raw = JSON.parse(await readFile(new URL("./bot-ranges.json", import.meta.url), "utf8"));
const table = compileRanges(raw);

// 範囲の先頭と末尾のアドレスを文字列で作る(末尾=ホスト部をすべて1にしたもの)
function edges(cidr) {
  const [addr, bits] = cidr.split("/");
  const [v, n] = parseIp(addr);
  const total = v === 4 ? 32 : 128;
  const last = n | ((1n << BigInt(total - Number(bits))) - 1n);
  const fmt = (x) => v === 4
    ? [24n, 16n, 8n, 0n].map((s) => String((x >> s) & 255n)).join(".")
    : Array.from({ length: 8 }, (_, i) => ((x >> BigInt(112 - 16 * i)) & 0xffffn).toString(16)).join(":");
  return [fmt(n), fmt(last)];
}

// UA の全文は、各社のページに載っているもの
const UA = {
  GPTBot: "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; GPTBot/1.4; +https://openai.com/gptbot",
  "OAI-SearchBot": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36; compatible; OAI-SearchBot/1.4; +https://openai.com/searchbot",
  "ChatGPT-User": "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; ChatGPT-User/1.0; +https://openai.com/bot",
  // Anthropic のページには UA の全文が載っていないので、名前だけを入れた仮の文字列
  ClaudeBot: "Mozilla/5.0 (compatible; ClaudeBot)",
  PerplexityBot: "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)",
  "Perplexity-User": "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Perplexity-User/1.0; +https://perplexity.ai/perplexity-user)",
  Googlebot: "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
};
const KEY = { GPTBot: "gptbot", "OAI-SearchBot": "oai-searchbot", "ChatGPT-User": "chatgpt-user", ClaudeBot: "anthropic", PerplexityBot: "perplexitybot", "Perplexity-User": "perplexity-user", Googlebot: "google" };

// 公開されているすべての範囲について、先頭と末尾のアドレスが「本物」になること
for (const [bot, key] of Object.entries(KEY)) {
  test(`${bot}: 公開範囲 ${raw[key].prefixes.length} 件の先頭と末尾がすべて本物になる`, () => {
    for (const cidr of raw[key].prefixes) {
      for (const ip of edges(cidr)) {
        assert.deepEqual(classify(UA[bot], ip, table), { bot, verified: true }, `${cidr} の ${ip}`);
      }
    }
  });
}

test("名乗りは本物でも、文書用のアドレスから来たら偽物になる", () => {
  for (const ip of ["192.0.2.10", "198.51.100.7", "203.0.113.45", "2001:db8::1"]) {
    for (const bot of Object.keys(UA)) assert.equal(classify(UA[bot], ip, table).verified, false, `${bot} ${ip}`);
  }
});

test("別の会社の範囲から来たら偽物になる(GPTBotを名乗ってAnthropicの範囲から)", () => {
  const [anthropicIp] = edges(raw.anthropic.prefixes[0]);
  assert.equal(classify(UA.GPTBot, anthropicIp, table).verified, false);
  const [gptIp] = edges(raw.gptbot.prefixes[0]);
  assert.equal(classify(UA.ClaudeBot, gptIp, table).verified, false);
});

test("範囲のすぐ外は偽物になる(gptbot.json の 20.125.66.80/28 の前後)", () => {
  assert.equal(ipInRanges("20.125.66.79", table.gptbot), false);
  assert.equal(ipInRanges("20.125.66.80", table.gptbot), true);
  assert.equal(ipInRanges("20.125.66.95", table.gptbot), true);
  assert.equal(ipInRanges("20.125.66.96", table.gptbot), false);
});

test("IPv4 を IPv6 の形(::ffff:)で書いても同じ結果になる", () => {
  const [ip] = edges(raw.gptbot.prefixes[0]);
  assert.equal(classify(UA.GPTBot, `::ffff:${ip}`, table).verified, true);
});

test("クローラーを名乗っていなければ null(記録しない)", () => {
  const chrome = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1";
  assert.equal(classify(chrome, "203.0.113.45", table), null);
  assert.equal(classify("", "203.0.113.45", table), null);
});

test("壊れた入力でも例外を投げず、偽物として扱う", () => {
  for (const ip of ["", "999.1.1.1", "1.2.3", "2001:db8:::1", "hello", null, undefined]) {
    assert.equal(classify(UA.GPTBot, ip, table).verified, false, String(ip));
  }
});
node --test --test-reporter=spec bot-verify.test.mjs の出力(2026年10月5日)
✔ GPTBot: 公開範囲 18 件の先頭と末尾がすべて本物になる (1.3805ms)
✔ OAI-SearchBot: 公開範囲 39 件の先頭と末尾がすべて本物になる (0.813792ms)
✔ ChatGPT-User: 公開範囲 230 件の先頭と末尾がすべて本物になる (2.566583ms)
✔ ClaudeBot: 公開範囲 28 件の先頭と末尾がすべて本物になる (0.279791ms)
✔ PerplexityBot: 公開範囲 8 件の先頭と末尾がすべて本物になる (0.114166ms)
✔ Perplexity-User: 公開範囲 4 件の先頭と末尾がすべて本物になる (0.047208ms)
✔ Googlebot: 公開範囲 317 件の先頭と末尾がすべて本物になる (3.125875ms)
✔ 名乗りは本物でも、文書用のアドレスから来たら偽物になる (0.28775ms)
✔ 別の会社の範囲から来たら偽物になる(GPTBotを名乗ってAnthropicの範囲から) (0.244833ms)
✔ 範囲のすぐ外は偽物になる(gptbot.json の 20.125.66.80/28 の前後) (0.255834ms)
✔ IPv4 を IPv6 の形(::ffff:)で書いても同じ結果になる (0.099667ms)
✔ クローラーを名乗っていなければ null(記録しない) (0.038792ms)
✔ 壊れた入力でも例外を投げず、偽物として扱う (0.056375ms)
ℹ tests 13
ℹ suites 0
ℹ pass 13
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 67.993167

テストがちゃんと間違いを見つけるかも確かめました。bot-verify.js の照合の行を、マスクをかけずに比べる形(n === r.net)にわざと壊したコピーで同じテストを流すと、13件のうち8件が失敗しました。範囲の先頭のアドレスは壊れた版でも一致してしまうので、末尾のアドレスまで確かめるテストが効いています。

速さも測りました。いちばん時間がかかるのは、Googlebotを名乗って一覧の外から来る場合で、Googleの317件をすべて見ることになります。手元のMac(Node.js 22.23.2)で10万回くり返すと、1回あたりIPv4で0.9マイクロ秒、IPv6で1.7マイクロ秒でした。

bench.mjsjs
// いちばん時間のかかる形(Googlebot を名乗って範囲の外から来る=317件をすべて見る)を10万回
import { readFile } from "node:fs/promises";
import { compileRanges, classify } from "./bot-verify.js";
const table = compileRanges(JSON.parse(await readFile(new URL("./bot-ranges.json", import.meta.url), "utf8")));
const ua = "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)";
for (const ip of ["203.0.113.45", "2001:db8::1"]) {
  const N = 100_000, t0 = performance.now();
  for (let i = 0; i < N; i++) classify(ua, ip, table);
  const ms = performance.now() - t0;
  console.log(`${ip.padEnd(14)} 1回あたり ${((ms / N) * 1000).toFixed(1)} マイクロ秒`);
}
node bench.mjs の出力(2026年10月5日、3回流して3回とも同じ値)
203.0.113.45   1回あたり 0.9 マイクロ秒
2001:db8::1    1回あたり 1.7 マイクロ秒

Workersの上での速さは測っていませんが、クローラーを名乗ったリクエストだけで動く処理なので、件数の面で困ることはないと考えています。

Googlebotは、DNSの方法でも同じ結果になった

Googleが案内している2つの方法が同じ答えになるかを、1つのIPアドレスで確かめました。一覧に入っている 66.249.66.1 について、手元のMacで host コマンドを使い、逆引きと正引きをしています。

host コマンドの出力(2026年10月5日)
$ host 66.249.66.1
1.66.249.66.in-addr.arpa domain name pointer crawl-66-249-66-1.googlebot.com.
$ host crawl-66-249-66-1.googlebot.com
crawl-66-249-66-1.googlebot.com has address 66.249.66.1
$ host 203.0.113.45
Host 45.113.0.203.in-addr.arpa. not found: 3(NXDOMAIN)

逆引きの結果が googlebot.com で終わり、その名前を正引きすると元のIPアドレスに戻るので、Googleの説明の手順どおり本物と判断できます。モジュールでも 66.249.66.1 は一覧の中と判定され、2つの方法は一致しました。文書用のアドレス 203.0.113.45 は、逆引きの結果がありませんでした。

Pages Functionsで読み込めるかを、wrangler pages devで確かめた

最後に、Pages Functionsの中で、このモジュールとJSONのファイルを読み込めるかを確かめました。手元に空のサイトを作り、次のミドルウェアを置いて、wrangler pages devで動かしています。応答は変えず、クローラーを名乗ったリクエストだけ、判定の結果をログに出すものです。

functions/_middleware.js(手元の確認用)js
// 手元の確認用:クローラーを名乗ったリクエストだけ、判定結果をログに出す(応答は変えない)
import { compileRanges, classify } from "./bot-verify.js";
import ranges from "./bot-ranges.json";

const table = compileRanges(ranges); // 起動時に1回だけ変換する

export async function onRequest({ request, next }) {
  const res = await next();
  try {
    const result = classify(request.headers.get("user-agent"), request.headers.get("cf-connecting-ip"), table);
    if (result) {
      console.log(JSON.stringify({ ...result, path: new URL(request.url).pathname, ip: request.headers.get("cf-connecting-ip") }));
    }
  } catch (_) {
    // 判定の失敗は応答に関係させない
  }
  return res;
}
手元で動かしたコマンドsh
npx wrangler pages dev public --port 8837 --compatibility-date 2026-10-01
# 別の端末から
G="Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; GPTBot/1.4; +https://openai.com/gptbot"
curl -s -o /dev/null -w "%{http_code}\n" -A "$G" localhost:8837/
curl -s -o /dev/null -w "%{http_code}\n" -A "$G" -H "CF-Connecting-IP: 20.171.206.10" localhost:8837/
curl -s -o /dev/null -w "%{http_code}\n" -A "Mozilla/5.0 (iPhone)" localhost:8837/
wrangler pages dev のログ(抜粋、2026年10月5日)
[wrangler:info] Ready on http://localhost:8837
[wrangler:info] GET / 200 OK (15ms)
{"bot":"GPTBot","verified":false,"path":"/","ip":"::1"}
[wrangler:info] GET / 200 OK (3ms)
{"bot":"GPTBot","verified":true,"path":"/","ip":"20.171.206.10"}
[wrangler:info] GET / 200 OK (2ms)
[wrangler:info] GET / 200 OK (2ms)

最初の GET / 200 は、起動を待つために名乗らずに送った確認の分です。JSONのファイルは、import ranges from "./bot-ranges.json" の形でそのまま読み込めました。手元から名乗っただけの1回目は、アクセス元が ::1(自分のMac)なので偽物、CF-Connecting-IP にGPTBotの範囲のアドレスを入れた2回目は本物、名乗っていない3回目はログに出ていません。3つとも応答は200で、判定が応答に影響していないことも確かめられました。

CF-Connecting-IP は、Cloudflareの説明では、Cloudflareに接続してきたクライアントのIPアドレスを伝えるヘッダーです。手元のwranglerでは、curlで付けた値がそのまま使われたので、テストのために本物の範囲のアドレスを入れています。本番でこのヘッダーがどう扱われるかは、この記事では確かめていません。

記録するときの、個人情報の扱い

名乗りだけのアクセスの中には、人が混ざります。自分や知り合いがcurlで確かめたアクセスも、名乗りだけの偽物として出てきます。そのため、記録するときは次のように決めています。

  • クローラーを名乗ったリクエストだけを記録する。 名乗っていない人のアクセスは、判定にも記録にも進みません
  • IPアドレスは丸めて残す。 このブログの今の記録の処理は、IPv4は /24、IPv6は /48 に丸めてから書く作りです。判定はリクエストを受けたその場で、丸める前のアドレスで行い、残すのは判定の結果(本物か名乗りだけか)と、丸めたアドレスだけにします
  • 保存期間を決めておく。 このブログの記録の書き先のWorkers Analytics Engineは、Cloudflareの説明では、書いたデータを3か月保存します。それより長く残したい集計は、ボット名と判定ごとの件数のように、個人に結びつかない形にしてから別に残します

本番に入れるときに決めること

この記事の時点では、このモジュールはこのブログの本番に入れていません。今の本番のミドルウェアは、User-Agentの名乗りでボットを分けて記録しているだけです。入れるときは、次のことを先に決めます。

  1. 一覧を更新する時機。 一覧のJSONには作成日時(creationTime)が入っていて、取得したときは2025年2月7日から2026年10月2日まで幅がありました。Perplexityの説明にも、公式の一覧の最新のものを使うよう書かれています。一覧が更新されたのに古いものを使い続けると、新しいアドレスから来た本物を偽物と判定します。ビルドのたびに fetch-ranges.mjs を流し、一覧の作成日時も記録に残す形を考えています
  2. 記録に足す項目。 今の記録に、判定の結果(true・false)と、照らした一覧の作成日時を足します
  3. 偽物と判定したアクセスをどうするか。 すぐに止めるのではなく、数週間記録して、件数と中身を見てから決めます。Anthropicのヘルプには、IPアドレスで止める方法は、robots.txtを読めなくするので、確実な拒否の方法にはならないと書かれています。Anthropicのヘルプが止め方として案内しているのは、robots.txtでの指定です。robots.txtの書き方とCloudflare側の設定の確かめ方は、AIクローラーのrobots.txt設定例の記事に書きました

Googleの説明には、Googleが一覧に無いIPアドレス(Apps Scriptなど)からサイトにアクセスすることがあり、その場合はGoogle全体のIPアドレスの一覧と照らすよう書かれています。このモジュールは、Googlebotを名乗ったアクセスを common-crawlers.json だけで照らしているので、そうしたアクセスは偽物に入ります。集計で「Googlebotの偽物」が多いときは、この点を先に疑います。

このモジュールでは分からないこと

IPアドレスで分かるのは、「その会社が公開している範囲から来たか」までです。次のことは分かりません。

  • 一覧に無い新しいアドレスから来た本物(一覧が更新される前の時間差)
  • 名乗っていないクローラー(User-Agentを人のブラウザに似せたもの)
  • 本物のクローラーが、そのページを何に使ったか(学習か、検索か、利用者の依頼か)。これは名前で分かれているので、名前ごとの各社の説明を見ます

会社の一覧を使った判定は、名乗りを一つずつ疑うための道具です。どのクローラーを受け入れるかの方針は、robots.txtを含めて別に決めます。

株式会社bundlyzeでは、Webサイトや業務システムの公開環境の構築から、監視の仕組みや運用の手順の整理まで、一緒に進めています。

動作確認した環境

確認日は2026年10月5日です。

  • macOS 26.6.2、Node.js 22.23.2(node:test、組み込みの fetch)
  • wrangler 4.147.0(pages dev、互換性の日付は 2026-10-01)
  • curl 8.7.1、host コマンド(macOS 付属)
  • 一覧は、上の表の7つのURLから2026年10月5日に取得したもの

確かめていないこと:

  • Cloudflareの本番(Pages Functions)での動作と速さ。このモジュールは本番に入れていません
  • 本番での CF-Connecting-IP の扱い(手元のwranglerでの値の入り方だけを確かめました)
  • このブログに実際に来たクローラーのうち、名乗りだけのものがどのくらいあるか(本番に入れてから数えます)
  • IPv4を埋め込んだIPv6の書き方のうち、::ffff:1.2.3.4 以外の形(モジュールは読めない値として偽物にします)

参照した公式ドキュメント(確認日:2026年10月5日)

  • OpenAIのクローラー(OAI-SearchBot・OAI-AdsBot・GPTBot・ChatGPT-User)の目的、User-Agent、IPアドレスの一覧の場所(searchbot.json・adsbot.json・gptbot.json・chatgpt-user.json)、ChatGPT-Userは利用者の操作で動くのでrobots.txtが適用されないことがあること → OpenAI「Overview of OpenAI Crawlers」
  • Anthropicのクローラー(ClaudeBot・Claude-User・Claude-SearchBot)の目的、送信元のIPアドレスが bots.json の一覧にあればAnthropicから来たクローラーであること、IPアドレスで止める方法は確実な拒否にならないこと → Anthropic「Does Anthropic crawl data from the web, and how can site owners block the crawler?」
  • Perplexityのクローラー(PerplexityBot・Perplexity-User)の目的、User-Agent、IPアドレスの一覧の場所、User-AgentとIPアドレスの確認を組み合わせるよう勧めていること、公式の一覧の最新のものを使うこと → Perplexity「Perplexity Crawlers」
  • Googleのクローラーの確かめ方(逆引きと正引きのDNSで、googlebot.com・google.com・googleusercontent.com のいずれかであることを確かめる手順、IPアドレスの一覧との照合、一覧の種類と common-crawlers.json がGooglebotなど一般的なクローラーの一覧であること、一覧に無いIPアドレスから来ることがあること、JSONのIPアドレスはCIDRの形であること) → Google「Google からのリクエストを確認する」
  • GooglebotのUser-Agentの文字列 → Google「Google の一般的なクローラー」
  • CF-Connecting-IP がCloudflareに接続してきたクライアントのIPアドレスを伝えること → Cloudflare「Cloudflare HTTP headers」
  • Workers Analytics Engineに書いたデータの保存期間が3か月であること → Cloudflare「Analytics Engine の Limits」
  • 文書の例に使うために予約されたIPv4のアドレス(192.0.2.0/24・198.51.100.0/24・203.0.113.0/24) → RFC 5737
  • 文書の例に使うために予約されたIPv6のアドレス(2001:db8::/32) → RFC 3849

よくある質問

User-Agentに「GPTBot」と書いてあれば、OpenAIのクローラーだと考えてよいですか?

考えないほうが安全です。User-Agentは送る側が自由に書けるので、curlなどで誰でも名乗れます。OpenAI・Anthropic・Perplexity・Googleは、クローラーが使うIPアドレスの一覧をJSONで公開しているので、名乗りとアクセス元のIPアドレスの両方を照らし合わせて判断します。Perplexityの説明にも、User-AgentとIPアドレスの確認を組み合わせるよう書かれています。

AnthropicのClaudeBotは、IPアドレスの一覧を公開していますか?

公開しています。Anthropicのヘルプには、送信元のIPアドレスが https://claude.com/crawling/bots.json の一覧にあれば、Anthropicから来たクローラーだと書かれています。2026年10月5日に取得したときは、IPv4の範囲が28件入っていました。ClaudeBot・Claude-User・Claude-SearchBotで分かれてはおらず、1つの一覧です。

Googlebotは、IPアドレスの一覧とDNSのどちらで確かめればよいですか?

Googleはどちらの方法も案内しています。1回限りの確認なら逆引きと正引きのDNS、大規模に自動で確かめるなら公開されているIPアドレスの一覧との照合です。Cloudflare Pages Functionsの中でDNSを引くより、一覧と照らす方が簡単なので、この記事では一覧を使っています。手元で1つのIPアドレスについて両方を試したところ、結果は一致しました。

名乗りが偽物のアクセスは、すぐにブロックしたほうがよいですか?

まずは記録して、件数と中身を見てから決めることをおすすめします。一覧は更新されることがあるので、古い一覧のまま照らすと本物を偽物と判定することがあります。また、自分や知り合いがcurlで確かめた分も偽物として出ます。この記事のモジュールは判定と記録までで、ブロックはしていません。