AI 搜索优化漏了服务渠道:本地商家 availableChannel 与 ServiceChannel 写法解读

2026-10-08 08:36:45 1 次浏览
GEOAI搜索Schema.org结构化数据本地服务

适用读者:给本地服务类站点(到店预约、上门服务、线上咨询)做结构化数据的后端与前端工程师,以及负责 GEO 落地的技术运营。

前几天帮一个做上门开锁的客户排查问题,场景挺典型。用某个带联网检索的助手问「附近这家开锁店怎么联系、能不能上门」,助手答得挺顺:营业时间、大概位置、服务范围都有,偏偏没给电话,也没说能不能在线预约。翻他们页面源码,<head> 里老老实实塞了 LocalBusiness,telephone、address、openingHours 一个不缺,缺的是另一层东西——Service 和它下面的 availableChannel。

这个缺口不是个例。做本地服务站的团队,十个里有七八个只标了「店是谁」,没标「这家店提供什么服务、这个服务怎么约」。前者是 LocalBusiness,后者是 Service 加 ServiceChannel。传统 SEO 时代这个缺口不太疼,用户点进页面自己看得见预约按钮;到了生成式引擎优化(Generative Engine Optimization, GEO)时代,AI 是照着实体属性拼答案的,属性没写,答案里那一格就空着,用户也就看不到。

被漏掉的那一层:Service 和它的渠道

先把概念摆正。LocalBusiness 描述的是一个「地点/商户」,Service 描述的是「一项可以被人获取的服务」,两者是并列又互补的实体,不是二选一的关系。

本地商家多服务渠道与门店位置连接的扁平科技插画

Service 上挂了一堆属性,其中对「怎么约、怎么联系」这类问题最关键的,是 availableChannel。它的值不是一段文本,而是一个 ServiceChannel(服务渠道)对象——也就是「这条服务可以通过哪些渠道被触达」。

ServiceChannel 自己又带 serviceUrl、servicePhone、availableLanguage、processingTime、serviceLocation 这些属性。说白了,availableChannel 是挂载点,ServiceChannel 是渠道卡片本体,两者是引用关系:Service 通过 availableChannel 指向一个或多个 ServiceChannel 节点。

一段服务页的结构化数据,理想情况长这样:LocalBusiness 负责说明商家身份,Service 负责说明服务内容,ServiceChannel 负责说明触达方式,再用 provider、areaServed、serviceType 把三者串成一张图。AI 引擎抓到的不是一段文字,而是一张有边的图,回答问题时顺着边把槽位填上。

判断标准很粗暴:把页面上的按钮、电话、表单全隐藏,只把结构化数据丢给一个只会读 JSON 的程序,它能不能说出「这家提供什么服务、约的方式是这个电话或这个链接」。答不上来,就说明渠道层没标。

底层机制:AI 引擎怎么从 Service 实体里抠出「服务渠道」

这一节讲机制,理解了它才知道字段为什么这么设计。搜索引擎和 AI 引擎处理结构化数据,底层走的是「实体—属性—值」三元组那一套,JSON-LD、RDFa 只是它的载体。抓取之后引擎会做实体对齐:把页面上的 LocalBusiness、Service、ServiceChannel 落成图数据库里的节点和边,同一个 @id 的节点会被合并成一条。

用户抛出一个意图类问题时,比如「这家店怎么预约」,引擎会把问题拆成「主体 + 槽位」两部分。主体是那家店,槽位是「预约渠道 / 联系方式」这类语义位置。拆完就进入槽位填充环节。

填充动作是这样走的:引擎在实体图上先定位主体对应的 Service 节点,再看它有没有指向 ServiceChannel 的 availableChannel 边。有边,就沿着边走,读 serviceUrl、servicePhone 把值填回槽位;没有边,这个槽位就是空的,生成答案时要么跳过,要么含糊地说一句「建议到店咨询」。用户看到的就是「这店在哪、几点开门」,但没有「怎么约」。

这里有个反直觉的点:servicePhone 和 telephone 不是一回事。telephone 挂在商户节点上,语义是「这个商户的电话」;servicePhone 挂在服务渠道节点上,语义是「这条服务的预约/咨询电话」,而且它的值是个 ContactPoint(联系方式点)结构,可以带 contactType、availableLanguage。多店多服务的情形下,总机号码和某个具体服务的预约号码经常不一样,AI 一旦读错就把用户引到了错误的号码。把渠道电话显式写出来,等于替引擎做了消歧。

