navigate.st: fuzzy search fallback + hide syskypo.de from suggestions

/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.
This commit is contained in:
Local Dev 2026-09-06 19:58:58 +02:00
parent e47413eeb5
commit a9f2974194

View file

@ -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/<q> -> { 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/<q> -> { 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));