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()