processingTime 常被小看。它描述的是「从这个渠道发起请求到被处理,大概要多久」,用 ISO 8601 时长格式写,比如 PT30M 表示三十分钟。写清楚能帮引擎判断这个渠道适不适合即时咨询。serviceUrl 则决定引擎能不能给出一条可点击的预约入口——很多 AI 答案是纯文本,没有链接,用户还得自己回站里找,体验就断在最后一公里。把 serviceUrl 给全,等于给答案补上落点。

引擎不做阅读理解,它做的是槽位填充。你缺哪个槽位,答案里就少哪一块。

三个实体怎么串成一张图

建模时把关系先画出来,字段就不容易漏。下面这张关系图是本地服务场景的常规骨架,可以照着它的边逐个检查自己的数据。

graph LR
    LB[LocalBusiness 商户] -->|provider| S[Service 服务]
    S -->|availableChannel| C1[ServiceChannel 电话渠道]
    S -->|availableChannel| C2[ServiceChannel 表单渠道]
    C1 -->|servicePhone| P[ContactPoint 联系方式点]
    C2 -->|serviceUrl| U[预约落地页]
    S -->|areaServed| A[City 服务范围]
    S -->|serviceType| T[服务类型文本]

图里有两条边最容易被省:availableChannel 和 provider。省掉 provider,引擎连不上「服务属于谁」;省掉 availableChannel,渠道信息整个缺失。这两条边是本地服务站点在 AI 问答里「被引用」的地基。

字段怎么填:一张对照表

各个属性挂在哪一层、值该长什么样,经常被写串。下面按实体分组列一遍,值是工程口径的示例。

属性 所属实体 值类型 是否关键 填法要点
provider Service 引用 高 指向商户的 @id,别内联整段商户对象
availableChannel Service 数组 高 一个服务挂多条渠道,电话与表单各一条
areaServed Service City/Text 中 划服务地理范围,回答覆盖判定
serviceType Service 文本 中 用业务方看得懂的服务名,别写内部编码
serviceUrl ServiceChannel URL 高 可点击的预约/咨询落地页,尽量带参数区分渠道
servicePhone ServiceChannel ContactPoint 高 预约专线,不是商户总机
availableLanguage ServiceChannel 数组 中 该渠道支持的语言,多语言站点要给全
processingTime ServiceChannel ISO 8601 低 从发起到被处理的时长,如 PT30M
contactType ContactPoint 文本 中 写「预约」「咨询」等业务词
telephone ContactPoint 文本 高 带国际区号,避免纯本地号码

表里标「高」的几个属性,缺一个 AI 回答里就少一块。标「中」的影响覆盖面判断和语言匹配,标「低」的在多数场景里可以后补。

完整 JSON-LD 示例(服务页)

下面这段是电话预约加在线表单双渠道的完整示例。注意以 // 开头的行是讲解注释,JSON-LD 规范本身不认注释,落到线上前要删掉;文末的 Python 生成器输出的就是不带注释的纯 JSON。

// ===== 服务页 JSON-LD:电话预约 + 在线表单双渠道 =====
// 下面以 // 开头的行是讲解注释,上线前必须删除
// 顶层用 @graph 把商户与服务放进同一张图,靠 @id 互相引用
{
  // @context 声明用 schema.org 词表,引擎靠它解析属性语义
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "LocalBusiness",
      // @id 全站要稳定,跨页面才能对齐成同一个商户节点
      "@id": "https://example.com/#business",
      // name 用对外展示的商户名,跟工商全称可以不同
      "name": "示例开锁服务部",
      // telephone 是商户总机,跟下面的服务专线不是一回事
      "telephone": "+86-000-0000000",
      // address 用 PostalAddress,省市必填,街道可省
      "address": {
        "@type": "PostalAddress",
        "addressLocality": "某市",
        "addressRegion": "某省",
        // addressCountry 用两位国家码,中国写 CN
        "addressCountry": "CN"
      }
    },
    {
      "@type": "Service",
      "@id": "https://example.com/service/lock#service",
      // serviceType 写业务方能读懂的服务名
      "serviceType": "门锁开锁与上门换锁",
      // provider 指回商户,形成 服务 -> 商户 的边
      "provider": { "@id": "https://example.com/#business" },
      // areaServed 划服务地理范围,回答覆盖判定靠它
      "areaServed": { "@type": "City", "name": "某市" },
      // availableChannel 是数组,一个服务挂多条渠道
      "availableChannel": [
        {
          "@type": "ServiceChannel",
          // 电话渠道:servicePhone 用 ContactPoint 结构
          "servicePhone": {
            "@type": "ContactPoint",
            "telephone": "+86-000-0000001",
            // contactType 用业务语义词,别写内部枚举
            "contactType": "预约",
            // availableLanguage 声明这条渠道支持的语言
            "availableLanguage": ["zh-CN"]
          },
          // processingTime 用 ISO 8601 时长,PT30M 表示 30 分钟
          "processingTime": "PT30M"
        },
        {
          "@type": "ServiceChannel",
          // 表单渠道:serviceUrl 是要给用户点的预约落地页
          "serviceUrl": "https://example.com/booking/new",
          // 表单比电话快,处理时长写短一点
          "processingTime": "PT5M",
          "availableLanguage": ["zh-CN", "en"]
        }
      ]
    }
  ]
}

