Learning Video Schema Framework
Use conditional-logic schema generation to keep Learning Video markup aligned with real page state and avoid stale or invalid static templates.
- Google no longer shows Learning video rich results. The phase-out was announced on Jun 12, 2025 (Simplifying the search results page), and on Sep 9, 2025 Google removed the documentation, the Search Console report and Rich Results Test support.
- Regular Video structured data is unaffected. A VideoObject with name, thumbnailUrl and uploadDate is still eligible for video results and key moments; the LearningResource type and properties such as educationalLevel now only help schema.org consumers and AI assistants.
- What to do instead: build the VideoObject to the Video spec first, and add LearningResource as a second type only when the page states the level and topic that the markup claims.
When To Use It
Google Search Central RetiredDocumentation changelogRetired feature. The old documentation URL now redirects to the Search Central updates changelog. Educational videos still qualify for the regular Video rich result: https://developers.google.com/search/docs/appearance/structured-data/video
Key Implementation Documentation Highlights
- Status: Learning video is retired. Phase-out announced Jun 12, 2025; documentation, Search Console reporting, Rich Results Test support and the Search appearance filter removed Sep 9, 2025. Google lists no required or recommended properties for it any more.
- Still supported, Video: required name, thumbnailUrl and uploadDate (ISO 8601 DateTime; include a timezone offset, otherwise Google uses the Googlebot timezone).
- Video recommended: contentUrl or embedUrl (contentUrl is the most effective; neither may be the watch page URL), description, duration (ISO 8601, for example PT12M34S), creator or author with a name, expires, hasPart, interactionStatistic, publication for live video, and regionsAllowed or ineligibleRegion (not both).
- Key moments: each Clip needs name, startOffset (seconds) and url on the same path as the video page plus a time parameter; no two clips share a startOffset, and endOffset must be after startOffset.
- LearningResource properties (educationalLevel, learningResourceType, teaches) are schema.org only now: keep them accurate and visible, but expect no Google feature from them.
Template Approach
const pageData = {
title: "Solving Linear Equations in One Variable",
summary: "A worked lesson on isolating the variable in linear equations.",
thumbnail: "https://www.example.com/media/linear-equations.jpg",
uploaded: "2026-08-18T09:00:00-04:00",
level: "Grade 8"
};
const staticTemplate = {
"@context": "https://schema.org",
"@type": ["VideoObject", "LearningResource"],
"name": pageData.title,
"description": pageData.summary,
"thumbnailUrl": pageData.thumbnail,
"uploadDate": pageData.uploaded,
"educationalLevel": pageData.level
};
{
"@context": "https://schema.org",
"@type": [
"VideoObject",
"LearningResource"
],
"name": "Solving Linear Equations in One Variable",
"description": "A worked lesson on isolating the variable in linear equations.",
"thumbnailUrl": "https://www.example.com/media/linear-equations.jpg",
"uploadDate": "2026-08-18T09:00:00-04:00",
"educationalLevel": "Grade 8"
}
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;
}
// Seconds to ISO 8601 duration, for example 754 -> "PT12M34S".
function toIsoDuration(seconds) {
if (!Number.isInteger(seconds) || seconds <= 0) return null;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
return "PT" + (h ? h + "H" : "") + (m ? m + "M" : "") + (s ? s + "S" : "");
}
// Key moments: unique integer startOffset, endOffset after start, url = page URL + ?t=.
function buildClips(chapters, pageUrl, durationSeconds) {
const seen = new Set();
return (chapters || [])
.filter((c) => c.name && Number.isInteger(c.start) && c.start >= 0 && !seen.has(c.start) && seen.add(c.start))
.filter((c) => !Number.isInteger(durationSeconds) || c.start < durationSeconds)
.map((c) => ({
"@type": "Clip",
name: c.name,
startOffset: c.start,
endOffset: Number.isInteger(c.end) && c.end > c.start ? c.end : null,
url: pageUrl + "?t=" + c.start
}));
}
// LearningResource is added only when the page states learning metadata.
// It earns no Google rich result (retired Sep 9, 2025); VideoObject still does.
function chooseVideoType(source) {
const learning = source.learning || {};
return learning.educationalLevel || learning.teaches ? ["VideoObject", "LearningResource"] : "VideoObject";
}
function buildLearningVideoSchema(source) {
if (!passesPageGates(source.page)) return null;
// Google Video required: name, thumbnailUrl, uploadDate (DateTime with offset).
const thumbnails = (source.thumbnailUrls || []).filter(isAbsoluteUrl);
const uploadDate = toIsoDateTime(source.uploadDate);
if (!source.name || thumbnails.length === 0 || !uploadDate) return null;
// contentUrl and embedUrl must be absolute and must not be the watch page itself.
const mediaUrl = (url) => (isAbsoluteUrl(url) && url !== source.page.url ? url : null);
const expires = toIsoDateTime(source.expires);
const views = Number.isInteger(source.viewCount) && source.viewCount >= 0 ? source.viewCount : null;
const learning = source.learning || {};
const type = chooseVideoType(source);
const schema = {
"@context": "https://schema.org",
"@type": type,
name: source.name,
description: source.description,
thumbnailUrl: thumbnails,
uploadDate,
duration: toIsoDuration(source.durationSeconds),
contentUrl: mediaUrl(source.contentUrl),
embedUrl: mediaUrl(source.embedUrl),
expires: expires && Date.parse(expires) > Date.parse(uploadDate) ? expires : null,
creator: source.creator && source.creator.name ? {
"@type": source.creator.type === "Organization" ? "Organization" : "Person",
name: source.creator.name,
url: isAbsoluteUrl(source.creator.url) ? source.creator.url : null
} : null,
hasPart: buildClips(source.chapters, source.page.url, source.durationSeconds),
interactionStatistic: views === null ? null : {
"@type": "InteractionCounter",
interactionType: { "@type": "WatchAction" },
userInteractionCount: views
}
};
if (Array.isArray(type)) {
Object.assign(schema, {
educationalLevel: learning.educationalLevel,
learningResourceType: learning.learningResourceType,
teaches: learning.teaches,
inLanguage: learning.inLanguage
});
}
return compact(schema);
}
// Usage:
// const source = {
// "page": {
// "url": "https://www.example.com/lessons/solving-linear-equations",
// "canonicalUrl": "https://www.example.com/lessons/solving-linear-equations",
// "contentVisible": true
// },
// "name": "Solving Linear Equations in One Variable",
// "description": "A worked lesson on isolating the variable in one-step and two-step linear equations, with four practice problems.",
// "thumbnailUrls": [
// "https://www.example.com/media/linear-equations-16x9.jpg",
// "https://www.example.com/media/linear-equations-4x3.jpg",
// "https://www.example.com/media/linear-equations-1x1.jpg"
// ],
// "uploadDate": "2026-08-18T09:00:00-04:00",
// "durationSeconds": 754,
// "contentUrl": "https://cdn.example.com/video/linear-equations.mp4",
// "embedUrl": "https://www.example.com/embed/linear-equations",
// "creator": {
// "type": "Organization",
// "name": "Example Math Academy",
// "url": "https://www.example.com/about"
// },
// "chapters": [
// {
// "name": "One-step equations",
// "start": 0,
// "end": 185
// },
// {
// "name": "Two-step equations",
// "start": 185,
// "end": 472
// },
// {
// "name": "Practice problems",
// "start": 472,
// "end": 754
// }
// ],
// "viewCount": 18342,
// "learning": {
// "educationalLevel": "Grade 8",
// "learningResourceType": "Video lesson",
// "teaches": "Solving one-step and two-step linear equations",
// "inLanguage": "en"
// }
// };
// const schema = buildLearningVideoSchema(source);
# Shared helpers
import re
from datetime import date, datetime
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
# Seconds to ISO 8601 duration, for example 754 -> "PT12M34S".
def to_iso_duration(seconds):
if not isinstance(seconds, int) or isinstance(seconds, bool) or seconds <= 0:
return None
h, rest = divmod(seconds, 3600)
m, s = divmod(rest, 60)
return "PT" + (f"{h}H" if h else "") + (f"{m}M" if m else "") + (f"{s}S" if s else "")
def parse_datetime(value):
return datetime.fromisoformat(value.replace("Z", "+00:00"))
# Key moments: unique integer startOffset, endOffset after start, url = page URL + ?t=.
def is_int(value):
return isinstance(value, int) and not isinstance(value, bool)
def build_clips(chapters, page_url, duration_seconds):
clips, seen = [], set()
for c in chapters or []:
start, end = c.get("start"), c.get("end")
if not c.get("name") or not is_int(start) or start < 0 or start in seen:
continue
seen.add(start)
if is_int(duration_seconds) and start >= duration_seconds:
continue
clips.append({
"@type": "Clip",
"name": c["name"],
"startOffset": start,
"endOffset": end if is_int(end) and end > start else None,
"url": f"{page_url}?t={start}",
})
return clips
# LearningResource is added only when the page states learning metadata.
# It earns no Google rich result (retired Sep 9, 2025); VideoObject still does.
def choose_video_type(source):
learning = source.get("learning") or {}
if learning.get("educationalLevel") or learning.get("teaches"):
return ["VideoObject", "LearningResource"]
return "VideoObject"
def build_learning_video_schema(source):
page = source.get("page")
if not passes_page_gates(page):
return None
# Google Video required: name, thumbnailUrl, uploadDate (DateTime with offset).
thumbnails = [url for url in source.get("thumbnailUrls") or [] if is_absolute_url(url)]
upload_date = to_iso_datetime(source.get("uploadDate"))
if not source.get("name") or not thumbnails or not upload_date:
return None
# contentUrl and embedUrl must be absolute and must not be the watch page itself.
def media_url(url):
return url if is_absolute_url(url) and url != page["url"] else None
expires = to_iso_datetime(source.get("expires"))
views = source.get("viewCount")
if not isinstance(views, int) or isinstance(views, bool) or views < 0:
views = None
creator = source.get("creator") or {}
learning = source.get("learning") or {}
type_ = choose_video_type(source)
schema = {
"@context": "https://schema.org",
"@type": type_,
"name": source["name"],
"description": source.get("description"),
"thumbnailUrl": thumbnails,
"uploadDate": upload_date,
"duration": to_iso_duration(source.get("durationSeconds")),
"contentUrl": media_url(source.get("contentUrl")),
"embedUrl": media_url(source.get("embedUrl")),
"expires": expires if expires and parse_datetime(expires) > parse_datetime(upload_date) else None,
"creator": {
"@type": "Organization" if creator.get("type") == "Organization" else "Person",
"name": creator["name"],
"url": creator.get("url") if is_absolute_url(creator.get("url")) else None,
} if creator.get("name") else None,
"hasPart": build_clips(source.get("chapters"), page["url"], source.get("durationSeconds")),
"interactionStatistic": None if views is None else {
"@type": "InteractionCounter",
"interactionType": {"@type": "WatchAction"},
"userInteractionCount": views,
},
}
if isinstance(type_, list):
schema.update({
"educationalLevel": learning.get("educationalLevel"),
"learningResourceType": learning.get("learningResourceType"),
"teaches": learning.get("teaches"),
"inLanguage": learning.get("inLanguage"),
})
return compact(schema)
# Usage: json_ld = build_learning_video_schema(source) # same source dict as the JS example; None means emit nothing
{
"@context": "https://schema.org",
"@type": [
"VideoObject",
"LearningResource"
],
"name": "Solving Linear Equations in One Variable",
"description": "A worked lesson on isolating the variable in one-step and two-step linear equations, with four practice problems.",
"thumbnailUrl": [
"https://www.example.com/media/linear-equations-16x9.jpg",
"https://www.example.com/media/linear-equations-4x3.jpg",
"https://www.example.com/media/linear-equations-1x1.jpg"
],
"uploadDate": "2026-08-18T09:00:00-04:00",
"duration": "PT12M34S",
"contentUrl": "https://cdn.example.com/video/linear-equations.mp4",
"embedUrl": "https://www.example.com/embed/linear-equations",
"creator": {
"@type": "Organization",
"name": "Example Math Academy",
"url": "https://www.example.com/about"
},
"hasPart": [
{
"@type": "Clip",
"name": "One-step equations",
"startOffset": 0,
"endOffset": 185,
"url": "https://www.example.com/lessons/solving-linear-equations?t=0"
},
{
"@type": "Clip",
"name": "Two-step equations",
"startOffset": 185,
"endOffset": 472,
"url": "https://www.example.com/lessons/solving-linear-equations?t=185"
},
{
"@type": "Clip",
"name": "Practice problems",
"startOffset": 472,
"endOffset": 754,
"url": "https://www.example.com/lessons/solving-linear-equations?t=472"
}
],
"interactionStatistic": {
"@type": "InteractionCounter",
"interactionType": {
"@type": "WatchAction"
},
"userInteractionCount": 18342
},
"educationalLevel": "Grade 8",
"learningResourceType": "Video lesson",
"teaches": "Solving one-step and two-step linear equations",
"inLanguage": "en"
}
The Output As An Entity Graph
The conditional-logic output above, parsed in your browser and drawn as the graph a crawler builds from it. Every nested object is a node and the property that holds it is the edge. Tap a node to inspect its properties, or drag to rearrange.
Why Conditional Logic Is Better Than Static Templates
- Gates on the three Video required properties, so a lesson page without a thumbnail or a timezone-qualified upload date emits nothing instead of an invalid VideoObject.
- Adds LearningResource only when the page states a level or topic, so the retired type never carries invented metadata.
- Derives duration and clip offsets from one seconds value, and drops duplicate or out-of-range chapters before they reach key moments.
- Rejects contentUrl or embedUrl values that point at the watch page itself, a common copy-paste error that Google flags.
Property Reference
Google retired the Learning Video rich result, so its documentation no longer lists required or recommended properties. The framework above is still valid schema.org markup and still describes the page to search engines and AI assistants.
Schema.org types used