完整差異
@@ -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");