客服电话写对了 AI 还是打不通:ContactPoint 与 contactType 的字段规范清单

2026-09-22 01:22:48 1 次浏览
GEOLocalBusinessContactPointSchema.org本地生活服务JSON-LD

读的时候最好手边开着你们首页的 JSON-LD 源码,可以跟着一起改。

telephone 字段没写错,AI 还是把用户推给了三年前停用的 400 号。这话不是我编的,是上个月一个做连锁家政的客户在工单里转述给我的原话——他们在 LocalBusiness 里规规矩矩写了 telephone,Google 的结构化数据校验一片绿,可用户在 AI 助手里问「你们客服电话多少」,得到的回答是页脚那个早就停机的旧号。

这类问题属于生成式引擎优化(Generative Engine Optimization, GEO)里最容易被忽略的一块:结构化数据不是写对了就算数,还得让机器能判断「这个号是干嘛的、什么时候能打、服务哪些人」。telephone 只是个字符串,ContactPoint 才带语义。

号码没错,AI 念的却是停用的那个

先说现场。这家公司有 37 家门店,站点是 Next.js 14 做的,每个门店一个落地页。出事的那个页面叫「城南店」,第 9 周上线,第 12 周客服部在工单系统里连续收到 4 条投诉:客户说打过去是空号。

主题配图

我把三个数据源拉出来对了一遍:

数据来源 号码 状态
页面 JSON-LD 的 LocalBusiness.telephone 4001234567 2019 年停用,模板里硬编码没改
联系页正文的 tel 链接 0755-88886666 在用,门店坐席
地图平台认领的门店电话 138****6666 在用,区域经理手机

三个号三个来源,JSON-LD 里那个偏偏是最旧的。更要命的是,这份 JSON-LD 里根本没有 contactPoint 节点,AI 助手拿到的是一串没有角色标注的数字,它没法判断这是客服号、销售号还是门店前台。于是它选了最「正式」的那个——停用 400。

这事儿白费劲的地方在于:很多人第一反应是去改 JSON-LD 里的号码,改完发现过两天又被覆盖回去了,因为页脚模板里还有一份。真正的病灶是两个:号码没有单一数据源,号码没有语义标注。

AI 挑号码的底层机制

要理解为什么写了也会被念错,得先看 AI 搜索是怎么消费结构化数据的。很多同学以为搜索引擎拿到 JSON-LD 会原样存起来,用户问什么就吐什么,其实中间还有几跳。

flowchart TD
    A[抓取门店页 HTML] --> B{页面里有没有 JSON-LD}
    B -- 没有 --> C[退化为解析正文里的 tel 链接和页脚文本]
    B -- 有 --> D[解析 JSON-LD 图节点]
    D --> E{有没有 contactPoint 子节点}
    E -- 有 --> F[按 contactType 匹配用户意图]
    E -- 没有 --> G[退回顶层 telephone 字符串]
    F --> H[再按 areaServed 与 hoursAvailable 过滤]
    G --> I[直接引用顶层字符串不做角色判断]
    H --> J[生成答案并附号码]
    I --> J

关键在 E 和 H 这两跳。有 contactPoint 的时候,引擎是先匹配意图再选号;没有 contactPoint 的时候,它只能拿顶层那个字符串,不做角色判断,也不做时段过滤。所以你写了 telephone 和没写 contactPoint,等于把选择权交给了抓取顺序和文本位置——谁在 HTML 里靠前就可能被选中。

结构化数据到答案的三跳

第一跳是抽取。爬虫取到的是一张图(graph),不是你眼睛看到的那个 JSON 缩进。节点之间靠 @id 关联,@type 决定它按什么规则解释。

第二跳是消歧。同一家店在页脚、地图平台、点评站上可能有三个号,引擎会给它们算一个置信度。带 contactType 且和正文一致的号,置信度明显高;孤零零一个字符串,容易被判成「历史遗留」。

第三跳是生成。大模型在组织答案时会挑置信度最高的那个节点展开。这就是为什么你把号改对了还不够——只要旧号在别的地方还活着,它就还在竞争。

