fix: clarify flash dates, hide duplicate ticker links and correct civil news categories (#382)
完整差異
diff --git a/data/articles/2026-10-21T16.json b/data/articles/2026-10-21T16.json\nindex 149d5728e..b2ec7eceb 100644\n--- a/data/articles/2026-10-21T16.json\n+++ b/data/articles/2026-10-21T16.json\n@@ -730,7 +730,7 @@\n {\n "id": "a-20261021-1637-japan-airlines-cancels-flights-at-nine-a",\n "publishedUtc": "2026-10-21T16:37Z",\n- "cat": "mil",\n+ "cat": "ops",\n "primarySource": "Simple Flying",\n "image": null,\n "sourceImageCandidates": [\ndiff --git a/data/articles/2026-10-22T10.json b/data/articles/2026-10-22T10.json\nindex 5574c9844..84f4c48d5 100644\n--- a/data/articles/2026-10-22T10.json\n+++ b/data/articles/2026-10-22T10.json\n@@ -3,7 +3,7 @@\n {\n "id": "a-20261022-1054-taiwan-caa-says-all-domestic-flights-wil",\n "publishedUtc": "2026-10-22T10:54Z",\n- "cat": "mil",\n+ "cat": "ops",\n "primarySource": "CAA Taiwan",\n "image": null,\n "sourceImageCandidates": [],\ndiff --git a/pipeline/CONTRACTS.md b/pipeline/CONTRACTS.md\nindex bd740cdc6..bf4b08de2 100644\n--- a/pipeline/CONTRACTS.md\n+++ b/pipeline/CONTRACTS.md\n@@ -241,6 +241,11 @@ Rare-aircraft monitor state, committed only when changed:\n \n Most recent first, max 10 kept:\n \n+Public flash and ticker labels use Taipei time for today's entries and\n+`MM/DD` for older entries, matching the news feed's date policy. The second\n+marquee pass is a visual duplicate only: it is hidden from assistive\n+technology and its links are excluded from keyboard navigation.\n+\n ```jsonc\n [ {"timeUtc": "2026-07-26T05:47Z", "hot": true, "pinned": true,\n "zh": "...", "en": "...",\ndiff --git a/pipeline/build.py b/pipeline/build.py\nindex d51243670..c057bffa3 100644\n--- a/pipeline/build.py\n+++ b/pipeline/build.py\n@@ -2256,7 +2256,7 @@ def prep_flashes(raw, known_ids):\n # A hidden/archived article must not leave an orphan ticker item.\n continue\n out.append({\n- "time": clock_12(dt.astimezone(TPE)),\n+ "time": _honest_time_label(dt, False),\n "hot": bool(f.get("hot")),\n "pinned": bool(f.get("pinned")),\n "zh": zh, "en": en,\ndiff --git a/templates/base.html b/templates/base.html\nindex f5021babc..b0a562bd4 100644\n--- a/templates/base.html\n+++ b/templates/base.html\n@@ -158,10 +158,10 @@ gtag('config', '{{ ga4_measurement_id }}', {\n <div class="ticker-window">\n <div class="marquee-track">\n {% for pass in range(2) %}\n- <span class="ticker-pass">\n+ <span class="ticker-pass"{% if pass == 1 %} aria-hidden="true"{% endif %}>\n {%- for it in ticker_items -%}\n {%- if it.url -%}\n- <a class="ticker-item" href="{{ it.url }}"{% if it.external %} target="_blank" rel="noopener"{% endif %}><b class="ticker-time{% if it.hot %} hot{% endif %}">{{ it.time }}</b>{% if it.priority|default(false) %}<span class="ticker-pin">{{ t.priorityLabel }}</span>{% endif %} {{ it.text }}<span class="ticker-go">↗</span></a>\n+ <a class="ticker-item" href="{{ it.url }}"{% if pass == 1 %} tabindex="-1"{% endif %}{% if it.external %} target="_blank" rel="noopener"{% endif %}><b class="ticker-time{% if it.hot %} hot{% endif %}">{{ it.time }}</b>{% if it.priority|default(false) %}<span class="ticker-pin">{{ t.priorityLabel }}</span>{% endif %} {{ it.text }}<span class="ticker-go">↗</span></a>\n {%- else -%}\n <span class="ticker-item"><b class="ticker-time{% if it.hot %} hot{% endif %}">{{ it.time }}</b>{% if it.priority|default(false) %}<span class="ticker-pin">{{ t.priorityLabel }}</span>{% endif %} {{ it.text }}</span>\n {%- endif -%}\ndiff --git a/tests/test_news_chronology.py b/tests/test_news_chronology.py\nindex 7159cf662..9d35308da 100644\n--- a/tests/test_news_chronology.py\n+++ b/tests/test_news_chronology.py\n@@ -2,9 +2,13 @@\n import json\n import sys\n import tempfile\n+from datetime import datetime, timezone\n from pathlib import Path\n from unittest.mock import patch\n \n+from bs4 import BeautifulSoup\n+from jinja2 import Environment, FileSystemLoader, StrictUndefined\n+\n sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "pipeline"))\n import build\n \n@@ -52,6 +56,45 @@ def test_news_chronology():\n assert build.prep_article(revised)["news_dt"] == collected[1]["news_dt"]\n \n \n+def test_flash_dates_and_marquee_accessibility():\n+ # Taipei has crossed midnight while UTC is still on the previous day.\n+ now = datetime(2026, 10, 24, 16, 30, tzinfo=timezone.utc)\n+ rows = [\n+ {"timeUtc": "2026-10-24T16:15Z", "articleId": "today",\n+ "zh": "今日快訊", "en": "Today's flash", "hot": True},\n+ {"timeUtc": "2026-10-24T15:59Z", "articleId": "yesterday",\n+ "zh": "昨日快訊", "en": "Yesterday's flash"},\n+ {"timeUtc": "2026-10-22T10:54Z", "articleId": "older",\n+ "zh": "舊快訊", "en": "Older flash"},\n+ ]\n+ with patch.object(build, "now_utc", return_value=now):\n+ flashes = build.prep_flashes(rows, {"today", "yesterday", "older"})\n+ assert [f["time"] for f in flashes] == ["12:15 AM", "10/24", "10/22"]\n+ env = Environment(loader=FileSystemLoader(build.TEMPLATES_DIR),\n+ undefined=StrictUndefined, autoescape=True)\n+ for lang in ("zh", "en"):\n+ ticker = build.flash_view(flashes, lang, {"today"})\n+ assert ticker[0]["hot"] and ticker[0]["priority"]\n+ assert ticker[1]["url"] == build.page_url(lang, "news/yesterday/")\n+ context = build.base_ctx(\n+ lang, "home", "", title="Test", description="Test", ticker=ticker,\n+ build={"date": "2026-10-25", "utc_hm": "4:30 PM",\n+ "tpe_hm": "12:30 AM", "stamp": "2026-10-24 4:30 PM"})\n+ soup = BeautifulSoup(env.get_template("base.html").render(**context),\n+ "html.parser")\n+ original, duplicate = soup.select(".ticker-pass")\n+ assert not original.has_attr("aria-hidden")\n+ assert duplicate["aria-hidden"] == "true"\n+ assert len(original.select("a")) == len(duplicate.select("a")) == 3\n+ assert all(a.get("tabindex") is None for a in original.select("a"))\n+ assert all(a.get("tabindex") == "-1" for a in duplicate.select("a"))\n+ assert [a["href"] for a in original.select("a")] == [\n+ a["href"] for a in duplicate.select("a")]\n+ assert [t.text for t in original.select(".ticker-time")] == [\n+ "12:15 AM", "10/24", "10/22"]\n+\n+\n if __name__ == "__main__":\n test_news_chronology()\n- print("PASS news chronology, source-time fallback and search timestamps")\n+ test_flash_dates_and_marquee_accessibility()\n+ print("PASS news chronology, flash dates and marquee accessibility")