{
  "sha": "1bea1c0b87d906ee9c3a19c8111de78adb774018",
  "message": "feat: rotate weather-related airline changes in the hero\n\nPrioritize fresh weather-driven national-airline flight changes in the homepage hero and rotate matching stories every five minutes.",
  "author": {
    "login": "nnneeeooo-nnneeeooo",
    "name": "",
    "email": ""
  },
  "committer": {
    "login": "web-flow",
    "name": "",
    "email": ""
  },
  "created_at": "2026-08-24T11:15:07Z",
  "files": [
    {
      "filename": "pipeline/build.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 128,
      "deletions": 3,
      "changes": 131
    },
    {
      "filename": "static/app.js",
      "previous_filename": "",
      "status": "modified",
      "additions": 63,
      "deletions": 0,
      "changes": 63
    },
    {
      "filename": "templates/home.html",
      "previous_filename": "",
      "status": "modified",
      "additions": 16,
      "deletions": 9,
      "changes": 25
    },
    {
      "filename": "tests/test_headline_rotation.py",
      "previous_filename": "",
      "status": "added",
      "additions": 85,
      "deletions": 0,
      "changes": 85
    }
  ],
  "diff": "@@ -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"
}