把这三跳画成时序,用户侧的一次提问大概是这样走的:

sequenceDiagram
    participant U as 用户
    participant E as AI 搜索引擎
    participant G as 结构化数据图谱
    U->>E: 城南店客服电话是多少
    E->>G: 查询城南店节点
    G-->>E: 返回 contactPoint 数组
    E->>E: 用 contactType 匹配客服意图
    E->>E: 用 hoursAvailable 过滤已下班号码
    alt 命中客服号
        E-->>U: 报出在用客服热线
    else 只有顶层 telephone
        E-->>U: 直接引用字符串可能是停用号
    end

结论:contactType 不是可选装饰,它是让引擎区分「售前咨询」和「售后报修」的核心语义入口。少了它,AI 只能瞎猜。

ContactPoint 上能挂哪些字段

ContactPoint 是 Schema.org 里挂在 Organization、LocalBusiness、Person 下面的通用类型。下面这几个字段在本地服务业场景里最常用,逐个说清楚。

字段 期望类型 该填什么 缺了会怎样
telephone Text E.164 风格,带国家码,如 +86-755-88886666 境外引擎拼错区号,外呼直接失败
contactType Text customer service、sales、technical support、reservations、billing 引擎分不出售前售后,把销售号当客服号念出去
contactOption ContactPointOption TollFree 或 HearingImpairedSupported 免费号与付费号混排,用户被莫名收费
availableLanguage Text 或 Language BCP 47 标签,zh-Hans、yue、en 粤语用户被推给只讲普通话的坐席
areaServed AdministrativeArea、GeoShape、Place、Text 城市名或行政区 深圳用户拿到只服务东莞的门店号
hoursAvailable OpeningHoursSpecification 坐班时段 深夜提问被报一个没人接的号

几个容易踩坑的细节:

  • telephone 一定要加引号写成字符串。写成 JSON 数字,前导 0 和加号会丢,+86 直接变成 86。
  • contactType 在 Schema.org 里是 Text,不是枚举,理论上可以写中文「客服」;但主流引擎的训练语料里英文取值命中率更高, bilingual 站点建议写英文,中文放到 alternateName 里。这一步做了,AI 搜索语境下的匹配会稳很多。
  • availableLanguage 要用 BCP 47 标签,写「中文」或「Chinese」都不规范,写 zh-Hans 才被认。
  • hoursAvailable 可以和 OpeningHoursSpecification 复用同一套 dayOfWeek 写法,别另起炉灶。

错误写法和正确写法摆一起看

下面是出事那家店改造前的 JSON-LD,我删掉了无关字段,只留联系方式相关的部分。环境是 Next.js 14 App Router,JSON-LD 由服务端组件内联输出。

// 改造前的写法:号码是纯数字、没有国家码、没有 contactPoint
// 结果:引擎只能拿到一个无角色标注的字符串
{
  // 上下文固定写 schema.org,不要写成 http 版本
  "@context": "https://schema.org",
  // 家政维修类用 HomeAndConstructionBusiness,它是 LocalBusiness 的子类型
  "@type": "HomeAndConstructionBusiness",
  "name": "某某家政服务(城南店)",
  // 这里写成了 JSON 数字,前导符号会丢
  "telephone": 4001234567,
  // 两个号硬塞进一个字符串,引擎无法拆分
  "faxNumber": "0755-88886666, 0755-88886667",
  // 邮箱没有对应到具体角色,等于没有语义
  "email": "cs@example.com",
  "address": {
    // 地址节点类型不能省,省了 PostalAddress 会被当成纯文本
    "@type": "PostalAddress",
    // 街道地址写到门牌号,别用简称
    "streetAddress": "城南大道 88 号 1 层",
    // 城市名不带省市后缀更利于匹配
    "addressLocality": "深圳市",
    "addressRegion": "广东省",
    // 国家码用 ISO 3166-1 alpha-2,中国是 CN
    "addressCountry": "CN"
  }
}

