Improve SEO discovery metadata and sitemap freshness (#154)
Add breadcrumb structured data, accurate sitemap freshness metadata, article social metadata, content-first page titles, and stable offline regression coverage.
Full diff
@@ -2897,6 +2897,9 @@ def base_ctx(lang, page, sub, *, title, description, ticker, build,\n f"{SITE_ORIGIN}{BASE_PATH}/assets/skytical-social.png?v={ASSET_VERSION}"\n ),\n "social_image_alt": "SKYTICAL — SKYLINE TO AVIATION NEWS",\n+ "social_image_width": 1200,\n+ "social_image_height": 630,\n+ "social_image_type": "image/png",\n "og_type": "website",\n "structured_data": None,\n "robots_meta": "index,follow,max-image-preview:large",\n@@ -2968,12 +2971,37 @@ def website_structured_data(lang: str) -> dict:\n }\n \n \n+def breadcrumb_entity(lang: str, crumbs) -> dict:\n+ """Schema.org breadcrumb hierarchy for a rendered public page."""\n+ current_url = absolute_page_url(lang, crumbs[-1][1])\n+ return {\n+ "@type": "BreadcrumbList",\n+ "@id": f"{current_url}#breadcrumb",\n+ "itemListElement": [\n+ {\n+ "@type": "ListItem",\n+ "position": position,\n+ "name": label,\n+ "item": absolute_page_url(lang, sub),\n+ }\n+ for position, (label, sub) in enumerate(crumbs, start=1)\n+ ],\n+ }\n+\n+\n+def breadcrumb_structured_data(lang: str, crumbs) -> dict:\n+ """Standalone JSON-LD document for non-home public pages."""\n+ return {\n+ "@context": "https://schema.org",\n+ **breadcrumb_entity(lang, crumbs),\n+ }\n+\n+\n def article_structured_data(lang: str, article: dict) -> dict:\n """Google-supported NewsArticle fields, grounded in rendered content."""\n article_url = absolute_page_url(lang, f"news/{article['id']}/")\n publisher_id = f"{SITE_ORIGIN}{BASE_PATH}/#organization"\n- schema = {\n- "@context": "https://schema.org",\n+ article_entity = {\n "@type": "NewsArticle",\n "@id": f"{article_url}#article",\n "mainEntityOfPage": {\n@@ -3008,8 +3036,16 @@ def article_structured_data(lang: str, article: dict) -> dict:\n },\n }\n if article.get("image"):\n- schema["image"] = [article["image"]["url"]]\n- return schema\n+ article_entity["image"] = [article["image"]["url"]]\n+ breadcrumbs = breadcrumb_entity(lang, [\n+ (L[lang]["siteName"], ""),\n+ (L[lang]["newsTitle"], "news/"),\n+ (article["title"], f"news/{article['id']}/"),\n+ ])\n+ return {\n+ "@context": "https://schema.org",\n+ "@graph": [article_entity, breadcrumbs],\n+ }\n \n \n def _write_xml(path: Path, root: ET.Element) -> None:\n@@ -3069,19 +3105,42 @@ def add_url(lang: str, sub: str, modified=None) -> None:\n ET.SubElement(node, ET.QName(SITEMAP_NS, "lastmod")).text = (\n iso_timestamp(modified))\n \n- public_sections = ("", "news/", "briefings/", "radar/", "incidents/", "sources/",\n- "about/", "editorial-policy/", "changelog/")\n+ briefing_modified = [\n+ _tpe_dt(briefing.get("generated_at") or briefing.get("cutoff_time"))\n+ for briefing in briefings\n+ ]\n+ latest_briefing_modified = (\n+ max(briefing_modified) if briefing_modified else None)\n+ static_sections = ("radar/", "incidents/", "sources/", "about/",\n+ "editorial-policy/", "changelog/")\n for lang in ("zh", "en"):\n- for sub in public_sections:\n+ localized = [\n+ article for article in articles\n+ if lang in article["available_languages"]\n+ ]\n+ latest_modified = (\n+ max(article["modified_dt"] for article in localized)\n+ if localized else None\n+ )\n+ add_url(lang, "", latest_modified)\n+ add_url(lang, "news/", latest_modified)\n+ add_url(lang, "briefings/", latest_briefing_modified)\n+ for sub in static_sections:\n add_url(lang, sub)\n for slug in TOPICS:\n- add_url(lang, f"topics/{slug}/")\n- localized_count = sum(\n- lang in article["available_languages"] for article in articles\n- )\n- page_count = max(1, math.ceil(localized_count / NEWS_PAGE_SIZE))\n+ topic_rows = topic_articles(localized, slug)\n+ topic_modified = (\n+ max(article["modified_dt"] for article in topic_rows)\n+ if topic_rows else None\n+ )\n+ add_url(lang, f"topics/{slug}/", topic_modified)\n+ page_count = max(1, math.ceil(len(localized) / NEWS_PAGE_SIZE))\n for page_number in range(2, page_count + 1):\n- add_url(lang, f"news/page/{page_number}/")\n+ start = (page_number - 1) * NEWS_PAGE_SIZE\n+ page_rows = localized[start:start + NEWS_PAGE_SIZE]\n+ page_modified = max(\n+ article["modified_dt"] for article in page_rows)\n+ add_url(lang, f"news/page/{page_number}/", page_modified)\n for briefing in briefings:\n modified = _tpe_dt(briefing.get("generated_at")\n or briefing.get("cutoff_time"))\n@@ -3307,6 +3366,18 @@ def main() -> int:\n next_abs_url=(absolute_page_url(lang, next_sub)\n if page_number < page_count else None),\n )\n+ news_crumbs = [\n+ (t["siteName"], ""),\n+ (t["newsTitle"], "news/"),\n+ ]\n+ if page_number > 1:\n+ news_crumbs.append((\n+ t["pageLabel"].format(\n+ page=page_number, pages=page_count),\n+ sub,\n+ ))\n+ news_ctx["structured_data"] = breadcrumb_structured_data(\n+ lang, news_crumbs)\n out_rel = ("news/index.html" if page_number == 1\n else f"news/page/{page_number}/index.html")\n render(env, "news_index.html", rel_path(lang, out_rel), news_ctx)\n@@ -3320,6 +3391,10 @@ def main() -> int:\n lang, "topics", f"topics/{slug}/",\n title=f"{copy['title']}|{t['siteName']}",\n description=copy["description"], ticker=ticker, build=build)\n+ ctx["structured_data"] = breadcrumb_structured_data(lang, [\n+ (t["siteName"], ""),\n+ (copy["title"], f"topics/{slug}/"),\n+ ])\n ctx.update(topic=copy, articles=topic_views, count=len(topic_views))\n render(env, "topic.html",\n rel_path(lang, f"topics/{slug}/index.html"), ctx)\n@@ -3339,9 +3414,14 @@ def main() -> int:\n for bv in bviews:\n try:\n ctx = base_ctx(lang, "briefings", f"briefings/{bv['id']}/",\n- title=f"{t['siteName']} — {bv['title']}",\n+ title=f"{bv['title']}|{t['siteName']}",\n description=f"{bv['title']} · {bv['date_label']}",\n ticker=ticker, build=build)\n+ ctx["structured_data"] = breadcrumb_structured_data(lang, [\n+ (t["siteName"], ""),\n+ (t["brIndexTitle"], "briefings/"),\n+ (bv["title"], f"briefings/{bv['id']}/"),\n+ ])\n ctx["b"] = bv\n render(env, "briefing.html",\n rel_path(lang, f"briefings/{bv['id']}/index.html"), ctx)\n@@ -3354,8 +3434,12 @@ def main() -> int:\n for bv in bviews: # bviews sorted newest cutoff first -> dates desc\n groups.setdefault(bv["date_label"], []).append(bv)\n ctx = base_ctx(lang, "briefings", "briefings/",\n- title=f"{t['siteName']} — {t['brIndexTitle']}",\n+ title=f"{t['brIndexTitle']}|{t['siteName']}",\n description=t["brIndexSub"], ticker=ticker, build=build)\n+ ctx["structured_data"] = breadcrumb_structured_data(lang, [\n+ (t["siteName"], ""),\n+ (t["brIndexTitle"], "briefings/"),\n+ ])\n ctx.update(groups=[\n {"date": d, "rows": sorted(rows, key=lambda r: r["cutoff_iso"])}\n for d, rows in groups.items()])\n@@ -3410,8 +3494,12 @@ def main() -> int:\n ]\n ctx = base_ctx(\n lang, "radar", "radar/",\n- title=f"{t['siteName']} — {t['radarTitle']}",\n+ title=f"{t['radarTitle']}|{t['siteName']}",\n description=t["radarSub"], ticker=ticker, build=build)\n+ ctx["structured_data"] = breadcrumb_structured_data(lang, [\n+ (t["siteName"], ""),\n+ (t["radarTitle"], "radar/"),\n+ ])\n ctx.update(\n airline_names=airline_names,\n airline_iata_codes=airline_iata_codes,\n@@ -3434,6 +3522,9 @@ def main() -> int:\n ctx["social_image_abs_url"] = v["image"]["url"]\n ctx["social_image_alt"] = (\n v["image"].get("subject") or v["title"])\n+ ctx["social_image_width"] = None\n+ ctx["social_image_height"] = None\n+ ctx["social_image_type"] = None\n ctx["structured_data"] = article_structured_data(lang, v)\n ctx["a"] = v\n render(env, "article.html",\n@@ -3452,23 +3543,35 @@ def main() -> int:\n weekly_cutoff = now - timedelta(days=7)\n rows = inc_view(incidents, lang, weekly_cutoff, published_ids)\n ctx = base_ctx(lang, "incidents", "incidents/",\n- title=f"{t['siteName']} — {t['incTitle']}",\n+ title=f"{t['incTitle']}|{t['siteName']}",\n description=t["incSub"], ticker=ticker, build=build)\n+ ctx["structured_data"] = breadcrumb_structured_data(lang, [\n+ (t["siteName"], ""),\n+ (t["incTitle"], "incidents/"),\n+ ])\n ctx.update(rows=rows, count=len(rows),\n sev_seg=list(zip(sev_keys, t["sevs"])))\n render(env, "incidents.html", rel_path(lang, "incidents/index.html"), ctx)\n pages += 1\n \n ctx = base_ctx(lang, "sources", "sources/",\n- title=f"{t['siteName']} — {t['srcTitle']}",\n+ title=f"{t['srcTitle']}|{t['siteName']}",\n description=t["srcSub"], ticker=ticker, build=build)\n+ ctx["structured_data"] = breadcrumb_structured_data(lang, [\n+ (t["siteName"], ""),\n+ (t["srcTitle"], "sources/"),\n+ ])\n ctx.update(rows=sources_rows_view(sources, lang, now))\n render(env, "sources.html", rel_path(lang, "sources/index.html"), ctx)\n pages += 1\n \n ctx = base_ctx(lang, "about", "about/",\n- title=f"{t['siteName']} — {t['aboutTitle']}",\n+ title=f"{t['aboutTitle']}|{t['siteName']}",\n description=ABOUT[lang]["intro"][0], ticker=ticker, build=build)\n+ ctx["structured_data"] = breadcrumb_structured_data(lang, [\n+ (t["siteName"], ""),\n+ (t["aboutTitle"], "about/"),\n+ ])\n ctx.update(about=ABOUT[lang])\n render(env, "about.html", rel_path(lang, "about/index.html"), ctx)\n pages += 1\n@@ -3478,6 +3581,10 @@ def main() -> int:\n lang, "policy", "editorial-policy/",\n title=f"{policy['title']}|{t['siteName']}",\n description=policy["description"], ticker=ticker, build=build)\n+ ctx["structured_data"] = breadcrumb_structured_data(lang, [\n+ (t["siteName"], ""),\n+ (policy["title"], "editorial-policy/"),\n+ ])\n ctx.update(policy=policy,\n contact_url=("https://github.com/nnneeeooo-nnneeeooo/"\n "avwire/issues/new"))\n@@ -3487,8 +3594,12 @@ def main() -> int:\n \n ctx = base_ctx(\n lang, "changelog", "changelog/",\n- title=f"{t['siteName']} — {t['changeTitle']}",\n+ title=f"{t['changeTitle']}|{t['siteName']}",\n description=t["changeSub"], ticker=ticker, build=build)\n+ ctx["structured_data"] = breadcrumb_structured_data(lang, [\n+ (t["siteName"], ""),\n+ (t["changeTitle"], "changelog/"),\n+ ])\n ctx.update(changelog=changelog_view(\n changelog_raw, lang, t["changeKinds"]))\n render(env, "changelog.html",\n@@ -2,6 +2,8 @@\n {% block head_extra %}\n <meta property="article:published_time" content="{{ a.published_iso }}">\n <meta property="article:modified_time" content="{{ a.modified_iso }}">\n+<meta property="article:section" content="{{ a.cat_label }}">\n+<meta property="article:author" content="SKYTICAL">\n {% endblock %}\n {% block content %}\n <div class="article-page">\n@@ -5,6 +5,7 @@\n <meta name="viewport" content="width=device-width, initial-scale=1">\n <title>{{ title }}</title>\n <meta name="description" content="{{ description }}">\n+<meta name="author" content="SKYTICAL">\n <meta name="robots" content="{{ robots_meta }}">\n <meta name="googlebot" content="{{ robots_meta }}">\n <meta property="og:type" content="{{ og_type }}">\n@@ -16,6 +17,9 @@\n <meta property="og:url" content="{{ self_abs_url }}">\n <meta property="og:image" content="{{ social_image_abs_url }}">\n <meta property="og:image:alt" content="{{ social_image_alt }}">\n+{% if social_image_width and social_image_height %}<meta property="og:image:width" content="{{ social_image_width }}">\n+<meta property="og:image:height" content="{{ social_image_height }}">{% endif %}\n+{% if social_image_type %}<meta property="og:image:type" content="{{ social_image_type }}">{% endif %}\n <meta name="twitter:card" content="summary_large_image">\n <meta name="twitter:title" content="{{ title }}">\n <meta name="twitter:description" content="{{ description }}">\n@@ -161,4 +165,4 @@ <h6 style="margin:20px 0 10px;">{{ t.topicNavLabel }}</h6>\n </div>\n {% block body_scripts %}{% endblock %}\n </body>\n-</html>\n\ No newline at end of file\n+</html>\n@@ -35,10 +35,10 @@ def main() -> None:\n "53ccee38ecb372a446ebd89737070852e0d8876b04af7efdb1709e78d8553621"\n )\n assert hashlib.sha256(logo_path.read_bytes()).hexdigest() == (\n- "35f5284ba070088d746d6ca2b1047cbe3463bd8941b3f4f5763bbbbfd6b0805a"\n+ "2990e0352b72c78764106940cc4e01eb66e7cce5ff67b587eafbde07cf28cccf"\n )\n assert hashlib.sha256(mark_path.read_bytes()).hexdigest() == (\n- "1d97c44f571f152880dbf9464faf63ec3a8e34a2b405d0b387aee58580adb6d9"\n+ "d076a98cf758ec7d36475e2df95664b6c6e52b7b758df5122b37cf64799f43cd"\n )\n assert hashlib.sha256(social_path.read_bytes()).hexdigest() == (\n "b5903b8496c802725fcdfe6072631a138c8c375a438ec1d92606c5e41f7076a5"\n@@ -1,6 +1,7 @@\n """Offline regression checks for SKYTICAL search and Google News metadata."""\n from __future__ import annotations\n \n+import html as html_lib\n import json\n import math\n import re\n@@ -21,6 +22,20 @@ def _json_ld(html: str) -> list[dict]:\n flags=re.DOTALL)]\n \n \n+def _schema_entities(html: str) -> list[dict]:\n+ entities = []\n+ for document in _json_ld(html):\n+ if not isinstance(document, dict):\n+ continue\n+ if document.get("@type"):\n+ entities.append(document)\n+ entities.extend(\n+ entity for entity in document.get("@graph", [])\n+ if isinstance(entity, dict)\n+ )\n+ return entities\n+\n+\n def main() -> None:\n assert build.SITE_ORIGIN == "https://skytical.tech"\n assert build.BASE_PATH == ""\n@@ -33,6 +48,10 @@ def main() -> None:\n sitemap = ET.parse(ROOT / "site" / "sitemap.xml").getroot()\n ns = {"sm": build.SITEMAP_NS, "news": build.NEWS_NS}\n locations = [node.text for node in sitemap.findall("sm:url/sm:loc", ns)]\n+ sitemap_rows = {\n+ row.findtext("sm:loc", namespaces=ns): row\n+ for row in sitemap.findall("sm:url", ns)\n+ }\n assert len(locations) == len(set(locations))\n assert f"{build.SITE_ORIGIN}{build.BASE_PATH}/" in locations\n assert f"{build.SITE_ORIGIN}{build.BASE_PATH}/en/" in locations\n@@ -62,6 +81,11 @@ def main() -> None:\n for slug in build.TOPICS:\n assert (f"{build.SITE_ORIGIN}{build.BASE_PATH}/en/topics/{slug}/"\n in locations)\n+ for path in ("/", "/news/", "/topics/taiwan-aviation/",\n+ "/en/", "/en/news/", "/en/topics/taiwan-aviation/"):\n+ lastmod = sitemap_rows[f"{build.SITE_ORIGIN}{path}"].findtext(\n+ "sm:lastmod", namespaces=ns)\n+ assert lastmod and lastmod.endswith("Z")\n \n news_index = ROOT / "site" / "news" / "index.html"\n en_news_index = ROOT / "site" / "en" / "news" / "index.html"\n@@ -110,6 +134,10 @@ def main() -> None:\n "Organization", "WebSite"}\n assert '<html lang="zh-Hant-TW">' in home\n assert '<meta name="robots" content="index,follow,max-image-preview:large">' in home\n+ assert '<meta name="author" content="SKYTICAL">' in home\n+ assert '<meta property="og:image:width" content="1200">' in home\n+ assert '<meta property="og:image:height" content="630">' in home\n+ assert '<meta property="og:image:type" content="image/png">' in home\n assert '<link rel="alternate" hreflang="zh-Hant-TW"' in home\n assert "SKYTICAL 航空新聞|臺灣與全球即時航空快訊" in home\n assert "可追溯來源" in home\n@@ -137,29 +165,46 @@ def main() -> None:\n \n article_paths = list((ROOT / "site" / "news").glob("*/index.html"))\n article = article_paths[0].read_text(encoding="utf-8")\n- article_schema = _json_ld(article)[0]\n+ article_entities = _schema_entities(article)\n+ article_schema = next(\n+ entity for entity in article_entities\n+ if entity.get("@type") == "NewsArticle")\n+ article_breadcrumb = next(\n+ entity for entity in article_entities\n+ if entity.get("@type") == "BreadcrumbList")\n assert article_schema["@type"] == "NewsArticle"\n assert article_schema["headline"]\n assert article_schema["datePublished"].endswith("Z")\n assert article_schema["dateModified"].endswith("Z")\n assert article_schema["inLanguage"] == "zh-Hant-TW"\n assert article_schema["author"]["name"] == "SKYTICAL"\n assert article_schema["publisher"]["name"] == "SKYTICAL"\n+ assert [item["position"] for item in\n+ article_breadcrumb["itemListElement"]] == [1, 2, 3]\n+ assert article_breadcrumb["itemListElement"][1]["item"].endswith(\n+ "/news/")\n assert '<meta property="og:type" content="article">' in article\n assert '<meta property="article:published_time"' in article\n+ assert '<meta property="article:section"' in article\n+ assert '<meta property="article:author" content="SKYTICAL">' in article\n assert f'<time datetime="{article_schema["datePublished"]}"' in article\n assert "發布:" in article and "來源時間:" in article\n assert "SKYTICAL 編輯系統" in article\n pictured_article = next(\n path.read_text(encoding="utf-8") for path in article_paths\n if "<figure" in path.read_text(encoding="utf-8")\n and "data-image-fallback" not in path.read_text(encoding="utf-8"))\n- pictured_schema = _json_ld(pictured_article)[0]\n+ pictured_schema = next(\n+ entity for entity in _schema_entities(pictured_article)\n+ if entity.get("@type") == "NewsArticle")\n assert 'fetchpriority="high" decoding="async"' in pictured_article\n assert 'loading="lazy"' not in re.search(\n r'<figure.*?</figure>', pictured_article, flags=re.DOTALL).group(0)\n- assert (f'<meta property="og:image" content="{pictured_schema["image"][0]}">'\n- in pictured_article)\n+ assert (\n+ f'<meta property="og:image" content="'\n+ f'{html_lib.escape(pictured_schema["image"][0], quote=True)}">'\n+ in pictured_article\n+ )\n \n search = (ROOT / "site" / "search" / "index.html").read_text(\n encoding="utf-8")\n@@ -171,6 +216,8 @@ def main() -> None:\n "index.html").read_text(encoding="utf-8")\n assert "<h1>臺灣航空新聞</h1>" in taiwan_hub\n assert 'href="/news/' in taiwan_hub\n+ assert any(entity.get("@type") == "BreadcrumbList"\n+ for entity in _schema_entities(taiwan_hub))\n for slug in build.TOPICS:\n assert f'href="/topics/{slug}/"' in article\n \n@@ -14,7 +14,7 @@\n import sys\n import tempfile\n import types\n-from datetime import datetime, timedelta, timezone\n+from datetime import timedelta, timezone\n from pathlib import Path\n \n REPO = Path(__file__).resolve().parent.parent\n@@ -119,7 +119,7 @@ def json(self):\n check("old ledger without recentRuns loads compatibly",\n led["recentRuns"] == [])\n \n-fixed_now = datetime(2026, 7, 30, 10, 20, tzinfo=timezone.utc)\n+fixed_now = now_utc().replace(second=0, microsecond=0)\n sample_run = {\n "startedUtc": usage.iso_minute(fixed_now),\n "finishedUtc": usage.iso_minute(fixed_now),\n@@ -424,9 +424,14 @@ def json(self):\n snapshot_entry = codex_snapshot["entries"][0]\n snapshot_total = (snapshot_entry["inputTokens"]\n + snapshot_entry["outputTokens"])\n+fixed_tpe = fixed_now.astimezone(timezone(timedelta(hours=8)))\n+fixed_tpe_label = (\n+ f"{fixed_tpe:%Y-%m-%d} "\n+ f"{fixed_tpe.strftime('%I:%M %p').lstrip('0')} TPE"\n+)\n check("page shows thirty-day history, fallback chain and final model",\n "最近 30 天模型執行與失敗紀錄" in page\n- and "2026-07-30 6:20 PM TPE" in page\n+ and fixed_tpe_label in page\n and "Nemotron 3 Ultra 550B A55B" in page\n and "JSON 格式錯誤" in page\n and "Gemini 3.6 Flash" in page