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.
Full 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