Skip to content

Commit 07fbb22

Browse files
geo: 加 /llms.txt,AI 爬虫按用途分开放行
llms.txt(llmstxt.org 约定)给 AI 引擎一份无导航噪音的全站索引: 标题 + 描述 + 绝对链接,构建期从 source 枚举,和 sitemap 同源。 309 条(中英各半),只做索引不做全文 —— 全站正文拼进去几 MB, 反而挤爆它本来要省的上下文。 robots 按用途分开,不再一刀切: - 引用型(OAI-SearchBot / ChatGPT-User / Claude-User / PerplexityBot) 用户提问时实时取用并附出处链接,走 * 组放行。刻意不给它们开 UA 专属组 —— robots.txt 里专属组整体覆盖 * 而不是叠加,开了就得把 admin/editor 那串 disallow 再抄一遍,抄漏一条等于把后台放给它们。 - 训练型(GPTBot / ClaudeBot / Google-Extended / CCBot / Bytespider 等) 只收语料不给回链,内容是 CC BY-NC-SA,整站 disallow。 顺手删掉 public/robots.txt:那份手写文件把两类一起屏蔽,而且早就不生效 —— App Router 下 app/robots.ts 才是线上实际服务的 /robots.txt,它被静默 遮蔽,读代码的人却会以为 AI 爬虫已经挡住了。线上实测放行所有爬虫, 和文件里写的意图正好相反。 draft 过滤和 slug 编码从 app/sitemap.ts 抽到 lib/doc-entry.ts 共用: 两边各写一份的话,早晚出现 sitemap 过滤了草稿、llms.txt 没过滤, 草稿泄漏给 AI 引擎和泄漏给搜索引擎一样糟。 测试守的是策略不变量而不是实现:训练型必须被挡、引用型必须不在名单里。 两边错任何一边都不报错 —— 要么内容白送进训练集,要么再也拿不到 AI 引用。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 1d81164 commit 07fbb22

8 files changed

Lines changed: 426 additions & 76 deletions

File tree

app/llms.txt/route.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// app/llms.txt/route.ts
2+
3+
/**
4+
* @file app/llms.txt/route.ts
5+
* @description
6+
* `/llms.txt` 路由(llmstxt.org 约定)。AI 引擎抓站时先读它拿全站索引。
7+
*
8+
* 为什么是 route handler 而不是 public/llms.txt:文档是 MDX,会增删改,
9+
* 手维护一份静态索引必然过期。这里从 `source` 现枚举,和 sitemap 同源。
10+
*
11+
* force-static:内容全部来自构建期的 MDX,没有请求相关的东西,
12+
* 构建时产出一次即可,别让它变成每次请求现算的 dynamic 路由
13+
* (i18n/routing.ts 文件头记着上次全站 dynamic 把 Vercel CPU 打爆的事)。
14+
*
15+
* @see https://llmstxt.org
16+
*/
17+
18+
import type { PageData } from "@/app/types/doc";
19+
import { routing } from "@/i18n/routing";
20+
import { docPathname, isDraftOrHidden } from "@/lib/doc-entry";
21+
import { buildLlmsTxt, type LlmsTxtEntry } from "@/lib/llms-txt";
22+
import { SITE_URL } from "@/lib/site-url";
23+
import { source } from "@/lib/source";
24+
25+
export const dynamic = "force-static";
26+
27+
/** 分组小标题里 locale 的显示名,未知 locale 直接显示代码。 */
28+
const LOCALE_LABEL: Record<string, string> = { zh: "中文", en: "English" };
29+
30+
export function GET() {
31+
const entries: LlmsTxtEntry[] = [];
32+
33+
for (const locale of routing.locales) {
34+
for (const page of source.getPages(locale)) {
35+
// 和 sitemap 同一套过滤:草稿泄漏给 AI 引擎和泄漏给搜索引擎一样糟
36+
if (isDraftOrHidden(page)) continue;
37+
38+
const data = (page.data ?? {}) as PageData;
39+
// slugs[0] 是顶层分区(career / learn / projects),拿来当分组
40+
const topLevel = page.slugs[0] ?? "docs";
41+
42+
entries.push({
43+
pathname: `/${locale}${docPathname(page.slugs)}`,
44+
title: data.title ?? page.slugs.at(-1) ?? "Untitled",
45+
description: data.description,
46+
section: `${LOCALE_LABEL[locale] ?? locale} · ${topLevel}`,
47+
});
48+
}
49+
}
50+
51+
return new Response(buildLlmsTxt(entries, SITE_URL), {
52+
headers: { "content-type": "text/plain; charset=utf-8" },
53+
});
54+
}

