设备案例页为什么拿不到 AI 引用:ASP.NET Core 服务端渲染与 Article Schema 改造全记录

2026-09-17 02:16:41 14 次浏览
.NET 8BlazorArticle SchemaJSON-LD服务端渲染GEOAI优化AIO

适用读者:制造业 B2B 网站负责人、.NET 后端工程师、正在做官网改版排期的技术团队

上个月有个做包装机械的客户问我一个问题:他们官网案例中心挂着六十多个设备改造案例,百度收录一直正常,可客户和销售试着在 DeepSeek、豆包里问"卧式贴标机换型时间怎么压缩",翻遍回答没有一条提到他们的案例页。后来我拿到服务器的 GPTBot 访问日志一看,抓取请求确实来了,但每个案例页只抓了一个不到 2KB 的 HTML 骨架就走了。问题不在内容质量,在渲染方式:案例中心整站是 Blazor WebAssembly 客户端渲染,AI 爬虫拿到的是一只空碗。

这篇文章把这次改造从头到尾拆开讲:怎么定位问题、怎么在 ASP.NET Core 里把案例页改成服务端渲染、Article Schema 怎么和现有的 Product 数据打通,以及改完之后四周的数据变化。所有代码都在 .NET 8 下跑通过,可以直接抄。

先看数据:改造前后的关键指标对比

先给结论,再讲过程。改造上线后第 4 周和第 1 周的数据对比如下,统计口径是三条 AI 引擎(DeepSeek、豆包、Kimi)每日各问 10 组行业问题、人工核对回答中是否出现案例链接或案例要点:

指标 改造前 改造后第 4 周 变化
AI 爬虫抓取单案例页平均字节数 1.9 KB 46.3 KB 约 24 倍
案例页可读正文(HTML 源码内) 0 字 平均 1120 字 从无到有
结构化数据覆盖率(有 JSON-LD 的案例页占比) 0% 100%
三引擎合计案例引用次数 / 周 0 11 从 0 到 11
引用页面中带 Article Schema 的比例 91% 数据见下文

说明两点,免得有人对不上数:第一,引用次数按"回答里出现案例链接或转述案例核心数据"计算,同一天同一个引擎只计一次;第二,91% 而不是 100%,是因为有两条引用来自案例被第三方转载页转述,源头不是我们的页面。

排查过程:从日志里看出空骨架

定位这事的线索链很短,但每一步都值得展开说。

第一步,确认 AI 爬虫来过没有。在 Nginx 访问日志里按 User-Agent 过滤 GPTBot、PerplexityBot、Bytespider,案例中心的请求量是有的,日均三十多次,说明 robots.txt 的放行没问题。

第二步,看爬虫到底拿到了什么。把日志里某次请求的响应字节数拉出来:列表页 2.3KB,详情页 1.9KB。一个正常案例页哪怕只有图和标题,源码也不止这个数。用 curl 模拟一次抓取,返回的 HTML 里 body 只有一行 <app></app>,外加几段 script 标签——所有内容都要等浏览器把 WebAssembly 跑起来才出现。

第三步,对一下搜索引擎爬虫的行为。百度蜘蛛抓同样的页面,源码里同样没有正文,但百度有渲染队列,收录正常;主流生成式引擎(Generative Engine)的爬虫目前不做客户端渲染,抓到什么就是什么。这就是"百度收录正常、AI 引用为零"并存的解释。

结论:不是内容问题,不是授权问题,是页面在 AI 爬虫眼里根本不存在。

原理剖析:AI 引擎从抓取到引用的链路,卡在哪一环

要理解为什么空骨架必死,得把生成式引擎的内容消费链路摊开。这条链路大致分四段,我用一个流程图表示:

flowchart LR
    A[AI 爬虫抓取页面] --> B{响应是完整 HTML?}
    B -- 否 --> C[丢弃正文<br/>仅记录 URL]
    B -- 是 --> D[DOM 解析<br/>提取主内容块]
    D --> E[正文分块 Chunking<br/>按语义段落切分]
    E --> F[向量化入库<br/>建立引用候选池]
    F --> G[用户提问<br/>检索召回 + 生成回答]
    G --> H{引用来源列表}

关键在第二段和第三段。生成式引擎的抓取器绝大多数不执行 JavaScript,也不加载 WebAssembly,这是工程上的取舍:抓取要控制算力成本,而大量站点不依赖客户端渲染。你的页面是 <app></app> 空标签,DOM 解析阶段就提不出主内容块,后面分块、向量化全都无从谈起,页面等于在候选池里不存在。

