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

提交原始紀錄

feat: rotate weather-related airline changes in the hero Prioritize fresh weather-driven national-airline flight changes in the homepage hero and rotate matching stories every five minutes.

SHA
1bea1c0b87d906ee9c3a19c8111de78adb774018
提交時間
作者
nnneeeooo-nnneeeooo
提交者
web-flow

變更檔案 (4)

  • pipeline/build.py modified · +128 / −3
  • static/app.js modified · +63 / −0
  • templates/home.html modified · +16 / −9
  • tests/test_headline_rotation.py added · +85 / −0

完整差異

@@ -1292,6 +1292,117 @@ def art_view(a, lang: str):\n     return v\n \n \n+\n+HERO_ROTATION_SECONDS = 5 * 60\n+_HERO_PIN_MAX_AGE = timedelta(hours=48)\n+_HERO_WEATHER_RE = re.compile(\n+    r"(?:颱風|台風|熱帶(?:性)?(?:低氣壓|風暴)|豪大雨|豪雨|暴雨|大雨|"\n+    r"強降雨|雷雨|強風|暴風|寒流|寒害|大雪|降雪|冰雹|濃霧|大霧|"\n+    r"低能見度|沙塵暴|洪水|淹水|地震|海嘯|氣象|"\n+    r"typhoon|tropical storm|tropical depression|hurricane|heavy rain|"\n+    r"rainstorm|thunderstorm|strong winds?|gale|fog|low visibility|"\n+    r"snow|hail|flood|earthquake|tsunami|weather)",\n+    re.IGNORECASE,\n+)\n+_HERO_NATIONAL_AIRLINE_RE = re.compile(\n+    r"(?:國籍航空|臺灣航空|台灣航空|華航|中華航空|長榮航空|星宇航空|"\n+    r"台灣虎航|臺灣虎航|立榮航空|華信航空|Taiwan(?:ese)? carriers?|"\n+    r"China Airlines|EVA Air|STARLUX|Tigerair Taiwan|UNI Air|"\n+    r"Mandarin Airlines)",\n+    re.IGNORECASE,\n+)\n+_HERO_FLIGHT_CHANGE_RE = re.compile(\n+    r"(?:航班|班機|航線|飛航|取消|停飛|延後|延誤|調整|異動|加班機|"\n+    r"疏運|改期|轉降|停駛|flight|flights|air service|schedule|"\n+    r"cancel|suspend|delay|divert|rerout|extra flight|relief flight)",\n+    re.IGNORECASE,\n+)\n+\n+\n+def is_weather_airline_flight_story(article, now=None) -> bool:\n+    """Return True for fresh weather-driven national-airline changes.\n+\n+    Only headline-level fields participate, so a secondary weather mention in\n+    a long article body cannot unexpectedly take over the homepage hero.\n+    """\n+    if not isinstance(article, dict):\n+        return False\n+    published = article.get("published_dt")\n+    if published is None:\n+        return False\n+    now = now or now_utc()\n+    try:\n+        age = now - published\n+    except TypeError:\n+        return False\n+    if age < timedelta(0) or age > _HERO_PIN_MAX_AGE:\n+        return False\n+\n+    values = [article.get("source"), article.get("primarySource")]\n+    for lang in ("zh", "en"):\n+        side = article.get(lang)\n+        if isinstance(side, dict):\n+            values.extend(side.get(key) for key in ("title", "summary"))\n+    text = " ".join(str(value or "") for value in values)\n+    return bool(\n+        _HERO_WEATHER_RE.search(text)\n+        and _HERO_NATIONAL_AIRLINE_RE.search(text)\n+        and _HERO_FLIGHT_CHANGE_RE.search(text)\n+    )\n+\n+\n+def _hero_image_caption(image, lang: str) -> str:\n+    if not isinstance(image, dict):\n+        return (\n+            "暫無可驗證的事件或資料照片,先顯示 SKYTICAL 預設圖片。"\n+            if lang == "zh"\n+            else "No verified event or file photo is available yet; "\n+                 "showing the SKYTICAL default image."\n+        )\n+    parts = []\n+    if image.get("subject"):\n+        parts.append(str(image["subject"]))\n+    kind = str(image.get("kind") or "")\n+    kind_label = L[lang]["photoKind"].get(kind)\n+    if kind_label:\n+        parts.append(kind_label)\n+    if image.get("credit"):\n+        parts.append(f"© {image['credit']}")\n+    if image.get("license"):\n+        parts.append(str(image["license"]))\n+    if image.get("provider"):\n+        parts.append(str(image["provider"]))\n+    return " · ".join(parts)\n+\n+\n+def hero_rotation_view(view, lang: str) -> dict:\n+    """Return the small JSON-safe payload used by the five-minute hero swap."""\n+    image = view.get("image") if isinstance(view.get("image"), dict) else None\n+    labels = [str(view.get("cat_label") or "")]\n+    if view.get("article_format") == "brief":\n+        labels.append(L[lang]["briefLabel"])\n+    elif view.get("article_format") == "roundup":\n+        labels.append(L[lang]["roundupLabel"])\n+    return {\n+        "id": view.get("id"),\n+        "url": view.get("url"),\n+        "external": bool(view.get("external")),\n+        "title": view.get("title") or "",\n+        "summary": view.get("display_summary") or "",\n+        "kicker": f"{' · '.join(label for label in labels if label)}"\n+                  f" — {L[lang]['topStory']}",\n+        "time": view.get("time") or "",\n+        "source_meta": f"{L[lang]['mainSource']}: "\n+                       f"{view.get('source') or '—'}",\n+        "image": image,\n+        "image_alt": (\n+            str(image.get("subject") or "SKYTICAL")\n+            if image else "SKYTICAL"\n+        ),\n+        "image_caption": _hero_image_caption(image, lang),\n+    }\n+\n+\n def normalize_search_text(value) -> str:\n     """Normalize human-entered/search-index text without losing CJK words."""\n     text = unicodedata.normalize("NFKC", str(value or "")).casefold()\n@@ -2951,19 +3062,32 @@ def main() -> int:\n         lang_articles = [\n             a for a in articles if lang in a["available_languages"]]\n         views = [art_view(a, lang) for a in lang_articles]\n+        pinned_articles = [\n+            article for article in lang_articles\n+            if is_weather_airline_flight_story(article, now)\n+        ]\n+        pinned_views = [art_view(article, lang)\n+                        for article in pinned_articles]\n+        hero_candidates = [hero_rotation_view(view, lang)\n+                           for view in pinned_views]\n         if views:\n             fl = flash_view(flashes, lang)\n-            hero = views[0]\n-            feed = views[1:1 + HOME_FEED_LIMIT]\n+            hero = pinned_views[0] if pinned_views else views[0]\n+            pinned_ids = ({view["id"] for view in pinned_views}\n+                          if pinned_views else {hero["id"]})\n+            feed = [view for view in views if view["id"] not in pinned_ids]\n+            feed = feed[:HOME_FEED_LIMIT]\n         elif agg_items:\n             agg = True\n             av = agg_view(agg_items, lang)\n             fl = agg_flashes(av)\n             hero = av[0]\n+            hero_candidates = []\n             feed = av[1:1 + HOME_FEED_LIMIT]\n         else:\n             fl = flash_view(flashes, lang)\n             hero = None\n+            hero_candidates = []\n             feed = []\n         # ticker items stay structured so the marquee can link each flash\n         # to its story (owner request: clickable, no underline)\n@@ -2986,7 +3110,8 @@ def main() -> int:\n         ctx = base_ctx(lang, "home", "", title=home_title,\n                        description=t["siteDesc"], ticker=ticker, build=build)\n         ctx["structured_data"] = website_structured_data(lang)\n-        ctx.update(hero=hero, feed=feed, flashes=fl, agg=agg,\n+        ctx.update(hero=hero, hero_candidates=hero_candidates,\n+                   feed=feed, flashes=fl, agg=agg,\n                    source_status=source_status_view(sources, now),\n                    stats=sv["tiles"], otp=sv["otp"], fleet=sv["fleet"],\n                    delays=sv["delays"],\n@@ -170,4 +170,67 @@\n     }) || sevBtns.find(function (b) { return b.dataset.filterSev === "all"; });\n     applyIncidentFilter(initialButton.dataset.filterSev, initialButton, false);\n   }\n+  /* — Priority weather/airline hero: rotate every five minutes — */\n+  var heroCandidatesNode = document.getElementById("hero-candidates");\n+  var heroLink = document.getElementById("hero-story-link");\n+  if (heroCandidatesNode && heroLink) {\n+    var heroCandidates = [];\n+    try {\n+      heroCandidates = JSON.parse(heroCandidatesNode.textContent || "[]");\n+    } catch (e) {\n+      heroCandidates = [];\n+    }\n+    var heroImage = document.getElementById("hero-story-image");\n+    var heroImageCaption = document.getElementById("hero-image-caption");\n+    var heroKicker = document.getElementById("hero-story-kicker");\n+    var heroTitle = document.getElementById("hero-story-title");\n+    var heroSummary = document.querySelector(\n+      ".summary-preview--hero .summary-preview__text"\n+    );\n+    var heroTime = document.getElementById("hero-story-time");\n+    var heroSource = document.getElementById("hero-story-source");\n+\n+    function renderPriorityHero(story) {\n+      if (!story || !heroImage) return;\n+      heroLink.href = story.url || "#";\n+      heroLink.setAttribute("aria-label", story.title || "SKYTICAL");\n+      if (story.external) {\n+        heroLink.target = "_blank";\n+        heroLink.rel = "noopener";\n+      } else {\n+        heroLink.removeAttribute("target");\n+        heroLink.removeAttribute("rel");\n+      }\n+      var image = story.image && story.image.url ? story.image : null;\n+      if (image) {\n+        heroImage.src = image.url;\n+        heroImage.alt = story.image_alt || "";\n+        heroImage.removeAttribute("data-image-fallback");\n+      } else {\n+        heroImage.src = heroImage.dataset.fallbackSrc;\n+        heroImage.alt = story.image_alt || "SKYTICAL";\n+        heroImage.setAttribute("data-image-fallback", "true");\n+      }\n+      if (heroImageCaption) {\n+        heroImageCaption.textContent = story.image_caption || "";\n+      }\n+      if (heroKicker) heroKicker.textContent = story.kicker || "";\n+      if (heroTitle) heroTitle.textContent = story.title || "";\n+      if (heroSummary) heroSummary.textContent = story.summary || "";\n+      if (heroTime) heroTime.textContent = story.time || "";\n+      if (heroSource) heroSource.textContent = story.source_meta || "";\n+    }\n+\n+    if (heroCandidates.length) {\n+      var heroIndex = 0;\n+      renderPriorityHero(heroCandidates[heroIndex]);\n+      if (heroCandidates.length > 1) {\n+        window.setInterval(function () {\n+          heroIndex = (heroIndex + 1) % heroCandidates.length;\n+          renderPriorityHero(heroCandidates[heroIndex]);\n+        }, 5 * 60 * 1000);\n+      }\n+    }\n+  }\n+\n })();\n@@ -1,6 +1,9 @@\n {% extends "base.html" %}\n {% from "_summary.html" import notification_summary %}\n {% block content %}\n+  {% if hero_candidates %}\n+  <script type="application/json" id="hero-candidates">{{ hero_candidates | tojson }}</script>\n+  {% endif %}\n   <div class="home-grid">\n     <div class="rail-left">\n       {% if latest_brief %}\n@@ -39,21 +42,25 @@ <h6 style="margin:28px 0 12px;">{{ t.sourceStatus }}</h6>\n     </div>\n     <div class="center-col">\n       {% if hero %}\n-      <a href="{{ hero.url }}"{% if hero.external %} target="_blank" rel="noopener"{% endif %} style="display:block;color:inherit;text-decoration:none;cursor:pointer;">\n+      <a id="hero-story-link" href="{{ hero.url }}"{% if hero.external %} target="_blank" rel="noopener"{% endif %} style="display:block;color:inherit;text-decoration:none;cursor:pointer;">\n         {% if hero.image %}\n-        <img class="hero-img" src="{{ hero.image.url }}" alt="{{ hero.image.subject or '' }}">\n-        <div class="text-muted" style="font-size:10.5px;margin-top:4px;">{% if hero.image.subject %}<b style="color:var(--color-text);">{{ hero.image.subject }}</b> · {% endif %}{{ t.photoKind[hero.image.kind] }}{% if hero.image.credit %} · © {{ hero.image.credit }}{% endif %}{% if hero.image.license %} · {{ hero.image.license }}{% endif %}{% if hero.image.provider %} · {{ hero.image.provider }}{% endif %}</div>\n+        <img id="hero-story-image" class="hero-img"\n+             data-fallback-src="{{ base }}/assets/skytical-social.png?v={{ asset_version }}"\n+             src="{{ hero.image.url }}" alt="{{ hero.image.subject or '' }}">\n+        <div id="hero-image-caption" class="text-muted" style="font-size:10.5px;margin-top:4px;">{% if hero.image.subject %}<b style="color:var(--color-text);">{{ hero.image.subject }}</b> · {% endif %}{{ t.photoKind[hero.image.kind] }}{% if hero.image.credit %} · © {{ hero.image.credit }}{% endif %}{% if hero.image.license %} · {{ hero.image.license }}{% endif %}{% if hero.image.provider %} · {{ hero.image.provider }}{% endif %}</div>\n         {% else %}\n-        <img class="hero-img" data-image-fallback="true" src="{{ base }}/assets/skytical-social.png?v={{ asset_version }}" alt="SKYTICAL">\n-        <div class="text-muted" style="font-size:10.5px;margin-top:4px;">{% if html_lang.startswith('zh') %}暫無可驗證的事件或資料照片,先顯示 SKYTICAL 預設圖片。{% else %}No verified event or file photo is available yet; showing the SKYTICAL default image.{% endif %}</div>\n+        <img id="hero-story-image" class="hero-img" data-image-fallback="true"\n+             data-fallback-src="{{ base }}/assets/skytical-social.png?v={{ asset_version }}"\n+             src="{{ base }}/assets/skytical-social.png?v={{ asset_version }}" alt="SKYTICAL">\n+        <div id="hero-image-caption" class="text-muted" style="font-size:10.5px;margin-top:4px;">{% if html_lang.startswith('zh') %}暫無可驗證的事件或資料照片,先顯示 SKYTICAL 預設圖片。{% else %}No verified event or file photo is available yet; showing the SKYTICAL default image.{% endif %}</div>\n         {% endif %}\n         <div style="margin-top:16px;">\n-          <span class="card-kicker" style="font-size:11px;">{{ hero.cat_label }}{% if hero.article_format == "brief" %} · {{ t.briefLabel }}{% elif hero.article_format == "roundup" %} · {{ t.roundupLabel }}{% endif %} — {{ t.topStory }}</span>\n-          <h2 class="hero-title">{{ hero.title }}</h2>\n+          <span id="hero-story-kicker" class="card-kicker" style="font-size:11px;">{{ hero.cat_label }}{% if hero.article_format == "brief" %} · {{ t.briefLabel }}{% elif hero.article_format == "roundup" %} · {{ t.roundupLabel }}{% endif %} — {{ t.topStory }}</span>\n+          <h2 id="hero-story-title" class="hero-title" aria-live="polite">{{ hero.title }}</h2>\n           {{ notification_summary(hero.display_summary, t.summaryLabel, 'hero') }}\n           <div class="hero-meta">\n-            <span style="font-weight:800;color:var(--color-accent);">{{ hero.time }}</span>\n-            <span class="text-muted">{{ t.mainSource }}: {{ hero.source }}</span>\n+            <span id="hero-story-time" style="font-weight:800;color:var(--color-accent);">{{ hero.time }}</span>\n+            <span id="hero-story-source" class="text-muted">{{ t.mainSource }}: {{ hero.source }}</span>\n             <span style="margin-left:auto;font-weight:600;">{{ t.readMore }}</span>\n           </div>\n         </div>\n@@ -0,0 +1,85 @@\n+"""Regression tests for weather-driven national-airline hero pinning."""\n+from datetime import timedelta\n+\n+from pipeline import build\n+\n+\n+def _article(now, title, summary, *, source="中央社 CNA 國籍航空",\n+             age_hours=1):\n+    return {\n+        "published_dt": now - timedelta(hours=age_hours),\n+        "source": source,\n+        "zh": {"title": title, "summary": summary},\n+        "en": {"title": title, "summary": summary},\n+    }\n+\n+\n+def test_weather_national_airline_flight_change_is_priority_story():\n+    now = build.now_utc()\n+    article = _article(\n+        now,\n+        "颱風沙德爾逼近 國籍航空調整25與26日沖繩航班",\n+        "受到颱風影響,國籍航空調整航班並協助旅客疏運。",\n+    )\n+    assert build.is_weather_airline_flight_story(article, now)\n+\n+\n+def test_priority_story_requires_all_three_signals():\n+    now = build.now_utc()\n+    weather_only = _article(\n+        now,\n+        "颱風影響東部地區",\n+        "氣象單位提醒民眾注意強風豪雨。",\n+        source="中央氣象署",\n+    )\n+    airline_only = _article(\n+        now,\n+        "華航宣布新增台北巴黎航線",\n+        "中華航空公布新航線與航班時刻。",\n+    )\n+    no_weather = _article(\n+        now,\n+        "華航籌集180萬元公益款項捐贈團體",\n+        "中華航空捐贈公益團體,會員哩程突破百萬哩。",\n+    )\n+    assert not build.is_weather_airline_flight_story(weather_only, now)\n+    assert not build.is_weather_airline_flight_story(airline_only, now)\n+    assert not build.is_weather_airline_flight_story(no_weather, now)\n+\n+\n+def test_stale_priority_story_is_not_pinned():\n+    now = build.now_utc()\n+    article = _article(\n+        now,\n+        "豪雨造成國籍航空航班異動",\n+        "國籍航空因天候調整航班。",\n+        age_hours=49,\n+    )\n+    assert not build.is_weather_airline_flight_story(article, now)\n+\n+\n+def test_rotation_payload_contains_localized_hero_fields():\n+    view = {\n+        "id": "a-weather-flight",\n+        "url": "/news/a-weather-flight/",\n+        "external": False,\n+        "title": "颱風影響航班",\n+        "display_summary": "國籍航空調整航班。",\n+        "cat_label": "營運",\n+        "article_format": "brief",\n+        "time": "6:00 PM",\n+        "source": "中央社 CNA 國籍航空",\n+        "image": {\n+            "url": "https://example.com/aircraft.jpg",\n+            "subject": "China Airlines Boeing 777-300ER",\n+            "kind": "file_photo",\n+            "credit": "Photographer",\n+            "license": "CC BY 4.0",\n+            "provider": "Wikimedia Commons",\n+        },\n+    }\n+    payload = build.hero_rotation_view(view, "zh")\n+    assert payload["kicker"] == "營運 · 短訊 — 頭條"\n+    assert payload["summary"] == "國籍航空調整航班。"\n+    assert "China Airlines Boeing 777-300ER" in payload["image_caption"]\n+    assert build.HERO_ROTATION_SECONDS == 300

你發現了隱藏主題!

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