diff --git a/app/llms.txt/route.ts b/app/llms.txt/route.ts new file mode 100644 index 00000000..cab97278 --- /dev/null +++ b/app/llms.txt/route.ts @@ -0,0 +1,50 @@ +// app/llms.txt/route.ts + +/** + * @file app/llms.txt/route.ts + * @description + * `/llms.txt` 路由(llmstxt.org 约定)。 + * + * force-static 是必需的,不是优化:内容只依赖构建期的 MDX,漏了它这条路由 + * 会退化成每次请求现枚举全站文档的 dynamic 路由。 + * + * @see https://llmstxt.org + */ + +import type { PageData } from "@/app/types/doc"; +import { routing } from "@/i18n/routing"; +import { docPathname, isDraftOrHidden } from "@/lib/doc-entry"; +import { buildLlmsTxt, type LlmsTxtEntry } from "@/lib/llms-txt"; +import { SITE_URL } from "@/lib/site-url"; +import { source } from "@/lib/source"; + +export const dynamic = "force-static"; + +/** 分组小标题里 locale 的显示名,未知 locale 直接显示代码。 */ +const LOCALE_LABEL: Record = { zh: "中文", en: "English" }; + +export function GET() { + const entries: LlmsTxtEntry[] = []; + + for (const locale of routing.locales) { + for (const page of source.getPages(locale)) { + // 和 sitemap 同一套过滤:草稿泄漏给 AI 引擎和泄漏给搜索引擎一样糟 + if (isDraftOrHidden(page)) continue; + + const data = (page.data ?? {}) as PageData; + // slugs[0] 是顶层分区(career / learn / projects),拿来当分组 + const topLevel = page.slugs[0] ?? "docs"; + + entries.push({ + pathname: `/${locale}${docPathname(page.slugs)}`, + title: data.title ?? page.slugs.at(-1) ?? "Untitled", + description: data.description, + section: `${LOCALE_LABEL[locale] ?? locale} · ${topLevel}`, + }); + } + } + + return new Response(buildLlmsTxt(entries, SITE_URL), { + headers: { "content-type": "text/plain; charset=utf-8" }, + }); +} diff --git a/app/robots.ts b/app/robots.ts index 0cf46f92..21e7804d 100644 --- a/app/robots.ts +++ b/app/robots.ts @@ -19,27 +19,63 @@ * sitemap 指向 app/sitemap.ts 产出的 /sitemap.xml,hostname 复用同一份 * NEXT_PUBLIC_SITE_URL。 * + * AI 爬虫按用途分:引用型(实时取用并附出处链接)放行,训练型(收进语料、 + * 不给回链)整站 disallow —— 内容是 CC BY-NC-SA。 + * * @see https://nextjs.org/docs/app/api-reference/file-conventions/robots */ import type { MetadataRoute } from "next"; import { SITE_URL } from "@/lib/site-url"; +/** + * 登录态 / 接口路径,任何爬虫都不该进。 + * `*` 组和下面每个 UA 专属组都要带上(专属组覆盖 `*`,不继承)。 + */ +const PRIVATE_PATHS = [ + "/*/admin/", + "/*/editor/", + "/*/settings/", + "/*/login", + "/api/", + // posts 详情页元数据已设 noindex,robots.txt 双重保险 + "/*/u/*/posts/", +]; + +/** + * 训练语料型爬虫,整站 disallow。 + * + * 刻意不含 OAI-SearchBot / ChatGPT-User / Claude-User / PerplexityBot: + * 它们靠 `*` 组放行就够了。**不要**为它们再开一个 Allow 组 —— robots.txt 里 + * UA 专属组整体覆盖 `*` 而不是叠加,开了就得把 PRIVATE_PATHS 再抄一遍, + * 抄漏一条等于把后台放给它们。 + */ +const AI_TRAINING_CRAWLERS = [ + "GPTBot", // OpenAI 训练语料 + "ClaudeBot", // Anthropic 爬虫 + "anthropic-ai", // Anthropic 旧 UA + "Google-Extended", // Gemini 训练 / grounding + "Applebot-Extended", // Apple 智能训练 + "meta-externalagent", // Meta AI 训练 + "FacebookBot", // Meta 语料采集 + "Bytespider", // 字节 + "Amazonbot", + "CCBot", // Common Crawl,多数开源模型语料的上游 + "Omgilibot", + "DataForSeoBot", // SEO 数据转售 +]; + export default function robots(): MetadataRoute.Robots { return { rules: [ { userAgent: "*", allow: "/", - disallow: [ - "/*/admin/", - "/*/editor/", - "/*/settings/", - "/*/login", - "/api/", - // posts 详情页元数据已设 noindex,robots.txt 双重保险 - "/*/u/*/posts/", - ], + disallow: PRIVATE_PATHS, + }, + { + userAgent: AI_TRAINING_CRAWLERS, + disallow: "/", }, ], sitemap: `${SITE_URL}/sitemap.xml`, diff --git a/app/sitemap.ts b/app/sitemap.ts index ec21a014..7517b708 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -22,6 +22,8 @@ import leaderboard from "@/generated/site-leaderboard.json"; import { SITE_URL } from "@/lib/site-url"; import { routing, type Locale } from "@/i18n/routing"; import { type PageData, type DateLike } from "@/app/types/doc"; +// 和 app/llms.txt/route.ts 共用,避免两边对 draft 过滤 / slug 编码各写一份 +import { docPathname, isDraftOrHidden } from "@/lib/doc-entry"; type SourcePage = ReturnType[number]; @@ -155,8 +157,7 @@ function buildDocsEntry( page: SourcePage, locale: Locale, ): MetadataRoute.Sitemap[number] { - const slugPath = sanitizeSlugPath(page.slugs); - const pathname = slugPath ? `/docs/${slugPath}` : "/docs"; + const pathname = docPathname(page.slugs); const fmDate = extractDateFromPage(page); return buildLocaleEntry({ pathname, @@ -194,20 +195,3 @@ function normalizeDate(value: DateLike): Date | undefined { const d = new Date(value); return isNaN(d.getTime()) ? undefined : d; } - -function sanitizeSlugPath(slugs: string[]): string { - return slugs - .filter(Boolean) - .map((s) => encodeURIComponent(s)) - .join("/"); -} - -function isDraftOrHidden(page: SourcePage): boolean { - const d = (page.data ?? {}) as PageData; - return !!( - d.draft || - d.hidden || - d.frontmatter?.draft || - d.frontmatter?.hidden - ); -} diff --git a/lib/doc-entry.ts b/lib/doc-entry.ts new file mode 100644 index 00000000..f7e9dd78 --- /dev/null +++ b/lib/doc-entry.ts @@ -0,0 +1,45 @@ +// lib/doc-entry.ts + +/** + * @file lib/doc-entry.ts + * @description + * sitemap 与 llms.txt 共用的文档过滤 / URL 编码,两边必须同进同退: + * 只有一边过滤 draft 的话,草稿会静默泄漏给另一边的抓取方。 + * + * 刻意不 import `@/lib/source`:那条链会把整个 fumadocs-mdx 管线拖进来, + * vitest 没配 MDX 插件会直接 parse 失败。入参因此用结构化宽类型。 + */ + +import type { PageData } from "@/app/types/doc"; + +/** + * 文档是否是草稿 / 隐藏页。 + * + * frontmatter 字段 fumadocs 会打平到 data 根部,但历史上也有代码显式写 + * `frontmatter.draft`,两处都查。入参用 `{ data?: unknown }` 而不是 + * PageData,是为了让调用方直接传 fumadocs 的 SourcePage 而不必先 cast。 + */ +export function isDraftOrHidden(page: { data?: unknown }): boolean { + const d = (page.data ?? {}) as PageData; + return !!( + d.draft || + d.hidden || + d.frontmatter?.draft || + d.frontmatter?.hidden + ); +} + +/** + * 文档 slugs → 站内路径(不含 locale 前缀)。 + * + * 逐段 encodeURIComponent:仓库里有中文文件名(`142.环形链表II`), + * 不编码的 URL 进 sitemap / llms.txt 会被部分抓取方判为非法。 + * 根文档(slugs 为空)落到 `/docs`。 + */ +export function docPathname(slugs: string[]): string { + const slugPath = slugs + .filter(Boolean) + .map((s) => encodeURIComponent(s)) + .join("/"); + return slugPath ? `/docs/${slugPath}` : "/docs"; +} diff --git a/lib/llms-txt.ts b/lib/llms-txt.ts new file mode 100644 index 00000000..761aaf42 --- /dev/null +++ b/lib/llms-txt.ts @@ -0,0 +1,80 @@ +// lib/llms-txt.ts + +/** + * @file lib/llms-txt.ts + * @description + * `/llms.txt` 正文生成器(llmstxt.org 约定)。只出索引不出全文 —— 三百多篇 + * 正文拼进去是几 MB,会挤爆它本来要省的上下文。 + * + * 不 import `@/lib/source`:那条链会拖进 fumadocs-mdx 管线,vitest 起不来。 + * 枚举文档在 `app/llms.txt/route.ts`,这里保持纯函数。 + */ + +export interface LlmsTxtEntry { + /** 站内绝对路径,如 `/zh/docs/career/xxx`,不含域名 */ + pathname: string; + title: string; + /** frontmatter description,可能缺失或为空 */ + description?: string; + /** 分组小标题,同一个值的条目会归到一起,按首次出现顺序排列 */ + section: string; +} + +/** + * 单条描述的长度上限。个别 frontmatter 描述写得极长(有的贡献者把整段 + * 摘要塞进去),不截断的话少数几篇就能把索引撑肥一倍,挤掉别的条目 + * 被读到的机会。300 够表达一篇文档讲什么了。 + */ +const MAX_DESCRIPTION = 300; + +const HEADER = `# Involution Hell(内卷地狱) + +> 面向留学生与求职者的开源社区知识库:算法题解、系统设计、面试经验与求职指南。内容由社区贡献者共同维护。 + +本文件是全站文档索引,供 AI 引擎检索与引用。中文文档在 \`/zh/docs/\`,英文在 \`/en/docs/\`; +某篇没有英文版时,\`/en/\` 路径会回退渲染中文原文。 + +内容采用 CC BY-NC-SA 4.0 许可:可以引用和转述,请保留出处链接并注明来源;禁止商业化再分发。 +`; + +/** 折叠空白 + 去掉会破坏 markdown 链接的方括号。 */ +function clean(text: string): string { + return text.replace(/\s+/g, " ").replace(/[[\]]/g, "").trim(); +} + +/** + * 把文档条目渲染成 llms.txt 正文。 + * + * @param entries 全部文档条目,分组顺序由 `section` 首次出现的顺序决定 + * @param siteUrl 站点根 URL(不带尾斜杠),拼成绝对链接 —— 相对链接对 + * 抓取方没用,它们不一定知道自己是从哪个域名拿到这个文件的 + */ +export function buildLlmsTxt(entries: LlmsTxtEntry[], siteUrl: string): string { + const groups = new Map(); + for (const entry of entries) { + const bucket = groups.get(entry.section); + if (bucket) { + bucket.push(entry); + } else { + groups.set(entry.section, [entry]); + } + } + + const lines: string[] = [HEADER]; + for (const [section, items] of groups) { + lines.push(`## ${section}`, ""); + for (const item of items) { + const title = clean(item.title) || item.pathname; + const raw = item.description ? clean(item.description) : ""; + const description = + raw.length > MAX_DESCRIPTION + ? `${raw.slice(0, MAX_DESCRIPTION).trimEnd()}…` + : raw; + const link = `- [${title}](${siteUrl}${item.pathname})`; + lines.push(description ? `${link}: ${description}` : link); + } + lines.push(""); + } + + return `${lines.join("\n").trimEnd()}\n`; +} diff --git a/public/robots.txt b/public/robots.txt deleted file mode 100644 index 924486a6..00000000 --- a/public/robots.txt +++ /dev/null @@ -1,48 +0,0 @@ -# Allow legitimate search engines, block aggressive scrapers + AI bots -Sitemap: https://involutionhell.com/sitemap.xml -User-agent: * -Disallow: /api/ -Disallow: /_next/ -Disallow: /search -Disallow: /*?* # Block query crawling (prevents scraping pagination) -Allow: / - -# Block AI scrapers (known major LLM training bots) -User-agent: GPTBot -Disallow: / - -User-agent: ChatGPT-User -Disallow: / - -User-agent: Google-Extended -Disallow: / - -User-agent: ClaudeBot -Disallow: / - -User-agent: Claude-Web -Disallow: / - -User-agent: Omgilibot -Disallow: / - -User-agent: FacebookBot -Disallow: / - -User-agent: Bytespider -Disallow: / - -User-agent: DataForSeoBot -Disallow: / - -User-agent: CCBot -Disallow: / - -User-agent: Amazonbot -Disallow: / - -Crawl-delay: 5 - -# CC BY-NC-SA License reminder -# This website's content is licensed under CC BY-NC-SA 4.0. Unauthorized scraping, -# copying, or commercial reuse is strictly prohibited. diff --git a/tests/llms-txt.test.ts b/tests/llms-txt.test.ts new file mode 100644 index 00000000..dd1a5539 --- /dev/null +++ b/tests/llms-txt.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; + +import { docPathname, isDraftOrHidden } from "@/lib/doc-entry"; +import { buildLlmsTxt, type LlmsTxtEntry } from "@/lib/llms-txt"; + +const SITE = "https://involutionhell.com"; + +function entry(overrides: Partial = {}): LlmsTxtEntry { + return { + pathname: "/zh/docs/career/foo", + title: "Foo", + description: "关于 Foo 的说明", + section: "中文 · career", + ...overrides, + }; +} + +describe("buildLlmsTxt", () => { + it("按 section 分组,顺序跟首次出现一致", () => { + const out = buildLlmsTxt( + [ + entry({ section: "中文 · career", title: "A" }), + entry({ section: "English · learn", title: "B" }), + entry({ section: "中文 · career", title: "C" }), + ], + SITE, + ); + expect(out.indexOf("## 中文 · career")).toBeLessThan( + out.indexOf("## English · learn"), + ); + // C 要回到第一组里,而不是自己另起一组 + const careerBlock = out.slice( + out.indexOf("## 中文 · career"), + out.indexOf("## English · learn"), + ); + expect(careerBlock).toContain("[A]"); + expect(careerBlock).toContain("[C]"); + expect(out.match(/## 中文 · career/g)).toHaveLength(1); + }); + + it("链接是带域名的绝对地址 —— 抓取方不一定知道自己从哪个域拿的文件", () => { + const out = buildLlmsTxt([entry({ pathname: "/zh/docs/x" })], SITE); + expect(out).toContain(`(${SITE}/zh/docs/x)`); + }); + + it("头部声明 CC BY-NC-SA,向引用方讲清署名要求", () => { + const out = buildLlmsTxt([entry()], SITE); + expect(out).toContain("CC BY-NC-SA"); + }); + + it("没有 description 时不留空冒号", () => { + const out = buildLlmsTxt([entry({ description: undefined })], SITE); + expect(out).toContain( + "- [Foo](https://involutionhell.com/zh/docs/career/foo)\n", + ); + expect(out).not.toContain("foo): \n"); + }); + + it("超长描述截断,不让个别条目撑肥整份索引", () => { + const out = buildLlmsTxt([entry({ description: "长".repeat(500) })], SITE); + const line = out.split("\n").find((l) => l.startsWith("- [Foo]"))!; + expect(line).toContain("…"); + expect(line.length).toBeLessThan(400); + }); + + it("标题里的方括号会破坏 markdown 链接,必须去掉", () => { + const out = buildLlmsTxt( + [entry({ title: "LeetCode [142] 环形链表" })], + SITE, + ); + expect(out).toContain("- [LeetCode 142 环形链表]("); + }); + + it("描述里的换行折成空格,一条目一行", () => { + const out = buildLlmsTxt( + [entry({ description: "第一行\n\n第二行" })], + SITE, + ); + expect(out).toContain(": 第一行 第二行"); + }); +}); + +describe("docPathname", () => { + it("中文 slug 逐段编码", () => { + expect(docPathname(["career", "142.环形链表II"])).toBe( + "/docs/career/142.%E7%8E%AF%E5%BD%A2%E9%93%BE%E8%A1%A8II", + ); + }); + + it("斜杠不会被编码掉(分段拼接而不是整串编码)", () => { + expect(docPathname(["a", "b", "c"])).toBe("/docs/a/b/c"); + }); + + it("根文档落到 /docs", () => { + expect(docPathname([])).toBe("/docs"); + expect(docPathname([""])).toBe("/docs"); + }); +}); + +describe("isDraftOrHidden", () => { + it("打平的字段和 frontmatter 里的都算数", () => { + expect(isDraftOrHidden({ data: { draft: true } })).toBe(true); + expect(isDraftOrHidden({ data: { hidden: true } })).toBe(true); + expect(isDraftOrHidden({ data: { frontmatter: { draft: true } } })).toBe( + true, + ); + expect(isDraftOrHidden({ data: { frontmatter: { hidden: true } } })).toBe( + true, + ); + }); + + it("正常文档和空 data 都放行", () => { + expect(isDraftOrHidden({ data: { title: "x" } })).toBe(false); + expect(isDraftOrHidden({})).toBe(false); + }); +}); diff --git a/tests/robots.test.ts b/tests/robots.test.ts new file mode 100644 index 00000000..6fe9ffce --- /dev/null +++ b/tests/robots.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; + +import robots from "@/app/robots"; + +/** + * 这组测试守的是一条策略不变量,不是实现细节: + * 训练型爬虫要被整站挡掉,引用型爬虫必须留在 `*` 组里被放行。 + * 两边错任何一边都不会有任何报错 —— 要么内容白送进训练集, + * 要么再也拿不到 AI 引用,只能靠测试拦。 + */ +describe("robots", () => { + const result = robots(); + const rules = Array.isArray(result.rules) ? result.rules : [result.rules!]; + const wildcard = rules.find((r) => r.userAgent === "*")!; + const blocked = rules.filter((r) => r !== wildcard); + const blockedAgents = blocked.flatMap((r) => + Array.isArray(r.userAgent) ? r.userAgent : [r.userAgent!], + ); + + it("`*` 放行全站,但挡住登录态和接口路径", () => { + expect(wildcard.allow).toBe("/"); + expect(wildcard.disallow).toContain("/api/"); + expect(wildcard.disallow).toContain("/*/admin/"); + expect(wildcard.disallow).toContain("/*/editor/"); + }); + + // 列全当前策略里的每一个训练型 UA:少列几个的话,从 app/robots.ts 删掉 + // 没列到的那些测试照样绿,等于白守。新增不用同步(放宽是安全方向)。 + it("训练型爬虫整站 disallow", () => { + for (const ua of [ + "GPTBot", + "ClaudeBot", + "anthropic-ai", + "Google-Extended", + "Applebot-Extended", + "meta-externalagent", + "FacebookBot", + "Bytespider", + "Amazonbot", + "CCBot", + "Omgilibot", + "DataForSeoBot", + ]) { + expect(blockedAgents).toContain(ua); + } + for (const rule of blocked) { + expect(rule.disallow).toBe("/"); + } + }); + + it("引用型爬虫不在屏蔽名单里 —— 挡了就等于放弃 AI 引用", () => { + for (const ua of [ + "OAI-SearchBot", + "ChatGPT-User", + "Claude-User", + "PerplexityBot", + ]) { + expect(blockedAgents).not.toContain(ua); + } + }); + + it("屏蔽组不能开 allow —— UA 专属组整体覆盖 `*`,写 allow 会把整站放回去", () => { + for (const rule of blocked) { + expect(rule.allow).toBeUndefined(); + } + }); + + it("带上 sitemap", () => { + expect(result.sitemap).toMatch(/\/sitemap\.xml$/); + }); +});