这份数据的三个要点:Service 与 LocalBusiness 用 @id 互指而不内联,渠道用数组而不是单对象,电话挂在渠道的 servicePhone 上而不是商户的 telephone 上。改完这三处,问答里「怎么约」这一格才填得上。

用 Python 批量产出结构化数据

手写 JSON-LD 在单页站点还行,多门店多服务就得程序生成。下面这个脚本用标准库就能跑,输入商户与服务参数,输出合规的 JSON-LD。

# -*- coding: utf-8 -*-
# 依赖:Python 3.10+,只用标准库 json,不需要三方包
# 作用:把 商户 + 服务 + 渠道 三元组渲染成 JSON-LD
import json

# 电话渠道:一个服务可以挂多条渠道,这里先封装电话预约
def phone_channel(tel: str, lang: str = "zh-CN") -> dict:
    # servicePhone 用 ContactPoint 结构,contactType 写「预约」
    return {
        "@type": "ServiceChannel",
        "servicePhone": {
            "@type": "ContactPoint",
            "telephone": tel,
            "contactType": "预约",
            # availableLanguage 声明语言,多语言站点传数组
            "availableLanguage": [lang],
        },
        # processingTime 用 ISO 8601 时长,PT30M 表示 30 分钟
        "processingTime": "PT30M",
    }

# 线上表单渠道:核心是给出可点击的 serviceUrl
def web_channel(url: str) -> dict:
    # serviceUrl 决定答案里能不能带一条可点入口
    return {
        "@type": "ServiceChannel",
        "serviceUrl": url,
        # 表单提交一般比电话快,处理时长写短一点
        "processingTime": "PT5M",
    }

# 组装 Service 实体:provider 指商户,areaServed 划范围
def build_service(biz_id: str, channels: list) -> dict:
    # serviceType 用业务方一眼能懂的说法
    return {
        "@type": "Service",
        "serviceType": "门锁开锁与上门换锁",
        # provider 只放引用,别内联整段商户对象
        "provider": {"@id": biz_id},
        # areaServed 划服务范围,决定覆盖判定
        "areaServed": {"@type": "City", "name": "某市"},
        # 把渠道数组挂到 availableChannel
        "availableChannel": channels,
    }

# 入口:命令行传入电话与表单地址,输出纯 JSON(无注释)
if __name__ == "__main__":
    biz = "https://example.com/#business"
    chans = [phone_channel("+86-000-0000001"), web_channel("https://example.com/booking/new")]
    doc = {
        "@context": "https://schema.org",
        "@graph": [
            {"@type": "LocalBusiness", "@id": biz, "name": "示例开锁服务部"},
            build_service(biz, chans),
        ],
    }
    # ensure_ascii=False 保证中文按原文输出,便于人工核对
    print(json.dumps(doc, ensure_ascii=False, indent=2))

批量场景下把 biz 和 chans 换成配置表里的一行,循环一遍就能出一批服务页的 JSON-LD,人工只需抽查几个。

.NET 8 里怎么拼 JSON-LD

后端是 ASP.NET Core 的话,可以用最小 API 直接吐 application/ld+json。下面这段依赖 .NET 8 与内置的 System.Text.Json,不引三方库。