app/robots.ts

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,27 +19,73 @@
1919
* sitemap 指向 app/sitemap.ts 产出的 /sitemap.xml,hostname 复用同一份
2020
* NEXT_PUBLIC_SITE_URL。
2121
*
22+
* AI 爬虫策略(2026-08):按用途分开,不是一刀切。
23+
*
24+
* - **引用型**(OAI-SearchBot、ChatGPT-User、Claude-User、PerplexityBot…):
25+
* 用户实时提问时才来抓,抓完在回答里附出处链接。本站要的就是被引用,
26+
* 所以放行 —— 走上面的 `*` 组即可,**不单独开组**。robots.txt 里 UA 专属
27+
* 组会整体覆盖 `*` 而不是叠加,给它们开组就得把 admin/editor 那串 disallow
28+
* 再抄一遍,抄漏一条就等于把后台开放给它们。少写一组反而更安全。
29+
*
30+
* - **训练型**(见 AI_TRAINING_CRAWLERS):把正文收进训练语料,不产生任何
31+
* 回链。内容是 CC BY-NC-SA,整站 disallow。
32+
*
33+
* 这份策略取代了 public/robots.txt —— 那份手写文件把两类一起屏蔽,而且
34+
* 早就不生效了:App Router 下 app/robots.ts 才是线上实际服务的 /robots.txt,
35+
* 那份文件被静默遮蔽,读代码的人却会以为 AI 爬虫已经被挡住了。已删除。
36+
*
2237
* @see https://nextjs.org/docs/app/api-reference/file-conventions/robots
2338
*/
2439

2540
import type { MetadataRoute } from "next";
2641
import { SITE_URL } from "@/lib/site-url";
2742

