语音助手念不出你的页面:Speakable Schema 的规范解读与配置清单

2026-09-19 09:59:26 10 次浏览
结构化数据JSON-LD语音助手播报生成式引擎优化SEOGEO

适用读者:负责企业官网前端与 SEO 的工程师,正在做结构化数据改造的技术负责人,以及需要把资讯内容送进智能音箱、车载语音与读屏场景的读者。

一次车载语音实测里,我们让助手朗读客户官网的一条行业资讯。它先念了标题,接着开始念「首页 产品中心 关于我们 联系我们 新闻中心」,然后是「版权所有 京 ICP 备某号」。三十二秒的播报,正文一个字都没出现。

同一条新闻资讯在 AI 应用里能被正常引用,摘要也写得准确,唯独到语音这一环崩了。差别不在内容质量,而在机器取值路径:语音链路拿不到「哪一段可以念」这个信息,就只能退回主导航和页脚这类全站通用文本。 承担这个信息的字段就是 speakable(可朗读区段声明)。

现场:助手念出来的稿子从哪来

先把这条链路和搜索引擎链路区分开。搜索引擎要的是「页面讲的是什么」,回答引擎要的是「能引用哪几句」,语音助手要的是「能原样念出来的一段文本,且长度可控」。三个下游的数据形状不同,喂的字段也不同。

语音播报与网页正文

我们用两种方式复测确认了取值来源。第一种是给页面加上 speakable 声明前后各跑二十条,记录播报首句;第二种是在局域网内用同一台车载设备对同一个 URL 连读三次,观察是否稳定。结果高度一致:没有 speakable 时,三次播报内容完全相同,都是导航加页脚;加上之后,三次都以正文首段开头。

这说明两件事。语音侧的正文提取不是每次随机发挥,它是有确定规则的降级过程;而这个降级的默认落点恰好是页面结构里那一处「全站一致、长度适中、文本干净」的区域——导航栏和版权行。

flowchart TD
    A[语音助手收到朗读请求] --> B[抓取目标 URL 的静态 HTML]
    B --> C{页面带 speakable 声明}
    C -- 有 --> D[解析 cssSelector 或 xpath]
    C -- 无 --> E[走通用正文提取]
    D --> F[按文档顺序合并命中节点的 textContent]
    E --> G[按标题与导航结构猜主体容器]
    F --> H{文本长度是否超出播报窗口}
    G --> H
    H -- 超长 --> I[截断到句边界后生成摘要]
    H -- 未超长 --> J[直接进入 TTS 合成]
    I --> J
    J --> K[输出语音]

图里有一条分支值得单独盯:走通用正文提取的那条线,在导航栏语义标记齐全(navfooterrole)的站点上反而更容易翻车——结构越工整,被误判成「主体文本」的概率越高。

Speakable 的字段分工:cssSelector、xpath、namePosition、headline

speakableWebPageArticle 上的一个属性,取值类型是 SpeakableSpecification(可朗读区段规范)。规范里的正式成员只有两个:cssSelector(CSS 选择器)与 xpath(XPath 表达式)。前者用 CSS 选择器指向一个或多个元素,后者用 XPath 路径做同样的事,二者取其一即可,同时出现时实现通常优先读 cssSelector

namePositionheadline 是另一回事。它们出现在更早的 AMP beta 文档里,用于标注新闻标题内部「从哪个字符位置开始念」,服务于当时的 Google 助理新闻播报。随着 saying:这一版写法没有进入 schema.org 的正式词汇,SpeakableSpecification 的类型定义里查不到这两个名字。现在再往页面里塞,校验器会当成未知属性处理。

字段 期望类型 消费环境 状态 典型误用
cssSelector CssSelectorType,取字符串数组 通用做法,AMP 与非 AMP 页都能写 规范正式成员 写内联样式里的哈希类名,nth-child 一改版就失效
xpath XPathType,取字符串数组 XPath 支持完备的解析器 规范正式成员 用从根节点出发的完整路径 /html/body/div[2],模板一动就断
namePosition 整数(历史 beta 字段) 仅限当年 AMP 新闻播报 已收敛淘汰 仍按旧博客抄进现在的 JSON-LD
headline 文本(历史 beta 字段) 仅限当年 AMP 新闻播报 已收敛淘汰 Article.headline 混为一谈

