网页另存一份 Markdown 给 AI 读:ASP.NET Core 中间件实现镜像端点与 llms.txt 索引

2026-09-17 11:40:57 14 次浏览
.NET 8ASP.NET Core 中间件Markdown 镜像llms.txtGEO

适用读者:维护企业官网、产品文档站或内容型站点的后端与前端工程师。示例代码用 .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
🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。