语音助手念不出你的页面:Speakable Schema 的规范解读与配置清单
适用读者:负责企业官网前端与 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[输出语音]
图里有一条分支值得单独盯:走通用正文提取的那条线,在导航栏语义标记齐全(nav、footer、role)的站点上反而更容易翻车——结构越工整,被误判成「主体文本」的概率越高。
Speakable 的字段分工:cssSelector、xpath、namePosition、headline
speakable 是 WebPage 与 Article 上的一个属性,取值类型是 SpeakableSpecification(可朗读区段规范)。规范里的正式成员只有两个:cssSelector(CSS 选择器)与 xpath(XPath 表达式)。前者用 CSS 选择器指向一个或多个元素,后者用 XPath 路径做同样的事,二者取其一即可,同时出现时实现通常优先读 cssSelector。
namePosition 与 headline 是另一回事。它们出现在更早的 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 或等价实现执行。数组里有三个元素,就执行三次查询,结果合集按文档顺序合并。注意这里没有「优先级」概念,第一个选择器匹配不到不会让第二个自动顶上,各自独立生效。
第二步是节点排序与去重。按文档出现位置排序是通用做法,嵌套命中(比如同时标注了 article 和 article 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_nav 为 True 时,八成是把导航或相关推荐标进去了。over_limit 为 True 则要换更短的摘要块,而不是继续加选择器——加多了只会让截断位置更靠前。
校验报错对照表
| 报错或现象 | 真正原因 | 修法 |
|---|---|---|
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 |
命中了导航、面包屑或推荐列表 | 收窄选择器,并给 nav、footer 补语义标签 |
| 播报内容与预期差一截 | 文本超窗被截断 | 只标导语,或自行拆分多个短区块 |
数据:标注前后语音播报命中正文的比例对照
样本是客户的行业资讯频道,六十条在改版后上线的文章。标注前用同一批设备在车载与智能音箱两种场景各播一次,标注后第六周再各播一次,两次均记录播报首句来源与是否出现页脚文本。
| 观测维度 | 标注前(样本 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_nav 与 over_limit。这一步做完,语音播报从「念导航栏」变成「念导语」,成本大概是一个人天。
如果实测中遇到标记齐全但播报仍读页脚的站点,或者反过来,标了 speakable 却被某些语音产品完全忽略,欢迎在评论区贴出 curl 的原始 HTML 片段与选择器写法,一起对着 DOM 找差异。
参考与延伸
- schema.org 的 SpeakableSpecification 类型定义,
cssSelector与xpath的期望类型说明: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, 结构化数据