speakable 挂在 Article 上时语义最清楚:这篇文章里可以朗读的区域。挂在 WebPage 上则更像站点级提示,粒度粗,适合频道首页这种没有独立正文的页面。资讯详情页一律挂在文章节点上。

AMP 与非 AMP 页的现状差异

这套规范的出身决定了它的现状。它最早服务于 Google 助理的新闻播报,落地场景是 AMP(Accelerated Mobile Pages,加速移动页面)。在 AMP 环境里,渲染确定性高、CSS 受限、DOM 结构稳定,cssSelector 的解析结果几乎不会有意外。

非 AMP 页是另一回事。词汇层面,speakable 对所有网页开放;消费方层面,各家语音产品的实现程度不同:有的严格按 cssSelector 取值,有的只在命中 News 类富结果时才读,有的完全忽略。Google Search Central 的 speakable 文档历史上标注为 beta 且与新闻场景绑定,后续的文档结构调整也让这套 beta 标注有所变化。把它当成「提示」而不是「富结果门票」,是现在比较稳妥的定位:写对了不见得有可见收益区块,写错了一定会被降级补救。

对比项 AMP 页 非 AMP 页
DOM 稳定性 模板受限,结构长期不变 随改版自由变动,选择器需走验收
消费方确定度 新闻播报场景明确 视具体语音产品而定,存在忽略的可能
选择器写法 标签与 class 均可,推荐语义标签 推荐用稳定的 data 属性,不依赖样式类名
调试手段 AMP 校验器与预览工具 curl 静态抓取加 Schema Markup Validator
收益可见性 播报触发条件明确 无公开报表,只能用播报抽样自测

差异之外有个共同点:两边都不接受 script 注入式解决。往后 async/defer 或客户端渲染生成的结构化数据,在无渲染能力的抓取通道里等于不存在。

原理剖析:语音播报链路与 cssSelector 的解析过程

抓取:以未执行的静态 HTML 为准

语音侧的抓取预算比搜索引擎更紧。为了控制时延,很多实现直接复用搜索引擎 crawler,或者自己跑一遍静态抓取(Static Fetch),两者都不做完整的 JavaScript 执行。这意味着放在客户端运行时注入的 JSON-LD、靠 Waterfall 首屏渲染出来的正文,在抓取侧可能只有一半。

验证方法很简单,一行 curl -sL 目标URL 看输出里有没有那段 JSON-LD 和正文文本。浏览器里 F12 看到的不算数。

切分:cssSelector 如何被解析

解析过程可以拆成四步。

第一步是选择器组拆分。数组里的每个字符串会被当成一个独立的选择器表达式交给 querySelectorAll 或等价实现执行。数组里有三个元素,就执行三次查询,结果合集按文档顺序合并。注意这里没有「优先级」概念,第一个选择器匹配不到不会让第二个自动顶上,各自独立生效。

第二步是节点排序与去重。按文档出现位置排序是通用做法,嵌套命中(比如同时标注了 articlearticle p 里的第一段)在多数实现里会保留父级、丢弃重复子节点,少数实现会重复拼接文本。不确定的时候宁可只标一层容器。

第三步是textContent。这一步只抽文本,不保留任何标签。副作用是有三类内容会混进来:<script><style> 里的内容、hidden 元素的隐藏文本、以及 ::before 这类伪元素的计数器字符。前两类靠 CSS 选择器规避,伪元素内容则在 CSS 里就别写。

第四步是空白折叠与截取。连续空格、换行、制表符被折叠成单个空格,之后再按播报窗口截断。中文场景下这一步还有个坑:有些站点的正文用 <br> 手工换行,textContent 会把它变成零字符,两个句子直接粘成一个长句。

flowchart LR
    A[读取 speakable.cssSelector 数组] --> B[逐条执行 querySelectorAll]
    B --> C[合并全部命中节点并按文档顺序排序]
    C --> D[剥离 script style hidden 节点]
    D --> E[取 textContent]
    E --> F[折叠空白字符]
    F --> G[按播报窗口截断到句边界]
    G --> H[交给 TTS 合成]
    B -- 选择器语法不支持 --> I[整条忽略]
    I --> J[降级到通用正文提取]

合成:SSML 与时长窗口

