feat: site search with a dialog in the header and a /api/busca endpoint
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d8323c653b
commit
7d0edf13c4
7 files changed
+1115
-5
No files matched your search
@@ -0,0 +1,391 @@
|
||||
import { TOPICOS, normalizar } from "./searchTopics.js";
|
||||
|
||||
/**
|
||||
* Motor da busca inteligente (puro: sem React nem DOM).
|
||||
*
|
||||
* Em vez de só casar texto, ele INTERPRETA a consulta:
|
||||
* "flash s3" → chip = ESP32-S3, assunto = Flash
|
||||
* e então monta respostas diretas: o que a série diz sobre aquele assunto,
|
||||
* quais pinos do diagrama se relacionam a ele (com link já filtrado) e, por
|
||||
* fim, os resultados de texto de sempre (glossário, erros, componentes...).
|
||||
*/
|
||||
|
||||
const STOP = new Set([
|
||||
"de", "do", "da", "dos", "das", "o", "a", "os", "as", "um", "uma", "no", "na", "nos", "nas", "em",
|
||||
"para", "pra", "pro", "com", "sem", "que", "qual", "quais", "como", "e", "ou", "ver", "sobre", "eh",
|
||||
"preciso", "quero", "onde", "fica", "ficam", "esta", "estao", "meu", "minha", "usar", "uso",
|
||||
]);
|
||||
const INTENCAO_PINOS = new Set(["pino", "pinos", "pinagem", "pinout", "pin", "pins", "gpios"]);
|
||||
const INTENCAO_DADOS = new Set([
|
||||
"dados", "especificacao", "especificacoes", "spec", "specs", "informacao", "informacoes", "info",
|
||||
"quanto", "quantos", "quantas", "capacidade", "limite", "limites", "tem", "possui", "suporta",
|
||||
]);
|
||||
|
||||
const EXEMPLOS_TOPICOS = ["flash", "psram", "usb", "adc", "i2c", "deep sleep", "wifi", "strapping"];
|
||||
|
||||
/** Distância de edição com corte: devolve > max assim que passa do limite. */
|
||||
function distancia(a, b, max) {
|
||||
if (Math.abs(a.length - b.length) > max) return max + 1;
|
||||
let anterior = Array.from({ length: b.length + 1 }, (_, i) => i);
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
const atual = [i];
|
||||
let minimo = i;
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
const v = Math.min(anterior[j] + 1, atual[j - 1] + 1, anterior[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
||||
atual[j] = v;
|
||||
if (v < minimo) minimo = v;
|
||||
}
|
||||
if (minimo > max) return max + 1;
|
||||
anterior = atual;
|
||||
}
|
||||
return anterior[b.length];
|
||||
}
|
||||
|
||||
const palavrasDe = (texto) => texto.split(/[^a-z0-9]+/).filter((w) => w.length > 1);
|
||||
|
||||
/** Normaliza o payload do servidor uma única vez, na abertura da busca. */
|
||||
export function preparar(dados) {
|
||||
if (!dados) return { itens: [], series: {}, porFuncao: new Map(), aliases: new Map(), chaves: [] };
|
||||
|
||||
const itensRaw = Array.isArray(dados) ? dados : (dados.itens ?? []);
|
||||
const seriesRaw = Array.isArray(dados) ? {} : (dados.series ?? {});
|
||||
|
||||
const itens = itensRaw.map((d) => {
|
||||
const _t = normalizar(d.titulo ?? "");
|
||||
const _d = normalizar(d.descricao ?? "");
|
||||
const _k = normalizar(d.keywords ?? "");
|
||||
return { ...d, _t, _d, _k, _all: `${_t} ${_k} ${_d}`, _w: new Set(palavrasDe(`${_t} ${_k}`)) };
|
||||
});
|
||||
|
||||
const series = {};
|
||||
const porFuncao = new Map(); // "gpio26" | "mtck" -> [{ chave, pino }]
|
||||
const aliases = new Map();
|
||||
|
||||
for (const [chave, s] of Object.entries(seriesRaw)) {
|
||||
const pinos = (s.pinos ?? []).map((p) => {
|
||||
const fs = (p.f ?? []).map(normalizar);
|
||||
const avs = (p.a ?? []).map(normalizar);
|
||||
return {
|
||||
...p,
|
||||
_fs: fs,
|
||||
_fn: fs,
|
||||
_avs: avs,
|
||||
_nome: normalizar(p.nome ?? ""),
|
||||
_blob: normalizar([p.nome ?? "", ...(p.f ?? []), ...(p.c ?? []), ...(p.a ?? [])].join(" ")),
|
||||
};
|
||||
});
|
||||
series[chave] = { ...s, pinos };
|
||||
|
||||
for (const p of pinos) {
|
||||
for (const f of new Set([p._nome, ...p._fs])) {
|
||||
if (!/^[a-z0-9_]{3,}$/.test(f)) continue;
|
||||
if (!porFuncao.has(f)) porFuncao.set(f, []);
|
||||
porFuncao.get(f).push({ chave, pino: p });
|
||||
}
|
||||
}
|
||||
|
||||
const k = normalizar(chave);
|
||||
aliases.set(k, chave);
|
||||
aliases.set(k.replace("-", ""), chave);
|
||||
if (k.startsWith("esp32-")) aliases.set(k.slice(6), chave);
|
||||
}
|
||||
|
||||
return { itens, series, porFuncao, aliases, chaves: Object.keys(series) };
|
||||
}
|
||||
|
||||
/** Tenta casar um termo do dicionário com os tokens livres. Devolve { idx[], qualidade } ou null. */
|
||||
function casarTermo(termo, livres) {
|
||||
const partes = termo.split(" ");
|
||||
if (partes.length > 1) {
|
||||
for (let i = 0; i + partes.length <= livres.length; i++) {
|
||||
if (partes.every((p, j) => livres[i + j].t === p)) {
|
||||
return { idx: partes.map((_, j) => livres[i + j].i), qualidade: 3 + partes.length };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
for (const { t, i } of livres) {
|
||||
if (t === termo) return { idx: [i], qualidade: 3 };
|
||||
if (t === `${termo}s` || `${t}s` === termo) return { idx: [i], qualidade: 2.5 };
|
||||
if (termo.length >= 5 && t.length >= 5 && distancia(t, termo, 1) <= 1) return { idx: [i], qualidade: 1 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function interpretar(indice, consulta) {
|
||||
const bruto = normalizar(consulta)
|
||||
.replace(/gpio\s*-?\s*(\d+)/g, "gpio$1")
|
||||
.replace(/[^a-z0-9.+\-_ ]/g, " ");
|
||||
const tokens = bruto.split(/\s+/).filter(Boolean);
|
||||
const usados = new Set();
|
||||
|
||||
// 1) Chips
|
||||
const chips = [];
|
||||
tokens.forEach((t, i) => {
|
||||
const chave = indice.aliases.get(t);
|
||||
if (chave && t !== "esp32") {
|
||||
if (!chips.includes(chave)) chips.push(chave);
|
||||
usados.add(i);
|
||||
}
|
||||
});
|
||||
tokens.forEach((t, i) => {
|
||||
if (t !== "esp32") return;
|
||||
usados.add(i);
|
||||
if (!chips.length && indice.series.ESP32) chips.push("ESP32");
|
||||
});
|
||||
|
||||
// 2) Pinos citados (GPIO26) e palavras de intenção / ligação
|
||||
const gpios = [];
|
||||
let querPinos = false;
|
||||
let querDados = false;
|
||||
tokens.forEach((t, i) => {
|
||||
if (/^gpio\d+$/.test(t)) {
|
||||
gpios.push(t);
|
||||
usados.add(i);
|
||||
} else if (INTENCAO_PINOS.has(t)) {
|
||||
querPinos = true;
|
||||
usados.add(i);
|
||||
} else if (INTENCAO_DADOS.has(t)) {
|
||||
querDados = true;
|
||||
usados.add(i);
|
||||
} else if (STOP.has(t)) {
|
||||
usados.add(i);
|
||||
}
|
||||
});
|
||||
|
||||
// 3) Assuntos: os melhores casamentos, sem reaproveitar token
|
||||
const livres = tokens.map((t, i) => ({ t, i })).filter(({ i }) => !usados.has(i));
|
||||
const candidatos = [];
|
||||
for (const topico of TOPICOS) {
|
||||
let melhor = null;
|
||||
for (const termo of topico.termos) {
|
||||
const m = casarTermo(normalizar(termo), livres);
|
||||
if (m && (!melhor || m.qualidade > melhor.qualidade)) melhor = m;
|
||||
}
|
||||
if (melhor) candidatos.push({ topico, ...melhor });
|
||||
}
|
||||
candidatos.sort((a, b) => b.qualidade - a.qualidade);
|
||||
const topicos = [];
|
||||
for (const c of candidatos) {
|
||||
if (topicos.length >= 3) break;
|
||||
if (c.idx.some((i) => usados.has(i))) continue;
|
||||
c.idx.forEach((i) => usados.add(i));
|
||||
topicos.push(c.topico);
|
||||
}
|
||||
|
||||
// 4) Nome/função de pino exato (ex.: "mtck", "xtal_p")
|
||||
const funcoes = [];
|
||||
tokens.forEach((t, i) => {
|
||||
if (usados.has(i) || t.length < 3) return;
|
||||
if (indice.porFuncao.has(t)) {
|
||||
funcoes.push(t);
|
||||
usados.add(i);
|
||||
}
|
||||
});
|
||||
|
||||
const restantes = tokens.filter((_, i) => !usados.has(i));
|
||||
return { tokens, chips, topicos, gpios, funcoes, restantes, querPinos, querDados };
|
||||
}
|
||||
|
||||
const rotuloPino = (p) => {
|
||||
const gpio = p.f.find((f) => /^GPIO\d+$/i.test(f));
|
||||
return gpio && gpio.toLowerCase() !== p.nome.toLowerCase() ? `${gpio}/${p.nome}` : p.nome;
|
||||
};
|
||||
|
||||
/** Pinos do diagrama ligados ao assunto, já separados por nível de certeza. */
|
||||
function pinosDoTopico(serie, topico) {
|
||||
const forte = [];
|
||||
const fraco = [];
|
||||
for (const p of serie.pinos) {
|
||||
let nivel = null;
|
||||
if (topico.pinoNivel) nivel = topico.pinoNivel(p);
|
||||
else if ((topico.pinoCats && p.c.some((c) => topico.pinoCats.includes(c))) || (topico.pinoRe && topico.pinoRe.test(p._blob))) nivel = "forte";
|
||||
if (nivel === "forte") forte.push(p);
|
||||
else if (nivel === "fraco") fraco.push(p);
|
||||
}
|
||||
return { forte, fraco };
|
||||
}
|
||||
|
||||
const hrefPinos = (chave, pinos, motivo) =>
|
||||
`/series/${chave}?pinos=${pinos.map((p) => p.n).join(",")}&motivo=${encodeURIComponent(motivo)}#connections`;
|
||||
|
||||
function respostas(indice, it) {
|
||||
const saida = [];
|
||||
const alvos = it.chips.slice(0, 2);
|
||||
|
||||
for (const chave of alvos) {
|
||||
const serie = indice.series[chave];
|
||||
if (!serie) continue;
|
||||
|
||||
for (const topico of it.topicos.slice(0, 2)) {
|
||||
const specs = serie.specs.filter((s) => s.topicos.includes(topico.id));
|
||||
const cartaoSpec = specs.length
|
||||
? {
|
||||
tipo: "resposta",
|
||||
titulo: `${topico.nome} no ${serie.nome}`,
|
||||
descricao: "Dados da série",
|
||||
linhas: specs.slice(0, 5).map((s) => ({ k: s.label, v: s.valor })),
|
||||
href: `/series/${chave}#${specs[0].secao}`,
|
||||
}
|
||||
: null;
|
||||
|
||||
const { forte, fraco } = pinosDoTopico(serie, topico);
|
||||
const principais = forte.length ? forte : fraco;
|
||||
const cartaoPinos = principais.length
|
||||
? {
|
||||
tipo: "pino",
|
||||
titulo: `Pinos do ${serie.nome} ${topico.pinoRotulo}`,
|
||||
descricao:
|
||||
forte.length && fraco.length
|
||||
? `${forte.length} ocupados + ${fraco.length} que dependem do modo de uso (ex.: SPI de 8 linhas) · abre o diagrama filtrado`
|
||||
: forte.length
|
||||
? `${forte.length} ${forte.length === 1 ? "pino" : "pinos"} · abre o diagrama já filtrado`
|
||||
: `${fraco.length} pinos que só são afetados em certos modos de uso · abre o diagrama filtrado`,
|
||||
chips: forte
|
||||
.slice(0, 12)
|
||||
.map(rotuloPino)
|
||||
.concat(forte.length > 12 ? [`+${forte.length - 12}`] : [])
|
||||
.concat(fraco.length && forte.length ? [`+${fraco.length} condicionais`] : forte.length ? [] : fraco.slice(0, 10).map(rotuloPino)),
|
||||
href: hrefPinos(chave, [...forte, ...fraco], `${topico.nome}: pinos ${topico.pinoRotulo}`),
|
||||
}
|
||||
: null;
|
||||
|
||||
(it.querPinos ? [cartaoPinos, cartaoSpec] : [cartaoSpec, cartaoPinos]).filter(Boolean).forEach((c) => saida.push(c));
|
||||
}
|
||||
|
||||
if (!it.topicos.length && !it.gpios.length && !it.funcoes.length) {
|
||||
if (it.querPinos && serie.pinos.length) {
|
||||
saida.push({
|
||||
tipo: "pino",
|
||||
titulo: `Diagrama de pinos do ${serie.nome}`,
|
||||
descricao: `${serie.pinos.length} pinos com funções, restrições e avisos`,
|
||||
href: `/series/${chave}#connections`,
|
||||
});
|
||||
}
|
||||
if (it.querDados) {
|
||||
saida.push({ tipo: "resposta", titulo: `Especificações do ${serie.nome}`, descricao: "Processador, memória, conectividade, periféricos e mais", href: `/series/${chave}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assunto sem chip: panorama por série ("qual série tem PSRAM?")
|
||||
if (!alvos.length && it.topicos.length) {
|
||||
const topico = it.topicos[0];
|
||||
const linhas = [];
|
||||
for (const chave of indice.chaves) {
|
||||
const serie = indice.series[chave];
|
||||
const spec = serie.specs.find((s) => s.topicos.includes(topico.id));
|
||||
if (spec) linhas.push({ k: serie.nome, v: spec.valor.length > 46 ? `${spec.valor.slice(0, 45)}…` : spec.valor });
|
||||
}
|
||||
if (linhas.length > 1) {
|
||||
saida.push({ tipo: "resposta", titulo: `${topico.nome} em cada série`, descricao: "Comparativo rápido · adicione o chip na busca para detalhar", linhas, href: "/comparacao" });
|
||||
}
|
||||
}
|
||||
|
||||
// GPIO26 e nomes de função: "gpio26 s3", "mtck"
|
||||
const buscas = [...it.gpios, ...it.funcoes];
|
||||
for (const termo of buscas) {
|
||||
const achados = indice.porFuncao.get(termo) ?? [];
|
||||
const filtrados = it.chips.length ? achados.filter((a) => it.chips.includes(a.chave)) : achados;
|
||||
for (const { chave, pino } of filtrados.slice(0, it.chips.length ? 3 : 5)) {
|
||||
const serie = indice.series[chave];
|
||||
saida.push({
|
||||
tipo: "pino",
|
||||
titulo: `${rotuloPino(pino)} no ${serie.nome}`,
|
||||
descricao: [pino.f.join(" · "), pino.a[0]].filter(Boolean).join(" — "),
|
||||
href: hrefPinos(chave, [pino], `Pino ${rotuloPino(pino)}`),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return saida;
|
||||
}
|
||||
|
||||
function pontuarTermo(item, t) {
|
||||
if (item._t === t) return 100;
|
||||
if (item._t.startsWith(t)) return 60;
|
||||
if (item._t.includes(t)) return 40;
|
||||
if (item._k.includes(t)) return 15;
|
||||
if (item._d.includes(t)) return 10;
|
||||
if (t.length >= 4) {
|
||||
const max = t.length >= 8 ? 2 : 1;
|
||||
for (const w of item._w) if (distancia(w, t, max) <= max) return 6;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function casaTopico(item, topico) {
|
||||
let melhor = 0;
|
||||
for (const termo of topico.termos) {
|
||||
const n = normalizar(termo);
|
||||
if (n.includes(" ") ? item._all.includes(n) : item._w.has(n)) {
|
||||
melhor = Math.max(melhor, item._t.includes(n) ? 2 : 1);
|
||||
}
|
||||
}
|
||||
return melhor;
|
||||
}
|
||||
|
||||
const topicoCasou = (item, topicos) => topicos.some((t) => casaTopico(item, t));
|
||||
|
||||
export function buscar(indice, consulta) {
|
||||
const it = interpretar(indice, consulta);
|
||||
const interpretacao = {
|
||||
chips: it.chips.map((c) => ({ chave: c, nome: indice.series[c]?.nome ?? c })),
|
||||
topicos: it.topicos.map((t) => ({ id: t.id, nome: t.nome })),
|
||||
};
|
||||
|
||||
const smart = respostas(indice, it).map((r, i) => ({ id: `r${i}`, keywords: "", ...r }));
|
||||
|
||||
const nomesChips = it.chips.map((c) => new RegExp(`(?<![a-z0-9])${normalizar(c).replace(/[-]/g, "\\-")}(?![a-z0-9]|-[a-z0-9])`));
|
||||
const pontuados = [];
|
||||
for (const item of indice.itens) {
|
||||
let s = 0;
|
||||
let ok = true;
|
||||
for (const t of it.restantes) {
|
||||
const p = pontuarTermo(item, t);
|
||||
if (!p) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
s += p;
|
||||
}
|
||||
if (!ok) continue;
|
||||
|
||||
let acertos = 0;
|
||||
for (const topico of it.topicos) {
|
||||
const m = casaTopico(item, topico);
|
||||
if (m) {
|
||||
s += m === 2 ? 50 : 20;
|
||||
acertos++;
|
||||
}
|
||||
}
|
||||
nomesChips.forEach((nome, i) => {
|
||||
if (item.tipo === "serie" && item.titulo === it.chips[i]) {
|
||||
s += 300;
|
||||
acertos++;
|
||||
} else if (nome.test(item._all)) {
|
||||
s += 25;
|
||||
acertos++;
|
||||
}
|
||||
});
|
||||
|
||||
const ehSerie = item.tipo === "serie" && it.chips.includes(item.titulo);
|
||||
if (!it.restantes.length && !(it.topicos.length ? acertos > (ehSerie ? 0 : 0) && topicoCasou(item, it.topicos) : ehSerie)) continue;
|
||||
if (s > 0) pontuados.push({ item, s });
|
||||
}
|
||||
pontuados.sort((a, b) => b.s - a.s);
|
||||
|
||||
// Quando já respondemos com cartões, a série que repete a resposta fica no fim.
|
||||
const texto = pontuados.slice(0, 24).map((p) => p.item);
|
||||
|
||||
const sugestoes = [];
|
||||
if (it.topicos.length && !it.chips.length) {
|
||||
for (const c of indice.chaves.slice(0, 12)) sugestoes.push({ rotulo: indice.series[c].nome, anexar: c });
|
||||
} else if (it.chips.length && !it.topicos.length && !it.gpios.length) {
|
||||
for (const nome of EXEMPLOS_TOPICOS) sugestoes.push({ rotulo: nome, anexar: nome });
|
||||
}
|
||||
|
||||
return { resultados: [...smart, ...texto], interpretacao, sugestoes, totalRespostas: smart.length };
|
||||
}
|
||||
|
||||
export const EXEMPLOS_DE_BUSCA = ["flash s3", "pinos usb c6", "deep sleep p4", "gpio0 esp32", "secure boot c3", "psram"];
|
||||
Reference in new issue
Block a user