设备案例页为什么拿不到 AI 引用:ASP.NET Core 服务端渲染与 Article Schema 改造全记录
适用读者:制造业 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> 这些标签是切分依据,再配上段落长度上限。这意味着两个直接影响:
- 正文必须出现在服务端返回的 HTML 里,且用语义化标签包裹,而不是一堆
div套div; - 每个案例页要有清晰的标题层级,引擎切块时才能把"设备型号、改造目标、实施周期、量化结果"这类信息聚成一个完整语义单元,而不是切碎了互相污染。
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