// 依赖:.NET 8 / ASP.NET Core 8,System.Text.Json 为框架内置
// 思路:用最小 API 端点把 JSON-LD 拼好,按 application/ld+json 返回
using System.Text.Json.Nodes;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// 映射「服务详情」页对应的结构化数据端点
app.MapGet("/service/lock/jsonld", (IConfiguration cfg) =>
{
    // 从配置读商户 id 与专线电话,避免把号码硬编码进代码
    var bizId = cfg["Seo:BusinessId"]!;
    var booking = cfg["Seo:BookingHotline"]!;

    // 电话渠道:servicePhone 挂在 ServiceChannel 上,不是商户上
    var phoneChannel = new JsonObject
    {
        ["@type"] = "ServiceChannel",
        // ContactPoint 的 contactType 用业务语义词,不要写内部枚举
        ["servicePhone"] = new JsonObject
        {
            ["@type"] = "ContactPoint",
            ["telephone"] = booking,
            ["contactType"] = "预约",
            // 多语言站点这里给数组
            ["availableLanguage"] = new JsonArray("zh-CN")
        },
        // 时长用 ISO 8601,PT30M 表示 30 分钟
        ["processingTime"] = "PT30M"
    };

    // 表单渠道:serviceUrl 是要给用户点的预约落地页
    var webChannel = new JsonObject
    {
        ["@type"] = "ServiceChannel",
        ["serviceUrl"] = "https://example.com/booking/new",
        // 表单没有语言限制之外的额外字段,时长同样用 ISO 8601
        ["processingTime"] = "PT5M"
    };

    // Service 实体:provider 指商户,areaServed 划服务范围
    var service = new JsonObject
    {
        ["@type"] = "Service",
        ["serviceType"] = "门锁开锁与上门换锁",
        ["provider"] = new JsonObject { ["@id"] = bizId },
        ["areaServed"] = new JsonObject { ["@type"] = "City", ["name"] = "某市" },
        // 渠道数组挂到 availableChannel
        ["availableChannel"] = new JsonArray(phoneChannel, webChannel)
    };

    // 顶层用 @graph 把商户与服务放进同一张图
    var doc = new JsonObject
    {
        ["@context"] = "https://schema.org",
        ["@graph"] = new JsonArray(
            new JsonObject { ["@type"] = "LocalBusiness", ["@id"] = bizId, ["name"] = "示例开锁服务部" },
            service)
    };

    // 返回 application/ld+json,便于爬虫直接识别
    return Results.Text(doc.ToJsonString(), "application/ld+json");
});

app.Run();

号码、商户 id 这些从配置读,别写死在代码里。多门店上线时按配置逐店生成,改一处配置就能全量重发。

提交前的结构自检

字段拼完先自检一遍再交,能省掉大量返工。下面这组命令依赖 curl、python3、jq,用来自查线上返回的 JSON-LD 是否合法、渠道是否真的存在。

# 依赖:curl / python3 / jq
# 拉取服务页的结构化数据端点
curl -sS "https://example.com/service/lock/jsonld" -o /tmp/ld.json

# 先确认是合法 JSON(若含 // 注释行,需在此步之前删掉)
python3 -c "import json; json.load(open('/tmp/ld.json')); print('JSON OK')"

# 用 jq 按图遍历,确认 availableChannel 存在且非空
jq -e '[.["@graph"][] | select(.["@type"]=="Service") | .availableChannel | length] | add > 0' /tmp/ld.json

# 再确认每个渠道至少带 serviceUrl 或 servicePhone 之一
jq -e '[.["@graph"][] | select(.["@type"]=="Service") | .availableChannel[] | select(.serviceUrl // .servicePhone)] | length' /tmp/ld.json

jq -e 在条件为假时返回非零退出码,接进 CI 就能当门禁用:渠道缺失直接让构建失败,比上线后靠人工发现靠谱。

改造前后:AI 回答对照

回到开头那个客户。给他们补上 Service 与 availableChannel 前后,同一批问题在助手里的回答差别挺明显。

用户问题 改造前 AI 回答 改造后 AI 回答 生效的属性
这家怎么预约 只给了营业时间,让自行到店咨询 给出预约专线与在线预约入口 availableChannel、servicePhone、serviceUrl
能不能上门 说不确定,建议电话确认 说明支持上门并给出服务范围 serviceType、areaServed
留个电话 拿总机号码,常转到非预约部门 直接给预约专线号码 servicePhone.telephone
支持哪些语言 无信息 说明该渠道支持中文与英文 availableLanguage
多久能回 无信息 说明电话约 30 分钟、在线约 5 分钟 processingTime

数据是客户站点自测口径,问题集固定了二十条,只统计「回答里是否出现可执行的预约方式」这一项。补渠道属性之前,命中大概三成;补齐之后涨到八成上下。数字不算惊艳,但足以说明槽位缺失的影响。

