完整差異
@@ -67,6 +67,7 @@ jobs:\n python tests/test_write_offline.py\n python tests/test_provider_health.py\n python tests/test_build_resilience.py\n+ python tests/test_home_sidebar.py\n python tests/test_english_backfill.py\n python tests/test_news_freshness.py\n python tests/test_news_retry.py\n@@ -250,6 +250,10 @@ def static_asset_version() -> str:\n "topicKicker": "Aviation Topics",\n "topicCount": "則相關新聞",\n "topicNavLabel": "航空新聞主題",\n+ "taiwanFocusTitle": "臺灣航空焦點",\n+ "taiwanFocusMore": "更多臺灣新聞 →",\n+ "taiwanFocusEmpty": "近 7 天暫無臺灣航空新聞,查看歷來報導。",\n+ "exploreTopics": "探索主題",\n "policyLink": "編輯、更正與聯絡",\n "awaiting": "等待首次管線執行",\n "lastBuild": "最後建置",\n@@ -390,6 +394,10 @@ def static_asset_version() -> str:\n "topicKicker": "Aviation Topics",\n "topicCount": "related stories",\n "topicNavLabel": "Aviation news topics",\n+ "taiwanFocusTitle": "Taiwan aviation focus",\n+ "taiwanFocusMore": "More Taiwan news →",\n+ "taiwanFocusEmpty": "No Taiwan aviation stories in the past 7 days. Browse the archive.",\n+ "exploreTopics": "Explore topics",\n "policyLink": "Editorial, corrections and contact",\n "awaiting": "Awaiting first pipeline run",\n "lastBuild": "Last build",\n@@ -1656,6 +1664,27 @@ def taiwan_focus_score(article, now=None) -> int:\n return score if score >= 60 else 0\n \n \n+def taiwan_sidebar_articles(articles, now=None):\n+ """Latest five direct Taiwan stories from the past week, without fallbacks.\n+\n+ Reuse headline-level Taiwan signals; avoid body-only mentions and the\n+ carousel's broader Cathay fallback in this explicitly local news rail.\n+ """\n+ now = now or now_utc()\n+ recent = []\n+ for article in articles:\n+ published = article.get("published_dt")\n+ if (published is None\n+ or not timedelta(0) <= now - published <= timedelta(days=7)):\n+ continue\n+ text = _hero_focus_text(article)\n+ if (_HERO_TAIWAN_CARRIER_RE.search(text)\n+ or _HERO_TAIWAN_PLACE_RE.search(text)):\n+ recent.append(article)\n+ recent.sort(key=lambda article: article["published_dt"], reverse=True)\n+ return recent[:5]\n+\n+\n def taiwan_focus_articles(articles, now=None, limit=_HERO_FOCUS_LIMIT):\n """Return fresh Taiwan-focus stories, or one safe recent fallback."""\n now = now or now_utc()\n@@ -3922,6 +3951,9 @@ def main() -> int:\n ctx.update(hero=hero, hero_candidates=hero_candidates,\n hero_rotation_seconds=HERO_ROTATION_SECONDS,\n feed=feed, flashes=fl, agg=agg,\n+ taiwan_focus=[art_view(article, lang) for article in\n+ taiwan_sidebar_articles(lang_articles, now)],\n+ taiwan_topic_url=page_url(lang, "topics/taiwan-aviation/"),\n source_status=source_status_view(sources, now, lang),\n stats=sv["tiles"], otp=sv["otp"], fleet=sv["fleet"],\n delays=sv["delays"], stats_note=sv["note"],\n@@ -215,6 +215,22 @@ a.seg-btn.active:hover { color: var(--color-bg); }\n .center-col { padding: 20px 24px; min-width: 0; }\n .rail-right { border-left: 2px solid var(--color-divider); padding: 20px; }\n \n+.home-rail-section { margin-top: 28px; min-width: 0; }\n+.home-rail-section h6 { margin: 0 0 12px; }\n+.rail-stories { list-style: none; margin: 0; padding: 0; }\n+.rail-stories li { border-top: 1px solid var(--color-divider); }\n+.rail-story { display: block; padding: 12px 0; color: inherit; text-decoration: none; }\n+.rail-story-meta { display: block; font-size: 11px; color: var(--color-accent); }\n+.rail-story-title { display: block; margin: 5px 0; font-size: 14px; font-weight: 700; line-height: 1.55; overflow-wrap: anywhere; }\n+.rail-story-source { display: block; font-size: 11px; overflow-wrap: anywhere; }\n+.rail-story:hover .rail-story-title { color: var(--color-accent); }\n+.rail-more { display: inline-block; padding: 10px 0; font-size: 12px; font-weight: 700; }\n+.rail-empty { font-size: 12px; line-height: 1.6; }\n+.rail-topics { display: flex; flex-wrap: wrap; gap: 8px; }\n+.rail-topics a { display: inline-flex; align-items: center; min-height: 36px; max-width: 100%; padding: 6px 10px; border: 1px solid var(--color-divider); color: inherit; text-decoration: none; font-size: 12px; overflow-wrap: anywhere; }\n+.rail-topics a:hover { border-color: var(--color-accent); color: var(--color-accent); }\n+.rail-story:focus-visible, .rail-more:focus-visible, .rail-topics a:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 3px; }\n+\n .flash-row {\n padding: 10px 0; border-bottom: 1px solid var(--color-divider);\n display: grid; grid-template-columns: 56px minmax(0, 1fr);\n@@ -137,6 +137,33 @@ <h6 style="margin:0 0 12px;">{{ t.today }}</h6>\n <div class="text-muted" style="font-size:11px;margin-top:4px;">{% if html_lang.startswith('zh') %}前往 FlightAware 查看(另開分頁){% else %}View on FlightAware (opens in a new tab){% endif %}</div>\n </a>\n </div>\n+ <section class="home-rail-section" aria-labelledby="taiwan-focus-heading">\n+ <h6 id="taiwan-focus-heading">{{ t.taiwanFocusTitle }}</h6>\n+ {% if taiwan_focus %}\n+ <ul class="rail-stories">\n+ {% for item in taiwan_focus %}\n+ <li>\n+ <a class="rail-story" href="{{ item.url }}">\n+ <span class="rail-story-meta"><time datetime="{{ item.published_iso }}" title="{{ item.published_meta_ts }}">{{ item.time }}</time> · {{ item.cat_label }}</span>\n+ <span class="rail-story-title">{{ item.title }}</span>\n+ <span class="rail-story-source text-muted">{{ item.source }}</span>\n+ </a>\n+ </li>\n+ {% endfor %}\n+ </ul>\n+ {% else %}\n+ <p class="rail-empty text-muted">{{ t.taiwanFocusEmpty }}</p>\n+ {% endif %}\n+ <a class="rail-more" href="{{ taiwan_topic_url }}">{{ t.taiwanFocusMore }}</a>\n+ </section>\n+ <nav class="home-rail-section" aria-labelledby="explore-topics-heading">\n+ <h6 id="explore-topics-heading">{{ t.exploreTopics }}</h6>\n+ <div class="rail-topics">\n+ {% for topic in topic_links %}\n+ <a href="{{ topic.url }}">{{ topic.label }}</a>\n+ {% endfor %}\n+ </div>\n+ </nav>\n {% if otp %}\n <h6 style="margin:28px 0 12px;">{{ t.otp }}</h6>\n {% for r in otp %}\n@@ -0,0 +1,63 @@\n+"""Offline selection and rendered-link checks for the homepage right rail."""\n+from datetime import datetime, timedelta, timezone\n+from pathlib import Path\n+import sys\n+\n+from bs4 import BeautifulSoup\n+\n+ROOT = Path(__file__).resolve().parent.parent\n+sys.path.insert(0, str(ROOT / "pipeline"))\n+import build\n+\n+\n+def test_taiwan_sidebar_selection():\n+ now = datetime(2026, 10, 20, 12, tzinfo=timezone.utc)\n+\n+ def article(title, hours, body=""):\n+ return {\n+ "id": title, "published_dt": now - timedelta(hours=hours),\n+ "source": "Test source",\n+ "zh": {"title": title, "summary": "", "body": [body]},\n+ "en": {"title": title, "summary": "", "body": [body]},\n+ }\n+\n+ local = [article(f"星宇航空新增航線 {index}", index) for index in range(7)]\n+ excluded = [\n+ article("Taiwan airport old announcement", 169),\n+ article("華航未來消息", -1),\n+ article("Cathay Pacific announces new London flights", 1),\n+ article("US TSA announces security policy", 1),\n+ article("Boeing quarterly earnings", 1, body="臺灣、Taiwan"),\n+ ]\n+ selected = build.taiwan_sidebar_articles(list(reversed(local)) + excluded, now)\n+ assert selected == local[:5]\n+ assert build.taiwan_sidebar_articles(excluded, now) == []\n+ assert build.taiwan_sidebar_articles([], now) == []\n+ boundary = article("EVA Air fleet update", 168)\n+ assert build.taiwan_sidebar_articles([boundary], now) == [boundary]\n+\n+\n+def test_rendered_sidebar():\n+ assert build.main() == 0\n+ for lang, prefix in (("zh", ""), ("en", "en/")):\n+ path = build.SITE_DIR / prefix / "index.html"\n+ soup = BeautifulSoup(path.read_text(encoding="utf-8"), "html.parser")\n+ rail = soup.select_one(".rail-right")\n+ assert rail.select_one("#taiwan-focus-heading").text == build.L[lang]["taiwanFocusTitle"]\n+ assert rail.select_one("#explore-topics-heading").text == build.L[lang]["exploreTopics"]\n+ assert len(rail.select(".rail-story")) <= 5\n+ assert rail.select(".rail-story") or rail.select_one(".rail-empty")\n+ topics = rail.select(".rail-topics a")\n+ assert len(topics) == len(build.TOPICS)\n+ for link in rail.select(".rail-story, .rail-more, .rail-topics a"):\n+ url = link["href"]\n+ assert url.startswith(f"/{prefix}")\n+ assert (build.SITE_DIR / url.lstrip("/") / "index.html").is_file()\n+ for timestamp in rail.select(".rail-story time"):\n+ assert build.parse_iso(timestamp["datetime"])\n+\n+\n+if __name__ == "__main__":\n+ test_taiwan_sidebar_selection()\n+ test_rendered_sidebar()\n+ print("test_home_sidebar: 2 passed")