{
  "sha": "e8e1f7b7800055d0fa3f466c022dc8a8db5fbdab",
  "message": "Fix suggested search queries returning no results (#182)",
  "author": {
    "login": "nnneeeooo-nnneeeooo",
    "name": "",
    "email": ""
  },
  "committer": {
    "login": "web-flow",
    "name": "",
    "email": ""
  },
  "created_at": "2026-09-12T01:25:28Z",
  "files": [
    {
      "filename": "static/app.js",
      "previous_filename": "",
      "status": "modified",
      "additions": 10,
      "deletions": 1,
      "changes": 11
    },
    {
      "filename": "static/search.js",
      "previous_filename": "",
      "status": "modified",
      "additions": 25,
      "deletions": 8,
      "changes": 33
    },
    {
      "filename": "tests/test_search.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 37,
      "deletions": 1,
      "changes": 38
    }
  ],
  "diff": "@@ -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 "
}
