预约按钮藏进弹窗 AI 全看不见:ReserveAction 与可抓取预约入口的门店页改造
适用读者:维护连锁门店官网与门店页模板的前端、后端工程师,以及负责结构化数据落地和 GEO 投放的技术负责人。示例用 ASP.NET Core 渲染管线加 Python 批量校验,换 Node 或 Go 的思路同样成立。
客户做连锁宠物服务,二十多家门店,门店页右上角一枚「立即预约」。点开是个模态框,选服务、选美容师、选时间、填手机号,提交走 POST。 点击率不低,平均停留时长也好看。 可老板拿着 AI 的回答截图来问:问他家门店所在区域有没有能当天约的宠物美容,模型提到了这家店,却从来没说过一句「支持在线预约」。
这不是文案的问题。在生成式引擎优化(Generative Engine Optimization, GEO)里,一个功能要能被 AI 说出来,前提是它在 HTML 里真的存在,而不是在用户的浏览器里存在。
弹窗按钮,在抓取端是一条没有出口的死路
大模型也好,传统抓取器也好,拿到门店页的方式基本都是拉一份 HTML。这份 HTML 里写着一个 <button>,挂着 onclick,没有 href。不去执行 JS,这个按钮就只是两个字。

就算碰到会渲染 JS 的 AI 抓取器,也不解决问题。模态框是点击后才 insert 到 DOM 的,初始文档里压根没有那个 FORM。而 AI 引用要解决的是「我要给用户一个能点的地方」,它需要的是一个稳定 URL,不是一个交互过程。
两条主线:一是把「可以预约」写成结构化数据里的能力声明,二是把预约动作落到一个能 GET 直达的真实 URL 上。前者交给 LocalBusiness 的 potentialAction,后者交给服务类目页和带参数的预约页。
原理剖析:potentialAction 给 AI 到底传了什么
属性与动作的分界线
name、telephone、openingHoursSpecification 描述的是「这家店是什么样」,potentialAction 描述的是「在这个页面上你能把它怎么样」。这条界线很关键:AI 判断能不能写「可以直接预约」,靠的不是正文里提过几次「一键预约」,而是代表这家店的节点有没有挂上一枚动作。
动作类型里,ReserveAction(预约动作)专门描述「预订某个度假资源或服务时段」这件事,它的 result 通常是一张 Reservation。相比遍地都是的 SearchAction,AI 引用管道对 ReserveAction 的处理更依赖 target 的完整度。
target 才是 AI 能不能「引路」的关键
有说服力的 target 要回答三件事:落到哪个 URL、用什么 HTTP 方法、在哪个端可用。缺了 URL,动作就是悬空的,模型最多能说「这家店提供预约」,说不出「可以在 xx 页预约」,更不会把 URL 排进引用位。
ReserveAction 挂在谁身上也不能含糊。挂到 WebPage 上,主语变成了页面;挂到 LocalBusiness 或它下面的 Service 上,主语才是门店本身。一家店能不能被 AI 理解成「可以预约的店」,取决于这枚动作是不是挂在代表这家店的那个节点下。
交互元素与静态可抓取元素的差别
工程师最容易忽略的差别在于「谁把它渲染出来」:
| 元素形态 | 初始 HTML 里的样子 | 抓取端能拿到什么 |
|---|---|---|
<button onclick> 触发弹窗 |
只有按钮文字 | 一句没有主语的动作描述,无 URL |
懒加载渲染的 <form> |
空 div + 一段 bundle | 什么都没有 |
<a href="/store/1/booking?service=groom"> |
完整链接 | 可索引 URL + 锚文本 |
服务端渲染的 <form action=... method="get"> |
完整表单 | 表单字段 + 提交目标 |
JSON-LD 里的 potentialAction |
<script type="application/ld+json"> |
结构化动作 + target URL |
一条粗暴但好用的判断标准:关掉 JS,还能看见、还能点到、还能发出一个 GET 请求的预约入口,才算抓取端意义上的存在。
三家试点门店的改造前基线
客户选了三家店做试点,都是同城不同区域,客群不一样,方便看差异。基线取了改造前 30 天的自然流量、AI 回答抽样和预约转化数据。
抽样口径:每家店设计 24 个问法(「XX 区宠物美容推荐」「XX 附近能给猫洗澡的店」之类),每天在主流 AI 搜索产品里跑一遍,记录是否被提及、是否出现可行动的表述、是否给出 URL。
| 试点门店 | 区域 | 月自然到店线索 | AI 回答提及率 | 出现「可预约」表述 | 静态预约 URL |
|---|---|---|---|---|---|
| 徐汇店 | 市区商圈 | 186 | 41.7% | 0 次 / 30 天 | 无 |
| 闵行店 | 近郊社区 | 143 | 33.3% | 0 次 / 30 天 | 无 |
| 松江店 | 远郊新区 | 97 | 25.0% | 0 次 / 30 天 | 无 |
三家店共同点很明显:被提及但不带任何动作,引用位给的是官网首页,从来没给过门店页。这跟我们的判断一致——模型知道实体存在,不知道实体上能做点什么。
门店页 JSON-LD 的骨架怎么改
改造从 @graph 开始。原来门店页只输出一个孤零零的 LocalBusiness 节点,现在把门店节点和服务节点串起来,potentialAction 挂门店节点,Service 上再挂 offers 提供价格信号。
代码片段依赖:schema.org 词表本身,无额外库;Schedule 里的开门时间用官方要求的简化写法,带负号和双位数。下面这段是页面上最终 <script type="application/ld+json"> 里的内容。
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "PetStore",
"@id": "https://example-pet.example/store/sh-xuhui-001#store",
"url": "https://example-pet.example/store/sh-xuhui-001",
"name": "徐汇店(示例门店)",
"telephone": "+86-21-0000-0001",
"address": {
"@type": "PostalAddress",
"streetAddress": "示例路 100 号 1 层",
"addressLocality": "上海市",
"addressRegion": "徐汇区",
"postalCode": "200030",
"addressCountry": "CN"
},
"geo": { "@type": "GeoCoordinates", "latitude": 31.1900, "longitude": 121.4400 },
"openingHoursSpecification": [
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
"opens": "10:00",
"closes": "20:00"
}
],
"potentialAction": {
"@type": "ReserveAction",
"name": "在线预约到店服务",
"object": { "@type": "Service", "@id": "https://example-pet.example/store/sh-xuhui-001/service/grooming#service" },
"result": { "@type": "Reservation", "name": "到店服务预约单" },
"target": {
"@type": "EntryPoint",
"urlTemplate": "https://example-pet.example/store/sh-xuhui-001/booking?service={service_slug}&date={reserve_date}",
"httpMethod": "GET",
"inLanguage": "zh-CN",
"actionPlatform": [
"https://schema.org/DesktopWebPlatform",
"https://schema.org/MobileWebPlatform"
]
}
}
},
{
"@type": "Service",
"@id": "https://example-pet.example/store/sh-xuhui-001/service/grooming#service",
"name": "宠物洗护美容",
"serviceType": "PetGrooming",
"provider": { "@id": "https://example-pet.example/store/sh-xuhui-001#store" },
"areaServed": { "@type": "City", "name": "上海市徐汇区" },
"offers": {
"@type": "Offer",
"price": "168.00",
"priceCurrency": "CNY",
"availability": "https://schema.org/InStock",
"url": "https://example-pet.example/store/sh-xuhui-001/service/grooming"
}
}
]
}
几个值得抄走的细节。urlTemplate 里用了 {service_slug}、{reserve_date} 两个占位符,抓取端拿到模板后按自己要查的组合填值,拼出具体 URL。offers.price 和门店实际价格由同一份 CMS 数据源吐出,结构化数据一旦跟页面真值不同源,AI 引用迟早会翻车。provider 用 @id 回指门店节点,让服务和门店形成闭环。
预约表单落成真实 URL
结构化数据只是声明,拿不到页面的 URL 就是在骗人。所以同时把预约流程改成了 GET 直达。
/store/{storeId}/booking 支持 service、date、staff 三个查询参数,服务端渲染出预选好的表单,form 的 method 用 GET。也就是说 /store/sh-xuhui-001/booking?service=grooming&date=2026-09-21 这份 HTML 里,服务下拉框默认选中了洗护美容,日期默认选中今天。这条 URL 可以被抓、可以被 cite、可以直接粘贴给朋友。
服务类目页一并静态化:/store/{storeId}/service/{slug} 每种服务一页,SSR 输出介绍、价格、时长、常见问答。这页既是 offers.url 的落地页,也是 AI 引用给得最多的那一类深链。
改造后的预约链路长这样:
flowchart TD
A[用户在 AI 回答里看到门店] --> B{是否有可引用的 URL}
B -- 改造前: 只有弹窗 --> C[只能记店名, 自行搜索官网]
B -- 改造后: target URL --> D[直接点进门店 booking 页]
D --> E[SSR 输出预选表单]
E --> F[选时间 填手机号]
F --> G[提交到 /booking/confirm]
H[AI 抓取门店页 HTML] --> I[读到 application/ld+json]
I --> J[识别 ReserveAction + EntryPoint]
J --> K[取 urlTemplate 占位符]
K --> L[拼出具体预约 URL]
L --> M[写入回答引用位]
E -.同一份 SSR 产物.-> H
图上最后那条虚线是关键:抓取器看到的和用户看到的是同一份服务端渲染产物。给抓取器单独吐一套 JSON-LD、给人类弹窗的做法短期可能有效,长期属于高风险操作。
JSON-LD 注入进现有渲染管线
客户门店页是 ASP.NET Core MVC 渲染的,二十几家店共用一套模板,数据来自 CMS。注入层做成一个 ViewComponent,构造逻辑单独抽一个 Builder 类方便测试。
依赖:.NET 8,System.Text.Json.Nodes 做节点拼接(不需要额外 NuGet 包);Microsoft.AspNetCore.Mvc 自带的 ViewComponent。用 JsonNode 而不是拼字符串,是为了避开手写转义导致的 JSON 非法。
// StoreSchemaBuilder.cs
// 依赖: .NET 8, System.Text.Json.Nodes (BCL 自带, 无需额外 NuGet)
// 职责: 把 CMS 里的门店实体翻译成 schema.org 的 @graph JSON-LD
// 约定: 所有 URL 一律拼成带域名的完整形式, schema.org 不建议用相对路径
// 使用方式, 在 ViewComponent 的 Invoke 里调用:
// var graph = new StoreSchemaBuilder(host, store).Build();
// writer.Write("<script type=\"application/ld+json\">");
// writer.Write(graph.ToJsonString());
// ToJsonString 默认保留非 ASCII 字符, 中文不需要再转义一次
// 注意 script 标签内部如果原样输出 "</script>" 会提前闭合, 生产环境建议过一层安全序列化
using System.Text.Json.Nodes;
namespace Site.Schema;
// Builder 做成无状态类的实例形式, 每家门店一次构造一次 Build
// 不写成 static 是为了在单元测试里能塞不同的 host 和 mock 门店数据
public sealed class StoreSchemaBuilder
{
// 站点根域名, 拼 @id 和 target 的全路径 URL 都要用
// 末尾斜杠统一裁掉, 避免出现 //store 这种双斜杠
private readonly string _host;
// 门店实体来自 CMS, 只用到下面几个字段
private readonly StoreEntity _store;
// 构造函数注入, 便于单元测试里替换 host
// _store 允许为 null 的场景不存在, 缺门店数据应该在上游拦截
public StoreSchemaBuilder(string host, StoreEntity store)
{
_host = host.TrimEnd('/');
_store = store;
}
// 门店页主 URL, 同时作为 @id 的基础
// 门店页 / 服务页 / 预约页共用这里拼出的前缀, 防止三处不一致
private string StoreUrl => $"{_host}/store/{_store.Slug}";
// 服务页 URL, offers.url 与 ReserveAction 的 object 都用得到
private string ServiceUrl(string slug) => $"{StoreUrl}/service/{slug}";
// 拼 ReserveAction 的 EntryPoint, urlTemplate 里保留 {} 占位符
// 占位符留给 AI 抓取端自行填值, 这里不能提前写死日期
// 一旦写死日期, 过期的 URL 会被当成无效 target
// EntryPoint 这一层是整个改造里最容易漏的, 只写 target 字符串是不够的
private JsonObject BuildEntryPoint()
{
return new JsonObject
{
// @type 必须显式写 EntryPoint, 写成 URL 字符串会丢失方法信息
["@type"] = "EntryPoint",
// C# 插值字符串里, 占位符的花括号要写成双花括号转义
// 渲染结果是 /booking?service={service_slug}&date={reserve_date}
["urlTemplate"] = $"{StoreUrl}/booking?service={{service_slug}}&date={{reserve_date}}",
// 明确声明 GET, 告诉抓取端这个动作不需要 POST
["httpMethod"] = "GET",
// 语言标记用小写的 zh-CN, 跟 html lang 保持一致
["inLanguage"] = "zh-CN",
// actionPlatform 列出可用端, 桌面端和移动 Web 都支持
["actionPlatform"] = new JsonArray
{
"https://schema.org/DesktopWebPlatform",
"https://schema.org/MobileWebPlatform"
}
};
}
// ReserveAction 本体: object 指向服务, result 声明产出是一张预约单
// target 指向上面构造的 EntryPoint
// 三个字段的关系可以理解成: 对 object 执行动作, 产出 result, 入口在 target
private JsonObject BuildReserveAction()
{
return new JsonObject
{
["@type"] = "ReserveAction",
// 动作名称用「在线预约到店服务」这类完整短语, 别写「预约」两个字
["name"] = "在线预约到店服务",
// object 回指服务节点, 用 @id 而不是重复展开整份服务信息
["object"] = new JsonObject
{
["@type"] = "Service",
["@id"] = $"{ServiceUrl(_store.MainServiceSlug)}#service"
},
// result 说明动作做完之后产出什么, 这里是一张预约单
["result"] = new JsonObject
{
["@type"] = "Reservation",
["name"] = "到店服务预约单"
},
["target"] = BuildEntryPoint()
};
}
// 对外暴露的构造入口, 输出整个 @graph 对象
// 返回值交给 ViewComponent 序列化, Builder 自己不碰 HttpResponse
public JsonObject Build()
{
// 门店节点本体, @id 后缀 #store 便于别的地方反查
// slug 里不要出现中文或空格, URL 拼出来才算干净
var storeNode = new JsonObject
{
// PetStore 是 LocalBusiness 的子类型, 比裸写 Store 更精确
["@type"] = "PetStore",
["@id"] = $"{StoreUrl}#store",
["url"] = StoreUrl,
["name"] = _store.Name,
// 电话按 E.164 习惯带国际区号
["telephone"] = _store.Phone
};
// 地址节点用 PostalAddress, 省/市/区拆开写
// 不要把整段地址糊进 name, 那样 AI 抽不出区域信号
storeNode["address"] = new JsonObject
{
["@type"] = "PostalAddress",
["streetAddress"] = _store.Address,
["addressLocality"] = _store.City,
["addressRegion"] = _store.District,
["addressCountry"] = "CN"
};
// potentialAction 挂在门店节点下, 不挂 WebPage
// 停用线上预约的门店, 上游会置 IsReservable=false, 这里整段跳过不输出
// 宁可不输出动作, 也不要输出一个点了没人接单的动作
// 这条规则后来写进了上线检查单
if (_store.IsReservable)
{
storeNode["potentialAction"] = BuildReserveAction();
}
return new JsonObject
{
// @context 放在最外层, 内层节点不再重复写
["@context"] = "https://schema.org",
// 服务节点在同一个 @graph 里, 由服务模板单独追加
["@graph"] = new JsonArray { storeNode }
};
}
}
ViewComponent 侧只做一件事:把 Builder 的产物序列化成字符串,塞进 <script type="application/ld+json">,并且统一 _store.MainServiceSlug 的缺省值,避免出现占位符 URL 指向 404 的服务页。
上线前的批量校验脚本
二十多家店不可能逐家肉眼验。写了个 Python 脚本每晚跑一次,抽每个门店页的 JSON-LD,检查动作是否齐全、target 的 URL 是否 200、模板拼出的 URL 是否命中、offers.price 和页面价格是否同源。
依赖:python>=3.10、requests、beautifulsoup4、jinja2(用于把 urlTemplate 渲染成实例 URL;不用模板引擎写个 replace 也行)。
# check_reserve_action.py
# 依赖: python>=3.10, requests, beautifulsoup4
# pip install requests beautifulsoup4
# 职责: 批量校验门店页的 ReserveAction / target / offers 是否自洽
# 运行: python check_reserve_action.py, 退出码非 0 即视为质检不通过
# 为什么不用现成的 schema 校验库: 那些库只验字段类型合法,
# 验不了 urlTemplate 拼出来的 URL 是不是真能打开, 而这里的问题全在后者
import json
import re
import sys
import requests
from bs4 import BeautifulSoup
# 待检查门店页列表, 实际跑的时候从 CMS 接口拉全量门店
# 这里写死三家试点店方便本地复现
# 域名用 example 占位, 上线前替换成真实站点
STORE_URLS = [
"https://example-pet.example/store/sh-xuhui-001",
"https://example-pet.example/store/sh-minhang-002",
"https://example-pet.example/store/sh-songjiang-003",
]
# 会话复用连接, 门店页数多的时候能省一半时间
# 没配重试策略是刻意的, 一次失败就要留下痕迹, 自动重试会掩盖偶发 500
SESSION = requests.Session()
# 保持只读抓取形态的 UA, 服务端据此返回给普通抓取bot 的同一份 HTML
# 不要伪装成浏览器 UA 去拿另一套渲染结果, 那跟 AI 看到的不是一回事
SESSION.headers.update({"User-Agent": "Mozilla/5.0 (compatible; StoreSchemaCheck/1.0)"})
# 取出页面里所有 ld+json 块, 合并成一个扁平节点列表
# html.parser 不执行任何脚本, 这正是我们要模拟的抓取视角
# 返回扁平列表而不是树, 后面按 @type 查找会更省事
def parse_graph(page_url):
html = SESSION.get(page_url, timeout=10).text
soup = BeautifulSoup(html, "html.parser")
nodes = []
for tag in soup.select('script[type="application/ld+json"]'):
try:
data = json.loads(tag.string or "")
except json.JSONDecodeError as exc:
# JSON 非法要当成硬错误抛出来, 别静默跳过
# 静默跳过的后果是这家店的问题要等用户投诉才发现
raise RuntimeError(f"{page_url} JSON-LD 解析失败: {exc}") from exc
# @graph 形态和单节点形态都要兼容
# 有的页面只吐一个裸对象, 有的页面是 @graph 数组
nodes.extend(data.get("@graph", [data]))
return nodes
# 在所有节点里按 @type 找 ReserveAction, 找不到返回 None
# 有些页面把动作挂在 Service 上而不是门店上, 这里两种都接受
def find_action(nodes):
for node in nodes:
action = node.get("potentialAction")
if isinstance(action, dict) and action.get("@type") == "ReserveAction":
return action
# potentialAction 也可能是数组, 逐个比对 @type
if isinstance(action, list):
for item in action:
if isinstance(item, dict) and item.get("@type") == "ReserveAction":
return item
return None
# 把 urlTemplate 里的 {xxx} 占位符替换成真实取值, 得到可实测的 URL
# 这一步模拟 AI 抓取端拿到模板之后自己填值的过程
def render_url_template(url_template):
# 取值组合覆盖主要服务类目即可, 不必穷举
# date 用一个未来日期, 测的是可用性而不是已过期状态
values = {"service_slug": "grooming", "reserve_date": "2026-09-30"}
# 未覆盖到的占位符替换成空串, 免得留下裸花括号
return re.sub(r"\{(\w+)\}", lambda m: values.get(m.group(1), ""), url_template)
# 逐个门店页走完检查项, 有失败就收集起来
# 返回非 0 退出码, CI 直接据此判定流水线红绿
# 检查顺序刻意从便宜到昂贵: 先解析 schema, 再发 URL 请求
def main():
failures = []
for page_url in STORE_URLS:
nodes = parse_graph(page_url)
action = find_action(nodes)
if action is None:
# 找不到动作有两种可能: 门店停用了预约, 或者模板退化
# 这里先按错误上报, 由人确认是不是预期内停用
failures.append(f"{page_url}: 未找到 ReserveAction")
continue
# target 必须有 urlTemplate, 否则 AI 拼不出具体 URL
target = action.get("target") or {}
template = target.get("urlTemplate")
# httpMethod 缺失不算致命, 但 GET 才是我们要的
method = target.get("httpMethod", "GET")
if not template:
failures.append(f"{page_url}: ReserveAction 缺 target.urlTemplate")
continue
if method != "GET":
failures.append(f"{page_url}: httpMethod 应为 GET, 实际为 {method}")
# 渲染出的实例 URL 必须返回 200, 否则 AI 引了也是死链
booking_url = render_url_template(template)
resp = SESSION.get(booking_url, timeout=10)
if resp.status_code != 200:
failures.append(f"{page_url}: 预约 URL {booking_url} 返回 {resp.status_code}")
continue
# 预约页要能在不发请求之外再验一次 SSR 表单
# find 找不到 form 会返回 None, 先判空再取 action 属性
soup = BeautifulSoup(resp.text, "html.parser")
form = soup.find("form")
form_action = form.get("action", "") if form else ""
if "booking" not in form_action:
failures.append(f"{page_url}: 预约页未 SSR 输出表单")
# 顺带核一遍页面可见价格跟 offers.price 是否同源
# 不同源会让 AI 引用一个页面上看不到的价格, 直接招投诉
# 用 get_text 而不是追 DOM 节点, 页面改版时这条检查不容易误报
offers_price = next(
(n.get("offers", {}).get("price") for n in nodes if isinstance(n.get("offers"), dict)),
None,
)
if offers_price and offers_price not in soup.get_text():
failures.append(f"{page_url}: offers.price {offers_price} 未在页面正文出现")
# 错误汇总打印, CI 里看这一段就够
# 一行一个失败项, 直接贴进工单就能派给对应门店的负责人
for line in failures:
print("[FAIL]", line)
# 打印结果供定时任务归档, 方便看趋势不是只看红绿
print(f"检查 {len(STORE_URLS)} 家门店, 失败 {len(failures)} 项")
return 1 if failures else 0
# 每次请求都设了 timeout, 卡住的门店页不能拖垮整晚的任务
# 建议配 crontab 每天跑一次, 结果归档按日期存, 看趋势比看单次红绿有用
if __name__ == "__main__":
sys.exit(main())
脚本本身也写错过一版。早期只校验 JSON-LD 能不能解析,没校验 target 的 URL 是否真能打开,结果上线第二天就有两家加盟店的 service_slug 拼成了旧值,跑了一个礼拜才被人工巡店发现。校验要打到真实 URL 的返回值,不能只停留在 schema 合法性层面。
注入的整体架构
结构上看,改造往渲染链路里插入了两个新的环节:一个构造层,一个校验层。
flowchart LR
A[CMS 门店主数据] --> B[StoreSchemaBuilder]
A --> C[服务/价格表]
C --> B
B --> D[JsonNode 拼 @graph]
D --> E[ViewComponent 序列化]
E --> F[门店页 HTML]
D --> G[服务页 Offers]
G --> F
F --> H[线上门店页]
H --> I[Python 巡检脚本]
I --> J[校验 target 与 SSR 表单]
J --> K[失败告警到工单]
B --> L[单元测试: URL 与占位符]
六十天后的对照数据
这套改动上线后观察了 60 天,三家门店的涨幅不算同步,但方向一致。采样口径跟基线保持完全一致:每店 24 个问法,每天一轮。
| 试点门店 | AI 回答含「可以在线预约」表述 | 引用位给到门店/预约页 | 预约页自然 PV | 其中 AI 来源占比 | 表单提交完成 |
|---|---|---|---|---|---|
| 徐汇店 | 0 → 11.2 次/月 | 0 → 18 次/月 | 0 → 1,240 | 0% → 23.4% | 0 → 96 |
| 闵行店 | 0 → 7.8 次/月 | 0 → 12 次/月 | 0 → 806 | 0% → 19.1% | 0 → 61 |
| 松江店 | 0 → 4.3 次/月 | 0 → 6 次/月 | 0 → 388 | 0% → 14.7% | 0 → 27 |
徐汇店跑得最快。复盘下来不是它内容写得更细,而是它本身被提及率就高,动作声明一加上去,citation 命中数几乎同步涨。能被 AI 引用到的量,约等于被提及的基础量乘以动作的可识别率。
另一个数也值得看:预约页 PV 里 AI 来源占比稳定在 15% 到 25%,随门店远离市中心而下降。这类本地服务在 AI 搜索里的长尾流量容易被门户聚合页吃掉一部分。GEO 在本地服务赛道上拼的往往是「能不能被描述成一件可完成的事」,不是排名。
第三张表是布尔项的通过情况,都是巡检脚本里的检查点:
| 校验项 | 改造前 | 改造后(60 天持续) |
|---|---|---|
| JSON-LD 解析通过 | 是(但无动作) | 是 |
| 主实体挂 ReserveAction | 否 | 是 |
| target.urlTemplate 存在 | 否 | 是 |
| 渲染后 URL 返回 200 | — | 是,三店均通过 |
| 无 JS 下可见预约表单 | 否 | 是 |
| AI 引用到非首页 URL | 否 | 是 |
这中间踩的几个坑
第一个坑是参数组合爆炸。service 有 12 个取值,date 理论上无限,/booking 的可索引组合会失控。处理办法是把 /store/{id}/booking 作为主索引对象,?date= 这类参数在 robots 里设 nofollow,预约页 canonical 指向不带日期的干净版本,索引权交给静态化的 /service/{slug} 页。
第二个坑是 canonical 与 target 打架。有一版我们把预约页 canonical 指到了门店页,结果 AI 抓到 target URL,却被 canonical 合并回门店页,引用位又回到了首页。改法很简单:target 指向的 URL,canonical 必须指向它自己。
第三个坑是价格不同步。offers.price 从一个三个月没同步的价格表读,页面上挂的是活动价。AI 会引用「人均一百六十八」,用户点进去看到一百三十八,第一通投诉电话就是这么来的。现在 offers.price 和渲染页面读同一个价格服务,CI 里加了一条「页面可见价格 == JSON-LD 价格」的断言。
第四个坑是 scope。一家加盟店人员调整暂停了线上预约,JSON-LD 却还在输出 ReserveAction,用户点进去照样能提交,只是没人接单。停用期就不要在门店节点上输出 potentialAction,而不是输出一个假装能用的动作。
什么情况下别急着上 ReserveAction
不是所有门店都适合。主要靠电话派单、网上入口只是咨询表单的情况下,ReserveAction 会给出一个兑现不了的承诺:AI 替你说「可以在线预约」,用户点进去发现只能留言,下次就可能被引擎降权。
判断标准可以直接写成一条规则:用户在这个 URL 上完成动作之后,是否真的产生了一笔服务时段占用。有,就挂 ReserveAction;只有咨询环节,老实用 ContactAction 加 ContactPoint,别贪那个「预约」的词。
参考与延伸
- ReserveAction 官方定义与可用属性:https://schema.org/ReserveAction
- LocalBusiness 及其子类型、地址与营业时间写法:https://schema.org/LocalBusiness
- potentialAction 与 EntryPoint 的基本约定:https://schema.org/docs/actions.html
- 结构化数据在搜索结果中的展示要求:https://developers.google.com/search/docs/appearance/structured-data/search-gallery
关键词:生成式引擎优化、GEO、AI 搜索、ReserveAction、potentialAction、LocalBusiness、JSON-LD