第三段的内容分块(Chunking)也值得单独讲。引擎一般按 DOM 的语义边界切块——<h2><article><section> 这些标签是切分依据,再配上段落长度上限。这意味着两个直接影响:

  1. 正文必须出现在服务端返回的 HTML 里,且用语义化标签包裹,而不是一堆 divdiv
  2. 每个案例页要有清晰的标题层级,引擎切块时才能把"设备型号、改造目标、实施周期、量化结果"这类信息聚成一个完整语义单元,而不是切碎了互相污染。

JSON-LD 结构化数据在这条链路里是"保险层":正文分块负责召回,Schema 负责帮引擎确认页面类型、发布主体和关键实体(实体指 Entity,即被引擎识别的具名对象,如产品型号、公司、设备类别),命中引用时置信度更高。我们的改造就是围绕这两件事做的。

实战改造:把案例中心改回服务端渲染

客户的技术栈是 .NET 8 + Blazor WebAssembly 托管部署。我们没推倒重来,只把案例中心这一个模块改成服务端输出,方案是 Razor Pages + 结构化数据注入,改动面控制在两周工作量以内。

第一步:案例列表页与详情页服务端化

新建一个 Cases Razor Pages 区域,从原来的 WebAssembly 路由里把 /cases 前后端路由剥离开,服务端直接渲染完整 HTML。核心动作是把原来 WebAssembly 调用的 API 改成页面模型直接查库,省掉一次 HTTP 往返:

// 环境:.NET 8 / ASP.NET Core Razor Pages
// 依赖:Microsoft.EntityFrameworkCore 8.0.4(已接入现有 DbContext)
public class CaseDetailModel : PageModel
{
    private readonly AppDbContext _db;

    public CaseDetailModel(AppDbContext db) => _db = db;

    [BindProperty(SupportsGet = true)]
    public string Slug { get; set; } = default!;   // SEO 友好 URL,如 /cases/labeler-changeover

    public CaseStudy Case { get; private set; } = default!;

    public async Task<IActionResult> OnGetAsync()
    {
        // 案例正文、关联产品、量化指标一次查回,避免 N+1
        Case = await _db.CaseStudies
            .Include(c => c.Product)          // 关联设备型号,用于 Schema 的 about 字段
            .Include(c => c.Metrics)          // 量化结果:换型时间、良品率等
            .AsNoTracking()                   // 只读查询,关掉变更跟踪省内存
            .FirstOrDefaultAsync(c => c.Slug == Slug && c.IsPublished);

        if (Case is null) return NotFound(); // 404 必须是 404,不能软跳首页

        // 顺手在响应头里带上最后修改时间,配合 304 协商缓存
        Response.GetTypedHeaders().LastModified = Case.UpdatedAt;
        return Page();
    }
}

模板这边要注意的不是功能,是标签语义:案例标题用 <h1>,改造过程小节用 <h2>,量化结果表用真正的 <table>,正文整体包在 <article> 里。原来 WebAssembly 版本里全是无语义的 div,引擎切块会切得很碎。

第二步:Article Schema 注入,和 Product 打通

案例在 schema.org 里用 Article 类型承载,about 指向关联的设备(Product),author 指向公司(Organization)。这样引擎能把"某公司发布的一篇关于某设备的技术案例"这个三元关系读出来。注入代码放在页面模型里,渲染时输出到 <head>

// Article JSON-LD 构建,复用项目里已有的 Organization Id
public string BuildCaseJsonLd(CaseStudy c, string pageUrl)
{
    var jsonLd = new
    {
        _context = "https://schema.org",           // JSON-LD 固定上下文声明
        _type = "Article",                          // 案例按技术文章处理
        headline = c.Title,                         // 标题,控制 110 字符内防截断
        description = c.Summary,                    // 一句话摘要,引擎常直接用作候选摘要
        datePublished = c.PublishedAt.ToString("yyyy-MM-dd"),
        dateModified = c.UpdatedAt.ToString("yyyy-MM-dd"), // 更新时间新鲜度信号
        author = new
        {
            _type = "Organization",
            _id = "https://www.example.com/#org"    // 全站唯一的组织实体锚点
        },
        about = new                                 // 案例讲的是哪台设备
        {
            _type = "Product",
            name = c.Product.Model,                 // 设备型号,B2B 检索的核心实体
            category = c.Product.CategoryName
        },
        mainEntityOfPage = pageUrl                  // 声明本页主实体就是这篇案例
    };
    // 序列化时忽略大小写约定的下划线前缀,输出标准 @context/@type
    return JsonSerializer.Serialize(jsonLd, JsonLdOptions);
}

这里有个实现细节:匿名对象里我用了 _context_type,序列化时通过自定义 JsonNamingPolicy 转回 @context@type——@ 开头没法直接当 C# 属性名,团队里通用做法是加前缀再替换,或者手写 JsonObject。别小看这个细节,JSON-LD 的 @context 写错一个字符,整个 Schema 就白写了。

第三步:sitemap 跟上,别让新页面等蜘蛛自己找

