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:
parent
e47413eeb5
commit
a9f2974194
1 changed files with 54 additions and 6 deletions
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue