# retoor <retoor@molodetz.nl>
from __future__ import annotations
import json
from .base import (
MEDIUM,
LOW,
INFO,
PASS,
WARN,
FAIL,
Check,
PageContext,
SiteContext,
ok_check,
page_check,
)
CATEGORY = "structured_data"
REQUIRED_PROPS = {
"Article": ("headline",),
"BlogPosting": ("headline",),
"NewsArticle": ("headline",),
"Product": ("name",),
"Offer": ("price", "priceCurrency"),
"Organization": ("name", "url"),
"LocalBusiness": ("name", "address"),
"BreadcrumbList": ("itemListElement",),
"FAQPage": ("mainEntity",),
"WebSite": ("name", "url"),
"Person": ("name",),
"Recipe": ("name", "recipeIngredient"),
"Event": ("name", "startDate"),
"VideoObject": ("name", "thumbnailUrl", "uploadDate"),
"SoftwareApplication": ("name",),
}
def _iter_objects(parsed):
if isinstance(parsed, list):
for item in parsed:
yield from _iter_objects(item)
return
if not isinstance(parsed, dict):
return
graph = parsed.get("@graph")
if isinstance(graph, list):
for item in graph:
yield from _iter_objects(item)
if parsed.get("@type"):
yield parsed
def _types(node) -> list:
raw = node.get("@type")
if isinstance(raw, list):
return [str(item) for item in raw]
return [str(raw)] if raw else []
@page_check
def jsonld(page: PageContext, site: SiteContext) -> list:
blocks = page.dom.get("jsonld", []) or []
present = bool(blocks)
results = [
ok_check(
"structured.jsonld_present",
CATEGORY,
"Structured data (JSON-LD)",
present,
severity=MEDIUM,
value=f"{len(blocks)} block(s)" if present else "none",
recommendation="Add JSON-LD structured data so search engines can render rich results.",
warn=True,
url=page.url,
)
]
if not present:
return results
invalid = 0
nodes = []
for block in blocks:
try:
parsed = json.loads(block)
except (ValueError, TypeError):
invalid += 1
continue
nodes.extend(_iter_objects(parsed))
results.append(
ok_check(
"structured.jsonld_valid",
CATEGORY,
"JSON-LD parses",
invalid == 0,
severity=MEDIUM,
value="valid" if invalid == 0 else f"{invalid} invalid block(s)",
recommendation="Fix JSON syntax errors in structured-data blocks.",
url=page.url,
)
)
found_types = sorted({t for node in nodes for t in _types(node)})
results.append(
Check(
"structured.types",
CATEGORY,
"Schema types",
INFO,
INFO,
value=", ".join(found_types) or "none",
details={"types": found_types},
url=page.url,
)
)
missing = []
for node in nodes:
for type_name in _types(node):
required = REQUIRED_PROPS.get(type_name)
if not required:
continue
absent = [prop for prop in required if not node.get(prop)]
if absent:
missing.append(f"{type_name}: {', '.join(absent)}")
if missing:
results.append(
Check(
"structured.required_props",
CATEGORY,
"Required schema properties",
WARN,
LOW,
value=f"{len(missing)} type(s) missing props",
recommendation="Add the required properties for each declared schema type.",
details={"missing": missing[:20]},
url=page.url,
)
)
return results
@page_check
def other_markup(page: PageContext, site: SiteContext) -> Check:
microdata = bool(page.dom.get("microdata"))
rdfa = bool(page.dom.get("rdfa"))
formats = []
if microdata:
formats.append("microdata")
if rdfa:
formats.append("RDFa")
return Check(
"structured.other_markup",
CATEGORY,
"Other structured-data formats",
INFO,
INFO,
value=", ".join(formats) or "none",
details={"microdata": microdata, "rdfa": rdfa},
url=page.url,
)