2026-10-20 TUE UTC 12:42 PM TPE 8:42 PM 每小時自動更新 中文 EN
LIVE 快訊
← 返回網站更新紀錄 Site Changelog

提交原始紀錄

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.

SHA
0959f7cc0703327a28c62ccaff484afa9e3fe0a5
提交時間
作者
nnneeeooo-nnneeeooo
提交者
web-flow

變更檔案 (6)

  • pipeline/build.py modified · +131 / −20
  • templates/article.html modified · +2 / −0
  • templates/base.html modified · +5 / −1
  • tests/test_logo_assets.py modified · +2 / −2
  • tests/test_seo.py modified · +51 / −4
  • tests/test_usage.py modified · +8 / −3

完整差異

@@ -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

你發現了隱藏主題!

8-bit 主題已解鎖。重新整理後,就能在主題選單選擇 8-bit。