给 AI 爬虫单列规则时,别漏掉公共限制
文章通过 robots.txt 实例解释:爬虫匹配到更具体的 User-Agent 组后,不会自动继承通配符组的限制。因此,为 AI 爬虫单列分组时,需要同步写入适用的公共规则。
完整中文译文
Nakodo 的 robots.txt 只有 30 行文本,由 34 行 TypeScript 生成,其中一半以上都是同一套规则重复写了两遍。这种重复恰恰是这个文件唯一正确的写法,我也是读过规范之后才相信这一点。
下面是线上正在使用的完整内容:
User-Agent: *
Allow: /
Disallow: /campaigns
Disallow: /settings
Disallow: /onboarding
Disallow: /billing
Disallow: /api/
Disallow: /auth/
User-Agent: GPTBot
User-Agent: OAI-SearchBot
User-Agent: ChatGPT-User
User-Agent: ClaudeBot
User-Agent: Claude-SearchBot
User-Agent: Claude-User
User-Agent: PerplexityBot
User-Agent: Perplexity-User
User-Agent: Google-Extended
User-Agent: Applebot-Extended
User-Agent: CCBot
Allow: /
Disallow: /campaigns
Disallow: /settings
Disallow: /onboarding
Disallow: /billing
Disallow: /api/
Disallow: /auth/
Sitemap: https://nakodo.app/sitemap.xml
规则组只会被选中,不会合并
RFC 9309 中有一条规则,让这个文件看起来有些冗余:爬虫会找到 User-Agent 行与自身匹配得最具体的规则组,遵守这一组规则,并忽略文件中的其他所有规则组,包括通配符规则组。
所以,下面这种写法是错的,而我最初就是这么写的:
User-Agent: *
Disallow: /campaigns
User-Agent: GPTBot
Allow: /
它的意思并不是「所有爬虫都不能访问 /campaigns,而 GPTBot 还可以访问网站的其他部分」,而是 GPTBot 的禁止访问列表为空,可以访问整个网站,包括原本由通配符规则组保护的页面。点名一个爬虫后,如果给它写的限制比通配符规则组少,实际上就是给了它更多访问权限。
这就是为什么这些规则要原样重复一遍。在源代码里,它们只有一份:
import { PRIVATE_PREFIXES } from "@/lib/public-paths";
// The app behind sign-in has nothing for a crawler; everything else is open.
// Links with tracking parameters stay crawlable on purpose: every page sets a
// canonical URL, and a crawler has to fetch the page to see it.
const DISALLOW = [...PRIVATE_PREFIXES, "/auth/"];
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{ userAgent: "*", allow: "/", disallow: DISALLOW },
{ userAgent: AI_CRAWLERS, allow: "/", disallow: DISALLOW },
],
sitemap: absoluteUrl("/sitemap.xml"),
};
}
Next 的 MetadataRoute.Robots 接受字符串或字符串数组作为 userAgent。数组会生成一个包含 11 行 User-Agent 的规则组,也就是 RFC 9309 所说的包含多个 product token 的规则组:这些标识共用一套规则。这样,文件里只需写 11 行,而不必写成 11 个各有 8 行的规则组。同时,两套策略也不可能逐渐出现偏差,因为它们引用的是同一个常量。
策略完全一样,为什么还要点名
通配符规则组已经允许访问应用之外的所有内容。这 11 个爬虫中的任何一个,通过 * 都能获得同样的访问权限。所以,这个规则组对文件当前的实际行为没有任何影响,但我还是保留了它:
// AI search and assistant crawlers, named so the policy toward them is a
// decision on record rather than an accident of the wildcard. A crawler obeys
// only the most specific group that names it, so each gets the full policy.
const AI_CRAWLERS = [
"GPTBot",
"OAI-SearchBot",
"ChatGPT-User",
"ClaudeBot",
"Claude-SearchBot",
"Claude-User",
"PerplexityBot",
"Perplexity-User",
"Google-Extended",
"Applebot-Extended",
"CCBot",
];
用通配符允许 AI 爬虫访问,和从来没考虑过 AI 爬虫,在外界看来没有区别。明确列出名称的规则组则代表一种立场:有人认真写下了这些名字。以后要改变立场,只需要修改数组中的一行,而不用争论通配符当初到底想表达什么。Nakodo 的公开页面就是供人阅读和引用的;潜在客户向助手询问创作者外联相关问题时,助手引用我们的页面,正是我们想要的结果。所以,这个数组真正表达的是:「没错,我们就是有意允许的。」
这 11 个名称并不都属于同一类,复制这份列表之前,值得先弄清楚:
- **训练与语料爬虫:**GPTBot、ClaudeBot、CCBot。它们抓取页面,用来构建数据集。
- **搜索索引爬虫:**OAI-SearchBot、Claude-SearchBot、PerplexityBot。它们构建索引,供助手回答问题时搜索。
- **用户触发的抓取客户端:**ChatGPT-User、Claude-User、Perplexity-User。它们之所以抓取页面,是因为有人在聊天中问到了这个页面,每次抓取一个 URL。
- **两个根本不是爬虫的标识:**Google-Extended 和 Applebot-Extended。没有任何抓取客户端会把这两个字符串作为 user agent 发送。它们是控制标识:Google 和 Apple 会从 robots.txt 中读取它们,判断普通爬虫已经获取的内容是否可以用于 AI 功能。要表达允许或禁止,只能通过包含这些名称的规则组,而其中的 Allow: / 就代表允许。
最后这一类,是我原本会理解错的地方。禁止 Google-Extended,并不会阻止 Googlebot 抓取任何内容;允许它,也不会给服务器增加哪怕一次请求。它表达的是一项使用许可,只不过放在了一个其他行都在规定抓取行为的文件里。
禁止访问列表,是从另一侧看登录边界
这 5 个路径并不是直接写在 robots.ts 里的,而是来自负责判断哪些路径需要会话的模块:
// Where robots.txt sends crawlers away from: the signed-in app. The auth pages
// are left crawlable so crawlers can read their noindex tag.
export const PRIVATE_PREFIXES = ["/campaigns", "/settings", "/onboarding", "/billing", "/api/"];
其中有两个细节,我花了一段时间才确定下来。
/auth/ 在 robots.ts 中追加,但登录页面本身并没有被禁止抓取。在 robots.txt 中屏蔽一个页面,同时又给它加上 noindex,是两条互相冲突的指令:爬虫如果不被允许抓取页面,就永远读不到要求它不要索引该页面的标签。因此,一个既被屏蔽又标记了 noindex 的 URL,仍然可能以只有链接、没有摘要的形式出现在搜索结果中。所以,登录页面保留可抓取状态,改用标签控制索引:
// Sign-in pages stay out of search results. robots.txt leaves them crawlable so
// crawlers can read this.
export const metadata = { robots: { index: false, follow: true } };
你可以在终端验证:运行 curl -s https://nakodo.app/login | grep robots,会输出 name="robots" content="noindex, follow"。
另外,这些前缀都没有尾部斜杠。robots.txt 使用的是字面前缀匹配,并不是按路径段匹配,所以 Disallow: /campaigns 也会覆盖假设存在的 /campaigns-public。对这个网站来说,这正是我想要的行为。唯一需要区分这两种匹配方式的 URL 空间是 API,因此它写成带斜杠的形式:/api/ 需要屏蔽其下的所有内容,而 /api 本身没有页面。
用测试确保两条边界保持一致
这里真正容易出问题的,并不是爬虫进入应用,而是公开页面被当成私有页面,而且这种问题悄无声息:已登录用户访问页面时一切正常,爬虫却会被重定向到登录表单。因此,同一个模块里的测试会遍历 sitemap:
test("every page in the sitemap is open to signed-out visitors", () => {
for (const path of [...STATIC_PAGES.map((p) => p.path), ...ENTRIES.map((e) => e.path)]) {
assert.ok(isPublicPath(path), `${path} would redirect crawlers to /login`);
}
for (const path of ["/opengraph-image", ...CARD_PATHS.map((p) => `/og${p}`)]) {
assert.ok(isPublicPath(path), `${path}: social cards must be public for link previews`);
}
});
测试的后半部分之所以存在,是因为链接预览本质上是一次不带登录身份的图片路由请求:如果社交分享卡片被会话检查拦住,每个分享链接的预览图都会变成登录表单,而你在应用内部根本发现不了这个问题。
爬虫进来之后能拿到什么
允许助手抓取,只完成了一半。我们还专门为这些客户端,通过 llms.txt 和 llms-full.txt 提供页面的 Markdown 版本。它们与 HTML 页面由同一组数组生成,所以助手引用的句子,和读者看到的句子完全相同。
有一个地方,声明自己是 bot 确实会改变响应,但它不在 robots 文件里。价格会在每次请求时,按照访客使用的货币渲染;只要请求看起来来自爬虫,就会使用基础货币,而不是数据中心所在地的货币:
// Crawlers get pounds: they're placed wherever their servers are, and search
// results should show the base price, not a datacentre's currency.
export async function displayCurrency(): Promise<Currency> {
if (BOT.test((await headers()).get("user-agent") ?? "")) return BASE_CURRENCY;
return currencyForCountry(await requestCountry());
}
一行命令就能验证:
$ curl -s -A GPTBot https://nakodo.app/pricing | grep -oE '£[0-9]+' | sort -u
£0
£39
£119
这些就是定价页面上真实的套餐价格。如果用欧元区的浏览器访问同一个 URL,返回的仍是这些数字,但货币会变成欧元。
这个文件刻意写得平淡无奇,但仍然需要做出三个决定:重复规则,因为规则组只会被选中,不会合并;点名 AI 客户端,因为通配符无法记录一个明确的决定;把禁止访问列表放在已经知道哪些内容属于私有内容的模块里,确保 robots.txt 与登录检查永远不会各说各话。文件里那行没人争论的 Sitemap:,反倒指向了被抓取次数最多的地址。
如需采取进一步措施,可以考虑屏蔽此人和/或举报滥用行为。