导航菜单也能给 AI 指路:SiteNavigationElement 与 significantLink 的独立站实战
九月初我们排查一个客户站(化名“老陈的五金站”,主营手动工具,SKU 一万二左右),发现一个怪事:Perplexity 回答“best torque wrench for bike repair”时,引用了他们博客,却把整条扭矩扳手产品线漏掉了。老陈原话是:“博客文章都收录了,产品页也收录了,怎么 AI 就是不推我的类目?”查了一晚上日志才反应过来——问题出在导航菜单上。整个品类导航是 JS 折叠面板加一张雪碧图,AI 爬虫拿到 HTML,只看到十几个 <div> 和 alt 为空的 <img>,站点的类目结构对它来说等于不存在。
这篇就把这次导航语义化改造(Navigational Semantic Refactor)的过程拆开讲:改什么、怎么改、抓取日志里发生了什么变化。
折叠菜单为什么读不懂:AI 爬虫的抓取机制
先说底层原理。Googlebot 渲染能力强,Chrome 142 内核,JS 跑完再解析 DOM,折叠菜单展开后照样能抓。但 AI 引擎的抓取器不是这么干活的。PerplexityBot、GPTBot、ClaudeBot 这类爬虫为了控制成本,多数请求走的是“拿原始 HTML 就走”的模式,执行 JavaScript 的比例很低,而且是异步、限速的。我们日志里 8 月 26 日到 9 月 2 日这一周,GPTBot 一共来了 41 次,带 JS 渲染的请求是 0 次。