43+
/**
44+
* 登录态 / 接口路径,任何爬虫都不该进。
45+
* `*` 组和下面每个 UA 专属组都要带上(专属组覆盖 `*`,不继承)。
46+
*/
47+
const PRIVATE_PATHS = [
48+
"/*/admin/",
49+
"/*/editor/",
50+
"/*/settings/",
51+
"/*/login",
52+
"/api/",
53+
// posts 详情页元数据已设 noindex,robots.txt 双重保险
54+
"/*/u/*/posts/",
55+
];
56+
57+
/**
58+
* 训练语料型爬虫:只取内容不给回链,整站 disallow。
59+
*
60+
* 刻意不含 ChatGPT-User / Claude-User / OAI-SearchBot / PerplexityBot ——
61+
* 那几个是用户提问时实时取用并产生引用的,属于放行的一侧(见文件头)。
62+
*/
63+
const AI_TRAINING_CRAWLERS = [
64+
"GPTBot", // OpenAI 训练语料
65+
"ClaudeBot", // Anthropic 爬虫
66+
"anthropic-ai", // Anthropic 旧 UA
67+
"Google-Extended", // Gemini 训练 / grounding
68+
"Applebot-Extended", // Apple 智能训练
69+
"meta-externalagent", // Meta AI 训练
70+
"FacebookBot", // Meta 语料采集
71+
"Bytespider", // 字节
72+
"Amazonbot",
73+
"CCBot", // Common Crawl,多数开源模型语料的上游
74+
"Omgilibot",
75+
"DataForSeoBot", // SEO 数据转售
76+
];
77+
2878
export default function robots(): MetadataRoute.Robots {
2979
return {
3080
rules: [
3181
{
3282
userAgent: "*",
3383
allow: "/",
34-
disallow: [
35-
"/*/admin/",
36-
"/*/editor/",
37-
"/*/settings/",
38-
"/*/login",
39-
"/api/",
40-
// posts 详情页元数据已设 noindex,robots.txt 双重保险
41-
"/*/u/*/posts/",
42-
],
84+
disallow: PRIVATE_PATHS,
85+
},
86+
{
87+
userAgent: AI_TRAINING_CRAWLERS,
88+
disallow: "/",
4389
},
4490
],
4591
sitemap: `${SITE_URL}/sitemap.xml`,

app/sitemap.ts

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import leaderboard from "@/generated/site-leaderboard.json";
2222
import { SITE_URL } from "@/lib/site-url";
2323
import { routing, type Locale } from "@/i18n/routing";
2424
import { type PageData, type DateLike } from "@/app/types/doc";
25+
// 和 app/llms.txt/route.ts 共用,避免两边对 draft 过滤 / slug 编码各写一份
26+
import { docPathname, isDraftOrHidden } from "@/lib/doc-entry";
2527

2628
type SourcePage = ReturnType<typeof source.getPages>[number];
2729

@@ -155,8 +157,7 @@ function buildDocsEntry(
155157
page: SourcePage,
156158
locale: Locale,
157159
): MetadataRoute.Sitemap[number] {
158-
const slugPath = sanitizeSlugPath(page.slugs);
159-
const pathname = slugPath ? `/docs/${slugPath}` : "/docs";
160+
const pathname = docPathname(page.slugs);
160161
const fmDate = extractDateFromPage(page);
161162
return buildLocaleEntry({
162163
pathname,
@@ -194,20 +195,3 @@ function normalizeDate(value: DateLike): Date | undefined {
194195
const d = new Date(value);
195196
return isNaN(d.getTime()) ? undefined : d;
196197
}
197-
198-
function sanitizeSlugPath(slugs: string[]): string {
199-
return slugs
200-
.filter(Boolean)
201-
.map((s) => encodeURIComponent(s))
202-
.join("/");
203-
}
204-
205-
function isDraftOrHidden(page: SourcePage): boolean {
206-
const d = (page.data ?? {}) as PageData;
207-
return !!(
208-
d.draft ||
209-
d.hidden ||
210-
d.frontmatter?.draft ||
211-
d.frontmatter?.hidden
212-
);
213-
}

lib/doc-entry.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// lib/doc-entry.ts
2+
3+
/**
4+
* @file lib/doc-entry.ts
5+
* @description
6+
* 文档页 → URL / 可见性的共享判定。
7+
*
8+
* 原来这两个函数是 `app/sitemap.ts` 的私有函数。`app/llms.txt/route.ts`
9+
* 要枚举同一批文档,如果各写一份,早晚会出现「sitemap 过滤了 draft、
10+
* llms.txt 没过滤」这种单边漂移 —— 草稿泄漏给 AI 引擎和泄漏给搜索引擎
11+
* 一样糟。抽到这里,两边共用一份,理由同 `lib/site-url.ts` 文件头。
12+
*
13+
* 刻意不 import `@/lib/source`:那条链会把整个 fumadocs-mdx 管线拖进来,
14+
* vitest 没配 MDX 插件会直接 parse 失败。入参用结构化的宽类型,
15+
* 让本文件保持纯函数、可单测。
16+
*/
17+
18+
import type { PageData } from "@/app/types/doc";
19+
20+
/**
21+
* 文档是否是草稿 / 隐藏页。
22+
*
23+
* frontmatter 字段 fumadocs 会打平到 data 根部,但历史上也有代码显式写
24+
* `frontmatter.draft`,两处都查。入参用 `{ data?: unknown }` 而不是
25+
* PageData,是为了让调用方直接传 fumadocs 的 SourcePage 而不必先 cast。
26+
*/
27+
export function isDraftOrHidden(page: { data?: unknown }): boolean {
28+
const d = (page.data ?? {}) as PageData;
29+
return !!(
30+
d.draft ||
31+
d.hidden ||
32+
d.frontmatter?.draft ||
33+
d.frontmatter?.hidden
34+
);
35+
}
36+
37+
/**
38+
* 文档 slugs → 站内路径(不含 locale 前缀)。
39+
*
40+
* 逐段 encodeURIComponent:仓库里有中文文件名(`142.环形链表II`),
41+
* 不编码的 URL 进 sitemap / llms.txt 会被部分抓取方判为非法。
42+
* 根文档(slugs 为空)落到 `/docs`。
43+
*/
44+
export function docPathname(slugs: string[]): string {
45+
const slugPath = slugs
46+
.filter(Boolean)
47+
.map((s) => encodeURIComponent(s))
48+
.join("/");
49+
return slugPath ? `/docs/${slugPath}` : "/docs";
50+
}

lib/llms-txt.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// lib/llms-txt.ts
2+
3+
/**
4+
* @file lib/llms-txt.ts
5+
* @description
6+
* `/llms.txt` 正文生成器(llmstxt.org 约定)。
7+
*
8+
* 干什么用:AI 引擎(ChatGPT / Perplexity / Claude 等)抓站时先看这个文件,
9+
* 拿到一份无导航噪音的全站索引,再决定深入读哪几篇。sitemap.xml 只有 URL,
10+
* 模型得逐个抓才知道讲什么;llms.txt 一次给全 标题 + 描述 + 链接。
11+
*
12+
* 只做索引不做全文:站内 152 篇中文 + 152 篇英文,全文拼进去是几 MB,
13+
* 反而挤爆上下文。约定本身也是「索引 + 链接」。
14+
*
15+
* 纯函数,不 import `@/lib/source`(那条链会拖进 fumadocs-mdx 管线,
16+
* vitest 起不来)。枚举文档的活儿在 `app/llms.txt/route.ts` 里干。
17+
*/
18+
19+
export interface LlmsTxtEntry {
20+
/** 站内绝对路径,如 `/zh/docs/career/xxx`,不含域名 */
21+
pathname: string;
22+
title: string;
23+
/** frontmatter description,可能缺失或为空 */
24+
description?: string;
25+
/** 分组小标题,同一个值的条目会归到一起,按首次出现顺序排列 */
26+
section: string;
27+
}
28+
29+
/**
30+
* 单条描述的长度上限。个别 frontmatter 描述写得极长(有的贡献者把整段
31+
* 摘要塞进去),不截断的话少数几篇就能把索引撑肥一倍,挤掉别的条目
32+
* 被读到的机会。300 够表达一篇文档讲什么了。
33+
*/
34+
const MAX_DESCRIPTION = 300;
35+
36+
const HEADER = `# Involution Hell(内卷地狱)
37+
38+
> 面向留学生与求职者的开源社区知识库:算法题解、系统设计、面试经验与求职指南。内容由社区贡献者共同维护。
39+
40+
本文件是全站文档索引,供 AI 引擎检索与引用。中文文档在 \`/zh/docs/\`,英文在 \`/en/docs/\`;
41+
某篇没有英文版时,\`/en/\` 路径会回退渲染中文原文。
42+
43+
内容采用 CC BY-NC-SA 4.0 许可:可以引用和转述,请保留出处链接并注明来源;禁止商业化再分发。
44+
`;
45+
46+
/** 折叠空白 + 去掉会破坏 markdown 链接的方括号。 */
47+
function clean(text: string): string {
48+
return text.replace(/\s+/g, " ").replace(/[[\]]/g, "").trim();
49+
}
50+
51+
/**
52+
* 把文档条目渲染成 llms.txt 正文。
53+
*
54+
* @param entries 全部文档条目,分组顺序由 `section` 首次出现的顺序决定
55+
* @param siteUrl 站点根 URL(不带尾斜杠),拼成绝对链接 —— 相对链接对
56+
* 抓取方没用,它们不一定知道自己是从哪个域名拿到这个文件的
57+
*/
58+
export function buildLlmsTxt(entries: LlmsTxtEntry[], siteUrl: string): string {
59+
const groups = new Map<string, LlmsTxtEntry[]>();
60+
for (const entry of entries) {
61+
const bucket = groups.get(entry.section);
62+
if (bucket) {
63+
bucket.push(entry);
64+
} else {
65+
groups.set(entry.section, [entry]);
66+
}
67+
}
68+
69+
const lines: string[] = [HEADER];
70+
for (const [section, items] of groups) {
71+
lines.push(`## ${section}`, "");
72+
for (const item of items) {
73+
const title = clean(item.title) || item.pathname;
74+
const raw = item.description ? clean(item.description) : "";
75+
const description =
76+
raw.length > MAX_DESCRIPTION
77+
? `${raw.slice(0, MAX_DESCRIPTION).trimEnd()}…`
78+
: raw;
79+
const link = `- [${title}](${siteUrl}${item.pathname})`;
80+
lines.push(description ? `${link}: ${description}` : link);
81+
}
82+
lines.push("");
83+
}
84+
85+
return `${lines.join("\n").trimEnd()}\n`;
86+
}

public/robots.txt

Lines changed: 0 additions & 48 deletions
This file was deleted.

0 commit comments

Comments
 (0)