预约按钮藏进弹窗 AI 全看不见:ReserveAction 与可抓取预约入口的门店页改造

2026-09-21 01:35:41 0 次浏览
GEOAI搜索ReserveActionLocalBusinessJSON-LD本地服务

适用读者:维护连锁门店官网与门店页模板的前端、后端工程师,以及负责结构化数据落地和 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 到底传了什么

属性与动作的分界线

nametelephoneopeningHoursSpecification 描述的是「这家店是什么样」,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 支持 servicedatestaff 三个查询参数,服务端渲染出预选好的表单,formmethod 用 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.10requestsbeautifulsoup4jinja2(用于把 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} 页。

第二个坑是 canonicaltarget 打架。有一版我们把预约页 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;只有咨询环节,老实用 ContactActionContactPoint,别贪那个「预约」的词。

参考与延伸

  • 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

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