问题有三处,从上往下数:telephone 是数字类型;contactPoint 整个缺失;faxNumber 里塞了两个号用逗号分隔,这种写法 Schema.org 不认,等于告诉引擎「这串东西你看着办」。

改造后的版本长这样:

// 改造后的写法:顶层只保留门店主号,角色号全部下沉到 contactPoint
// 环境同上,由服务端组件内联输出,号码统一取自门店配置表的单个字段
{
  "@context": "https://schema.org",
  "@type": "HomeAndConstructionBusiness",
  // 稳定 @id,改造后重抓时引擎能识别为同一实体
  "@id": "https://example.com/store/cheng-nan#store",
  "name": "某某家政服务(城南店)",
  // 顶层 telephone 与客服号保持一致,避免两处打架
  "telephone": "+86-400-123-4567",
  "email": "cs@example.com",
  "contactPoint": [
    {
      "@type": "ContactPoint",
      // 客服热线:放在第一位,用户最常问的就是它
      "contactType": "customer service",
      "telephone": "+86-400-123-4567",
      // 标注免费电话,引擎会优先推荐给用户
      "contactOption": "TollFree",
      // 两种语言,普通话与粤语
      "availableLanguage": ["zh-Hans", "yue"],
      // 服务范围限定本市,别让外地用户拿到这个号
      "areaServed": {"@type": "AdministrativeArea", "name": "深圳市"},
      "hoursAvailable": {
        "@type": "OpeningHoursSpecification",
        "dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
        "opens": "08:00",
        "closes": "22:00"
      }
    },
    {
      "@type": "ContactPoint",
      // 技术支持:空调维修、水电抢修走这条线
      "contactType": "technical support",
      // 门店固话,抢修类问题打这条
      "telephone": "+86-755-88886666",
      // 只支持普通话
      "availableLanguage": ["zh-Hans"],
      "areaServed": {"@type": "AdministrativeArea", "name": "深圳市"},
      "hoursAvailable": {
        "@type": "OpeningHoursSpecification",
        "dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
        "opens": "00:00",
        "closes": "23:59"
      }
    },
    {
      "@type": "ContactPoint",
      // 销售加盟:只服务工作日,别在夜里被报出去
      "contactType": "sales",
      // 加盟咨询专线,工作日才有人
      "telephone": "+86-755-88886688",
      "availableLanguage": ["zh-Hans", "en"],
      "areaServed": {"@type": "AdministrativeArea", "name": "广东省"},
      "hoursAvailable": {
        "@type": "OpeningHoursSpecification",
        "dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
        "opens": "09:30",
        "closes": "18:30"
      }
    }
  ]
}

对照着看,改动其实就四件事:号码统一加国家码写成字符串、顶层号与客服号对齐、三条 contactPoint 各带 contactType、每条都补 hoursAvailable 和 areaServed。

多个 ContactPoint 的排序问题

很多人问我,数组顺序是不是代表优先级。在 Schema.org 的规范里,数组顺序不携带优先级语义,引擎应该按 contactType 匹配用户意图。但实际抓取里,排在前面的节点更容易被先解析到,所以习惯上把最高频的那条放第一位——客服号放第一,销售号往后。

去重规则也简单:同一个 contactType 在同一个 LocalBusiness 下只保留一条。我们扫过一家有 9 个门店页的站点,其中一个页面上挂了 4 条 customer service,两个是旧号、两个是新号,引擎自己也不知道该信谁。清理办法是先按 contactType 分组,同组里留 hoursAvailable 覆盖时段最宽且与页脚一致的那条。

还有个容易忽略的点:@id。给每个 ContactPoint 加稳定 @id,改造后重新提交的时候,引擎更容易判断这是同一个实体的更新而不是新冒出来的号。

400 和手机号混在一个字段里

本地服务业特别爱干这事——400 是全国热线,门店还有自己的固话,区域经理再留个手机,三个全往 telephone 里塞,用顿号或斜杠隔开。

这在 Schema.org 里是无效写法。telephone 的期望类型是单个 Text,塞多个号等于给了一个引擎无法解析的复合值。正确做法是拆成三条 contactPoint,靠 contactType 和 areaServed 区分:

  • 400 走 customer service + contactOption: TollFree,areaServed 写全国。
  • 门店固话走 customer service 之外的 technical support,areaServed 写本市。
  • 手机号原则上不进结构化数据。它变动频繁,一旦换人就是一批失效数据,而且私人号码进公开图谱也不合适。

另外提醒一句,contactOption 只有两个合法取值:TollFreeHearingImpairedSupported。见过有人写 FreeToll Free(中间有空格)甚至写中文「免费」,这些都不在枚举里,写了跟没写一样。

全站号码一致性怎么批量查

改完单个页面不算完,得确认全站模板里没有第二份旧号。我们内部用一段脚本做这件事:把仓库里所有模板和构建产物扫一遍,抽出所有疑似号码,和门店配置表里的号做比对。

# 环境:Python 3.11,仅用标准库,无第三方依赖
# 用途:扫描站点源码与构建产物,找出与配置表不一致的电话号码
# 运行方式:python scripts/check_phone_consistency.py
# 退出码约定:脚本只打印,交由人工判断是否清理,避免误删
import re
import json
import pathlib

# 门店配置表:号码的权威数据源,改造时以它为准
STORE_JSON = pathlib.Path("data/stores.json")
# 需要扫描的目录:模板源码 + 构建产物
SCAN_DIRS = ["templates", "pages", "public", ".next/server"]

# 匹配中国区号固话、手机号、400 号码,允许分隔符或连写
PHONE_RE = re.compile(r"(?:\+?86[- ]?)?(4\d{2}|1[3-9]\d{2}|0\d{2,3})[- ]?\d{3,4}[- ]?\d{3,4}")

def load_official_numbers():
    # 读取门店配置,返回官方号码集合
    stores = json.loads(STORE_JSON.read_text(encoding="utf-8"))
    # 用集合去重,同一号码在多家门店出现只算一次
    return {normalize(s["telephone"]) for s in stores}

def normalize(raw):
    # 归一化:去掉所有非数字字符,只保留数字部分用于比对
    digits = re.sub(r"\D", "", raw)
    # 去掉国际前缀,统一成国内直拨格式再比对
    for prefix in ("0086", "86"):
        if digits.startswith(prefix):
            digits = digits[len(prefix):]
    return digits

def scan():
    # 遍历目录,收集每个号码出现的次数与位置
    hits = {}
    for d in SCAN_DIRS:
        for p in pathlib.Path(d).rglob("*"):
            # 只处理文本文件,跳过图片和二进制
            if not p.is_file() or p.suffix not in {".html", ".tsx", ".jsx", ".js", ".json", ".md"}:
                continue
            text = p.read_text(encoding="utf-8", errors="ignore")
            for m in PHONE_RE.findall(text):
                # 记录归一化后的号码出现在哪些文件里
                hits.setdefault(normalize(m[0]), set()).add(str(p))
    return hits

official = load_official_numbers()
for number, files in scan().items():
    # 不在官方集合里的号码,就是需要清理的遗留数据
    if number and number not in official:
        print("疑似遗留号码:", number)
        for f in sorted(files)[:5]:
            print("   出现在:", f)

跑一遍大概十几秒,输出里每个号后面跟着它出现的文件路径。我们第一次跑的时候,扫出 23 个号码,其中 11 个是停用的老号,散落在 6 个页脚模板里。清掉这 11 个,才是真正的收尾。

不想写脚本的话,命令行也能粗筛:

# 环境:Git Bash / macOS / Linux,ripgrep 14.1
# 目的:把源码与构建产物里出现过的电话号码全捞出来做频次统计
# 说明:-o 只输出命中片段,--no-filename 去掉文件名便于聚合
# 管道第二段:sed 去掉分隔符,让同一号码的不同写法归一
# 管道第三段:uniq -c 统计出现次数,再按次数倒序排列
# 扫描目录按需增删,静态导出站点只看 out/ 或 dist/ 即可
rg -o --no-filename '\+?86[- ]?[0-9]{3,4}[- ]?[0-9]{7,8}' templates/ public/ .next/server/ \
  | sed 's/[-+ ]//g' \
  | sort | uniq -c | sort -nr

频次排在最前面却不在配置表里的那个,基本就是出事的号。

官方校验工具怎么跑

改完一定要过一遍校验,别凭感觉。两个工具配合着用:

第一个是 Schema.org 官方的标记校验器,地址是 https://validator.schema.org/ 。它只管语法和类型,不管 Google 认不认。把页面 URL 或 JSON-LD 原文贴进去,重点看 ContactPoint 节点有没有被识别成独立类型,以及 contactOption 有没有报「不在枚举范围内」。

第二个是 Google 的富媒体结果测试,地址是 https://search.google.com/test/rich-results 。它管的是 Google 侧的展示资格。本地商家类目下,它主要看 name、address、telephone、openingHoursSpecification 这些,对 contactPoint 的 contactType 不做强校验——所以这边不报错,不代表 AI 那边能答对,两个工具看到的东西不一样。

跑的时候注意两点:一是用线上 URL 而不是本地地址,二是改完之后等抓取周期,别改完五分钟就去问 AI 助手,那时候索引里还是旧的。我们那次是改完第 3 天再抽查的。

改造前后,差异在哪

第 12 周做的改造,第 15 周回头抽了一次样,用的是 20 组固定提问,比如「城南店客服电话」「空调坏了打哪个号」「你们加盟找谁」。结果如下:

指标 改造前 改造后
电话号码来源 页脚硬编码,含 2019 年停用的 400 号 单一配置字段,与联系页同一变量
contactType 标注 客服、技术支持、销售三条
20 组提问命中率 6 组给出停用号 20 组全部给出在用号
号码格式 4001234567(纯数字,无国家码) +86-400-123-4567
夜间提问(22 点后) 报坐席固话,无人接听 报 24 小时值班号
全站遗留号码数量 11 个停用号散落 6 个模板 0

变化最明显的是夜间那个场景。改造前用户在晚上十一点问,AI 照样把白天的坐席固话报出去;加上 hoursAvailable 之后,引擎会跳过已下班的号,改推覆盖全时段的那条。这一条是纯粹的字段功夫,没动任何页面文案。

容易想歪的几个点

误区一:以为写了 telephone 就够了。telephone 只是个字符串,AI 搜索语境下它承载不了「这是客服还是销售」的信息,contactType 才承载。

误区二:以为校验器全绿就等于 AI 会答对。校验器验证的是语法合规,不验证语义合理。一个语法完美但写着停用号码的 JSON-LD,照样全绿。

误区三:以为改一处就行。号码在模板、联系页、地图平台、点评站上各有一份,改了 JSON-LD 不改页脚,过几天又被覆盖回去。做 GEO 的时候,号码一致性比字段写法更基础。

往后看,本地生活服务这类站点在 AI 搜索里的竞争,会越来越集中在「实体信息的准确度」而不是「关键词密度」。门店号、营业时间、服务范围这些字段,谁的数据干净谁就容易被引用。把 ContactPoint 写规范,成本就是几个字段的事,收益是用户打过来的电话能接通。

你们站点上有没有类似的遗留号码问题,评论区聊聊排查思路。

参考与延伸

  • ContactPoint 官方类型定义:https://schema.org/ContactPoint
  • LocalBusiness 官方类型定义:https://schema.org/LocalBusiness
  • Schema.org 标记校验器:https://validator.schema.org/
  • Google 本地商家结构化数据文档:https://developers.google.com/search/docs/appearance/structured-data/local-business

关键词:GEO、AI优化AIO、AI 推荐门店、LocalBusiness、ContactPoint、Schema.org、结构化数据、本地生活服务

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