进入 TTS(Text To Speech,文本转语音)之前,文本通常会被包一层 SSML(Speech Synthesis Markup Language,语音合成标记语言),加上停顿、数字读法、多音字标注。播报窗口一般在二十到四十秒,超出则按句子边界截断——这也是为什么标注的区块不能贪长:标了整篇正文,效果和不标几乎一样,因为最后都会被砍到同一个长度。

哪些块标 speakable,哪些别标

判断标准只有一条:这段文本单独拎出来念给一个没看屏幕的人听,是否成立。 成立就标,不成立就不标。

区域 是否标注 理由 建议选择器形态
文章标题 h1 适合 确立上下文,播报首句的最佳来源 h1.post-title[data-speakable="title"]
导语或摘要段落 适合 一两句话讲完核心信息,长度契合播报窗口 [data-speakable="summary"]
正文首段 适合 无独立摘要时的兜底 [data-speakable="lead"]
主导航栏 严禁标注 站点级文本,与内容无关,播报噪声的主要来源 不标,并加 nav 语义标签便于排除
页脚版权与备案号 严禁标注 全站一致文本,被误读即为本次实测的故障现象 不标,并加 footer 语义标签
面包屑 严禁标注 「首页 大于 新闻中心」这类符号在语音里是噪声 不标
相关推荐列表 不建议 多个标题拼接,模型难以判断边界 不标
图片图注 视情况 有解释性文字时可标,纯装饰图略过 figcaption

严禁标注的三类不靠「不写选择器」来保证,靠的是给 nav、footer、面包屑加上正确的语义标签与 aria-hidden 之外的结构标记,让想保底排除的实现有依据可循。

flowchart TD
    A[候选内容块] --> B{单独念出来是否成立}
    B -- 不成立 --> C[不标注]
    B -- 成立 --> D{是否全站重复}
    D -- 是 --> C
    D -- 否 --> E{是否有稳定选择器}
    E -- 依赖样式类名或 nth-child --> F[补 data-speakable 属性]
    E -- 已有语义标签或 data 属性 --> G[写入 cssSelector 数组]
    F --> G
    G --> H{文本长度是否超播报窗口}
    H -- 超长 --> I[换标更短的摘要块]
    H -- 未超长 --> J[纳入配置清单]

落地的 JSON-LD 与自测脚本