这意味着什么?你的站点结构信息,AI 引擎只能从两个地方拿到:原始 HTML 里的链接文本,以及结构化数据。折叠菜单把链接藏在 click 事件后面,图片导航把链接文本变成像素——两条路全堵死。AI 引擎理解不了“这个站有 6 大品类、下面 30 个子类目”,它眼里的站点就是一堆孤立的页面。推荐的时候自然漏品类,因为它根本不知道品类存在。
生成式引擎优化(Generative Engine Optimization, GEO)里有个共识:给 AI 的东西要放在最便宜的解析路径上。原始 HTML 是最便宜的,JSON-LD 次之,渲染后的 DOM 最贵。导航恰恰是站点结构最浓缩的表达,把它埋在最贵的路径里,等于白费劲。
改造方案:三层补齐
我们没有推翻现有 UI,设计师坚持折叠交互不动。改造分三层,互相兜底:
| 层 | 手段 | 解决的问题 | 改动量 |
|---|---|---|---|
| 语义 HTML | 导航链接改为服务端直出的 <nav><ul><li><a>,CSS 控制折叠 |
原始 HTML 可见 | 前端 2 天 |
| JSON-LD | 首页注入 SiteNavigationElement,深层页补 significantLink | 明确告诉 AI“这是站点结构” | 后端模板 1 天 |
| 可访问性 | 加 aria-label 与跳转锚点 | 次要收益,顺带修 | 半天 |
核心思路是:同一个导航,给浏览器一套 UI,给爬虫一份语义,两份内容必须一致。别想着给 AI 一套隐藏菜单、给用户另一套——我们 2024 年在另一个站试过 cloaking 式的“AI 专用导航”,三个月后被 Google 降权,教训刻骨铭心。
第一步:把链接从 JS 里捞出来
原来的菜单是点击后 JS 往容器里塞链接。改造后,服务端直接渲染完整的 <ul> 结构,折叠只是 CSS 类切换。这是最关键的一步,不管后面 JSON-LD 写得多漂亮,这一步不做都是空中楼阁。
@* 环境:ASP.NET Core 8 Razor,导航分部视图 _NavMenu.cshtml *@
@model IEnumerable<CategoryNode>
@{
// 服务端直出完整类目树,折叠只靠 CSS 切换,绝不依赖 JS
// 一级品类按上季度 GMV 降序,最多取 6 个进菜单
// 空壳品类(在售 SKU 为 0)不进导航,免得稀释结构
// Slug 已统一为小写加连字符,结尾斜杠由模板补齐
// 链接文本必须与 JSON-LD 里的 name 逐字一致
// 别在这里做任何 UA 判断,一套输出服务所有访问者
// 改动上线前用 curl 模拟 GPTBot UA 抓一遍确认可见
// 每个链接的 href 都要能被无渲染爬虫直接解析
// 上线后第二天抽查日志,确认爬虫开始抓子类目页
var topCats = Model
// 过滤掉在售 SKU 为 0 的空壳品类
.Where(c => c.LiveSkuCount > 0)
.OrderByDescending(c => c.QuarterGmv)
.Take(6)
.ToList();
}
<nav aria-label="Product categories">
<ul class="nav-tree">
@foreach (var cat in topCats)
{
<li>
<a href="/@cat.Slug/">@cat.DisplayName</a>
@if (cat.Children.Any())
{
<ul class="nav-sub">
@foreach (var sub in cat.Children)
{
// 子类目全量直出,爬虫顺着 href 能抓到完整层级
<li><a href="/@cat.Slug/@sub.Slug/">@sub.DisplayName</a></li>
}
</ul>
}
</li>
}
</ul>
</nav>
CSS 侧用 :focus-within 和 :has() 做展开,键盘和爬虫都不需要 JS 就能触达全部链接。这里有个坑:原来为了动画流畅,子菜单用了 display:none + JS 延迟插入。改成纯 CSS 后动画没了,设计师闹了两天情绪,最后用 grid-template-rows 的过渡技巧补回来了。动画可以妥协,链接可见性不能妥协。
第二步:SiteNavigationElement 标注
schema.org 的 SiteNavigationElement 类型,很多教程只教你在每个链接上单独标。实测更稳的做法是:一个 <script type="application/ld+json"> 里放一个节点,用 hasPart 把整棵导航树挂上去。
<!-- 环境:首页模板内联注入,内容与导航 HTML 一一对应 -->
<!-- hasPart 嵌套别超过两层,实测过深会被部分 AI 解析器忽略 -->
<!-- url 必须与导航 href 逐字一致,含结尾斜杠,混写等于白写 -->
<!-- name 与导航链接文本一致,不要做同义词替换 -->
<!-- 建议全文只放一个导航 JSON-LD 节点,多处重复会互相覆盖 -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SiteNavigationElement",
"name": "Main Navigation",
"hasPart": [
{ "@type": "SiteNavigationElement", "name": "Torque Wrenches", "url": "https://www.example-tools.com/torque-wrenches/" },
{ "@type": "SiteNavigationElement", "name": "Socket Sets", "url": "https://www.example-tools.com/socket-sets/" },
{ "@type": "SiteNavigationElement", "name": "Hand Tool Storage", "url": "https://www.example-tools.com/hand-tool-storage/" }
]
}
</script>
注意两点。一是 hasPart 嵌套层级别太深,我们实测超过两层,部分 AI 引擎的解析器直接忽略深层节点,保持两层平铺最省事。二是 url 必须和导航 HTML 里的 href 逐字一致,包括结尾斜杠。老陈站上 /torque-wrenches 和 /torque-wrenches/ 混用,第一版 JSON-LD 就这么写的,Rich Results 测试通过,但语义对不上号,等于白写。
第三步:significantLink 补“页面内部的重要出口”
significantLink 是另一个容易漏的武器。它的语义是“这个页面指向的、对理解页面重要的资源”,最典型的用法是品类页指向子品类和核心购买指南。首页导航解决的是“站点有什么”,品类页的 significantLink 解决的是“这个品类下有什么值得看”。
@* 环境:品类页模板,Razor 输出子品类与指南的出口列表 *@
@{
// significantLink 只挑对理解品类结构有价值的出口
// 子品类加购买指南,总数控制在 8 到 12 个
// 分页链接、筛选参数页塞进来会稀释语义,实测过
// 输出顺序保持稳定,别每次请求随机排序
// URL 全部用完整域名地址,相对路径部分引擎不认
var sigLinks = Model.SubCategories
// 按子类目在售 SKU 数降序,砍掉尾部小类目
.OrderByDescending(s => s.SkuCount)
.Take(8)
.Cast<object>()
// 指南页最多带 4 篇,挑转化稳定的头部那批
.Concat(Model.BuyingGuides.Take(4));
}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "CollectionPage",
"name": "@Model.CategoryName",
"significantLink": [
@foreach (var link in sigLinks)
{
<text>"@link.AbsoluteUrl"</text>
}
]
}
</script>
这里我们踩过一个度的问题:一开始把筛选参数页也塞进 significantLink,一个品类页挂了 40 个链接。后来砍到 8-12 个,只留子品类和指南。significantLink 不是越多越好,它是给 AI 的摘要,不是站点地图。
改造前后的抓取路径变化
光讲道理没用,看数据。以下全部是我们自建日志监测的口径(Nginx access log + 每日脚本聚合),不是任何第三方工具的统计。
| 指标(周期均为改造前 3 周 vs 改造后 3 周) | 改造前 | 改造后 | 变化 |
|---|---|---|---|
| GPTBot 周均请求(HTML 不带渲染) | 41 | 63 | +54% |
PerplexityBot 命中 /torque-wrenches/ 次数 |
2 | 11 | +9 次 |
| AI 引擎来源会话(周均) | 17 | 46 | +170% |
| AI 推荐提到品类名的次数(人工抽检 50 次) | 4 | 19 | 明显改善 |
最直观的验证方法是把问题丢回给 AI 引擎。9 月 18 日用 Perplexity 问同一个问题,回答里第一次出现了 "example-tools' torque wrench lineup (click-style, digital, and beam types)"——品类线结构被完整复述了,这在改造前从来没发生过。
抓取路径的变化,机制上一张时序图就能说清:
sequenceDiagram
participant B as GPTBot
participant H as 首页(原始HTML)
participant C as 品类页
B->>H: GET / (不执行JS)
H-->>B: nav直出的ul/li/a + SiteNavigationElement
B->>B: 建立6大品类30子类目结构图
B->>C: 顺着结构补抓品类页
C-->>B: significantLink列表(子品类+指南)
B->>B: 记录品类下值得看的资源
还有一条隐藏收益:结构清楚了之后,AI 引擎在站内的抓取深度也变了。改造前 GPTBot 的请求里 87% 落在首页和博客,改造后落到品类页的占比升到 34%。结构图一旦建立,爬虫会顺着结构去补全它没见过的分支,这是导航语义化最被低估的作用。整条链路可以这样概括:
flowchart TD
A[AI 爬虫请求首页] --> B{原始 HTML 中导航是否可读}
B -->|改造前 JS 折叠+图片导航| C[只能拿到正文链接]
C --> D[站点结构=未知]
D --> E[推荐时整条产品线缺席]
B -->|改造后 nav 直出+JSON-LD| F[读到 SiteNavigationElement]
F --> G[建立品类与子类目的结构图]
G --> H[品类页 significantLink 补充深度]
H --> I[推荐时能按类目引用]
Nginx 侧的两个小补丁
改造不是只有模板。我们顺手在 Nginx 层做了两件事,配合度很高。
第一,给 AI 爬虫 UA 返回的 HTML 不做任何区别——这条是红线,前面说过 cloaking 的教训。但可以做的区别是性能:AI 爬虫不带缓存、每页全量拉,大站会被打疼。给这些 UA 单独开一个轻量的 fastcgi 缓存池,5 分钟过期,内容完全一致,纯粹省机器。
# 环境:Nginx 1.24,AI 爬虫轻量缓存池
# 只有缓存性能不同,返回的 HTML 一个字节都不许变,防 cloaking
map $http_user_agent $ai_bot_group {
default 0;
~*GPTBot 1;
~*PerplexityBot 1;
~*ClaudeBot 1;
~*Google-Extended 1;
}
# 打标之后日志里按 $ai_bot_group 聚合,周报脚本直接可用
# 缓存区 5 分钟过期,够挡全量拉取又不至于太陈旧
# 再配一条 fastcgi_cache 分区映射,命中 ai_light 组走轻量缓存
# 灰度期间先只对 PerplexityBot 开,观察两周再全量
# fastcgi_cache_path /var/cache/nginx/ai_light levels=1:2 keys_zone=ai_light:50m inactive=5m;
第二,日志里给这几个 UA 打标,方便周报聚合。没这个,你连“AI 爬虫来了多少次、看了什么”都说不清,GEO 这事儿就全靠感觉。我们现在的周报脚本 40 行 bash,每周一早上自动跑,省事得很。
没解决的问题,照实说
改造三周,有两个问题还在观察。
一个是 Bing 系的反馈慢。ChatGPT 的搜索底座深度依赖 Bing 索引,而 Bing 对我们站点结构化数据的更新周期肉眼可见地长,9 月 20 日提交了 IndexNow,到写这篇时品类结构在 Copilot 的回答里还没体现。这个只能等。
另一个是 significantLink 的权重到底有多高,说实话没测出来。我们做了一次 A/B:一半品类页带 significantLink,一半不带,两周后 AI 引用的差异在噪声范围内。样本太小,可能要跑到 Q4 才有结论。所以现在我们把它当“低成本的正向押注”维护着,不吹它是决定性因素。
最后提醒一句:导航语义化是让 AI 看懂你的结构,前提是你的结构本身合理。6 个品类藏 30 个子类目没问题,要是 40 个平级品类挤在一个菜单里,AI 读懂了也记不住。先理顺信息架构,再做语义标注,顺序别反。
参考与延伸
- schema.org SiteNavigationElement 类型定义
- schema.org significantLink 属性说明
- Google 搜索中心:结构化数据通用指南
- web.dev:无障碍与导航最佳实践
GEO · AI搜索 · SiteNavigationElement · significantLink · JSON-LD · 独立站 · AI爬虫