Merge pull request #145 from nnneeeooo-nnneeeooo/fix/site-quality-improvements-20260826
Improve news scope, search discovery, and image QA
Full diff
@@ -97,6 +97,7 @@ jobs:\n # verified site articles are the reliable fallback; false opts out\n BRIEFING_INCLUDE_ARTICLES: ${{ vars.BRIEFING_INCLUDE_ARTICLES }}\n run: |\n+ python tests/test_headline_rotation.py\n if [ -n "${{ steps.ed.outputs.date }}" ]; then\n python pipeline/briefing.py --edition "${{ steps.ed.outputs.edition }}" --date "${{ steps.ed.outputs.date }}"\n elif [ "${{ github.event_name }}" = "schedule" ]; then\n@@ -40,10 +40,14 @@ jobs:\n - name: Test summary boundary guard\n run: python tests/test_summary_guard.py\n \n+ - name: Test headline priority and subject scope\n+ run: python tests/test_headline_rotation.py\n+\n - name: Test image relevance and fallback guards\n run: |\n python tests/test_images.py\n python tests/test_image_fallbacks.py\n+ python tests/test_image_qa.py\n python tests/test_recent_image_quality.py\n \n - name: Run pipeline\n@@ -82,6 +86,7 @@ jobs:\n python pipeline/airport_codes.py\n python pipeline/images.py\n python pipeline/image_fallbacks.py\n+ python pipeline/image_qa.py\n # The saved Taipei date makes this one LLM-assisted refresh per day.\n # Missing/invalid model output falls back to published article titles.\n python pipeline/search_prompts.py --daily-if-due\n@@ -39,10 +39,14 @@ jobs:\n - name: Test summary boundary guard\n run: python tests/test_summary_guard.py\n \n+ - name: Test headline priority and subject scope\n+ run: python tests/test_headline_rotation.py\n+\n - name: Test image relevance and fallback guards\n run: |\n python tests/test_images.py\n python tests/test_image_fallbacks.py\n+ python tests/test_image_qa.py\n python tests/test_recent_image_quality.py\n \n - name: Build site from committed data\n@@ -240,7 +240,8 @@ actual occurrence (ICAO Annex 13):\n \n ## data/stats.json (written by write.py — optional FlightAware AeroAPI part; read by build.py)\n \n-All fields optional; build.py renders "—" for missing numbers and hides\n+All fields optional; build.py renders an explicit unavailable state for a\n+missing dashboard metric and adds a note below the dashboard, while hiding\n sections whose arrays are missing/empty:\n \n ```jsonc\n@@ -259,8 +260,12 @@ sections whose arrays are missing/empty:\n site/\n ├─ index.html zh home /\n ├─ en/index.html en home /en/\n+├─ news/index.html zh news index /news/\n+├─ en/news/index.html en news index /en/news/\n ├─ news/<id>/index.html zh article /news/<id>/\n ├─ en/news/<id>/index.html en article\n+├─ feed.xml zh RSS feed /feed.xml\n+├─ en/feed.xml en RSS feed /en/feed.xml\n ├─ incidents/index.html + en/incidents/\n ├─ sources/index.html + en/sources/\n ├─ about/index.html + en/about/\n@@ -311,6 +316,24 @@ priority over generic aircraft matches, and rejects logos, diagrams, interiors,\n civic buildings, wreckage and other deterministic false matches. If no\n confident replacement exists, the article keeps the neutral site fallback.\n \n+`pipeline/image_qa.py` also maintains `data/image-qa.json`, a seven-day\n+maintainer queue of current image provenance for manual visual inspection.\n+Its `status` may be kept as `pending`, `approved` or `rejected` by the\n+maintainer; the queue is not copied to `site/`.\n+\n+## News scope and search index\n+\n+Broad feeds require both a keyword hit and a transport subject in the\n+headline. A carrier mentioned only in a summary cannot make an unrelated\n+headline enter the editorial queue. During a build, stored articles with a\n+headline outside the same subject gate are omitted from public pages and\n+orphaned ticker entries are omitted as well; source JSON remains preserved.\n+\n+`site/search-index.json` keeps titles, summaries and display metadata in their\n+structured fields and stores body/source text plus matched aliases in its\n+search field. The search page loads the index only when a query is made, so\n+visiting the page without searching does not download the full index.\n+\n ## data/search-prompts.json (written by search_prompts.py, read by build.py)\n \n One daily set of six bilingual search-box suggestions. `targetDateTpe` prevents\n@@ -21,6 +21,7 @@\n import unicodedata\n import xml.etree.ElementTree as ET\n from datetime import timedelta, timezone\n+from email.utils import format_datetime\n from functools import lru_cache\n from pathlib import Path\n from urllib.parse import urlsplit\n@@ -44,6 +45,7 @@\n norm_url,\n now_utc,\n parse_iso,\n+ is_transport_headline,\n story_category,\n )\n from model_config import (\n@@ -95,6 +97,8 @@ def static_asset_version() -> str:\n "readMore": "閱讀全文 →", "back": "← 返回首頁",\n "heroImg": "[ 頭條照片 — 由來源圖庫自動帶入,灰階顯示 ]",\n "today": "今日全球數據", "statFlights": "目前追蹤航班", "statDelay": "全球延誤率",\n+ "statUnavailable": "暫無資料",\n+ "statsNote": "部分即時數據目前尚未取得;已取得的項目照常顯示。",\n "statSerious": "本週嚴重事件", "statNotam": "生效中 NOTAM",\n "otp": "準點率排行 · 7月", "fleet": "機隊與訂單追蹤", "fleetDeliv": "2026 上半年交付",\n "fleetBacklog": "積壓訂單", "fleetOrders": "本月新訂單",\n@@ -136,6 +140,8 @@ def static_asset_version() -> str:\n "nav": ["最新", "快報", "航班雷達", "事故資料庫", "來源", "方法論"],\n "cats": ["全部", "事故", "法規", "商業", "營運", "軍事"],\n "searchKicker": "News Search", "searchTitle": "搜尋新聞",\n+ "newsKicker": "News Index", "newsTitle": "全部新聞",\n+ "newsSub": "依發布時間排列的本站航空與運輸新聞索引。",\n "headerSearchPlaceholder": "搜尋班號,例如 CI100",\n "headerSearchPlaceholders": [\n "搜尋班號,例如 CI100",\n@@ -212,6 +218,8 @@ def static_asset_version() -> str:\n "readMore": "Read more →", "back": "← Back to front page",\n "heroImg": "[ Lead photo — pulled from source library, grayscale ]",\n "today": "Global today", "statFlights": "Flights tracked now", "statDelay": "Global delay rate",\n+ "statUnavailable": "Data unavailable",\n+ "statsNote": "Some live metrics are currently unavailable; available metrics are shown as reported.",\n "statSerious": "Serious events this week", "statNotam": "Active NOTAMs",\n "otp": "On-time ranking · Jul", "fleet": "Fleet & orders", "fleetDeliv": "H1 2026 deliveries",\n "fleetBacklog": "Backlog", "fleetOrders": "New orders this month",\n@@ -256,6 +264,8 @@ def static_asset_version() -> str:\n "cats": ["All", "Safety", "Regulation", "Business", "Operations",\n "Military"],\n "searchKicker": "News Search", "searchTitle": "Search news",\n+ "newsKicker": "News Index", "newsTitle": "All news",\n+ "newsSub": "An archive of SKYTICAL aviation and transport news, ordered by publication time.",\n "headerSearchPlaceholder": "Search a flight number, e.g. CI100",\n "headerSearchPlaceholders": [\n "Search a flight number, e.g. CI100",\n@@ -1189,6 +1199,8 @@ def collect_articles():\n and raw.get("archived") is True\n else "bad article entry skipped")\n print(f"build: {path.name}: {reason}")\n+ elif not is_transport_headline(raw):\n+ print(f"build: {path.name}: non-transport headline skipped")\n else:\n arts.append(art)\n by_id = {}\n@@ -1482,21 +1494,30 @@ def expand_search_aliases(text: str, groups=None) -> list[str]:\n \n \n def search_index_item(article, alias_groups) -> dict:\n- """Build one bilingual, full-text record for the static client index."""\n- searchable = [\n- article["id"], article["cat"], article["source"],\n- article["meta_ts"],\n+ """Build one bilingual, full-text record for the static client index.\n+\n+ Titles, summaries and display metadata are already stored as structured\n+ fields below. Keep only body/source text plus matched aliases in the\n+ opaque search field; the browser adds the structured fields while scoring.\n+ This removes a large amount of duplicated text from the public index.\n+ """\n+ searchable = []\n+ raw_searchable = [\n+ article["id"], article["cat"], article["source"], article["meta_ts"],\n _bi(CATS.get(article["cat"]), "zh", article["cat"]),\n _bi(CATS.get(article["cat"]), "en", article["cat"]),\n ]\n for lang in ("zh", "en"):\n- searchable.extend([\n+ raw_searchable.extend([\n article[lang]["title"], article[lang]["summary"],\n *article[lang]["body"],\n ])\n for source in article["sources"]:\n+ raw_searchable.extend([source["name"], source["host"]])\n searchable.extend([source["name"], source["host"]])\n- raw_search = " ".join(str(value) for value in searchable if value)\n+ for lang in ("zh", "en"):\n+ searchable.extend(article[lang]["body"])\n+ raw_search = " ".join(str(value) for value in raw_searchable if value)\n searchable.extend(expand_search_aliases(raw_search, alias_groups))\n urls = {\n lang: page_url(lang, f"news/{article['id']}/")\n@@ -1641,6 +1662,9 @@ def prep_flashes(raw, known_ids):\n if not zh:\n continue\n aid = f.get("articleId")\n+ if isinstance(aid, str) and aid not in 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 "hot": bool(f.get("hot")),\n@@ -1944,12 +1968,23 @@ def brief_view(b, lang: str, t, published_ids):\n def stats_views(stats, lang: str):\n if not isinstance(stats, dict):\n stats = {}\n+ unavailable = L[lang]["statUnavailable"]\n+\n+ def metric(formatter, key):\n+ value = formatter(stats.get(key))\n+ return value if value != "—" else unavailable\n+\n tiles = {\n- "flights": fmt_int(stats.get("flightsTracked")),\n- "delay": fmt_pct(stats.get("delayRate")),\n- "serious": fmt_int(stats.get("seriousThisWeek")),\n- "notams": fmt_int(stats.get("notams")),\n+ "flights": metric(fmt_int, "flightsTracked"),\n+ "delay": metric(fmt_pct, "delayRate"),\n+ "serious": metric(fmt_int, "seriousThisWeek"),\n+ "notams": metric(fmt_int, "notams"),\n }\n+ optional_keys = ("flightsTracked", "delayRate", "notams")\n+ note = (L[lang]["statsNote"]\n+ if any(metric(fmt_int if key != "delayRate" else fmt_pct, key)\n+ == unavailable for key in optional_keys)\n+ else "")\n otp = []\n raw_otp = stats.get("otp")\n if isinstance(raw_otp, list):\n@@ -2001,7 +2036,8 @@ def stats_views(stats, lang: str):\n delays.append({"code": str(d["code"]),\n "name": str(d.get(lang) or d.get("name") or ""),\n "pct": f"{pct:g}"})\n- return {"tiles": tiles, "otp": otp, "fleet": fleet, "delays": delays}\n+ return {"tiles": tiles, "otp": otp, "fleet": fleet, "delays": delays,\n+ "note": note}\n \n \n # ── private usage dashboard (URL token from AVWIRE_USAGE_TOKEN secret) ──────\n@@ -2811,6 +2847,8 @@ def base_ctx(lang, page, sub, *, title, description, ticker, build,\n "robots_meta": "index,follow,max-image-preview:large",\n "hreflang": hreflang,\n "home_url": page_url(lang, ""),\n+ "news_url": page_url(lang, "news/"),\n+ "feed_url": page_url(lang, "feed.xml"),\n "search_url": page_url(lang, "search/"),\n "search_aliases_url": (\n f"{BASE_PATH}/search-aliases.json"\n@@ -2926,8 +2964,39 @@ def _write_xml(path: Path, root: ET.Element) -> None:\n short_empty_elements=True)\n \n \n+def _write_rss_feed(path: Path, lang: str, articles, now) -> None:\n+ """Write a small RSS 2.0 feed for the latest localized articles."""\n+ root = ET.Element("rss", {"version": "2.0"})\n+ channel = ET.SubElement(root, "channel")\n+ title = ("SKYTICAL 航空新聞" if lang == "zh"\n+ else "SKYTICAL Aviation News")\n+ description = L[lang]["newsSub"]\n+ ET.SubElement(channel, "title").text = title\n+ ET.SubElement(channel, "link").text = absolute_page_url(lang, "news/")\n+ ET.SubElement(channel, "description").text = description\n+ ET.SubElement(channel, "language").text = "zh-tw" if lang == "zh" else "en"\n+ ET.SubElement(channel, "lastBuildDate").text = format_datetime(\n+ now.astimezone(timezone.utc), usegmt=True)\n+ ET.SubElement(channel, "generator").text = "SKYTICAL static pipeline"\n+\n+ localized = [article for article in articles\n+ if lang in article["available_languages"]]\n+ for article in localized[:50]:\n+ item = ET.SubElement(channel, "item")\n+ url = absolute_page_url(lang, f"news/{article['id']}/")\n+ ET.SubElement(item, "title").text = article[lang]["title"]\n+ ET.SubElement(item, "link").text = url\n+ ET.SubElement(item, "guid", {"isPermaLink": "true"}).text = url\n+ ET.SubElement(item, "description").text = (\n+ article[lang]["summary"] or article[lang]["title"])\n+ ET.SubElement(item, "pubDate").text = format_datetime(\n+ article["published_dt"].astimezone(timezone.utc), usegmt=True)\n+ ET.SubElement(item, "source").text = article["source"]\n+ _write_xml(path, root)\n+\n+\n def write_search_discovery_files(articles, briefings, now) -> None:\n- """Write generic/news sitemaps plus a crawler policy for the public site."""\n+ """Write sitemaps, RSS feeds and a crawler policy for the public site."""\n urlset = ET.Element(ET.QName(SITEMAP_NS, "urlset"))\n seen = set()\n \n@@ -2942,7 +3011,7 @@ def add_url(lang: str, sub: str, modified=None) -> None:\n ET.SubElement(node, ET.QName(SITEMAP_NS, "lastmod")).text = (\n iso_timestamp(modified))\n \n- public_sections = ("", "briefings/", "radar/", "incidents/", "sources/",\n+ public_sections = ("", "news/", "briefings/", "radar/", "incidents/", "sources/",\n "about/", "editorial-policy/", "changelog/")\n for lang in ("zh", "en"):\n for sub in public_sections:\n@@ -2981,6 +3050,8 @@ def add_url(lang: str, sub: str, modified=None) -> None:\n ET.SubElement(news, ET.QName(\n NEWS_NS, "title")).text = article[lang]["title"]\n _write_xml(SITE_DIR / "news-sitemap.xml", news_urlset)\n+ _write_rss_feed(SITE_DIR / "feed.xml", "zh", articles, now)\n+ _write_rss_feed(SITE_DIR / "en" / "feed.xml", "en", articles, now)\n \n sitemap_url = f"{SITE_ORIGIN}{BASE_PATH}/sitemap.xml"\n news_sitemap_url = f"{SITE_ORIGIN}{BASE_PATH}/news-sitemap.xml"\n@@ -3114,12 +3185,21 @@ def main() -> int:\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+ delays=sv["delays"], stats_note=sv["note"],\n cat_seg=list(zip(cat_keys, t["cats"])),\n latest_brief=latest_brief)\n render(env, "home.html", rel_path(lang, "index.html"), ctx)\n pages += 1\n \n+ news_ctx = base_ctx(\n+ lang, "news", "news/",\n+ title=f"{t['newsTitle']}|{t['siteName']}",\n+ description=t["newsSub"], ticker=ticker, build=build)\n+ news_ctx.update(articles=views[:100], count=len(views))\n+ render(env, "news_index.html", rel_path(lang, "news/index.html"),\n+ news_ctx)\n+ pages += 1\n+\n for slug, topic in TOPICS.items():\n topic_rows = topic_articles(lang_articles, slug)\n topic_views = [art_view(article, lang) for article in topic_rows]\n@@ -517,6 +517,23 @@ def _load_manufacturer_source_catalog() -> dict:\n r")\b",\n re.IGNORECASE,\n )\n+_TRANSPORT_HEADLINE_ZH_TERMS = (\n+ "豪華經濟艙", "商務艙", "機組員", "紅箭", "火箭", "探空", "地滑翔機",\n+ "航艦", "無人載具", "教練機", "開航", "航展", "登機門", "候機廳",\n+ "指揮控制", "發動機", "原型機", "史基浦", "達美", "墨航",\n+)\n+_TRANSPORT_HEADLINE_EN_RE = re.compile(\n+ r"\b(?:air\s+force|airshow|air\s+show|aircrew|red\s+arrows?|"\n+ r"seaglider|schiphol|farnborough|gulfstream|cessna|lockheed|"\n+ r"recaro|collins|delta|rafale|fighter|prototype|rocket|missile|"\n+ r"air\s+base|airbase)\b",\n+ re.IGNORECASE,\n+)\n+_AIRCRAFT_MODEL_RE = re.compile(\n+ r"(?<![A-Za-z0-9])(?:[A-Z]{1,4}-?\d{2,4}[A-Z0-9]*(?:-\d+)?|"\n+ r"Il-\d{2,4}(?:-\d+)?|"\n+ r"R\d|JASSM|EPACS)\b"\n+)\n _MILITARY_REPLICA_ZH_RE = re.compile(\n r"(?:建造|打造|興建|搭建|建).{0,24}(?:模型|複製品)"\n r"|(?:模型|複製品).{0,32}(?:國防預算|敵情|威脅|情報)"\n@@ -560,6 +577,43 @@ def is_transport_story(*records) -> bool:\n or _TRANSPORT_EN_RE.search(text) is not None)\n \n \n+def _headline_values(value):\n+ """Yield only title fields from RSS items or stored bilingual articles."""\n+ if isinstance(value, str):\n+ yield value\n+ return\n+ if not isinstance(value, dict):\n+ return\n+ title = value.get("title")\n+ if isinstance(title, str):\n+ yield title\n+ for lang in ("zh", "en"):\n+ block = value.get(lang)\n+ if isinstance(block, dict) and isinstance(block.get("title"), str):\n+ yield block["title"]\n+\n+\n+def is_transport_headline(*records) -> bool:\n+ """True when a story's headline itself names a transport subject.\n+\n+ Summaries and body paragraphs often mention an airline only as a partner,\n+ advertiser or piece of background context. Broad feeds therefore need a\n+ title-level gate in addition to the wider source-material gate.\n+ """\n+ headline = " ".join(\n+ title for record in records for title in _headline_values(record)\n+ )\n+ if not headline.strip():\n+ return False\n+ if (_MILITARY_REPLICA_ZH_RE.search(headline)\n+ or _MILITARY_REPLICA_EN_RE.search(headline)):\n+ return False\n+ return (is_transport_story({"title": headline})\n+ or any(term in headline for term in _TRANSPORT_HEADLINE_ZH_TERMS)\n+ or _TRANSPORT_HEADLINE_EN_RE.search(headline) is not None\n+ or _AIRCRAFT_MODEL_RE.search(headline) is not None)\n+\n+\n _TAIWAN_AIRLINE_ZH_TERMS = TAIWAN_AIRLINE_KEYWORDS\n _TAIWAN_AIRLINE_EN_RE = re.compile(\n r"\b(?:china airlines|eva air|starlux(?: airlines?)?|"\n@@ -29,7 +29,7 @@\n RAW_DIR,\n SOURCES,\n USER_AGENT,\n- is_transport_story,\n+ is_transport_headline,\n iso_minute,\n load_json,\n norm_url,\n@@ -42,11 +42,15 @@\n \n \n def _matches_keywords(item: dict, keywords) -> bool:\n- """True when a broad-feed item matches both keyword and site scope."""\n- blob = (f"{item.get('title') or ''} "\n- f"{item.get('summary') or ''}").casefold()\n- return (any(str(k).casefold() in blob for k in keywords)\n- and is_transport_story(item))\n+ """True when a broad-feed item's headline passes the subject gate.\n+\n+ A carrier mentioned only in a summary can be a partner or background\n+ entity, so it must not make a non-aviation headline enter the editorial\n+ queue.\n+ """\n+ title = str(item.get("title") or "").casefold()\n+ return (any(str(k).casefold() in title for k in keywords)\n+ and is_transport_headline(item))\n RETRY_DELAY = 2 # seconds between the two attempts\n RESOLVE_TIMEOUT = 6 # seconds for google-news link resolution\n MAX_RESOLVE_FAILURES = 3 # stop resolving after this many misses in a row\n@@ -649,10 +653,10 @@ def _fetch_source(session: requests.Session, key: str, spec: dict,\n f"{len(raw_items)}/{before} items")\n elif spec.get("scope_filter"):\n # Broad feeds without a keyword list (currently MND/軍聞社) still need\n- # the same aviation/transport gate. General defence politics and\n- # land-exercise stories never enter the editorial queue.\n+ # the headline-level aviation/transport gate. General defence\n+ # politics and land-exercise stories never enter the editorial queue.\n before = len(raw_items)\n- raw_items = [it for it in raw_items if is_transport_story(it)]\n+ raw_items = [it for it in raw_items if is_transport_headline(it)]\n if before != len(raw_items):\n print(f"[fetch] {key}: scope filter kept "\n f"{len(raw_items)}/{before} items")\n@@ -0,0 +1,116 @@\n+"""Maintain a seven-day queue for manual image visual checks.\n+\n+The automatic image policy can reject obvious metadata mismatches, but it\n+cannot know whether a thumbnail actually depicts the promised subject. This\n+queue records every current article image with enough context for a human to\n+inspect it. It is deliberately kept under ``data/`` and is not copied into\n+the public site.\n+"""\n+from __future__ import annotations\n+\n+from datetime import timedelta\n+from pathlib import Path\n+import sys\n+\n+sys.path.insert(0, str(Path(__file__).resolve().parent))\n+from common import (ARTICLES_DIR, DATA_DIR, load_json, now_utc, parse_iso,\n+ save_json)\n+\n+QA_PATH = DATA_DIR / "image-qa.json"\n+MAX_ARTICLE_AGE_DAYS = 7\n+MAX_QUEUE_ITEMS = 200\n+VALID_STATUSES = {"pending", "approved", "rejected"}\n+\n+\n+def _image_snapshot(image: dict) -> dict:\n+ """Keep provenance fields useful to a reviewer, not arbitrary payload."""\n+ return {\n+ key: image.get(key)\n+ for key in ("url", "link", "provider", "kind", "matched",\n+ "subject", "credit", "license", "photoYear")\n+ if image.get(key) not in (None, "")\n+ }\n+\n+\n+def _article_rows(articles_dir: Path, now):\n+ cutoff = now - timedelta(days=MAX_ARTICLE_AGE_DAYS)\n+ for path in sorted(articles_dir.glob("*.json"), reverse=True):\n+ batch = load_json(path, None)\n+ if not isinstance(batch, dict):\n+ continue\n+ for article in batch.get("articles") or []:\n+ if not isinstance(article, dict) or not article.get("id"):\n+ continue\n+ try:\n+ published = parse_iso(str(article.get("publishedUtc")))\n+ except (TypeError, ValueError):\n+ continue\n+ if published < cutoff or not isinstance(article.get("image"), dict):\n+ continue\n+ image = article["image"]\n+ if not str(image.get("url") or "").strip():\n+ continue\n+ yield article, published, image\n+\n+\n+def _queue_key(article: dict, image: dict) -> str:\n+ return f"{article.get('id')}|{image.get('url')}"\n+\n+\n+def build_queue(now, *, articles_dir: Path = ARTICLES_DIR,\n+ existing: dict | None = None) -> dict:\n+ """Return the current seven-day visual-review queue."""\n+ old_items = (existing or {}).get("items") if isinstance(existing, dict) else []\n+ old_by_key = {\n+ str(row.get("key")): row for row in old_items\n+ if isinstance(row, dict) and row.get("key")\n+ }\n+ rows = []\n+ for article, published, image in _article_rows(articles_dir, now):\n+ key = _queue_key(article, image)\n+ old = old_by_key.get(key) or {}\n+ status = (old.get("status") if old.get("status") in VALID_STATUSES\n+ else "pending")\n+ zh = article.get("zh") if isinstance(article.get("zh"), dict) else {}\n+ en = article.get("en") if isinstance(article.get("en"), dict) else {}\n+ row = {\n+ "key": key,\n+ "articleId": str(article["id"]),\n+ "publishedUtc": published.isoformat(),\n+ "title": {\n+ "zh": str(zh.get("title") or en.get("title") or ""),\n+ "en": str(en.get("title") or zh.get("title") or ""),\n+ },\n+ "image": _image_snapshot(image),\n+ "status": status,\n+ "createdUtc": str(old.get("createdUtc") or now.isoformat()),\n+ "lastSeenUtc": now.isoformat(),\n+ }\n+ if isinstance(old.get("note"), str) and old["note"].strip():\n+ row["note"] = old["note"][:500]\n+ rows.append(row)\n+\n+ rows.sort(key=lambda row: row["publishedUtc"], reverse=True)\n+ return {\n+ "updatedUtc": now.isoformat(),\n+ "windowDays": MAX_ARTICLE_AGE_DAYS,\n+ "items": rows[:MAX_QUEUE_ITEMS],\n+ }\n+\n+\n+def main() -> int:\n+ if not ARTICLES_DIR.is_dir():\n+ print("image_qa: no articles yet")\n+ return 0\n+ existing = load_json(QA_PATH, {})\n+ payload = build_queue(now_utc(), existing=existing)\n+ if payload != existing:\n+ save_json(QA_PATH, payload)\n+ pending = sum(row.get("status") == "pending" for row in payload["items"])\n+ print(f"image_qa: {len(payload['items'])} current image(s), "\n+ f"{pending} pending visual review")\n+ return 0\n+\n+\n+if __name__ == "__main__":\n+ raise SystemExit(main())\n@@ -14,6 +14,7 @@\n var otherLang = lang === "zh" ? "en" : "zh";\n var records = [];\n var ready = false;\n+ var loading = null;\n var debounceTimer = 0;\n \n function normalize(value) {\n@@ -29,6 +30,25 @@\n return value[lang] || value[otherLang] || "";\n }\n \n+ function localizedValues(record, field) {\n+ var value = record[field] || {};\n+ if (typeof value === "string") return [value];\n+ return [value.zh || "", value.en || ""];\n+ }\n+\n+ function recordSearchText(record) {\n+ return normalize([\n+ record.id,\n+ record.source,\n+ record.date,\n+ record.published,\n+ localizedValues(record, "title").join(" "),\n+ localizedValues(record, "summary").join(" "),\n+ localizedValues(record, "category").join(" "),\n+ record.search,\n+ ].join(" "));\n+ }\n+\n function recordUrl(record, highlightQuery) {\n var urls = record.url || {};\n var rawUrl = urls[lang] || urls[otherLang] || "#";\n@@ -94,7 +114,7 @@\n }\n \n function scoreRecord(record, normalizedQuery, terms) {\n- var haystack = String(record.search || "");\n+ var haystack = recordSearchText(record);\n if (!terms.every(function (term) { return haystack.indexOf(term) !== -1; })) {\n return -1;\n }\n@@ -115,15 +135,17 @@\n updateQueryUrl(query);\n results.replaceChildren();\n \n- if (!ready) {\n- status.textContent = root.dataset.loading;\n- return;\n- }\n if (!query) {\n status.textContent = root.dataset.prompt;\n return;\n }\n \n+ if (!ready) {\n+ status.textContent = root.dataset.loading;\n+ loadIndex().then(runSearch).catch(showLoadError);\n+ return;\n+ }\n+\n var normalizedQuery = normalize(query);\n var terms = normalizedQuery.split(/\s+/).filter(Boolean);\n var matches = records.map(function (record) {\n@@ -161,19 +183,33 @@\n } catch (error) {\n input.value = "";\n }\n- fetch(root.dataset.indexUrl, { credentials: "same-origin" })\n- .then(function (response) {\n- if (!response.ok) throw new Error("HTTP " + response.status);\n- return response.json();\n- })\n- .then(function (payload) {\n- records = Array.isArray(payload.items) ? payload.items : [];\n- ready = true;\n- runSearch();\n- })\n- .catch(function () {\n- ready = false;\n- results.replaceChildren();\n- status.textContent = root.dataset.error;\n- });\n+\n+ function showLoadError() {\n+ ready = false;\n+ loading = null;\n+ results.replaceChildren();\n+ status.textContent = root.dataset.error;\n+ }\n+\n+ function loadIndex() {\n+ if (ready) return Promise.resolve();\n+ if (loading) return loading;\n+ loading = fetch(root.dataset.indexUrl, { credentials: "same-origin" })\n+ .then(function (response) {\n+ if (!response.ok) throw new Error("HTTP " + response.status);\n+ return response.json();\n+ })\n+ .then(function (payload) {\n+ records = Array.isArray(payload.items) ? payload.items : [];\n+ ready = true;\n+ })\n+ .catch(function (error) {\n+ showLoadError();\n+ throw error;\n+ });\n+ return loading;\n+ }\n+\n+ if (input.value.trim()) runSearch();\n+ else status.textContent = root.dataset.prompt;\n })();\n@@ -292,6 +292,9 @@ a.flash-row:hover { color: inherit; }\n font-size: 12.5px; line-height: 1.55; max-height: 3.1em;\n }\n \n+.stat-unavailable { font-size: 15px !important; letter-spacing: 0 !important; }\n+.stats-note { margin: 8px 0 0; font-size: 11px; line-height: 1.5; }\n+\n [data-theme="dark"] .summary-preview__label {\n color: color-mix(in srgb, #b8adff 88%, var(--color-text));\n }\n@@ -37,6 +37,8 @@\n <link href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;600;800&family=Noto+Sans+TC:wght@400;500;700;900&display=swap" rel="stylesheet">\n <link rel="stylesheet" href="{{ base }}/assets/modernist.css">\n <link rel="stylesheet" href="{{ base }}/assets/site.css?v={{ asset_version }}">\n+<link rel="alternate" type="application/rss+xml"\n+ title="{{ t.siteName }} — {{ t.latest }}" href="{{ feed_url }}">\n {% block head_extra %}{% endblock %}\n <script src="{{ base }}/assets/app.js?v={{ asset_version }}" defer></script>\n </head>\n@@ -72,7 +72,7 @@ <h2 id="hero-story-title" class="hero-title" aria-live="polite">{{ hero.title }}\n {% endif %}\n <hr class="hr" style="margin:20px 0 0;">\n <div style="display:flex;justify-content:space-between;align-items:center;padding:12px 0;flex-wrap:wrap;gap:10px;">\n- <h6 style="margin:0;">{{ t.latest }}</h6>\n+ <h6 style="margin:0;"><a href="{{ news_url }}" style="color:inherit;text-decoration:none;">{{ t.latest }} →</a></h6>\n {% if agg %}\n <span class="text-muted" style="font-size:12px;letter-spacing:.02em;">{{ t.aggNote }}</span>\n {% else %}\n@@ -101,22 +101,25 @@ <h6 style="margin:0;">{{ t.latest }}</h6>\n <h6 style="margin:0 0 12px;">{{ t.today }}</h6>\n <div style="display:grid;grid-template-columns:1fr 1fr;border:1px solid var(--color-divider);">\n <a class="radar-stat-link" href="{{ radar_url }}" style="padding:12px;border-right:1px solid var(--color-divider);border-bottom:1px solid var(--color-divider);">\n- <div style="font-size:24px;font-weight:800;color:var(--color-accent);letter-spacing:-.02em;">{{ stats.flights }}</div>\n+ <div class="stat-value{% if stats.flights == t.statUnavailable %} stat-unavailable{% endif %}" style="font-size:24px;font-weight:800;color:var(--color-accent);letter-spacing:-.02em;">{{ stats.flights }}</div>\n <div class="text-muted" style="font-size:11px;">{{ t.statFlights }}</div>\n </a>\n <div style="padding:12px;border-bottom:1px solid var(--color-divider);">\n- <div style="font-size:24px;font-weight:800;letter-spacing:-.02em;">{{ stats.delay }}</div>\n+ <div class="stat-value{% if stats.delay == t.statUnavailable %} stat-unavailable{% endif %}" style="font-size:24px;font-weight:800;letter-spacing:-.02em;">{{ stats.delay }}</div>\n <div class="text-muted" style="font-size:11px;">{{ t.statDelay }}</div>\n </div>\n <a class="radar-stat-link" href="{{ incidents_week_url }}" aria-label="{{ t.incWeeklySerious }}:{{ stats.serious }} {{ t.incCountLabel }}" style="padding:12px;border-right:1px solid var(--color-divider);">\n- <div style="font-size:24px;font-weight:800;letter-spacing:-.02em;">{{ stats.serious }}</div>\n+ <div class="stat-value{% if stats.serious == t.statUnavailable %} stat-unavailable{% endif %}" style="font-size:24px;font-weight:800;letter-spacing:-.02em;">{{ stats.serious }}</div>\n <div class="text-muted" style="font-size:11px;">{{ t.statSerious }}</div>\n </a>\n <div style="padding:12px;">\n- <div style="font-size:24px;font-weight:800;letter-spacing:-.02em;">{{ stats.notams }}</div>\n+ <div class="stat-value{% if stats.notams == t.statUnavailable %} stat-unavailable{% endif %}" style="font-size:24px;font-weight:800;letter-spacing:-.02em;">{{ stats.notams }}</div>\n <div class="text-muted" style="font-size:11px;">{{ t.statNotam }}</div>\n </div>\n </div>\n+ {% if stats_note %}\n+ <p class="stats-note text-muted">{{ stats_note }}</p>\n+ {% endif %}\n {% if otp %}\n <h6 style="margin:28px 0 12px;">{{ t.otp }}</h6>\n {% for r in otp %}\n@@ -0,0 +1,27 @@\n+{% extends "base.html" %}\n+{% from "_summary.html" import notification_summary %}\n+{% block content %}\n+ <main class="search-page topic-page">\n+ <header class="search-header">\n+ <span class="card-kicker">{{ t.newsKicker }}</span>\n+ <h1>{{ t.newsTitle }}</h1>\n+ <p class="text-muted">{{ t.newsSub }}</p>\n+ <p class="text-muted" style="font-size:12.5px;">{{ count }} {{ t.topicCount }}</p>\n+ </header>\n+ <section aria-label="{{ t.newsTitle }}">\n+ {% for item in articles %}\n+ <a class="feed-row" href="{{ item.url }}">\n+ <span class="feed-time">{{ item.time }}</span>\n+ <span class="feed-category tag {{ item.tag_class }}">{{ item.cat_label }}{% if item.article_format == "brief" %} · {{ t.briefLabel }}{% elif item.article_format == "roundup" %} · {{ t.roundupLabel }}{% endif %}</span>\n+ <span class="feed-copy">\n+ <span style="display:block;font-size:15.5px;font-weight:700;line-height:1.4;letter-spacing:-.01em;">{{ item.title }}</span>\n+ </span>\n+ {{ notification_summary(item.display_summary, t.summaryLabel, 'feed') }}\n+ <span class="feed-source text-muted">{{ item.source }}</span>\n+ </a>\n+ {% else %}\n+ <p class="text-muted">{{ t.awaiting }}</p>\n+ {% endfor %}\n+ </section>\n+ </main>\n+{% endblock %}\n@@ -1,5 +1,10 @@\n """Regression tests for weather-driven national-airline hero pinning."""\n from datetime import timedelta\n+from pathlib import Path\n+import sys\n+\n+ROOT = Path(__file__).resolve().parent.parent\n+sys.path.insert(0, str(ROOT))\n \n from pipeline import build\n \n@@ -94,3 +99,21 @@ def test_rotation_payload_contains_localized_hero_fields():\n assert payload["summary"] == "國籍航空調整航班。"\n assert "China Airlines Boeing 777-300ER" in payload["image_caption"]\n assert build.HERO_ROTATION_SECONDS == 300\n+\n+\n+def main() -> int:\n+ tests = (\n+ test_weather_national_airline_flight_change_is_priority_story,\n+ test_priority_story_requires_all_three_signals,\n+ test_priority_story_at_sixteen_hours_is_still_pinned,\n+ test_stale_priority_story_is_not_pinned,\n+ test_rotation_payload_contains_localized_hero_fields,\n+ )\n+ for test in tests:\n+ test()\n+ print(f"test_headline_rotation: {len(tests)} passed")\n+ return 0\n+\n+\n+if __name__ == "__main__":\n+ raise SystemExit(main())\n@@ -0,0 +1,61 @@\n+"""Regression checks for the seven-day manual image visual-review queue."""\n+from __future__ import annotations\n+\n+import json\n+import tempfile\n+from datetime import datetime, timezone\n+from pathlib import Path\n+\n+ROOT = Path(__file__).resolve().parent.parent\n+import sys\n+sys.path.insert(0, str(ROOT))\n+\n+from pipeline import image_qa\n+\n+\n+def _write_batch(path: Path) -> None:\n+ path.write_text(json.dumps({"articles": [{\n+ "id": "a-qa",\n+ "publishedUtc": "2026-08-26T08:00:00Z",\n+ "zh": {"title": "華航航班調整"},\n+ "en": {"title": "China Airlines flight adjustment"},\n+ "image": {\n+ "url": "https://example.com/aircraft.jpg",\n+ "provider": "Wikimedia Commons",\n+ "kind": "file_photo",\n+ "subject": "China Airlines Boeing 777-300ER",\n+ },\n+ }]}), encoding="utf-8")\n+\n+\n+def test_queue_records_current_image_and_preserves_decision(tmp_path):\n+ articles = tmp_path / "articles"\n+ articles.mkdir()\n+ _write_batch(articles / "2026-08-26T08.json")\n+ now = datetime(2026, 8, 26, 9, tzinfo=timezone.utc)\n+ existing = {\n+ "items": [{\n+ "key": "a-qa|https://example.com/aircraft.jpg",\n+ "status": "approved",\n+ "createdUtc": "2026-08-26T08:30:00+00:00",\n+ }]\n+ }\n+ payload = image_qa.build_queue(now, articles_dir=articles,\n+ existing=existing)\n+ assert payload["windowDays"] == 7\n+ assert len(payload["items"]) == 1\n+ row = payload["items"][0]\n+ assert row["status"] == "approved"\n+ assert row["image"]["subject"].endswith("777-300ER")\n+ assert row["createdUtc"] == "2026-08-26T08:30:00+00:00"\n+\n+\n+def main() -> int:\n+ with tempfile.TemporaryDirectory() as directory:\n+ test_queue_records_current_image_and_preserves_decision(Path(directory))\n+ print("PASS test_image_qa")\n+ return 0\n+\n+\n+if __name__ == "__main__":\n+ raise SystemExit(main())\n@@ -70,6 +70,16 @@ def main() -> None:\n assert not common.is_transport_story(political)\n assert not fetch._matches_keywords(\n political["items"][0], ["國軍", "漢光"])\n+ summary_only = {\n+ "title": "工研院攜手奇美等成立塑膠包材循環示範聯盟",\n+ "summary": "中華航空參與聯盟,推動資源循環。",\n+ }\n+ assert not common.is_transport_headline(summary_only)\n+ assert not fetch._matches_keywords(\n+ summary_only, common.TAIWAN_TRANSPORT_KEYWORDS)\n+ assert fetch._matches_keywords(\n+ {"title": "華航調整航班", "summary": "受到天候影響。"},\n+ common.TAIWAN_TRANSPORT_KEYWORDS)\n \n assert common.is_transport_story(story(\n "國防部公布共機艦動態",\n@@ -118,14 +128,17 @@ def main() -> None:\n \n flash_data = json.loads(\n (ROOT / "data" / "flashes.json").read_text(encoding="utf-8"))\n- for target_id in TARGET_IDS:\n- archived_flash = next(row for row in flash_data\n- if row.get("articleId") == target_id)\n- assert archived_flash["archived"] is True\n+ archived_flashes = [row for row in flash_data\n+ if row.get("articleId") in TARGET_IDS]\n+ assert all(row.get("archived") is True for row in archived_flashes)\n visible_flashes = build.prep_flashes(flash_data, set(TARGET_IDS))\n assert all(row.get("articleId") not in TARGET_IDS\n for row in visible_flashes)\n \n+ orphan = {"timeUtc": "2026-07-27T08:00Z", "zh": "不應顯示",\n+ "en": "Should be hidden", "articleId": "missing-article"}\n+ assert build.prep_flashes([orphan], set()) == []\n+\n print("test_scope: OK")\n \n \n@@ -118,6 +118,9 @@ def main() -> None:\n assert "航空公司正式名稱與常用簡稱可互相查找" in zh\n assert "Official airline names and common short names are interchangeable" in en\n assert "terms.every" in script\n+ assert "function loadIndex()" in script\n+ assert "function recordSearchText" in script\n+ assert "if (input.value.trim()) runSearch();" in script\n assert "textContent" in script\n assert "replaceChildren" in script\n assert "innerHTML" not in script\n@@ -43,6 +43,20 @@ def main() -> None:\n assert (f"{build.SITE_ORIGIN}{build.BASE_PATH}/en/topics/{slug}/"\n in locations)\n \n+ news_index = ROOT / "site" / "news" / "index.html"\n+ en_news_index = ROOT / "site" / "en" / "news" / "index.html"\n+ assert news_index.is_file() and en_news_index.is_file()\n+ news_html = news_index.read_text(encoding="utf-8")\n+ assert "https://skytical.tech/news/" in news_html\n+ assert "塑膠包材循環示範聯盟" not in news_html\n+\n+ feed = ET.parse(ROOT / "site" / "feed.xml").getroot()\n+ en_feed = ET.parse(ROOT / "site" / "en" / "feed.xml").getroot()\n+ assert feed.tag == "rss" and en_feed.tag == "rss"\n+ assert feed.findtext("channel/link").endswith("/news/")\n+ assert en_feed.findtext("channel/link").endswith("/en/news/")\n+ assert feed.findall("channel/item")\n+\n news_sitemap = ET.parse(ROOT / "site" / "news-sitemap.xml").getroot()\n news_rows = news_sitemap.findall("sm:url", ns)\n assert news_rows and len(news_rows) <= 1000\n@@ -74,6 +88,9 @@ def main() -> None:\n assert '<meta name="robots" content="index,follow,max-image-preview:large">' in home\n assert '<link rel="alternate" hreflang="zh-Hant-TW"' in home\n assert "SKYTICAL 航空新聞|臺灣與全球即時航空快訊" in home\n+ assert "暫無資料" in home\n+ assert "部分即時數據目前尚未取得" in home\n+ assert 'type="application/rss+xml"' in home\n \n article_paths = list((ROOT / "site" / "news").glob("*/index.html"))\n article = article_paths[0].read_text(encoding="utf-8")\n@@ -92,7 +109,8 @@ def main() -> None:\n assert "SKYTICAL 編輯系統" in article\n pictured_article = next(\n path.read_text(encoding="utf-8") for path in article_paths\n- if "<figure" in path.read_text(encoding="utf-8"))\n+ if "<figure" in path.read_text(encoding="utf-8")\n+ and "data-image-fallback" not in path.read_text(encoding="utf-8"))\n pictured_schema = _json_ld(pictured_article)[0]\n assert 'fetchpriority="high" decoding="async"' in pictured_article\n assert 'loading="lazy"' not in re.search(