MENU
Cadastre-se para receber nossas ofertas!
/// Botão do Whatsapp WhatsApp ///Nome dos produtos ///grid de produtos /* * Busca Inteligente para catálogo de motopeças * ------------------------------------------------------------ * - Não depende de uma lista manual de produtos. * - Indexa automaticamente qualquer produto atual ou futuro. * - Entende abreviações, sinônimos, modelos, cilindradas, * posições, lados, medidas e intervalos de anos. * - Funciona no navegador e no Node.js. */ (function (root, factory) { const api = factory(); if (typeof module === "object" && module.exports) module.exports = api; else root.BuscaMotopecas = api; })(typeof globalThis !== "undefined" ? globalThis : this, function () { "use strict"; const STOP_WORDS = new Set([ "a", "as", "o", "os", "de", "da", "das", "do", "dos", "para", "por", "com", "sem", "em", "no", "na", "nos", "nas", "um", "uma", "e", "peca", "pecas", "produto", "produtos", "moto", "motocicleta" ]); // Regras ordenadas: expressões maiores devem vir antes das menores. const PHRASE_RULES = [ // Tipos de peça e formas usadas pelo cliente [["motor de partida", "motor partida", "motor part", "motor de arranque", "motor arranque", "arranque eletrico", "partida eletrica"], "motor_partida"], [["kit relacao", "kit de relacao", "kit rel", "relacao completa"], "kit_relacao"], [["cabo acelerador", "cabo de acelerador", "cabo acel"], "cabo_acelerador"], [["cabo embreagem", "cabo de embreagem", "cabo emb"], "cabo_embreagem"], [["cabo velocimetro", "cabo do velocimetro", "cabo vel"], "cabo_velocimetro"], [["interruptor partida", "interruptor de partida", "interr partida", "interr part"], "interruptor_partida"], [["interruptor freio", "interruptor de freio", "interr freio"], "interruptor_freio"], [["cilindro bengala", "cilin beng", "cilindro da bengala"], "cilindro_bengala"], [["retentor bengala", "ret bengala", "ret da bengala"], "retentor_bengala"], [["filtro de ar", "filtro ar"], "filtro_ar"], [["filtro de oleo", "filtro oleo"], "filtro_oleo"], [["bomba combustivel", "bomba de combustivel", "bomba comb"], "bomba_combustivel"], [["corrente comando", "corrente de comando"], "corrente_comando"], [["pastilha freio", "pastilha de freio"], "pastilha_freio"], [["disco freio", "disco de freio"], "disco_freio"], [["pedal partida", "pedal de partida"], "pedal_partida"], [["pedal freio", "pedal de freio"], "pedal_freio"], [["pedal cambio", "pedal de cambio"], "pedal_cambio"], [["bobina ignicao", "bobina de ignicao", "bobina ign"], "bobina_ignicao"], [["chave ignicao", "chave de ignicao", "chave ign"], "chave_ignicao"], [["vela ignicao", "vela de ignicao"], "vela_ignicao"], [["rele partida", "rele de partida"], "rele_partida"], [["rele pisca", "rele de pisca"], "rele_pisca"], [["kit motor", "kit de motor"], "kit_motor"], [["kit reparo", "kit de reparo", "kit rep"], "kit_reparo"], [["jogo juntas", "jogo de juntas", "jg juntas", "jg de juntas"], "jogo_juntas"], [["jogo direcao", "jogo de direcao", "jg direcao"], "jogo_direcao"], [["embreagem completa", "embreagem comp"], "embreagem_completa"], [["bloco optico", "bloco otico"], "bloco_optico"], [["carcaca painel", "carcaca do painel"], "carcaca_painel"], [["capa banco", "capa de banco"], "capa_banco"], [["oleo lubrificante", "lubrificante", "oleo de motor", "oleo motor", "oleo"], "oleo_lubrificante"], [["fluido freio", "fluido de freio"], "fluido_freio"], [["com camara", "c camara", "c cam", "com camera"], "com_camara"], [["sem camara", "s camara", "s cam", "sem camera"], "sem_camara"], [["modelo original", "mod original", "mod orig"], "modelo_original"], // Abreviações gerais usadas nos nomes do estoque [["parafuso", "paraf"], "parafuso"], [["retentor", "ret"], "retentor"], [["interruptor", "interr"], "interruptor"], [["carenagem", "caren"], "carenagem"], [["paralama", "paral"], "paralama"], [["protetor", "prot"], "protetor"], [["retrovisor", "retrov"], "retrovisor"], [["cilindro", "cilin"], "cilindro"], [["balancim", "balancin"], "balancim"], [["velocimetro", "veloc"], "velocimetro"], [["combustivel", "comb"], "combustivel"], [["embreagem", "embreag"], "embreagem"], [["tampa", "tpa"], "tampa"], [["jogo", "jg"], "jogo"], [["dentes", "dente", "d"], "dentes"], // Posições e lados [["dianteiro", "dianteira", "frente", "dt"], "dianteiro"], [["traseiro", "traseira", "tras", "tz"], "traseiro"], [["direito", "direita", "dir"], "direito"], [["esquerdo", "esquerda", "esq"], "esquerdo"], [["superior", "sup"], "superior"], [["inferior", "inf"], "inferior"], [["admissao", "adm"], "admissao"], [["escape", "escap"], "escape"], [["completo", "completa", "compl", "comp"], "completo"], // Cores frequentes [["preto", "preta", "pto"], "preto"], [["vermelho", "vermelha", "verm", "vemelho", "vemelha"], "vermelho"], [["azul", "az"], "azul"], [["prata", "prat"], "prata"], [["amarelo", "amarela", "amar"], "amarelo"], [["cristal"], "cristal"], [["fumê", "fume"], "fume"], // Modelos e abreviações encontradas no catálogo [["cg titan", "titan", "ti"], "titan"], [["cg today", "today", "to"], "today"], [["cg fan", "fan"], "fan"], [["nxr bros", "bros"], "bros"], [["c 100 biz", "c100 biz", "biz"], "biz"], [["pop"], "pop"], [["xre"], "xre"], [["xrl"], "xrl"], [["xlr"], "xlr"], [["xr"], "xr"], [["nx"], "nx"], [["cbx"], "cbx"], [["cb twister", "twister", "twist"], "twister"], [["tornado", "torn"], "tornado"], [["ybr"], "ybr"], [["factor"], "factor"], [["fazer"], "fazer"], [["lander", "land"], "lander"], [["tenere"], "tenere"], [["xtz"], "xtz"], [["crypton"], "crypton"], [["intruder"], "intruder"], [["yes"], "yes"], [["crf"], "crf"], [["falcon"], "falcon"], [["sahara"], "sahara"], [["cb"], "cb"], [["cg"], "cg"] ]; const KNOWN_MODELS = new Set([ "titan", "today", "fan", "bros", "biz", "pop", "xre", "xrl", "xlr", "xr", "nx", "cbx", "twister", "tornado", "ybr", "factor", "fazer", "lander", "tenere", "xtz", "crypton", "intruder", "yes", "crf", "falcon", "sahara", "cb", "cg" ]); const POSITION_TOKENS = new Set([ "dianteiro", "traseiro", "direito", "esquerdo", "superior", "inferior", "admissao", "escape" ]); const KNOWN_CC = new Set([ "50", "80", "90", "100", "105", "110", "115", "125", "135", "150", "160", "180", "190", "200", "230", "250", "300", "350", "400", "450", "500", "600", "650", "660", "700", "750", "800", "900", "1000", "1100" ]); const PRIMARY_TYPE_TOKENS = new Set([ "motor_partida", "kit_relacao", "cabo_acelerador", "cabo_embreagem", "cabo_velocimetro", "interruptor_partida", "interruptor_freio", "cilindro_bengala", "retentor_bengala", "filtro_ar", "filtro_oleo", "bomba_combustivel", "corrente_comando", "pastilha_freio", "disco_freio", "pedal_partida", "pedal_freio", "pedal_cambio", "bobina_ignicao", "chave_ignicao", "vela_ignicao", "rele_partida", "rele_pisca", "kit_motor", "kit_reparo", "jogo_juntas", "jogo_direcao", "embreagem_completa", "bloco_optico", "carcaca_painel", "capa_banco", "oleo_lubrificante", "fluido_freio", "pneu", "capacete", "cabo", "kit", "chave", "retentor", "eixo", "interruptor", "pisca", "pistao", "tampa", "capa", "rolamento", "lente", "estator", "chicote", "valvula", "painel", "mola", "manopla", "pedal", "carenagem", "cubo", "viseira", "biela", "filtro", "anel", "rabeta", "guia", "camara", "bloco", "trava", "carcaca", "lampada", "raio", "pastilha", "junta", "disco", "protetor", "alca", "aro", "bomba", "rele", "embreagem", "bico", "retrovisor", "corrente", "patim", "amortecedor", "bucha", "mesa", "carburador", "borracha", "macaneta", "escova", "porca", "suporte", "sensor", "vareta", "lanterna", "soquete", "farol", "refil", "motor", "arruela", "escapamento", "cavalete", "reparo", "cdi", "boia", "graxa", "desengripante", "descarbonizante", "parafuso", "cilindro", "balancim", "paralama", "carenagem", "combustivel" ]); function escapeRegex(text) { return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function stripAccents(text) { return String(text || "") .normalize("NFD") .replace(/[\u0300-\u036f]/g, ""); } function cleanBase(text) { return stripAccents(text) .toLowerCase() .replace(/^\s*[a-z]\s*-\s*/i, " ") .replace(/([a-z])([0-9])/g, "$1 $2") .replace(/([0-9])([a-z])/g, "$1 $2") .replace(/[º°]/g, " ") .replace(/\s+/g, " ") .trim(); } function isTwoDigitYear(value) { const n = Number(value); return Number.isInteger(n) && ((n >= 0 && n <= 30) || (n >= 70 && n <= 99)); } function fullYear(value) { const n = Number(value); return n <= 30 ? 2000 + n : 1900 + n; } function extractYearTokens(text) { const years = new Set(); const ranges = []; // Intervalos como 04/15, 92/02 e 16/24. Não confunde 90/90 de pneus. const rangeRegex = /(?:^|[^0-9])(\d{2})\s*[\/-]\s*(\d{2})(?![0-9])/g; let match; while ((match = rangeRegex.exec(text)) !== null) { const a = match[1]; const b = match[2]; if (!isTwoDigitYear(a) || !isTwoDigitYear(b)) continue; const start = fullYear(a); const end = fullYear(b); if (end < start || end - start > 35) continue; ranges.push([start, end]); for (let year = start; year <= end; year += 1) years.add(`ano_${year}`); } const fourDigitRangeRegex = /\b(19\d{2}|20\d{2})\s*[\/-]\s*(19\d{2}|20\d{2})\b/g; while ((match = fourDigitRangeRegex.exec(text)) !== null) { const start = Number(match[1]); const end = Number(match[2]); if (end >= start && end - start <= 35) { ranges.push([start, end]); for (let year = start; year <= end; year += 1) years.add(`ano_${year}`); } } const fourDigitRegex = /\b(19\d{2}|20\d{2})\b/g; while ((match = fourDigitRegex.exec(text)) !== null) years.add(`ano_${match[1]}`); return { years, ranges }; } function protectSpecialTokens(text) { let out = text; const protectedTokens = []; // Medidas de pneus: 100/80-17, 90/90x14, 2.75-18. out = out.replace(/\b(\d{2,3})\s*\/\s*(\d{2,3})\s*(?:[-x]|\s)\s*(\d{2})\b/g, (original, a, b, c) => { const rim = Number(c); // O terceiro número precisa ter formato de aro; evita confundir 125/150 02 com pneu. if (Number(a) < 50 || Number(b) < 50 || rim < 10 || rim > 23) return original; const token = `medida_${a}_${b}_${c}`; protectedTokens.push(token); return ` ${token} `; }); out = out.replace(/\b(\d(?:[.,]\d{1,2}))\s*[-x]\s*(\d{2})\b/g, (_, a, b) => { const token = `medida_${a.replace(",", ".").replace(".", "_")}_${b}`; protectedTokens.push(token); return ` ${token} `; }); // Viscosidade, classificação DOT e códigos técnicos. out = out.replace(/\b(\d{1,2})\s*w\s*(\d{2})\b/g, (_, a, b) => { const token = `visc_${a}w${b}`; protectedTokens.push(token); return ` ${token} `; }); out = out.replace(/\bdot\s*([3451])\b/g, (_, grade) => { const token = `dot_${grade}`; protectedTokens.push(token); return ` ${token} `; }); return { text: out, protectedTokens }; } function applyPhraseRules(text) { let out = ` ${text} `; for (const [aliases, canonical] of PHRASE_RULES) { const ordered = aliases.slice().sort((a, b) => b.length - a.length); for (const alias of ordered) { const pattern = escapeRegex(stripAccents(alias.toLowerCase())).replace(/\s+/g, "\\s+"); out = out.replace(new RegExp(`(^|[^a-z0-9_])${pattern}(?=$|[^a-z0-9_])`, "g"), `$1${canonical}`); } } return out.replace(/\s+/g, " ").trim(); } function singularize(token) { if (token.length > 5 && token.endsWith("oes")) return `${token.slice(0, -3)}ao`; if (token.length > 4 && token.endsWith("ais")) return `${token.slice(0, -3)}al`; if (token.length > 4 && token.endsWith("res")) return token.slice(0, -2); if (token.length > 4 && token.endsWith("s")) return token.slice(0, -1); return token; } function canonicalize(text) { const base = cleanBase(text); const yearData = extractYearTokens(base); const protectedData = protectSpecialTokens(base); const phrased = applyPhraseRules(protectedData.text) .replace(/[^a-z0-9_./+-]+/g, " ") .replace(/[./+\\-]+/g, " ") .replace(/\s+/g, " ") .trim(); const tokens = []; for (const rawToken of phrased.split(" ")) { if (!rawToken || STOP_WORDS.has(rawToken)) continue; const token = singularize(rawToken); if (!STOP_WORDS.has(token)) tokens.push(token); } // Mantém também as palavras internas dos termos compostos. // Ex.: cabo_acelerador também gera "cabo" e "acelerador". for (const token of tokens.slice()) { if (token.includes("_") && !token.startsWith("ano_") && !token.startsWith("medida_") && !token.startsWith("visc_") && !token.startsWith("dot_") && !token.startsWith("cc_")) { for (const part of token.split("_")) if (part) tokens.push(part); } } // Anos isolados abreviados, como 09, 15 ou 24, quando aparecem perto do modelo/cilindrada. for (let i = 0; i < tokens.length; i += 1) { const token = tokens[i]; if (!/^\d{2}$/.test(token) || !isTwoDigitYear(token)) continue; const previous = tokens[i - 1] || ""; const previous2 = tokens[i - 2] || ""; const likelyYear = token.startsWith("0") || KNOWN_MODELS.has(previous) || KNOWN_MODELS.has(previous2) || KNOWN_CC.has(previous); if (likelyYear) yearData.years.add(`ano_${fullYear(token)}`); } for (const year of yearData.years) tokens.push(year); for (const special of protectedData.protectedTokens) tokens.push(special); // Acrescenta fabricantes inferidos pelos modelos, ajudando buscas como "Honda Titan". const tokenSnapshot = new Set(tokens); if (["titan", "today", "fan", "bros", "biz", "pop", "xre", "xlr", "xr", "nx", "cbx", "twister", "tornado", "crf", "falcon", "sahara", "cb", "cg"].some(m => tokenSnapshot.has(m))) tokens.push("honda"); if (["ybr", "factor", "fazer", "lander", "tenere", "xtz", "crypton"].some(m => tokenSnapshot.has(m))) tokens.push("yamaha"); if (["intruder", "yes"].some(m => tokenSnapshot.has(m))) tokens.push("suzuki"); // Acrescenta cilindrada canônica sem remover o número original. for (const token of tokens.slice()) { if (KNOWN_CC.has(token)) tokens.push(`cc_${token}`); } const uniqueTokens = Array.from(new Set(tokens)); return { base, normalized: uniqueTokens.join(" "), tokens: uniqueTokens, tokenSet: new Set(uniqueTokens), years: new Set(uniqueTokens.filter(t => t.startsWith("ano_"))), models: new Set(uniqueTokens.filter(t => KNOWN_MODELS.has(t))), cc: new Set(uniqueTokens.filter(t => t.startsWith("cc_"))), positions: new Set(uniqueTokens.filter(t => POSITION_TOKENS.has(t))), specs: new Set(uniqueTokens.filter(t => t.startsWith("medida_") || t.startsWith("visc_") || t.startsWith("dot_"))) }; } function getPrimaryType(tokens) { for (const token of tokens.slice(0, 4)) { if (PRIMARY_TYPE_TOKENS.has(token)) return token; if (KNOWN_MODELS.has(token) || token.startsWith("ano_") || token.startsWith("cc_")) break; } return tokens[0] || ""; } function levenshtein(a, b) { if (a === b) return 0; if (!a.length) return b.length; if (!b.length) return a.length; const previous = Array.from({ length: b.length + 1 }, (_, i) => i); const current = new Array(b.length + 1); for (let i = 1; i <= a.length; i += 1) { current[0] = i; for (let j = 1; j <= b.length; j += 1) { current[j] = Math.min( current[j - 1] + 1, previous[j] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1) ); } for (let j = 0; j <= b.length; j += 1) previous[j] = current[j]; } return previous[b.length]; } function fuzzyTokenMatch(queryToken, productTokens) { if (queryToken.length < 4 || queryToken.includes("_")) return false; for (const productToken of productTokens) { if (productToken.includes("_") || productToken.length < 4) continue; if (productToken.startsWith(queryToken) || queryToken.startsWith(productToken)) return true; const maxDistance = Math.max(queryToken.length, productToken.length) >= 8 ? 2 : 1; if (Math.abs(queryToken.length - productToken.length) <= maxDistance && levenshtein(queryToken, productToken) <= maxDistance) return true; } return false; } function indexProduct(product, options) { const nameField = options.nameField || "nome"; const extraFields = options.extraFields || ["marca", "categoria", "descricao", "codigo", "sku"]; const name = String(product[nameField] || ""); const searchableText = [name] .concat(extraFields.map(field => product[field]).filter(value => value !== undefined && value !== null)) .join(" "); const parsed = canonicalize(searchableText); const nameParsed = canonicalize(name); return { product, name, parsed, nameParsed, primaryType: getPrimaryType(nameParsed.tokens), normalizedName: nameParsed.normalized }; } function createIndex(products, options = {}) { if (!Array.isArray(products)) throw new TypeError("products deve ser um array."); return products .filter(product => product && product[options.nameField || "nome"]) .map(product => indexProduct(product, options)); } function setMatches(querySet, productSet, requireAll = true) { if (!querySet.size) return true; const values = Array.from(querySet); return requireAll ? values.every(value => productSet.has(value)) : values.some(value => productSet.has(value)); } function scoreProduct(query, indexedProduct) { const product = indexedProduct.parsed; const productName = indexedProduct.nameParsed; const queryPrimary = getPrimaryType(query.tokens); // Quando o cliente informa claramente o tipo da peça, o produto precisa conter esse tipo. if (queryPrimary && PRIMARY_TYPE_TOKENS.has(queryPrimary) && !product.tokenSet.has(queryPrimary)) return null; // Filtros fortes evitam resultados tecnicamente incompatíveis. if (!setMatches(query.years, product.years, true)) return null; if (!setMatches(query.models, product.models, true)) return null; if (!setMatches(query.cc, product.cc, true)) return null; if (!setMatches(query.positions, product.positions, true)) return null; if (!setMatches(query.specs, product.specs, true)) return null; let score = 0; let exactMatches = 0; let fuzzyMatches = 0; if (queryPrimary && queryPrimary === indexedProduct.primaryType) score += 350; else if (queryPrimary && PRIMARY_TYPE_TOKENS.has(queryPrimary)) score -= 120; if (productName.normalized.includes(query.normalized)) score += 180; if (product.normalized.includes(query.normalized)) score += 90; for (const token of query.tokens) { if (product.tokenSet.has(token)) { exactMatches += 1; if (token.startsWith("ano_")) score += 95; else if (token.startsWith("cc_")) score += 75; else if (KNOWN_MODELS.has(token)) score += 90; else if (POSITION_TOKENS.has(token)) score += 55; else if (token.startsWith("medida_") || token.startsWith("visc_") || token.startsWith("dot_")) score += 100; else if (token.includes("_")) score += 70; else score += 22; } else if (fuzzyTokenMatch(token, product.tokens)) { fuzzyMatches += 1; score += 8; } } const coverage = (exactMatches + 0.45 * fuzzyMatches) / Math.max(1, query.tokens.length); score += Math.round(coverage * 140); if (coverage < 0.45) return null; // Produtos cujo nome começa pelo tipo pesquisado recebem preferência. if (queryPrimary && productName.tokens[0] === queryPrimary) score += 120; // Penaliza acessórios quando o cliente procura a peça principal. if (queryPrimary === "motor_partida" && indexedProduct.primaryType !== "motor_partida") score -= 220; if (queryPrimary === "kit_relacao" && indexedProduct.primaryType !== "kit_relacao") score -= 180; return { score, coverage, exactMatches, fuzzyMatches }; } function search(queryText, index, options = {}) { const limit = Number.isFinite(options.limit) ? options.limit : 20; const minScore = Number.isFinite(options.minScore) ? options.minScore : 120; const query = canonicalize(queryText); if (!query.tokens.length) return []; return index .map(item => { const result = scoreProduct(query, item); if (!result || result.score < minScore) return null; return { ...item.product, _busca: { score: result.score, cobertura: Number(result.coverage.toFixed(3)), nomeNormalizado: item.normalizedName, tipoPrincipal: item.primaryType } }; }) .filter(Boolean) .sort((a, b) => b._busca.score - a._busca.score) .slice(0, limit); } function generateSearchKeywords(product, options = {}) { const indexed = indexProduct(product, options); const readable = indexed.parsed.tokens.map(token => { if (token.startsWith("ano_")) return token.slice(4); if (token.startsWith("cc_")) return `${token.slice(3)}cc`; if (token.startsWith("visc_")) return token.slice(5); if (token.startsWith("dot_")) return token.replace("_", ""); if (token.startsWith("medida_")) return token.slice(7).replace(/_/g, " "); return token.replace(/_/g, " "); }); return Array.from(new Set(readable)).join(", "); } function updateIndex(index, product, options = {}) { const idField = options.idField || "codigo"; const id = product[idField]; const position = index.findIndex(item => item.product[idField] === id); const newItem = indexProduct(product, options); if (position >= 0) index[position] = newItem; else index.push(newItem); return index; } return { canonicalize, createIndex, search, generateSearchKeywords, updateIndex }; });