Skip to content

Commit ba0cdc7

Browse files
longsizhuogithub-actions[bot]Copilot
authored
geo: 加 /llms.txt,AI 爬虫按用途分开放行 (#395)
* 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 引用。 * fix: CR 反馈 —— 补全训练型 UA 断言,注释按 CLAUDE.md §3 收敛 tests/robots.test.ts 原来只断言 5 个训练型 UA,而策略里有 12 个: 从 AI_TRAINING_CRAWLERS 删掉没被断言的那几个,测试照样绿,等于白守。 补全成 12 个(新增不用同步,放宽是安全方向)。变异验证过:删掉 Applebot-Extended 会红,还原后绿。 注释违反 CLAUDE.md §3「严禁历史叙事 / 大段 docstring 描述功能」: 删掉 public/robots.txt 的来龙去脉、doc-entry 的抽取过程、force-static 里那段 Vercel CPU 往事,只留维护时会踩的不变量 —— UA 专属组整体覆盖 `*` 不叠加(所以不给引用型开 Allow 组)、不要 import @/lib/source (会拖进 MDX 管线让 vitest 挂)、sitemap 与 llms.txt 的过滤必须同进同退。 Co-authored-by: copilot-pull-request-reviewer[bot] <198982749+copilot-pull-request-reviewer[bot]@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: copilot-pull-request-reviewer[bot] <198982749+copilot-pull-request-reviewer[bot]@users.noreply.github.com>
1 parent 2db2e04 commit ba0cdc7

8 files changed

Lines changed: 410 additions & 76 deletions

File tree

‎app/llms.txt/route.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// app/llms.txt/route.ts
2+
3+
/**
4+
* @file app/llms.txt/route.ts
5+
* @description
6+
* `/llms.txt` 路由(llmstxt.org 约定)。
7+
*
8+
* force-static 是必需的,不是优化:内容只依赖构建期的 MDX,漏了它这条路由
9+
* 会退化成每次请求现枚举全站文档的 dynamic 路由。
10+
*
11+
* @see https://llmstxt.org
12+
*/
13+
14+
import type { PageData } from "@/app/types/doc";
15+
import { routing } from "@/i18n/routing";
16+
import { docPathname, isDraftOrHidden } from "@/lib/doc-entry";
17+
import { buildLlmsTxt, type LlmsTxtEntry } from "@/lib/llms-txt";
18+
import { SITE_URL } from "@/lib/site-url";
19+
import { source } from "@/lib/source";
20+
21+
export const dynamic = "force-static";
22+
23+
/** 分组小标题里 locale 的显示名,未知 locale 直接显示代码。 */
24+
const LOCALE_LABEL: Record<string, string> = { zh: "中文", en: "English" };
25+
26+
export function GET() {
27+
const entries: LlmsTxtEntry[] = [];
28+
29+
for (const locale of routing.locales) {
30+
for (const page of source.getPages(locale)) {
31+
// 和 sitemap 同一套过滤:草稿泄漏给 AI 引擎和泄漏给搜索引擎一样糟
32+
if (isDraftOrHidden(page)) continue;
33+
34+
const data = (page.data ?? {}) as PageData;
35+
// slugs[0] 是顶层分区(career / learn / projects),拿来当分组
36+
const topLevel = page.slugs[0] ?? "docs";
37+
38+
entries.push({
39+
pathname: `/${locale}${docPathname(page.slugs)}`,
40+
title: data.title ?? page.slugs.at(-1) ?? "Untitled",
41+
description: data.description,
42+
section: `${LOCALE_LABEL[locale] ?? locale} · ${topLevel}`,
43+
});
44+
}
45+
}
46+
47+
return new Response(buildLlmsTxt(entries, SITE_URL), {
48+
headers: { "content-type": "text/plain; charset=utf-8" },
49+
});
50+
}

‎app/robots.ts‎

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,27 +19,63 @@
1919
* sitemap 指向 app/sitemap.ts 产出的 /sitemap.xml,hostname 复用同一份
2020
* NEXT_PUBLIC_SITE_URL。
2121
*
22+
* AI 爬虫按用途分:引用型(实时取用并附出处链接)放行,训练型(收进语料、
23+
* 不给回链)整站 disallow —— 内容是 CC BY-NC-SA。
24+
*
2225
* @see https://nextjs.org/docs/app/api-reference/file-conventions/robots
2326
*/
2427

2528
import type { MetadataRoute } from "next";
2629
import { SITE_URL } from "@/lib/site-url";
2730

31+
/**
32+
* 登录态 / 接口路径,任何爬虫都不该进。
33+
* `*` 组和下面每个 UA 专属组都要带上(专属组覆盖 `*`,不继承)。
34+
*/
35+
const PRIVATE_PATHS = [
36+
"/*/admin/",
37+
"/*/editor/",
38+
"/*/settings/",
39+
"/*/login",
40+
"/api/",
41+
// posts 详情页元数据已设 noindex,robots.txt 双重保险
42+
"/*/u/*/posts/",
43+
];
44+
45+
/**
46+
* 训练语料型爬虫,整站 disallow。
47+
*
48+
* 刻意不含 OAI-SearchBot / ChatGPT-User / Claude-User / PerplexityBot:
49+
* 它们靠 `*` 组放行就够了。**不要**为它们再开一个 Allow 组 —— robots.txt 里
50+
* UA 专属组整体覆盖 `*` 而不是叠加,开了就得把 PRIVATE_PATHS 再抄一遍,
51+
* 抄漏一条等于把后台放给它们。
52+
*/
53+
const AI_TRAINING_CRAWLERS = [
54+
"GPTBot", // OpenAI 训练语料
55+
"ClaudeBot", // Anthropic 爬虫
56+
"anthropic-ai", // Anthropic 旧 UA
57+
"Google-Extended", // Gemini 训练 / grounding
58+
"Applebot-Extended", // Apple 智能训练
59+
"meta-externalagent", // Meta AI 训练
60+
"FacebookBot", // Meta 语料采集
61+
"Bytespider", // 字节
62+
"Amazonbot",
63+
"CCBot", // Common Crawl,多数开源模型语料的上游
64+
"Omgilibot",
65+
"DataForSeoBot", // SEO 数据转售
66+
];
67+
2868
export default function robots(): MetadataRoute.Robots {
2969
return {
3070
rules: [
3171
{
3272
userAgent: "*",
3373
allow: "/",
34-
disallow: [
35-
"/*/admin/",
36-
"/*/editor/",
37-
"/*/settings/",
38-
"/*/login",
39-
"/api/",
40-
// posts 详情页元数据已设 noindex,robots.txt 双重保险
41-
"/*/u/*/posts/",
42-
],
74+
disallow: PRIVATE_PATHS,
75+
},
76+
{
77+
userAgent: AI_TRAINING_CRAWLERS,
78+
disallow: "/",
4379
},
4480
],
4581
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: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// lib/doc-entry.ts
2+
3+
/**
4+
* @file lib/doc-entry.ts
5+
* @description
6+
* sitemap 与 llms.txt 共用的文档过滤 / URL 编码,两边必须同进同退:
7+
* 只有一边过滤 draft 的话,草稿会静默泄漏给另一边的抓取方。
8+
*
9+
* 刻意不 import `@/lib/source`:那条链会把整个 fumadocs-mdx 管线拖进来,
10+
* vitest 没配 MDX 插件会直接 parse 失败。入参因此用结构化宽类型。
11+
*/
12+
13+
import type { PageData } from "@/app/types/doc";
14+
15+
/**
16+
* 文档是否是草稿 / 隐藏页。
17+
*
18+
* frontmatter 字段 fumadocs 会打平到 data 根部,但历史上也有代码显式写
19+
* `frontmatter.draft`,两处都查。入参用 `{ data?: unknown }` 而不是
20+
* PageData,是为了让调用方直接传 fumadocs 的 SourcePage 而不必先 cast。
21+
*/
22+
export function isDraftOrHidden(page: { data?: unknown }): boolean {
23+
const d = (page.data ?? {}) as PageData;
24+
return !!(
25+
d.draft ||
26+
d.hidden ||
27+
d.frontmatter?.draft ||
28+
d.frontmatter?.hidden
29+
);
30+
}
31+
32+
/**
33+
* 文档 slugs → 站内路径(不含 locale 前缀)。
34+
*
35+
* 逐段 encodeURIComponent:仓库里有中文文件名(`142.环形链表II`),
36+
* 不编码的 URL 进 sitemap / llms.txt 会被部分抓取方判为非法。
37+
* 根文档(slugs 为空)落到 `/docs`。
38+
*/
39+
export function docPathname(slugs: string[]): string {
40+
const slugPath = slugs
41+
.filter(Boolean)
42+
.map((s) => encodeURIComponent(s))
43+
.join("/");
44+
return slugPath ? `/docs/${slugPath}` : "/docs";
45+
}

‎lib/llms-txt.ts‎

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

‎public/robots.txt‎

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

0 commit comments

Comments
 (0)