Speakable Schema Framework
Use conditional-logic schema generation to keep Speakable markup aligned with real page state and avoid stale or invalid static templates.
When To Use It
Google Search CentralSpeakable documentationUse on English-language news pages to mark the short sections (headline, summary) that Google Assistant can read aloud.
Key Implementation Documentation Highlights
- Availability: beta and subject to change. Only for users in the U.S. with Google Home devices set to English, and only for English-language content from news publishers.
- Required: speakable (a SpeakableSpecification) on an Article or WebPage. Inside it, use either cssSelector or xpath, not both.
- Google lists no recommended properties for this feature.
- Each selector should resolve to visible content in the rendered page. Aim for about 20 to 30 seconds of content per section, roughly two to three sentences.
- Mark concise headlines and summaries that make sense in voice-only situations. Do not mark datelines, photo captions or source attributions, and do not highlight the entire article.
- Validate with the Rich Results Test before deploying.
Template Approach
const pageData = {
title: "City Council Approves 2027 Transit Budget",
url: "https://www.example.com/news/2026/09/city-council-approves-transit-budget"
};
const staticTemplate = {
"@context": "https://schema.org",
"@type": "WebPage",
"name": pageData.title,
"url": pageData.url,
"speakable": {
"@type": "SpeakableSpecification",
"cssSelector": [".article-headline", ".article-summary"]
}
};
{
"@context": "https://schema.org",
"@type": "WebPage",
"name": "City Council Approves 2027 Transit Budget",
"url": "https://www.example.com/news/2026/09/city-council-approves-transit-budget",
"speakable": {
"@type": "SpeakableSpecification",
"cssSelector": [
".article-headline",
".article-summary"
]
}
}
Conditional-logic Framework
// Shared helpers
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
const ISO_DATETIME = /^(\d{4}-\d{2}-\d{2})T([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?(Z|[+-](0\d|1[0-4]):[0-5]\d)$/;
function isAbsoluteUrl(value) {
if (typeof value !== "string") return false;
try {
const url = new URL(value);
return (url.protocol === "https:" || url.protocol === "http:") && url.hostname !== "";
} catch {
return false;
}
}
function toIsoDate(value) {
const match = typeof value === "string" ? ISO_DATE.exec(value) : null;
if (!match) return null;
const [y, m, d] = match.slice(1).map(Number);
const date = new Date(Date.UTC(y, m - 1, d));
return date.getUTCMonth() === m - 1 && date.getUTCDate() === d ? value : null;
}
// Keeps the original timezone offset; rejects naive or unparseable values.
function toIsoDateTime(value) {
const match = typeof value === "string" ? ISO_DATETIME.exec(value) : null;
return match && toIsoDate(match[1]) ? value : null;
}
// Drops null, undefined, "", [] and {} recursively.
function compact(value) {
if (Array.isArray(value)) {
const items = value.map(compact).filter((item) => item !== undefined);
return items.length ? items : undefined;
}
if (value !== null && typeof value === "object") {
const out = {};
for (const [key, item] of Object.entries(value)) {
const kept = compact(item);
if (kept !== undefined) out[key] = kept;
}
return Object.keys(out).length ? out : undefined;
}
return value === null || value === undefined || value === "" ? undefined : value;
}
// Page gates: indexable, self-canonical, marked-up content visible.
function passesPageGates(page) {
if (!page || page.noindex || page.robotsBlocked) return false;
if (!isAbsoluteUrl(page.url)) return false;
if (page.canonicalUrl && page.canonicalUrl !== page.url) return false;
return page.contentVisible === true;
}
// Speakable helpers
// Google: about 20 to 30 seconds per section, roughly two to three sentences.
// 75 words is our ceiling (about 30 seconds at a typical speaking pace).
const MAX_SPEAKABLE_WORDS = 75;
function chooseSpeakableType(source) {
return source.isNewsArticle ? "NewsArticle" : "WebPage";
}
// Keep sections that matched in the rendered page and fit the length guideline.
// Skip datelines, photo captions and source attributions.
function selectSpeakableSections(sections) {
return (sections || []).filter((s) =>
s && s.selector && s.matchCount > 0 && s.wordCount > 0 && s.wordCount <= MAX_SPEAKABLE_WORDS &&
!["dateline", "caption", "attribution"].includes(s.role)
);
}
function buildSpeakableSchema(source) {
const page = source.page;
if (!passesPageGates(page)) return null;
// Feature only serves English-language content (U.S. Google Home users).
if (!/^en(-|$)/i.test(page.lang || "")) return null;
const sections = selectSpeakableSections(source.speakableSections);
// Use cssSelector or xpath, never both: pick one addressing mode per page.
const mode = sections.some((s) => s.kind === "css") ? "css" : "xpath";
const selectors = sections.filter((s) => s.kind === mode).map((s) => s.selector);
// Required: speakable with at least one cssSelector or xpath.
if (!selectors.length) return null;
const type = chooseSpeakableType(source);
return compact({
"@context": "https://schema.org",
"@type": type,
[type === "WebPage" ? "name" : "headline"]: source.title,
url: page.url,
speakable: {
"@type": "SpeakableSpecification",
[mode === "css" ? "cssSelector" : "xpath"]: selectors
}
}) ?? null;
}
// Usage:
// const source = {
// "page": {
// "url": "https://www.example.com/news/2026/09/city-council-approves-transit-budget",
// "canonicalUrl": "https://www.example.com/news/2026/09/city-council-approves-transit-budget",
// "noindex": false,
// "contentVisible": true,
// "lang": "en-US"
// },
// "isNewsArticle": false,
// "title": "City Council Approves 2027 Transit Budget",
// "speakableSections": [
// {
// "kind": "css",
// "selector": ".article-headline",
// "matchCount": 1,
// "wordCount": 6,
// "role": "headline"
// },
// {
// "kind": "css",
// "selector": ".article-summary",
// "matchCount": 1,
// "wordCount": 48,
// "role": "summary"
// },
// {
// "kind": "css",
// "selector": ".article-dateline",
// "matchCount": 1,
// "wordCount": 4,
// "role": "dateline"
// },
// {
// "kind": "css",
// "selector": ".article-body",
// "matchCount": 1,
// "wordCount": 912,
// "role": "body"
// }
// ]
// };
// const schema = buildSpeakableSchema(source);
# Shared helpers
import re
from datetime import date
from urllib.parse import urlparse
ISO_DATE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})$")
ISO_DATETIME = re.compile(r"^(\d{4}-\d{2}-\d{2})T([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?(Z|[+-](0\d|1[0-4]):[0-5]\d)$")
def is_absolute_url(value):
if not isinstance(value, str):
return False
try:
parts = urlparse(value)
except ValueError:
return False
return parts.scheme in ("http", "https") and bool(parts.hostname)
def to_iso_date(value):
if not isinstance(value, str) or not ISO_DATE.match(value):
return None
try:
date.fromisoformat(value)
except ValueError:
return None
return value
# Keeps the original timezone offset; rejects naive or unparseable values.
def to_iso_datetime(value):
match = ISO_DATETIME.match(value) if isinstance(value, str) else None
return value if match and to_iso_date(match.group(1)) else None
# Drops None, "", [] and {} recursively.
def compact(value):
if isinstance(value, list):
items = [kept for kept in (compact(item) for item in value) if kept is not None]
return items or None
if isinstance(value, dict):
out = {key: kept for key, kept in ((k, compact(v)) for k, v in value.items()) if kept is not None}
return out or None
return None if value is None or value == "" else value
# Page gates: indexable, self-canonical, marked-up content visible.
def passes_page_gates(page):
if not page or page.get("noindex") or page.get("robotsBlocked"):
return False
if not is_absolute_url(page.get("url")):
return False
if page.get("canonicalUrl") and page["canonicalUrl"] != page["url"]:
return False
return page.get("contentVisible") is True
# Speakable helpers
# Google: about 20 to 30 seconds per section, roughly two to three sentences.
# 75 words is our ceiling (about 30 seconds at a typical speaking pace).
MAX_SPEAKABLE_WORDS = 75
EN_LANG = re.compile(r"^en(-|$)", re.I)
def choose_speakable_type(source):
return "NewsArticle" if source.get("isNewsArticle") else "WebPage"
# Keep sections that matched in the rendered page and fit the length guideline.
# Skip datelines, photo captions and source attributions.
def select_speakable_sections(sections):
return [
s for s in (sections or [])
if s and s.get("selector") and (s.get("matchCount") or 0) > 0
and 0 < (s.get("wordCount") or 0) <= MAX_SPEAKABLE_WORDS
and s.get("role") not in ("dateline", "caption", "attribution")
]
def build_speakable_schema(source):
page = source.get("page")
if not passes_page_gates(page):
return None
# Feature only serves English-language content (U.S. Google Home users).
if not EN_LANG.match(page.get("lang") or ""):
return None
sections = select_speakable_sections(source.get("speakableSections"))
# Use cssSelector or xpath, never both: pick one addressing mode per page.
mode = "css" if any(s.get("kind") == "css" for s in sections) else "xpath"
selectors = [s["selector"] for s in sections if s.get("kind") == mode]
# Required: speakable with at least one cssSelector or xpath.
if not selectors:
return None
schema_type = choose_speakable_type(source)
return compact({
"@context": "https://schema.org",
"@type": schema_type,
"name" if schema_type == "WebPage" else "headline": source.get("title"),
"url": page["url"],
"speakable": {
"@type": "SpeakableSpecification",
"cssSelector" if mode == "css" else "xpath": selectors,
},
})
{
"@context": "https://schema.org",
"@type": "WebPage",
"name": "City Council Approves 2027 Transit Budget",
"url": "https://www.example.com/news/2026/09/city-council-approves-transit-budget",
"speakable": {
"@type": "SpeakableSpecification",
"cssSelector": [
".article-headline",
".article-summary"
]
}
}
Why Conditional Logic Is Better Than Static Templates
- Prevents emitting empty, null, or stale fields that frequently trigger rich result warnings.
- Supports multiple page states (for example: no rating yet, no image yet, no offers yet) without duplicate templates.
- Lets engineering teams centralize schema policy checks and validation in reusable code paths.
- Makes large-scale schema maintenance safer when content models evolve over time.
Property Reference
What Google's Speakable documentation requires and recommends, property by property. These are the same rules the SchemaCDN validator checks. Each name links to its schema.org definition.
Availability: Beta and subject to change.
Required, one of: In speakable: speakableSpecification requires cssSelector or xpath.
| Property | Expects | Notes | |
|---|---|---|---|
| speakable | Required | SpeakableSpecification | Indicates sections of a web page that are particularly suited for text-to-speech playback using Google Assistant. Use on Article or WebPage. Content is located by cssSelector or xpath. |
| speakable.cssSelector | One of | Text, can repeat | Provide cssSelector (or xpath) to address the speakable content. |
| speakable.xpath | One of | Text, can repeat | Provide xpath (or cssSelector) to address the speakable content. |