完整差異
@@ -65,14 +65,23 @@\n } catch (e) { /* keep the server-rendered fallback */ }\n }\n if (headerSearchForm && headerSearchInput) {\n- headerSearchForm.addEventListener("submit", function () {\n+ headerSearchForm.addEventListener("submit", function (event) {\n if (headerSearchInput.value.trim()) return;\n var suggestedQuery = queryFromSearchPlaceholder(\n headerSearchInput.placeholder\n );\n if (!suggestedQuery) return;\n headerSearchInput.value = suggestedQuery;\n headerSearchInput.dispatchEvent(new Event("input", { bubbles: true }));\n+ if (document.getElementById("news-search-app")) return;\n+\n+ /* Build the navigation URL explicitly. Some browsers snapshot the\n+ form controls before this submit handler fills the suggestion, which\n+ otherwise opens the search page without q. */\n+ event.preventDefault();\n+ var searchUrl = new URL(headerSearchForm.action, window.location.href);\n+ searchUrl.searchParams.set(headerSearchInput.name || "q", suggestedQuery);\n+ window.location.assign(searchUrl.toString());\n });\n }\n if (headerSearchInput && headerSearchClear) {\n@@ -113,23 +113,37 @@\n return card;\n }\n \n- function scoreRecord(record, normalizedQuery, terms) {\n+ function scoreRecord(record, normalizedQuery, terms, minimumMatches) {\n var haystack = recordSearchText(record);\n- if (!terms.every(function (term) { return haystack.indexOf(term) !== -1; })) {\n+ var matchedTerms = terms.filter(function (term) {\n+ return haystack.indexOf(term) !== -1;\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- terms.forEach(function (term) {\n+ matchedTerms.forEach(function (term) {\n if (title.indexOf(term) !== -1) score += 20;\n if (summary.indexOf(term) !== -1) score += 6;\n });\n return score;\n }\n \n+ function matchingRecords(normalizedQuery, terms, minimumMatches) {\n+ return records.map(function (record) {\n+ return {\n+ record: record,\n+ score: scoreRecord(record, normalizedQuery, terms, minimumMatches)\n+ };\n+ }).filter(function (row) {\n+ return row.score >= 0;\n+ });\n+ }\n+\n function runSearch() {\n var query = input.value.trim();\n updateQueryUrl(query);\n@@ -148,11 +162,14 @@\n \n var normalizedQuery = normalize(query);\n var terms = normalizedQuery.split(/\s+/).filter(Boolean);\n- var matches = records.map(function (record) {\n- return { record: record, score: scoreRecord(record, normalizedQuery, terms) };\n- }).filter(function (row) {\n- return row.score >= 0;\n- }).sort(function (left, right) {\n+ var matches = matchingRecords(normalizedQuery, terms, terms.length);\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+ }\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@@ -4,6 +4,7 @@\n import json\n import re\n import sys\n+import unicodedata\n from pathlib import Path\n \n ROOT = Path(__file__).resolve().parent.parent\n@@ -32,6 +33,32 @@ def sample_article(title: str):\n })\n \n \n+def normalize_search_text(value: str) -> str:\n+ value = unicodedata.normalize("NFKC", str(value or "")).lower()\n+ return re.sub(r"[^\w]+", " ", value).strip()\n+\n+\n+def suggestion_has_result(prompt: str, items: list[dict]) -> bool:\n+ terms = normalize_search_text(prompt).split()\n+ minimum_matches = len(terms) if len(terms) < 3 else len(terms) - 1\n+ for item in items:\n+ values = [\n+ item.get("id", ""), item.get("source", ""),\n+ item.get("date", ""), item.get("published", ""),\n+ item.get("search", ""),\n+ ]\n+ for field in ("title", "summary", "category"):\n+ localized = item.get(field, {})\n+ if isinstance(localized, dict):\n+ values.extend((localized.get("zh", ""), localized.get("en", "")))\n+ else:\n+ values.append(localized)\n+ haystack = normalize_search_text(" ".join(values))\n+ if sum(term in haystack for term in terms) >= minimum_matches:\n+ return True\n+ return False\n+\n+\n def main() -> None:\n aliases = build.load_search_alias_groups()\n assert len(aliases) >= 25\n@@ -92,6 +119,10 @@ def main() -> None:\n assert zh_placeholders == list(build.header_search_placeholders("zh"))\n assert en_placeholders == list(build.header_search_placeholders("en"))\n assert len(zh_placeholders) == len(en_placeholders) == 6\n+ assert all(suggestion_has_result(prompt, payload["items"])\n+ for prompt in zh_placeholders + en_placeholders)\n+ assert suggestion_has_result(\n+ "亞馬遜空運 貨機 邁阿密 衝出跑道", payload["items"])\n assert zh.count('id="news-search-form"') == 1\n assert 'class="header-search-form"' in home\n assert 'class="header-search-form"' in article\n@@ -117,7 +148,8 @@ def main() -> None:\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 "terms.every" in script\n+ assert "function matchingRecords" in script\n+ assert "terms.length - 1" in script\n assert "function loadIndex()" in script\n assert "function recordSearchText" in script\n assert "if (input.value.trim()) runSearch();" in script\n@@ -145,6 +177,10 @@ def main() -> None:\n assert "Search|Explore|Find|Look\\s+up|Show\\s+me" in app_script\n assert "if (headerSearchInput.value.trim()) return" in app_script\n assert "headerSearchInput.value = suggestedQuery" in app_script\n+ assert 'document.getElementById("news-search-app")' in app_script\n+ assert "event.preventDefault()" in app_script\n+ assert 'searchUrl.searchParams.set(headerSearchInput.name || "q", suggestedQuery)' in app_script\n+ assert "window.location.assign(searchUrl.toString())" in app_script\n assert 'headerSearchInput.value = ""' in app_script\n assert 'new Event("input", { bubbles: true })' in app_script\n