From a9f2974194650e4e73d8c47aeeb2cf58864aacf1 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 6 Sep 2026 19:58:58 +0200 Subject: [PATCH] navigate.st: fuzzy search fallback + hide syskypo.de from suggestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/search/ now catches typos with a bounded-Levenshtein pass against each label — max edit distance 1|2|3 depending on query length. Fuzzy hits are labeled `matchType:"fuzzy"` and always rank below real matches (scores 40|25|10 vs 100|70|50), so exact matches never get displaced by a near-miss. Client shows a "closest name we found" header when the whole result set is fuzzy, and a subtle amber "did you mean" badge per row. Verified: hlelo → hello.bch, silntmode → silentmode.{bch,x}, pouakidis → poutakidis.de, and silentmode still returns label-exact first with fuzzy hidden. syskypo.de is now excluded from /api/search/ results (still reachable via /api/name/ and /bns/…): it's a mirror of poutakidis-content and was cluttering suggestions. Swapped it out of the placeholder too. --- src/gateway/public-gateway.mjs | 60 ++++++++++++++++++++++++++++++---- 1 file changed, 54 insertions(+), 6 deletions(-) diff --git a/src/gateway/public-gateway.mjs b/src/gateway/public-gateway.mjs index c01dcc8..3b1861b 100644 --- a/src/gateway/public-gateway.mjs +++ b/src/gateway/public-gateway.mjs @@ -32,6 +32,8 @@ async function getIndex() { const MOUNT = process.env.BNS_MOUNT || "/bns"; const PORT = Number(process.env.BNS_PORT || 8700); +// Names hidden from /api/search/ results — direct navigation still works. +const HIDDEN_FROM_SEARCH = new Set(["syskypo.de"]); const S3_ENDPOINT = process.env.BNS_S3 || "http://127.0.0.1:8500"; const SUB_BASE = (process.env.BNS_SUB_BASE || "bch.navigate.st").toLowerCase(); // subdomain suffix const aws = new AwsClient({ @@ -393,9 +395,11 @@ const server = http.createServer(async (req, res) => { return; } - // --- unified search: label + content --- - // GET /api/search/ -> { query, count, results: [{ name, tld, matchType, score, snippet, records }] } - // Scores: label-exact (100) > label-substring (70) > h-record content (50). + // --- unified search: label + content + fuzzy fallback --- + // GET /api/search/ -> { query, count, results: [{ name, tld, matchType, score, snippet, dist?, records }] } + // Scores: label-exact (100) > label-substring (70) > h-record content (50) > fuzzy (40|25|10 for dist 1|2|3). + // Fuzzy is bounded Levenshtein against the label — catches typos like "hlelo" → "hello.bch" + // without polluting exact-match results (fuzzy hits only rank below every other match type). // Content search covers on-chain inline HTML only; Sia/ip sites await Helios v1. // Same cached index as /api/labels/ and /api/name/ — no extra fetch cost on the hot path. if (req.url.startsWith("/api/search/")) { @@ -407,6 +411,32 @@ const server = http.createServer(async (req, res) => { res.end(JSON.stringify({ error: "query must be 2-64 chars" })); return; } + // Bounded Levenshtein: early-exits once the running minimum row-value exceeds `max`. + // Returns `max + 1` for "too far to matter" — the caller ignores anything > max. + const boundedLev = (a, b, max) => { + if (Math.abs(a.length - b.length) > max) return max + 1; + if (a === b) return 0; + // Ensure a is the shorter for less memory. + if (a.length > b.length) { const t = a; a = b; b = t; } + let prev = new Array(a.length + 1); + for (let i = 0; i <= a.length; i++) prev[i] = i; + for (let j = 1; j <= b.length; j++) { + const curr = new Array(a.length + 1); + curr[0] = j; + let rowMin = curr[0]; + const bj = b.charCodeAt(j - 1); + for (let i = 1; i <= a.length; i++) { + const cost = a.charCodeAt(i - 1) === bj ? 0 : 1; + curr[i] = Math.min(prev[i] + 1, curr[i - 1] + 1, prev[i - 1] + cost); + if (curr[i] < rowMin) rowMin = curr[i]; + } + if (rowMin > max) return max + 1; // no path can recover below the threshold + prev = curr; + } + return prev[a.length]; + }; + // Typo budget scales with query length so short queries don't match everything. + const maxDist = q.length <= 3 ? 1 : q.length <= 6 ? 2 : 3; try { const idx = await getIndex(); const results = []; @@ -416,8 +446,13 @@ const server = http.createServer(async (req, res) => { const tld = dot < 0 ? "bch" : key.slice(dot + 1); const displayName = dot < 0 ? key + ".bch" : key; const records = entry?.records ?? {}; + // Names on chain but hidden from free-text search — direct lookup + // (/api/name/, /bns/…) still works so demos stay reachable, they + // just don't clutter the suggestion list. syskypo.de is a mirror + // of poutakidis-content, kept live but redundant in search. + if (HIDDEN_FROM_SEARCH.has(displayName)) continue; - let matchType = null, score = 0, snippet = null; + let matchType = null, score = 0, snippet = null, dist; if (label === q) { matchType = "label-exact"; score = 100; } else if (label.includes(q)) { matchType = "label-substring"; score = 70; } @@ -434,9 +469,22 @@ const server = http.createServer(async (req, res) => { } } + // Fuzzy fallback — only when nothing above matched, only against the label. + // Score decays sharply so a real substring match always outranks a 1-edit near-miss. + if (!matchType) { + const d = boundedLev(q, label, maxDist); + if (d <= maxDist) { + matchType = "fuzzy"; + dist = d; + score = d === 1 ? 40 : d === 2 ? 25 : 10; + } + } + if (matchType) { - results.push({ name: displayName, tld, matchType, score, snippet, - records: entry?.records ?? null, category: entry?.category ?? null }); + const row = { name: displayName, tld, matchType, score, snippet, + records: entry?.records ?? null, category: entry?.category ?? null }; + if (dist !== undefined) row.dist = dist; + results.push(row); } } results.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));