依赖与环境:任意可输出静态 HTML 的站点;示例域名为虚构的 www.example.com。校验工具为 Schema Markup Validator(https://validator.schema.org)与 Google Rich Results Test。下面这段 JSON-LD 里的 // 注释只为讲解,JSON 规范不支持行注释,上线前需要移除或改用 JSONC 预处理。

// 资讯详情页 head 中的 JSON-LD,speakable 挂在 Article 节点上
{
  // 上下文固定写法,指向 schema.org 官方词汇表
  "@context": "https://schema.org",
  // 资讯详情页的主类型
  "@type": "NewsArticle",
  // 文章 ID 用带域名的完整 URL,全站不重复
  "@id": "https://www.example.com/news/2026/valve-industry-trend/#article",
  // 页面 URL,须与 canonical 一致
  "url": "https://www.example.com/news/2026/valve-industry-trend/",
  // 标题与 h1 文本保持一致
  "headline": "衬氟阀门行业季度产能报告发布",
  // speakable 声明从这里开始,类型是 SpeakableSpecification
  "speakable": {
    "@type": "SpeakableSpecification",
    // cssSelector 是数组,每个元素是一个独立的 CSS 选择器
    // 这里只标标题、导语、正文首段三层,不标正文全篇
    "cssSelector": [
      // 标题节点,指向 h1 而非整个 header
      "h1[data-speakable='title']",
      // 导语段落,长度控制在一百字以内
      "p[data-speakable='summary']",
      // 正文首段
      "div[data-speakable='lead'] p:first-of-type"
    ]
  },
  // 发布时间,ISO 8601 带时区
  "datePublished": "2026-08-11T09:30:00+08:00",
  // 语言属性,影响 TTS 的发音引擎选择
  "inLanguage": "zh-CN",
  // 作者节点,语音播报有时会念来源
  "author": {
    "@type": "Organization",
    "name": "示例阀门科技"
  }
}

选择器为什么用 data-speakable 属性而不是样式类名?改版时 CSS 重构是常规动作,class 名随时会被打包工具重写(CSS Modules、Tailwind 原子类都算),而 data-* 属性属于结构契约,改动会走评审。这一步是整套配置能不能撑过下一轮改版的关键。

下面的自测脚本用来替代手工 curl,它做的事和播报侧的前两步一样:抽取静态 HTML、按 cssSelector 取文本、判断有没有误伤导航或超长。

依赖与环境:Python 3.10 及以上,beautifulsoup4 4.12、requests 2.31、soupsieve 随 bs4 自动安装,命令 pip install beautifulsoup4 requests

# -*- coding: utf-8 -*-
# speakable_probe.py — 按 speakable.cssSelector 复现语音侧的取值结果
import json
import re
import requests
from bs4 import BeautifulSoup

# 播报窗口上限,取自主流语音助手的公开时长区间
SPEAK_LIMIT = 320
# 这些标签的内容即便被选择器命中也要剔除
DROP_TAGS = {"script", "style", "noscript", "template"}


def fetch_static(url, timeout=10):
    # 模拟无 JS 执行的抓取,UA 用常见语音抓取器标识
    headers = {"User-Agent": "Mozilla/5.0 (compatible; VoiceReaderBot/1.0)"}
    resp = requests.get(url, headers=headers, timeout=timeout)
    # 断言状态码,重定向由 requests 自动跟进
    resp.raise_for_status()
    return resp.text


def read_speakable(html):
    # 从静态 HTML 里取出 Article 的 speakable 声明
    soup = BeautifulSoup(html, "html.parser")
    for tag in soup.find_all("script", attrs={"type": "application/ld+json"}):
        # 跳过为空或注释残留的脚本节点
        if not tag.string:
            continue
        data = json.loads(tag.string)
        # @graph 与平铺结构都要兼容
        nodes = data.get("@graph", [data]) if isinstance(data, dict) else data
        for node in nodes:
            # speakable 可能挂在 Article、NewsArticle 或 WebPage 上
            # 这里不做类型过滤,凡是带该属性的节点都取出来
            if isinstance(node, dict) and "speakable" in node:
                # 返回的是 SpeakableSpecification 这个字典对象
                return node["speakable"]
    return None


def clean_text(node):
    # 先剔除脚本与样式类子节点,避免内联脚本被念出来
    # 这里的 decompose 会改动 soup 树,务必在排序去重之后再调用
    for bad in node.find_all(list(DROP_TAGS)):
        bad.decompose()
    # 折叠空白,模拟 TTS 前的文本规整
    # get_text 的分隔符用空格,防止相邻标签的文本直接粘连
    return re.sub(r"\s+", " ", node.get_text(" ", strip=True)).strip()


def probe(url):
    # 入口函数,返回一个可直接判读的字典
    html = fetch_static(url)
    speakable = read_speakable(html)
    # 拿不到声明时直接给出降级提示,省得误判
    if not speakable:
        return {"ok": False, "reason": "静态 HTML 里没有 speakable 声明"}

    soup = BeautifulSoup(html, "html.parser")
    selectors = speakable.get("cssSelector", [])
    # 逐个选择器执行查询,命中节点按文档顺序合并
    hits = []
    for sel in selectors:
        try:
            hits.extend(soup.select(sel))
        except Exception as exc:
            # 选择器语法不被支持时整条跳过,和多数实现一致
            print(f"选择器不可用:{sel} -> {exc}")
    if not hits:
        return {"ok": False, "reason": "所有选择器均未命中,会降级到通用正文提取"}

    # dict.fromkeys 去重的同时保留文档顺序,等价于解析器的节点排序
    hits = list(dict.fromkeys(hits))
    text = " ".join(clean_text(h) for h in hits)
    # 链接密度过高通常是误标了导航或推荐位
    links = sum(len(h.find_all("a")) for h in hits)
    return {
        "ok": True,
        # 文本长度与播报窗口的关系,超出会被截断
        "chars": len(text),
        "over_limit": len(text) > SPEAK_LIMIT,
        "link_count": links,
        "looks_like_nav": links > 3,
        "preview": text[:80],
    }


if __name__ == "__main__":
    print(probe("https://www.example.com/news/2026/valve-industry-trend/"))

脚本跑出来的 looks_like_navTrue 时,八成是把导航或相关推荐标进去了。over_limitTrue 则要换更短的摘要块,而不是继续加选择器——加多了只会让截断位置更靠前。

校验报错对照表

报错或现象 真正原因 修法
Validator 报 cssSelector is not a known property 属性写在了错误的层级,或类型拼成了 Speakable 外层必须是 speakable,内层写 "@type": "SpeakableSpecification"
Validator 报取值类型不符 写成单个字符串而非数组 即使只有一个选择器也要用方括号包起来
富结果测试工具无任何 speakable 提示 它不是该工具当前支持的富媒体类型,属正常现象 改用 Schema Markup Validator 做语法级校验
namePosition 被标为未知属性 沿用了 AMP beta 时期的旧写法 删掉,改用 cssSelector
本地 curl 找不到 JSON-LD 客户端渲染或运行时注入 改为服务端渲染输出同一段脚本
脚本显示 looks_like_nav 命中了导航、面包屑或推荐列表 收窄选择器,并给 navfooter 补语义标签
播报内容与预期差一截 文本超窗被截断 只标导语,或自行拆分多个短区块

数据:标注前后语音播报命中正文的比例对照

样本是客户的行业资讯频道,六十条在改版后上线的文章。标注前用同一批设备在车载与智能音箱两种场景各播一次,标注后第六周再各播一次,两次均记录播报首句来源与是否出现页脚文本。

观测维度 标注前(样本 60 条) 标注后(样本 60 条) 变化
播报首句来自正文或导语 11 条 52 条 提升 68 个百分点
播报内容含导航或页脚文本 47 条 2 条 下降 75 个百分点
停留在敏:完整念完摘要未被截断 13 条 46 条 提升 55 个百分点
平均播报时长 41 秒 23 秒 缩短 18 秒
两次重播内容完全一致率 60 条 58 条 基本持平

剩下那 2 条仍命中页脚的文章,原因是一条 Tailwind 类名重构后 [data-speakable] 属性被前端同学顺手删了,第二轮评审里补回,顺手加进了 CI 的断言。

误区澄清与执行清单

第一个误区是把 speakable 当收录开关。它不负责让页面被抓到,只负责告诉已经抓到页面的那一端「念哪一段」。

第二个误区是贪心地标整篇正文。播报窗口二十到四十秒,标的区块再长也会被截断到同一长度,而截止点由模型决定,等于把主动权交出去。标一百字导语,效果好过标三千字全文。

第三个误区是选择器写 nth-child 或样式类名。这两类写法在下一轮 CSS 重构里属于高危区,一次改版静默失效三个月都不会有人发现。

第四个误区是只在 head 里写声明而不管 DOM。speakable 生效的前提是选择器能在静态 HTML 里命中节点,声明和 DOM 是两件事,缺一边就降级。

执行层面可以压成五步:服务端渲染输出 JSON-LD;speakable 挂在 Article 节点;cssSelector 只写标题、导语、首段三层;选择器一律走 data-speakable 属性;把上面那个探针脚本接进 CI,改版后自动跑一遍 looks_like_navover_limit。这一步做完,语音播报从「念导航栏」变成「念导语」,成本大概是一个人天。

如果实测中遇到标记齐全但播报仍读页脚的站点,或者反过来,标了 speakable 却被某些语音产品完全忽略,欢迎在评论区贴出 curl 的原始 HTML 片段与选择器写法,一起对着 DOM 找差异。

参考与延伸

  • schema.org 的 SpeakableSpecification 类型定义,cssSelectorxpath 的期望类型说明:https://schema.org/SpeakableSpecification
  • Google Search Central 的结构化数据总览,含 speakable 在富结果体系中的定位说明:https://developers.google.com/search/docs/appearance/structured-data
  • W3C 的语音合成标记语言规范 SSML 1.1,用于理解 TTS 前后的停顿与发音控制:https://www.w3.org/TR/speech-synthesis11/
  • Schema Markup Validator,做语法级校验时优先于富结果测试工具:https://validator.schema.org/

关键词:Speakable Schema, cssSelector, JSON-LD, 语音助手播报, 生成式引擎优化, GEO, AI优化AIO, 结构化数据

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