Files
ESPDocs/lib/searchEngine.js
T
Lucas RayanandClaude Sonnet 5.5 5a9fe676fc
CI / Lint e auditorias (push) Successful in 1m10s
CI / Trivy (dependências e segredos) (push) Successful in 12s
CI / Imagem Docker (linux/arm64) (push) Successful in 2m39s
Release / Plano (reaproveitar a imagem?) (push) Successful in 6s
Release / ci (push) Skipped
Release / Publica a imagem (linux/arm64) (push) Successful in 18s
Release / Cria a release no Gitea (push) Successful in 5s
feat(i18n): Simplified Chinese (zh-CN), search answers per locale, fr/de/es data fixes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 23:28:39 -03:00

447 lines
16 KiB
JavaScript

import { TOPICOS, normalizar } from "./searchTopics.js";
import { BUSCA_I18N } from "./searchI18n.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 CJK = /[\u3400-\u9fff]/;
const INTENCAO_PINOS_ZH = ["引脚", "管脚", "针脚", "脚位"];
const INTENCAO_DADOS_ZH = ["规格", "参数", "容量", "多少", "支持吗", "是否支持", "有吗"];
const STOP_ZH = new Set(["的", "吗", "呢", "啊", "吧", "了", "是", "什么", "哪个", "哪些", "怎么", "如何", "有", "和", "与", "在", "要", "想", "我", "请", "能", "可以"]);
/** Palavras de um trecho em chinês (Intl.Segmenter; sem ele, bigramas de caracteres). */
function segmentar(texto) {
let palavras = [];
try {
const seg = new Intl.Segmenter("zh", { granularity: "word" });
palavras = [...seg.segment(texto)].filter((s) => s.isWordLike).map((s) => s.segment);
} catch {
const cs = [...texto];
palavras = cs.length <= 2 ? [texto] : cs.slice(0, -1).map((c, i) => c + cs[i + 1]);
}
return palavras.filter((w) => !STOP_ZH.has(w));
}
const fmt = (modelo, v) => modelo.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ""));
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: [], locale: "pt", topicosLoc: null };
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), locale: Array.isArray(dados) ? "pt" : (dados.locale ?? "pt"), topicosLoc: Array.isArray(dados) ? null : (dados.topicos ?? null) };
}
/** Tenta casar um termo do dicionário com os tokens livres. Devolve { idx[], qualidade } ou null. */
function casarTermo(termo, livres) {
if (CJK.test(termo)) {
for (const { t, i } of livres) if (CJK.test(t) && t.includes(termo)) return { idx: [i], qualidade: 3 + termo.length / 10, cjk: true };
return null;
}
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(/([\u3400-\u9fff])([a-z0-9])/g, "$1 $2")
.replace(/([a-z0-9])([\u3400-\u9fff])/g, "$1 $2")
.replace(/[^a-z0-9.+\-_ \u3400-\u9fff]/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;
// Chinês não separa palavras por espaço: tira do trecho as palavras de intenção.
tokens.forEach((t, i) => {
if (!CJK.test(t)) return;
let resto = t;
for (const w of INTENCAO_PINOS_ZH) if (resto.includes(w)) { querPinos = true; resto = resto.split(w).join(""); }
for (const w of INTENCAO_DADOS_ZH) if (resto.includes(w)) { querDados = true; resto = resto.split(w).join(""); }
if (resto !== t) {
tokens[i] = resto;
if (!resto) usados.add(i);
}
});
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;
const extras = indice.topicosLoc?.[topico.id]?.termos;
for (const termo of extras ? [...topico.termos, ...extras] : 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 = [];
const cjkUsados = [];
for (const c of candidatos) {
if (topicos.length >= 3) break;
if (c.cjk) {
// Um mesmo trecho em chinês pode citar vários assuntos.
topicos.push(c.topico);
cjkUsados.push(...c.idx);
continue;
}
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);
}
});
cjkUsados.forEach((i) => usados.add(i));
const restantes = tokens.filter((_, i) => !usados.has(i)).flatMap((t) => (CJK.test(t) ? segmentar(t) : [t]));
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 = (I, chave, pinos, motivo) =>
`${I.rotas.series}/${chave}?pinos=${pinos.map((p) => p.n).join(",")}&motivo=${encodeURIComponent(motivo)}#connections`;
function respostas(indice, it) {
const saida = [];
const I = BUSCA_I18N[indice.locale] ?? BUSCA_I18N.pt;
const T = I.t;
const loc = (topico) => ({
nome: indice.topicosLoc?.[topico.id]?.nome ?? topico.nome,
rotulo: indice.topicosLoc?.[topico.id]?.pinoRotulo ?? topico.pinoRotulo,
});
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 tn = loc(topico);
const specs = serie.specs.filter((s) => s.topicos.includes(topico.id));
const cartaoSpec = specs.length
? {
tipo: "resposta",
titulo: fmt(T.specTitulo, { topico: tn.nome, serie: serie.nome }),
descricao: T.specDesc,
linhas: specs.slice(0, 5).map((s) => ({ k: s.label, v: s.valor })),
href: `${I.rotas.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: fmt(T.pinosTitulo, { serie: serie.nome, rotulo: tn.rotulo }),
descricao:
forte.length && fraco.length
? fmt(T.pinosMistos, { forte: forte.length, fraco: fraco.length })
: forte.length
? fmt(forte.length === 1 ? T.pinoUm : T.pinosVarios, { n: forte.length })
: fmt(T.pinosFraco, { n: fraco.length }),
chips: forte
.slice(0, 12)
.map(rotuloPino)
.concat(forte.length > 12 ? [`+${forte.length - 12}`] : [])
.concat(fraco.length && forte.length ? [fmt(T.condicionais, { n: fraco.length })] : forte.length ? [] : fraco.slice(0, 10).map(rotuloPino)),
href: hrefPinos(I, chave, [...forte, ...fraco], fmt(T.motivoTopico, { topico: tn.nome, rotulo: tn.rotulo })),
}
: 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: fmt(T.diagTitulo, { serie: serie.nome }),
descricao: fmt(T.diagDesc, { n: serie.pinos.length }),
href: `${I.rotas.series}/${chave}#connections`,
});
}
if (it.querDados) {
saida.push({ tipo: "resposta", titulo: fmt(T.especTitulo, { serie: serie.nome }), descricao: T.especDesc, href: `${I.rotas.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 tn = loc(topico);
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: fmt(T.emCada, { topico: tn.nome }), descricao: T.compDesc, linhas, href: I.rotas.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: fmt(T.pinoNo, { pino: rotuloPino(pino), serie: serie.nome }),
descricao: [pino.f.join(" · "), pino.a[0]].filter(Boolean).join(" — "),
href: hrefPinos(I, chave, [pino], fmt(T.motivoPino, { 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: indice.topicosLoc?.[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"];