网页另存一份 Markdown 给 AI 读:ASP.NET Core 中间件实现镜像端点与 llms.txt 索引
适用读者:维护企业官网、产品文档站或内容型站点的后端与前端工程师。示例代码用 .NET 8 / ASP.NET Core,思路可以直接搬到 Node、FastAPI 或 Nginx 层。
上周客户的销售拿着一个 AI 回答来对质:官网产品页白纸黑字写着「整机保修三年,含上门」,DeepSeek 却回答「该品牌整机保修两年,具体以厂家说明为准」。我们把页面源码拉下来看——那句保修条款埋在第 6 层 div 里,前面隔着两个轮播组件、一段 CSS-in-JS 注入的样式表和一个懒加载的价格模块,正文在 DOM 里的位置比页脚还靠后。
AI 引擎不是没抓到这一页,它抓到了,只是没抽出那句话。这件事之后我们做的改造,不是再加一段 JSON-LD,而是给每个页面额外输出一份干净的 Markdown。
页面正文在 AI 眼里到底长什么样
先建立一个认知:AI 引擎消费的正文,是它自己从 HTML 里抽出来的,不是你 CMS 里存的那份。你在后台写的段落、表格、加粗,中间要过好几道加工,任何一道都可能把内容丢掉。
抽取阶段主流做法是先解析 DOM,再按可读性算法(Readability 类)或「文本密度 + 标签权重」打分,选出主体区块,其余全部丢掉。导航、侧栏、推荐位、弹窗、Cookie 提示条,在这一步被剔除——这是好事。问题出在主体区块本身不干净的时候:正文被拆进 12 个互相嵌套的组件容器,抽出来的文本会带上大量组件残渣,句子被切成碎片,上下文丢失。
抽完之后还要切分。AI 引擎不会把整页当成一个整体来索引,它会把正文切成若干段落级片段(Passage),有时也叫引用单元(Citation Unit)。切分粒度通常跟着标题层级和段落边界走。这就解释了一个常见现象:同一批内容,写成连续长段落时经常被"整段引用",写成小标题 + 短段落时更容易被精确引用到某一个点上——因为后者天然给出了更小的语义单元。
再往后是向量化与召回,最后生成阶段模型按召回片段组织答案,并决定要不要给出处。这个链路上,我们能直接影响的是前两步:让抽取结果更干净,让切分边界更合理。
Markdown 在这两步上都占便宜。它本身就是结构化的纯文本,标题、列表、表格、代码块都有显式标记,抽取时不需要猜「这行大字是标题还是广告」,切分时也能直接按 ## 粒度落刀。
原理剖析:Markdown 镜像为什么比"再优化 HTML"更省事
有人在评审会上问过:与其输出 Markdown,为什么不把 HTML 结构改得更语义化?两个原因。
第一,成本落在不同的人身上。改 HTML 结构意味着动模板、动组件、动设计稿验收,页面一改就要重新验证视觉;而输出 Markdown 是只读派生,不改任何现有渲染路径,出问题最多是镜像不可用,不会影响用户看到的页面。
第二,AI 引擎的抽取器对 HTML 语义标签的支持程度并不一致。<article>、<main> 这些标签语义明确,但抽取器是否优先采用、采用到什么程度,各家实现不同,也没有公开的稳定规范。Markdown 没有这个问题——它没有歧义可猜。
| 方案 | 改动范围 | 对现网风险 | AI 抽取质量 | 维护成本 |
|---|---|---|---|---|
| 重构 HTML 语义结构 | 模板、组件、样式、测试 | 高(影响视觉与交互) | 依赖抽取器实现,不确定 | 每次改版都要复核 |
| 手工维护内容副本 | 内容团队 | 低 | 好 | 高,且必然不同步 |
| 运行时 Markdown 镜像 | 新增一个派生端点 | 低(只读,可随时下线) | 好,且结构稳定 | 低,随模板自动更新 |
我们最终选的是第三行:运行时派生(Runtime Derivation)。正文的单一数据源(Single Source of Truth, SSOT)仍然是 CMS,Markdown 只是同一份数据在另一个端点上的投影。
flowchart LR
A[AI 爬虫请求 /product/gear-x] --> B{路径后缀判断}
B -->|HTML| C[Razor 渲染完整页面]
B -->|/md 后缀| D[Markdown 投影管线]
D --> E[从 CMS 读正文结构化数据]
E --> F[过滤导航/推荐/广告区块]
F --> G[按 H2/H3 生成层级标题]
G --> H[表格与代码块还原为 Markdown 语法]
H --> I[返回 text/markdown]
C --> J[用户浏览器]
这张图里有个关键点:投影管线的输入不是渲染好的 HTML,而是 CMS 里的结构化正文数据。如果拿 HTML 反向转 Markdown,你会把上一步的噪声再解一遍,得不偿失。
端点设计:URL 映射、内容协商与缓存
镜像端点的 URL 设计有两种主流走法,各有取舍。
| 映射方式 | 示例 | 优点 | 需要注意 |
|---|---|---|---|
| 路径后缀 | /product/gear-x.md |
直观,便于爬虫与人工核对,静态化容易 | 需要路由排除,避免与真实页面冲突 |
| 前缀命名空间 | /md/product/gear-x |
与页面路径完全隔离,不会污染业务路由 | 相对链接需要全部改写为带域名的完整地址 |
| 内容协商 | /product/gear-x + Accept: text/markdown |
一个 URL 两种表示,符合 HTTP 语义 | 部分爬虫与 CDN 缓存处理不规范,调试麻烦 |
我们用的是前缀命名空间加后缀双保险:真实镜像地址是 /md/product/gear-x,同时把 .md 也接受为等价写法,方便人工在浏览器里直接打开核对。内容协商虽然最"正统",但在 CDN 层需要配 Vary: Accept,遇到过中间层缓存把 Markdown 响应当成 HTML 缓存下来的情况,排查起来很费时间,所以没有作为主路径。
缓存策略上,镜像端点跟随正文的修改时间走:正文没改,ETag 不变,返回 304;正文改了,ETag 变化,重新生成。不要给镜像端点设一个固定的长时间缓存,否则正文改了、AI 拿到的还是旧版,问题比没有镜像更隐蔽。
.NET 8 中间件的落地代码
环境:.NET 8.0(SDK 8.0.300 以上)、ASP.NET Core 内置中间件能力,不依赖第三方包。核心思路是短路请求,自己产出响应体。
// MarkdownMirrorMiddleware.cs
// 作用:拦截 /md/ 前缀请求,从内容服务读取结构化正文并投影为 Markdown
// 设计约束:只读派生,不改动现有渲染路径,随时可以下线
// 依赖注册(Program.cs):
// builder.Services.AddSingleton<IContentService, ContentService>();
// builder.Services.AddMemoryCache();
public sealed class MarkdownMirrorMiddleware
{
private readonly RequestDelegate _next; // 常规管线入口,非 /md/ 请求交回它
private readonly IContentService _content; // 正文单一数据源
private readonly IMemoryCache _cache; // 镜像文本缓存,键含内容版本号
public MarkdownMirrorMiddleware(RequestDelegate next, IContentService content, IMemoryCache cache)
{
_next = next; // 注入顺序与注册顺序一致
_content = content; // 走内容服务读取,不直连数据库
_cache = cache; // 缓存的是投影后的文本,不是数据库行
}
public async Task InvokeAsync(HttpContext ctx)
{
// 取原始请求路径:不要用 PathBase,中间件已按应用根路径挂载
var path = ctx.Request.Path.Value ?? string.Empty;
// 只处理 /md/ 命名空间,其余一律放行走原管线(零影响现网)
if (!path.StartsWith("/md/", StringComparison.OrdinalIgnoreCase))
{
await _next(ctx); // 交回常规管线,页面照常渲染
return;
}
// 剥掉 /md/ 前缀,得到业务 slug
// 尾部的斜杠一并去掉,避免 /md/a/ 与 /md/a 被当成两个资源
var slug = path[4..].TrimEnd('/');
// /md/product/gear-x.md 与 /md/product/gear-x 视为同一资源
if (slug.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
slug = slug[..^3];
// 从内容服务取文档;返回 null 表示该 slug 不存在或未开放索引
var doc = await _content.GetBySlugAsync(slug);
if (doc is null)
{
ctx.Response.StatusCode = StatusCodes.Status404NotFound; // 缺失就明确 404,别返回空 200
await ctx.Response.WriteAsync("# 404 Not Found\n"); // 正文也给一句话,便于人工核对
return;
}
// 缓存键带上内容版本号:正文一变,键就变,天然不会读到旧文本
var cacheKey = $"md:{slug}:v{doc.Version}";
var md = await _cache.GetOrCreateAsync(cacheKey, async entry =>
{
entry.SlidingExpiration = TimeSpan.FromHours(6); // 6 小时活跃窗口足够
return MarkdownProjector.Project(doc); // 投影:区块过滤 + 层级重建
});
// 状态码显式写 200:中间件短路后默认值就是 200,但写出来便于读代码
ctx.Response.StatusCode = StatusCodes.Status200OK;
// 必须显式声明 charset,否则部分抓取端会按 latin-1 解码,中文全乱
ctx.Response.ContentType = "text/markdown; charset=utf-8";
// 自定义头回传内容版本,日志里能直接看出抓的是哪一版
ctx.Response.Headers["X-Content-Version"] = doc.Version.ToString();
// 用正文修改时间做 ETag,配合 If-None-Match 让爬虫增量抓取
ctx.Response.Headers.ETag = $"\"{doc.Version}\"";
await ctx.Response.WriteAsync(md); // 直接写文本,不再经过 Razor 视图
}
}
投影器(Projector)本身不复杂,但有几个细节值得写下来:
// MarkdownProjector.cs —— 把结构化正文数据投影成 Markdown 文本
// 输入是 CMS 的 ContentDoc,不是渲染好的 HTML:反向转换会把噪声再解一遍
public static class MarkdownProjector
{
public static string Project(ContentDoc doc)
{
// 顺序拼接:输出顺序就是阅读顺序,不要事后再排序
var sb = new StringBuilder();
// 1) 首部元信息:AI 对开头 200 字的权重最高,放最关键的结论
sb.Append($"# {doc.Title}\n\n");
// 摘要当引言,避免正文一上来就是细节
sb.Append($"> {doc.Summary}\n\n");
foreach (var block in doc.Blocks) // Blocks 是编辑器里的结构化块序列
{
switch (block.Type)
{
// 标题:页面里 h2 是视觉层级,这里映射成 Markdown 的 ##
// +1 是因为 title 已占用一级,避免结构断层
case BlockType.Heading:
sb.Append($"\n{new string('#', block.Level + 1)} {block.Text}\n\n");
break;
// 段落:内部不做任何压缩,换行原样保留
// 切分器是按空行与标题落刀的,压掉换行等于破坏引用单元
case BlockType.Paragraph:
sb.Append($"{block.Text}\n\n");
break;
// 表格:交给 AppendTable,列头与分隔行缺一不可
case BlockType.Table:
AppendTable(sb, block);
break;
// 代码:语言标记不能省
// 不带语言的代码块在抽取时容易和正文糊在一起
case BlockType.Code:
sb.Append($"```{block.Language}\n{block.Text.Trim()}\n```\n\n");
break;
// 运营位 / 导航 / 推荐位:整块跳过
// 这是镜像文本比页面干净的核心原因,不做「保留但压缩」的折中
case BlockType.Promo:
case BlockType.Navigation:
case BlockType.RelatedList:
break;
}
}
return sb.ToString();
}
// 表格单独抽方法:列数不一致是这类投影最常见的事故
private static void AppendTable(StringBuilder sb, ContentBlock block)
{
// 表头行:Markdown 表格认第一行当列名
sb.Append("| " + string.Join(" | ", block.Headers) + " |\n");
// 分隔行:必须与列数一致,少一列整张表就退化成普通文本
sb.Append("|" + string.Join("|", block.Headers.Select(_ => "---")) + "|\n");
foreach (var row in block.Rows) // 逐行输出数据行
{
// 单元格里的竖线要转义,不然一整行会被拆成多余的列
var cells = row.Select(c => c.Replace("|", "\\|"));
sb.Append("| " + string.Join(" | ", cells) + " |\n");
}
sb.Append("\n"); // 表后留空行,确保下一个块独立成段
}
}
注册顺序上,中间件要放在静态文件中间件之前、路由之前,保证 /md/ 请求不过 MVC 管线:
// Program.cs 片段:注册顺序决定镜像端点会不会被 MVC 抢走
// 中间件越靠前越先拿到请求;放在路由之后就永远短路不掉
var app = builder.Build();
app.UseMiddleware<MarkdownMirrorMiddleware>(); // 放在最前面,先短路 /md/ 请求
app.UseStaticFiles(); // 静态文件:镜像端点不依赖它,但页面依赖
app.UseRouting(); // 走到这里说明不是 /md/ 请求,交回常规管线
app.MapControllers();
app.Run();
llms.txt:给镜像端点和关键页面做一份索引
镜像端点解决"单页读得干净",索引文件解决"AI 知道有哪些页值得读"。llms.txt 是一份放在站点根目录的纯文本清单,用 Markdown 语法组织,告诉 AI 引擎哪些链接是核心内容。
它和 sitemap 的分工不一样:sitemap 面向通用搜索引擎,罗列全站 URL 与时间戳,讲究完备;llms.txt 面向大模型,讲究取舍——只列你希望被引用为权威来源的那几十条。
# 站点名(示例:机械配件供应商)
# 说明段不是客套话,它经常被直接当成"这个站点是干什么的"的回答素材
> 一段两三百字的站点说明。这里要把业务范围、服务区域、核心能力写清楚,
> 因为这段文字经常被直接用作"这个站点是干什么的"的回答素材。
## 核心产品
- [齿轮箱产品页](/md/product/gear-x):型号 X 的规格、选型条件与交付周期
- [齿轮箱选型指南](/md/guide/selection):按扭矩与转速选型的完整方法
## 企业信息
- [关于我们](/md/about):成立年份、产能、质量体系认证
## 可选
- [技术白皮书](/md/whitepaper/2026):可公开引用的技术说明
生成这份文件不建议手工维护,正文一变就不同步。写个定时任务扫 CMS 里的内容集合,按 LlmsPriority 字段排序输出即可,同时把 llms.txt 本身也纳入镜像端点的监控——它被 AI 抓取频次高,值得单独看日志。
灰度上线与观测:怎么判断 AI 真来读了
镜像端点和页面走的是不同 URL,日志里天然可分辨,这给验证留了方便。上线分三步:先只对内网开放,用 curl 逐个核对投影文本;再让 llms.txt 只列 5 个低风险页面,观察两周;最后全量放开。
内网核对那一步别省,几条命令就能把大部分配置问题挡住:
# 环境:curl 7.x;站点已启用 HTTP/2 与 gzip
# 1) 核对内容类型与字符集,中文乱码基本都栽在这
curl -sI https://example.com/md/product/gear-x | grep -i '^content-type'
# 期望:text/markdown; charset=utf-8
# 2) 核对 ETag 是否存在,缺失说明没接内容版本
curl -sI https://example.com/md/product/gear-x | grep -i '^etag'
# 3) 带上 ETag 再请求,验证 304 是否生效
curl -s -o /dev/null -w '%{http_code}\n' \
-H 'If-None-Match: "<上一步取到的值>"' https://example.com/md/product/gear-x
# 期望:304;若返回 200,说明响应头写了但没走条件请求分支
# 4) 核对 llms.txt 可读、未被 CDN 缓存成 HTML
curl -s https://example.com/llms.txt | head -5
# 5) 核对压缩:Markdown 文本压缩后通常只剩三成体积
curl -sI -H 'Accept-Encoding: gzip' https://example.com/md/product/gear-x | grep -i 'content-encoding'
观测上我们盯的是三个指标,方向比数值本身重要:
| 指标 | 采集方式 | 上线后 4 周实际变化 | 说明 |
|---|---|---|---|
| 镜像端点抓取次数 | Nginx 访问日志按 /md/ 前缀聚合 |
每周 60 次左右起步,第 4 周约 210 次 | 说明索引被读到并产生了后续抓取 |
| 镜像请求的 UA 分布 | 日志 UA 字段分组 | 出现 4 类 AI 抓取 UA,另有 2 类无标识 | 无标识 UA 占比约三成,属正常现象 |
| 引用口径一致率 | 人工抽 20 个问题问 AI,核对与官网是否一致 | 改造前 11/20,第 4 周 17/20 | 关注的指标是「回答是否与官网口径一致」 |
那个「引用口径一致率」的抽检方法值得多说两句。不要只看 AI 提没提到你,要看它说的和官网写的是不是一回事。我们抽的是 20 个带具体数字的问题(保修年限、交付周期、型号区别),逐条标注一致 / 矛盾 / 未提及。改造后的提升主要出现在原来"矛盾"的那几条上——这正好对应镜像文本把埋在组件深处的条款提到了正文首部。
日志侧的统计不必上重型平台,一个几十行的脚本就够用:
# 环境:Python 3.10+,仅用标准库(re / collections / gzip)
# 作用:从 Nginx access log 里统计镜像端点的抓取情况
import re
from collections import Counter, defaultdict
# 日志格式按实际配置改;这里假设是 combined 格式
LOG = re.compile(
r'(?P<ip>\S+) \S+ \S+ \[(?P<time>[^\]]+)\] '
r'"(?P<method>\S+) (?P<path>\S+) [^"]*" (?P<status>\d{3}) '
r'\S+ "(?P<ref>[^"]*)" "(?P<ua>[^"]*)"'
)
def scan(path_log: str) -> None:
by_host = Counter() # 抓取方分布
by_path = Counter() # 最常被抓的页面
# errors='ignore':日志里难免混入二进制垃圾,别让一行脏数据中断整个统计
with open(path_log, encoding='utf-8', errors='ignore') as f:
for line in f:
m = LOG.match(line)
if not m: # 格式不符的行直接跳过,别让脏数据污染统计
continue
req_path = m['path']
# 只统计镜像端点,页面请求不进这份统计
if not req_path.startswith('/md/'):
continue
ua = m['ua'] or 'unknown'
# UA 只取首个 token,避免版本号把分组打散
by_host[ua.split('/')[0][:40]] += 1
by_path[req_path] += 1
# 状态码暂不聚合:404 单独人工看,混进总数会掩盖真实的抓取量
print('抓取方分布(前 10)') # 看是谁在抓,比看总量更有意义
for ua, n in by_host.most_common(10):
print(f' {n:>6} {ua}')
print('被读最多的镜像页(前 10)') # 顺带发现哪些页面被反复读
for p, n in by_path.most_common(10):
print(f' {n:>6} {p}')
# 注意:无 UA 的请求不要当成爬虫忽略,我见过占三成的比例
if __name__ == '__main__':
scan('/var/log/nginx/access.log') # 换成实际日志路径即可
sequenceDiagram
participant B as AI 抓取器
participant S as 站点(ASP.NET Core)
participant L as 访问日志
B->>S: GET /llms.txt
S-->>B: 200 纯文本索引
B->>S: GET /md/product/gear-x
S-->>B: 200 text/markdown + ETag
B->>L: 记录(UA、路径、状态码)
B->>S: GET /md/product/gear-x(带 If-None-Match)
S-->>B: 304 Not Modified
Note over B,S: 后续增量抓取只取变更内容
三个容易踩错的判断
以为 Markdown 镜像要替换 HTML。 不需要,也不应该。镜像端点是并存的第二份表示,HTML 页面继续服务用户与常规搜索引擎。两者同源同版本,只是呈现方式不同。
以为要暴露全部内容。 镜像端点应该走和页面一样的权限判断:付费课程、内部文档一律不输出。生产环境里我们给投影管线加了一个 IsPubliclyIndexable 开关,默认关闭,只有显式打开的内容才会出现在镜像端点和 llms.txt 里。
以为上线就能见效。 我们的实际节奏是第 2 周才在日志里看到零星的 /md/ 请求,第 4 周才出现稳定的重复抓取。索引层本身有延迟,把观察窗口按周计算,别按天。
下一步会怎么变
可以预期的方向是内容表示与内容渲染分离。现在大多数站点的内容在渲染之后才成为"页面",AI 想读就得自己做还原;往后越来越多的站点会同时维护 HTML 表示、Markdown 表示和结构化数据表示,三者由同一份内容数据派生。落到工程上,就是今天这种"投影管线"从可选项变成基础组件。
具体到实施,我的建议是先在派生层用最小改动上线——一个中间件、一个投影器、一份 llms.txt,剩下的靠日志说话。等看到了真实的抓取数据,再决定要不要把投影逻辑下沉到 CMS 保存时(也就是内容一改就生成镜像,而不是请求时生成)。后者性能更好,但改的内容更多,没有观测数据支撑的情况下不值得先做。
评论区可以聊聊你们的站内正文抽取情况:如果 AI 回答里出现的细节和你官网上写的不一致,先把那一页的 view-source 拉出来看看正文在第几层——这个动作往往比加任何结构化数据都直接。
关键词:GEO、AI优化AIO、llms.txt、Markdown 镜像、ASP.NET Core 中间件、内容投影、AI 爬虫、结构化数据
参考与延伸
- llms.txt 规范说明:https://llmstxt.org
- ASP.NET Core 中间件官方文档:https://learn.microsoft.com/aspnet/core/fundamentals/middleware/
- MDN 关于 HTTP 内容协商与 Vary 头的说明:https://developer.mozilla.org/docs/Web/HTTP/Content_negotiation
- Schema.org 官方词汇表(用于镜像页与页面 Schema 对齐):https://schema.org