改造前后的流程差异,用一张时序图看得更清楚。

sequenceDiagram
    participant U as 用户
    participant AI as AI 引擎
    participant D as 站点结构化数据
    U->>AI: 这家怎么预约
    AI->>D: 读取 Service 实体
    D-->>AI: 只有 LocalBusiness 信息
    AI-->>U: 给了地址和营业时间,没给预约方式
    Note over D: 补上 availableChannel 之后
    AI->>D: 读取 ServiceChannel
    D-->>AI: servicePhone 与 serviceUrl
    AI-->>U: 给出预约专线号码和在线预约入口

校验与踩坑

字段拼对了不代表引擎就认。踩过的坑大致这么几类,列出来省得你重蹈。

坑一:@id 不稳定

同一家店在服务页写 #business、在门店页写 #shop,两个 @id 指向同一主体,引擎却当成两个节点,图直接散了。全站商户 @id 用同一套规则生成,别手写。

坑二:把渠道电话写成总机

servicePhone 填了前台总机,用户打过去被转三四个部门。渠道电话就填该服务的专线,多服务多专线。

坑三:serviceUrl 指向首页

serviceUrl 给首页,等于没给。要给到能直接提交预约的那一页,最好带上渠道参数,方便后端区分来源。

坑四:注释行忘了删

上面示例里的 // 行是给人看的,直接上线会让 JSON 解析失败。生成脚本输出的走纯 JSON,手写的一定要过一遍解析校验。

抽取链路全景

下面这张流程图把引擎从抓取到出答案的整条链路画了出来,标注了渠道缺失时断在哪一环。

flowchart TD
    A[爬虫抓取页面] --> B[解析 JSON-LD 生成三元组]
    B --> C[实体对齐 合并同 @id 节点]
    C --> D{问题里的目标槽位}
    D -->|怎么预约| E[查 Service.availableChannel]
    D -->|怎么联系| F[查 ServiceChannel.servicePhone]
    E --> G[填充槽位]
    F --> G
    G --> H[生成自然语言答案]
    C -.渠道缺失.-> I[槽位为空 答案里没有预约方式]

和传统 SEO 的关系

有人会问,这套东西跟传统 SEO 是不是重叠。重叠的部分是「页面要被抓到」,不重叠的部分是「抓到之后能不能被引用」。传统 SEO 关心排名和点击,页面本身是终点;GEO 关心的是页面能不能被拆成实体、填进答案,页面反而成了语料。

所以 LocalBusiness、Service、ServiceChannel 这些结构化数据,在传统 SEO 里是锦上添花,在 GEO 里是入场券。排名再高,属性缺失,AI 也拼不出「怎么预约」这句回答。两者不冲突,只是重心不一样:一个把人引到页面,一个让内容被直接引用。

误区澄清

几个挺常见的误解,顺手澄清一下。

误解一:标了 LocalBusiness 就够了。 商户实体只说「这家店存在」,不说「怎么触达它的服务」。渠道信息在 Service 那一层,不在商户层。

误解二:渠道信息写在正文里,AI 能读到。 能读到,但读到的是一段非结构化文本,需要额外抽取,抽取有损耗和不确定性。写成 ServiceChannel 是把它变成引擎可直接取用的结构化槽位,命中更稳。

误解三:字段越多越好。 不是。属性之间自相矛盾比缺失更糟,比如 areaServed 写全市、serviceLocation 又限定在某区,引擎判断覆盖范围时会犹豫。宁可少写,也别写冲突的。

误解四:改一次就一劳永逸。 引擎的抽取策略在变,ServiceChannel 这类相对冷门的类型,各家支持度也不一样。定期用真实问题回归一遍,看哪些槽位又被漏掉了。

往后看,AI 搜索对「可执行信息」的胃口只会更大。用户不再满足于知道「有这家店」,还要知道「怎么立刻联系上」。把服务渠道做成结构化实体,是本地商家在 AI 回答里被准确引用的低成本动作,值得尽早补上。

参考与延伸

  • schema.org 的 ServiceChannel 类型定义:https://schema.org/ServiceChannel
  • schema.org 的 availableChannel 属性说明:https://schema.org/availableChannel
  • schema.org 的 Service 类型定义:https://schema.org/Service
  • Google Search Central 本地商家结构化数据指南:https://developers.google.com/search/docs/appearance/structured-data/local-business

AI 搜索优化、GEO、AI优化AIO、本地商家被 AI 推荐、ServiceChannel、availableChannel、Schema.org、结构化数据

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