2026-10-20 TUE UTC 12:42 PM TPE 8:42 PM 每小時自動更新 中文 EN
LIVE 快訊
← 返回網站更新紀錄 Site Changelog

提交原始紀錄

add structured airline code search

SHA
56b58637d6c1be2ed8ea730eea952c47546aaaa4
提交時間
作者
nnneeeooo-nnneeeooo
提交者
nnneeeooo-nnneeeooo

變更檔案 (8)

  • config/airline_icao_codes.json modified · +2 / −1
  • config/search_aliases.json modified · +1 / −0
  • pipeline/CONTRACTS.md modified · +10 / −0
  • pipeline/build.py modified · +149 / −6
  • static/search.js modified · +107 / −21
  • templates/search.html modified · +2 / −0
  • tests/test_search.py modified · +47 / −7
  • tests/test_search_client.js added · +45 / −0

完整差異

@@ -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");

你發現了隱藏主題!

8-bit 主題已解鎖。重新整理後,就能在主題選單選擇 8-bit。