Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions app/llms.txt/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = { 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" },
});
}
54 changes: 45 additions & 9 deletions app/robots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
22 changes: 3 additions & 19 deletions app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof source.getPages>[number];

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
);
}
45 changes: 45 additions & 0 deletions lib/doc-entry.ts
Original file line number Diff line number Diff line change
@@ -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";
}
80 changes: 80 additions & 0 deletions lib/llms-txt.ts
Original file line number Diff line number Diff line change
@@ -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<string, LlmsTxtEntry[]>();
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`;
}
48 changes: 0 additions & 48 deletions public/robots.txt

This file was deleted.

Loading
Loading