{
  "sha": "56b58637d6c1be2ed8ea730eea952c47546aaaa4",
  "message": "add structured airline code search",
  "author": {
    "login": "nnneeeooo-nnneeeooo",
    "name": "",
    "email": ""
  },
  "committer": {
    "login": "nnneeeooo-nnneeeooo",
    "name": "",
    "email": ""
  },
  "created_at": "2026-09-16T15:21:07Z",
  "files": [
    {
      "filename": "config/airline_icao_codes.json",
      "previous_filename": "",
      "status": "modified",
      "additions": 2,
      "deletions": 1,
      "changes": 3
    },
    {
      "filename": "config/search_aliases.json",
      "previous_filename": "",
      "status": "modified",
      "additions": 1,
      "deletions": 0,
      "changes": 1
    },
    {
      "filename": "pipeline/CONTRACTS.md",
      "previous_filename": "",
      "status": "modified",
      "additions": 10,
      "deletions": 0,
      "changes": 10
    },
    {
      "filename": "pipeline/build.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 149,
      "deletions": 6,
      "changes": 155
    },
    {
      "filename": "static/search.js",
      "previous_filename": "",
      "status": "modified",
      "additions": 107,
      "deletions": 21,
      "changes": 128
    },
    {
      "filename": "templates/search.html",
      "previous_filename": "",
      "status": "modified",
      "additions": 2,
      "deletions": 0,
      "changes": 2
    },
    {
      "filename": "tests/test_search.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 47,
      "deletions": 7,
      "changes": 54
    },
    {
      "filename": "tests/test_search_client.js",
      "previous_filename": "",
      "status": "added",
      "additions": 45,
      "deletions": 0,
      "changes": 45
    }
  ],
  "diff": "@@ -1,5 +1,5 @@\\n {\\n-  \"_comment\": \"ICAO 呼號前三碼 -> 航空公司與 IATA 兩碼對照表(程式端唯一辨識來源,模型不得自行猜測)。查不到的代碼保留 operator_icao、operator_name 為 null。legacy_iata_codes 僅記錄上游歷史/別名前綴，不代表現行售票代碼。source_note 記錄依據。\",\\n+  \"_comment\": \"ICAO 呼號前三碼 -> 航空公司與 IATA 兩碼對照表(程式端唯一辨識來源,模型不得自行猜測)。查不到的代碼保留 operator_icao、operator_name 為 null。legacy_iata_codes 僅記錄上游歷史/別名前綴，不代表現行售票代碼。search_aliases 僅收錄經審核的完整航空公司名稱，不得放入代碼或一般短詞。source_note 記錄依據。\",\\n   \"regions\": {\\n     \"TW\": {\"zh_tw\": \"臺灣\", \"en\": \"Taiwan\"},\\n     \"MO\": {\"zh_tw\": \"澳門\", \"en\": \"Macao\"},\\n@@ -78,6 +78,7 @@\\n     {\"icao_code\": \"UAE\", \"iata_code\": \"EK\", \"airline_name_en\": \"Emirates\", \"airline_name_zh_tw\": \"阿聯酋航空\", \"country_or_region\": \"AE\", \"active\": true, \"source_note\": \"ICAO 8585 / IATA designator\"},\\n     {\"icao_code\": \"ETD\", \"iata_code\": \"EY\", \"airline_name_en\": \"Etihad Airways\", \"airline_name_zh_tw\": \"阿提哈德航空\", \"country_or_region\": \"AE\", \"active\": true, \"source_note\": \"ICAO 8585 / IATA designator\"},\\n     {\"icao_code\": \"BAW\", \"iata_code\": \"BA\", \"airline_name_en\": \"British Airways\", \"airline_name_zh_tw\": \"英國航空\", \"country_or_region\": \"GB\", \"active\": true, \"source_note\": \"ICAO 8585 / IATA designator\"},\\n+    {\"icao_code\": \"CFG\", \"iata_code\": \"DE\", \"airline_name_en\": \"Condor\", \"airline_name_zh_tw\": \"德國神鷹航空\", \"country_or_region\": \"DE\", \"active\": true, \"search_aliases\": [\"Condor Flugdienst\", \"神鷹航空\"], \"source_note\": \"IATA airline member directory: Condor, IATA DE, ICAO CFG\"},\\n     {\"icao_code\": \"BOX\", \"iata_code\": \"3S\", \"airline_name_en\": \"AeroLogic\", \"airline_name_zh_tw\": \"AeroLogic\", \"country_or_region\": \"DE\", \"active\": true, \"source_note\": \"ICAO operator code / ADSBdb route identity\"},\\n     {\"icao_code\": \"DLH\", \"iata_code\": \"LH\", \"airline_name_en\": \"Lufthansa\", \"airline_name_zh_tw\": \"漢莎航空\", \"country_or_region\": \"DE\", \"active\": true, \"source_note\": \"ICAO 8585 / IATA designator\"},\\n     {\"icao_code\": \"AFR\", \"iata_code\": \"AF\", \"airline_name_en\": \"Air France\", \"airline_name_zh_tw\": \"法國航空\", \"country_or_region\": \"FR\", \"active\": true, \"source_note\": \"ICAO 8585 / IATA designator\"},\\n@@ -20,6 +20,7 @@\\n     {\"key\": \"lufthansa\", \"aliases\": [\"德國漢莎航空\", \"漢莎航空\", \"漢莎\", \"Lufthansa\"]},\\n     {\"key\": \"air-france\", \"aliases\": [\"法國航空\", \"法航\", \"Air France\"]},\\n     {\"key\": \"british-airways\", \"aliases\": [\"英國航空\", \"英航\", \"British Airways\"]},\\n+    {\"key\": \"condor\", \"aliases\": [\"德國神鷹航空\", \"神鷹航空\", \"Condor\", \"Condor Flugdienst\"]},\\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@@ -347,6 +347,16 @@ orphaned ticker entries are omitted as well; source JSON remains preserved.\\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+Version 2 also publishes a verified `airlineCodes` map and an `airlines` list\\n+of canonical ICAO identity keys on each record. Exact IATA/ICAO queries filter\\n+by those keys instead of treating short codes as ordinary substrings; an\\n+optional remaining phrase (for example `DE A320`) is then matched only within\\n+that carrier's articles. Membership comes from structured airline entities;\\n+only older rows without airline entity data fall back to an exact reviewed\\n+airline name in the headline. A translated label or passing body mention cannot\\n+override a present entity list. Legacy radar-only IATA aliases are intentionally not\\n+published to the news search index. Short Latin full-text terms of up to three\\n+characters require whole-token matches.\\n Daily search-box suggestions are also indexed as exact aliases of their linked\\n article, so every displayed suggestion resolves without weakening ordinary\\n full-text matching. Previously displayed suggestions stay searchable after the\\n@@ -63,7 +63,9 @@\\n if not isinstance(IMAGE_CAPTIONS, dict):\\n     IMAGE_CAPTIONS = {}\\n \\n-PUBLIC_DIR = Path(__file__).resolve().parent.parent / \"public\"\\n+PROJECT_DIR = Path(__file__).resolve().parent.parent\\n+PUBLIC_DIR = PROJECT_DIR / \"public\"\\n+CONFIG_DIR = PROJECT_DIR / \"config\"\\n \\n \\n def static_asset_version() -> str:\\n@@ -167,12 +169,14 @@ def static_asset_version() -> str:\\n             \"隨意搜尋想看的 SKYTICAL 內容\",\\n             \"今天想看什麼航空新聞？\",\\n         ],\\n-        \"searchSub\": \"搜尋標題、摘要、全文、來源、日期、航班、機型與機場代碼；航空公司正式名稱與常用簡稱可互相查找。\",\\n+        \"searchSub\": \"搜尋標題、摘要、全文、來源、日期、航班、機型、機場代碼與航空公司 IATA／ICAO 代碼；航空公司正式名稱與常用簡稱可互相查找。\",\\n         \"searchButton\": \"搜尋\", \"searchClear\": \"清除\",\\n         \"searchLoading\": \"正在載入新聞索引…\",\\n         \"searchPrompt\": \"輸入關鍵字開始搜尋全部新聞。\",\\n         \"searchCount\": \"找到 {count} 則新聞\",\\n         \"searchNoResults\": \"找不到符合「{query}」的新聞，請改用航空公司簡稱、機型、航班或機場代碼。\",\\n+        \"searchAirlineCodeCount\": \"航空公司代碼：{code}（{type}）· {airline}；找到 {count} 則新聞\",\\n+        \"searchAirlineCodeNoResults\": \"已辨識航空公司代碼：{code}（{type}）· {airline}，但目前沒有對應新聞。\",\\n         \"searchError\": \"新聞索引暫時無法載入，請稍後重新整理頁面。\",\\n         \"radarKicker\": \"Taiwan Flight Radar\",\\n         \"radarTitle\": \"台灣民航雷達\",\\n@@ -300,12 +304,14 @@ def static_asset_version() -> str:\\n             \"Explore anything on SKYTICAL\",\\n             \"What aviation news are you looking for?\",\\n         ],\\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+        \"searchSub\": \"Search titles, summaries, full text, sources, dates, flights, aircraft types, airport codes and airline IATA/ICAO codes. Official airline names and common short names are interchangeable.\",\\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+        \"searchAirlineCodeCount\": \"Airline code: {code} ({type}) · {airline}; {count} articles found\",\\n+        \"searchAirlineCodeNoResults\": \"Airline code recognised: {code} ({type}) · {airline}, but no matching stories are currently available.\",\\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@@ -1177,6 +1183,15 @@ def side(own, other):\\n     ]\\n     if not available_languages:\\n         available_languages = [\"zh\", \"en\"]\\n+    entities = raw.get(\"entities\") if isinstance(raw.get(\"entities\"), dict) else {}\\n+    raw_airlines = entities.get(\"airlines\")\\n+    airline_entities = []\\n+    for value in raw_airlines[:20] if isinstance(raw_airlines, list) else []:\\n+        clean = re.sub(r\"[\\x00-\\x1f\\x7f<>]+\", \" \", str(value))\\n+        clean = re.sub(r\"\\s+\", \" \", clean).strip()[:80]\\n+        if clean and normalize_search_text(clean) not in {\\n+                normalize_search_text(existing) for existing in airline_entities}:\\n+            airline_entities.append(clean)\\n     from image_selection import prepare_image, rejection_reason\\n     selected_image = prepare_image(raw, raw.get(\"image\"), IMAGE_CAPTIONS)\\n     if selected_image and rejection_reason(raw, selected_image, IMAGE_CAPTIONS):\\n@@ -1227,6 +1242,7 @@ def side(own, other):\\n         \"zh\": side(zh, en),\\n         \"en\": side(en, zh),\\n         \"sources\": sources,\\n+        \"airline_entities\": airline_entities,\\n         \"writer_model\": writer_model(\\n             raw.get(\"writer\"), raw.get(\"writerModels\")),\\n         \"available_languages\": available_languages,\\n@@ -1726,6 +1742,88 @@ def load_search_alias_groups() -> list[tuple[str, ...]]:\\n     return groups[:200]\\n \\n \\n+@lru_cache(maxsize=1)\\n+def load_airline_search_catalog() -> dict:\\n+    \"\"\"Load verified airline identities for structured IATA/ICAO search.\\n+\\n+    Codes are never treated as full-text aliases.  They resolve to one\\n+    canonical ICAO key, while article membership is inferred only from\\n+    source-grounded airline entities or complete reviewed airline names.\\n+    Duplicate codes and ambiguous names fail closed.\\n+    \"\"\"\\n+    raw = load_json(CONFIG_DIR / \"airline_icao_codes.json\", {})\\n+    rows = raw.get(\"airlines\") if isinstance(raw, dict) else None\\n+    identities = {}\\n+    alias_groups = load_search_alias_groups()\\n+    for row in rows[:500] if isinstance(rows, list) else []:\\n+        if not isinstance(row, dict) or row.get(\"active\") is not True:\\n+            continue\\n+        icao = str(row.get(\"icao_code\") or \"\").strip().upper()\\n+        iata = str(row.get(\"iata_code\") or \"\").strip().upper()\\n+        zh = re.sub(r\"\\s+\", \" \", str(\\n+            row.get(\"airline_name_zh_tw\") or \"\")).strip()[:80]\\n+        en = re.sub(r\"\\s+\", \" \", str(\\n+            row.get(\"airline_name_en\") or \"\")).strip()[:80]\\n+        if not re.fullmatch(r\"[A-Z]{3}\", icao) \\\\n+                or (iata and not re.fullmatch(r\"[A-Z0-9]{2}\", iata)) \\\\n+                or not zh or not en or icao in identities:\\n+            continue\\n+\\n+        aliases = [zh, en]\\n+        configured = row.get(\"search_aliases\")\\n+        if isinstance(configured, list):\\n+            aliases.extend(str(value) for value in configured[:20])\\n+        official = {normalize_search_text(zh), normalize_search_text(en)}\\n+        for group in alias_groups:\\n+            if official & {normalize_search_text(value) for value in group}:\\n+                aliases.extend(group)\\n+\\n+        clean_aliases, seen = [], set()\\n+        for value in aliases:\\n+            alias = re.sub(r\"[\\x00-\\x1f\\x7f<>]+\", \" \", str(value))\\n+            alias = re.sub(r\"\\s+\", \" \", alias).strip()[:80]\\n+            normalized = normalize_search_text(alias)\\n+            # Never infer article membership from a short Latin token such as\\n+            # \"IT\" or \"ANA\".  Codes are resolved separately, and structured\\n+            # article entities may still match an exact short brand safely.\\n+            unsafe_short_latin = re.fullmatch(r\"[a-z0-9]{1,3}\", normalized)\\n+            if alias and normalized and not unsafe_short_latin \\\\n+                    and normalized not in seen:\\n+                seen.add(normalized)\\n+                clean_aliases.append(alias)\\n+        identities[icao] = {\\n+            \"iata\": iata,\\n+            \"icao\": icao,\\n+            \"zh\": zh,\\n+            \"en\": en,\\n+            \"aliases\": tuple(clean_aliases),\\n+        }\\n+\\n+    def unique_map(field: str) -> dict[str, str]:\\n+        owners = {}\\n+        for key, identity in identities.items():\\n+            owners.setdefault(identity[field], []).append(key)\\n+        return {\\n+            code: keys[0] for code, keys in owners.items()\\n+            if code and len(keys) == 1\\n+        }\\n+\\n+    alias_owners = {}\\n+    for key, identity in identities.items():\\n+        for alias in identity[\"aliases\"]:\\n+            alias_owners.setdefault(normalize_search_text(alias), []).append(key)\\n+    alias_to_key = {\\n+        alias: keys[0] for alias, keys in alias_owners.items()\\n+        if alias and len(set(keys)) == 1\\n+    }\\n+    return {\\n+        \"identities\": identities,\\n+        \"iata\": unique_map(\"iata\"),\\n+        \"icao\": unique_map(\"icao\"),\\n+        \"alias_to_key\": alias_to_key,\\n+    }\\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@@ -1748,7 +1846,36 @@ def expand_search_aliases(text: str, groups=None) -> list[str]:\\n     return matched\\n \\n \\n-def search_index_item(article, alias_groups, prompt_aliases=()) -> dict:\\n+def article_airline_keys(article: dict, catalog=None) -> list[str]:\\n+    \"\"\"Return verified airline identity keys represented by one article.\"\"\"\\n+    catalog = catalog or load_airline_search_catalog()\\n+    identities = catalog.get(\"identities\", {})\\n+    alias_to_key = catalog.get(\"alias_to_key\", {})\\n+    matched = set()\\n+    entity_values = article.get(\"airline_entities\") or []\\n+    for value in entity_values:\\n+        key = alias_to_key.get(normalize_search_text(value))\\n+        if key:\\n+            matched.add(key)\\n+\\n+    # Trust a present structured entity list over translated headline wording.\\n+    # Titles are only the conservative fallback for older rows with no airline\\n+    # entity data.  A passing reference in the body is never enough.\\n+    if entity_values:\\n+        return sorted(matched)\\n+    raw_text = \" \".join([\\n+        article[\"zh\"][\"title\"], article[\"en\"][\"title\"],\\n+    ])\\n+    normalized_text = normalize_search_text(raw_text)\\n+    for key, identity in identities.items():\\n+        if any(_search_text_has_alias(normalized_text, alias)\\n+               for alias in identity[\"aliases\"]):\\n+            matched.add(key)\\n+    return sorted(matched)\\n+\\n+\\n+def search_index_item(article, alias_groups, prompt_aliases=(),\\n+                      airline_catalog=None) -> dict:\\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@@ -1794,6 +1921,7 @@ def search_index_item(article, alias_groups, prompt_aliases=()) -> dict:\\n         \"published\": article[\"dt\"].isoformat(),\\n         \"articleFormat\": article[\"article_format\"],\\n         \"availableLanguages\": article[\"available_languages\"],\\n+        \"airlines\": article_airline_keys(article, airline_catalog),\\n         \"url\": urls,\\n         \"search\": normalize_search_text(\" \".join(\\n             str(value) for value in searchable if value)),\\n@@ -1802,18 +1930,33 @@ def search_index_item(article, alias_groups, prompt_aliases=()) -> dict:\\n \\n def search_index_payload(articles, generated_utc) -> dict:\\n     alias_groups = load_search_alias_groups()\\n+    airline_catalog = load_airline_search_catalog()\\n     article_ids = {article[\"id\"] for article in articles}\\n     prompt_aliases = {}\\n     for article_id, zh_prompt, en_prompt in search_prompt_alias_rows():\\n         if article_id not in article_ids:\\n             continue\\n         prompt_aliases.setdefault(article_id, []).extend((zh_prompt, en_prompt))\\n     return {\\n-        \"version\": 1,\\n+        \"version\": 2,\\n         \"generatedUtc\": generated_utc.isoformat(),\\n         \"count\": len(articles),\\n+        \"airlineCodes\": {\\n+            \"iata\": airline_catalog[\"iata\"],\\n+            \"icao\": airline_catalog[\"icao\"],\\n+            \"labels\": {\\n+                key: {\\n+                    \"iata\": identity[\"iata\"],\\n+                    \"icao\": identity[\"icao\"],\\n+                    \"zh\": identity[\"zh\"],\\n+                    \"en\": identity[\"en\"],\\n+                }\\n+                for key, identity in airline_catalog[\"identities\"].items()\\n+            },\\n+        },\\n         \"items\": [search_index_item(\\n-            article, alias_groups, prompt_aliases.get(article[\"id\"], ()))\\n+            article, alias_groups, prompt_aliases.get(article[\"id\"], ()),\\n+            airline_catalog)\\n                   for article in articles],\\n     }\\n \\n@@ -3,7 +3,68 @@\\n (function () {\\n   \"use strict\";\\n \\n-  var root = document.getElementById(\"news-search-app\");\\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 termMatches(haystack, term) {\\n+    if (/^[a-z0-9]{1,3}$/.test(term)) {\\n+      return haystack.split(/\\s+/).indexOf(term) !== -1;\\n+    }\\n+    return haystack.indexOf(term) !== -1;\\n+  }\\n+\\n+  function parseAirlineCodeQuery(query, codeIndex) {\\n+    var raw = String(query || \"\").trim();\\n+    var explicit = /^(iata|icao|airline)\\s*:\\s*([A-Za-z0-9]{2,3})(?:\\s+(.+))?$/i.exec(raw);\\n+    var automatic = explicit ? null : /^([A-Za-z0-9]{2,3})$/.exec(raw);\\n+    var combined = explicit || automatic ? null : /^([A-Z0-9]{2,3})\\s+(.+)$/.exec(raw);\\n+    var requestedType = explicit ? explicit[1].toLowerCase() : \"airline\";\\n+    var code = String(\\n+      explicit ? explicit[2] : automatic ? automatic[1] : combined ? combined[1] : \"\"\\n+    ).toUpperCase();\\n+    var remainder = String(\\n+      explicit ? explicit[3] || \"\" : combined ? combined[2] || \"\" : \"\"\\n+    ).trim();\\n+    if (!code) return null;\\n+\\n+    var source = codeIndex && typeof codeIndex === \"object\" ? codeIndex : {};\\n+    var iata = source.iata && typeof source.iata === \"object\" ? source.iata : {};\\n+    var icao = source.icao && typeof source.icao === \"object\" ? source.icao : {};\\n+    var labels = source.labels && typeof source.labels === \"object\" ? source.labels : {};\\n+    var key = \"\";\\n+    var type = \"\";\\n+    if (requestedType === \"iata\") {\\n+      key = iata[code] || \"\";\\n+      type = \"IATA\";\\n+    } else if (requestedType === \"icao\") {\\n+      key = icao[code] || \"\";\\n+      type = \"ICAO\";\\n+    } else if (iata[code]) {\\n+      key = iata[code];\\n+      type = \"IATA\";\\n+    } else if (icao[code]) {\\n+      key = icao[code];\\n+      type = \"ICAO\";\\n+    }\\n+    if (!key || !labels[key] || typeof labels[key] !== \"object\") return null;\\n+    return { key: key, code: code, type: type, remainder: remainder, label: labels[key] };\\n+  }\\n+\\n+  if (typeof module !== \"undefined\" && module.exports) {\\n+    module.exports = {\\n+      normalize: normalize,\\n+      termMatches: termMatches,\\n+      parseAirlineCodeQuery: parseAirlineCodeQuery\\n+    };\\n+  }\\n+\\n+  var root = typeof document === \"undefined\" ? null :\\n+    document.getElementById(\"news-search-app\");\\n   if (!root) return;\\n \\n   var form = document.getElementById(\"news-search-form\");\\n@@ -13,18 +74,11 @@\\n   var lang = root.dataset.lang === \"en\" ? \"en\" : \"zh\";\\n   var otherLang = lang === \"zh\" ? \"en\" : \"zh\";\\n   var records = [];\\n+  var airlineCodes = { iata: {}, icao: {}, labels: {} };\\n   var ready = false;\\n   var loading = null;\\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@@ -116,25 +170,29 @@\\n   function scoreRecord(record, normalizedQuery, terms, minimumMatches) {\\n     var haystack = recordSearchText(record);\\n     var matchedTerms = terms.filter(function (term) {\\n-      return haystack.indexOf(term) !== -1;\\n+      return termMatches(haystack, term);\\n     });\\n     if (matchedTerms.length < minimumMatches) {\\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+    if (normalizedQuery && title === normalizedQuery) score += 200;\\n+    else if (normalizedQuery && title.indexOf(normalizedQuery) !== -1) score += 100;\\n     matchedTerms.forEach(function (term) {\\n-      if (title.indexOf(term) !== -1) score += 20;\\n-      if (summary.indexOf(term) !== -1) score += 6;\\n+      if (termMatches(title, term)) score += 20;\\n+      if (termMatches(summary, term)) score += 6;\\n     });\\n     return score;\\n   }\\n \\n-  function matchingRecords(normalizedQuery, terms, minimumMatches) {\\n+  function matchingRecords(normalizedQuery, terms, minimumMatches, airlineKey) {\\n     return records.map(function (record) {\\n+      var airlines = Array.isArray(record.airlines) ? record.airlines : [];\\n+      if (airlineKey && airlines.indexOf(airlineKey) === -1) {\\n+        return { record: record, score: -1 };\\n+      }\\n       return {\\n         record: record,\\n         score: scoreRecord(record, normalizedQuery, terms, minimumMatches)\\n@@ -160,32 +218,57 @@\\n       return;\\n     }\\n \\n-    var normalizedQuery = normalize(query);\\n+    var airlineQuery = parseAirlineCodeQuery(query, airlineCodes);\\n+    var textQuery = airlineQuery ? airlineQuery.remainder : query;\\n+    var normalizedQuery = normalize(textQuery);\\n     var terms = normalizedQuery.split(/\\s+/).filter(Boolean);\\n-    var matches = matchingRecords(normalizedQuery, terms, terms.length);\\n+    var airlineKey = airlineQuery ? airlineQuery.key : \"\";\\n+    var matches = matchingRecords(\\n+      normalizedQuery, terms, terms.length, airlineKey);\\n     if (!matches.length && terms.length >= 3) {\\n       /* Daily suggestions may paraphrase one phrase from the source title.\\n          Keep normal searches strict, then allow one unmatched term only when\\n          the strict pass produced nothing. */\\n-      matches = matchingRecords(normalizedQuery, terms, terms.length - 1);\\n+      matches = matchingRecords(\\n+        normalizedQuery, terms, terms.length - 1, airlineKey);\\n     }\\n     matches.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+      if (airlineQuery) {\\n+        status.textContent = airlineStatus(\\n+          root.dataset.airlineCodeEmptyTemplate, airlineQuery, 0);\\n+      } else {\\n+        status.textContent = root.dataset.emptyTemplate.replace(\"{query}\", query);\\n+      }\\n       return;\\n     }\\n-    status.textContent = root.dataset.countTemplate.replace(\"{count}\", String(matches.length));\\n+    status.textContent = airlineQuery ? airlineStatus(\\n+      root.dataset.airlineCodeCountTemplate, airlineQuery, matches.length) :\\n+      root.dataset.countTemplate.replace(\"{count}\", String(matches.length));\\n+    var highlightQuery = airlineQuery ? (\\n+      airlineQuery.remainder || airlineQuery.label[lang] ||\\n+      airlineQuery.label[otherLang] || \"\") : query;\\n     var fragment = document.createDocumentFragment();\\n     matches.forEach(function (row) {\\n-      fragment.appendChild(resultCard(row.record, query));\\n+      fragment.appendChild(resultCard(row.record, highlightQuery));\\n     });\\n     results.appendChild(fragment);\\n   }\\n \\n+  function airlineStatus(template, airlineQuery, count) {\\n+    var label = airlineQuery.label[lang] ||\\n+      airlineQuery.label[otherLang] || airlineQuery.key;\\n+    return String(template || \"\")\\n+      .replace(\"{code}\", airlineQuery.code)\\n+      .replace(\"{type}\", airlineQuery.type)\\n+      .replace(\"{airline}\", label)\\n+      .replace(\"{count}\", String(count));\\n+  }\\n+\\n   form.addEventListener(\"submit\", function (event) {\\n     event.preventDefault();\\n     window.clearTimeout(debounceTimer);\\n@@ -218,6 +301,9 @@\\n       })\\n       .then(function (payload) {\\n         records = Array.isArray(payload.items) ? payload.items : [];\\n+        airlineCodes = payload.airlineCodes &&\\n+          typeof payload.airlineCodes === \"object\" ? payload.airlineCodes :\\n+          { iata: {}, icao: {}, labels: {} };\\n         ready = true;\\n       })\\n       .catch(function (error) {\\n@@ -14,6 +14,8 @@ <h1>{{ t.searchTitle }}</h1>\\n              data-prompt=\"{{ t.searchPrompt }}\"\\n              data-count-template=\"{{ t.searchCount }}\"\\n              data-empty-template=\"{{ t.searchNoResults }}\"\\n+             data-airline-code-count-template=\"{{ t.searchAirlineCodeCount }}\"\\n+             data-airline-code-empty-template=\"{{ t.searchAirlineCodeNoResults }}\"\\n              data-error=\"{{ t.searchError }}\"\\n              data-brief-label=\"{{ t.briefLabel }}\"\\n              data-roundup-label=\"{{ t.roundupLabel }}\"\\n@@ -14,7 +14,8 @@\\n import build  # noqa: E402\\n \\n \\n-def sample_article(title: str):\\n+def sample_article(title: str, *, en_title: str = \"Airline search alias test\",\\n+                   airlines=()):\\n     return build.prep_article({\\n         \"id\": \"a-search-alias-test\",\\n         \"publishedUtc\": \"2026-08-10T00:00:00Z\",\\n@@ -26,10 +27,11 @@ def sample_article(title: str):\\n             \"body\": [\"航機抵達臺灣桃園國際機場。\"],\\n         },\\n         \"en\": {\\n-            \"title\": \"Airline search alias test\",\\n+            \"title\": en_title,\\n             \"summary\": \"Testing airline aliases in search.\",\\n             \"body\": [\"The aircraft arrived at Taoyuan Airport.\"],\\n         },\\n+        \"entities\": {\"airlines\": list(airlines)},\\n         \"sources\": [{\"name\": \"Test source\", \"url\": \"https://example.com/news\"}],\\n     })\\n \\n@@ -67,6 +69,26 @@ def matches(item: dict, required: int) -> bool:\\n def main() -> None:\\n     aliases = build.load_search_alias_groups()\\n     assert len(aliases) >= 25\\n+    catalog = build.load_airline_search_catalog()\\n+    assert catalog[\"iata\"][\"DE\"] == \"CFG\"\\n+    assert catalog[\"icao\"][\"CFG\"] == \"CFG\"\\n+    assert catalog[\"icao\"][\"ASV\"] == \"ASV\"\\n+    assert \"ASV\" in catalog[\"identities\"]\\n+    assert \"9S\" not in catalog[\"iata\"]\\n+\\n+    condor = build.search_index_item(sample_article(\\n+        \"Condor A320因宵禁被拒降法蘭克福\",\\n+        en_title=\"Condor A320 denied landing at Frankfurt\",\\n+        airlines=(\"Condor\",),\\n+    ), aliases, airline_catalog=catalog)\\n+    etihad = build.search_index_item(sample_article(\\n+        \"阿提哈德航空推出Beyond Borders全新客艙體驗\",\\n+        en_title=\"Etihad Airways unveils Beyond Borders cabin experience\",\\n+        airlines=(\"Etihad Airways\",),\\n+    ), aliases, airline_catalog=catalog)\\n+    assert condor[\"airlines\"] == [\"CFG\"]\\n+    assert \"CFG\" not in etihad[\"airlines\"]\\n+    assert \"ETD\" in etihad[\"airlines\"]\\n \\n     formal = build.search_index_item(sample_article(\"中華航空新增航班\"), aliases)\\n     formal_search = formal[\"search\"]\\n@@ -99,10 +121,24 @@ def main() -> None:\\n         (ROOT / \"site\" / \"search-index.json\").read_text(encoding=\"utf-8\"))\\n     alias_payload = json.loads(\\n         (ROOT / \"site\" / \"search-aliases.json\").read_text(encoding=\"utf-8\"))\\n-    assert payload[\"version\"] == 1\\n+    assert payload[\"version\"] == 2\\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 payload[\"airlineCodes\"][\"iata\"][\"DE\"] == \"CFG\"\\n+    assert payload[\"airlineCodes\"][\"icao\"][\"CFG\"] == \"CFG\"\\n+    assert payload[\"airlineCodes\"][\"icao\"][\"ASV\"] == \"ASV\"\\n+    assert \"9S\" not in payload[\"airlineCodes\"][\"iata\"]\\n+    items_by_id = {row[\"id\"]: row for row in payload[\"items\"]}\\n+    live_condor = items_by_id[\\n+        \"a-20260915-0811-simple-flying-reports-condor-a320-missed\"]\\n+    beyond_borders = items_by_id[\\n+        \"a-20260916-0014-etihad-airways-unveils-beyond-borders-ca\"]\\n+    assert \"CFG\" in live_condor[\"airlines\"]\\n+    assert \"CFG\" not in beyond_borders[\"airlines\"]\\n+    assert \"ETD\" in beyond_borders[\"airlines\"]\\n+    assert [row[\"id\"] for row in payload[\"items\"]\\n+            if \"CFG\" in row[\"airlines\"]] == [live_condor[\"id\"]]\\n     assert any(\\n         \"中華航空\" in row[\"search\"] and \"華航\" in row[\"search\"]\\n         for row in payload[\"items\"]\\n@@ -136,7 +172,6 @@ def main() -> None:\\n                for prompt in zh_placeholders + en_placeholders)\\n     prompt_rows = build.daily_search_prompt_rows()\\n     assert len(prompt_rows) == len(zh_placeholders) == len(en_placeholders)\\n-    items_by_id = {row[\"id\"]: row for row in payload[\"items\"]}\\n     for article_id, zh_prompt, en_prompt in prompt_rows:\\n         assert article_id in items_by_id\\n         indexed = items_by_id[article_id][\"search\"]\\n@@ -183,9 +218,14 @@ def main() -> None:\\n     assert \".header-search-form:focus-within\" in css\\n     assert \".header-search-input:not(:placeholder-shown) + .header-search-clear\" in css\\n     assert \".header-search-submit\" in css\\n-    assert \"航空公司正式名稱與常用簡稱可互相查找\" in zh\\n-    assert \"Official airline names and common short names are interchangeable\" in en\\n+    assert \"航空公司 IATA／ICAO 代碼\" in zh\\n+    assert \"airline IATA/ICAO codes\" in en\\n+    assert \"data-airline-code-count-template\" in zh\\n+    assert \"data-airline-code-empty-template\" in en\\n     assert \"function matchingRecords\" in script\\n+    assert \"function parseAirlineCodeQuery\" in script\\n+    assert \"function termMatches\" in script\\n+    assert \"record.airlines\" in script\\n     assert \"terms.length - 1\" in script\\n     assert \"function loadIndex()\" in script\\n     assert \"function recordSearchText\" in script\\n@@ -195,7 +235,7 @@ def main() -> None:\\n     assert \"innerHTML\" not in script\\n     assert \"URLSearchParams\" not in script  # URL.searchParams is used directly\\n     assert 'url.searchParams.set(\"highlight\", highlightQuery)' in script\\n-    assert \"resultCard(row.record, query)\" in script\\n+    assert \"resultCard(row.record, highlightQuery)\" in script\\n     assert 'searchParams.get(\"highlight\")' in highlight_script\\n     assert \"payload.groups\" in highlight_script\\n     assert 'mark.className = \"search-hit\"' in highlight_script\\n@@ -0,0 +1,45 @@\\n+\"use strict\";\\n+\\n+const assert = require(\"assert\");\\n+const search = require(\"../static/search.js\");\\n+\\n+const codes = {\\n+  iata: { DE: \"CFG\", CI: \"CAL\", IT: \"TTW\" },\\n+  icao: { CFG: \"CFG\", CAL: \"CAL\", TTW: \"TTW\", ASV: \"ASV\" },\\n+  labels: {\\n+    CFG: { iata: \"DE\", icao: \"CFG\", zh: \"德國神鷹航空\", en: \"Condor\" },\\n+    CAL: { iata: \"CI\", icao: \"CAL\", zh: \"中華航空\", en: \"China Airlines\" },\\n+    TTW: { iata: \"IT\", icao: \"TTW\", zh: \"台灣虎航\", en: \"Tigerair Taiwan\" },\\n+    ASV: { iata: \"\", icao: \"ASV\", zh: \"Astravia\", en: \"Astravia\" }\\n+  }\\n+};\\n+\\n+function parsed(query) {\\n+  return search.parseAirlineCodeQuery(query, codes);\\n+}\\n+\\n+assert.deepStrictEqual(\\n+  { key: parsed(\"DE\").key, type: parsed(\"DE\").type, remainder: parsed(\"DE\").remainder },\\n+  { key: \"CFG\", type: \"IATA\", remainder: \"\" }\\n+);\\n+assert.strictEqual(parsed(\"de\").key, \"CFG\");\\n+assert.strictEqual(parsed(\"CFG\").key, \"CFG\");\\n+assert.strictEqual(parsed(\"CFG\").type, \"ICAO\");\\n+assert.strictEqual(parsed(\"iata:de\").key, \"CFG\");\\n+assert.strictEqual(parsed(\"icao:cfg\").key, \"CFG\");\\n+assert.strictEqual(parsed(\"airline:DE A320\").remainder, \"A320\");\\n+assert.strictEqual(parsed(\"DE A320\").key, \"CFG\");\\n+assert.strictEqual(parsed(\"DE A320\").remainder, \"A320\");\\n+assert.strictEqual(parsed(\"de A320\"), null);\\n+assert.strictEqual(parsed(\"9S\"), null);\\n+assert.strictEqual(parsed(\"ZZ\"), null);\\n+assert.strictEqual(parsed(\"ASV\").type, \"ICAO\");\\n+\\n+const beyond = search.normalize(\"Etihad launches Beyond Borders cabins\");\\n+assert.strictEqual(search.termMatches(beyond, \"de\"), false);\\n+assert.strictEqual(search.termMatches(search.normalize(\"DE A320\"), \"de\"), true);\\n+assert.strictEqual(search.termMatches(search.normalize(\"waiting for passengers\"), \"it\"), false);\\n+assert.strictEqual(search.termMatches(search.normalize(\"IT flight 216\"), \"it\"), true);\\n+assert.strictEqual(search.termMatches(beyond, \"beyond\"), true);\\n+\\n+console.log(\"test_search_client: OK\");"
}
