{
  "sha": "0959f7cc0703327a28c62ccaff484afa9e3fe0a5",
  "message": "Improve SEO discovery metadata and sitemap freshness (#154)\n\nAdd breadcrumb structured data, accurate sitemap freshness metadata, article social metadata, content-first page titles, and stable offline regression coverage.",
  "author": {
    "login": "nnneeeooo-nnneeeooo",
    "name": "",
    "email": ""
  },
  "committer": {
    "login": "web-flow",
    "name": "",
    "email": ""
  },
  "created_at": "2026-08-29T22:17:27Z",
  "files": [
    {
      "filename": "pipeline/build.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 131,
      "deletions": 20,
      "changes": 151
    },
    {
      "filename": "templates/article.html",
      "previous_filename": "",
      "status": "modified",
      "additions": 2,
      "deletions": 0,
      "changes": 2
    },
    {
      "filename": "templates/base.html",
      "previous_filename": "",
      "status": "modified",
      "additions": 5,
      "deletions": 1,
      "changes": 6
    },
    {
      "filename": "tests/test_logo_assets.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 2,
      "deletions": 2,
      "changes": 4
    },
    {
      "filename": "tests/test_seo.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 51,
      "deletions": 4,
      "changes": 55
    },
    {
      "filename": "tests/test_usage.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 8,
      "deletions": 3,
      "changes": 11
    }
  ],
  "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"
}
