2026-10-20 TUE UTC 12:42 PM TPE 8:42 PM Auto-updates hourly 中文 EN
LIVE WIRE
← Back to the site changelog Site Changelog

Original commit record

新增新聞別名搜尋 (#60) 新增雙語全文搜尋頁、靜態新聞索引與可維護的航空公司及機場別名群組。

SHA
4d92c552432409abb40e542e31a838fe8bbb4902
Commit date
Author
nnneeeooo-nnneeeooo
Committer
web-flow

Changed files (6)

  • config/search_aliases.json added · +36 / −0
  • pipeline/build.py modified · +142 / −4
  • static/search.js added · +176 / −0
  • static/site.css modified · +45 / −0
  • templates/search.html added · +38 / −0
  • tests/test_search.py added · +92 / −0

Full diff

@@ -0,0 +1,36 @@\n+{\n+  "schemaVersion": 1,\n+  "groups": [\n+    {"key": "china-airlines", "aliases": ["中華航空", "華航", "China Airlines"]},\n+    {"key": "eva-air", "aliases": ["長榮航空", "長榮", "EVA Air", "EVA"]},\n+    {"key": "starlux", "aliases": ["星宇航空", "星宇", "STARLUX Airlines", "STARLUX"]},\n+    {"key": "tigerair-taiwan", "aliases": ["臺灣虎航", "台灣虎航", "虎航", "Tigerair Taiwan"]},\n+    {"key": "mandarin-airlines", "aliases": ["華信航空", "華信", "Mandarin Airlines"]},\n+    {"key": "uni-air", "aliases": ["立榮航空", "立榮", "UNI Air"]},\n+    {"key": "cathay-pacific", "aliases": ["國泰航空", "國泰", "Cathay Pacific"]},\n+    {"key": "japan-airlines", "aliases": ["日本航空", "日航", "Japan Airlines", "JAL"]},\n+    {"key": "ana", "aliases": ["全日本空輸", "全日空", "All Nippon Airways", "ANA"]},\n+    {"key": "korean-air", "aliases": ["大韓航空", "韓航", "Korean Air"]},\n+    {"key": "singapore-airlines", "aliases": ["新加坡航空", "新航", "Singapore Airlines"]},\n+    {"key": "emirates", "aliases": ["阿聯酋航空", "阿聯酋", "Emirates"]},\n+    {"key": "qatar-airways", "aliases": ["卡達航空", "Qatar Airways"]},\n+    {"key": "qantas", "aliases": ["澳洲航空", "澳航", "Qantas"]},\n+    {"key": "air-macau", "aliases": ["澳門航空", "Air Macau"]},\n+    {"key": "klm", "aliases": ["荷蘭皇家航空", "荷航", "KLM Royal Dutch Airlines", "KLM"]},\n+    {"key": "lufthansa", "aliases": ["德國漢莎航空", "漢莎航空", "漢莎", "Lufthansa"]},\n+    {"key": "air-france", "aliases": ["法國航空", "法航", "Air France"]},\n+    {"key": "british-airways", "aliases": ["英國航空", "英航", "British Airways"]},\n+    {"key": "american-airlines", "aliases": ["美國航空", "美航", "American Airlines"]},\n+    {"key": "united-airlines", "aliases": ["美國聯合航空", "聯合航空", "聯航", "United Airlines"]},\n+    {"key": "delta-air-lines", "aliases": ["達美航空", "Delta Air Lines", "Delta"]},\n+    {"key": "turkish-airlines", "aliases": ["土耳其航空", "土航", "Turkish Airlines"]},\n+    {"key": "air-new-zealand", "aliases": ["紐西蘭航空", "紐航", "Air New Zealand"]},\n+    {"key": "airasia", "aliases": ["亞洲航空", "亞航", "AirAsia"]},\n+    {"key": "jetstar", "aliases": ["捷星航空", "捷星", "Jetstar"]},\n+    {"key": "peach", "aliases": ["樂桃航空", "樂桃", "Peach Aviation"]},\n+    {"key": "taoyuan-airport", "aliases": ["臺灣桃園國際機場", "台灣桃園國際機場", "桃園國際機場", "桃園機場", "桃機", "Taiwan Taoyuan International Airport", "Taoyuan Airport", "RCTP", "TPE"]},\n+    {"key": "songshan-airport", "aliases": ["臺北松山機場", "台北松山機場", "松山機場", "Taipei Songshan Airport", "RCSS", "TSA"]},\n+    {"key": "kaohsiung-airport", "aliases": ["高雄國際機場", "小港機場", "Kaohsiung International Airport", "RCKH", "KHH"]},\n+    {"key": "taichung-airport", "aliases": ["臺中國際機場", "台中國際機場", "清泉崗機場", "Taichung International Airport", "RCMQ", "RMQ"]}\n+  ]\n+}\n@@ -10,12 +10,14 @@\n """\n from __future__ import annotations\n \n+import json\n import os\n import re\n import shutil\n import sys\n import time\n import math\n+import unicodedata\n from datetime import timedelta, timezone\n from pathlib import Path\n from urllib.parse import quote, urlsplit\n@@ -104,8 +106,17 @@\n         "footerAbout": "全自動航空新聞聚合站。GitHub Actions 每小時抓取各可信來源,自動撰寫並發布,每篇文末標註原始出處。本頁為設計原型,內容為示意樣本。",\n         "footerSources": "資料來源 Data Sources",\n         "footerChangelog": "網站更新紀錄",\n-        "nav": ["最新", "快報", "航班雷達", "事故資料庫", "來源", "方法論"],\n+        "nav": ["最新", "搜尋", "快報", "航班雷達", "事故資料庫", "來源", "方法論"],\n         "cats": ["全部", "事故", "法規", "商業", "營運", "軍事"],\n+        "searchKicker": "News Search", "searchTitle": "搜尋新聞",\n+        "searchSub": "搜尋標題、摘要、全文、來源、日期、航班、機型與機場代碼;航空公司正式名稱與常用簡稱可互相查找。",\n+        "searchPlaceholder": "輸入關鍵字,例如:華航、A321neo、桃園",\n+        "searchButton": "搜尋", "searchClear": "清除",\n+        "searchLoading": "正在載入新聞索引…",\n+        "searchPrompt": "輸入關鍵字開始搜尋全部新聞。",\n+        "searchCount": "找到 {count} 則新聞",\n+        "searchNoResults": "找不到符合「{query}」的新聞,請改用航空公司簡稱、機型、航班或機場代碼。",\n+        "searchError": "新聞索引暫時無法載入,請稍後重新整理頁面。",\n         "radarKicker": "Taiwan Flight Radar",\n         "radarTitle": "台灣民航雷達",\n         "radarSub": "公開 ADS-B 即時觀測圖,顯示台灣周邊民航機的 ICAO/IATA 航班代碼、航線、機型、高度、速度與航向。",\n@@ -191,10 +202,19 @@\n         "footerAbout": "A fully automated aviation news aggregator. GitHub Actions fetches trusted sources hourly, writes and publishes automatically, and credits originals at the end of every article. Design prototype; sample content.",\n         "footerSources": "Data Sources",\n         "footerChangelog": "Site changelog",\n-        "nav": ["Latest", "Briefings", "Flight Radar", "Incident DB", "Sources",\n-                "Methodology"],\n+        "nav": ["Latest", "Search", "Briefings", "Flight Radar", "Incident DB", "Sources",\n+                 "Methodology"],\n         "cats": ["All", "Safety", "Regulation", "Business", "Operations",\n                  "Military"],\n+        "searchKicker": "News Search", "searchTitle": "Search news",\n+        "searchSub": "Search titles, summaries, full text, sources, dates, flights, aircraft types and airport codes. Official airline names and common short names are interchangeable.",\n+        "searchPlaceholder": "Enter keywords, e.g. China Airlines, A321neo, TPE",\n+        "searchButton": "Search", "searchClear": "Clear",\n+        "searchLoading": "Loading the news index…",\n+        "searchPrompt": "Enter keywords to search every published story.",\n+        "searchCount": "{count} articles found",\n+        "searchNoResults": "No stories match “{query}”. Try an airline short name, aircraft type, flight or airport code.",\n+        "searchError": "The news index is temporarily unavailable. Please reload this page later.",\n         "radarKicker": "Taiwan Flight Radar",\n         "radarTitle": "Taiwan civil flight radar",\n         "radarSub": "A live public ADS-B view of civil aircraft around Taiwan, with ICAO/IATA flight codes, routes, type, altitude, speed and heading.",\n@@ -973,6 +993,109 @@ def art_view(a, lang: str):\n     return v\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+    return re.sub(r"[^\w]+", " ", text, flags=re.UNICODE).strip()\n+\n+\n+def load_search_alias_groups() -> list[tuple[str, ...]]:\n+    """Load bounded, duplicate-free alias groups used to expand the index."""\n+    path = Path(__file__).resolve().parent.parent / "config" / "search_aliases.json"\n+    raw = load_json(path, {})\n+    groups = []\n+    for row in (raw.get("groups") if isinstance(raw, dict) else []) or []:\n+        aliases = row.get("aliases") if isinstance(row, dict) else None\n+        if not isinstance(aliases, list):\n+            continue\n+        clean = []\n+        seen = set()\n+        for value in aliases[:20]:\n+            alias = re.sub(r"\s+", " ", str(value)).strip()[:80]\n+            norm = normalize_search_text(alias)\n+            if alias and norm and norm not in seen:\n+                clean.append(alias)\n+                seen.add(norm)\n+        if len(clean) >= 2:\n+            groups.append(tuple(clean))\n+    return groups[:200]\n+\n+\n+def _search_text_has_alias(normalized_text: str, alias: str) -> bool:\n+    needle = normalize_search_text(alias)\n+    if not needle:\n+        return False\n+    if re.fullmatch(r"[a-z0-9]+", needle):\n+        return re.search(\n+            rf"(?<![a-z0-9]){re.escape(needle)}(?![a-z0-9])",\n+            normalized_text,\n+        ) is not None\n+    return needle in normalized_text\n+\n+\n+def expand_search_aliases(text: str, groups=None) -> list[str]:\n+    """Return every alias from groups represented by at least one term."""\n+    normalized = normalize_search_text(text)\n+    matched = []\n+    for group in groups if groups is not None else load_search_alias_groups():\n+        if any(_search_text_has_alias(normalized, alias) for alias in group):\n+            matched.extend(group)\n+    return matched\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+        _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+            article[lang]["title"], article[lang]["summary"],\n+            *article[lang]["body"],\n+        ])\n+    for source in article["sources"]:\n+        searchable.extend([source["name"], source["host"]])\n+    raw_search = " ".join(str(value) for value in 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+        for lang in article["available_languages"]\n+    }\n+    return {\n+        "id": article["id"],\n+        "title": {lang: article[lang]["title"] for lang in ("zh", "en")},\n+        "summary": {\n+            lang: article[lang]["summary"] for lang in ("zh", "en")\n+        },\n+        "category": {\n+            lang: _bi(CATS.get(article["cat"]), lang, article["cat"])\n+            for lang in ("zh", "en")\n+        },\n+        "source": article["source"],\n+        "date": article["meta_ts"],\n+        "published": article["dt"].isoformat(),\n+        "articleFormat": article["article_format"],\n+        "availableLanguages": article["available_languages"],\n+        "url": urls,\n+        "search": normalize_search_text(" ".join(\n+            str(value) for value in searchable if value)),\n+    }\n+\n+\n+def search_index_payload(articles, generated_utc) -> dict:\n+    alias_groups = load_search_alias_groups()\n+    return {\n+        "version": 1,\n+        "generatedUtc": generated_utc.isoformat(),\n+        "count": len(articles),\n+        "items": [search_index_item(article, alias_groups)\n+                  for article in articles],\n+    }\n+\n+\n def collect_agg_items(now):\n     """Aggregation fallback: when no LLM-written articles exist yet, surface\n     the newest raw source headlines directly (free, no-API mode)."""\n@@ -2139,7 +2262,8 @@ def render_manual_workbench(env, build) -> int:\n \n def base_ctx(lang, page, sub, *, title, description, ticker, build, hreflang=True):\n     t = L[lang]\n-    nav_defs = [("home", ""), ("briefings", "briefings/"),\n+    nav_defs = [("home", ""), ("search", "search/"),\n+                ("briefings", "briefings/"),\n                 ("radar", "radar/"),\n                 ("incidents", "incidents/"),\n                 ("sources", "sources/"), ("about", "about/")]\n@@ -2198,6 +2322,10 @@ def main() -> int:\n             if f.is_file():\n                 shutil.copy2(f, SITE_DIR / "assets" / f.name)\n     (SITE_DIR / ".nojekyll").write_text("", encoding="utf-8")\n+    (SITE_DIR / "search-index.json").write_text(\n+        json.dumps(search_index_payload(articles, now), ensure_ascii=False,\n+                   separators=(",", ":")),\n+        encoding="utf-8", newline="\n")\n \n     env = Environment(\n         loader=FileSystemLoader(str(TEMPLATES_DIR)),\n@@ -2268,6 +2396,16 @@ def main() -> int:\n         render(env, "home.html", rel_path(lang, "index.html"), ctx)\n         pages += 1\n \n+        ctx = base_ctx(\n+            lang, "search", "search/",\n+            title=f"{t['siteName']} — {t['searchTitle']}",\n+            description=t["searchSub"], ticker=ticker, build=build)\n+        ctx["search_index_url"] = (\n+            f"{BASE_PATH}/search-index.json"\n+            if BASE_PATH else "/search-index.json")\n+        render(env, "search.html", rel_path(lang, "search/index.html"), ctx)\n+        pages += 1\n+\n         for bv in bviews:\n             try:\n                 ctx = base_ctx(lang, "briefings", f"briefings/{bv['id']}/",\n@@ -0,0 +1,176 @@\n+/* AVWIRE static full-text news search. The build expands configured aliases\n+   into each record, so official names and common short names are equivalent. */\n+(function () {\n+  "use strict";\n+\n+  var root = document.getElementById("news-search-app");\n+  if (!root) return;\n+\n+  var form = document.getElementById("news-search-form");\n+  var input = document.getElementById("news-search-input");\n+  var clear = document.getElementById("news-search-clear");\n+  var status = document.getElementById("news-search-status");\n+  var results = document.getElementById("news-search-results");\n+  var lang = root.dataset.lang === "en" ? "en" : "zh";\n+  var otherLang = lang === "zh" ? "en" : "zh";\n+  var records = [];\n+  var ready = false;\n+  var debounceTimer = 0;\n+\n+  function normalize(value) {\n+    return String(value || "")\n+      .normalize("NFKC")\n+      .toLocaleLowerCase()\n+      .replace(/[^\p{L}\p{N}_]+/gu, " ")\n+      .trim();\n+  }\n+\n+  function localized(record, field) {\n+    var value = record[field] || {};\n+    return value[lang] || value[otherLang] || "";\n+  }\n+\n+  function recordUrl(record) {\n+    var urls = record.url || {};\n+    return urls[lang] || urls[otherLang] || "#";\n+  }\n+\n+  function updateQueryUrl(query) {\n+    try {\n+      var url = new URL(window.location.href);\n+      if (query) url.searchParams.set("q", query);\n+      else url.searchParams.delete("q");\n+      window.history.replaceState(null, "", url.pathname + url.search + url.hash);\n+    } catch (error) {\n+      /* Searching still works if history manipulation is unavailable. */\n+    }\n+  }\n+\n+  function textNode(tag, className, text) {\n+    var node = document.createElement(tag);\n+    if (className) node.className = className;\n+    node.textContent = text;\n+    return node;\n+  }\n+\n+  function resultCard(record) {\n+    var card = document.createElement("article");\n+    card.className = "search-result";\n+\n+    var meta = document.createElement("div");\n+    meta.className = "search-result-meta";\n+    meta.appendChild(textNode("span", "tag " + (record.articleFormat === "brief" ? "tag-neutral" : "tag-outline"), localized(record, "category") + (record.articleFormat === "brief" ? " · " + root.dataset.briefLabel : "")));\n+    meta.appendChild(textNode("time", "text-muted", record.date || ""));\n+    card.appendChild(meta);\n+\n+    var link = document.createElement("a");\n+    link.className = "search-result-link";\n+    link.href = recordUrl(record);\n+    link.appendChild(textNode("h2", "", localized(record, "title")));\n+    card.appendChild(link);\n+\n+    var summary = localized(record, "summary");\n+    if (summary) card.appendChild(textNode("p", "text-muted", summary));\n+\n+    var footer = document.createElement("div");\n+    footer.className = "search-result-footer";\n+    footer.appendChild(textNode("span", "text-muted", record.source || ""));\n+    var more = document.createElement("a");\n+    more.href = recordUrl(record);\n+    more.textContent = root.dataset.readMore;\n+    footer.appendChild(more);\n+    card.appendChild(footer);\n+    return card;\n+  }\n+\n+  function scoreRecord(record, normalizedQuery, terms) {\n+    var haystack = String(record.search || "");\n+    if (!terms.every(function (term) { return haystack.indexOf(term) !== -1; })) {\n+      return -1;\n+    }\n+    var title = normalize(localized(record, "title"));\n+    var summary = normalize(localized(record, "summary"));\n+    var score = 1;\n+    if (title === normalizedQuery) score += 200;\n+    else if (title.indexOf(normalizedQuery) !== -1) score += 100;\n+    terms.forEach(function (term) {\n+      if (title.indexOf(term) !== -1) score += 20;\n+      if (summary.indexOf(term) !== -1) score += 6;\n+    });\n+    return score;\n+  }\n+\n+  function runSearch() {\n+    var query = input.value.trim();\n+    clear.hidden = !query;\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+    var normalizedQuery = normalize(query);\n+    var terms = normalizedQuery.split(/\s+/).filter(Boolean);\n+    var matches = records.map(function (record) {\n+      return { record: record, score: scoreRecord(record, normalizedQuery, terms) };\n+    }).filter(function (row) {\n+      return row.score >= 0;\n+    }).sort(function (left, right) {\n+      if (right.score !== left.score) return right.score - left.score;\n+      return String(right.record.published || "").localeCompare(String(left.record.published || ""));\n+    });\n+\n+    if (!matches.length) {\n+      status.textContent = root.dataset.emptyTemplate.replace("{query}", query);\n+      return;\n+    }\n+    status.textContent = root.dataset.countTemplate.replace("{count}", String(matches.length));\n+    var fragment = document.createDocumentFragment();\n+    matches.forEach(function (row) { fragment.appendChild(resultCard(row.record)); });\n+    results.appendChild(fragment);\n+  }\n+\n+  form.addEventListener("submit", function (event) {\n+    event.preventDefault();\n+    window.clearTimeout(debounceTimer);\n+    runSearch();\n+  });\n+  input.addEventListener("input", function () {\n+    window.clearTimeout(debounceTimer);\n+    debounceTimer = window.setTimeout(runSearch, 120);\n+  });\n+  clear.addEventListener("click", function () {\n+    input.value = "";\n+    runSearch();\n+    input.focus();\n+  });\n+\n+  try {\n+    input.value = new URL(window.location.href).searchParams.get("q") || "";\n+  } catch (error) {\n+    input.value = "";\n+  }\n+  clear.hidden = !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@@ -299,6 +299,46 @@ a.flash-row:hover { color: inherit; }\n   letter-spacing: .04em; text-decoration: none;\n }\n \n+/* — full-text news search — */\n+.search-page { padding: 34px 28px 56px; }\n+.search-header { max-width: 820px; margin-bottom: 24px; }\n+.search-header h1 {\n+  margin: 6px 0 8px; font-size: clamp(30px, 5vw, 52px);\n+  letter-spacing: -.025em;\n+}\n+.search-header p { margin: 0; font-size: 14px; line-height: 1.8; }\n+.news-search { max-width: 960px; }\n+.news-search-form {\n+  display: grid; grid-template-columns: minmax(0, 1fr) auto auto;\n+  gap: 8px; padding: 12px; border: 2px solid var(--color-divider);\n+  background: var(--color-surface);\n+}\n+.news-search-form input {\n+  min-width: 0; min-height: 44px; padding: 9px 12px;\n+  border: 1px solid var(--color-divider); background: var(--color-bg);\n+  color: var(--color-text); font: inherit;\n+}\n+.news-search-form input:focus {\n+  outline: 2px solid var(--color-accent); outline-offset: -2px;\n+}\n+.news-search-form .btn { min-height: 44px; white-space: nowrap; }\n+.search-status { margin: 14px 0 0; min-height: 24px; font-size: 13px; }\n+.search-results { margin-top: 8px; border-top: 2px solid var(--color-divider); }\n+.search-result { padding: 18px 0; border-bottom: 1px solid var(--color-divider); }\n+.search-result-meta {\n+  display: flex; flex-wrap: wrap; align-items: center; gap: 10px;\n+  font-size: 11.5px;\n+}\n+.search-result-link { color: inherit; text-decoration: none; }\n+.search-result-link:hover { color: var(--color-accent); }\n+.search-result h2 { margin: 9px 0 6px; font-size: 21px; line-height: 1.35; }\n+.search-result p { margin: 0; font-size: 13.5px; line-height: 1.75; }\n+.search-result-footer {\n+  display: flex; justify-content: space-between; align-items: baseline;\n+  gap: 16px; margin-top: 10px; font-size: 12px;\n+}\n+.search-result-footer a { flex-shrink: 0; font-weight: 700; }\n+\n /* — responsive fallback: the design is desktop-first; below 1100px the\n      three-column home grid simply stacks. — */\n @media (max-width: 1100px) {\n@@ -318,6 +358,11 @@ a.flash-row:hover { color: inherit; }\n }\n \n @media (max-width: 640px) {\n+  .search-page { padding: 24px 14px 40px; }\n+  .news-search-form { grid-template-columns: minmax(0, 1fr) auto; }\n+  .news-search-form #news-search-clear { grid-column: 1 / -1; }\n+  .search-result h2 { font-size: 18px; }\n+  .search-result-footer { align-items: flex-start; flex-direction: column; gap: 6px; }\n   .radar-page { padding: 18px 12px 24px; }\n   .radar-header { align-items: flex-start; flex-direction: column; gap: 12px; }\n   .radar-toolbar { align-items: stretch; flex-wrap: wrap; }\n@@ -0,0 +1,38 @@\n+{% extends "base.html" %}\n+{% block content %}\n+  <main class="search-page">\n+    <header class="search-header">\n+      <span class="card-kicker">{{ t.searchKicker }}</span>\n+      <h1>{{ t.searchTitle }}</h1>\n+      <p class="text-muted">{{ t.searchSub }}</p>\n+    </header>\n+\n+    <section id="news-search-app" class="news-search"\n+             data-index-url="{{ search_index_url }}"\n+             data-lang="{{ lang }}"\n+             data-loading="{{ t.searchLoading }}"\n+             data-prompt="{{ t.searchPrompt }}"\n+             data-count-template="{{ t.searchCount }}"\n+             data-empty-template="{{ t.searchNoResults }}"\n+             data-error="{{ t.searchError }}"\n+             data-brief-label="{{ t.briefLabel }}"\n+             data-read-more="{{ t.readMore }}">\n+      <form id="news-search-form" class="news-search-form" role="search">\n+        <label class="sr-only" for="news-search-input">{{ t.searchTitle }}</label>\n+        <input id="news-search-input" name="q" type="search"\n+               autocomplete="off" enterkeyhint="search"\n+               placeholder="{{ t.searchPlaceholder }}"\n+               aria-controls="news-search-results">\n+        <button class="btn btn-primary" type="submit">{{ t.searchButton }}</button>\n+        <button id="news-search-clear" class="btn btn-secondary" type="button" hidden>{{ t.searchClear }}</button>\n+      </form>\n+      <p id="news-search-status" class="search-status text-muted"\n+         role="status" aria-live="polite">{{ t.searchLoading }}</p>\n+      <div id="news-search-results" class="search-results"></div>\n+      <noscript><p class="search-status">{{ t.searchError }}</p></noscript>\n+    </section>\n+  </main>\n+{% endblock %}\n+{% block body_scripts %}\n+<script src="{{ base }}/assets/search.js" defer></script>\n+{% endblock %}\n@@ -0,0 +1,92 @@\n+"""Offline regression checks for AVWIRE's static full-text news search."""\n+from __future__ import annotations\n+\n+import json\n+import sys\n+from pathlib import Path\n+\n+ROOT = Path(__file__).resolve().parent.parent\n+sys.path.insert(0, str(ROOT / "pipeline"))\n+\n+import build  # noqa: E402\n+\n+\n+def sample_article(title: str):\n+    return build.prep_article({\n+        "id": "a-search-alias-test",\n+        "publishedUtc": "2026-08-10T00:00:00Z",\n+        "cat": "ops",\n+        "primarySource": "Test source",\n+        "zh": {\n+            "title": title,\n+            "summary": "測試航空公司別名搜尋。",\n+            "body": ["航機抵達臺灣桃園國際機場。"],\n+        },\n+        "en": {\n+            "title": "Airline search alias test",\n+            "summary": "Testing airline aliases in search.",\n+            "body": ["The aircraft arrived at Taoyuan Airport."],\n+        },\n+        "sources": [{"name": "Test source", "url": "https://example.com/news"}],\n+    })\n+\n+\n+def main() -> None:\n+    aliases = build.load_search_alias_groups()\n+    assert len(aliases) >= 25\n+\n+    formal = build.search_index_item(sample_article("中華航空新增航班"), aliases)\n+    formal_search = formal["search"]\n+    assert "中華航空" in formal_search\n+    assert "華航" in formal_search\n+    assert "china airlines" in formal_search\n+    assert "桃園機場" in formal_search\n+    assert "taoyuan airport" in formal_search\n+    assert "rctp" in formal_search\n+    assert "tpe" in formal_search\n+\n+    short = build.search_index_item(sample_article("華航新增航班"), aliases)\n+    assert "中華航空" in short["search"]\n+    assert "china airlines" in short["search"]\n+\n+    # Short Latin aliases require token boundaries: ordinary words must not\n+    # accidentally activate an airline group.\n+    assert "中華航空" not in build.expand_search_aliases(\n+        "The local calendar was updated.", aliases)\n+\n+    assert build.main() == 0\n+    payload = json.loads(\n+        (ROOT / "site" / "search-index.json").read_text(encoding="utf-8"))\n+    assert payload["version"] == 1\n+    assert payload["count"] == len(payload["items"]) == len(build.collect_articles())\n+    assert payload["items"]\n+    assert all(row["search"] and row["url"] for row in payload["items"])\n+    assert any(\n+        "中華航空" in row["search"] and "華航" in row["search"]\n+        for row in payload["items"]\n+    )\n+\n+    zh = (ROOT / "site" / "search" / "index.html").read_text(encoding="utf-8")\n+    en = (ROOT / "site" / "en" / "search" / "index.html").read_text(\n+        encoding="utf-8")\n+    home = (ROOT / "site" / "index.html").read_text(encoding="utf-8")\n+    script = (ROOT / "static" / "search.js").read_text(encoding="utf-8")\n+\n+    assert 'id="news-search-input"' in zh\n+    assert 'data-index-url="/avwire/search-index.json"' in zh\n+    assert 'data-index-url="/avwire/search-index.json"' in en\n+    assert 'href="/avwire/search/"' in home\n+    assert 'href="/avwire/en/search/"' in en\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 "textContent" in script\n+    assert "replaceChildren" in script\n+    assert "innerHTML" not in script\n+    assert "URLSearchParams" not in script  # URL.searchParams is used directly\n+\n+    print("test_search: OK")\n+\n+\n+if __name__ == "__main__":\n+    main()

You found a secret theme!

8-bit mode is unlocked. Refresh to add it to your theme selector.