|
# 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,
|
|
)
|