{
  "sha": "4d92c552432409abb40e542e31a838fe8bbb4902",
  "message": "新增新聞別名搜尋 (#60)\n\n新增雙語全文搜尋頁、靜態新聞索引與可維護的航空公司及機場別名群組。",
  "author": {
    "login": "nnneeeooo-nnneeeooo",
    "name": "",
    "email": ""
  },
  "committer": {
    "login": "web-flow",
    "name": "",
    "email": ""
  },
  "created_at": "2026-08-09T21:28:21Z",
  "files": [
    {
      "filename": "config/search_aliases.json",
      "previous_filename": "",
      "status": "added",
      "additions": 36,
      "deletions": 0,
      "changes": 36
    },
    {
      "filename": "pipeline/build.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 142,
      "deletions": 4,
      "changes": 146
    },
    {
      "filename": "static/search.js",
      "previous_filename": "",
      "status": "added",
      "additions": 176,
      "deletions": 0,
      "changes": 176
    },
    {
      "filename": "static/site.css",
      "previous_filename": "",
      "status": "modified",
      "additions": 45,
      "deletions": 0,
      "changes": 45
    },
    {
      "filename": "templates/search.html",
      "previous_filename": "",
      "status": "added",
      "additions": 38,
      "deletions": 0,
      "changes": 38
    },
    {
      "filename": "tests/test_search.py",
      "previous_filename": "",
      "status": "added",
      "additions": 92,
      "deletions": 0,
      "changes": 92
    }
  ],
  "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()"
}