案例页改造完要尽快被抓取,我们把 sitemap 里的案例 URL 全部带上 lastmod,并且每次案例更新自动刷新时间戳。生成逻辑很简单:

// sitemap 增量刷新:案例更新时重写对应 URL 的 lastmod
// 依赖:System.Xml.Linq(内置,无需安装)
public async Task RefreshCaseNodeAsync(string slug, DateTime updatedAt)
{
    var doc = XDocument.Load(_sitemapPath);           // 读现有 sitemap.xml
    var ns = doc.Root!.Name.Namespace;
    var node = doc.Descendants(ns + "url")
        .FirstOrDefault(u => (string?)u.Element(ns + "loc")!.Value
            .EndsWith($"/cases/{slug}", StringComparison.Ordinal));
    if (node is null)                                 // 新案例:追加一个 url 节点
    {
        node = new XElement(ns + "url",
            new XElement(ns + "loc", $"https://www.example.com/cases/{slug}"));
        doc.Root!.Add(node);
    }
    node.Element(ns + "lastmod")!
        .SetValue(updatedAt.ToString("yyyy-MM-dd"));  // 只动时间戳,不动其他
    doc.Save(_sitemapPath);
}

整条改造管线的结构我用一张图收拢:

flowchart TD
    A[案例编辑后台保存] --> B[EF Core 写库<br/>CaseStudies 表]
    B --> C[Razor Pages<br/>服务端渲染完整 HTML]
    C --> D[页面模型构建 JSON-LD<br/>Article + Product + Organization]
    D --> E[head 注入 Schema]
    B --> F[sitemap lastmod 自动刷新]
    C --> G[AI 爬虫抓取完整正文]
    E --> G
    F --> H[增量重抓调度]
    G --> I[分块向量化进入引用候选池]

字段映射:业务库到 Schema 的对照表

落地时最容易卡住的是"库里这个字段该塞到 Schema 哪个属性"。我们当时的映射清单如下,供对号入座:

业务库字段 Schema 属性 说明
案例标题 headline 控制在 110 字符内,超出会被截断
案例摘要 description 引擎生成回答时常直接摘取,别写成广告语
发布/更新时间 datePublished / dateModified 更新案例务必同步刷新 dateModified
关联设备型号 about(Product.name) B2B 检索最高频实体,必填
改造周期 无标准属性,写入正文 非标数据进正文,别硬造属性
客户行业 about(Product.category) 辅助引擎做行业聚类
公司主体 author(Organization) 复用全站唯一的组织实体锚点

两个映射原则:业务库里没有标准 Schema 属性可放的字段,直接写进正文,不要发明属性——引擎遇到不认识的属性通常直接忽略,写错不如不写;about 指向的 Product 必须在站内有对应的落地页,实体之间要能互相印证。

四周的数据与两个意外发现

改完两周后,日志里案例页的抓取字节数上来了,第三周开始出现引用。除了开头表格里的主指标,有两个次要发现值得记录:

一是引用的提问形态。11 次引用里 7 次是"设备型号 + 具体指标"的长尾问法,比如"贴标机换型时间能不能压到 5 分钟以内"——恰好是我们案例正文里量化小节的切块。这印证了分块假设:引擎召回的不是整页,是那个语义完整的段落。

二是 Article Schema 的作用边界。有 1 次引用来自一个早期没注入 Schema 的测试案例页(正文同样是服务端渲染的),说明服务端渲染是门票,Schema 是加分项而不是准入证。如果你的页面还是客户端渲染,先改渲染方式,别指望 Schema 救场。

三个常见误区,最后澄清一下

误区一:"百度收录正常,AI 就该能引用我。"两套爬虫管线完全独立,收录状态不可互相推导。

误区二:"把案例页做成 PDF 或图片更美观。"生成式引擎对 PDF 的解析质量远低于 HTML 正文,关键数据请进 HTML。

误区三:"Schema 属性越多越好。"属性堆砌不提升召回,错的属性反而让解析器丢弃整段数据,按标准映射表来就够。

往后看,生成式引擎的抓取器对服务端渲染的依赖短期不会变,但"引用"正在从链接引用走向"转述不署名",案例正文里带量化数据的段落会越来越值钱。下一步我们打算给案例库加行业标签维度,观察引擎在行业聚合类问题下的引用表现,有数据了再写一篇。

代码部分从 Razor Pages 页面模型到 sitemap 刷新都是现成可跑的,如果你的案例中心还压在 WebAssembly 或 Vue 里,欢迎来评论区聊聊你的迁移路径。

关键词:GEO、生成式引擎优化(Generative Engine Optimization)、AI优化AIO、ASP.NET Core、Blazor、Article Schema、JSON-LD、服务端渲染(SSR)、sitemap lastmod

参考与延伸

🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。