Commit inicial: GTI Maquina Comercial

App local (Node + SQLite) para captura, tratamento e exportacao de leads
comerciais, com dashboard de gestao, login/usuarios e integracao Z-PRO.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
DESKTOP-F2O4A80\User
2026-07-12 14:22:21 -03:00
co-authored by Claude
commit b8d10b210c
78 changed files with 19781 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "gti-servidor",
"runtimeExecutable": "node",
"runtimeArgs": ["scripts/servidor.mjs"],
"port": 3900
}
]
}
+27
View File
@@ -0,0 +1,27 @@
# GTI Maquina Comercial - configuracao local (copie para .env se precisar mudar algo)
# Porta do aplicativo local (padrao 3900)
#GTI_PORT=3900
# Caminho do banco SQLite (padrao data/gti.db)
#GTI_DB_PATH=data/gti.db
# Pasta com os CSVs da base CNPJ extraida (padrao prospeccao/cnpj)
#CNPJ_BASE_DIR=prospeccao/cnpj
# Necessaria APENAS para a fonte Google Places (paga)
#GOOGLE_MAPS_API_KEY=
# Senha pedida para confirmar a limpeza total da base (Configuracoes > Zona de risco)
#GTI_RESET_PASSWORD=troque-esta-senha
# Endpoints das fontes gratuitas (padroes ja funcionam; mude so se precisar)
#OVERPASS_API_URL=https://overpass-api.de/api/interpreter,https://overpass.kumi.systems/api/interpreter
#NOMINATIM_API_URL=https://nominatim.openstreetmap.org/search
#WIKIDATA_API_URL=https://query.wikidata.org/sparql
#BRASILAPI_URL=https://brasilapi.com.br/api
# Ritmo das consultas ao OpenStreetMap (ms)
#OVERPASS_REQUEST_DELAY_MS=5000
#OVERPASS_TIMEOUT_MS=45000
#OVERPASS_TEXT_SEARCH=true
+10
View File
@@ -0,0 +1,10 @@
node_modules/
.env
.env.local
data/
prospeccao/cnpj/rfb-raw/
prospeccao/cnpj/*.csv
prospeccao/cnpj/*.ndjson
*.log
GTI-Comercial.exe
launcher/publish/
+10
View File
@@ -0,0 +1,10 @@
@echo off
setlocal
cd /d "%~dp0"
if exist "GTI-Comercial.exe" (
start "" "GTI-Comercial.exe"
) else (
echo GTI-Comercial.exe nao encontrado. Gerando agora...
powershell -NoProfile -ExecutionPolicy Bypass -File "scripts\build-launcher.ps1"
if exist "GTI-Comercial.exe" start "" "GTI-Comercial.exe"
)
+89
View File
@@ -0,0 +1,89 @@
# GTI Máquina Comercial
Aplicativo local do Grupo GTI para **capturar** e **tratar** leads B2B em qualquer
estado/cidade do Brasil, com foco na base oficial de CNPJs da Receita Federal.
## Como usar (dia a dia)
1. Dê dois cliques em **`GTI-Comercial.exe`** (ou em `INICIAR-GTI.bat`).
2. O navegador abre em `http://127.0.0.1:3900` com duas abas:
- **📡 Capturar Leads** — escolha estado, cidades, nichos e fontes e rode a captura
com progresso ao vivo. Se a base CNPJ do estado ainda não existe, o botão
"Extrair base agora" prepara tudo (~40–60 min, uma única vez por estado).
- **📊 Tratar Leads** — dashboard com totais e rankings por cidade/nicho, navegador
de leads com filtros (qualidade, WhatsApp, exportado...) e o botão
**Gerar relatório / exportação** que produz os arquivos para envio.
Tudo roda somente neste computador (porta local 127.0.0.1). Nenhum dado sai da máquina,
exceto as consultas às fontes públicas durante a captura.
## Exportações disponíveis
| Tipo | Para quê |
|---|---|
| 📊 Planilha (CSV + Excel) | Lista completa com score — números como texto, sem notação científica |
| 🗂️ Z-PRO Oportunidades (JSONL) | Um payload `createOpportunity` por linha, pronto para importar |
| 📨 Z-PRO Disparo em massa (CSV) | `dataInput` para `BulkSendMessageWithVariable` + instruções |
Leads exportados ficam marcados — o filtro "ainda não exportados" evita reenvio.
Os IDs padrão do Z-PRO (pipeline/etapa/responsável) ficam no ⚙️ Configurações.
> O `.xls` gerado é uma tabela HTML (o Excel avisa sobre o formato ao abrir — é normal,
> basta confirmar). Use o `.csv` se preferir.
## Qualidade dos leads (score)
Score: +35 telefone válido, +25 CNPJ válido, +25 empresa válida, +5 cidade,
+5 nicho, +5 CNAE. **Bom ≥ 85**, médio 60–84, ruim < 60. Empresa vazia, com lixo
ou começando com número é sempre **ruim** (regra crítica). Duplicidade: CNPJ →
WhatsApp → empresa+cidade.
## Fontes de captura
- **Base CNPJ (Receita Federal)** — principal: todas as empresas ativas da UF, ~97% com telefone.
- **OpenStreetMap** (Overpass e Nominatim) — grátis, complementar.
- **Wikidata** — grátis, volume baixo.
- **BrasilAPI** — enriquecimento opcional da base CNPJ.
- **Google Places** — paga; preencha `GOOGLE_MAPS_API_KEY` no `.env`.
## Novo estado (ex.: expandir para Sergipe)
1. Na aba Captura, escolha **SE — Sergipe**.
2. Clique em **Extrair base agora** e aguarde (usa os zips já baixados em
`prospeccao/cnpj/rfb-raw/`).
3. Pronto: capture qualquer cidade de SE normalmente. Nada muda na interface.
Os zips brutos da Receita (7 GB, Brasil inteiro) já estão no projeto. Para atualizá-los
no futuro (a Receita publica mensalmente): `bash scripts/baixar-base-rfb.sh`.
## Uso avançado (linha de comando)
```bash
npm start # sobe o servidor
npm test # roda os testes
node scripts/capturar-leads.mjs --sources=cnpj-base --uf=BA --cities="Ilheus"
node scripts/extrair-base-cnpj.mjs --uf=SP # extrai base de outra UF
```
## Estrutura do projeto
```
GTI-Comercial.exe inicia tudo (servidor + navegador)
scripts/ entradas de linha de comando
src/ código do servidor, fontes de captura, exportações
public/ interface (HTML/CSS/JS)
data/gti.db banco local (SQLite) com todos os leads
data/exports/ arquivos de exportação gerados
prospeccao/cnpj/ base CNPJ extraída por UF + zips brutos da Receita
docs/ playbook comercial, LGPD e referência da API Z-PRO
```
## Requisitos
- Windows com **Node.js 24+** (https://nodejs.org)
- Para extrair novas UFs: `unzip` (vem com o Git para Windows) ou o `tar` nativo do Windows
## Log e problemas
- Log do servidor: `data/server.log`
- Se a tela não abrir: rode `node scripts/servidor.mjs` num terminal e veja a mensagem.
+55
View File
@@ -0,0 +1,55 @@
# Visão Geral
O Grupo GTI opera uma **máquina comercial local**: um aplicativo (GTI-Comercial.exe)
que captura leads B2B das fontes públicas — principalmente a base oficial de CNPJs
da Receita Federal — e ajuda a tratá-los e exportá-los para as campanhas de
WhatsApp no Z-PRO.
## Soluções vendidas
- **ERP e sistemas de gestão** para comércio, serviços, restaurantes, mercados e distribuidoras.
- **Gestão de TI** para empresas com suporte, infraestrutura, servidores, redes, backup e segurança.
- **Autoatendimento para WhatsApp** com triagem, atendimento automático, vendas e agendamento.
Cada nicho do catálogo (57 nichos) já vem associado ao interesse provável e às dores
típicas, que alimentam a personalização das mensagens.
## Arquitetura (simples de propósito)
```
GTI-Comercial.exe → servidor Node local (porta 3900) → navegador
│
├── Captura: base CNPJ por UF + OpenStreetMap + Wikidata (+ Google opcional)
├── Banco SQLite local (data/gti.db): leads, score, dedup, histórico
└── Tratamento: dashboard, filtros, relatórios e exportações Z-PRO
```
Sem n8n, sem Docker, sem serviços externos obrigatórios: tudo roda no computador
da GTI. O envio das mensagens é feito por fora (Z-PRO), a partir dos arquivos
gerados pelo módulo de tratamento.
## Premissas
- Foco inicial na Bahia (Santo Antônio de Jesus e região + capitais), mas o app
funciona para **qualquer UF/cidade do Brasil** sem mudança de interface.
- A equipe não tem braço para operação manual intensa: o app faz a triagem
(score de qualidade dos dados + score comercial por nicho, combinados numa
prioridade P1-P4) e evita retrabalho (leads exportados ficam marcados).
- O WhatsApp é o canal central de conversão — leads com telefone válido têm prioridade.
## Resultado esperado
- Base grande e organizada de leads com WhatsApp (97%+ de cobertura via base CNPJ).
- Relatórios por nicho/cidade prontos para campanhas segmentadas.
- Histórico de tudo que já foi exportado/enviado, sem duplicar contato.
- Dashboard de gestão comercial na tela de Tratamento: percentual da base
pronta para abordagem imediata (Potencial Comercial), cobertura da base
já trabalhada, distribuição visual por prioridade, produtos recomendados
por cidade e meta diária de exportações da equipe.
- Interface explica o "porquê" de cada score (composição detalhada no modal
do lead), destaca a cidade+produto de maior oportunidade, mostra a
evolução diária de exportações e oferece atalhos de um clique para gerar
listas de prospecção (só P1, só um produto, só não exportados etc.).
- Acesso protegido por login (usuário/senha): o primeiro acesso vem com
`admin`/`admin`, que pode cadastrar novos usuários (comercial ou admin)
pela tela de Usuários.
+83
View File
@@ -0,0 +1,83 @@
# Playbook Comercial
## Oferta De Entrada
Nome: Diagnostico GTI de Gestao, TI e Atendimento.
Promessa: em 15 minutos, identificar gargalos em sistema, atendimento, infraestrutura ou processos manuais da empresa.
Essa oferta reduz resistencia porque nao comeca vendendo ERP, contrato de TI ou automacao diretamente.
## ICP Principal
Priorizar empresas de Santo Antonio de Jesus e regiao com qualquer uma destas caracteristicas:
- Controle por planilha ou papel.
- Sistema atual ruim ou insatisfatorio.
- Alto volume de WhatsApp.
- Muitos computadores ou usuarios.
- Problemas frequentes com suporte, rede, backup ou servidor.
- Dificuldade com estoque, financeiro, fiscal ou vendas.
## Segmentos Prioritarios
- Mercados e supermercados.
- Farmacias e drogarias.
- Restaurantes e delivery.
- Distribuidoras.
- Autopecas.
- Material de construcao.
- Clinicas.
- Oficinas.
- Escolas.
- Lojas de varejo.
- Empresas de servicos.
## Metodo De Atendimento
Usar SPIN de forma simples:
- Situacao: entender como a empresa funciona hoje.
- Problema: descobrir gargalos.
- Implicacao: mostrar o impacto de continuar assim.
- Necessidade: conectar a dor a uma solucao GTI.
## Perguntas Do Diagnostico
- Como voces controlam vendas, estoque e financeiro hoje?
- Qual processo mais toma tempo ou gera retrabalho?
- Hoje voces usam sistema, planilha ou controle manual?
- Quando da problema de TI, quem resolve?
- Existe rotina de backup?
- Quantas mensagens chegam por dia no WhatsApp?
- Qual o impacto se esse problema continuar por mais 6 meses?
- Se a GTI resolver isso, qual resultado seria mais importante?
## Roteiro Para Lead Quente
```text
Ola, [nome]. Vi aqui seu contato sobre [servico].
Pelo que voce informou, parece que existe uma oportunidade clara de melhorar [dor].
Quero fazer um diagnostico rapido de 15 minutos para entender sua operacao e te dizer o melhor caminho.
Pode ser hoje pela tarde ou amanha pela manha?
```
## Follow-up Manual Em Proposta
Dia 1:
```text
Oi, [nome]. Conseguiu avaliar a proposta da GTI? Posso te ajudar com alguma duvida tecnica ou comercial?
```
Dia 3:
```text
[nome], queria confirmar se esse projeto ainda faz sentido para agora ou se prefere que eu reprograme o contato para outro momento.
```
Dia 7:
```text
Vou deixar seu atendimento em aberto para retomarmos quando fizer sentido. Se quiser avancar, me responda por aqui.
```
+48
View File
@@ -0,0 +1,48 @@
# LGPD E Regras De WhatsApp
Este projeto deve ser operado com cuidado para evitar bloqueio de WhatsApp e problemas de privacidade.
## Regras Basicas
- Guardar origem do lead.
- Registrar consentimento quando existir.
- Evitar disparo massivo sem opt-in.
- Permitir descadastro ou parada de contato.
- Nao comprar listas frias para disparo automatico.
- Usar abordagem consultiva, nao agressiva.
- Manter historico de data, origem e mensagem enviada.
## Mensagem De Descadastro
Toda sequencia pode aceitar palavras como:
```text
PARAR
CANCELAR
SAIR
NAO QUERO
```
Quando uma dessas respostas chegar, o lead deve ser marcado como `Opt-out` no CRM e sair das automacoes.
## Prospecao Ativa Segura
Para listas frias locais, a abordagem deve ser manual ou semi-automatizada, com baixo volume e personalizacao.
Exemplo:
```text
Ola, tudo bem? Aqui e do Grupo GTI, de Santo Antonio de Jesus.
Vi que sua empresa atua em [segmento] e queria entender se hoje voces ja usam algum sistema para vendas, estoque ou atendimento.
Se nao fizer sentido, pode me avisar que nao envio novas mensagens.
```
## Dados Minimos No CRM
- Nome.
- Empresa.
- WhatsApp.
- Origem.
- Data do primeiro contato.
- Consentimento.
- Status de opt-out.
+75
View File
@@ -0,0 +1,75 @@
# Captura de Leads
A captura procura empresas por **UF + cidades + nichos** nas fontes selecionadas,
pontua cada lead (score/classificação) e grava tudo no banco local com
deduplicação automática — um lead capturado uma vez nunca é duplicado, em nenhuma
execução futura.
O jeito normal de usar é pela **aba "Capturar Leads" do aplicativo**
(GTI-Comercial.exe). Este documento cobre os detalhes e o uso por linha de comando.
## Fontes
| Fonte | Custo | Observações |
|---|---|---|
| `cnpj-base` | grátis | **Principal.** Base oficial da Receita Federal extraída por UF (~97% com telefone). |
| `brasilapi` | grátis | Mesma base CNPJ, enriquecida online via BrasilAPI (mais lenta). |
| `overpass` | grátis | OpenStreetMap por área da cidade (bounding box automática via geocode). |
| `nominatim` | grátis | Busca textual no OpenStreetMap. |
| `wikidata` | grátis | Empresas registradas no Wikidata (volume baixo). |
| `google-places` | paga | Exige `GOOGLE_MAPS_API_KEY` no `.env`. |
| `mock` | — | 3 leads fictícios para testar o fluxo. |
## Base CNPJ por estado
Cada UF precisa de uma extração única (~40–60 min) que gera
`prospeccao/cnpj/estabelecimentos-<UF>.csv` a partir dos zips brutos da Receita
(`prospeccao/cnpj/rfb-raw/`, Brasil inteiro, já baixados). A interface oferece o
botão **"Extrair base agora"** quando a UF escolhida ainda não tem base; na linha
de comando:
```bash
node scripts/extrair-base-cnpj.mjs --uf=SP
```
A extração cobre **todos os municípios da UF** — depois dela, qualquer cidade do
estado pode ser capturada na hora, sem re-extração. Um checkpoint em disco permite
retomar de onde parou se o processo for interrompido.
## Deduplicação e pontuação
- Chave de duplicidade: **CNPJ válido → WhatsApp válido → empresa+cidade**.
- **Score de Qualidade** (0-100, "os dados são confiáveis?"): +30 WhatsApp válido,
+20 CNPJ válido, +20 empresa válida, +5 cidade, +10 nicho, +10 CNAE, +5 website.
- **Score Comercial** (0-100, "qual a chance de comprar?"): aderência do nicho
(alta 40 / média 30 / baixa 20, configurada por nicho em `prospeccao/config.json`)
+ website 15 + e-mail 10 + WhatsApp válido 15 + CNPJ válido 20.
- **Score Geral** = 60% comercial + 40% qualidade — é o valor usado para ordenar
a tela de Tratamento.
- **Classificação**: 90-100 Excelente, 75-89 Bom, 55-74 Médio, 35-54 Baixo,
0-34 Muito Baixo. A **Prioridade comercial** (P1-P4, mostrada na tela) deriva
direto dela: Excelente/Bom ⇒ P1 (atacar já), Médio ⇒ P2 (na semana), Baixo ⇒ P3,
Muito Baixo ⇒ P4 (só em campanhas específicas).
- Cada nicho tem produtos GTI recomendados automaticamente (ERP, G-Delivery,
G-Chat, Sistema de Ponto) que aparecem no card do lead. Se o catálogo de
aderência/produtos do `config.json` mudar, rode `npm run recalc-scores` para
recalcular todos os leads já capturados.
## Linha de comando (uso avançado)
```bash
node scripts/capturar-leads.mjs --sources=mock
node scripts/capturar-leads.mjs --sources=cnpj-base --uf=BA --cities="Ilheus,Itabuna"
node scripts/capturar-leads.mjs --sources=cnpj-base,overpass --uf=BA --cities=all --segments=all
node scripts/capturar-leads.mjs --help
```
Parâmetros úteis: `--segments=a,b,c` (nichos pelo nome), `--segment-limit=N`,
`--city-limit=N`, `--max=N` (resultados por consulta nas fontes de API),
`--query-limit=N` (teto total de consultas).
## Ritmo e boas práticas (fontes OpenStreetMap)
Os servidores públicos do Overpass/Nominatim têm limites de uso. O app já aplica
espera entre consultas (`OVERPASS_REQUEST_DELAY_MS`), failover entre endpoints e
cooldown após falhas — falha de uma cidade não derruba a captura, só pula.
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>GTI-Comercial</AssemblyName>
<RootNamespace>GtiComercialLauncher</RootNamespace>
</PropertyGroup>
</Project>
+198
View File
@@ -0,0 +1,198 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Net;
using System.Threading;
// Launcher do GTI Maquina Comercial:
// 1. localiza a pasta do projeto (procura package.json + scripts/servidor.mjs);
// 2. se o servidor ja responde em http://127.0.0.1:3900, so abre o navegador;
// 3. senao, inicia "node scripts/servidor.mjs" escondido (log em data/server.log),
// espera o /api/health responder (ate 30s) e abre o navegador.
//
// Compilavel tanto pelo dotnet SDK (net8.0) quanto pelo csc.exe do
// .NET Framework 4 (C# 5): sem interpolacao de string, sem APIs novas.
namespace GtiComercialLauncher
{
internal static class Program
{
private static string BaseUrl()
{
string port = Environment.GetEnvironmentVariable("GTI_PORT");
if (string.IsNullOrEmpty(port)) port = "3900";
return "http://127.0.0.1:" + port;
}
private static int Main()
{
Console.Title = "GTI Maquina Comercial";
Console.WriteLine("=== GTI Maquina Comercial ===");
string root = LocateProjectDir();
if (root == null)
{
return Fail("Pasta do projeto nao encontrada (procurei package.json + scripts/servidor.mjs).\n" +
"Deixe o GTI-Comercial.exe dentro da pasta do projeto.");
}
string url = BaseUrl();
if (IsHealthy(url))
{
Console.WriteLine("Servidor ja esta rodando. Abrindo o navegador...");
OpenBrowser(url);
return 0;
}
string node = FindNode();
if (node == null)
{
return Fail("Node.js nao encontrado no PATH. Instale em https://nodejs.org (versao 24 ou superior).");
}
Console.WriteLine("Iniciando o servidor (aguarde alguns segundos)...");
if (!StartServer(node, root))
{
return Fail("Nao foi possivel iniciar o servidor. Veja o log em data\\server.log");
}
for (int i = 0; i < 60; i++)
{
Thread.Sleep(500);
if (IsHealthy(url))
{
Console.WriteLine("Pronto! Abrindo " + url + " no navegador...");
OpenBrowser(url);
Thread.Sleep(1500);
return 0;
}
}
return Fail("O servidor nao respondeu em 30 segundos.\n" +
"Veja o log em data\\server.log ou rode manualmente: node scripts\\servidor.mjs");
}
private static string LocateProjectDir()
{
string[] candidates = new string[]
{
AppDomain.CurrentDomain.BaseDirectory,
Directory.GetCurrentDirectory()
};
foreach (string start in candidates)
{
DirectoryInfo dir = new DirectoryInfo(start);
for (int depth = 0; dir != null && depth < 5; depth++)
{
if (File.Exists(Path.Combine(dir.FullName, "package.json")) &&
File.Exists(Path.Combine(Path.Combine(dir.FullName, "scripts"), "servidor.mjs")))
{
return dir.FullName;
}
dir = dir.Parent;
}
}
return null;
}
private static string FindNode()
{
try
{
ProcessStartInfo psi = new ProcessStartInfo("where.exe", "node");
psi.UseShellExecute = false;
psi.RedirectStandardOutput = true;
psi.CreateNoWindow = true;
using (Process p = Process.Start(psi))
{
string output = p.StandardOutput.ReadToEnd();
p.WaitForExit(5000);
if (!string.IsNullOrEmpty(output))
{
string first = output.Split(new char[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)[0].Trim();
if (File.Exists(first)) return first;
}
}
}
catch (Exception)
{
// continua para o retorno nulo
}
return null;
}
private static bool StartServer(string node, string root)
{
try
{
string dataDir = Path.Combine(root, "data");
Directory.CreateDirectory(dataDir);
string logPath = Path.Combine(dataDir, "server.log");
File.AppendAllText(logPath,
"---- " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " launcher iniciou o servidor ----\r\n");
// O redirecionamento para o log e feito pelo proprio cmd.exe: assim o
// launcher pode encerrar sem deixar o servidor escrevendo num pipe
// sem leitor (o que derrubaria o node com EPIPE mais tarde).
string comando = "/c \"\"" + node + "\" scripts\\servidor.mjs >> \"" + logPath + "\" 2>&1\"";
ProcessStartInfo psi = new ProcessStartInfo("cmd.exe", comando);
psi.WorkingDirectory = root;
psi.UseShellExecute = false;
psi.CreateNoWindow = true;
Process.Start(psi);
return true;
}
catch (Exception ex)
{
Console.WriteLine("Falha ao iniciar: " + ex.Message);
return false;
}
}
private static bool IsHealthy(string baseUrl)
{
try
{
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(baseUrl + "/api/health");
request.Timeout = 1500;
request.Method = "GET";
using (HttpWebResponse response = (HttpWebResponse)request.GetResponse())
{
return (int)response.StatusCode == 200;
}
}
catch (Exception)
{
return false;
}
}
private static void OpenBrowser(string url)
{
try
{
ProcessStartInfo psi = new ProcessStartInfo(url);
psi.UseShellExecute = true;
Process.Start(psi);
}
catch (Exception)
{
Console.WriteLine("Abra manualmente no navegador: " + url);
}
}
private static int Fail(string message)
{
Console.WriteLine();
Console.WriteLine("ERRO: " + message);
Console.WriteLine();
Console.WriteLine("Pressione qualquer tecla para fechar...");
try { Console.ReadKey(true); } catch (Exception) { Thread.Sleep(8000); }
return 1;
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "gti-maquina-comercial",
"version": "2.0.0",
"private": true,
"type": "module",
"description": "GTI Maquina Comercial: aplicativo local de captura e tratamento de leads B2B (base CNPJ da Receita Federal + fontes abertas).",
"scripts": {
"start": "node scripts/servidor.mjs",
"test": "node --test tests/*.test.mjs",
"capture": "node scripts/capturar-leads.mjs",
"extract": "node scripts/extrair-base-cnpj.mjs",
"recalc-scores": "node scripts/recalcular-scores.mjs",
"build:launcher": "powershell -File scripts/build-launcher.ps1"
},
"engines": {
"node": ">=24"
}
}
+26
View File
@@ -0,0 +1,26 @@
Coloque aqui arquivos CSV extraidos da base publica de CNPJ da Receita Federal ou uma planilha ja tratada.
O captador aceita CSV com cabecalho e tenta reconhecer colunas comuns como:
- `cnpj`
- `razao_social` ou `razao social`
- `nome_fantasia` ou `nome fantasia`
- `telefone`, `telefone 1`, `ddd`, `ddd 1`
- `municipio` ou `cidade`
- `uf`
- `cnae`, `cnae_fiscal` ou `cnae principal`
- `logradouro`, `numero`, `bairro`
Comando para usar a base local:
```bash
npm run prospect -- --source=cnpj-base --send
```
Com enriquecimento pela BrasilAPI, use:
```bash
npm run prospect -- --source=brasilapi --send
```
O modo `brasilapi` usa os CNPJs encontrados nos CSVs locais para consultar dados atualizados por CNPJ.
File diff suppressed because it is too large Load Diff
+323
View File
@@ -0,0 +1,323 @@
/* GTI Máquina Comercial — tokens de design (paleta validada, light/dark) */
:root {
--page: #f9f9f7;
--surface: #fcfcfb;
--ink: #0b0b0b;
--ink-2: #52514e;
--muted: #898781;
--grid: #e1e0d9;
--border: rgba(11, 11, 11, 0.10);
--accent: #2a78d6; /* series-1 azul */
--accent-ink: #ffffff;
--bar: #5598e7; /* sequential 350 — barras de ranking */
--good: #0ca30c;
--good-text: #006300;
--warning: #fab219;
--critical: #d03b3b;
--shadow: 0 1px 3px rgba(11, 11, 11, 0.08);
}
@media (prefers-color-scheme: dark) {
:root {
--page: #0d0d0d;
--surface: #1a1a19;
--ink: #ffffff;
--ink-2: #c3c2b7;
--muted: #898781;
--grid: #2c2c2a;
--border: rgba(255, 255, 255, 0.10);
--accent: #3987e5;
--bar: #3987e5;
--good-text: #0ca30c;
--shadow: none;
}
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--page);
color: var(--ink);
font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif;
}
/* ---------- topo ---------- */
.topbar {
display: flex; align-items: center; gap: 20px;
padding: 10px 20px;
background: var(--surface);
border-bottom: 1px solid var(--grid);
position: sticky; top: 0; z-index: 20;
}
.brand { display: flex; align-items: baseline; gap: 8px; }
.brand-mark {
background: var(--accent); color: var(--accent-ink);
font-weight: 800; padding: 3px 9px; border-radius: 6px; letter-spacing: 0.5px;
}
.brand-name { font-weight: 600; color: var(--ink-2); }
.tabs { display: flex; gap: 4px; flex: 1; }
.tab {
border: none; background: none; color: var(--ink-2);
font: inherit; font-weight: 600; padding: 8px 16px;
border-radius: 8px; cursor: pointer;
}
.tab:hover { background: var(--grid); }
.tab.active { background: var(--accent); color: var(--accent-ink); }
main { max-width: 1280px; margin: 0 auto; padding: 16px 20px 60px; }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* ---------- cartões e grades ---------- */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px 18px;
box-shadow: var(--shadow);
margin-bottom: 16px;
}
.card h2 { margin: 0 0 12px; font-size: 16px; }
.captura-wrap { max-width: 720px; margin: 0 auto; }
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
@media (max-width: 1100px) {
.grid-3 { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 980px) {
.grid-3 { grid-template-columns: 1fr; }
}
/* ---------- formulários ---------- */
.field { margin-bottom: 18px; }
.field > label:first-child, .field > summary { font-weight: 600; display: block; margin-bottom: 6px; }
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.row.space-between { justify-content: space-between; }
.row.wrap { flex-wrap: wrap; }
.radio { display: flex; align-items: center; gap: 6px; font-weight: 400; cursor: pointer; margin: 4px 0; }
select, input, textarea {
font: inherit; color: var(--ink);
background: var(--page);
border: 1px solid var(--grid);
border-radius: 8px; padding: 7px 10px;
}
select:focus, input:focus, textarea:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
input[type="number"] { width: 90px; }
label > input[type="number"] { display: block; margin-top: 4px; }
details summary { cursor: pointer; }
.btn, .btn-ghost, .btn-danger {
font: inherit; font-weight: 600; cursor: pointer;
border-radius: 8px; padding: 8px 14px;
border: 1px solid var(--grid);
background: var(--surface); color: var(--ink);
}
.btn:hover, .btn-ghost:hover { border-color: var(--accent); }
.btn-primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
.btn-primary:hover { filter: brightness(1.1); }
.btn-lg { width: 100%; padding: 12px; font-size: 15px; }
.btn-ghost { border-color: transparent; color: var(--ink-2); }
.btn-danger { color: var(--critical); border-color: var(--critical); background: none; }
.btn-danger:hover { background: var(--critical); color: #fff; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.zona-risco { border: 1px solid var(--critical); border-radius: 10px; padding: 10px 12px; margin-top: 14px; }
.zona-risco label { color: var(--critical); font-weight: 700; }
/* ---------- login ---------- */
.login-body { display: flex; min-height: 100vh; }
.login-wrap { margin: auto; width: 100%; max-width: 360px; padding: 20px; }
.login-card { display: flex; flex-direction: column; gap: 14px; }
.login-brand { justify-content: center; margin-bottom: 4px; }
.login-erro { color: var(--critical); min-height: 1em; margin: 0; }
/* ---------- usuarios ---------- */
#lista-usuarios { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
#lista-usuarios > div { padding: 6px 0; border-bottom: 1px solid var(--grid); }
#form-novo-usuario select { border: 1px solid var(--grid); border-radius: 8px; padding: 7px 10px; background: var(--surface); color: var(--ink); }
.chip {
font-size: 12px; font-weight: 600;
padding: 3px 10px; border-radius: 999px;
background: var(--grid); color: var(--ink-2);
}
.chip.ok { background: color-mix(in srgb, var(--good) 15%, transparent); color: var(--good-text); }
.chip.warn { background: color-mix(in srgb, var(--warning) 20%, transparent); color: var(--ink); }
.notice {
margin-top: 10px; padding: 12px;
border: 1px solid var(--warning);
border-radius: 8px;
background: color-mix(in srgb, var(--warning) 8%, transparent);
}
.notice p { margin: 0 0 10px; }
/* pickers de cidade/nicho */
.picker { margin-top: 8px; }
.picker input[type="search"] { width: 100%; margin-bottom: 6px; }
.sugestoes { display: flex; flex-direction: column; gap: 2px; max-height: 180px; overflow-y: auto; }
.sugestoes button {
text-align: left; font: inherit; border: none; background: none;
color: var(--ink); padding: 6px 8px; border-radius: 6px; cursor: pointer;
}
.sugestoes button:hover { background: var(--grid); }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.chips .chip { cursor: pointer; }
.chips .chip::after { content: " ✕"; color: var(--muted); }
.nichos-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 2px 12px; max-height: 300px; overflow-y: auto; margin-top: 8px;
}
.nichos-grid label { display: flex; gap: 6px; align-items: center; padding: 3px 4px; border-radius: 6px; cursor: pointer; }
.nichos-grid label:hover { background: var(--grid); }
.cnae-avulso { margin-top: 10px; border-top: 1px dashed var(--grid); padding-top: 10px; }
.cnae-avulso summary { cursor: pointer; font-size: 13px; color: var(--ink-2); font-weight: 600; }
.cnae-avulso-hint { margin: 6px 0 8px; font-size: 12.5px; }
.cnae-avulso input[type="search"] { width: 100%; margin-bottom: 6px; }
.cnae-avulso .sugestoes button { display: flex; justify-content: space-between; gap: 8px; }
.cnae-avulso .sugestoes .cod { color: var(--muted); font-variant-numeric: tabular-nums; font-size: 12px; }
.fontes { display: flex; flex-direction: column; gap: 6px; }
.fonte {
display: flex; gap: 10px; align-items: flex-start;
border: 1px solid var(--grid); border-radius: 8px; padding: 8px 10px; cursor: pointer;
}
.fonte input { margin-top: 3px; }
.fonte .desc { font-size: 12.5px; color: var(--ink-2); }
.fonte.indisponivel { opacity: 0.55; }
/* ---------- progresso ---------- */
.progress-bar { height: 8px; border-radius: 999px; background: var(--grid); overflow: hidden; margin: 10px 0 8px; }
.progress-fill { height: 100%; width: 3%; background: var(--accent); border-radius: 999px; transition: width 0.4s; }
.progress-msg { color: var(--ink-2); margin: 4px 0 10px; min-height: 1.4em; }
.stat-row { display: flex; gap: 18px; margin-bottom: 10px; }
.stat-row .mini { display: flex; flex-direction: column; }
.stat-row .mini b { font-size: 20px; }
.stat-row .mini span { font-size: 12px; color: var(--muted); }
.log {
font: 12px/1.5 ui-monospace, Consolas, monospace;
background: var(--page); border: 1px solid var(--grid); border-radius: 8px;
padding: 8px 10px; max-height: 160px; overflow-y: auto; white-space: pre-wrap;
}
.lista { display: flex; flex-direction: column; gap: 8px; }
.lista .item {
display: flex; justify-content: space-between; align-items: center; gap: 10px;
border-bottom: 1px solid var(--grid); padding: 8px 2px; flex-wrap: wrap;
}
.lista .item:last-child { border-bottom: none; }
.lista .item .sub { font-size: 12px; color: var(--muted); }
/* ---------- tratamento: tiles e rankings ---------- */
.filtros-card { padding: 12px 16px; }
.filtros { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.filtros select, .filtros input { min-width: 130px; }
#f-busca { flex: 1; min-width: 200px; }
.stat-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 16px; }
.tile {
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 14px 16px; box-shadow: var(--shadow);
}
.tile .valor { font-size: 26px; font-weight: 700; }
.tile .rotulo { font-size: 12.5px; color: var(--ink-2); margin-top: 2px; }
.tile .detalhe { font-size: 12px; color: var(--muted); }
.tile.t-good .valor { color: var(--good-text); }
.tile.t-warn .valor { color: var(--ink); }
.tile.tile-wide { grid-column: span 2; }
@media (max-width: 640px) { .tile.tile-wide { grid-column: span 1; } }
.meta-input { width: 70px; text-align: right; padding: 3px 6px; border-radius: 6px; border: 1px solid var(--border); background: var(--surface); color: var(--ink); }
/* ---------- funil comercial ---------- */
.funil { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
.funil-etapa { display: grid; grid-template-columns: 190px 1fr 70px; align-items: center; gap: 8px; padding: 1px 4px; }
.funil-etapa.pendente { opacity: 0.4; }
.funil-label { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.funil-barra { height: 14px; border-radius: 4px; overflow: hidden; background: none; }
.funil-fill { height: 100%; background: var(--bar); border-radius: 0 4px 4px 0; min-width: 2px; }
.funil-valor { font-size: 12.5px; color: var(--ink-2); text-align: right; font-variant-numeric: tabular-nums; }
/* barras de ranking: magnitude = um matiz (azul), rótulo direto, sem legenda */
.ranking { display: flex; flex-direction: column; gap: 7px; }
.rank-row { display: grid; grid-template-columns: 150px 1fr 70px; gap: 8px; align-items: center; cursor: pointer; border-radius: 6px; padding: 1px 4px; }
.rank-row:hover { background: var(--grid); }
.rank-row.ativo { background: color-mix(in srgb, var(--accent) 16%, transparent); font-weight: 600; }
.rank-row.nao-clicavel { cursor: default; }
.rank-row.nao-clicavel:hover { background: none; }
.oportunidade-clicavel { cursor: pointer; border-radius: 8px; padding: 6px 8px; }
.oportunidade-clicavel:hover { background: var(--grid); }
.oportunidade-clicavel.ativo { background: color-mix(in srgb, var(--accent) 16%, transparent); }
.rank-label { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rank-track { height: 14px; border-radius: 4px; overflow: hidden; background: none; }
.rank-fill { height: 100%; background: var(--bar); border-radius: 0 4px 4px 0; min-width: 2px; }
.rank-value { font-size: 12.5px; color: var(--ink-2); text-align: right; font-variant-numeric: tabular-nums; }
/* ---------- tabela ---------- */
.table-wrap { overflow-x: auto; margin: 0 -4px; }
table { border-collapse: collapse; width: 100%; }
th, td { text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--grid); white-space: nowrap; }
th { font-size: 12px; text-transform: uppercase; letter-spacing: 0.4px; color: var(--muted); }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
tbody tr { cursor: pointer; }
tbody tr:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); }
.badge { font-size: 12px; font-weight: 600; padding: 2px 8px; border-radius: 999px; white-space: nowrap; }
/* Escala sequencial ordinal (excelente -> muito baixo), verde -> ambar -> vermelho,
construida so com os tokens ja validados (--good/--warning/--critical). */
.badge.excelente { background: color-mix(in srgb, var(--good) 20%, transparent); color: var(--good-text); }
.badge.bom { background: color-mix(in srgb, var(--good) 12%, var(--warning) 8%); color: var(--good-text); }
.badge.medio { background: color-mix(in srgb, var(--warning) 22%, transparent); color: var(--ink); }
.badge.baixo { background: color-mix(in srgb, var(--warning) 18%, var(--critical) 12%); color: var(--critical); }
.badge.muito_baixo { background: color-mix(in srgb, var(--critical) 16%, transparent); color: var(--critical); }
.badge.prioridade-1 { background: color-mix(in srgb, var(--good) 20%, transparent); color: var(--good-text); }
.badge.prioridade-2 { background: color-mix(in srgb, var(--warning) 22%, transparent); color: var(--ink); }
.badge.prioridade-3 { background: var(--grid); color: var(--ink-2); }
.badge.prioridade-4 { background: var(--grid); color: var(--muted); }
.produto-cell { font-size: 12.5px; }
.estrelas { color: var(--warning); letter-spacing: 1px; white-space: nowrap; }
.pontos-fortes { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 3px; font-size: 13px; }
.resumo-comercial { border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; margin-top: 12px; }
.resumo-comercial h3 { margin: 0 0 8px; font-size: 14px; }
.empresa-cell { max-width: 280px; overflow: hidden; text-overflow: ellipsis; }
/* ---------- explicacao de score (v2.1) ---------- */
.score-breakdown-wrap { border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; margin-top: 12px; }
.score-breakdown { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 3px; font-size: 13px; }
.score-breakdown li.ok { color: var(--ink); }
.score-breakdown li.nok { color: var(--muted); }
/* ---------- atalhos de exportacao (v2.1) ---------- */
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0 12px; }
.chip {
border: 1px solid var(--border); background: var(--surface); color: var(--ink);
border-radius: 999px; padding: 4px 12px; font-size: 12.5px; cursor: pointer;
}
.chip:hover { background: var(--grid); border-color: var(--accent); }
/* ---------- modais e toast ---------- */
dialog {
border: 1px solid var(--border); border-radius: 14px;
background: var(--surface); color: var(--ink);
max-width: 560px; width: calc(100vw - 40px);
padding: 0;
}
dialog::backdrop { background: rgba(0, 0, 0, 0.45); }
.modal-body { padding: 20px 22px; }
.modal-body h2 { margin-top: 0; }
#modal-historico { max-width: 620px; }
#modal-historico .lista { max-height: 60vh; overflow-y: auto; margin-top: 10px; }
textarea { width: 100%; resize: vertical; }
.toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
background: var(--ink); color: var(--page);
padding: 10px 18px; border-radius: 10px; font-weight: 600;
z-index: 100; max-width: 80vw;
}
.hidden { display: none !important; }
.muted { color: var(--muted); font-weight: 400; }
/* detalhe do lead */
.detalhe-grid { display: grid; grid-template-columns: 140px 1fr; gap: 4px 12px; font-size: 13.5px; }
.detalhe-grid dt { color: var(--muted); }
.detalhe-grid dd { margin: 0; word-break: break-word; }
+360
View File
@@ -0,0 +1,360 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GTI Máquina Comercial</title>
<link rel="stylesheet" href="/css/app.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎯</text></svg>">
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="brand-mark">GTI</span>
<span class="brand-name">Máquina Comercial</span>
</div>
<nav class="tabs" role="tablist">
<button class="tab active" data-tab="captura" role="tab" aria-selected="true">📡 Capturar Leads</button>
<button class="tab" data-tab="tratamento" role="tab" aria-selected="false">📊 Tratar Leads</button>
</nav>
<span class="muted" id="usuario-logado"></span>
<button class="btn-ghost hidden" id="btn-usuarios" title="Usuários">👤 Usuários</button>
<button class="btn-ghost" id="btn-settings" title="Configurações">⚙️</button>
<button class="btn-ghost" id="btn-sair" title="Sair">🚪 Sair</button>
</header>
<main>
<!-- ==================== CAPTURA ==================== -->
<section id="tab-captura" class="tab-panel active">
<div class="captura-wrap">
<div class="card" id="captura-form">
<div class="row space-between">
<h2>Nova captura</h2>
<button class="btn-ghost" id="btn-ver-historico" type="button">🕓 Histórico de capturas</button>
</div>
<div class="field">
<label for="sel-uf">1. Estado (UF)</label>
<div class="row">
<select id="sel-uf"></select>
<span id="uf-status" class="chip"></span>
</div>
<div id="extracao-aviso" class="notice hidden">
<p>A base CNPJ de <strong id="extracao-uf"></strong> ainda não foi extraída neste computador.
A extração lê os arquivos da Receita Federal e demora <strong>~40 a 60 minutos</strong> (uma única vez por estado).</p>
<button class="btn" id="btn-extrair">⛏️ Extrair base agora</button>
</div>
</div>
<div class="field">
<label>2. Cidades</label>
<div class="row">
<label class="radio"><input type="radio" name="cidades-modo" value="todas" checked> Todas as cidades da UF</label>
<label class="radio"><input type="radio" name="cidades-modo" value="escolher"> Escolher cidades</label>
</div>
<div id="cidades-picker" class="picker hidden">
<input type="search" id="busca-cidade" placeholder="Digite para buscar cidade...">
<div id="cidades-sugestoes" class="sugestoes"></div>
<div id="cidades-chips" class="chips"></div>
</div>
</div>
<div class="field">
<label>3. Nichos <span class="muted" id="nichos-contagem"></span></label>
<div class="row">
<label class="radio"><input type="radio" name="nichos-modo" value="todos" checked> Todos os nichos</label>
<label class="radio"><input type="radio" name="nichos-modo" value="escolher"> Escolher nichos</label>
</div>
<div id="nichos-picker" class="picker hidden">
<div class="row">
<input type="search" id="busca-nicho" placeholder="Filtrar nichos...">
<button class="btn-ghost" id="btn-nichos-todos" type="button">Marcar visíveis</button>
<button class="btn-ghost" id="btn-nichos-nenhum" type="button">Desmarcar todos</button>
</div>
<div id="nichos-grid" class="nichos-grid"></div>
</div>
<details class="cnae-avulso">
<summary>Não achou o nicho? Buscar qualquer atividade da Receita Federal (CNAE)</summary>
<p class="muted cnae-avulso-hint">Os 57 nichos acima já vêm com mensagem e interesse configurados. Um CNAE avulso é adicionado só para a busca — ele participa da captura normalmente.</p>
<input type="search" id="busca-cnae" placeholder="Ex.: salão de beleza, transportadora, escola...">
<div id="cnae-sugestoes" class="sugestoes"></div>
<div id="cnae-chips" class="chips"></div>
</details>
</div>
<div class="field">
<label>4. Fontes de busca</label>
<div id="fontes-lista" class="fontes"></div>
</div>
<details class="field">
<summary>Opções avançadas</summary>
<div class="row">
<label>Máx. por consulta (fontes de API)
<input type="number" id="inp-max" value="10" min="1" max="60">
</label>
<label>Limite total de consultas (0 = sem limite)
<input type="number" id="inp-query-limit" value="0" min="0">
</label>
</div>
</details>
<button class="btn btn-primary btn-lg" id="btn-capturar">🚀 Iniciar captura</button>
</div>
<div class="card hidden" id="progresso-card">
<div class="row space-between">
<h2 id="progresso-titulo">Captura em andamento</h2>
<button class="btn-danger" id="btn-cancelar">Cancelar</button>
</div>
<div class="progress-bar"><div class="progress-fill" id="progresso-fill"></div></div>
<p class="progress-msg" id="progresso-msg">iniciando...</p>
<div class="stat-row" id="progresso-stats"></div>
<div class="log" id="progresso-log"></div>
</div>
</div>
</section>
<!-- ==================== TRATAMENTO ==================== -->
<section id="tab-tratamento" class="tab-panel">
<div class="card filtros-card">
<div class="filtros" id="filtros">
<select id="f-uf" data-filtro="uf"><option value="">UF: todas</option></select>
<input list="dl-cidades" id="f-cidade" data-filtro="cidade" placeholder="Cidade: todas">
<datalist id="dl-cidades"></datalist>
<select id="f-segmento" data-filtro="segmento"><option value="">Nicho: todos</option></select>
<select id="f-classificacao" data-filtro="classificacao">
<option value="">Classificação: todas</option>
<option value="excelente">🟢 Excelente</option>
<option value="bom">🟢 Bom</option>
<option value="medio">🟡 Médio</option>
<option value="baixo">🟠 Baixo</option>
<option value="muito_baixo">🔴 Muito Baixo</option>
</select>
<select id="f-prioridade" data-filtro="prioridade">
<option value="">Prioridade: todas</option>
<option value="1">P1 — atacar agora</option>
<option value="2">P2 — esta semana</option>
<option value="3">P3 — quando sobrar</option>
<option value="4">P4 — campanhas específicas</option>
</select>
<select id="f-produto" data-filtro="produto"><option value="">Produto: todos</option></select>
<select id="f-interesse" data-filtro="interesse"><option value="">Interesse: todos</option></select>
<select id="f-telefone" data-filtro="telefone_valido">
<option value="">WhatsApp: todos</option>
<option value="sim">Com WhatsApp</option>
<option value="nao">Sem WhatsApp</option>
</select>
<select id="f-website" data-filtro="website">
<option value="">Website: todos</option>
<option value="sim">Com website</option>
<option value="nao">Sem website</option>
</select>
<select id="f-email" data-filtro="email">
<option value="">E-mail: todos</option>
<option value="sim">Com e-mail</option>
<option value="nao">Sem e-mail</option>
</select>
<select id="f-exportado" data-filtro="exportado">
<option value="">Exportação: todos</option>
<option value="nao">Ainda não exportados</option>
<option value="sim">Já exportados</option>
</select>
<input type="search" id="f-busca" data-filtro="q" placeholder="🔎 empresa, CNPJ ou WhatsApp">
<button class="btn-ghost" id="btn-limpar-filtros">Limpar</button>
</div>
</div>
<div class="stat-tiles" id="stat-tiles"></div>
<div class="grid-3">
<div class="card">
<div class="row space-between">
<h2>Top cidades</h2>
</div>
<div id="rank-cidades" class="ranking"></div>
</div>
<div class="card">
<div class="row space-between">
<h2>Top nichos</h2>
</div>
<div id="rank-segmentos" class="ranking"></div>
</div>
<div class="card">
<div class="row space-between">
<h2 id="rank-produtos-titulo">Top produto recomendado</h2>
</div>
<div id="rank-produtos" class="ranking"></div>
</div>
</div>
<div class="grid-3">
<div class="card" id="card-oportunidades-dia"></div>
<div class="card" id="card-maior-oportunidade"></div>
<div class="card" id="card-evolucao-diaria"></div>
</div>
<div class="card" id="card-funil"></div>
<div class="card">
<div class="row space-between wrap">
<h2>Leads <span class="muted" id="leads-total"></span></h2>
<button class="btn btn-primary" id="btn-exportar">📤 Gerar relatório / exportação com este filtro</button>
</div>
<div class="table-wrap">
<table id="tabela-leads">
<thead>
<tr>
<th>Empresa</th><th>WhatsApp</th><th>Cidade</th><th>Nicho</th>
<th>Prioridade</th><th class="num">Geral</th>
<th>Classificação</th><th>Produto</th><th>Exportado</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="row space-between">
<button class="btn-ghost" id="pg-prev">← Anterior</button>
<span class="muted" id="pg-info"></span>
<button class="btn-ghost" id="pg-next">Próxima →</button>
</div>
</div>
<div class="card">
<h2>Exportações geradas</h2>
<div id="historico-exports" class="lista"></div>
</div>
</section>
</main>
<!-- Modal de exportação -->
<dialog id="modal-export">
<form method="dialog" class="modal-body" id="form-export">
<h2>Gerar relatório / exportação</h2>
<p class="muted" id="export-recap"></p>
<div id="export-atalhos" class="chips"></div>
<div class="field">
<label>Tipo de arquivo</label>
<label class="radio"><input type="radio" name="export-tipo" value="excel" checked>
📊 <strong>Planilha (CSV + Excel)</strong> — lista completa com score, à prova de notação científica</label>
<label class="radio"><input type="radio" name="export-tipo" value="zpro-oportunidades">
🗂️ <strong>Z-PRO: Oportunidades (JSONL)</strong> — payloads prontos para createOpportunity</label>
<label class="radio"><input type="radio" name="export-tipo" value="zpro-bulk">
📨 <strong>Z-PRO: Disparo em massa (CSV)</strong> — dataInput para BulkSendMessageWithVariable</label>
</div>
<div class="field" id="export-zpro-params">
<label>Parâmetros Z-PRO</label>
<div class="row">
<label>Pipeline <input type="number" id="zp-pipeline" min="1"></label>
<label>Etapa <input type="number" id="zp-stage" min="1"></label>
<label>Responsável <input type="number" id="zp-responsible" min="1"></label>
</div>
</div>
<div class="field hidden" id="export-mensagem-wrap">
<label>Mensagem sugerida (use {empresa}, {cidade}, {segmento})</label>
<textarea id="export-mensagem" rows="3">Olá {empresa}! Somos do Grupo GTI e ajudamos empresas de {cidade} com sistema de gestão, TI e atendimento automático no WhatsApp. Podemos conversar?</textarea>
</div>
<label class="radio"><input type="checkbox" id="export-marcar" checked>
Marcar estes leads como exportados (não aparecem de novo no filtro "ainda não exportados")</label>
<div class="row space-between">
<button class="btn-ghost" value="cancel">Cancelar</button>
<button class="btn btn-primary" id="btn-confirmar-export" value="default">Gerar arquivos</button>
</div>
</form>
</dialog>
<!-- Modal de histórico de capturas -->
<dialog id="modal-historico">
<div class="modal-body">
<div class="row space-between">
<h2>Últimas capturas</h2>
<button class="btn-ghost" id="btn-fechar-historico" type="button">Fechar</button>
</div>
<div id="historico-capturas" class="lista"></div>
</div>
</dialog>
<!-- Modal de configurações -->
<dialog id="modal-settings">
<form method="dialog" class="modal-body">
<h2>Configurações</h2>
<div class="field">
<label>Padrões Z-PRO (usados nas exportações)</label>
<div class="row">
<label>Pipeline <input type="number" id="cfg-pipeline" min="1"></label>
<label>Etapa <input type="number" id="cfg-stage" min="1"></label>
<label>Responsável <input type="number" id="cfg-responsible" min="1"></label>
</div>
<div class="row">
<label>Previsão de fechamento (dias) <input type="number" id="cfg-forecast" min="1"></label>
</div>
</div>
<div class="field zona-risco">
<label>Zona de risco</label>
<p class="muted">Apaga permanentemente todos os leads, exportações, capturas e extrações. Configurações e nichos não são afetados.</p>
<button class="btn-danger" id="btn-limpar-base" type="button">🗑️ Limpar toda a base de leads</button>
</div>
<div class="row space-between">
<button class="btn-ghost" value="cancel">Fechar</button>
<button class="btn btn-primary" id="btn-salvar-settings" value="default">Salvar</button>
</div>
</form>
</dialog>
<!-- Modal de confirmacao da limpeza total da base -->
<dialog id="modal-reset-base">
<div class="modal-body">
<h2>Limpar toda a base de leads</h2>
<p class="muted">Esta ação é <strong>irreversível</strong>: todos os leads, exportações, capturas e extrações serão apagados. Digite a senha de segurança para confirmar.</p>
<div class="field">
<label>Senha de segurança <input type="password" id="reset-base-senha" autocomplete="off"></label>
</div>
<div class="row space-between">
<button class="btn-ghost" id="btn-cancelar-reset-base" type="button">Cancelar</button>
<button class="btn-danger" id="btn-confirmar-reset-base" type="button">Confirmar limpeza</button>
</div>
</div>
</dialog>
<!-- Modal de usuarios (somente admin) -->
<dialog id="modal-usuarios">
<div class="modal-body">
<div class="row space-between">
<h2>Usuários</h2>
<button class="btn-ghost" id="btn-fechar-usuarios" type="button">Fechar</button>
</div>
<div id="lista-usuarios" class="lista"></div>
<form class="field" id="form-novo-usuario">
<label>Novo usuário</label>
<div class="row">
<input type="text" id="novo-usuario-username" placeholder="usuário" minlength="3" required>
<input type="password" id="novo-usuario-password" placeholder="senha" minlength="4" required>
<select id="novo-usuario-role">
<option value="comercial">Comercial</option>
<option value="admin">Administrador</option>
</select>
<button class="btn btn-primary" type="submit">Criar</button>
</div>
</form>
</div>
</dialog>
<!-- Modal detalhe do lead -->
<dialog id="modal-lead">
<div class="modal-body" id="lead-detalhe"></div>
</dialog>
<div id="toast" class="toast hidden"></div>
<script src="/js/api.js"></script>
<script src="/js/captura.js"></script>
<script src="/js/tratamento.js"></script>
<script src="/js/app.js"></script>
</body>
</html>
+94
View File
@@ -0,0 +1,94 @@
/* Cliente da API local + utilitários compartilhados. */
// 401 fora da tela de login = sessao expirada/inexistente: manda pro login.
function redirecionaSeSessaoExpirou(path, status) {
if (status === 401 && path !== '/api/auth/login' && !location.pathname.endsWith('/login.html')) {
window.location.href = '/login.html';
}
}
const API = {
async get(path) {
const res = await fetch(path);
const data = await res.json();
if (!res.ok) {
redirecionaSeSessaoExpirou(path, res.status);
throw new Error(data.erro || `HTTP ${res.status}`);
}
return data;
},
async send(method, path, body) {
const res = await fetch(path, {
method,
headers: { 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body)
});
const data = await res.json();
if (!res.ok) {
redirecionaSeSessaoExpirou(path, res.status);
const error = new Error(data.erro || `HTTP ${res.status}`);
error.data = data;
error.status = res.status;
throw error;
}
return data;
},
post(path, body) { return this.send('POST', path, body); },
put(path, body) { return this.send('PUT', path, body); },
// Acompanha um job via SSE; handlers: {progress, log, done, error}
watchJob(jobId, handlers) {
const source = new EventSource(`/api/jobs/${jobId}/events`);
const close = () => source.close();
source.addEventListener('progress', (e) => handlers.progress?.(JSON.parse(e.data)));
source.addEventListener('log', (e) => handlers.log?.(JSON.parse(e.data)));
source.addEventListener('done', (e) => {
handlers.done?.(JSON.parse(e.data));
close();
});
source.addEventListener('error', (e) => {
if (e.data) {
handlers.error?.(JSON.parse(e.data));
close();
}
// sem e.data = queda de conexão; EventSource tenta reconectar sozinho
});
return close;
}
};
const fmt = new Intl.NumberFormat('pt-BR');
function el(id) { return document.getElementById(id); }
function h(tag, attrs = {}, ...children) {
const node = document.createElement(tag);
for (const [key, value] of Object.entries(attrs)) {
if (key === 'class') node.className = value;
else if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
else if (value !== null && value !== undefined) node.setAttribute(key, value);
}
for (const child of children.flat()) {
if (child === null || child === undefined) continue;
node.append(child.nodeType ? child : document.createTextNode(child));
}
return node;
}
let toastTimer = null;
function toast(message, ms = 3500) {
const box = el('toast');
box.textContent = message;
box.classList.remove('hidden');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => box.classList.add('hidden'), ms);
}
function dataBr(iso) {
if (!iso) return '—';
const d = new Date(iso.includes('T') || iso.includes(' ') ? iso.replace(' ', 'T') + (iso.endsWith('Z') ? '' : 'Z') : iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
}
+137
View File
@@ -0,0 +1,137 @@
/* Shell do aplicativo: login + abas + configurações + usuários. */
(async function init() {
// sessao (o servidor ja bloqueia "/" sem cookie, mas precisamos dos dados do usuario)
let usuarioAtual;
try {
({ user: usuarioAtual } = await API.get('/api/auth/me'));
} catch {
window.location.href = '/login.html';
return;
}
el('usuario-logado').textContent = `${usuarioAtual.username} (${usuarioAtual.role === 'admin' ? 'admin' : 'comercial'})`;
el('btn-usuarios').classList.toggle('hidden', usuarioAtual.role !== 'admin');
el('btn-sair').addEventListener('click', async () => {
await API.post('/api/auth/logout');
window.location.href = '/login.html';
});
// usuarios (somente admin)
async function carregarListaUsuarios() {
const { users } = await API.get('/api/users');
el('lista-usuarios').replaceChildren(...users.map((u) =>
h('div', { class: 'row space-between' },
h('span', {}, u.username),
h('span', { class: 'muted' }, u.role === 'admin' ? 'Administrador' : 'Comercial'))));
}
el('btn-usuarios').addEventListener('click', async () => {
el('modal-usuarios').showModal();
try {
await carregarListaUsuarios();
} catch (error) {
toast(`Erro: ${error.message}`);
}
});
el('btn-fechar-usuarios').addEventListener('click', () => el('modal-usuarios').close());
el('form-novo-usuario').addEventListener('submit', async (event) => {
event.preventDefault();
try {
await API.post('/api/users', {
username: el('novo-usuario-username').value,
password: el('novo-usuario-password').value,
role: el('novo-usuario-role').value
});
el('novo-usuario-username').value = '';
el('novo-usuario-password').value = '';
await carregarListaUsuarios();
toast('Usuário criado');
} catch (error) {
toast(`Erro: ${error.message}`);
}
});
// abas
for (const tab of document.querySelectorAll('.tab')) {
tab.addEventListener('click', () => {
for (const t of document.querySelectorAll('.tab')) {
t.classList.toggle('active', t === tab);
t.setAttribute('aria-selected', t === tab ? 'true' : 'false');
}
for (const panel of document.querySelectorAll('.tab-panel')) {
panel.classList.toggle('active', panel.id === `tab-${tab.dataset.tab}`);
}
if (tab.dataset.tab === 'tratamento') Tratamento.ativar();
});
}
// configurações
el('btn-settings').addEventListener('click', async () => {
const { settings } = await API.get('/api/settings');
el('cfg-pipeline').value = settings['zpro.pipelineId'] || 1;
el('cfg-stage').value = settings['zpro.stageId'] || 1;
el('cfg-responsible').value = settings['zpro.responsibleId'] || 15;
el('cfg-forecast').value = settings['zpro.closingForecastDays'] || 30;
el('modal-settings').showModal();
});
el('btn-salvar-settings').addEventListener('click', async (event) => {
event.preventDefault();
try {
await API.put('/api/settings', {
'zpro.pipelineId': el('cfg-pipeline').value,
'zpro.stageId': el('cfg-stage').value,
'zpro.responsibleId': el('cfg-responsible').value,
'zpro.closingForecastDays': el('cfg-forecast').value
});
el('modal-settings').close();
toast('Configurações salvas');
} catch (error) {
toast(`Erro: ${error.message}`);
}
});
// limpar toda a base (zona de risco)
el('btn-limpar-base').addEventListener('click', () => {
el('reset-base-senha').value = '';
el('modal-settings').close();
el('modal-reset-base').showModal();
});
el('btn-cancelar-reset-base').addEventListener('click', () => {
el('modal-reset-base').close();
});
el('btn-confirmar-reset-base').addEventListener('click', async () => {
const senha = el('reset-base-senha').value;
if (!senha) {
toast('Informe a senha de segurança');
return;
}
try {
await API.post('/api/admin/reset', { senha });
el('modal-reset-base').close();
toast('Base de leads limpa com sucesso — recarregando...');
setTimeout(() => window.location.reload(), 1200);
} catch (error) {
toast(`Erro: ${error.message}`);
}
});
// fecha modais clicando fora
for (const dialog of document.querySelectorAll('dialog')) {
dialog.addEventListener('click', (event) => {
if (event.target === dialog) dialog.close();
});
}
Tratamento.bindEvents();
try {
await Captura.init();
} catch (error) {
toast(`Erro ao carregar: ${error.message}`, 8000);
}
})();
+405
View File
@@ -0,0 +1,405 @@
/* Aba Captura: wizard UF → cidades → nichos → fontes → rodar, com progresso SSE. */
const Captura = (() => {
let ufs = [];
let municipios = []; // da UF selecionada
let segments = [];
let sources = [];
const cidadesEscolhidas = new Set();
const nichosEscolhidos = new Set();
const cnaesAvulsos = new Map(); // codigo -> descricao
let stopWatcher = null;
// ---------- carregamento ----------
async function init() {
const [ufsData, segData, srcData] = await Promise.all([
API.get('/api/meta/ufs'),
API.get('/api/meta/segments'),
API.get('/api/meta/sources')
]);
ufs = ufsData.ufs;
segments = segData.segments;
sources = srcData.sources;
renderUfs();
renderNichos();
renderFontes();
bindEvents();
await onUfChange();
await carregarHistorico();
await retomarJobAtivo();
}
function renderUfs() {
const sel = el('sel-uf');
sel.replaceChildren(...ufs.map((u) =>
h('option', { value: u.uf, selected: u.uf === 'BA' ? '' : null },
`${u.uf} — ${u.nome}${u.extraida ? ' ✓' : ''}`)
));
}
function renderNichos(filtro = '') {
const grid = el('nichos-grid');
const termo = filtro.toLowerCase();
grid.replaceChildren(...segments
.filter((s) => !termo || s.label.toLowerCase().includes(termo))
.map((s) => h('label', {},
h('input', {
type: 'checkbox', value: s.name,
checked: nichosEscolhidos.has(s.name) ? '' : null,
onchange: (e) => {
if (e.target.checked) nichosEscolhidos.add(s.name);
else nichosEscolhidos.delete(s.name);
atualizarContagem();
}
}),
s.label
)));
el('nichos-contagem').textContent = `(${segments.length} disponíveis)`;
}
function atualizarContagem() {
const modo = document.querySelector('input[name="nichos-modo"]:checked').value;
el('nichos-contagem').textContent = modo === 'todos'
? `(${segments.length} disponíveis)`
: `(${nichosEscolhidos.size} de ${segments.length} selecionados)`;
}
function renderFontes() {
const box = el('fontes-lista');
box.replaceChildren(...sources.map((s) => {
const input = h('input', {
type: 'checkbox', value: s.id,
checked: s.id === 'cnpj-base' ? '' : null,
disabled: s.disponivel ? null : ''
});
return h('label', { class: `fonte${s.disponivel ? '' : ' indisponivel'}` },
input,
h('div', {},
h('div', {}, h('strong', {}, s.label), s.recomendada ? ' ⭐' : '', s.gratuita ? '' : ' 💰'),
h('div', { class: 'desc' }, s.descricao + (s.disponivel ? '' : ` — ${s.motivo}`))
)
);
}));
}
// ---------- UF / extração ----------
function ufAtual() {
return ufs.find((u) => u.uf === el('sel-uf').value);
}
async function onUfChange() {
const u = ufAtual();
const status = el('uf-status');
if (u.extraindo) {
status.textContent = '⏳ extraindo base...';
status.className = 'chip warn';
} else if (u.extraida) {
status.textContent = '✓ base CNPJ pronta';
status.className = 'chip ok';
} else {
status.textContent = 'base CNPJ não extraída';
status.className = 'chip warn';
}
el('extracao-aviso').classList.toggle('hidden', u.extraida || u.extraindo);
el('extracao-uf').textContent = `${u.uf} — ${u.nome}`;
cidadesEscolhidas.clear();
renderChips();
const data = await API.get(`/api/meta/municipios?uf=${u.uf}`);
municipios = data.municipios;
}
async function extrairBase() {
const u = ufAtual();
try {
const { jobId } = await API.post('/api/extractions', { uf: u.uf });
toast(`Extração de ${u.uf} iniciada — pode levar até 1 hora`);
acompanharJob(jobId, `Extraindo base CNPJ de ${u.uf}`);
u.extraindo = true;
await onUfChange();
} catch (error) {
toast(`Erro: ${error.message}`);
}
}
// ---------- cidades ----------
function renderChips() {
el('cidades-chips').replaceChildren(...[...cidadesEscolhidas].map((nome) =>
h('span', {
class: 'chip', title: 'remover',
onclick: () => { cidadesEscolhidas.delete(nome); renderChips(); }
}, nome)
));
}
function buscarCidade(termo) {
const box = el('cidades-sugestoes');
if (!termo || termo.length < 2) {
box.replaceChildren();
return;
}
const t = termo.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
const achados = municipios
.filter((m) => m.nome.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').includes(t))
.slice(0, 12);
box.replaceChildren(...achados.map((m) =>
h('button', {
type: 'button',
onclick: () => {
cidadesEscolhidas.add(m.nome);
renderChips();
el('busca-cidade').value = '';
box.replaceChildren();
}
}, m.nome)
));
}
// ---------- CNAE avulso (qualquer atividade da Receita, alem dos 57 nichos) ----------
function renderCnaeChips() {
el('cnae-chips').replaceChildren(...[...cnaesAvulsos].map(([codigo, descricao]) =>
h('span', {
class: 'chip', title: 'remover',
onclick: () => { cnaesAvulsos.delete(codigo); renderCnaeChips(); }
}, descricao)
));
}
let buscaCnaeTimer = null;
function buscarCnae(termo) {
clearTimeout(buscaCnaeTimer);
const box = el('cnae-sugestoes');
if (!termo || termo.trim().length < 2) {
box.replaceChildren();
return;
}
buscaCnaeTimer = setTimeout(async () => {
const { cnaes } = await API.get(`/api/meta/cnaes?q=${encodeURIComponent(termo)}`);
box.replaceChildren(...cnaes.map((c) =>
h('button', {
type: 'button',
onclick: () => {
cnaesAvulsos.set(c.codigo, c.descricao);
renderCnaeChips();
el('busca-cnae').value = '';
box.replaceChildren();
}
}, h('span', {}, c.descricao), h('span', { class: 'cod' }, c.codigo))
));
if (!cnaes.length) {
box.replaceChildren(h('div', { class: 'muted' }, 'Nenhuma atividade encontrada'));
}
}, 250);
}
// ---------- captura ----------
function montarPayload() {
const fontes = [...document.querySelectorAll('#fontes-lista input:checked')].map((i) => i.value);
const cidadesModo = document.querySelector('input[name="cidades-modo"]:checked').value;
const nichosModo = document.querySelector('input[name="nichos-modo"]:checked').value;
const cnaeIds = [...cnaesAvulsos.keys()].map((codigo) => `cnae:${codigo}`);
return {
sources: fontes,
uf: el('sel-uf').value,
cities: cidadesModo === 'todas' ? ['all'] : [...cidadesEscolhidas],
segments: [...(nichosModo === 'todos' ? ['all'] : [...nichosEscolhidos]), ...cnaeIds],
limits: {
maxPerQuery: Number(el('inp-max').value) || 10,
queryLimit: Number(el('inp-query-limit').value) || 0
}
};
}
async function iniciarCaptura() {
const payload = montarPayload();
if (!payload.sources.length) {
toast('Selecione pelo menos uma fonte de busca');
return;
}
if (payload.cities.length === 0) {
toast('Escolha pelo menos uma cidade (ou marque "todas")');
return;
}
if (payload.segments.length === 0) {
toast('Escolha pelo menos um nicho (ou marque "todos")');
return;
}
try {
const { jobId } = await API.post('/api/capture/runs', payload);
acompanharJob(jobId, 'Captura em andamento');
} catch (error) {
if (error.data?.precisaExtracao) {
el('extracao-aviso').classList.remove('hidden');
toast(error.message);
} else {
toast(`Erro: ${error.message}`);
}
}
}
// ---------- progresso ----------
function acompanharJob(jobId, titulo) {
const card = el('progresso-card');
card.classList.remove('hidden');
card.dataset.jobId = jobId;
el('progresso-titulo').textContent = titulo;
el('progresso-msg').textContent = 'iniciando...';
el('progresso-log').textContent = '';
el('progresso-fill').style.width = '3%';
el('progresso-stats').replaceChildren();
el('btn-cancelar').disabled = false;
el('btn-capturar').disabled = true;
if (stopWatcher) stopWatcher();
stopWatcher = API.watchJob(jobId, {
progress(p) {
if (p.message) el('progresso-msg').textContent = p.message;
if (typeof p.pct === 'number') el('progresso-fill').style.width = `${Math.max(3, p.pct)}%`;
if (p.counts) {
el('progresso-fill').style.width = '50%';
el('progresso-stats').replaceChildren(
mini(p.counts.raw, 'encontrados'),
mini(p.counts.new, 'novos'),
mini(p.counts.dup, 'já capturados')
);
}
},
log(l) {
const box = el('progresso-log');
box.textContent += `${l.message}\n`;
box.scrollTop = box.scrollHeight;
},
done(d) {
el('progresso-fill').style.width = '100%';
const s = d.summary || {};
el('progresso-msg').textContent = s.cancelled
? 'Cancelado.'
: s.totalRows !== undefined
? `✅ Base extraída: ${fmt.format(s.totalRows)} empresas (${fmt.format(s.withPhone)} com telefone)`
: `✅ Concluído: ${fmt.format(s.new ?? 0)} leads novos (${fmt.format(s.dup ?? 0)} já capturados antes)`;
finalizar();
},
error(e) {
el('progresso-msg').textContent = `❌ Erro: ${e.message}`;
finalizar();
}
});
function mini(valor, rotulo) {
return h('div', { class: 'mini' }, h('b', {}, fmt.format(valor ?? 0)), h('span', {}, rotulo));
}
async function finalizar() {
el('btn-cancelar').disabled = true;
el('btn-capturar').disabled = false;
await Promise.all([carregarHistorico(), recarregarUfs()]);
window.Tratamento?.invalidar();
}
}
async function recarregarUfs() {
ufs = (await API.get('/api/meta/ufs')).ufs;
const atual = el('sel-uf').value;
renderUfs();
el('sel-uf').value = atual;
await onUfChange();
}
async function cancelarJob() {
const jobId = el('progresso-card').dataset.jobId;
const ativos = (await API.get('/api/jobs/active')).jobs;
const job = ativos.find((j) => j.jobId === jobId);
if (!job) {
toast('Nenhum job ativo para cancelar');
return;
}
const rota = job.type === 'extracao'
? `/api/extractions/${job.meta.extractionId}/cancel`
: `/api/capture/runs/${job.meta.runId}/cancel`;
try {
await API.post(rota, {});
toast('Cancelamento solicitado — aguardando o processo parar...');
} catch (error) {
toast(`Erro: ${error.message}`);
}
}
async function retomarJobAtivo() {
const ativos = (await API.get('/api/jobs/active')).jobs;
if (!ativos.length) return;
const job = ativos[0];
acompanharJob(job.jobId, job.type === 'extracao'
? `Extraindo base CNPJ de ${job.meta.uf}`
: 'Captura em andamento');
}
// ---------- histórico ----------
async function carregarHistorico() {
const { runs } = await API.get('/api/capture/runs?limit=8');
el('btn-ver-historico').textContent = runs.length ? `🕓 Histórico de capturas (${runs.length})` : '🕓 Histórico de capturas';
const box = el('historico-capturas');
if (!runs.length) {
box.replaceChildren(h('div', { class: 'muted' }, 'Nenhuma captura ainda. Configure ao lado e clique em Iniciar.'));
return;
}
const statusLabel = {
done: '✅ concluída', running: '⏳ rodando', pending: '⏳ na fila',
cancelled: '⚪ cancelada', error: '❌ erro'
};
box.replaceChildren(...runs.map((r) => h('div', { class: 'item' },
h('div', {},
h('div', {}, h('strong', {}, `#${r.id} · ${(r.params.sources || []).join(', ')} · ${r.params.uf || ''}`)),
h('div', { class: 'sub' },
`${dataBr(r.created_at)} · ${statusLabel[r.status] || r.status}`,
r.error ? ` · ${r.error.slice(0, 80)}` : '')
),
h('div', { class: 'sub' },
r.status === 'done' ? `${fmt.format(r.new_count)} novos / ${fmt.format(r.dup_count)} repetidos` : '')
)));
}
// ---------- eventos ----------
function bindEvents() {
el('sel-uf').addEventListener('change', onUfChange);
el('btn-extrair').addEventListener('click', extrairBase);
el('btn-capturar').addEventListener('click', iniciarCaptura);
el('btn-cancelar').addEventListener('click', cancelarJob);
el('btn-ver-historico').addEventListener('click', () => el('modal-historico').showModal());
el('btn-fechar-historico').addEventListener('click', () => el('modal-historico').close());
for (const radio of document.querySelectorAll('input[name="cidades-modo"]')) {
radio.addEventListener('change', () => {
el('cidades-picker').classList.toggle('hidden', radio.value !== 'escolher' || !radio.checked);
});
}
for (const radio of document.querySelectorAll('input[name="nichos-modo"]')) {
radio.addEventListener('change', () => {
el('nichos-picker').classList.toggle('hidden', radio.value !== 'escolher' || !radio.checked);
atualizarContagem();
});
}
el('busca-cidade').addEventListener('input', (e) => buscarCidade(e.target.value));
el('busca-nicho').addEventListener('input', (e) => renderNichos(e.target.value));
el('busca-cnae').addEventListener('input', (e) => buscarCnae(e.target.value));
el('btn-nichos-todos').addEventListener('click', () => {
for (const input of document.querySelectorAll('#nichos-grid input')) {
input.checked = true;
nichosEscolhidos.add(input.value);
}
atualizarContagem();
});
el('btn-nichos-nenhum').addEventListener('click', () => {
nichosEscolhidos.clear();
renderNichos(el('busca-nicho').value);
atualizarContagem();
});
}
return { init };
})();
+16
View File
@@ -0,0 +1,16 @@
/* Tela de login: autentica e redireciona para o app. */
el('form-login').addEventListener('submit', async (event) => {
event.preventDefault();
const erroBox = el('login-erro');
erroBox.textContent = '';
try {
await API.post('/api/auth/login', {
username: el('login-username').value,
password: el('login-password').value
});
window.location.href = '/';
} catch (error) {
erroBox.textContent = error.message;
}
});
+629
View File
@@ -0,0 +1,629 @@
/* Aba Tratamento: dashboard, navegador de leads com filtros e exportações. */
const Tratamento = (() => {
let page = 1;
let totalPages = 1;
let carregado = false;
let refreshTimer = null;
const CLASSIFICACAO_LABELS = {
excelente: 'Excelente', bom: 'Bom', medio: 'Médio', baixo: 'Baixo', muito_baixo: 'Muito Baixo'
};
const PRIORIDADE_LABELS = {
1: 'P1 — atacar imediatamente', 2: 'P2 — trabalhar durante a semana',
3: 'P3 — quando não houver prioritários', 4: 'P4 — só em campanhas específicas'
};
function estrelas(n) {
const cheias = Math.max(0, Math.min(5, Number(n) || 0));
return '★'.repeat(cheias) + '☆'.repeat(5 - cheias);
}
// ---------- filtros ----------
function filtroAtual() {
const filtro = {};
for (const input of document.querySelectorAll('#filtros [data-filtro]')) {
const value = input.value.trim();
if (value) filtro[input.dataset.filtro] = value;
}
return filtro;
}
function filtroQuery(extra = {}) {
return new URLSearchParams({ ...filtroAtual(), ...extra }).toString();
}
// Clique nos cards/barras do dashboard: aplica o filtro somando aos ja
// ativos; clicar de novo no mesmo valor remove so aquele filtro.
function toggleFiltro(inputId, valor) {
const input = el(inputId);
const novo = String(valor ?? '');
input.value = input.value === novo ? '' : novo;
atualizar();
}
function descreverFiltro() {
const f = filtroAtual();
const partes = [];
if (f.uf) partes.push(`UF ${f.uf}`);
if (f.cidade) partes.push(f.cidade);
if (f.segmento) partes.push(el('f-segmento').selectedOptions[0]?.textContent || f.segmento);
if (f.classificacao) partes.push(CLASSIFICACAO_LABELS[f.classificacao] || f.classificacao);
if (f.prioridade) partes.push(`P${f.prioridade}`);
if (f.produto) partes.push(`produto: ${f.produto}`);
if (f.interesse) partes.push(f.interesse);
if (f.telefone_valido) partes.push(f.telefone_valido === 'sim' ? 'com WhatsApp' : 'sem WhatsApp');
if (f.website) partes.push(f.website === 'sim' ? 'com website' : 'sem website');
if (f.email) partes.push(f.email === 'sim' ? 'com e-mail' : 'sem e-mail');
if (f.exportado) partes.push(f.exportado === 'nao' ? 'ainda não exportados' : 'já exportados');
if (f.q) partes.push(`busca "${f.q}"`);
return partes.length ? partes.join(' · ') : 'todos os leads';
}
// ---------- carregamento ----------
async function atualizar() {
page = 1;
const [summary, evolutionRes, oportunidadeRes] = await Promise.all([
API.get(`/api/dashboard/summary?${filtroQuery()}`),
API.get(`/api/dashboard/evolution?dias=14&${filtroQuery()}`),
API.get(`/api/dashboard/oportunidade?${filtroQuery()}`)
]);
await Promise.all([carregarTiles(summary), carregarRankings(summary), carregarFunil(summary), carregarTabela()]);
carregarEvolucaoDiaria(evolutionRes.evolution);
carregarMaiorOportunidade(oportunidadeRes.oportunidade);
}
function invalidar() {
carregado = false;
clearTimeout(refreshTimer);
refreshTimer = setTimeout(() => {
if (document.querySelector('#tab-tratamento.active')) ativar();
}, 800);
}
async function ativar() {
if (carregado) return;
carregado = true;
await Promise.all([carregarFacets(), atualizar(), carregarExports()]);
}
async function carregarFacets() {
const facets = await API.get('/api/leads/facets');
const fill = (select, valores, prefixo, formatar = (v) => v) => {
const atual = select.value;
const primeira = select.options[0];
select.replaceChildren(primeira, ...valores.map((item) =>
h('option', { value: item.value }, `${formatar(item.value)} (${fmt.format(item.total)})`)
));
select.value = atual;
};
fill(el('f-uf'), facets.ufs);
fill(el('f-interesse'), facets.interesses);
fill(el('f-produto'), facets.produtos);
const segSelect = el('f-segmento');
const atual = segSelect.value;
segSelect.replaceChildren(segSelect.options[0], ...facets.segmentos.map((s) =>
h('option', { value: s.value }, `${s.value.replace(/_/g, ' ')} (${fmt.format(s.total)})`)
));
segSelect.value = atual;
el('dl-cidades').replaceChildren(...facets.cidades.map((c) => h('option', { value: c.value })));
}
async function salvarMetaDiaria(valor) {
const meta = Math.max(1, Number(valor) || 100);
await API.put('/api/settings', { meta_diaria: meta });
const summary = await API.get(`/api/dashboard/summary?${filtroQuery()}`);
carregarTiles(summary);
}
async function carregarTiles(s) {
const pct = (n) => s.total ? ` (${((n / s.total) * 100).toFixed(1)}%)` : '';
el('stat-tiles').replaceChildren(
tile(s.total, 'Leads no filtro', ''),
tile(s.com_whatsapp, 'Com WhatsApp', pct(s.com_whatsapp), 't-good'),
tile(s.p1, '🎯 Potencial Comercial Imediato', `${s.potencial_pct}% da base — atacar agora`, 't-good'),
tile(s.p4, '📦 P4 — campanhas específicas', pct(s.p4))
);
el('stat-tiles').append(
cardCobertura(s),
cardPrioridades(s),
cardMeta(s)
);
function tile(valor, rotulo, detalhe, cls = '') {
return h('div', { class: `tile ${cls}` },
h('div', { class: 'valor' }, fmt.format(valor)),
h('div', { class: 'rotulo' }, rotulo),
h('div', { class: 'detalhe' }, detalhe)
);
}
function cardCobertura(s) {
const cidadeAtiva = filtroAtual().cidade;
const titulo = cidadeAtiva ? `Cobertura em ${cidadeAtiva}` : 'Base Comercial — cobertura';
return h('div', { class: 'tile tile-wide' },
h('div', { class: 'rotulo' }, titulo),
h('div', { class: 'progress-bar' },
h('div', { class: 'progress-fill', style: `width:${s.cobertura_pct}%` })),
h('div', { class: 'detalhe' },
`${s.cobertura_pct}% trabalhada · ${fmt.format(s.exportados)} exportadas · ${fmt.format(s.disponiveis)} disponíveis`)
);
}
function cardPrioridades(s) {
const ativa = filtroAtual().prioridade;
const linhas = [
['P1', s.p1, s.p1_pct], ['P2', s.p2, s.p2_pct], ['P3', s.p3, s.p3_pct], ['P4', s.p4, s.p4_pct]
];
return h('div', { class: 'tile tile-wide' },
h('div', { class: 'rotulo' }, 'Distribuição por prioridade'),
h('div', { class: 'ranking', style: 'margin-top:6px' }, ...linhas.map(([label, total, p], i) =>
h('div', {
class: `rank-row${ativa === String(i + 1) ? ' ativo' : ''}`,
title: `${label}: ${fmt.format(total)} leads — clique para filtrar`,
onclick: () => toggleFiltro('f-prioridade', i + 1)
},
h('div', { class: 'rank-label' }, label),
h('div', { class: 'rank-track' }, h('div', { class: 'rank-fill', style: `width:${Math.max(1.5, p)}%` })),
h('div', { class: 'rank-value' }, `${p}%`)
)))
);
}
function cardMeta(s) {
return h('div', { class: 'tile tile-wide' },
h('div', { class: 'row space-between' },
h('div', { class: 'rotulo' }, '🎯 Meta diária'),
h('input', {
type: 'number', min: '1', value: String(s.meta_diaria), class: 'meta-input',
onchange: (e) => salvarMetaDiaria(e.target.value)
})
),
h('div', { class: 'progress-bar' },
h('div', { class: 'progress-fill', style: `width:${Math.min(100, s.meta_pct)}%` })),
h('div', { class: 'detalhe' },
`${fmt.format(s.hoje_exportados)} de ${fmt.format(s.meta_diaria)} hoje (${s.meta_pct}%) · ~${fmt.format(s.dias_estimados)} dias úteis restantes no ritmo atual`)
);
}
}
async function carregarRankings(summary) {
const cidadeAtiva = filtroAtual().cidade;
el('rank-produtos-titulo').textContent = cidadeAtiva ? `Produtos recomendados em ${cidadeAtiva}` : 'Top produto recomendado';
const [cidades, segmentos, produtos] = await Promise.all([
API.get(`/api/dashboard/rankings?by=cidade&limit=10&${filtroQuery()}`),
API.get(`/api/dashboard/rankings?by=segmento&limit=10&${filtroQuery()}`),
API.get(`/api/dashboard/rankings?by=produto&limit=10&${filtroQuery()}`)
]);
const f = filtroAtual();
const segmentoValuePorLabel = (label) => {
const opts = [...el('f-segmento').options];
const porValor = opts.find((o) => o.value && o.value.replace(/_/g, ' ').toLowerCase() === label.toLowerCase());
const porTexto = opts.find((o) => o.textContent.toLowerCase().startsWith(label.toLowerCase()));
return (porValor || porTexto)?.value || '';
};
renderRanking(el('rank-cidades'), cidades.ranking,
(label) => toggleFiltro('f-cidade', label),
null,
(label) => f.cidade === label);
renderRanking(el('rank-segmentos'), segmentos.ranking,
(label) => toggleFiltro('f-segmento', segmentoValuePorLabel(label)),
null,
(label) => !!f.segmento && f.segmento === segmentoValuePorLabel(label));
renderRanking(el('rank-produtos'), produtos.ranking,
(label) => toggleFiltro('f-produto', label),
summary.total,
(label) => f.produto === label);
carregarOportunidadesDoDia(produtos.ranking, summary.total);
}
function carregarOportunidadesDoDia(produtosRanking, totalGeral) {
const top3 = produtosRanking.slice(0, 3);
if (!top3.length) {
el('card-oportunidades-dia').replaceChildren(
h('h2', {}, '🔥 Oportunidades do Dia'),
h('div', { class: 'muted' }, 'Sem dados para este filtro.')
);
return;
}
const produtoAtivo = filtroAtual().produto;
el('card-oportunidades-dia').replaceChildren(
h('h2', {}, '🔥 Oportunidades do Dia'),
h('div', { class: 'ranking', style: 'margin-top:6px' }, ...top3.map((r) => {
const p = totalGeral ? Math.round((r.total / totalGeral) * 100) : 0;
return h('div', {
class: `rank-row${produtoAtivo === r.label ? ' ativo' : ''}`,
title: `${r.label}: ${fmt.format(r.total)} leads — clique para filtrar`,
onclick: () => toggleFiltro('f-produto', r.label)
},
h('div', { class: 'rank-label' }, r.label),
h('div', { class: 'rank-track' }, h('div', { class: 'rank-fill', style: `width:${Math.max(1.5, p)}%` })),
h('div', { class: 'rank-value' }, `${p}%`)
);
}))
);
}
function dataBrCurta(diaIso) {
const [, mes, dia] = diaIso.split('-');
return `${dia}/${mes}`;
}
function carregarEvolucaoDiaria(evolution) {
if (!evolution.length) {
el('card-evolucao-diaria').replaceChildren(
h('h2', {}, '📈 Evolução Diária'),
h('div', { class: 'muted' }, 'Nenhuma exportação registrada ainda.')
);
return;
}
const max = Math.max(...evolution.map((d) => d.total));
el('card-evolucao-diaria').replaceChildren(
h('h2', {}, '📈 Evolução Diária'),
h('div', { class: 'muted', style: 'margin:2px 0 8px' }, 'Exportações por dia — única atividade com data registrada hoje.'),
h('div', { class: 'ranking' }, ...evolution.map((d) => h('div', { class: 'rank-row nao-clicavel' },
h('div', { class: 'rank-label' }, dataBrCurta(d.dia)),
h('div', { class: 'rank-track' }, h('div', { class: 'rank-fill', style: `width:${Math.max(1.5, (d.total / max) * 100)}%` })),
h('div', { class: 'rank-value' }, fmt.format(d.total))
)))
);
}
function carregarMaiorOportunidade(oportunidade) {
if (!oportunidade) {
el('card-maior-oportunidade').replaceChildren(
h('h2', {}, '🏆 Maior Oportunidade'),
h('div', { class: 'muted' }, 'Sem dados suficientes para este filtro.')
);
return;
}
const f = filtroAtual();
const jaAplicado = f.cidade === oportunidade.cidade && f.produto === oportunidade.produto;
el('card-maior-oportunidade').replaceChildren(
h('h2', {}, '🏆 Maior Oportunidade'),
h('div', {
class: `row space-between oportunidade-clicavel${jaAplicado ? ' ativo' : ''}`,
style: 'margin-top:6px',
title: jaAplicado ? 'Clique para remover o filtro cidade + produto' : 'Clique para filtrar por esta cidade + produto',
onclick: () => {
el('f-cidade').value = jaAplicado ? '' : oportunidade.cidade;
el('f-produto').value = jaAplicado ? '' : oportunidade.produto;
atualizar();
}
},
h('div', {}, h('div', { class: 'rotulo' }, 'Cidade'), h('div', { class: 'valor', style: 'font-size:20px' }, oportunidade.cidade)),
h('div', {}, h('div', { class: 'rotulo' }, 'Produto'), h('div', { class: 'valor', style: 'font-size:20px' }, oportunidade.produto)),
h('div', {}, h('div', { class: 'rotulo' }, 'Empresas'), h('div', { class: 'valor', style: 'font-size:20px' }, fmt.format(oportunidade.total)))
)
);
}
function renderRanking(box, ranking, onClick, totalGeral, isAtivo = () => false) {
if (!ranking.length) {
box.replaceChildren(h('div', { class: 'muted' }, 'Sem dados para este filtro.'));
return;
}
const max = ranking[0].total;
box.replaceChildren(...ranking.map((r) => {
const pctGeral = totalGeral ? ` · ${Math.round((r.total / totalGeral) * 100)}%` : '';
const ativo = isAtivo(r.label);
return h('div', {
class: `rank-row${ativo ? ' ativo' : ''}`,
title: `${r.label}: ${fmt.format(r.total)} leads (${fmt.format(r.p1)} prioridade 1) — ${ativo ? 'clique para remover o filtro' : 'clique para filtrar'}`,
onclick: () => onClick(r.label)
},
h('div', { class: 'rank-label' }, r.label),
h('div', { class: 'rank-track' },
h('div', { class: 'rank-fill', style: `width:${Math.max(1.5, (r.total / max) * 100)}%` })),
h('div', { class: 'rank-value' }, `${fmt.format(r.total)}${pctGeral}`)
);
}));
}
const FUNIL_ETAPAS = [
{ key: 'total', label: 'Leads capturados' },
{ key: 'disponiveis', label: 'Leads disponíveis' },
{ key: 'exportados', label: 'Leads exportados' },
{ key: null, label: 'Empresas contatadas' },
{ key: null, label: 'Empresas interessadas' },
{ key: null, label: 'Propostas enviadas' },
{ key: null, label: 'Clientes convertidos' }
];
function carregarFunil(s) {
const base = s.total || 1;
el('card-funil').replaceChildren(
h('h2', {}, 'Evolução Comercial'),
h('div', { class: 'funil' }, ...FUNIL_ETAPAS.map((etapa) => {
const pendente = etapa.key === null;
const valor = pendente ? null : s[etapa.key];
const largura = pendente ? 0 : Math.max(1.5, (valor / base) * 100);
return h('div', { class: `funil-etapa${pendente ? ' pendente' : ''}`,
title: pendente ? 'Aguardando integração com CRM' : '' },
h('div', { class: 'funil-label' }, etapa.label),
h('div', { class: 'funil-barra' }, h('div', { class: 'funil-fill', style: `width:${largura}%` })),
h('div', { class: 'funil-valor' }, pendente ? '—' : fmt.format(valor))
);
}))
);
}
async function carregarTabela() {
const data = await API.get(`/api/leads?${filtroQuery({ page, pageSize: 50 })}`);
totalPages = Math.max(1, Math.ceil(data.total / data.pageSize));
el('leads-total').textContent = `— ${fmt.format(data.total)} encontrados`;
el('pg-info').textContent = `página ${data.page} de ${fmt.format(totalPages)}`;
el('pg-prev').disabled = page <= 1;
el('pg-next').disabled = page >= totalPages;
const tbody = document.querySelector('#tabela-leads tbody');
if (!data.rows.length) {
tbody.replaceChildren(h('tr', {}, h('td', { colspan: '9', class: 'muted' }, 'Nenhum lead com este filtro.')));
return;
}
tbody.replaceChildren(...data.rows.map((lead) => h('tr', { onclick: () => abrirLead(lead.id) },
h('td', { class: 'empresa-cell', title: lead.legal_name || lead.empresa }, lead.empresa),
h('td', {}, lead.whatsapp || '—'),
h('td', {}, lead.cidade),
h('td', {}, lead.segmento_label || lead.segmento),
h('td', {}, h('span', { class: `badge prioridade-${lead.prioridade}` }, `P${lead.prioridade}`)),
h('td', { class: 'num' }, String(lead.score)),
h('td', {}, h('span', { class: `badge ${lead.classificacao}` }, CLASSIFICACAO_LABELS[lead.classificacao] || lead.classificacao)),
h('td', { class: 'produto-cell' }, produtoCell(lead.produto_principal, lead.afinidade)),
h('td', {}, lead.exported_at ? '📤 sim' : '—')
)));
}
const AFINIDADE_TABELA_LABELS = { alta: 'Alta aderência', media: 'Média aderência', baixa: 'Baixa aderência' };
function produtoCell(produto, afinidade) {
if (!produto) return '—';
const label = AFINIDADE_TABELA_LABELS[afinidade];
return label ? `${produto} · ${label}` : produto;
}
const OBSERVACAO_POR_PRIORIDADE = {
1: 'Empresa apta para abordagem imediata.',
2: 'Aguardar a janela de trabalho desta semana.',
3: 'Abordar quando não houver leads de prioridade mais alta.',
4: 'Reservar para campanhas específicas — baixa prioridade comercial.'
};
const AFINIDADE_LABELS = { alta: 'nicho de alta aderência', media: 'nicho de média aderência', baixa: 'nicho de baixa aderência' };
function montarResumoComercial(lead) {
const potencial = Math.max(1, Math.min(5, Math.round((lead.score_comercial || 0) / 20)));
const pontosFortes = [];
if (lead.telefone_valido) pontosFortes.push('WhatsApp válido');
if (lead.cnpj_valido) pontosFortes.push('CNPJ válido');
if (lead.website) pontosFortes.push('Website');
if (lead.email) pontosFortes.push('E-mail cadastrado');
if (lead.afinidade) pontosFortes.push(AFINIDADE_LABELS[lead.afinidade] || 'nicho identificado');
return h('div', { class: 'resumo-comercial' },
h('h3', {}, 'Resumo comercial'),
h('div', { class: 'row space-between' },
h('span', { class: 'estrelas', title: `Potencial comercial: ${potencial}/5` }, estrelas(potencial)),
h('span', { class: `badge prioridade-${lead.prioridade}` }, PRIORIDADE_LABELS[lead.prioridade] || `P${lead.prioridade}`)
),
lead.produtos && lead.produtos.length
? h('div', {}, h('div', { class: 'muted', style: 'margin:8px 0 4px' }, 'Produtos recomendados'),
...lead.produtos.map((p) => h('div', {}, h('span', { class: 'estrelas' }, estrelas(p.estrelas)), ` ${p.nome}`)))
: h('div', { class: 'muted', style: 'margin-top:8px' }, 'Nenhum produto sugerido para este nicho.'),
pontosFortes.length
? h('ul', { class: 'pontos-fortes', style: 'margin-top:10px' }, ...pontosFortes.map((p) => h('li', {}, `✓ ${p}`)))
: null,
h('p', { class: 'muted', style: 'margin:10px 0 0' }, OBSERVACAO_POR_PRIORIDADE[lead.prioridade] || '')
);
}
function montarScoreBreakdown(lead) {
const nichoPontos = lead.afinidade === 'alta' ? '+40' : lead.afinidade === 'baixa' ? '+20' : '+30';
const qualidade = [
[lead.telefone_valido, 'WhatsApp válido', '+30'],
[lead.cnpj_valido, 'CNPJ válido', '+20'],
[lead.empresa_valida, 'Empresa válida', '+20'],
[!!lead.cidade, 'Cidade identificada', '+5'],
[!!lead.segmento, 'Nicho identificado', '+10'],
[!!lead.cnae, 'CNAE identificado', '+10'],
[!!lead.website, 'Website informado', '+5']
];
const comercial = [
[true, `Nicho ${AFINIDADE_TABELA_LABELS[lead.afinidade] || 'de aderência não identificada'}`, nichoPontos],
[!!lead.website, 'Website informado', '+15'],
[!!lead.email, 'E-mail informado', '+10'],
[lead.telefone_valido, 'WhatsApp válido', '+15'],
[lead.cnpj_valido, 'CNPJ válido', '+20']
];
const bloco = (itens, titulo, valorTotal) => h('div', {},
h('div', { class: 'row space-between', style: 'margin-top:10px' },
h('strong', {}, titulo), h('span', {}, String(valorTotal))),
h('ul', { class: 'score-breakdown' }, ...itens.map(([ok, label, pontos]) =>
h('li', { class: ok ? 'ok' : 'nok' }, `${ok ? '✓' : '✗'} ${label} (${pontos})`)))
);
return h('div', { class: 'score-breakdown-wrap' },
bloco(qualidade, 'Score Qualidade', lead.score_qualidade),
bloco(comercial, 'Score Comercial', lead.score_comercial)
);
}
async function abrirLead(id) {
const lead = await API.get(`/api/leads/${id}`);
const linha = (dt, dd) => [h('dt', {}, dt), h('dd', {}, dd || '—')];
el('lead-detalhe').replaceChildren(
h('h2', {}, lead.empresa),
montarResumoComercial(lead),
montarScoreBreakdown(lead),
h('dl', { class: 'detalhe-grid', style: 'margin-top:14px' },
linha('Razão social', lead.legal_name),
linha('WhatsApp', lead.whatsapp),
linha('Cidade/UF', `${lead.cidade}/${lead.uf}`),
linha('Endereço', lead.endereco),
linha('Nicho', lead.segmento_label || lead.segmento),
linha('Interesse', lead.interesse),
linha('CNPJ', lead.cnpj),
linha('CNAE', lead.cnae),
linha('Score geral', `${lead.score} (${CLASSIFICACAO_LABELS[lead.classificacao] || lead.classificacao})`),
linha('Dores', (lead.pains || []).join(', ')),
linha('Origem', lead.origem),
linha('Capturado em', dataBr(lead.captured_at || lead.created_at)),
linha('Exportado em', lead.exported_at ? dataBr(lead.exported_at) : 'ainda não'),
linha('Exportações', lead.exports.length
? lead.exports.map((e) => `#${e.id} ${e.tipo}`).join(', ')
: 'nenhuma')
),
h('div', { class: 'row space-between', style: 'margin-top:14px' },
h('button', { class: 'btn-ghost', onclick: () => el('modal-lead').close() }, 'Fechar')
)
);
el('modal-lead').showModal();
}
// ---------- exportação ----------
const EXPORT_ATALHOS = [
{ label: 'Só P1', set: () => { el('f-prioridade').value = '1'; } },
{ label: 'Só ERP', set: () => { el('f-produto').value = 'ERP'; } },
{ label: 'Só G-Chat', set: () => { el('f-produto').value = 'G-Chat'; } },
{ label: 'Só Sistema de Ponto', set: () => { el('f-produto').value = 'Sistema de Ponto'; } },
{ label: 'Não exportados', set: () => { el('f-exportado').value = 'nao'; } }
];
async function atualizarRecapExport() {
const { total } = await API.get(`/api/dashboard/summary?${filtroQuery()}`);
el('export-recap').textContent = `${fmt.format(total)} leads · ${descreverFiltro()}`;
return total;
}
function renderAtalhosExport() {
el('export-atalhos').replaceChildren(...EXPORT_ATALHOS.map((atalho) =>
h('button', {
type: 'button', class: 'chip', onclick: async () => {
atalho.set();
await atualizar();
await atualizarRecapExport();
}
}, atalho.label)));
}
async function abrirModalExport() {
const total = await atualizarRecapExport();
if (!total) {
toast('Nenhum lead com este filtro — ajuste os filtros antes de exportar');
return;
}
renderAtalhosExport();
const { settings } = await API.get('/api/settings');
el('zp-pipeline').value = settings['zpro.pipelineId'] || 1;
el('zp-stage').value = settings['zpro.stageId'] || 1;
el('zp-responsible').value = settings['zpro.responsibleId'] || 15;
el('modal-export').showModal();
}
async function confirmarExport(event) {
event.preventDefault();
const tipo = document.querySelector('input[name="export-tipo"]:checked').value;
const body = {
tipo,
filtro: filtroAtual(),
params: {
marcarExportado: el('export-marcar').checked,
pipelineId: Number(el('zp-pipeline').value) || undefined,
stageId: Number(el('zp-stage').value) || undefined,
responsibleId: Number(el('zp-responsible').value) || undefined,
mensagem: tipo === 'zpro-bulk' ? el('export-mensagem').value : undefined
}
};
try {
const { jobId, totalEstimado } = await API.post('/api/exports', body);
el('modal-export').close();
toast(`Gerando arquivos para ${fmt.format(totalEstimado)} leads...`);
API.watchJob(jobId, {
done() {
toast('✅ Exportação pronta! Baixe os arquivos na lista abaixo.');
carregarExports();
atualizar();
},
error(e) {
toast(`❌ Erro na exportação: ${e.message}`);
carregarExports();
}
});
} catch (error) {
toast(`Erro: ${error.message}`);
}
}
async function carregarExports() {
const { exports } = await API.get('/api/exports?limit=12');
const box = el('historico-exports');
if (!exports.length) {
box.replaceChildren(h('div', { class: 'muted' }, 'Nenhuma exportação gerada ainda.'));
return;
}
const tipoLabel = {
excel: '📊 Planilha', 'zpro-oportunidades': '🗂️ Z-PRO Oportunidades',
'zpro-bulk': '📨 Z-PRO Disparo em massa', legado: '📦 Importação antiga (Z-PRO)'
};
box.replaceChildren(...exports.map((e) => h('div', { class: 'item' },
h('div', {},
h('div', {}, h('strong', {}, `#${e.id} · ${tipoLabel[e.tipo] || e.tipo}`),
` · ${fmt.format(e.total_leads)} leads`),
h('div', { class: 'sub' }, `${dataBr(e.created_at)} · ${e.status}${e.error ? ` · ${e.error.slice(0, 60)}` : ''}`)
),
h('div', { class: 'row' }, ...(e.status === 'done' && e.tipo !== 'legado'
? e.arquivos.map((nome) =>
h('a', { class: 'btn-ghost', href: `/api/exports/${e.id}/download?arquivo=${encodeURIComponent(nome)}` },
`⬇ ${nome.split('.').pop().toUpperCase()}`))
: []))
)));
}
// ---------- eventos ----------
function bindEvents() {
let debounce = null;
for (const input of document.querySelectorAll('#filtros [data-filtro]')) {
input.addEventListener(input.tagName === 'SELECT' ? 'change' : 'input', () => {
clearTimeout(debounce);
debounce = setTimeout(atualizar, input.tagName === 'SELECT' ? 0 : 350);
});
}
el('btn-limpar-filtros').addEventListener('click', () => {
for (const input of document.querySelectorAll('#filtros [data-filtro]')) input.value = '';
atualizar();
});
el('pg-prev').addEventListener('click', () => { page -= 1; carregarTabela(); });
el('pg-next').addEventListener('click', () => { page += 1; carregarTabela(); });
el('btn-exportar').addEventListener('click', abrirModalExport);
el('form-export').addEventListener('submit', confirmarExport);
for (const radio of document.querySelectorAll('input[name="export-tipo"]')) {
radio.addEventListener('change', () => {
el('export-mensagem-wrap').classList.toggle('hidden', radio.value !== 'zpro-bulk');
el('export-zpro-params').classList.toggle('hidden', radio.value === 'excel');
});
}
el('export-zpro-params').classList.add('hidden');
}
return { ativar, invalidar, bindEvents };
})();
window.Tratamento = Tratamento;
+33
View File
@@ -0,0 +1,33 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GTI Máquina Comercial — Login</title>
<link rel="stylesheet" href="/css/app.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎯</text></svg>">
</head>
<body class="login-body">
<main class="login-wrap">
<form class="card login-card" id="form-login">
<div class="brand login-brand">
<span class="brand-mark">GTI</span>
<span class="brand-name">Máquina Comercial</span>
</div>
<div class="field">
<label>Usuário <input type="text" id="login-username" autocomplete="username" required></label>
</div>
<div class="field">
<label>Senha <input type="password" id="login-password" autocomplete="current-password" required></label>
</div>
<p class="muted login-erro" id="login-erro"></p>
<button class="btn btn-primary btn-lg" type="submit">Entrar</button>
</form>
</main>
<div id="toast" class="toast hidden"></div>
<script src="/js/api.js"></script>
<script src="/js/login.js"></script>
</body>
</html>
+44
View File
@@ -0,0 +1,44 @@
#!/bin/bash
# Baixa a base completa de CNPJ da Receita Federal (extrato mais recente) a partir do
# espelho publico da Casa dos Dados (mesma base oficial, servida via Cloudflare).
# Uso: bash scripts/baixar-base-rfb.sh
set -e
BASE_URL="https://dados-abertos-rf-cnpj.casadosdados.com.br/arquivos/2026-06-14"
DEST_DIR="prospeccao/cnpj/rfb-raw"
mkdir -p "$DEST_DIR"
FILES=(
Cnaes.zip Motivos.zip Municipios.zip Naturezas.zip Paises.zip Qualificacoes.zip Simples.zip
Empresas0.zip Empresas1.zip Empresas2.zip Empresas3.zip Empresas4.zip
Empresas5.zip Empresas6.zip Empresas7.zip Empresas8.zip Empresas9.zip
Estabelecimentos0.zip Estabelecimentos1.zip Estabelecimentos2.zip Estabelecimentos3.zip Estabelecimentos4.zip
Estabelecimentos5.zip Estabelecimentos6.zip Estabelecimentos7.zip Estabelecimentos8.zip Estabelecimentos9.zip
Socios0.zip Socios1.zip Socios2.zip Socios3.zip Socios4.zip
Socios5.zip Socios6.zip Socios7.zip Socios8.zip Socios9.zip
)
TOTAL=${#FILES[@]}
COUNT=0
for FILE in "${FILES[@]}"; do
COUNT=$((COUNT+1))
DEST="$DEST_DIR/$FILE"
if [ -f "$DEST" ]; then
EXPECTED=$(curl -sI --max-time 20 "$BASE_URL/$FILE" | grep -i content-length | tr -d '\r' | awk '{print $2}')
ACTUAL=$(stat -c%s "$DEST" 2>/dev/null || stat -f%z "$DEST" 2>/dev/null)
if [ -n "$EXPECTED" ] && [ "$EXPECTED" == "$ACTUAL" ]; then
echo "[$COUNT/$TOTAL] $FILE ja existe e esta completo ($ACTUAL bytes). Pulando."
continue
fi
fi
echo "[$COUNT/$TOTAL] baixando $FILE ..."
curl -sS --retry 3 --retry-delay 5 --max-time 1800 -o "$DEST" "$BASE_URL/$FILE"
SIZE=$(stat -c%s "$DEST" 2>/dev/null || stat -f%z "$DEST" 2>/dev/null)
echo "[$COUNT/$TOTAL] $FILE concluido ($SIZE bytes)"
done
echo "download completo."
du -sh "$DEST_DIR"
+22
View File
@@ -0,0 +1,22 @@
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
$target = Join-Path $root "GTI-Comercial.exe"
$source = Join-Path $root "launcher\Program.cs"
$sdk = & dotnet --list-sdks 2>$null
if ($sdk) {
$project = Join-Path $root "launcher\GTI-Comercial.csproj"
$publish = Join-Path $root "launcher\publish"
dotnet publish $project -c Release -r win-x64 --self-contained false -p:PublishSingleFile=true -o $publish
$exe = Join-Path $publish "GTI-Comercial.exe"
Copy-Item -LiteralPath $exe -Destination $target -Force
} else {
$csc = "C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe"
if (-not (Test-Path -LiteralPath $csc)) {
throw "Compilador C# nao encontrado: $csc"
}
& $csc /nologo /target:exe /out:$target $source
}
Write-Host "Executavel gerado em: $target"
+113
View File
@@ -0,0 +1,113 @@
import { loadDotEnv } from '../src/env.mjs';
import { openDb, ensureSeeds } from '../src/db.mjs';
import { runCapture, createCaptureRun } from '../src/capture/runner.mjs';
import { describeSources } from '../src/sources/index.mjs';
// CLI de captura (uso avancado / testes). A interface principal e o app:
// node scripts/servidor.mjs (ou o GTI-Comercial.exe)
//
// Exemplos:
// node scripts/capturar-leads.mjs --sources=mock
// node scripts/capturar-leads.mjs --sources=cnpj-base --uf=BA --cities="Santo Antonio de Jesus"
// node scripts/capturar-leads.mjs --sources=cnpj-base,overpass --uf=BA --cities=all --segments=all
await loadDotEnv();
const args = new Map(
process.argv.slice(2).map((arg) => {
const [key, value = 'true'] = arg.replace(/^--/, '').split('=');
return [key, value];
})
);
function usage() {
const fontes = describeSources().map((s) => ` ${s.id} — ${s.label}${s.disponivel ? '' : ` (indisponivel: ${s.motivo})`}`);
return [
'Uso: node scripts/capturar-leads.mjs --sources=<ids> [opcoes]',
'',
'Opcoes:',
' --sources=cnpj-base,overpass fontes separadas por virgula (ou "mock")',
' --uf=BA estado alvo',
' --cities=all | "A,B,C" cidades (all = todas as cidades da UF na base)',
' --segments=all | "a,b,c" nichos pelo nome (all = todos os 57)',
' --segment-limit=N usa so os N primeiros nichos',
' --city-limit=N usa so as N primeiras cidades',
' --max=10 max de resultados por consulta (fontes de API)',
' --query-limit=0 max total de consultas (0 = sem limite)',
'',
'Fontes:',
...fontes
].join('\n');
}
async function main() {
if (args.has('help')) {
console.log(usage());
return;
}
const db = openDb();
await ensureSeeds(db, { log: (m) => console.log(`[captura] ${m}`) });
const uf = (args.get('uf') || 'BA').toUpperCase();
const sources = (args.get('sources') || 'mock').split(',').map((s) => s.trim()).filter(Boolean);
let segments = db.prepare('SELECT * FROM segments WHERE active = 1 ORDER BY id').all().map((row) => ({
name: row.name,
label: row.label,
interest: row.interest,
queries: JSON.parse(row.queries_json),
cnaeCodes: JSON.parse(row.cnae_codes_json),
overpassTags: JSON.parse(row.overpass_tags_json),
pains: JSON.parse(row.pains_json)
}));
const segmentsArg = args.get('segments');
if (segmentsArg && segmentsArg !== 'all') {
const wanted = new Set(segmentsArg.split(',').map((s) => s.trim()));
segments = segments.filter((s) => wanted.has(s.name));
}
const segmentLimit = Number(args.get('segment-limit') || 0);
if (segmentLimit > 0) segments = segments.slice(0, segmentLimit);
let cities;
const citiesArg = args.get('cities') || 'all';
if (citiesArg === 'all') {
cities = db.prepare('SELECT nome FROM municipios WHERE uf = ? ORDER BY nome').all(uf).map((r) => r.nome);
} else {
cities = citiesArg.split(',').map((c) => c.trim()).filter(Boolean);
}
const cityLimit = Number(args.get('city-limit') || 0);
if (cityLimit > 0) cities = cities.slice(0, cityLimit);
const params = {
sources,
uf,
cities,
segments,
limits: {
maxPerQuery: Number(args.get('max') || 10),
queryLimit: Number(args.get('query-limit') || 0),
googlePages: Number(args.get('google-pages') || 3)
}
};
console.log(`[captura] fontes: ${sources.join(', ')} | UF: ${uf} | cidades: ${cities.length} | nichos: ${segments.length}`);
const runId = createCaptureRun(db, { ...params, segments: segments.map((s) => s.name) });
const summary = await runCapture({
db,
runId,
params,
emit: (p) => console.log(`[captura] ${p.phase}: ${p.message || ''}`),
log: (m) => console.log(`[captura] ${m}`)
});
console.log(`[captura] resultado: ${summary.raw} brutos, ${summary.new} novos, ${summary.dup} duplicados (${summary.status})`);
db.close();
}
main().catch((error) => {
console.error(`[captura] erro: ${error.stack || error.message}`);
process.exit(1);
});
+76
View File
@@ -0,0 +1,76 @@
import path from 'node:path';
import { loadDotEnv, paths } from '../src/env.mjs';
import { openDb, ensureSeeds } from '../src/db.mjs';
import { extractUf } from '../src/extract/rfb-extractor.mjs';
// Extrai a base CNPJ de uma UF inteira a partir dos zips da Receita Federal:
// node scripts/extrair-base-cnpj.mjs --uf=BA
// Pre-requisito: zips baixados em prospeccao/cnpj/rfb-raw (scripts/baixar-base-rfb.sh).
await loadDotEnv();
const args = new Map(
process.argv.slice(2).map((arg) => {
const [key, value = 'true'] = arg.replace(/^--/, '').split('=');
return [key, value];
})
);
const uf = (args.get('uf') || '').toUpperCase();
if (!/^[A-Z]{2}$/.test(uf)) {
console.error('Uso: node scripts/extrair-base-cnpj.mjs --uf=BA');
process.exit(1);
}
const rawDir = args.get('raw-dir') || paths.rfbRaw;
const outCsv = path.join(paths.cnpjDir, `estabelecimentos-${uf}.csv`);
const checkpointPath = path.join(paths.cnpjDir, `checkpoint-estabelecimentos-${uf}.ndjson`);
async function main() {
const db = openDb();
await ensureSeeds(db, { log: (m) => console.log(`[extrair] ${m}`) });
const inserted = db.prepare(
"INSERT INTO extractions (uf, status, started_at) VALUES (?, 'running', datetime('now'))"
).run(uf);
const extractionId = Number(inserted.lastInsertRowid);
db.prepare('UPDATE extractions SET pid = ? WHERE id = ?').run(process.pid, extractionId);
try {
const result = await extractUf({
uf,
rawDir,
outCsv,
checkpointPath,
db,
log: (m) => console.log(`[extrair] ${m}`),
onProgress: (p) => {
console.log(`[extrair] ${p.pct}% ${p.phase}: ${p.message}`);
db.prepare('UPDATE extractions SET progress_json = ? WHERE id = ?')
.run(JSON.stringify(p), extractionId);
}
});
if (result.cancelled) {
db.prepare("UPDATE extractions SET status = 'cancelled', finished_at = datetime('now') WHERE id = ?")
.run(extractionId);
} else {
db.prepare(
`UPDATE extractions SET status = 'done', csv_path = ?, total_rows = ?, with_phone = ?,
municipios_count = ?, finished_at = datetime('now') WHERE id = ?`
).run(result.csvPath, result.totalRows, result.withPhone, result.municipiosCount, extractionId);
}
} catch (error) {
db.prepare(
"UPDATE extractions SET status = 'error', error = ?, finished_at = datetime('now') WHERE id = ?"
).run(String(error.message || error), extractionId);
throw error;
} finally {
db.close();
}
}
main().catch((error) => {
console.error(`[extrair] erro: ${error.stack || error.message}`);
process.exit(1);
});
+85
View File
@@ -0,0 +1,85 @@
import { loadDotEnv } from '../src/env.mjs';
import { openDb, transaction } from '../src/db.mjs';
import { scoreQualidade, scoreComercial, scoreGeral, classificarScoreGeral } from '../src/scoring.mjs';
// Recalcula score_qualidade/score_comercial/score/classificacao/prioridade/
// produto_principal de TODOS os leads a partir dos dados ja gravados + da
// afinidade/produtos configurados por nicho (tabela segments). Nao e um
// script de uso unico: roda de novo (`npm run recalc-scores`) sempre que o
// catalogo de afinidade/produtos em prospeccao/config.json mudar.
//
// Roda com o servidor parado (evita concorrencia de escrita no WAL).
await loadDotEnv();
const BATCH_SIZE = 2000;
function produtoPrincipalDe(produtos) {
return produtos.length ? [...produtos].sort((a, b) => b.estrelas - a.estrelas)[0].nome : '';
}
async function main() {
const db = openDb();
const segmentos = new Map(
db.prepare('SELECT name, affinity, products_json FROM segments').all()
.map((row) => [row.name, { affinity: row.affinity, products: JSON.parse(row.products_json) }])
);
const update = db.prepare(`
UPDATE leads SET
score = ?, classificacao = ?, motivo_classificacao = ?,
score_qualidade = ?, score_comercial = ?, prioridade = ?,
produto_principal = ?, produtos_json = ?, updated_at = datetime('now')
WHERE id = ?
`);
const total = db.prepare('SELECT COUNT(*) AS t FROM leads').get().t;
const porPrioridade = { 1: 0, 2: 0, 3: 0, 4: 0 };
let processed = 0;
let lastId = 0;
const page = db.prepare('SELECT * FROM leads WHERE id > ? ORDER BY id LIMIT ?');
while (true) {
const rows = page.all(lastId, BATCH_SIZE);
if (!rows.length) break;
transaction(db, () => {
for (const lead of rows) {
const info = segmentos.get(lead.segmento) || { affinity: 'media', products: [] };
const qualidade = scoreQualidade({
whatsapp: lead.whatsapp, cnpj: lead.cnpj, empresa: lead.empresa,
cidade: lead.cidade, segmento: lead.segmento, cnae: lead.cnae, website: lead.website
});
const comercial = scoreComercial({
whatsapp: lead.whatsapp, cnpj: lead.cnpj, website: lead.website,
email: lead.email, affinity: info.affinity
});
const geral = scoreGeral(qualidade.score, comercial);
const tier = classificarScoreGeral(geral);
const produtoPrincipal = produtoPrincipalDe(info.products);
update.run(
geral, tier.id, `score geral ${geral} (qualidade ${qualidade.score}, comercial ${comercial})`,
qualidade.score, comercial, tier.prioridade,
produtoPrincipal, JSON.stringify(info.products),
lead.id
);
porPrioridade[tier.prioridade] += 1;
}
});
processed += rows.length;
lastId = rows[rows.length - 1].id;
console.log(`${processed}/${total} leads recalculados...`);
}
console.log('Concluido. Distribuicao por prioridade:', porPrioridade);
db.close();
}
main().catch((error) => {
console.error('Erro no recalculo de scores:', error);
process.exit(1);
});
+37
View File
@@ -0,0 +1,37 @@
import { loadDotEnv, GTI_PORT } from '../src/env.mjs';
import { openDb, ensureSeeds } from '../src/db.mjs';
import { createJobManager, recoverOrphans } from '../src/jobs/manager.mjs';
import { createAppServer } from '../src/server/index.mjs';
// Servidor local do GTI Maquina Comercial. Inicie com:
// npm start (ou node scripts/servidor.mjs, ou o GTI-Comercial.exe)
// e abra http://127.0.0.1:3900 no navegador.
await loadDotEnv();
const log = (message) => console.log(`[servidor] ${message}`);
const db = openDb();
await ensureSeeds(db, { log });
recoverOrphans(db, log);
const jobs = createJobManager();
const server = createAppServer({ db, jobs, log });
const port = GTI_PORT;
server.listen(port, '127.0.0.1', () => {
log(`GTI Maquina Comercial rodando em http://127.0.0.1:${port}`);
log('somente este computador tem acesso (127.0.0.1). Ctrl+C para encerrar.');
});
function shutdown() {
log('encerrando...');
server.close(() => {
db.close();
process.exit(0);
});
setTimeout(() => process.exit(0), 3000).unref();
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
+19
View File
@@ -0,0 +1,19 @@
import crypto from 'node:crypto';
const KEY_LEN = 64;
// Hash com salt aleatorio (scrypt) — formato armazenado: "salt:hash", ambos hex.
export function hashPassword(password) {
const salt = crypto.randomBytes(16).toString('hex');
const hash = crypto.scryptSync(password, salt, KEY_LEN).toString('hex');
return `${salt}:${hash}`;
}
export function verifyPassword(password, stored) {
const [salt, hash] = String(stored || '').split(':');
if (!salt || !hash) return false;
const expected = Buffer.from(hash, 'hex');
const candidate = crypto.scryptSync(password, salt, expected.length);
return candidate.length === expected.length && crypto.timingSafeEqual(candidate, expected);
}
+110
View File
@@ -0,0 +1,110 @@
import { loadDotEnv } from '../env.mjs';
import { openDb } from '../db.mjs';
import { runCapture } from './runner.mjs';
import { createJobIO } from '../jobs/protocol.mjs';
// Processo filho de captura: le os parametros da linha em capture_runs,
// resolve nichos/cidades e roda o runner, reportando progresso via stdout.
await loadDotEnv();
const args = new Map(
process.argv.slice(2).map((arg) => {
const [key, value = 'true'] = arg.replace(/^--/, '').split('=');
return [key, value];
})
);
const io = createJobIO();
async function main() {
const runId = Number(args.get('run-id'));
if (!runId) throw new Error('capture-job: --run-id obrigatorio');
const db = openDb();
const run = db.prepare('SELECT * FROM capture_runs WHERE id = ?').get(runId);
if (!run) throw new Error(`capture_run #${runId} nao encontrado`);
const saved = JSON.parse(run.params_json);
let segments = db.prepare('SELECT * FROM segments WHERE active = 1 ORDER BY id').all().map((row) => ({
name: row.name,
label: row.label,
interest: row.interest,
queries: JSON.parse(row.queries_json),
cnaeCodes: JSON.parse(row.cnae_codes_json),
overpassTags: JSON.parse(row.overpass_tags_json),
pains: JSON.parse(row.pains_json),
affinity: row.affinity,
products: JSON.parse(row.products_json)
}));
{
// Cada item selecionado pode ser o nome de um nicho pre-configurado, o
// sentinela "all" (todos os pre-configurados), ou um CNAE avulso
// escolhido na busca livre por atividade da Receita (prefixo "cnae:<codigo>").
const wanted = Array.isArray(saved.segments) && saved.segments.length ? saved.segments : ['all'];
const includeAllCurated = wanted.includes('all');
const wantedNames = new Set(wanted.filter((id) => typeof id === 'string' && id !== 'all' && !id.startsWith('cnae:')));
const wantedCnaes = wanted.filter((id) => typeof id === 'string' && id.startsWith('cnae:')).map((id) => id.slice('cnae:'.length));
if (!includeAllCurated) {
segments = segments.filter((s) => wantedNames.has(s.name));
}
if (wantedCnaes.length) {
const placeholders = wantedCnaes.map(() => '?').join(',');
const cnaeRows = db.prepare(`SELECT codigo, descricao FROM cnaes WHERE codigo IN (${placeholders})`).all(...wantedCnaes);
for (const row of cnaeRows) {
segments.push({
name: `cnae_${row.codigo}`,
label: row.descricao,
interest: 'gestao de TI',
queries: [row.descricao],
cnaeCodes: [row.codigo],
overpassTags: [],
pains: ['atendimento', 'vendas'],
affinity: 'media',
products: []
});
}
}
}
let cities = saved.cities;
if (!Array.isArray(cities) || !cities.length || cities[0] === 'all') {
cities = db.prepare('SELECT nome FROM municipios WHERE uf = ? ORDER BY nome')
.all(saved.uf).map((r) => r.nome);
}
const params = {
sources: saved.sources,
uf: saved.uf,
cities,
segments,
limits: saved.limits || { maxPerQuery: 10, queryLimit: 0 }
};
const summary = await runCapture({
db,
runId,
params,
emit: (p) => io.emit(p),
log: (m) => io.log(m),
isCancelled: io.isCancelled
});
io.done({
cancelled: summary.status === 'cancelled',
raw: summary.raw,
new: summary.new,
dup: summary.dup,
status: summary.status
});
db.close();
}
main().catch((error) => {
io.fail(error);
process.exit(1);
});
+43
View File
@@ -0,0 +1,43 @@
import { cleanText } from '../lead-engine.mjs';
import { UA_HEADERS, UF_NAMES } from '../sources/common.mjs';
// Resolve a bounding box de uma cidade para as consultas Overpass.
// Substitui o antigo cityAreas fixo do config.json: consulta o Nominatim
// uma unica vez por cidade e guarda para sempre em geo_cache (os bboxes
// legados das 36 cidades da Bahia ja foram semeados como 'config-legacy').
export function createGeocoder(db, { nominatimUrl }) {
const select = db.prepare('SELECT south, west, north, east FROM geo_cache WHERE key = ?');
const insert = db.prepare(
`INSERT OR REPLACE INTO geo_cache (key, south, west, north, east, provider)
VALUES (?, ?, ?, ?, ?, 'nominatim')`
);
return async function resolveBbox(uf, cidade) {
const key = `bbox:${uf}:${cleanText(cidade)}`;
const cached = select.get(key);
if (cached) return [cached.south, cached.west, cached.north, cached.east];
const url = new URL(nominatimUrl);
url.searchParams.set('format', 'jsonv2');
url.searchParams.set('q', `${cidade}, ${UF_NAMES[uf] || uf}, Brasil`);
url.searchParams.set('countrycodes', 'br');
url.searchParams.set('limit', '1');
const response = await fetch(url, { headers: UA_HEADERS });
if (!response.ok) throw new Error(`Nominatim geocode HTTP ${response.status} para ${cidade}/${uf}`);
const results = await response.json();
const box = results[0]?.boundingbox;
if (!box || box.length !== 4) return null;
// Nominatim retorna [sul, norte, oeste, leste]; geo_cache guarda sul/oeste/norte/leste.
const south = Number(box[0]);
const north = Number(box[1]);
const west = Number(box[2]);
const east = Number(box[3]);
if (![south, north, west, east].every(Number.isFinite)) return null;
insert.run(key, south, west, north, east);
return [south, west, north, east];
};
}
+65
View File
@@ -0,0 +1,65 @@
import {
scoreQualidade, scoreComercial, scoreGeral, classificarScoreGeral,
dedupKey, extractDdd, segmentLabel, onlyDigits
} from '../scoring.mjs';
// Converte um item processado ({payload, lead}) — formato produzido por
// processLead e tambem o formato dos JSONs legados — na linha canonica da
// tabela leads, com os scores/classificacao/dedup_key calculados.
export function leadRowFromProcessed(item, fallbackOrigin = '') {
const lead = item.lead || {};
const prospecting = item.payload?.prospecting || lead.raw?.prospecting || {};
const empresa = String(lead.companyName ?? '').trim();
const whatsapp = onlyDigits(lead.phone);
const cnpj = onlyDigits(prospecting.cnpj);
const cidade = String(lead.city ?? '').trim();
const segmento = String(lead.segment ?? '').trim();
const cnae = String(prospecting.cnae ?? '').trim();
const website = String(prospecting.website ?? '').trim();
const email = String(lead.email ?? '').trim();
const affinity = prospecting.affinity || 'media';
const produtos = Array.isArray(prospecting.products) ? prospecting.products : [];
const qualidade = scoreQualidade({ whatsapp, cnpj, empresa, cidade, segmento, cnae, website });
const comercial = scoreComercial({ whatsapp, cnpj, website, email, affinity });
const geral = scoreGeral(qualidade.score, comercial);
const tier = classificarScoreGeral(geral);
const produtoPrincipal = produtos.length
? [...produtos].sort((a, b) => b.estrelas - a.estrelas)[0].nome
: '';
return {
lead_uid: lead.id || null,
dedup_key: dedupKey({ cnpj, whatsapp, empresa, cidade }),
empresa,
legal_name: String(prospecting.legal_name ?? ''),
whatsapp,
ddd: extractDdd(whatsapp),
email,
cidade,
uf: String(lead.state ?? '').trim().toUpperCase(),
segmento,
segmento_label: segmentLabel(segmento),
interesse: String(lead.serviceLabel ?? ''),
cnpj,
cnae,
endereco: String(prospecting.address ?? ''),
website,
pains_json: JSON.stringify(lead.pains || []),
origem: String(lead.source || fallbackOrigin || 'indefinida'),
telefone_valido: qualidade.telefoneValido ? 1 : 0,
cnpj_valido: qualidade.cnpjValido ? 1 : 0,
empresa_valida: qualidade.empresaValida ? 1 : 0,
score: geral,
classificacao: tier.id,
motivo_classificacao: `score geral ${geral} (qualidade ${qualidade.score}, comercial ${comercial})`,
score_qualidade: qualidade.score,
score_comercial: comercial,
prioridade: tier.prioridade,
produto_principal: produtoPrincipal,
produtos_json: JSON.stringify(produtos),
capture_run_id: null,
captured_at: lead.createdAt || null,
exported_at: null
};
}
+133
View File
@@ -0,0 +1,133 @@
import { processLead } from '../lead-engine.mjs';
import { createLeadInserter, transaction } from '../db.mjs';
import { createGeocoder } from './geocode.mjs';
import { leadRowFromProcessed } from './lead-row.mjs';
import { getSource } from '../sources/index.mjs';
import { envString, envNumber, paths } from '../env.mjs';
export function defaultEndpoints() {
return {
overpassUrls: envString(
'OVERPASS_API_URL',
'https://overpass-api.de/api/interpreter,https://overpass.kumi.systems/api/interpreter'
).split(',').map((url) => url.trim()).filter(Boolean),
nominatimUrl: envString('NOMINATIM_API_URL', 'https://nominatim.openstreetmap.org/search'),
wikidataUrl: envString('WIKIDATA_API_URL', 'https://query.wikidata.org/sparql'),
brasilApiUrl: envString('BRASILAPI_URL', 'https://brasilapi.com.br/api').replace(/\/$/, '')
};
}
// Executa uma captura: percorre as fontes selecionadas, processa cada lead
// (normalizacao + score) e insere no banco em lotes. A dedup acontece no
// proprio INSERT (ON CONFLICT dedup_key DO NOTHING) — leads ja capturados
// em qualquer execucao anterior contam como duplicados.
export async function runCapture({ db, runId, params, emit = () => {}, log = () => {}, isCancelled = () => false }) {
const insert = createLeadInserter(db);
const endpoints = defaultEndpoints();
const ctx = {
db,
emit,
log,
isCancelled,
cnpjDir: params.cnpjDir || paths.cnpjDir,
endpoints,
delays: {
overpassDelayMs: envNumber('OVERPASS_REQUEST_DELAY_MS', 5000),
overpassTimeoutMs: envNumber('OVERPASS_TIMEOUT_MS', 30000)
},
overpassTextSearch: envString('OVERPASS_TEXT_SEARCH', 'true') !== 'false',
geocode: createGeocoder(db, { nominatimUrl: endpoints.nominatimUrl })
};
const counts = { raw: 0, new: 0, dup: 0 };
const updateRun = runId
? db.prepare(
`UPDATE capture_runs SET raw_count = ?, new_count = ?, dup_count = ?, progress_json = ? WHERE id = ?`
)
: null;
const persistCounts = (progress) => {
if (updateRun) {
updateRun.run(counts.raw, counts.new, counts.dup, JSON.stringify(progress || {}), runId);
}
};
if (runId) {
db.prepare(
`UPDATE capture_runs SET status = 'running', pid = ?, started_at = datetime('now') WHERE id = ?`
).run(process.pid, runId);
}
let cancelled = false;
try {
for (const sourceId of params.sources) {
if (isCancelled()) {
cancelled = true;
break;
}
const source = getSource(sourceId);
if (!source) {
log(`fonte desconhecida: ${sourceId} — pulada`);
continue;
}
log(`fonte ${sourceId}: iniciando`);
emit({ phase: sourceId, message: 'iniciando', counts: { ...counts } });
for await (const batch of source.collect(params, ctx)) {
counts.raw += batch.leads.length;
transaction(db, () => {
for (const payload of batch.leads) {
const result = processLead(payload);
const row = leadRowFromProcessed({ payload, lead: result.lead }, sourceId);
row.capture_run_id = runId || null;
if (insert(row) === 'inserted') counts.new += 1;
else counts.dup += 1;
}
});
const progress = {
phase: sourceId,
message: `${batch.label}: ${counts.new} novos, ${counts.dup} duplicados (${counts.raw} brutos)`,
counts: { ...counts }
};
emit(progress);
persistCounts(progress);
if (isCancelled()) {
cancelled = true;
break;
}
}
if (cancelled) break;
log(`fonte ${sourceId}: concluida`);
}
} catch (error) {
if (runId) {
db.prepare(
`UPDATE capture_runs SET status = 'error', error = ?, raw_count = ?, new_count = ?, dup_count = ?, finished_at = datetime('now') WHERE id = ?`
).run(String(error.message || error), counts.raw, counts.new, counts.dup, runId);
}
throw error;
}
const finalStatus = cancelled ? 'cancelled' : 'done';
if (runId) {
db.prepare(
`UPDATE capture_runs SET status = ?, raw_count = ?, new_count = ?, dup_count = ?, finished_at = datetime('now') WHERE id = ?`
).run(finalStatus, counts.raw, counts.new, counts.dup, runId);
}
return { ...counts, status: finalStatus };
}
export function createCaptureRun(db, params) {
const result = db.prepare('INSERT INTO capture_runs (params_json, status) VALUES (?, ?)')
.run(JSON.stringify(params), 'pending');
return Number(result.lastInsertRowid);
}
+441
View File
@@ -0,0 +1,441 @@
import { DatabaseSync } from 'node:sqlite';
import fs from 'node:fs';
import path from 'node:path';
import { paths } from './env.mjs';
import { segmentLabel } from './scoring.mjs';
import { cleanText } from './lead-engine.mjs';
import { hashPassword } from './auth.mjs';
const MIGRATIONS = [
// v1 — esquema inicial
`
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE municipios (
codigo_ibge TEXT PRIMARY KEY,
codigo_tom TEXT,
nome TEXT NOT NULL,
nome_clean TEXT NOT NULL,
uf TEXT NOT NULL
);
CREATE INDEX idx_municipios_uf ON municipios(uf, nome_clean);
CREATE INDEX idx_municipios_tom ON municipios(codigo_tom);
CREATE TABLE cnaes (
codigo TEXT PRIMARY KEY,
descricao TEXT NOT NULL
);
CREATE TABLE segments (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
label TEXT NOT NULL,
interest TEXT,
queries_json TEXT NOT NULL DEFAULT '[]',
cnae_codes_json TEXT NOT NULL DEFAULT '[]',
overpass_tags_json TEXT NOT NULL DEFAULT '[]',
pains_json TEXT NOT NULL DEFAULT '[]',
active INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE geo_cache (
key TEXT PRIMARY KEY,
south REAL, west REAL, north REAL, east REAL,
provider TEXT NOT NULL,
resolved_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE capture_runs (
id INTEGER PRIMARY KEY,
job_id TEXT UNIQUE,
params_json TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
progress_json TEXT,
raw_count INTEGER NOT NULL DEFAULT 0,
new_count INTEGER NOT NULL DEFAULT 0,
dup_count INTEGER NOT NULL DEFAULT 0,
error TEXT,
pid INTEGER,
started_at TEXT,
finished_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE extractions (
id INTEGER PRIMARY KEY,
job_id TEXT UNIQUE,
uf TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
csv_path TEXT,
total_rows INTEGER,
with_phone INTEGER,
municipios_count INTEGER,
progress_json TEXT,
error TEXT,
pid INTEGER,
started_at TEXT,
finished_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE leads (
id INTEGER PRIMARY KEY,
lead_uid TEXT,
dedup_key TEXT NOT NULL UNIQUE,
empresa TEXT NOT NULL,
legal_name TEXT DEFAULT '',
whatsapp TEXT DEFAULT '',
ddd TEXT DEFAULT '',
email TEXT DEFAULT '',
cidade TEXT DEFAULT '',
uf TEXT DEFAULT '',
segmento TEXT DEFAULT '',
segmento_label TEXT DEFAULT '',
interesse TEXT DEFAULT '',
cnpj TEXT DEFAULT '',
cnae TEXT DEFAULT '',
endereco TEXT DEFAULT '',
website TEXT DEFAULT '',
pains_json TEXT NOT NULL DEFAULT '[]',
origem TEXT NOT NULL DEFAULT '',
telefone_valido INTEGER NOT NULL DEFAULT 0,
cnpj_valido INTEGER NOT NULL DEFAULT 0,
empresa_valida INTEGER NOT NULL DEFAULT 0,
score INTEGER NOT NULL DEFAULT 0,
classificacao TEXT NOT NULL DEFAULT 'ruim',
motivo_classificacao TEXT DEFAULT '',
capture_run_id INTEGER REFERENCES capture_runs(id),
captured_at TEXT,
exported_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT
);
CREATE INDEX idx_leads_uf_cidade ON leads(uf, cidade);
CREATE INDEX idx_leads_class_score ON leads(classificacao, score DESC);
CREATE INDEX idx_leads_segmento ON leads(segmento);
CREATE INDEX idx_leads_whatsapp ON leads(whatsapp);
CREATE INDEX idx_leads_cnpj ON leads(cnpj);
CREATE INDEX idx_leads_exported ON leads(exported_at);
CREATE INDEX idx_leads_origem ON leads(origem);
CREATE TABLE exports (
id INTEGER PRIMARY KEY,
job_id TEXT UNIQUE,
tipo TEXT NOT NULL,
filtro_json TEXT NOT NULL DEFAULT '{}',
params_json TEXT NOT NULL DEFAULT '{}',
arquivos_json TEXT NOT NULL DEFAULT '[]',
total_leads INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
finished_at TEXT
);
CREATE TABLE export_leads (
export_id INTEGER NOT NULL REFERENCES exports(id) ON DELETE CASCADE,
lead_id INTEGER NOT NULL REFERENCES leads(id),
PRIMARY KEY (export_id, lead_id)
) WITHOUT ROWID;
CREATE INDEX idx_export_leads_lead ON export_leads(lead_id);
`,
// v2 — score de qualidade dos dados + score comercial (aderencia de nicho,
// website, e-mail) + score geral (60% comercial + 40% qualidade), prioridade
// comercial (1-4, derivada da classificacao) e produto recomendado por nicho.
`
ALTER TABLE segments ADD COLUMN affinity TEXT NOT NULL DEFAULT 'media';
ALTER TABLE segments ADD COLUMN products_json TEXT NOT NULL DEFAULT '[]';
ALTER TABLE leads ADD COLUMN score_qualidade INTEGER NOT NULL DEFAULT 0;
ALTER TABLE leads ADD COLUMN score_comercial INTEGER NOT NULL DEFAULT 0;
ALTER TABLE leads ADD COLUMN prioridade INTEGER NOT NULL DEFAULT 4;
ALTER TABLE leads ADD COLUMN produto_principal TEXT DEFAULT '';
ALTER TABLE leads ADD COLUMN produtos_json TEXT NOT NULL DEFAULT '[]';
CREATE INDEX idx_leads_prioridade ON leads(prioridade, score DESC);
CREATE INDEX idx_leads_produto ON leads(produto_principal);
`,
// v3 — login: usuarios do app (senha com hash, papel admin/comercial).
`
CREATE TABLE users (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'comercial',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`
];
export function openDb(dbPath = paths.db) {
if (dbPath !== ':memory:') {
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
}
const db = new DatabaseSync(dbPath);
db.exec('PRAGMA journal_mode = WAL;');
db.exec('PRAGMA busy_timeout = 5000;');
db.exec('PRAGMA foreign_keys = ON;');
migrate(db);
return db;
}
export function migrate(db) {
db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);`);
const row = db.prepare('SELECT MAX(version) AS version FROM schema_migrations').get();
const current = row?.version || 0;
for (let version = current + 1; version <= MIGRATIONS.length; version += 1) {
db.exec('BEGIN');
try {
db.exec(MIGRATIONS[version - 1]);
db.prepare('INSERT INTO schema_migrations (version) VALUES (?)').run(version);
db.exec('COMMIT');
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
}
}
export function transaction(db, fn) {
db.exec('BEGIN');
try {
const result = fn();
db.exec('COMMIT');
return result;
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
}
const LEAD_COLUMNS = [
'lead_uid', 'dedup_key', 'empresa', 'legal_name', 'whatsapp', 'ddd', 'email',
'cidade', 'uf', 'segmento', 'segmento_label', 'interesse', 'cnpj', 'cnae',
'endereco', 'website', 'pains_json', 'origem', 'telefone_valido', 'cnpj_valido',
'empresa_valida', 'score', 'classificacao', 'motivo_classificacao',
'score_qualidade', 'score_comercial', 'prioridade', 'produto_principal', 'produtos_json',
'capture_run_id', 'captured_at', 'exported_at'
];
// INSERT com dedup embutida: conflitos na dedup_key sao ignorados
// (lead ja existe de captura/importacao anterior).
export function createLeadInserter(db) {
const placeholders = LEAD_COLUMNS.map((c) => `:${c}`).join(', ');
const stmt = db.prepare(
`INSERT INTO leads (${LEAD_COLUMNS.join(', ')}) VALUES (${placeholders})
ON CONFLICT(dedup_key) DO NOTHING`
);
return (lead) => {
const params = {};
for (const column of LEAD_COLUMNS) {
const value = lead[column];
params[column] = value === undefined || value === null ? null : value;
}
if (params.empresa === null) params.empresa = '';
if (params.score_qualidade === null) params.score_qualidade = 0;
if (params.score_comercial === null) params.score_comercial = 0;
if (params.prioridade === null) params.prioridade = 4;
if (params.produto_principal === null) params.produto_principal = '';
if (params.produtos_json === null) params.produtos_json = '[]';
if (params.dedup_key === null) throw new Error('lead sem dedup_key');
const result = stmt.run(params);
return result.changes > 0 ? 'inserted' : 'duplicate';
};
}
export function getSetting(db, key, fallback = null) {
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
return row ? row.value : fallback;
}
export function setSetting(db, key, value) {
db.prepare(
'INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
).run(key, String(value));
}
const DEFAULT_SETTINGS = {
'zpro.pipelineId': '1',
'zpro.stageId': '1',
'zpro.responsibleId': '15',
'zpro.status': 'open',
'zpro.closingForecastDays': '30',
'zpro.validateNumber': 'true',
'meta_diaria': '100'
};
export function seedSettings(db, extra = {}) {
const entries = { ...DEFAULT_SETTINGS, ...extra };
transaction(db, () => {
const stmt = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
for (const [key, value] of Object.entries(entries)) {
stmt.run(key, String(value));
}
});
}
// Upsert (nao so INSERT OR IGNORE): reaplicar o config.json com afinidade/produtos
// ajustados deve atualizar os 57 nichos ja existentes, nao so inserir os novos.
export function seedSegments(db, segments) {
transaction(db, () => {
const stmt = db.prepare(
`INSERT INTO segments (name, label, interest, queries_json, cnae_codes_json, overpass_tags_json, pains_json, affinity, products_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(name) DO UPDATE SET
label = excluded.label,
interest = excluded.interest,
queries_json = excluded.queries_json,
cnae_codes_json = excluded.cnae_codes_json,
overpass_tags_json = excluded.overpass_tags_json,
pains_json = excluded.pains_json,
affinity = excluded.affinity,
products_json = excluded.products_json`
);
for (const segment of segments) {
stmt.run(
segment.name,
segmentLabel(segment.name),
segment.interest || '',
JSON.stringify(segment.queries || []),
JSON.stringify(segment.cnaeCodes || []),
JSON.stringify(segment.overpassTags || []),
JSON.stringify(segment.pains || []),
segment.affinity || 'media',
JSON.stringify(segment.products || [])
);
}
});
}
export function seedMunicipios(db, rows) {
transaction(db, () => {
const stmt = db.prepare(
'INSERT OR IGNORE INTO municipios (codigo_ibge, nome, nome_clean, uf) VALUES (?, ?, ?, ?)'
);
for (const row of rows) {
stmt.run(String(row.codigo_ibge), row.nome, cleanText(row.nome), row.uf);
}
});
}
export function seedCnaes(db, rows) {
transaction(db, () => {
const stmt = db.prepare('INSERT OR IGNORE INTO cnaes (codigo, descricao) VALUES (?, ?)');
for (const row of rows) {
stmt.run(String(row.codigo), row.descricao);
}
});
}
export function seedGeoCache(db, cityAreas) {
transaction(db, () => {
const stmt = db.prepare(
`INSERT OR IGNORE INTO geo_cache (key, south, west, north, east, provider)
VALUES (?, ?, ?, ?, ?, 'config-legacy')`
);
for (const area of cityAreas || []) {
const [south, west, north, east] = area.bbox;
stmt.run(`bbox:${area.state}:${cleanText(area.name)}`, south, west, north, east);
}
});
}
// Zera a base comercial (leads, exportacoes, capturas e extracoes) mantendo
// tabelas de referencia (segments, settings, municipios, cnaes) intactas.
// Usado pelo botao "Limpar toda a base" da tela de Configuracoes.
export function resetLeadsBase(db) {
transaction(db, () => {
db.exec('DELETE FROM export_leads');
db.exec('DELETE FROM exports');
db.exec('DELETE FROM leads');
db.exec('DELETE FROM capture_runs');
db.exec('DELETE FROM extractions');
db.exec('DELETE FROM geo_cache');
});
db.exec('VACUUM');
}
export function createUser(db, { username, passwordHash, role = 'comercial' }) {
db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)').run(username, passwordHash, role);
}
export function findUserByUsername(db, username) {
return db.prepare('SELECT * FROM users WHERE username = ?').get(username);
}
export function listUsers(db) {
return db.prepare('SELECT id, username, role, created_at FROM users ORDER BY id').all();
}
// Primeiro acesso: sem usuarios cadastrados, cria admin/admin — deve ser
// trocado ou complementado com outros usuarios pela tela de Usuarios.
export function ensureDefaultAdmin(db) {
if (countRows(db, 'users') > 0) return;
createUser(db, { username: 'admin', passwordHash: hashPassword('admin'), role: 'admin' });
}
function countRows(db, table) {
return db.prepare(`SELECT COUNT(*) AS total FROM ${table}`).get().total;
}
// Popula tabelas de referencia na primeira execucao. Tolerante a arquivos de
// seed ausentes (loga e segue) para o servidor nunca falhar no boot por isso.
export async function ensureSeeds(db, { log = () => {} } = {}) {
const fsp = await import('node:fs/promises');
// seedSegments faz upsert (nao so insere): sempre reaplicado no boot para que
// ajustes de afinidade/produtos no config.json cheguem ao banco so reiniciando.
try {
const config = JSON.parse(await fsp.readFile(paths.config, 'utf8'));
seedSegments(db, config.segments || []);
if (countRows(db, 'geo_cache') === 0) seedGeoCache(db, config.cityAreas || []);
log(`seeds: ${countRows(db, 'segments')} segmentos, ${countRows(db, 'geo_cache')} bboxes`);
} catch (error) {
log(`seeds: config.json indisponivel (${error.message})`);
}
if (countRows(db, 'municipios') === 0) {
try {
const seedPath = path.join(paths.seeds, 'municipios-ibge.json');
const rows = JSON.parse(await fsp.readFile(seedPath, 'utf8'));
seedMunicipios(db, rows);
log(`seeds: ${countRows(db, 'municipios')} municipios (IBGE)`);
} catch (error) {
log(`seeds: municipios-ibge.json indisponivel (${error.message})`);
}
}
if (countRows(db, 'cnaes') === 0) {
try {
const seedPath = path.join(paths.seeds, 'cnaes.json');
const rows = JSON.parse(await fsp.readFile(seedPath, 'utf8'));
seedCnaes(db, rows);
log(`seeds: ${countRows(db, 'cnaes')} CNAEs`);
} catch (error) {
log(`seeds: cnaes.json indisponivel (${error.message})`);
}
}
let templates = {};
try {
const templatesPath = path.join(paths.root, 'whatsapp', 'templates-mensagens.json');
templates = { 'templates.mensagens': await fsp.readFile(templatesPath, 'utf8') };
} catch {
// pasta whatsapp/ ja removida: template ja deve estar no banco
}
seedSettings(db, templates);
const criouAdmin = countRows(db, 'users') === 0;
ensureDefaultAdmin(db);
if (criouAdmin) log('seeds: usuario admin/admin criado (primeiro acesso)');
}
+50
View File
@@ -0,0 +1,50 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
// Raiz do projeto resolvida a partir deste arquivo (src/env.mjs -> pasta acima),
// para funcionar igual quando o servidor e iniciado pelo launcher (cwd diferente).
export const projectRoot = path.dirname(fileURLToPath(new URL('.', import.meta.url)));
export const paths = {
root: projectRoot,
data: path.join(projectRoot, 'data'),
db: process.env.GTI_DB_PATH || path.join(projectRoot, 'data', 'gti.db'),
exports: path.join(projectRoot, 'data', 'exports'),
seeds: path.join(projectRoot, 'data', 'seeds'),
cnpjDir: process.env.CNPJ_BASE_DIR || path.join(projectRoot, 'prospeccao', 'cnpj'),
rfbRaw: path.join(projectRoot, 'prospeccao', 'cnpj', 'rfb-raw'),
config: path.join(projectRoot, 'prospeccao', 'config.json'),
public: path.join(projectRoot, 'public')
};
export async function loadDotEnv(rootDir = projectRoot) {
try {
const content = await fs.readFile(path.join(rootDir, '.env'), 'utf8');
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const separator = trimmed.indexOf('=');
if (separator === -1) continue;
const key = trimmed.slice(0, separator).trim();
const value = trimmed.slice(separator + 1).trim();
if (!process.env[key]) process.env[key] = value;
}
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
}
export function envNumber(name, fallback) {
const value = Number(process.env[name]);
return Number.isFinite(value) && value > 0 ? value : fallback;
}
export function envString(name, fallback) {
return process.env[name] || fallback;
}
export const GTI_PORT = envNumber('GTI_PORT', 3900);
+104
View File
@@ -0,0 +1,104 @@
import fs from 'node:fs';
import path from 'node:path';
import { csvEscape, excelTextCell, BOM } from '../util/csv.mjs';
import { iterateLeads } from '../treatment/queries.mjs';
// Exportacao "planilha": CSV + .xls gemeo, ambos a prova de Excel — os campos
// numericos longos (whatsapp, cnpj, cnae) sao gravados como TEXTO para nunca
// virarem notacao cientifica. Mesmo formato dos arquivos TRATADOS legados:
// CSV -> formula ="valor"
// XLS -> tabela HTML com mso-number-format:"\@"
const HEADERS = [
'empresa', 'whatsapp', 'ddd', 'cidade', 'uf', 'segmento', 'segmento_legivel', 'interesse',
'cnpj', 'cnae', 'telefone_valido', 'motivo_telefone', 'cnpj_valido', 'empresa_valida',
'motivo_empresa', 'score', 'classificacao', 'motivo_classificacao', 'chave_duplicidade', 'linha_origem',
'score_qualidade', 'score_comercial', 'prioridade', 'produto_recomendado'
];
const TEXT_COLUMNS = new Set(['whatsapp', 'ddd', 'cnpj', 'cnae']);
function leadToExportRow(lead) {
return {
empresa: lead.empresa,
whatsapp: lead.whatsapp,
ddd: lead.ddd,
cidade: lead.cidade,
uf: lead.uf,
segmento: lead.segmento,
segmento_legivel: lead.segmento_label,
interesse: lead.interesse,
cnpj: lead.cnpj,
cnae: lead.cnae,
telefone_valido: lead.telefone_valido ? 'sim' : 'nao',
motivo_telefone: lead.telefone_valido ? 'telefone valido' : 'telefone invalido ou vazio',
cnpj_valido: lead.cnpj_valido ? 'sim' : 'nao',
empresa_valida: lead.empresa_valida ? 'sim' : 'nao',
motivo_empresa: lead.empresa_valida ? 'empresa valida' : (lead.motivo_classificacao || 'empresa invalida'),
score: lead.score,
classificacao: lead.classificacao,
motivo_classificacao: lead.motivo_classificacao,
chave_duplicidade: lead.dedup_key,
linha_origem: lead.id,
score_qualidade: lead.score_qualidade,
score_comercial: lead.score_comercial,
prioridade: lead.prioridade,
produto_recomendado: lead.produto_principal
};
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
async function writeStream(stream, text) {
if (!stream.write(text)) {
await new Promise((resolve) => stream.once('drain', resolve));
}
}
export async function exportExcel({ db, filtro, dir, baseName, onProgress = () => {}, collectIds }) {
fs.mkdirSync(dir, { recursive: true });
const csvPath = path.join(dir, `${baseName}.csv`);
const xlsPath = path.join(dir, `${baseName}.xls`);
const csv = fs.createWriteStream(csvPath, { encoding: 'utf8' });
const xls = fs.createWriteStream(xlsPath, { encoding: 'utf8' });
await writeStream(csv, BOM + HEADERS.join(';') + '\n');
await writeStream(xls, `${BOM}<html><head><meta charset="utf-8"><style>td.text{mso-number-format:"\\@";}</style></head><body><table border="1"><tr>${
HEADERS.map((h) => `<th>${escapeHtml(h)}</th>`).join('')
}</tr>\n`);
let total = 0;
for (const lead of iterateLeads(db, filtro)) {
const row = leadToExportRow(lead);
const csvCells = HEADERS.map((h) => (TEXT_COLUMNS.has(h) && row[h] !== '' ? excelTextCell(row[h]) : csvEscape(row[h])));
await writeStream(csv, csvCells.join(';') + '\n');
const xlsCells = HEADERS.map((h) =>
`<td${TEXT_COLUMNS.has(h) ? ' class="text"' : ''}>${escapeHtml(row[h])}</td>`
);
await writeStream(xls, `<tr>${xlsCells.join('')}</tr>\n`);
if (collectIds) collectIds.push(lead.id);
total += 1;
if (total % 10000 === 0) onProgress({ message: `${total} leads gravados...` });
}
await writeStream(xls, '</table></body></html>\n');
await Promise.all([
new Promise((resolve, reject) => csv.end((e) => (e ? reject(e) : resolve()))),
new Promise((resolve, reject) => xls.end((e) => (e ? reject(e) : resolve())))
]);
return {
total,
files: [path.basename(csvPath), path.basename(xlsPath)]
};
}
+93
View File
@@ -0,0 +1,93 @@
import path from 'node:path';
import { loadDotEnv, paths } from '../env.mjs';
import { openDb, transaction } from '../db.mjs';
import { createJobIO } from '../jobs/protocol.mjs';
import { exportExcel } from './excel.mjs';
import { exportOportunidades, exportBulkDataInput } from './zpro.mjs';
// Processo filho de exportacao: gera os arquivos do tipo pedido e, ao final,
// numa unica transacao, registra os leads exportados (export_leads) e marca
// exported_at (para o filtro "ainda nao exportado" excluir esses leads).
await loadDotEnv();
const args = new Map(
process.argv.slice(2).map((arg) => {
const [key, value = 'true'] = arg.replace(/^--/, '').split('=');
return [key, value];
})
);
const io = createJobIO();
const GENERATORS = {
excel: exportExcel,
'zpro-oportunidades': exportOportunidades,
'zpro-bulk': exportBulkDataInput
};
async function main() {
const exportId = Number(args.get('export-id'));
if (!exportId) throw new Error('export-job: --export-id obrigatorio');
const db = openDb();
const row = db.prepare('SELECT * FROM exports WHERE id = ?').get(exportId);
if (!row) throw new Error(`export #${exportId} nao encontrado`);
const generator = GENERATORS[row.tipo];
if (!generator) throw new Error(`tipo de exportacao desconhecido: ${row.tipo}`);
const filtro = JSON.parse(row.filtro_json || '{}');
const params = JSON.parse(row.params_json || '{}');
db.prepare("UPDATE exports SET status = 'running' WHERE id = ?").run(exportId);
io.emit({ phase: row.tipo, message: 'gerando arquivos...' });
const dir = path.join(paths.exports, `export-${exportId}`);
const baseName = params.baseName || `leads-${row.tipo}`;
const leadIds = [];
try {
const result = await generator({
db,
filtro,
params,
dir,
baseName,
collectIds: leadIds,
onProgress: (p) => io.emit({ phase: row.tipo, ...p })
});
const marcar = params.marcarExportado !== false;
io.emit({ phase: row.tipo, message: `registrando ${leadIds.length} leads exportados...` });
transaction(db, () => {
const insertLink = db.prepare('INSERT OR IGNORE INTO export_leads (export_id, lead_id) VALUES (?, ?)');
const mark = marcar
? db.prepare("UPDATE leads SET exported_at = COALESCE(exported_at, datetime('now')), updated_at = datetime('now') WHERE id = ?")
: null;
for (const leadId of leadIds) {
insertLink.run(exportId, leadId);
if (mark) mark.run(leadId);
}
db.prepare(
"UPDATE exports SET status = 'done', total_leads = ?, arquivos_json = ?, finished_at = datetime('now') WHERE id = ?"
).run(result.total, JSON.stringify(result.files), exportId);
});
io.done({ total: result.total, arquivos: result.files });
} catch (error) {
db.prepare("UPDATE exports SET status = 'error', error = ?, finished_at = datetime('now') WHERE id = ?")
.run(String(error.message || error), exportId);
throw error;
} finally {
db.close();
}
}
main().catch((error) => {
io.fail(error);
process.exit(1);
});
+122
View File
@@ -0,0 +1,122 @@
import fs from 'node:fs';
import path from 'node:path';
import { iterateLeads } from '../treatment/queries.mjs';
import { getSetting } from '../db.mjs';
// Exportacoes para o Z-PRO (sem envio direto — gera os arquivos prontos):
// - zpro-oportunidades: JSONL com um payload createOpportunity por linha,
// no formato exato testado na importacao manual de 2026-07-09.
// - zpro-bulk: CSV dataInput para BulkSendMessageWithVariable
// (colunas: numero,empresa,cidade,segmento) + txt com instrucoes.
function zproDefaults(db, params = {}) {
const setting = (key, fallback) => params[key] ?? getSetting(db, `zpro.${key}`, fallback);
return {
pipelineId: Number(setting('pipelineId', 1)),
stageId: Number(setting('stageId', 1)),
responsibleId: Number(setting('responsibleId', 15)),
status: String(setting('status', 'open')),
closingForecastDays: Number(setting('closingForecastDays', 30)),
validateNumber: String(setting('validateNumber', 'true')) !== 'false'
};
}
function closingForecast(days) {
const date = new Date(Date.now() + days * 24 * 60 * 60 * 1000);
return date.toISOString().slice(0, 10);
}
async function writeStream(stream, text) {
if (!stream.write(text)) {
await new Promise((resolve) => stream.once('drain', resolve));
}
}
export async function exportOportunidades({ db, filtro, params, dir, baseName, onProgress = () => {}, collectIds }) {
fs.mkdirSync(dir, { recursive: true });
const zpro = zproDefaults(db, params);
const forecast = closingForecast(zpro.closingForecastDays);
const jsonlPath = path.join(dir, `${baseName}.jsonl`);
const out = fs.createWriteStream(jsonlPath, { encoding: 'utf8' });
let total = 0;
for (const lead of iterateLeads(db, filtro)) {
const payload = {
number: lead.whatsapp,
contactName: lead.empresa,
email: lead.email || '',
name: `${lead.empresa} - ${lead.interesse || 'Contato comercial'}`,
value: 0,
status: zpro.status,
pipelineId: zpro.pipelineId,
stageId: zpro.stageId,
responsibleId: zpro.responsibleId,
closingForecast: forecast,
description: [
'Exportado pelo GTI Maquina Comercial.',
`Empresa: ${lead.empresa}`,
lead.legal_name ? `Razao social: ${lead.legal_name}` : '',
`CNPJ: ${lead.cnpj || 'nao informado'}`,
`WhatsApp: ${lead.whatsapp || 'nao informado'}`,
`Cidade/UF: ${lead.cidade}/${lead.uf}`,
`Segmento: ${lead.segmento_label || lead.segmento}`,
`Interesse: ${lead.interesse || 'a definir'}`,
`Score: ${lead.score}`,
`Classificacao: ${lead.classificacao}`,
`Lead: ${lead.id}`
].filter(Boolean).join('\n'),
validateNumber: zpro.validateNumber
};
await writeStream(out, JSON.stringify(payload) + '\n');
if (collectIds) collectIds.push(lead.id);
total += 1;
if (total % 10000 === 0) onProgress({ message: `${total} oportunidades geradas...` });
}
await new Promise((resolve, reject) => out.end((e) => (e ? reject(e) : resolve())));
return { total, files: [path.basename(jsonlPath)] };
}
export async function exportBulkDataInput({ db, filtro, params, dir, baseName, onProgress = () => {}, collectIds }) {
fs.mkdirSync(dir, { recursive: true });
const dataPath = path.join(dir, `${baseName}-datainput.csv`);
const infoPath = path.join(dir, `${baseName}-instrucoes.txt`);
const out = fs.createWriteStream(dataPath, { encoding: 'utf8' });
let total = 0;
for (const lead of iterateLeads(db, filtro)) {
if (!lead.whatsapp) continue;
const cells = [lead.whatsapp, lead.empresa, lead.cidade, lead.segmento_label || lead.segmento]
.map((v) => String(v ?? '').replace(/[,\r\n]+/g, ' ').trim());
await writeStream(out, cells.join(',') + '\n');
if (collectIds) collectIds.push(lead.id);
total += 1;
if (total % 10000 === 0) onProgress({ message: `${total} numeros gravados...` });
}
await new Promise((resolve, reject) => out.end((e) => (e ? reject(e) : resolve())));
const mensagem = params?.mensagem || 'Ola {empresa}! Somos o Grupo GTI...';
fs.writeFileSync(infoPath, [
'DISPARO EM MASSA NO Z-PRO (BulkSendMessageWithVariable)',
'',
`Arquivo de dados: ${path.basename(dataPath)} (${total} numeros)`,
'Colunas (nesta ordem): numero, empresa, cidade, segmento',
'',
'Como usar no Z-PRO:',
'1. Endpoint: POST {BaseUrl}/v2/api/external/{ApiID}/bulkSendMessageWithVariable',
'2. Campo "dataInput": cole o conteudo do CSV (ou envie por upload, conforme sua versao).',
'3. Campo "message": escreva a mensagem usando as variaveis pela posicao das colunas,',
' ex.: "Ola {empresa}! Vimos que voces sao de {cidade}..."',
'4. Campos "min" e "max": intervalo aleatorio (em segundos) entre envios — use valores',
' altos (ex.: 30 a 90) para reduzir risco de bloqueio do numero.',
'',
'Mensagem sugerida:',
mensagem
].join('\n'), 'utf8');
return { total, files: [path.basename(dataPath), path.basename(infoPath)] };
}
+75
View File
@@ -0,0 +1,75 @@
import path from 'node:path';
import { loadDotEnv, paths } from '../env.mjs';
import { openDb } from '../db.mjs';
import { extractUf } from './rfb-extractor.mjs';
import { createJobIO } from '../jobs/protocol.mjs';
// Processo filho de extracao RFB: le a UF da linha em extractions e roda a
// extracao completa, reportando progresso via stdout.
await loadDotEnv();
const args = new Map(
process.argv.slice(2).map((arg) => {
const [key, value = 'true'] = arg.replace(/^--/, '').split('=');
return [key, value];
})
);
const io = createJobIO();
async function main() {
const extractionId = Number(args.get('extraction-id'));
if (!extractionId) throw new Error('extract-job: --extraction-id obrigatorio');
const db = openDb();
const extraction = db.prepare('SELECT * FROM extractions WHERE id = ?').get(extractionId);
if (!extraction) throw new Error(`extraction #${extractionId} nao encontrada`);
const uf = extraction.uf;
db.prepare("UPDATE extractions SET status = 'running', pid = ?, started_at = datetime('now') WHERE id = ?")
.run(process.pid, extractionId);
const outCsv = path.join(paths.cnpjDir, `estabelecimentos-${uf}.csv`);
const checkpointPath = path.join(paths.cnpjDir, `checkpoint-estabelecimentos-${uf}.ndjson`);
try {
const result = await extractUf({
uf,
rawDir: paths.rfbRaw,
outCsv,
checkpointPath,
db,
log: (m) => io.log(m),
isCancelled: io.isCancelled,
onProgress: (p) => {
io.emit(p);
db.prepare('UPDATE extractions SET progress_json = ? WHERE id = ?')
.run(JSON.stringify(p), extractionId);
}
});
if (result.cancelled) {
db.prepare("UPDATE extractions SET status = 'cancelled', finished_at = datetime('now') WHERE id = ?")
.run(extractionId);
io.done({ cancelled: true });
} else {
db.prepare(
`UPDATE extractions SET status = 'done', csv_path = ?, total_rows = ?, with_phone = ?,
municipios_count = ?, finished_at = datetime('now') WHERE id = ?`
).run(result.csvPath, result.totalRows, result.withPhone, result.municipiosCount, extractionId);
io.done({ totalRows: result.totalRows, withPhone: result.withPhone, municipios: result.municipiosCount });
}
} catch (error) {
db.prepare("UPDATE extractions SET status = 'error', error = ?, finished_at = datetime('now') WHERE id = ?")
.run(String(error.message || error), extractionId);
throw error;
} finally {
db.close();
}
}
main().catch((error) => {
io.fail(error);
process.exit(1);
});
+361
View File
@@ -0,0 +1,361 @@
import { spawn, spawnSync } from 'node:child_process';
import readline from 'node:readline';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import { cleanText, stripAccents } from '../lead-engine.mjs';
import { copyStr } from '../util/collections.mjs';
import { csvEscape, BOM } from '../util/csv.mjs';
// Extrai a base de estabelecimentos ativos de UMA UF inteira (todos os
// municipios) a partir dos zips brutos da Receita Federal, gerando
// prospeccao/cnpj/estabelecimentos-<UF>.csv.
//
// Cuidados de memoria (aprendidos nas execucoes da Bahia):
// - checkpoint em NDJSON streamado para disco: nunca guarda o array de
// matches em memoria (uma UF inteira passa de 1 milhao de linhas);
// - copyStr (Buffer.from) quebra o vinculo SlicedString do V8 entre campos
// retidos e a linha CSV original;
// - firstField evita o split completo das ~50 milhoes de linhas descartadas
// dos Empresas*.zip.
const CANDIDATE_READERS = [
{ name: 'unzip', probeArgs: ['-v'], argsFor: (zipPath) => ['-p', zipPath] },
{ name: 'C:\\Program Files\\Git\\usr\\bin\\unzip.exe', probeArgs: ['-v'], argsFor: (zipPath) => ['-p', zipPath] },
{ name: 'tar', probeArgs: ['--version'], argsFor: (zipPath) => ['-xOf', zipPath] },
{ name: 'C:\\Windows\\System32\\tar.exe', probeArgs: ['--version'], argsFor: (zipPath) => ['-xOf', zipPath] }
];
export function resolveZipReader() {
for (const candidate of CANDIDATE_READERS) {
try {
const probe = spawnSync(candidate.name, candidate.probeArgs, { stdio: 'ignore' });
if (!probe.error && probe.status === 0) return candidate;
} catch {
// tenta o proximo
}
}
throw new Error(
'Nenhum leitor de ZIP encontrado (unzip ou tar). Instale o Git para Windows ou verifique o PATH.'
);
}
// Abre um zip da RFB e devolve as linhas ja decodificadas de ISO-8859-1.
function readZipLines(reader, zipPath) {
const child = spawn(reader.name, reader.argsFor(zipPath), { stdio: ['ignore', 'pipe', 'pipe'] });
child.stderr.on('data', () => {});
child.stdout.setEncoding('latin1');
const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
return {
rl,
close() {
rl.close();
child.kill();
}
};
}
// So o primeiro campo (antes do primeiro '";"'), sem split completo.
function firstField(line) {
const end = line.indexOf('";"', 1);
if (end === -1) return '';
return line.slice(1, end);
}
function parseQuotedRow(line) {
if (line.startsWith('"') && line.endsWith('"')) {
return line.slice(1, -1).split('";"');
}
return line.split(';').map((value) => value.replace(/^"|"$/g, ''));
}
function titleCase(name) {
return String(name).toLowerCase().replace(/(^|\s)([a-z])/g, (m, sep, ch) => sep + ch.toUpperCase());
}
async function buildTomMap(reader, rawDir) {
const zipPath = path.join(rawDir, 'Municipios.zip');
if (!fs.existsSync(zipPath)) {
throw new Error(`Municipios.zip nao encontrado em ${rawDir}`);
}
const tomToNome = new Map();
const zip = readZipLines(reader, zipPath);
for await (const line of zip.rl) {
if (!line.trim()) continue;
const [codigo, nome] = parseQuotedRow(line);
if (codigo) tomToNome.set(copyStr(codigo), copyStr(nome));
}
zip.close();
return tomToNome;
}
async function* estabelecimentoRows(reader, rawDir, uf, { onFileProgress, isCancelled }) {
for (let i = 0; i <= 9; i += 1) {
const zipPath = path.join(rawDir, `Estabelecimentos${i}.zip`);
if (!fs.existsSync(zipPath)) continue;
const zip = readZipLines(reader, zipPath);
let lineCount = 0;
let matchCount = 0;
for await (const line of zip.rl) {
lineCount += 1;
if (lineCount % 500000 === 0 && isCancelled()) {
zip.close();
return;
}
if (!line.trim()) continue;
// Filtro barato por UF antes do parse completo: a UF aparece como
// campo '";UF";"' — o indexOf evita o split das ~29M linhas de outras UFs.
if (!line.includes(`"${uf}"`)) continue;
const cols = parseQuotedRow(line);
if (cols[19] !== uf) continue;
if (cols[5] !== '02') continue;
matchCount += 1;
yield {
b: copyStr(cols[0]), // cnpj basico
o: copyStr(cols[1]), // ordem
d: copyStr(cols[2]), // dv
f: copyStr(cols[4]), // nome fantasia
c: copyStr(cols[11]), // cnae
l: copyStr([cols[13], cols[14]].filter(Boolean).join(' ')), // logradouro
n: copyStr(cols[15]), // numero
ba: copyStr(cols[17]), // bairro
m: copyStr(cols[20]), // codigo municipio (TOM)
d1: copyStr(cols[21]),
t1: copyStr(cols[22]),
d2: copyStr(cols[23]),
t2: copyStr(cols[24])
};
}
zip.close();
onFileProgress(`Estabelecimentos${i}.zip`, lineCount, matchCount, i);
if (global.gc) global.gc();
if (isCancelled()) return;
}
}
async function buildRazaoMap(reader, rawDir, cnpjBasicoSet, { onFileProgress, isCancelled }) {
const razaoByCnpj = new Map();
for (let i = 0; i <= 9; i += 1) {
const zipPath = path.join(rawDir, `Empresas${i}.zip`);
if (!fs.existsSync(zipPath)) continue;
const zip = readZipLines(reader, zipPath);
let lineCount = 0;
let matchCount = 0;
for await (const line of zip.rl) {
lineCount += 1;
if (lineCount % 500000 === 0 && isCancelled()) {
zip.close();
return razaoByCnpj;
}
if (!line.trim()) continue;
const cnpjBasico = firstField(line);
if (!cnpjBasico || !cnpjBasicoSet.has(cnpjBasico)) continue;
const cols = parseQuotedRow(line);
razaoByCnpj.set(copyStr(cnpjBasico), copyStr(cols[1]));
matchCount += 1;
}
zip.close();
onFileProgress(`Empresas${i}.zip`, lineCount, matchCount, i);
if (global.gc) global.gc();
if (isCancelled()) break;
}
return razaoByCnpj;
}
export async function extractUf({
uf,
rawDir,
outCsv,
checkpointPath,
db = null,
onProgress = () => {},
isCancelled = () => false,
log = () => {}
}) {
const ufUpper = String(uf).toUpperCase();
const reader = resolveZipReader();
log(`leitor de zip: ${reader.name}`);
const tomToNome = await buildTomMap(reader, rawDir);
log(`${tomToNome.size} municipios no indice da RFB`);
// Nome "bonito" (IBGE, sem acento, capitalizado) por codigo TOM, para o CSV
// ficar consistente com os leads ja migrados ('Santo Antonio de Jesus').
const prettyByClean = new Map();
if (db) {
for (const row of db.prepare('SELECT nome, nome_clean FROM municipios WHERE uf = ?').all(ufUpper)) {
prettyByClean.set(row.nome_clean, stripAccents(row.nome));
}
}
const prettyName = (tomCode) => {
const tomNome = tomToNome.get(tomCode) || '';
return prettyByClean.get(cleanText(tomNome)) || titleCase(tomNome);
};
// ---- Fase 1: Estabelecimentos -> checkpoint NDJSON em disco ----
const cnpjBasicoSet = new Set();
const tomSeen = new Set();
let totalRows = 0;
let withPhone = 0;
const checkpointDone = fs.existsSync(checkpointPath);
if (checkpointDone) {
log('checkpoint encontrado: pulando varredura de Estabelecimentos');
await new Promise((resolve, reject) => {
const rl = readline.createInterface({
input: fs.createReadStream(checkpointPath, { encoding: 'utf8' }),
crlfDelay: Infinity
});
rl.on('line', (line) => {
if (!line.trim()) return;
const item = JSON.parse(line);
cnpjBasicoSet.add(item.b);
tomSeen.add(item.m);
totalRows += 1;
if (item.t1 || item.t2) withPhone += 1;
});
rl.on('close', resolve);
rl.on('error', reject);
});
} else {
const tmpPath = `${checkpointPath}.tmp`;
const out = fs.createWriteStream(tmpPath, { encoding: 'utf8' });
onProgress({ phase: 'estabelecimentos', pct: 0, message: 'varrendo Estabelecimentos0-9.zip' });
for await (const item of estabelecimentoRows(reader, rawDir, ufUpper, {
isCancelled,
onFileProgress: (file, lines, matches, index) => {
log(`${file}: ${lines} linhas, ${matches} ativas em ${ufUpper}`);
onProgress({
phase: 'estabelecimentos',
pct: Math.round(((index + 1) / 10) * 55),
message: `${file}: ${totalRows} estabelecimentos ate agora`
});
}
})) {
cnpjBasicoSet.add(item.b);
tomSeen.add(item.m);
totalRows += 1;
if (item.t1 || item.t2) withPhone += 1;
if (!out.write(JSON.stringify(item) + '\n')) {
await new Promise((resolve) => out.once('drain', resolve));
}
}
await new Promise((resolve, reject) => out.end((e) => (e ? reject(e) : resolve())));
if (isCancelled()) {
await fsp.rm(tmpPath, { force: true });
return { cancelled: true };
}
await fsp.rename(tmpPath, checkpointPath);
log(`checkpoint salvo: ${checkpointPath}`);
}
log(`total de estabelecimentos ativos em ${ufUpper}: ${totalRows}`);
if (isCancelled()) return { cancelled: true };
// ---- Fase 2: Empresas -> razao social ----
onProgress({ phase: 'empresas', pct: 55, message: 'varrendo Empresas0-9.zip (razao social)' });
let razoesRecuperadas = 0;
const razaoByCnpj = await buildRazaoMap(reader, rawDir, cnpjBasicoSet, {
isCancelled,
onFileProgress: (file, lines, matches, index) => {
razoesRecuperadas += matches;
const heapMb = (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(0);
log(`${file}: ${lines} linhas, ${matches} razoes sociais, heap=${heapMb}MB`);
onProgress({
phase: 'empresas',
pct: 55 + Math.round(((index + 1) / 10) * 35),
message: `${file}: ${razoesRecuperadas} razoes sociais`
});
}
});
if (isCancelled()) return { cancelled: true };
// ---- Fase 3: CSV final (streaming do checkpoint) ----
onProgress({ phase: 'csv', pct: 90, message: 'gravando CSV final' });
const headers = [
'cnpj', 'razao_social', 'nome_fantasia', 'ddd_1', 'telefone_1', 'ddd_2', 'telefone_2',
'municipio', 'municipio_codigo', 'uf', 'cnae', 'logradouro', 'numero', 'bairro'
];
const csvOut = fs.createWriteStream(outCsv, { encoding: 'utf8' });
csvOut.write(BOM + headers.join(';') + '\n');
await new Promise((resolve, reject) => {
const rl = readline.createInterface({
input: fs.createReadStream(checkpointPath, { encoding: 'utf8' }),
crlfDelay: Infinity
});
// readline ainda entrega linhas ja em buffer depois do pause(); o flag
// evita empilhar um listener de drain por linha excedente.
let waitingDrain = false;
rl.on('line', (line) => {
if (!line.trim()) return;
const item = JSON.parse(line);
const row = [
`${item.b}${item.o}${item.d}`,
razaoByCnpj.get(item.b) || '',
item.f,
item.d1, item.t1, item.d2, item.t2,
prettyName(item.m),
item.m,
ufUpper,
item.c,
item.l, item.n, item.ba
].map(csvEscape).join(';');
if (!csvOut.write(row + '\n') && !waitingDrain) {
waitingDrain = true;
rl.pause();
csvOut.once('drain', () => {
waitingDrain = false;
rl.resume();
});
}
});
rl.on('close', resolve);
rl.on('error', reject);
});
await new Promise((resolve, reject) => csvOut.end((e) => (e ? reject(e) : resolve())));
// ---- Fase 4: aprende codigo TOM dos municipios no banco ----
let municipiosCount = tomSeen.size;
if (db) {
const update = db.prepare('UPDATE municipios SET codigo_tom = ? WHERE uf = ? AND nome_clean = ?');
db.exec('BEGIN');
try {
for (const tom of tomSeen) {
const nome = tomToNome.get(tom);
if (nome) update.run(tom, ufUpper, cleanText(nome));
}
db.exec('COMMIT');
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
}
// O checkpoint so serve para retomar uma extracao interrompida. Mantido apos
// concluir, faria uma re-extracao futura (base nova da Receita) reutilizar
// dados velhos da fase 1 — entao e apagado no sucesso.
await fsp.rm(checkpointPath, { force: true });
onProgress({ phase: 'done', pct: 100, message: `${totalRows} estabelecimentos, ${withPhone} com telefone` });
log(`CSV gravado: ${outCsv}`);
log(`com telefone: ${withPhone} (${totalRows ? ((withPhone / totalRows) * 100).toFixed(1) : 0}%)`);
return { cancelled: false, totalRows, withPhone, municipiosCount, csvPath: outCsv };
}
+160
View File
@@ -0,0 +1,160 @@
import { spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import readline from 'node:readline';
import path from 'node:path';
import { paths } from '../env.mjs';
import { parseJobLine } from './protocol.mjs';
const KILL_GRACE_MS = 10000;
// Gerencia os processos filhos de trabalho pesado (captura, extracao RFB,
// exportacao). O filho e dono das escritas de leads no banco (conexao
// propria, WAL); o servidor so acompanha o progresso via stdout e repassa
// para os assinantes SSE.
export function createJobManager() {
const jobs = new Map();
function fanout(job, event) {
job.lastEvent = event;
for (const send of job.subscribers) {
try {
send(event);
} catch {
// assinante ja desconectou
}
}
}
function startJob({ type, script, args = [], meta = {} }) {
const jobId = randomUUID();
const child = spawn(
process.execPath,
[path.join(paths.root, script), `--job-id=${jobId}`, ...args],
{ cwd: paths.root, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }
);
const job = {
id: jobId,
type,
meta,
child,
status: 'running',
startedAt: new Date().toISOString(),
finishedAt: null,
lastEvent: { t: 'progress', phase: type, message: 'iniciando...' },
stderrTail: '',
subscribers: new Set(),
killTimer: null
};
jobs.set(jobId, job);
const rl = readline.createInterface({ input: child.stdout });
rl.on('line', (line) => {
const event = parseJobLine(line);
if (!event) return;
if (event.t === 'done') {
job.status = event.summary?.cancelled ? 'cancelled' : 'done';
job.finishedAt = new Date().toISOString();
} else if (event.t === 'error') {
job.status = 'error';
job.finishedAt = new Date().toISOString();
}
fanout(job, event);
});
child.stderr.setEncoding('utf8');
child.stderr.on('data', (chunk) => {
job.stderrTail = (job.stderrTail + chunk).slice(-4096);
});
child.on('exit', (code) => {
if (job.killTimer) clearTimeout(job.killTimer);
if (job.status === 'running') {
job.status = 'error';
job.finishedAt = new Date().toISOString();
fanout(job, {
t: 'error',
message: `processo encerrou inesperadamente (codigo ${code}). ${job.stderrTail.slice(-500)}`
});
}
// guarda o job finalizado por 10 minutos para consultas tardias
setTimeout(() => jobs.delete(jobId), 10 * 60 * 1000).unref();
});
return jobId;
}
function cancelJob(jobId) {
const job = jobs.get(jobId);
if (!job || job.status !== 'running') return false;
try {
job.child.stdin.write('cancel\n');
} catch {
// stdin ja fechado
}
job.killTimer = setTimeout(() => {
if (job.status === 'running') job.child.kill();
}, KILL_GRACE_MS);
job.killTimer.unref();
return true;
}
function subscribe(jobId, send) {
const job = jobs.get(jobId);
if (!job) return null;
// replay do ultimo evento para quem conecta no meio (refresh da pagina)
send(job.lastEvent);
if (job.status !== 'running') {
return () => {};
}
job.subscribers.add(send);
return () => job.subscribers.delete(send);
}
function getJob(jobId) {
return jobs.get(jobId) || null;
}
function activeJobs() {
return [...jobs.values()]
.filter((job) => job.status === 'running')
.map((job) => ({
jobId: job.id,
type: job.type,
meta: job.meta,
startedAt: job.startedAt,
lastEvent: job.lastEvent
}));
}
return { startJob, cancelJob, subscribe, getJob, activeJobs };
}
// No boot do servidor: execucoes marcadas como running cujo processo morreu
// (queda de energia, kill) viram erro para nao travar a interface.
export function recoverOrphans(db, log = () => {}) {
const isAlive = (pid) => {
if (!pid) return false;
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
};
for (const table of ['capture_runs', 'extractions']) {
const running = db.prepare(`SELECT id, pid FROM ${table} WHERE status IN ('running', 'pending')`).all();
for (const row of running) {
if (!isAlive(row.pid)) {
db.prepare(
`UPDATE ${table} SET status = 'error', error = 'interrompido (processo nao esta mais rodando)', finished_at = datetime('now') WHERE id = ?`
).run(row.id);
log(`${table} #${row.id} marcado como interrompido`);
}
}
}
}
+40
View File
@@ -0,0 +1,40 @@
import readline from 'node:readline';
// Protocolo entre o servidor e os processos filhos de job:
// - filho imprime UMA mensagem JSON por linha no stdout:
// {"t":"progress", ...} | {"t":"log","message":...} |
// {"t":"done","summary":{...}} | {"t":"error","message":...}
// - servidor escreve "cancel\n" no stdin do filho para pedir cancelamento
// cooperativo (o filho checa isCancelled() entre lotes).
export function createJobIO() {
let cancelled = false;
const rl = readline.createInterface({ input: process.stdin });
rl.on('line', (line) => {
if (line.trim() === 'cancel') cancelled = true;
});
// stdin nao deve segurar o processo vivo apos o trabalho terminar
process.stdin.unref?.();
const write = (obj) => {
process.stdout.write(JSON.stringify(obj) + '\n');
};
return {
isCancelled: () => cancelled,
emit: (progress) => write({ t: 'progress', ...progress }),
log: (message) => write({ t: 'log', message: String(message) }),
done: (summary = {}) => write({ t: 'done', summary }),
fail: (error) => write({ t: 'error', message: String(error?.message || error) })
};
}
export function parseJobLine(line) {
try {
const obj = JSON.parse(line);
return obj && typeof obj === 'object' && obj.t ? obj : null;
} catch {
return null;
}
}
+411
View File
@@ -0,0 +1,411 @@
const TARGET_CITY_ALIASES = new Set([
'santo antonio de jesus',
'santo antonio de jesus ba',
'saj'
]);
const TARGET_SEGMENTS = new Set([
'mercado',
'supermercado',
'farmacia',
'drogaria',
'restaurante',
'delivery',
'distribuidora',
'autopecas',
'material de construcao',
'clinica',
'loja',
'varejo',
'atacado',
'oficina',
'escola',
'salao de beleza',
'barbearia',
'estetica',
'celular',
'transportadora',
'logistica'
]);
const SEGMENT_STOPWORDS = new Set(['de', 'da', 'do', 'e']);
function segmentTokens(value = '') {
return cleanText(value)
.split(/[^a-z0-9]+/)
.filter((token) => token.length > 2 && !SEGMENT_STOPWORDS.has(token));
}
const TARGET_SEGMENT_TOKENS = new Set(
[...TARGET_SEGMENTS].flatMap((term) => segmentTokens(term))
);
function tokenMatches(token, targetToken) {
return token === targetToken
|| (token.length > 4 && targetToken.length > 4 && (token.startsWith(targetToken) || targetToken.startsWith(token)));
}
function isTargetSegmentMatch(segmentKey) {
if (!segmentKey) return false;
if (TARGET_SEGMENTS.has(segmentKey)) return true;
return segmentTokens(segmentKey).some((token) =>
[...TARGET_SEGMENT_TOKENS].some((targetToken) => tokenMatches(token, targetToken))
);
}
const SERVICE_LABELS = {
erp: 'ERP / sistema de gestao',
ti: 'Gestao de TI',
whatsapp: 'Autoatendimento para WhatsApp',
indefinido: 'A definir'
};
const URGENCY_LABELS = {
alta: 'Alta',
media: 'Media',
baixa: 'Baixa',
indefinida: 'Indefinida'
};
export function stripAccents(value = '') {
return String(value)
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
}
export function cleanText(value = '') {
return stripAccents(value)
.toLowerCase()
.replace(/[^a-z0-9\s@._+-]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function firstDefined(...values) {
return values.find((value) => value !== undefined && value !== null && value !== '') ?? '';
}
function numberFrom(value) {
if (value === undefined || value === null || value === '') return 0;
if (typeof value === 'number' && Number.isFinite(value)) return value;
const match = String(value).match(/\d+/);
return match ? Number(match[0]) : 0;
}
function toArray(value) {
if (Array.isArray(value)) return value.filter(Boolean).map(String);
if (!value) return [];
return String(value)
.split(/[,;|]/)
.map((item) => item.trim())
.filter(Boolean);
}
export function normalizePhone(value = '') {
let digits = String(value).replace(/\D/g, '');
while (digits.startsWith('0')) digits = digits.slice(1);
if (digits.length === 10 || digits.length === 11) {
digits = `55${digits}`;
}
return digits;
}
export function detectService(value = '') {
const text = cleanText(value);
if (!text) return 'indefinido';
if (/\b(1|erp|sistema|gestao empresarial|estoque|financeiro|fiscal|nfce|nfe|pdv)\b/.test(text)) {
return 'erp';
}
if (/\b(2|ti|suporte|servidor|rede|backup|seguranca|computador|infraestrutura)\b/.test(text)) {
return 'ti';
}
if (/\b(3|whatsapp|autoatendimento|chatbot|bot|atendimento automatico|mensagem)\b/.test(text)) {
return 'whatsapp';
}
return 'indefinido';
}
export function normalizeUrgency(value = '') {
const text = cleanText(value);
if (!text) return 'indefinida';
if (/\b(alta|urgente|imediato|hoje|agora|essa semana|parado|problema serio|1)\b/.test(text)) return 'alta';
if (/\b(media|medio|proximo mes|em breve|avaliando|2)\b/.test(text)) return 'media';
if (/\b(baixa|sem pressa|futuro|pesquisa|so pesquisando|3)\b/.test(text)) return 'baixa';
return 'indefinida';
}
export function normalizeCurrentSystem(value = '') {
const text = cleanText(value);
if (!text) return 'nao_informado';
if (/\b(trocar|insatisfeito|migrar|outro sistema|uso outro|4)\b/.test(text)) return 'quer_trocar';
if (/\b(planilha|excel|manual|papel|3)\b/.test(text)) return 'planilhas';
if (/\b(sim|tenho|uso sistema|ja uso|1)\b/.test(text)) return 'usa_sistema';
if (/\b(nao|nenhum|nao tenho|sem sistema|2)\b/.test(text)) return 'sem_sistema';
return 'nao_informado';
}
export function normalizeLead(input = {}) {
const body = input.body && typeof input.body === 'object' ? input.body : input;
const contact = body.contact ?? body.contato ?? {};
const company = body.company ?? body.empresa_dados ?? {};
const message = body.message ?? body.mensagem ?? body.text ?? body.texto ?? body.description ?? '';
const serviceRaw = firstDefined(
body.service,
body.servico,
body.interest,
body.interesse,
body.solution,
body.solucao,
message
);
const city = firstDefined(company.city, company.cidade, body.city, body.cidade);
const state = firstDefined(company.state, company.uf, body.state, body.uf, body.estado);
const segment = firstDefined(company.segment, company.segmento, body.segment, body.segmento);
const phone = normalizePhone(firstDefined(contact.phone, contact.whatsapp, body.phone, body.telefone, body.whatsapp, body.from));
const name = firstDefined(contact.name, contact.nome, body.name, body.nome, body.contact_name, 'Lead sem nome');
const email = firstDefined(contact.email, body.email);
const companyName = firstDefined(company.name, company.nome, body.company_name, body.empresa, body.nome_empresa, 'Empresa nao informada');
const pains = toArray(firstDefined(body.pains, body.dores, body.pain, body.dor, body.maior_dificuldade));
const source = firstDefined(body.source, body.origem, body.canal, 'indefinida');
const service = detectService(serviceRaw);
const urgency = normalizeUrgency(firstDefined(body.urgency, body.urgencia, body.prazo, message));
const currentSystem = normalizeCurrentSystem(firstDefined(body.current_system, body.sistema_atual, body.usa_sistema, body.sistema, message));
const employees = numberFrom(firstDefined(company.employees, company.funcionarios, body.employees, body.funcionarios));
const computers = numberFrom(firstDefined(company.computers, company.computadores, body.computers, body.computadores, body.usuarios));
const whatsappVolume = numberFrom(firstDefined(body.whatsapp_volume, body.volume_whatsapp, body.mensagens_dia));
const consent = Boolean(firstDefined(body.consent, body.consentimento, body.opt_in, body.lgpd, false));
const cityKey = cleanText(city);
const segmentKey = cleanText(segment);
return {
id: body.id ?? body.lead_id ?? `GTI-${Date.now()}-${Math.random().toString(36).slice(2, 8).toUpperCase()}`,
createdAt: body.created_at ?? body.createdAt ?? new Date().toISOString(),
source: String(source || 'indefinida'),
name: String(name),
phone,
email: String(email || ''),
companyName: String(companyName),
city: String(city || ''),
state: String(state || ''),
segment: String(segment || ''),
service,
serviceLabel: SERVICE_LABELS[service],
urgency,
urgencyLabel: URGENCY_LABELS[urgency],
currentSystem,
pains,
message: String(message || ''),
employees,
computers,
whatsappVolume,
consent,
isLocal: TARGET_CITY_ALIASES.has(cityKey) || (cityKey.includes('santo antonio') && cityKey.includes('jesus')),
isTargetSegment: isTargetSegmentMatch(segmentKey),
raw: body
};
}
export function prioritizeLead(lead) {
const hasWhatsapp = Boolean(lead.phone);
return {
priority: hasWhatsapp ? 'com_whatsapp' : 'sem_whatsapp',
hasWhatsapp,
reasons: hasWhatsapp ? ['Possui WhatsApp/telefone para contato'] : []
};
}
export function buildNextQuestion(lead) {
if (lead.service === 'indefinido') {
return [
'Para te direcionar melhor, escolha uma opcao:',
'1 - Sistema ERP / gestao empresarial',
'2 - Suporte e gestao de TI',
'3 - Autoatendimento para WhatsApp',
'4 - Ainda nao sei, preciso de orientacao'
].join('\n');
}
if (lead.service === 'erp') {
if (!lead.segment) {
return [
'Qual o segmento da sua empresa?',
'1 - Comercio / loja',
'2 - Restaurante / delivery',
'3 - Mercado / supermercado',
'4 - Distribuidora',
'5 - Servicos',
'6 - Outro'
].join('\n');
}
if (lead.currentSystem === 'nao_informado') {
return [
'Hoje voce ja usa algum sistema?',
'1 - Sim',
'2 - Nao',
'3 - Uso planilhas',
'4 - Uso outro sistema, mas quero trocar'
].join('\n');
}
if (!lead.pains.length) {
return [
'Qual sua maior dificuldade hoje?',
'1 - Controle financeiro',
'2 - Estoque',
'3 - Vendas',
'4 - Emissao fiscal',
'5 - Relatorios',
'6 - Tudo muito manual'
].join('\n');
}
}
if (lead.service === 'ti') {
if (!lead.computers) {
return [
'Sua empresa possui quantos computadores ou usuarios?',
'1 - Ate 5',
'2 - 6 a 15',
'3 - 16 a 30',
'4 - Mais de 30'
].join('\n');
}
if (!lead.pains.length) {
return [
'Qual sua maior necessidade de TI?',
'1 - Suporte tecnico',
'2 - Servidores',
'3 - Rede / internet',
'4 - Backup',
'5 - Seguranca',
'6 - Terceirizar o TI'
].join('\n');
}
}
if (lead.service === 'whatsapp') {
if (!lead.pains.length) {
return [
'Voce quer automatizar qual tipo de atendimento?',
'1 - Vendas',
'2 - Suporte',
'3 - Agendamento',
'4 - Cardapio / pedidos',
'5 - Triagem de mensagens',
'6 - Financeiro / segunda via'
].join('\n');
}
if (!lead.whatsappVolume) {
return [
'Em media, quantas mensagens chegam no WhatsApp da empresa por dia?',
'1 - Ate 20',
'2 - 21 a 50',
'3 - 51 a 100',
'4 - Mais de 100'
].join('\n');
}
}
if (lead.urgency === 'indefinida') {
return [
'Qual a urgencia para resolver isso?',
'1 - Alta, preciso resolver agora',
'2 - Media, quero avaliar para breve',
'3 - Baixa, estou pesquisando'
].join('\n');
}
return [
'Pelo que voce informou, faz sentido um diagnostico rapido com a GTI.',
'Escolha o melhor horario:',
'1 - Hoje pela manha',
'2 - Hoje a tarde',
'3 - Amanha pela manha',
'4 - Amanha a tarde'
].join('\n');
}
export function buildInternalWhatsAppMessage(lead, priorityInfo) {
const pains = lead.pains.length ? lead.pains.join(', ') : 'Nao informado';
const phone = lead.phone || 'Nao informado';
const priority = priorityInfo.priority === 'com_whatsapp' ? 'COM WHATSAPP' : 'SEM WHATSAPP';
return [
`Novo lead captado - ${priority}`,
'',
`Empresa: ${lead.companyName}`,
`Contato: ${lead.name}`,
`WhatsApp: ${phone}`,
`Cidade/UF: ${lead.city || 'Nao informada'}${lead.state ? `/${lead.state}` : ''}`,
`Segmento: ${lead.segment || 'Nao informado'}`,
`Origem: ${lead.source}`,
`Dores: ${pains}`,
'',
'Acao recomendada: tratar manualmente pela equipe GTI.'
].join('\n');
}
export function buildCrmRecord(lead, priorityInfo) {
return {
lead_id: lead.id,
data_criacao: lead.createdAt,
status: 'Captado',
prioridade: priorityInfo.priority,
tem_whatsapp: Boolean(lead.phone),
origem: lead.source,
servico: lead.service,
servico_label: lead.serviceLabel,
empresa: lead.companyName,
contato: lead.name,
whatsapp: lead.phone,
email: lead.email,
cidade: lead.city,
uf: lead.state,
segmento: lead.segment,
urgencia: lead.urgency,
sistema_atual: lead.currentSystem,
dores: lead.pains.join('; '),
funcionarios: lead.employees,
computadores: lead.computers,
volume_whatsapp: lead.whatsappVolume,
consentimento: lead.consent,
proxima_acao: 'Tratamento manual',
observacao: lead.message
};
}
export function processLead(input = {}, options = {}) {
const lead = normalizeLead(input);
const priority = prioritizeLead(lead);
const commercialWhatsApp = options.commercialWhatsApp || process.env.GTI_COMMERCIAL_WHATSAPP || '{{GTI_COMMERCIAL_WHATSAPP}}';
return {
lead,
priority,
crmRecord: buildCrmRecord(lead, priority),
whatsapp: {
internal: {
to: commercialWhatsApp,
message: buildInternalWhatsAppMessage(lead, priority)
}
},
nextAction: 'tratamento_manual'
};
}
+152
View File
@@ -0,0 +1,152 @@
import { cleanText } from './lead-engine.mjs';
// Regras de validacao, score e deduplicacao do tratamento de leads.
//
// Dois indicadores separados:
// Score Qualidade (0-100, "os dados sao confiaveis?"): +30 WhatsApp valido,
// +20 CNPJ valido, +20 empresa valida, +5 cidade, +10 segmento, +10 CNAE,
// +5 website.
// Score Comercial (0-100, "qual a chance de comprar?"): aderencia do nicho
// (alta 40 / media 30 / baixa 20) + 15 website + 10 e-mail + 15 WhatsApp
// valido + 20 CNPJ valido.
// Score Geral = round(Comercial*0.6 + Qualidade*0.4) — usado pra ordenar a tela.
// Classificacao (por Score Geral): 90-100 excelente, 75-89 bom, 55-74 medio,
// 35-54 baixo, 0-34 muito_baixo. Prioridade comercial deriva 1:1 dela.
// Duplicidade: CNPJ valido > WhatsApp valido > empresa + cidade.
export function onlyDigits(value = '') {
return String(value).replace(/\D/g, '');
}
export function validatePhone(value = '') {
const digits = onlyDigits(value);
if (!digits) return { valid: false, motivo: 'telefone vazio' };
if (!digits.startsWith('55') || digits.length < 12 || digits.length > 13) {
return { valid: false, motivo: 'telefone invalido' };
}
const ddd = Number(digits.slice(2, 4));
if (ddd < 11 || ddd > 99) return { valid: false, motivo: 'ddd invalido' };
const local = digits.slice(4);
if (/^(\d)\1+$/.test(local)) return { valid: false, motivo: 'telefone invalido' };
return { valid: true, motivo: 'telefone valido' };
}
export function validateCnpj(value = '') {
const cnpj = onlyDigits(value);
if (cnpj.length !== 14 || /^(\d)\1+$/.test(cnpj)) return false;
const digit = (base) => {
let weight = base.length - 7;
let sum = 0;
for (const char of base) {
sum += Number(char) * weight;
weight -= 1;
if (weight < 2) weight = 9;
}
const rest = sum % 11;
return rest < 2 ? 0 : 11 - rest;
};
return digit(cnpj.slice(0, 12)) === Number(cnpj[12])
&& digit(cnpj.slice(0, 13)) === Number(cnpj[13]);
}
export function validateEmpresa(nome = '') {
const texto = String(nome ?? '').trim();
const limpo = cleanText(texto);
if (!limpo || limpo.length < 2) {
return { valid: false, motivo: 'empresa vazia ou com lixo' };
}
if (/^\d/.test(texto)) {
return { valid: false, motivo: 'empresa comeca com numero' };
}
if (!/[a-z]/.test(limpo)) {
return { valid: false, motivo: 'empresa vazia ou com lixo' };
}
return { valid: true, motivo: 'empresa valida' };
}
// "Os dados deste lead sao confiaveis?" — nao mede potencial de compra.
export function scoreQualidade({ whatsapp, cnpj, empresa, cidade, segmento, cnae, website }) {
const telefone = validatePhone(whatsapp);
const cnpjValido = validateCnpj(cnpj);
const empresaCheck = validateEmpresa(empresa);
let score = 0;
if (telefone.valid) score += 30;
if (cnpjValido) score += 20;
if (empresaCheck.valid) score += 20;
if (String(cidade ?? '').trim()) score += 5;
if (String(segmento ?? '').trim()) score += 10;
if (onlyDigits(cnae)) score += 10;
if (String(website ?? '').trim()) score += 5;
return {
score,
telefoneValido: telefone.valid,
motivoTelefone: telefone.motivo,
cnpjValido,
empresaValida: empresaCheck.valid,
motivoEmpresa: empresaCheck.motivo
};
}
const AFFINITY_POINTS = { alta: 40, media: 30, baixa: 20 };
// "Qual a chance desta empresa contratar uma solucao da GTI?"
export function scoreComercial({ whatsapp, cnpj, website, email, affinity }) {
let score = AFFINITY_POINTS[affinity] ?? AFFINITY_POINTS.media;
if (String(website ?? '').trim()) score += 15;
if (String(email ?? '').trim()) score += 10;
if (validatePhone(whatsapp).valid) score += 15;
if (validateCnpj(cnpj)) score += 20;
return score;
}
export function scoreGeral(scoreQualidadeValor, scoreComercialValor) {
return Math.round(scoreComercialValor * 0.6 + scoreQualidadeValor * 0.4);
}
// Prioridade comercial: 1 = atacar imediatamente, 4 = so em campanhas especificas.
export const CLASSIFICACAO_TIERS = [
{ min: 90, id: 'excelente', label: 'Excelente', prioridade: 1 },
{ min: 75, id: 'bom', label: 'Bom', prioridade: 1 },
{ min: 55, id: 'medio', label: 'Médio', prioridade: 2 },
{ min: 35, id: 'baixo', label: 'Baixo', prioridade: 3 },
{ min: 0, id: 'muito_baixo', label: 'Muito Baixo', prioridade: 4 }
];
export function classificarScoreGeral(score) {
return CLASSIFICACAO_TIERS.find((tier) => score >= tier.min);
}
// Chave byte-compativel com a chave_duplicidade do TRATADOS (cnpj:...) e com o
// registro legado de envios (phone:... / name:<empresa>:<cidade>).
export function dedupKey({ cnpj, whatsapp, empresa, cidade }) {
const cnpjDigits = onlyDigits(cnpj);
if (validateCnpj(cnpjDigits)) return `cnpj:${cnpjDigits}`;
const phoneDigits = onlyDigits(whatsapp);
if (validatePhone(phoneDigits).valid) return `phone:${phoneDigits}`;
return `name:${cleanText(empresa)}:${cleanText(cidade)}`;
}
export function extractDdd(whatsapp = '') {
const digits = onlyDigits(whatsapp);
return digits.startsWith('55') && digits.length >= 12 ? digits.slice(2, 4) : '';
}
// 'lojas_de_calcados' -> 'Lojas De Calcados' (mesmo formato do segmento_legivel legado)
export function segmentLabel(name = '') {
return String(name)
.split('_')
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
+126
View File
@@ -0,0 +1,126 @@
import http from 'node:http';
import { paths } from '../env.mjs';
import { createRouter } from './router.mjs';
import { serveStatic } from './static.mjs';
import { openSse } from './sse.mjs';
import { registerMetaRoutes } from './routes/meta.mjs';
import { registerCaptureRoutes } from './routes/capture.mjs';
import { registerExtractionRoutes } from './routes/extractions.mjs';
import { registerLeadRoutes } from './routes/leads.mjs';
import { registerExportRoutes } from './routes/exports.mjs';
import { registerAuthRoutes } from './routes/auth.mjs';
import { getCookie, getSession, SESSION_COOKIE } from './session.mjs';
const PUBLIC_API_PATHS = new Set(['/api/auth/login', '/api/health']);
const BODY_LIMIT = 1024 * 1024; // 1MB
function readJsonBody(req) {
return new Promise((resolve, reject) => {
let size = 0;
const chunks = [];
req.on('data', (chunk) => {
size += chunk.length;
if (size > BODY_LIMIT) {
reject(new Error('corpo da requisicao muito grande'));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => {
if (!chunks.length) {
resolve(null);
return;
}
try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
} catch {
reject(new Error('JSON invalido no corpo da requisicao'));
}
});
req.on('error', reject);
});
}
export function createAppServer({ db, jobs, log = () => {} }) {
const router = createRouter();
const deps = { db, jobs };
registerAuthRoutes(router, deps);
registerMetaRoutes(router, deps);
registerCaptureRoutes(router, deps);
registerExtractionRoutes(router, deps);
registerLeadRoutes(router, deps);
registerExportRoutes(router, deps);
// SSE de progresso dos jobs (rota especial: gerencia a resposta manualmente)
router.add('GET', '/api/jobs/:jobId/events', async (req, res, { params }) => {
const sse = openSse(res);
const unsubscribe = jobs.subscribe(params.jobId, (event) => {
const name = event.t === 'done' ? 'done' : event.t === 'error' ? 'error' : event.t;
sse.send(name, event);
});
if (unsubscribe === null) {
sse.send('error', { t: 'error', message: 'job nao encontrado (o servidor pode ter sido reiniciado)' });
sse.close();
return;
}
req.on('close', () => {
unsubscribe();
sse.close();
});
});
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://127.0.0.1');
const pathname = url.pathname;
const json = (status, data) => {
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(data));
};
const session = getSession(getCookie(req, SESSION_COOKIE));
const user = session?.user || null;
try {
if (pathname.startsWith('/api/') && !PUBLIC_API_PATHS.has(pathname) && !user) {
json(401, { erro: 'não autenticado' });
return;
}
const matched = router.match(req.method, pathname);
if (matched) {
let body = null;
if (req.method === 'POST' || req.method === 'PUT') {
body = await readJsonBody(req);
}
await matched.handler(req, res, { params: matched.params, query: url.searchParams, body, json, user });
return;
}
if (req.method === 'GET' && !pathname.startsWith('/api/')) {
if ((pathname === '/' || pathname === '/index.html') && !user) {
res.writeHead(302, { location: '/login.html' });
res.end();
return;
}
if (serveStatic(paths.public, pathname, res)) return;
}
json(404, { erro: `rota nao encontrada: ${req.method} ${pathname}` });
} catch (error) {
log(`erro em ${req.method} ${pathname}: ${error.stack || error.message}`);
if (!res.headersSent) {
json(500, { erro: String(error.message || error) });
} else {
res.end();
}
}
});
return server;
}
+33
View File
@@ -0,0 +1,33 @@
// Roteador minimo: metodo + padrao com :parametros. Sem dependencias.
export function createRouter() {
const routes = [];
function add(method, pattern, handler) {
const parts = pattern.split('/').filter(Boolean);
routes.push({ method, parts, handler, pattern });
}
function match(method, pathname) {
const parts = pathname.split('/').filter(Boolean);
for (const route of routes) {
if (route.method !== method || route.parts.length !== parts.length) continue;
const params = {};
let ok = true;
for (let i = 0; i < parts.length; i += 1) {
const expected = route.parts[i];
if (expected.startsWith(':')) {
params[expected.slice(1)] = decodeURIComponent(parts[i]);
} else if (expected !== parts[i]) {
ok = false;
break;
}
}
if (ok) return { handler: route.handler, params };
}
return null;
}
return { add, match };
}
+74
View File
@@ -0,0 +1,74 @@
import { findUserByUsername, createUser, listUsers } from '../../db.mjs';
import { hashPassword, verifyPassword } from '../../auth.mjs';
import { createSession, destroySession, getCookie, sessionCookieHeader, clearSessionCookieHeader, SESSION_COOKIE } from '../session.mjs';
function publicUser(user) {
return { id: user.id, username: user.username, role: user.role };
}
export function registerAuthRoutes(router, { db }) {
router.add('POST', '/api/auth/login', async (req, res, { json, body }) => {
const username = String(body?.username || '').trim();
const password = String(body?.password || '');
const user = username ? findUserByUsername(db, username) : null;
if (!user || !verifyPassword(password, user.password_hash)) {
json(401, { erro: 'usuário ou senha inválidos' });
return;
}
const token = createSession(publicUser(user));
res.setHeader('Set-Cookie', sessionCookieHeader(token));
json(200, { user: publicUser(user) });
});
router.add('POST', '/api/auth/logout', async (req, res, { json }) => {
const token = getCookie(req, SESSION_COOKIE);
if (token) destroySession(token);
res.setHeader('Set-Cookie', clearSessionCookieHeader());
json(200, { ok: true });
});
router.add('GET', '/api/auth/me', async (req, res, { json, user }) => {
if (!user) {
json(401, { erro: 'não autenticado' });
return;
}
json(200, { user });
});
router.add('GET', '/api/users', async (req, res, { json, user }) => {
if (user?.role !== 'admin') {
json(403, { erro: 'apenas administradores podem ver os usuários' });
return;
}
json(200, { users: listUsers(db) });
});
router.add('POST', '/api/users', async (req, res, { json, user, body }) => {
if (user?.role !== 'admin') {
json(403, { erro: 'apenas administradores podem criar usuários' });
return;
}
const username = String(body?.username || '').trim();
const password = String(body?.password || '');
const role = body?.role === 'admin' ? 'admin' : 'comercial';
if (username.length < 3) {
json(400, { erro: 'usuário deve ter ao menos 3 caracteres' });
return;
}
if (password.length < 4) {
json(400, { erro: 'senha deve ter ao menos 4 caracteres' });
return;
}
if (findUserByUsername(db, username)) {
json(409, { erro: 'já existe um usuário com esse nome' });
return;
}
createUser(db, { username, passwordHash: hashPassword(password), role });
json(201, { ok: true });
});
}
+112
View File
@@ -0,0 +1,112 @@
import fs from 'node:fs';
import path from 'node:path';
import { paths } from '../../env.mjs';
import { UF_NAMES } from '../../sources/common.mjs';
import { getSource } from '../../sources/index.mjs';
function hasCnpjBaseForUf(uf) {
return fs.existsSync(path.join(paths.cnpjDir, `estabelecimentos-${uf}.csv`))
|| (uf === 'BA' && fs.existsSync(path.join(paths.cnpjDir, 'estabelecimentos-bahia.csv')));
}
export function registerCaptureRoutes(router, { db, jobs }) {
router.add('POST', '/api/capture/runs', async (req, res, { json, body }) => {
const sources = Array.isArray(body?.sources) ? body.sources.filter(Boolean) : [];
const uf = String(body?.uf || '').toUpperCase();
const cities = Array.isArray(body?.cities) && body.cities.length ? body.cities : ['all'];
const segments = Array.isArray(body?.segments) && body.segments.length ? body.segments : ['all'];
const limits = {
maxPerQuery: Math.max(1, Number(body?.limits?.maxPerQuery) || 10),
queryLimit: Math.max(0, Number(body?.limits?.queryLimit) || 0),
googlePages: Math.min(3, Math.max(1, Number(body?.limits?.googlePages) || 3))
};
if (!sources.length) {
json(400, { erro: 'selecione pelo menos uma fonte' });
return;
}
for (const id of sources) {
if (!getSource(id)) {
json(400, { erro: `fonte desconhecida: ${id}` });
return;
}
}
if (!UF_NAMES[uf]) {
json(400, { erro: 'informe uma UF valida' });
return;
}
if ((sources.includes('cnpj-base') || sources.includes('brasilapi')) && !hasCnpjBaseForUf(uf)) {
json(409, {
erro: `a base CNPJ de ${uf} ainda nao foi extraida`,
precisaExtracao: true,
uf
});
return;
}
const running = db.prepare("SELECT id FROM capture_runs WHERE status IN ('pending','running')").get();
if (running) {
json(409, { erro: `ja existe uma captura em andamento (#${running.id}). Aguarde ou cancele.` });
return;
}
const params = { sources, uf, cities, segments, limits };
const inserted = db.prepare("INSERT INTO capture_runs (params_json, status) VALUES (?, 'pending')")
.run(JSON.stringify(params));
const runId = Number(inserted.lastInsertRowid);
const jobId = jobs.startJob({
type: 'captura',
script: 'src/capture/capture-job.mjs',
args: [`--run-id=${runId}`],
meta: { runId, uf, sources }
});
db.prepare('UPDATE capture_runs SET job_id = ? WHERE id = ?').run(jobId, runId);
json(202, { runId, jobId });
});
router.add('GET', '/api/capture/runs', async (req, res, { json, query }) => {
const limit = Math.min(100, Math.max(1, Number(query.get('limit')) || 20));
const rows = db.prepare(
`SELECT id, job_id, params_json, status, progress_json, raw_count, new_count, dup_count,
error, started_at, finished_at, created_at
FROM capture_runs ORDER BY id DESC LIMIT ?`
).all(limit);
json(200, {
runs: rows.map((row) => ({
...row,
params: JSON.parse(row.params_json || '{}'),
progress: JSON.parse(row.progress_json || 'null'),
params_json: undefined,
progress_json: undefined
}))
});
});
router.add('GET', '/api/capture/runs/:id', async (req, res, { json, params }) => {
const row = db.prepare('SELECT * FROM capture_runs WHERE id = ?').get(Number(params.id));
if (!row) {
json(404, { erro: 'execucao nao encontrada' });
return;
}
json(200, {
...row,
params: JSON.parse(row.params_json || '{}'),
progress: JSON.parse(row.progress_json || 'null'),
params_json: undefined,
progress_json: undefined
});
});
router.add('POST', '/api/capture/runs/:id/cancel', async (req, res, { json, params }) => {
const row = db.prepare('SELECT job_id, status FROM capture_runs WHERE id = ?').get(Number(params.id));
if (!row) {
json(404, { erro: 'execucao nao encontrada' });
return;
}
const ok = row.job_id ? jobs.cancelJob(row.job_id) : false;
json(ok ? 202 : 409, ok ? { cancelando: true } : { erro: `nao ha job ativo para cancelar (status: ${row.status})` });
});
}
+96
View File
@@ -0,0 +1,96 @@
import fs from 'node:fs';
import path from 'node:path';
import { paths } from '../../env.mjs';
import { countLeads } from '../../treatment/queries.mjs';
const TIPOS = new Set(['excel', 'zpro-oportunidades', 'zpro-bulk']);
function parseExportRow(row) {
return {
...row,
filtro: JSON.parse(row.filtro_json || '{}'),
params: JSON.parse(row.params_json || '{}'),
arquivos: JSON.parse(row.arquivos_json || '[]'),
filtro_json: undefined,
params_json: undefined,
arquivos_json: undefined
};
}
export function registerExportRoutes(router, { db, jobs }) {
router.add('POST', '/api/exports', async (req, res, { json, body }) => {
const tipo = String(body?.tipo || '');
if (!TIPOS.has(tipo)) {
json(400, { erro: `tipo invalido. Use: ${[...TIPOS].join(', ')}` });
return;
}
const filtro = body?.filtro && typeof body.filtro === 'object' ? body.filtro : {};
const params = body?.params && typeof body.params === 'object' ? body.params : {};
const total = countLeads(db, filtro);
if (!total) {
json(400, { erro: 'nenhum lead corresponde ao filtro selecionado' });
return;
}
const inserted = db.prepare(
"INSERT INTO exports (tipo, filtro_json, params_json, status) VALUES (?, ?, ?, 'pending')"
).run(tipo, JSON.stringify(filtro), JSON.stringify(params));
const exportId = Number(inserted.lastInsertRowid);
const jobId = jobs.startJob({
type: 'exportacao',
script: 'src/export/export-job.mjs',
args: [`--export-id=${exportId}`],
meta: { exportId, tipo, total }
});
db.prepare('UPDATE exports SET job_id = ? WHERE id = ?').run(jobId, exportId);
json(202, { exportId, jobId, totalEstimado: total });
});
router.add('GET', '/api/exports', async (req, res, { json, query }) => {
const limit = Math.min(100, Math.max(1, Number(query.get('limit')) || 20));
const rows = db.prepare('SELECT * FROM exports ORDER BY id DESC LIMIT ?').all(limit);
json(200, { exports: rows.map(parseExportRow) });
});
router.add('GET', '/api/exports/:id', async (req, res, { json, params }) => {
const row = db.prepare('SELECT * FROM exports WHERE id = ?').get(Number(params.id));
if (!row) {
json(404, { erro: 'exportacao nao encontrada' });
return;
}
json(200, parseExportRow(row));
});
router.add('GET', '/api/exports/:id/download', async (req, res, { json, params, query }) => {
const row = db.prepare('SELECT * FROM exports WHERE id = ?').get(Number(params.id));
if (!row) {
json(404, { erro: 'exportacao nao encontrada' });
return;
}
const arquivos = JSON.parse(row.arquivos_json || '[]');
const nome = String(query.get('arquivo') || arquivos[0] || '');
if (!arquivos.includes(nome)) {
json(400, { erro: 'arquivo nao pertence a esta exportacao' });
return;
}
const dir = path.resolve(paths.exports, `export-${row.id}`);
const resolved = path.resolve(dir, nome);
if (!resolved.startsWith(dir + path.sep) || !fs.existsSync(resolved)) {
json(404, { erro: 'arquivo nao encontrado' });
return;
}
res.writeHead(200, {
'content-type': 'application/octet-stream',
'content-disposition': `attachment; filename="${encodeURIComponent(nome)}"`,
'content-length': fs.statSync(resolved).size
});
fs.createReadStream(resolved).pipe(res);
});
}
+72
View File
@@ -0,0 +1,72 @@
import fs from 'node:fs';
import { paths } from '../../env.mjs';
import { UF_NAMES } from '../../sources/common.mjs';
import { resolveZipReader } from '../../extract/rfb-extractor.mjs';
export function registerExtractionRoutes(router, { db, jobs }) {
router.add('POST', '/api/extractions', async (req, res, { json, body }) => {
const uf = String(body?.uf || '').toUpperCase();
if (!UF_NAMES[uf]) {
json(400, { erro: 'informe uma UF valida' });
return;
}
if (!fs.existsSync(paths.rfbRaw)) {
json(409, { erro: `zips da Receita Federal nao encontrados em ${paths.rfbRaw}. Rode scripts/baixar-base-rfb.sh primeiro.` });
return;
}
try {
resolveZipReader();
} catch (error) {
json(409, { erro: error.message });
return;
}
const running = db.prepare(
"SELECT id, uf FROM extractions WHERE status IN ('pending','running')"
).get();
if (running) {
json(409, { erro: `ja existe uma extracao em andamento (${running.uf}). Aguarde terminar.` });
return;
}
const inserted = db.prepare("INSERT INTO extractions (uf, status) VALUES (?, 'pending')").run(uf);
const extractionId = Number(inserted.lastInsertRowid);
const jobId = jobs.startJob({
type: 'extracao',
script: 'src/extract/extract-job.mjs',
args: [`--extraction-id=${extractionId}`],
meta: { extractionId, uf }
});
db.prepare('UPDATE extractions SET job_id = ? WHERE id = ?').run(jobId, extractionId);
json(202, { extractionId, jobId });
});
router.add('GET', '/api/extractions', async (req, res, { json }) => {
const rows = db.prepare(
`SELECT id, job_id, uf, status, csv_path, total_rows, with_phone, municipios_count,
progress_json, error, started_at, finished_at, created_at
FROM extractions ORDER BY id DESC LIMIT 20`
).all();
json(200, {
extractions: rows.map((row) => ({
...row,
progress: JSON.parse(row.progress_json || 'null'),
progress_json: undefined
}))
});
});
router.add('POST', '/api/extractions/:id/cancel', async (req, res, { json, params }) => {
const row = db.prepare('SELECT job_id, status FROM extractions WHERE id = ?').get(Number(params.id));
if (!row) {
json(404, { erro: 'extracao nao encontrada' });
return;
}
const ok = row.job_id ? jobs.cancelJob(row.job_id) : false;
json(ok ? 202 : 409, ok ? { cancelando: true } : { erro: `nao ha job ativo para cancelar (status: ${row.status})` });
});
}
+53
View File
@@ -0,0 +1,53 @@
import { listLeads, getLead, leadFacets } from '../../treatment/queries.mjs';
import {
dashboardSummary, dashboardRanking, dashboardDailyEvolution, dashboardMaiorOportunidade
} from '../../treatment/dashboard.mjs';
function filterFromQuery(query) {
const filtro = {};
for (const key of ['uf', 'cidade', 'segmento', 'classificacao', 'interesse', 'origem',
'telefone_valido', 'exportado', 'q', 'score_min', 'page', 'pageSize', 'sort',
'prioridade', 'produto', 'website', 'email']) {
const value = query.get(key);
if (value !== null && value !== '') filtro[key] = value;
}
return filtro;
}
export function registerLeadRoutes(router, { db }) {
router.add('GET', '/api/leads', async (req, res, { json, query }) => {
json(200, listLeads(db, filterFromQuery(query)));
});
router.add('GET', '/api/leads/facets', async (req, res, { json, query }) => {
json(200, leadFacets(db, filterFromQuery(query)));
});
router.add('GET', '/api/leads/:id', async (req, res, { json, params }) => {
const lead = getLead(db, Number(params.id));
if (!lead) {
json(404, { erro: 'lead nao encontrado' });
return;
}
json(200, lead);
});
router.add('GET', '/api/dashboard/summary', async (req, res, { json, query }) => {
json(200, dashboardSummary(db, filterFromQuery(query)));
});
router.add('GET', '/api/dashboard/rankings', async (req, res, { json, query }) => {
const by = query.get('by') || 'cidade';
const limit = query.get('limit') || 15;
json(200, { by, ranking: dashboardRanking(db, by, filterFromQuery(query), limit) });
});
router.add('GET', '/api/dashboard/evolution', async (req, res, { json, query }) => {
const dias = query.get('dias') || 14;
json(200, { evolution: dashboardDailyEvolution(db, filterFromQuery(query), dias) });
});
router.add('GET', '/api/dashboard/oportunidade', async (req, res, { json, query }) => {
json(200, { oportunidade: dashboardMaiorOportunidade(db, filterFromQuery(query)) });
});
}
+142
View File
@@ -0,0 +1,142 @@
import fs from 'node:fs';
import path from 'node:path';
import { paths } from '../../env.mjs';
import { UF_NAMES } from '../../sources/common.mjs';
import { describeSources } from '../../sources/index.mjs';
import { getSetting, setSetting, resetLeadsBase } from '../../db.mjs';
import { cleanText } from '../../lead-engine.mjs';
// Senha de confirmacao para a limpeza total da base (acao destrutiva e
// irreversivel) — pedida no modal de Configuracoes antes de zerar leads,
// exportacoes, capturas e extracoes. Vem do .env (nunca versionado).
const RESET_BASE_PASSWORD = process.env.GTI_RESET_PASSWORD;
function extractedCsvForUf(uf) {
const candidates = [
path.join(paths.cnpjDir, `estabelecimentos-${uf}.csv`),
// nome antigo da primeira extracao (Bahia)
...(uf === 'BA' ? [path.join(paths.cnpjDir, 'estabelecimentos-bahia.csv')] : [])
];
return candidates.find((file) => fs.existsSync(file)) || null;
}
export function registerMetaRoutes(router, { db, jobs }) {
router.add('GET', '/api/health', async (req, res, { json }) => {
json(200, { ok: true, app: 'gti-maquina-comercial', pid: process.pid });
});
router.add('GET', '/api/meta/ufs', async (req, res, { json }) => {
const doneByUf = new Map(
db.prepare(
`SELECT uf, MAX(finished_at) AS finished_at, total_rows, municipios_count
FROM extractions WHERE status = 'done' GROUP BY uf`
).all().map((row) => [row.uf, row])
);
const municipiosByUf = new Map(
db.prepare('SELECT uf, COUNT(*) AS total FROM municipios GROUP BY uf').all()
.map((row) => [row.uf, row.total])
);
const runningUfs = new Set(
db.prepare("SELECT uf FROM extractions WHERE status IN ('pending','running')").all().map((r) => r.uf)
);
const ufs = Object.entries(UF_NAMES).map(([uf, nome]) => {
const csv = extractedCsvForUf(uf);
const done = doneByUf.get(uf);
return {
uf,
nome,
municipios: municipiosByUf.get(uf) || 0,
extraida: Boolean(csv),
extraindo: runningUfs.has(uf),
extracao: done ? { finished_at: done.finished_at, total_rows: done.total_rows } : null
};
});
json(200, { ufs });
});
router.add('GET', '/api/meta/municipios', async (req, res, { json, query }) => {
const uf = String(query.get('uf') || '').toUpperCase();
if (!UF_NAMES[uf]) {
json(400, { erro: 'informe uf valida, ex.: ?uf=BA' });
return;
}
const q = String(query.get('q') || '').toLowerCase();
let rows = db.prepare('SELECT codigo_ibge, nome, nome_clean, uf FROM municipios WHERE uf = ? ORDER BY nome').all(uf);
if (q) rows = rows.filter((row) => row.nome_clean.includes(q));
json(200, { municipios: rows.map((r) => ({ codigo: r.codigo_ibge, nome: r.nome })) });
});
router.add('GET', '/api/meta/segments', async (req, res, { json }) => {
const rows = db.prepare(
'SELECT id, name, label, interest, cnae_codes_json FROM segments WHERE active = 1 ORDER BY label'
).all();
json(200, {
segments: rows.map((row) => ({
id: row.id,
name: row.name,
label: row.label,
interesse: row.interest,
cnaes: JSON.parse(row.cnae_codes_json).length
}))
});
});
// Busca livre em todas as ~1359 atividades (CNAE) da Receita Federal — usada
// para adicionar nichos avulsos alem dos 57 pre-configurados em segments.
router.add('GET', '/api/meta/cnaes', async (req, res, { json, query }) => {
const q = cleanText(String(query.get('q') || ''));
if (q.length < 2) {
json(200, { cnaes: [] });
return;
}
const rows = db.prepare('SELECT codigo, descricao FROM cnaes').all();
const achados = rows
.filter((r) => cleanText(r.descricao).includes(q) || r.codigo.startsWith(q))
.slice(0, 30);
json(200, { cnaes: achados });
});
router.add('GET', '/api/meta/sources', async (req, res, { json }) => {
json(200, { sources: describeSources() });
});
router.add('GET', '/api/settings', async (req, res, { json }) => {
const rows = db.prepare('SELECT key, value FROM settings ORDER BY key').all();
json(200, { settings: Object.fromEntries(rows.map((r) => [r.key, r.value])) });
});
router.add('PUT', '/api/settings', async (req, res, { json, body }) => {
if (!body || typeof body !== 'object') {
json(400, { erro: 'corpo JSON esperado: {"chave": "valor"}' });
return;
}
for (const [key, value] of Object.entries(body)) {
setSetting(db, key, value);
}
json(200, { ok: true, atualizadas: Object.keys(body).length });
});
router.add('GET', '/api/jobs/active', async (req, res, { json }) => {
json(200, { jobs: jobs.activeJobs() });
});
// Zera toda a base de leads (irreversivel): exige a senha de confirmacao.
router.add('POST', '/api/admin/reset', async (req, res, { json, body }) => {
if (!body || body.senha !== RESET_BASE_PASSWORD) {
json(401, { erro: 'senha incorreta' });
return;
}
resetLeadsBase(db);
for (const entry of fs.readdirSync(paths.exports, { withFileTypes: true })) {
fs.rmSync(path.join(paths.exports, entry.name), { recursive: true, force: true });
}
json(200, { ok: true });
});
}
+51
View File
@@ -0,0 +1,51 @@
import crypto from 'node:crypto';
// Sessoes em memoria: simples e suficiente para um app local de usuario unico
// por vez. Reiniciar o servidor derruba as sessoes (usuarios logam de novo).
export const SESSION_COOKIE = 'gti_session';
const SESSION_TTL_MS = 12 * 60 * 60 * 1000; // 12h, renovada a cada requisicao
const sessions = new Map();
export function createSession(user) {
const token = crypto.randomBytes(32).toString('hex');
sessions.set(token, { user, expiresAt: Date.now() + SESSION_TTL_MS });
return token;
}
export function getSession(token) {
if (!token) return null;
const session = sessions.get(token);
if (!session) return null;
if (session.expiresAt < Date.now()) {
sessions.delete(token);
return null;
}
session.expiresAt = Date.now() + SESSION_TTL_MS;
return session;
}
export function destroySession(token) {
sessions.delete(token);
}
export function getCookie(req, name) {
const header = req.headers.cookie;
if (!header) return null;
for (const part of header.split(';')) {
const separator = part.indexOf('=');
if (separator === -1) continue;
if (part.slice(0, separator).trim() === name) {
return decodeURIComponent(part.slice(separator + 1).trim());
}
}
return null;
}
export function sessionCookieHeader(token) {
return `${SESSION_COOKIE}=${token}; HttpOnly; Path=/; SameSite=Strict; Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`;
}
export function clearSessionCookieHeader() {
return `${SESSION_COOKIE}=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0`;
}
+33
View File
@@ -0,0 +1,33 @@
// Server-Sent Events: canal de progresso ao vivo dos jobs para a tela.
export function openSse(res) {
res.writeHead(200, {
'content-type': 'text/event-stream; charset=utf-8',
'cache-control': 'no-cache, no-transform',
connection: 'keep-alive',
'x-accel-buffering': 'no'
});
res.write(':ok\n\n');
const keepAlive = setInterval(() => {
try {
res.write(':ping\n\n');
} catch {
clearInterval(keepAlive);
}
}, 15000);
keepAlive.unref();
return {
send(event, data) {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
},
close() {
clearInterval(keepAlive);
try {
res.end();
} catch {
// ja fechado
}
}
};
}
+39
View File
@@ -0,0 +1,39 @@
import fs from 'node:fs';
import path from 'node:path';
const MIME = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.ico': 'image/x-icon',
'.woff2': 'font/woff2'
};
// Servidor de arquivos do frontend (pasta public/), com protecao contra
// path traversal: qualquer caminho resolvido fora da pasta e rejeitado.
export function serveStatic(publicDir, urlPath, res) {
const relative = urlPath === '/' ? 'index.html' : urlPath.replace(/^\/+/, '');
const resolved = path.resolve(publicDir, relative);
if (!resolved.startsWith(path.resolve(publicDir) + path.sep) && resolved !== path.resolve(publicDir, 'index.html')) {
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
res.end('Acesso negado');
return true;
}
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
return false;
}
const ext = path.extname(resolved).toLowerCase();
res.writeHead(200, {
'content-type': MIME[ext] || 'application/octet-stream',
'cache-control': 'no-cache'
});
fs.createReadStream(resolved).pipe(res);
return true;
}
+172
View File
@@ -0,0 +1,172 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { cleanText } from '../lead-engine.mjs';
import { onlyDigits } from '../scoring.mjs';
import { streamCsvRows } from '../util/csv.mjs';
import { buildLeadFromPlace, UA_HEADERS } from './common.mjs';
async function brasilApiCnpj(baseUrl, cnpj) {
if (!cnpj || cnpj.length !== 14) return null;
const response = await fetch(`${baseUrl}/cnpj/v1/${cnpj}`, { headers: UA_HEADERS });
if (!response.ok) return null;
return response.json();
}
function mergeBrasilApi(place, data) {
if (!data) return place;
return {
...place,
name: data.nome_fantasia || data.razao_social || place.name,
legal_name: data.razao_social || place.legal_name,
phone: [data.ddd_telefone_1, data.ddd_telefone_2].filter(Boolean)[0] || place.phone,
city: data.municipio || place.city,
state: data.uf || place.state,
cnae: data.cnae_fiscal ? String(data.cnae_fiscal) : place.cnae,
address: [data.logradouro, data.numero, data.bairro].filter(Boolean).join(', ') || place.address
};
}
function normalizeHeader(header) {
return cleanText(header).replace(/\s+/g, '_');
}
function getAny(row, keys) {
for (const key of keys) {
const normalized = normalizeHeader(key);
if (row[normalized]) return row[normalized];
}
return '';
}
function cnpjMatchesSegment(row, segment, textCache) {
const cnae = onlyDigits(getAny(row, ['cnae', 'cnae_fiscal', 'cnae principal', 'cnae_principal']));
const cnaeCodes = segment._cnaeCodes;
for (const code of cnaeCodes) {
if (cnae.startsWith(code)) return true;
}
if (textCache.value === null) {
textCache.value = cleanText([
getAny(row, ['nome fantasia', 'nome_fantasia', 'fantasia']),
getAny(row, ['razao social', 'razao_social', 'nome empresarial']),
getAny(row, ['atividade principal', 'atividade_principal', 'descricao cnae', 'descricao_cnae'])
].join(' '));
}
const text = textCache.value;
if (!text) return false;
return segment._queryTerms.some((term) => text.includes(term));
}
function cnpjRowToPlace(row, segment, city, state) {
const cnpj = onlyDigits(getAny(row, ['cnpj', 'cnpj_basico', 'cnpj completo', 'cnpj_completo']));
const phone = [
getAny(row, ['ddd 1', 'ddd1', 'ddd']),
getAny(row, ['telefone 1', 'telefone1', 'telefone', 'whatsapp'])
].filter(Boolean).join('');
const name = getAny(row, ['nome fantasia', 'nome_fantasia', 'fantasia'])
|| getAny(row, ['razao social', 'razao_social', 'nome empresarial']);
if (!name) return null;
return {
name,
legal_name: getAny(row, ['razao social', 'razao_social', 'nome empresarial']),
phone,
city: getAny(row, ['municipio', 'cidade']) || city,
state: getAny(row, ['uf', 'estado']) || state,
segment: segment.name,
website: '',
cnpj,
cnae: getAny(row, ['cnae', 'cnae_fiscal', 'cnae principal', 'cnae_principal']),
address: [
getAny(row, ['logradouro']),
getAny(row, ['numero']),
getAny(row, ['bairro'])
].filter(Boolean).join(', '),
query: `cnpj-base ${segment.name} em ${city} ${state}`
};
}
// Fonte cnpj-base: le os CSVs locais extraidos da Receita Federal
// (prospeccao/cnpj/estabelecimentos-*.csv) em streaming, filtrando por
// UF + cidades e casando cada linha com o primeiro nicho compativel.
export async function* collect(params, ctx) {
const { uf, cities, segments } = params;
const allCities = !cities || !cities.length || cities.includes('*');
const citySet = allCities ? null : new Set(cities.map((c) => cleanText(c)));
const ufClean = cleanText(uf);
// Pre-processa termos por segmento uma unica vez (o loop interno roda
// milhoes de vezes na base de uma UF inteira).
const preparedSegments = segments.map((segment) => ({
...segment,
_cnaeCodes: (segment.cnaeCodes || []).map(onlyDigits).filter(Boolean),
_queryTerms: (segment.queries || []).map((term) => cleanText(term)).filter(Boolean)
}));
let files = [];
try {
files = (await fs.readdir(ctx.cnpjDir))
.filter((file) => /\.csv$/i.test(file))
.map((file) => path.join(ctx.cnpjDir, file));
} catch (error) {
if (error.code === 'ENOENT') {
ctx.log(`base CNPJ nao encontrada em ${ctx.cnpjDir} — fonte cnpj-base pulada`);
return;
}
throw error;
}
let batch = [];
let scanned = 0;
for (const file of files) {
ctx.log(`cnpj-base: lendo ${path.basename(file)}`);
for await (const row of streamCsvRows(file, { normalizeHeader })) {
if (ctx.isCancelled()) return;
scanned += 1;
if (scanned % 100000 === 0) {
ctx.emit({ phase: 'cnpj-base', message: `${scanned} linhas lidas`, detail: path.basename(file) });
}
const rowState = cleanText(getAny(row, ['uf', 'estado']));
if (rowState && ufClean && rowState !== ufClean) continue;
const rowCity = cleanText(getAny(row, ['municipio', 'cidade']));
if (citySet && !citySet.has(rowCity)) continue;
const textCache = { value: null };
for (const segment of preparedSegments) {
if (!cnpjMatchesSegment(row, segment, textCache)) continue;
let place = cnpjRowToPlace(row, segment, rowCity, uf);
if (place) {
if (params.enrichBrasilApi && place.cnpj) {
try {
place = mergeBrasilApi(place, await brasilApiCnpj(ctx.endpoints.brasilApiUrl, place.cnpj));
} catch {
// enriquecimento e opcional: falha de rede nao derruba a captura
}
}
const origin = params.enrichBrasilApi ? 'cnpj-base+brasilapi' : 'cnpj-base';
batch.push(buildLeadFromPlace(place, segment, place.city, uf, origin));
}
break;
}
if (batch.length >= 2000) {
yield { origin: 'cnpj-base', label: path.basename(file), leads: batch };
batch = [];
}
}
}
if (batch.length) {
yield { origin: 'cnpj-base', label: 'cnpj-base', leads: batch };
}
ctx.log(`cnpj-base: ${scanned} linhas da base local processadas`);
}
+74
View File
@@ -0,0 +1,74 @@
import { normalizePhone } from '../lead-engine.mjs';
export const UA_HEADERS = {
accept: 'application/json,text/plain,*/*',
'user-agent': 'Grupo-GTI-Comercial/1.0 contato@grupogti.local'
};
export const UF_NAMES = {
AC: 'Acre', AL: 'Alagoas', AP: 'Amapa', AM: 'Amazonas', BA: 'Bahia',
CE: 'Ceara', DF: 'Distrito Federal', ES: 'Espirito Santo', GO: 'Goias',
MA: 'Maranhao', MT: 'Mato Grosso', MS: 'Mato Grosso do Sul', MG: 'Minas Gerais',
PA: 'Para', PB: 'Paraiba', PR: 'Parana', PE: 'Pernambuco', PI: 'Piaui',
RJ: 'Rio de Janeiro', RN: 'Rio Grande do Norte', RS: 'Rio Grande do Sul',
RO: 'Rondonia', RR: 'Roraima', SC: 'Santa Catarina', SP: 'Sao Paulo',
SE: 'Sergipe', TO: 'Tocantins'
};
export const UFS = Object.keys(UF_NAMES);
export function splitCityState(cityText = '') {
const parts = String(cityText).trim().split(/\s+/);
const last = parts.at(-1);
if (last && last.length === 2 && UF_NAMES[last.toUpperCase()]) {
return { city: parts.slice(0, -1).join(' '), state: last.toUpperCase() };
}
return { city: String(cityText).trim(), state: '' };
}
// Monta o payload padrao de lead (mesmo formato consumido por processLead)
// a partir de um estabelecimento/lugar encontrado em qualquer fonte.
export function buildLeadFromPlace(place, segment, city, state, origin) {
const phone = normalizePhone(place.phone || place.formatted_phone_number || place.international_phone_number || '');
const segmentName = place.segment || segment.name;
return {
source: origin,
contact: {
name: 'Responsavel comercial',
phone
},
company: {
name: place.name,
city: place.city || city,
state: place.state || state,
segment: segmentName
},
interest: segment.interest,
pains: segment.pains,
urgency: 'media',
message: `Lead prospectado automaticamente em ${origin}. Segmento: ${segmentName}. Website: ${place.website || 'nao informado'}.`,
consent: false,
send_external_whatsapp: false,
prospecting: {
source: origin,
query: place.query,
place_id: place.place_id || '',
osm_id: place.osm_id || '',
osm_type: place.osm_type || '',
website: place.website || '',
cnpj: place.cnpj || '',
cnae: place.cnae || '',
legal_name: place.legal_name || '',
address: place.formatted_address || place.address || '',
rating: place.rating || '',
user_ratings_total: place.user_ratings_total || '',
affinity: segment.affinity || 'media',
products: segment.products || []
}
};
}
export function applyLimits(items, limit) {
return limit > 0 ? items.slice(0, limit) : items;
}
+103
View File
@@ -0,0 +1,103 @@
import { sleep } from '../util/collections.mjs';
import { buildLeadFromPlace } from './common.mjs';
async function searchPage(apiKey, params) {
const url = new URL('https://maps.googleapis.com/maps/api/place/textsearch/json');
for (const [key, value] of Object.entries(params)) {
if (value) url.searchParams.set(key, value);
}
url.searchParams.set('key', apiKey);
url.searchParams.set('language', 'pt-BR');
url.searchParams.set('region', 'br');
const response = await fetch(url);
if (!response.ok) throw new Error(`Google Places HTTP ${response.status}`);
const data = await response.json();
if (data.status && !['OK', 'ZERO_RESULTS'].includes(data.status)) {
throw new Error(`Google Places ${data.status}: ${data.error_message || 'sem detalhes'}`);
}
return data;
}
async function search(query, apiKey, { maxPerQuery, pages }) {
const places = [];
let page = 0;
let pageToken = '';
while (page < pages && places.length < maxPerQuery) {
if (pageToken) await sleep(2500);
const data = await searchPage(apiKey, pageToken ? { pagetoken: pageToken } : { query });
// Resultados por pagina sao no maximo 20 — spread aqui e seguro.
places.push(...(data.results || []));
page += 1;
pageToken = data.next_page_token || '';
if (!pageToken) break;
}
return places.slice(0, maxPerQuery);
}
async function placeDetails(placeId, apiKey) {
const url = new URL('https://maps.googleapis.com/maps/api/place/details/json');
url.searchParams.set('place_id', placeId);
url.searchParams.set('key', apiKey);
url.searchParams.set('language', 'pt-BR');
url.searchParams.set('fields', 'place_id,name,formatted_address,formatted_phone_number,international_phone_number,website,rating,user_ratings_total');
const response = await fetch(url);
if (!response.ok) throw new Error(`Google Details HTTP ${response.status}`);
const data = await response.json();
return data.result || {};
}
// Fonte google-places: exige GOOGLE_MAPS_API_KEY (paga). Traz telefone e
// website com boa cobertura.
export async function* collect(params, ctx) {
const apiKey = process.env.GOOGLE_MAPS_API_KEY;
if (!apiKey || apiKey === 'preencher_google_maps_api_key') {
throw new Error('GOOGLE_MAPS_API_KEY ausente: configure a chave para usar a fonte Google Places');
}
const { uf, cities, segments, limits } = params;
const pages = Math.min(3, Math.max(1, limits.googlePages || 3));
let executedQueries = 0;
for (const city of cities || []) {
const label = `${city} ${uf}`;
const cityLeads = [];
for (const segment of segments) {
for (const queryTerm of segment.queries || []) {
if (ctx.isCancelled()) return;
if (limits.queryLimit > 0 && executedQueries >= limits.queryLimit) {
if (cityLeads.length) yield { origin: 'google-places', label, leads: cityLeads };
return;
}
const query = `${queryTerm} em ${label}`;
ctx.emit({ phase: 'google-places', message: query });
executedQueries += 1;
let places = [];
try {
places = await search(query, apiKey, { maxPerQuery: limits.maxPerQuery || 10, pages });
} catch (error) {
ctx.log(`google-places falhou em ${query}: ${error.message}`);
continue;
}
for (const place of places) {
try {
const details = place.place_id ? await placeDetails(place.place_id, apiKey) : {};
cityLeads.push(buildLeadFromPlace({ ...place, ...details, query }, segment, city, uf, 'google-places'));
} catch (error) {
ctx.log(`google-places falhou ao detalhar "${place.name || place.place_id}": ${error.message}`);
}
}
}
}
if (cityLeads.length) yield { origin: 'google-places', label, leads: cityLeads };
}
}
+89
View File
@@ -0,0 +1,89 @@
import * as cnpjBase from './cnpj-base.mjs';
import * as overpass from './overpass.mjs';
import * as nominatim from './nominatim.mjs';
import * as wikidata from './wikidata.mjs';
import * as googlePlaces from './google-places.mjs';
import * as mock from './mock.mjs';
// Registro central de fontes de captura. `describe()` alimenta a tela de
// captura (o front nunca conhece detalhes das fontes, so este catalogo).
export const SOURCES = [
{
id: 'cnpj-base',
label: 'Base CNPJ (Receita Federal)',
descricao: 'Base oficial local: todas as empresas ativas da UF com telefone. Fonte principal.',
gratuita: true,
recomendada: true,
collect: cnpjBase.collect
},
{
id: 'brasilapi',
label: 'Base CNPJ + BrasilAPI',
descricao: 'Mesma base CNPJ, enriquecida online pela BrasilAPI (mais lenta).',
gratuita: true,
recomendada: false,
collect: (params, ctx) => cnpjBase.collect({ ...params, enrichBrasilApi: true }, ctx)
},
{
id: 'overpass',
label: 'OpenStreetMap (Overpass)',
descricao: 'Estabelecimentos mapeados no OpenStreetMap, com telefone/site quando cadastrados.',
gratuita: true,
recomendada: true,
collect: overpass.collect
},
{
id: 'nominatim',
label: 'OpenStreetMap (Nominatim)',
descricao: 'Busca textual no OpenStreetMap por termo de nicho.',
gratuita: true,
recomendada: false,
collect: nominatim.collect
},
{
id: 'wikidata',
label: 'Wikidata',
descricao: 'Empresas registradas no Wikidata (volume baixo, util para sites).',
gratuita: true,
recomendada: false,
collect: wikidata.collect
},
{
id: 'google-places',
label: 'Google Places',
descricao: 'Google Maps (paga, exige GOOGLE_MAPS_API_KEY no .env).',
gratuita: false,
recomendada: false,
needsKey: 'GOOGLE_MAPS_API_KEY',
collect: googlePlaces.collect
},
{
id: 'mock',
label: 'Teste (mock)',
descricao: '3 leads ficticios para testar o fluxo sem tocar em APIs.',
gratuita: true,
recomendada: false,
oculta: true,
collect: mock.collect
}
];
export function getSource(id) {
return SOURCES.find((source) => source.id === id) || null;
}
export function describeSources() {
return SOURCES.filter((s) => !s.oculta).map((source) => {
const keyMissing = source.needsKey
&& (!process.env[source.needsKey] || process.env[source.needsKey].startsWith('preencher'));
return {
id: source.id,
label: source.label,
descricao: source.descricao,
gratuita: source.gratuita,
recomendada: source.recomendada,
disponivel: !keyMissing,
motivo: keyMissing ? `configure ${source.needsKey} no arquivo .env` : ''
};
});
}
+49
View File
@@ -0,0 +1,49 @@
import { buildLeadFromPlace } from './common.mjs';
// Fonte mock: 3 leads ficticios para testar o fluxo completo (captura ->
// banco -> tratamento -> exportacao) sem tocar em nenhuma API externa.
const MOCK_PLACES = [
{
name: 'Mercado Central SAJ',
phone: '(75) 99999-0001',
city: 'Santo Antonio de Jesus',
state: 'BA',
segment: 'mercados_de_bairro',
website: 'https://mercadocentral.example'
},
{
name: 'Clinica Vida Plena',
phone: '(75) 99999-0002',
city: 'Santo Antonio de Jesus',
state: 'BA',
segment: 'clinicas_medicas',
website: ''
},
{
name: 'Restaurante Sabor da Terra',
phone: '(75) 99999-0003',
city: 'Santo Antonio de Jesus',
state: 'BA',
segment: 'restaurantes',
website: ''
}
];
export async function* collect(params, ctx) {
const segments = params.segments || [];
const leads = MOCK_PLACES.map((place) => {
const segment = segments.find((item) => item.name === place.segment)
|| segments[0]
|| { name: place.segment, interest: 'ERP / sistema de gestao', pains: [] };
return buildLeadFromPlace(
{ ...place, query: `mock ${place.segment}` },
segment,
place.city,
place.state,
'mock'
);
});
ctx.emit({ phase: 'mock', message: `${leads.length} leads de teste gerados` });
yield { origin: 'mock', label: 'mock', leads };
}
+103
View File
@@ -0,0 +1,103 @@
import { cleanText } from '../lead-engine.mjs';
import { sleep } from '../util/collections.mjs';
import { UA_HEADERS, UF_NAMES, buildLeadFromPlace } from './common.mjs';
async function nominatimSearch(baseUrl, query, limit) {
const url = new URL(baseUrl);
url.searchParams.set('format', 'jsonv2');
url.searchParams.set('q', query);
url.searchParams.set('countrycodes', 'br');
url.searchParams.set('addressdetails', '1');
url.searchParams.set('limit', String(Math.max(1, limit)));
const response = await fetch(url, { headers: UA_HEADERS });
if (!response.ok) {
const text = await response.text();
throw new Error(`Nominatim HTTP ${response.status}: ${text.slice(0, 300)}`);
}
return response.json();
}
function isResultInTarget(result, city, uf) {
const address = result.address || {};
const resultCity = address.city || address.town || address.village || address.municipality || '';
const resultState = cleanText(address.state || '');
const expectedState = cleanText(UF_NAMES[uf] || uf);
const currentCity = cleanText(resultCity);
const expectedCity = cleanText(city);
return resultState === expectedState
&& (currentCity === expectedCity || currentCity.includes(expectedCity) || expectedCity.includes(currentCity));
}
function resultToPlace(result, segment, city, uf, query) {
const name = result.name || result.display_name?.split(',')[0];
if (!name) return null;
const address = result.address || {};
return {
name,
phone: '',
city: address.city || address.town || address.village || city,
state: uf,
segment: segment.name,
website: '',
address: result.display_name || '',
formatted_address: result.display_name || '',
query,
osm_id: String(result.osm_id || ''),
osm_type: result.osm_type || ''
};
}
// Fonte nominatim (OpenStreetMap): busca textual termo a termo por cidade,
// validando que o resultado pertence mesmo a cidade/UF pedida.
export async function* collect(params, ctx) {
const { uf, cities, segments, limits } = params;
if (!cities || !cities.length) {
ctx.log('nominatim: nenhuma cidade selecionada — fonte pulada');
return;
}
let executedQueries = 0;
for (const city of cities) {
const label = `${city} ${uf}`;
const cityLeads = [];
for (const segment of segments) {
for (const queryTerm of segment.queries || []) {
if (ctx.isCancelled()) return;
if (limits.queryLimit > 0 && executedQueries >= limits.queryLimit) {
if (cityLeads.length) yield { origin: 'nominatim', label, leads: cityLeads };
return;
}
const query = `${queryTerm} ${city} ${uf}`;
executedQueries += 1;
ctx.emit({ phase: 'nominatim', message: query });
let results = [];
try {
results = await nominatimSearch(ctx.endpoints.nominatimUrl, query, limits.maxPerQuery || 10);
} catch (error) {
ctx.log(`nominatim falhou em ${query}: ${error.message}`);
await sleep(Math.max(ctx.delays.overpassDelayMs, 5000));
continue;
}
for (const result of results) {
if (!isResultInTarget(result, city, uf)) continue;
const place = resultToPlace(result, segment, city, uf, query);
if (!place) continue;
cityLeads.push(buildLeadFromPlace(place, segment, city, uf, 'nominatim'));
}
await sleep(Math.max(ctx.delays.overpassDelayMs, 1200));
}
}
if (cityLeads.length) yield { origin: 'nominatim', label, leads: cityLeads };
}
}
+228
View File
@@ -0,0 +1,228 @@
import { cleanText } from '../lead-engine.mjs';
import { sleep } from '../util/collections.mjs';
import { UA_HEADERS, buildLeadFromPlace } from './common.mjs';
const OVERPASS_COOLDOWN_MS = 60000;
function overpassSelector(tag, bbox) {
const [south, west, north, east] = bbox;
const key = String(tag.key).replace(/"/g, '\\"');
const value = String(tag.value).replace(/"/g, '\\"');
const selector = `["${key}"="${value}"](${south},${west},${north},${east});`;
return [`node${selector}`, `way${selector}`, `relation${selector}`].join('\n');
}
export function escapeOverpassRegex(value) {
return String(value).replace(/[\\^$.*+?()[\]{}|]/g, '\\$&').replace(/"/g, '\\"');
}
function overpassTextSelector(key, regex, bbox) {
const [south, west, north, east] = bbox;
const selector = `["${key}"~"${regex}",i](${south},${west},${north},${east});`;
return [`node${selector}`, `way${selector}`].join('\n');
}
function segmentTextRegex(segment) {
return (segment.queries || [])
.filter((term) => String(term).trim().length >= 6)
.slice(0, 8)
.map(escapeOverpassRegex)
.join('|');
}
function buildCombinedQuery(segments, bbox, includeText) {
const seen = new Set();
const selectors = [];
for (const segment of segments) {
for (const tag of segment.overpassTags || []) {
const key = `tag:${tag.key}=${tag.value}`;
if (seen.has(key)) continue;
seen.add(key);
selectors.push(overpassSelector(tag, bbox));
}
}
if (includeText) {
for (const segment of segments) {
const textRegex = segmentTextRegex(segment);
const key = `text:${segment.name}`;
if (!textRegex || seen.has(key)) continue;
seen.add(key);
selectors.push(overpassTextSelector('name', textRegex, bbox));
selectors.push(overpassTextSelector('brand', textRegex, bbox));
selectors.push(overpassTextSelector('operator', textRegex, bbox));
}
}
return ['[out:json][timeout:40];', '(', selectors.join('\n'), ');', 'out body center;'].join('\n');
}
function matchSegmentForElement(tags, segments, includeText) {
for (const segment of segments) {
for (const tag of segment.overpassTags || []) {
if (tags[tag.key] === tag.value) return segment;
}
}
if (includeText) {
const haystack = cleanText([tags.name, tags.brand, tags.operator].filter(Boolean).join(' '));
if (!haystack) return null;
for (const segment of segments) {
const terms = (segment.queries || []).filter((term) => String(term).trim().length >= 6);
if (terms.some((term) => haystack.includes(cleanText(term)))) return segment;
}
}
return null;
}
function valueFromTags(tags = {}, keys = []) {
for (const key of keys) {
if (tags[key]) return tags[key];
}
return '';
}
function elementToPlace(element, segment, city, uf, query) {
const tags = element.tags || {};
const name = tags.name || tags.brand || tags.operator;
if (!name) return null;
const street = valueFromTags(tags, ['addr:street']);
const housenumber = valueFromTags(tags, ['addr:housenumber']);
const address = [street, housenumber].filter(Boolean).join(', ');
return {
name,
phone: valueFromTags(tags, ['contact:phone', 'phone', 'mobile', 'contact:mobile']),
city: valueFromTags(tags, ['addr:city']) || city,
state: uf,
segment: segment.name,
website: valueFromTags(tags, ['contact:website', 'website', 'url']),
address,
formatted_address: address,
query,
osm_id: String(element.id || ''),
osm_type: element.type || ''
};
}
function createOverpassClient({ urls, timeoutMs }) {
const failures = new Map();
return async function request(query) {
let lastError;
for (const apiUrl of urls) {
const state = failures.get(apiUrl);
if (state?.until && Date.now() < state.until) continue;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
const recordFailure = () => {
const current = failures.get(apiUrl) || { count: 0, until: 0 };
const count = current.count + 1;
failures.set(apiUrl, { count, until: count >= 3 ? Date.now() + OVERPASS_COOLDOWN_MS : 0 });
};
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
...UA_HEADERS,
'content-type': 'application/x-www-form-urlencoded;charset=UTF-8'
},
body: new URLSearchParams({ data: query }),
signal: controller.signal
}).catch((error) => {
lastError = error.name === 'AbortError'
? new Error(`Overpass timeout apos ${timeoutMs}ms em ${apiUrl}`)
: error;
recordFailure();
return null;
}).finally(() => clearTimeout(timeout));
if (!response) continue;
if (!response.ok) {
const text = await response.text();
lastError = new Error(`Overpass HTTP ${response.status} em ${apiUrl}: ${text.slice(0, 300)}`);
recordFailure();
if (response.status === 429) await sleep(5000);
continue;
}
const data = await response.json();
failures.set(apiUrl, { count: 0, until: 0 });
return data.elements || [];
}
throw lastError || new Error('Overpass indisponivel: todos os endpoints em cooldown');
};
}
// Fonte overpass (OpenStreetMap): uma consulta combinada por cidade cobrindo
// todos os nichos selecionados, com failover entre endpoints e cooldown.
export async function* collect(params, ctx) {
const { uf, cities, segments, limits } = params;
if (!cities || !cities.length) {
ctx.log('overpass: nenhuma cidade selecionada — fonte pulada');
return;
}
const request = createOverpassClient({
urls: ctx.endpoints.overpassUrls,
timeoutMs: ctx.delays.overpassTimeoutMs
});
for (const city of cities) {
if (ctx.isCancelled()) return;
const label = `${city} ${uf}`;
let bbox = null;
try {
bbox = await ctx.geocode(uf, city);
} catch (error) {
ctx.log(`overpass: geocode falhou em ${label}: ${error.message}`);
}
if (!bbox) {
ctx.log(`overpass: sem bounding box para ${label} — cidade pulada`);
continue;
}
ctx.emit({ phase: 'overpass', message: `consultando ${label}` });
let elements = [];
try {
elements = await request(buildCombinedQuery(segments, bbox, ctx.overpassTextSearch));
} catch (error) {
ctx.log(`overpass falhou em ${label}: ${error.message}`);
await sleep(Math.max(ctx.delays.overpassDelayMs, 15000));
continue;
}
const perSegmentCount = new Map();
const cityLeads = [];
for (const element of elements) {
const segment = matchSegmentForElement(element.tags || {}, segments, ctx.overpassTextSearch);
if (!segment) continue;
if (limits.maxPerQuery > 0) {
const count = perSegmentCount.get(segment.name) || 0;
if (count >= limits.maxPerQuery) continue;
perSegmentCount.set(segment.name, count + 1);
}
const place = elementToPlace(element, segment, city, uf, `${segment.name} em ${label}`);
if (!place) continue;
cityLeads.push(buildLeadFromPlace(place, segment, city, uf, 'overpass'));
}
ctx.log(`overpass: ${elements.length} elementos brutos, ${cityLeads.length} leads em ${label}`);
if (cityLeads.length) yield { origin: 'overpass', label, leads: cityLeads };
await sleep(ctx.delays.overpassDelayMs);
}
}
+104
View File
@@ -0,0 +1,104 @@
import { cleanText } from '../lead-engine.mjs';
import { UA_HEADERS, buildLeadFromPlace } from './common.mjs';
import { escapeOverpassRegex } from './overpass.mjs';
async function findWikidataCityId(city) {
// Busca por rotulo exato em SPARQL falha para nomes sem acento (base usa
// "Santo Antonio", Wikidata registra "Santo Antônio"). wbsearchentities
// e tolerante a acentos.
try {
const url = new URL('https://www.wikidata.org/w/api.php');
url.searchParams.set('action', 'wbsearchentities');
url.searchParams.set('search', city);
url.searchParams.set('language', 'pt');
url.searchParams.set('type', 'item');
url.searchParams.set('format', 'json');
const response = await fetch(url, { headers: UA_HEADERS });
if (!response.ok) return '';
const data = await response.json();
const match = (data.search || []).find((item) => {
const description = cleanText(item.description || '');
return description.includes('municipality') || description.includes('municipio') || description.includes('munic');
});
return match?.id || '';
} catch {
return '';
}
}
// Fonte wikidata: empresas registradas no Wikidata por municipio (SPARQL).
// Volume baixo, mas gratuito e util para achar empresas com website.
export async function* collect(params, ctx) {
const { uf, cities, segments, limits } = params;
let executedQueries = 0;
for (const city of cities || []) {
if (ctx.isCancelled()) return;
const cityId = await findWikidataCityId(city);
if (!cityId) {
ctx.log(`wikidata: municipio nao encontrado para ${city} — pulando`);
continue;
}
const label = `${city} ${uf}`;
const cityLeads = [];
for (const segment of segments) {
if (ctx.isCancelled()) return;
if (limits.queryLimit > 0 && executedQueries >= limits.queryLimit) break;
const regex = (segment.queries || [])
.filter((term) => term.length >= 4)
.slice(0, 10)
.map(escapeOverpassRegex)
.join('|');
if (!regex) continue;
const sparql = `
SELECT ?item ?itemLabel ?website WHERE {
?item wdt:P31/wdt:P279* wd:Q4830453.
{ ?item wdt:P131 wd:${cityId}. } UNION { ?item wdt:P159/wdt:P131* wd:${cityId}. }
OPTIONAL { ?item wdt:P856 ?website. }
SERVICE wikibase:label { bd:serviceParam wikibase:language "pt,en". }
FILTER(REGEX(?itemLabel, "${regex}", "i"))
}
LIMIT ${limits.maxPerQuery || 10}
`;
ctx.emit({ phase: 'wikidata', message: `${segment.name} em ${label}` });
executedQueries += 1;
try {
const url = new URL(ctx.endpoints.wikidataUrl);
url.searchParams.set('query', sparql);
url.searchParams.set('format', 'json');
const response = await fetch(url, {
headers: { ...UA_HEADERS, accept: 'application/sparql-results+json' }
});
if (!response.ok) throw new Error(`Wikidata HTTP ${response.status}`);
const data = await response.json();
for (const result of data.results?.bindings || []) {
const place = {
name: result.itemLabel?.value || '',
city,
state: uf,
segment: segment.name,
website: result.website?.value || '',
query: `wikidata ${segment.name} em ${label}`,
place_id: result.item?.value || ''
};
if (place.name) cityLeads.push(buildLeadFromPlace(place, segment, city, uf, 'wikidata'));
}
} catch (error) {
ctx.log(`wikidata falhou em ${segment.name} ${label}: ${error.message}`);
}
}
if (cityLeads.length) yield { origin: 'wikidata', label, leads: cityLeads };
}
}
+111
View File
@@ -0,0 +1,111 @@
import { buildLeadFilter } from './queries.mjs';
import { getSetting } from '../db.mjs';
function pct(parte, total) {
return total > 0 ? Math.round((parte / total) * 100) : 0;
}
export function dashboardSummary(db, filtro = {}) {
const { whereSql, params } = buildLeadFilter(filtro);
const row = db.prepare(
`SELECT
COUNT(*) AS total,
SUM(CASE WHEN prioridade = 1 THEN 1 ELSE 0 END) AS p1,
SUM(CASE WHEN prioridade = 2 THEN 1 ELSE 0 END) AS p2,
SUM(CASE WHEN prioridade = 3 THEN 1 ELSE 0 END) AS p3,
SUM(CASE WHEN prioridade = 4 THEN 1 ELSE 0 END) AS p4,
SUM(telefone_valido) AS com_whatsapp,
SUM(CASE WHEN exported_at IS NOT NULL THEN 1 ELSE 0 END) AS exportados,
SUM(CASE WHEN exported_at IS NOT NULL AND date(exported_at) = date('now') THEN 1 ELSE 0 END) AS hoje_exportados
FROM leads ${whereSql}`
).get(...params);
const total = row.total || 0;
const exportados = row.exportados || 0;
const hojeExportados = row.hoje_exportados || 0;
const metaDiaria = Number(getSetting(db, 'meta_diaria', '100')) || 100;
const disponiveis = total - exportados;
return {
total,
p1: row.p1 || 0,
p2: row.p2 || 0,
p3: row.p3 || 0,
p4: row.p4 || 0,
p1_pct: pct(row.p1 || 0, total),
p2_pct: pct(row.p2 || 0, total),
p3_pct: pct(row.p3 || 0, total),
p4_pct: pct(row.p4 || 0, total),
potencial_pct: pct(row.p1 || 0, total),
com_whatsapp: row.com_whatsapp || 0,
sem_whatsapp: total - (row.com_whatsapp || 0),
exportados,
disponiveis,
cobertura_pct: pct(exportados, total),
hoje_exportados: hojeExportados,
meta_diaria: metaDiaria,
meta_pct: pct(hojeExportados, metaDiaria),
dias_estimados: metaDiaria > 0 ? Math.ceil(disponiveis / metaDiaria) : 0
};
}
const RANKING_COLUMNS = {
cidade: 'cidade',
segmento: 'segmento_label',
uf: 'uf',
interesse: 'interesse',
origem: 'origem',
produto: 'produto_principal'
};
export function dashboardRanking(db, by, filtro = {}, limit = 15) {
const column = RANKING_COLUMNS[by] || 'cidade';
const { whereSql, params } = buildLeadFilter(filtro);
const capped = Math.min(50, Math.max(1, Number(limit) || 15));
return db.prepare(
`SELECT ${column} AS label,
COUNT(*) AS total,
SUM(CASE WHEN prioridade = 1 THEN 1 ELSE 0 END) AS p1
FROM leads ${whereSql}
${whereSql ? 'AND' : 'WHERE'} ${column} != ''
GROUP BY ${column}
ORDER BY total DESC
LIMIT ?`
).all(...params, capped);
}
// Historico de exportacoes por dia — unico evento comercial com timestamp
// que o app registra (nao ha rastreio de contato/interesse/proposta).
export function dashboardDailyEvolution(db, filtro = {}, dias = 14) {
const { whereSql, params } = buildLeadFilter(filtro);
const capped = Math.min(90, Math.max(1, Number(dias) || 14));
const rows = db.prepare(
`SELECT date(exported_at) AS dia, COUNT(*) AS total
FROM leads ${whereSql}
${whereSql ? 'AND' : 'WHERE'} exported_at IS NOT NULL
GROUP BY dia
ORDER BY dia DESC
LIMIT ?`
).all(...params, capped);
return rows.reverse();
}
// Combinacao cidade+produto com mais empresas dentro do filtro atual.
export function dashboardMaiorOportunidade(db, filtro = {}) {
const { whereSql, params } = buildLeadFilter(filtro);
const row = db.prepare(
`SELECT cidade, produto_principal AS produto, COUNT(*) AS total
FROM leads ${whereSql}
${whereSql ? 'AND' : 'WHERE'} cidade != '' AND produto_principal != ''
GROUP BY cidade, produto_principal
ORDER BY total DESC
LIMIT 1`
).get(...params);
return row || null;
}
+157
View File
@@ -0,0 +1,157 @@
// Filtros de leads compartilhados entre a listagem, o dashboard e as
// exportacoes: o MESMO objeto de filtro que a tela usa para navegar e o que
// define quais leads entram num relatorio/exportacao.
const SORTS = {
'prioridade:asc': 'prioridade ASC, score DESC, cidade ASC, empresa ASC',
'score:desc': 'score DESC, empresa ASC',
'score:asc': 'score ASC, empresa ASC',
'empresa:asc': 'empresa ASC',
'empresa:desc': 'empresa DESC',
'cidade:asc': 'cidade ASC, empresa ASC',
'captured_at:desc': "COALESCE(captured_at, created_at) DESC",
'captured_at:asc': "COALESCE(captured_at, created_at) ASC"
};
export function buildLeadFilter(filtro = {}) {
const where = [];
const params = [];
const eq = (column, value) => {
if (value === undefined || value === null || value === '') return;
where.push(`${column} = ?`);
params.push(value);
};
eq('uf', filtro.uf ? String(filtro.uf).toUpperCase() : '');
eq('cidade', filtro.cidade);
eq('segmento', filtro.segmento);
eq('classificacao', filtro.classificacao);
eq('interesse', filtro.interesse);
eq('origem', filtro.origem);
if (filtro.telefone_valido === 'sim') where.push('telefone_valido = 1');
if (filtro.telefone_valido === 'nao') where.push('telefone_valido = 0');
if (filtro.exportado === 'sim') where.push('exported_at IS NOT NULL');
if (filtro.exportado === 'nao') where.push('exported_at IS NULL');
if (filtro.website === 'sim') where.push("website != ''");
if (filtro.website === 'nao') where.push("website = ''");
if (filtro.email === 'sim') where.push("email != ''");
if (filtro.email === 'nao') where.push("email = ''");
eq('produto_principal', filtro.produto);
if (filtro.prioridade !== undefined && filtro.prioridade !== '') {
where.push('prioridade = ?');
params.push(Number(filtro.prioridade) || 4);
}
if (filtro.score_min !== undefined && filtro.score_min !== '') {
where.push('score >= ?');
params.push(Number(filtro.score_min) || 0);
}
if (filtro.q) {
const like = `%${String(filtro.q).trim()}%`;
where.push('(empresa LIKE ? OR legal_name LIKE ? OR whatsapp LIKE ? OR cnpj LIKE ?)');
params.push(like, like, like, like);
}
return {
whereSql: where.length ? `WHERE ${where.join(' AND ')}` : '',
params
};
}
export function listLeads(db, filtro = {}) {
const { whereSql, params } = buildLeadFilter(filtro);
const pageSize = Math.min(200, Math.max(1, Number(filtro.pageSize) || 50));
const page = Math.max(1, Number(filtro.page) || 1);
const orderBy = SORTS[filtro.sort] || SORTS['prioridade:asc'];
const total = db.prepare(`SELECT COUNT(*) AS t FROM leads ${whereSql}`).get(...params).t;
const rows = db.prepare(
`SELECT leads.id AS id, empresa, legal_name, whatsapp, ddd, cidade, uf, segmento, segmento_label,
interesse, cnpj, cnae, score, score_qualidade, score_comercial, classificacao,
prioridade, produto_principal, telefone_valido, origem,
exported_at, captured_at, segments.affinity AS afinidade
FROM leads LEFT JOIN segments ON segments.name = leads.segmento
${whereSql}
ORDER BY ${orderBy}
LIMIT ? OFFSET ?`
).all(...params, pageSize, (page - 1) * pageSize);
return { total, page, pageSize, rows };
}
export function getLead(db, id) {
const lead = db.prepare('SELECT * FROM leads WHERE id = ?').get(id);
if (!lead) return null;
const exportHistory = db.prepare(
`SELECT e.id, e.tipo, e.created_at, e.total_leads
FROM export_leads el JOIN exports e ON e.id = el.export_id
WHERE el.lead_id = ?
ORDER BY e.created_at DESC`
).all(id);
const segmentInfo = lead.segmento
? db.prepare('SELECT affinity FROM segments WHERE name = ?').get(lead.segmento)
: null;
return {
...lead,
pains: JSON.parse(lead.pains_json || '[]'),
produtos: JSON.parse(lead.produtos_json || '[]'),
afinidade: segmentInfo?.affinity || null,
exports: exportHistory
};
}
export function countLeads(db, filtro = {}) {
const { whereSql, params } = buildLeadFilter(filtro);
return db.prepare(`SELECT COUNT(*) AS t FROM leads ${whereSql}`).get(...params).t;
}
// Percorre todos os leads do filtro em blocos paginados por id — usado pelas
// exportacoes para nunca carregar dezenas de milhares de linhas de uma vez.
export function* iterateLeads(db, filtro = {}, chunkSize = 5000) {
const { whereSql, params } = buildLeadFilter(filtro);
const sql = `SELECT * FROM leads ${whereSql ? `${whereSql} AND` : 'WHERE'} id > ?
ORDER BY id LIMIT ?`;
const stmt = db.prepare(sql);
let lastId = 0;
while (true) {
const rows = stmt.all(...params, lastId, chunkSize);
if (!rows.length) return;
for (const row of rows) yield row;
lastId = rows[rows.length - 1].id;
}
}
// Valores distintos para os seletores de filtro da tela.
export function leadFacets(db, filtro = {}) {
const base = buildLeadFilter({ uf: filtro.uf });
const facet = (column) => db.prepare(
`SELECT ${column} AS value, COUNT(*) AS total FROM leads ${base.whereSql}
${base.whereSql ? 'AND' : 'WHERE'} ${column} != ''
GROUP BY ${column} ORDER BY total DESC LIMIT 300`
).all(...base.params);
return {
ufs: db.prepare("SELECT uf AS value, COUNT(*) AS total FROM leads WHERE uf != '' GROUP BY uf ORDER BY total DESC").all(),
cidades: facet('cidade'),
segmentos: facet('segmento'),
interesses: facet('interesse'),
origens: facet('origem'),
produtos: facet('produto_principal'),
classificacoes: db.prepare(
`SELECT classificacao AS value, COUNT(*) AS total FROM leads ${base.whereSql} GROUP BY classificacao`
).all(...base.params)
};
}
+27
View File
@@ -0,0 +1,27 @@
// Evita `target.push(...source)`: com arrays de dezenas/centenas de milhares de itens
// (bases CNPJ completas, varias cidades) o spread operator em argumentos de funcao
// estoura a pilha de chamadas do V8 (RangeError: Maximum call stack size exceeded).
export function pushAll(target, source) {
for (const item of source) target.push(item);
return target;
}
export function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// Quebra o vinculo do V8 entre uma substring (fatia) e o buffer da string original.
// Sem isso, qualquer campo guardado a longo prazo mantem a linha CSV/JSON inteira
// viva na memoria (SlicedString), o que com centenas de milhares de linhas retidas
// simultaneamente estoura o heap.
export function copyStr(value) {
return Buffer.from(String(value ?? ''), 'utf8').toString('utf8');
}
export function chunk(array, size) {
const chunks = [];
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size));
}
return chunks;
}
+117
View File
@@ -0,0 +1,117 @@
import fs from 'node:fs';
import readline from 'node:readline';
export const BOM = '';
export function splitCsvLine(line, separator = ';') {
const values = [];
let current = '';
let quoted = false;
for (let index = 0; index < line.length; index += 1) {
const char = line[index];
const next = line[index + 1];
if (char === '"' && quoted && next === '"') {
current += '"';
index += 1;
} else if (char === '"') {
quoted = !quoted;
} else if (char === separator && !quoted) {
values.push(current.trim());
current = '';
} else {
current += char;
}
}
values.push(current.trim());
return values;
}
export function csvEscape(value) {
const text = String(value ?? '');
if (/[",\n;]/.test(text)) return `"${text.replace(/"/g, '""')}"`;
return text;
}
// Formula de texto do Excel: impede que numeros longos (whatsapp, cnpj) virem
// notacao cientifica ao abrir o CSV. Mesmo truque usado nos arquivos TRATADOS.
export function excelTextCell(value) {
const text = String(value ?? '').replace(/"/g, '""');
return `"=""${text}"""`;
}
export function stripBom(text) {
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
}
function detectSeparator(headerLine) {
return headerLine.includes(';') ? ';' : ',';
}
// Le um CSV linha a linha sem carregar o arquivo inteiro em memoria
// (a base CNPJ de uma UF inteira passa de 100MB). Emite um objeto por linha,
// chaveado pelos headers (opcionalmente normalizados). Campos entre aspas
// podem conter quebras de linha (ex.: descricoes de exportacao) — a paridade
// de aspas detecta o registro incompleto e acumula ate fechar.
export async function* streamCsvRows(filePath, { separator, normalizeHeader } = {}) {
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
let headers = null;
let sep = separator;
let pending = '';
let pendingQuotes = 0;
try {
for await (const rawLine of rl) {
const piece = headers === null && !pending ? stripBom(rawLine) : rawLine;
pending = pending ? `${pending}\n${piece}` : piece;
pendingQuotes += (piece.match(/"/g) || []).length;
if (pendingQuotes % 2 === 1) continue;
const line = pending;
pending = '';
pendingQuotes = 0;
if (!line.trim()) continue;
if (headers === null) {
sep = sep || detectSeparator(line);
headers = splitCsvLine(line, sep).map((header) =>
normalizeHeader ? normalizeHeader(header) : header
);
continue;
}
const values = splitCsvLine(line, sep);
const row = {};
for (let i = 0; i < headers.length; i += 1) {
row[headers[i]] = values[i] || '';
}
yield row;
}
} finally {
rl.close();
stream.destroy();
}
}
// Writer de CSV em stream: grava BOM + header e depois linha a linha,
// sem montar o arquivo inteiro em memoria.
export function createCsvWriter(filePath, headers, { separator = ';' } = {}) {
const stream = fs.createWriteStream(filePath, { encoding: 'utf8' });
stream.write(BOM + headers.join(separator) + '\n');
return {
writeRow(values) {
return stream.write(values.join(separator) + '\n');
},
async end() {
await new Promise((resolve, reject) => {
stream.end((error) => (error ? reject(error) : resolve()));
});
}
};
}
+78
View File
@@ -0,0 +1,78 @@
import fs from 'node:fs';
// Le um arquivo JSON que contem um array de objetos SEM carregar o arquivo todo
// em memoria (o export legado de leads tem 472MB — JSON.parse direto estoura o heap).
// Scanner de profundidade: acha cada objeto de nivel superior do array e faz
// JSON.parse elemento a elemento (~3KB cada), mantendo a memoria constante.
// Suporta apenas arrays cujos elementos sao objetos (caso deste projeto).
export async function* streamJsonArray(filePath, { highWaterMark = 64 * 1024 } = {}) {
const stream = fs.createReadStream(filePath, { encoding: 'utf8', highWaterMark });
let arrayStarted = false;
let depth = 0;
let inString = false;
let escapeNext = false;
let parts = [];
let first = true;
try {
for await (let chunkText of stream) {
if (first) {
first = false;
if (chunkText.charCodeAt(0) === 0xfeff) chunkText = chunkText.slice(1);
}
let elementStart = -1;
for (let i = 0; i < chunkText.length; i += 1) {
const char = chunkText[i];
if (inString) {
if (escapeNext) {
escapeNext = false;
} else if (char === '\\') {
escapeNext = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
if (depth > 0) inString = true;
continue;
}
if (!arrayStarted) {
if (char === '[') arrayStarted = true;
continue;
}
if (char === '{') {
if (depth === 0) elementStart = i;
depth += 1;
} else if (char === '}') {
depth -= 1;
if (depth === 0) {
const startIndex = elementStart >= 0 ? elementStart : 0;
parts.push(chunkText.slice(startIndex, i + 1));
const text = parts.length === 1 ? parts[0] : parts.join('');
parts = [];
elementStart = -1;
yield JSON.parse(text);
}
}
}
if (depth > 0) {
parts.push(elementStart >= 0 ? chunkText.slice(elementStart) : chunkText);
}
}
} finally {
stream.destroy();
}
if (depth !== 0) {
throw new Error(`JSON truncado em ${filePath}: elemento incompleto no fim do arquivo`);
}
}
+38
View File
@@ -0,0 +1,38 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { hashPassword, verifyPassword } from '../src/auth.mjs';
import { openDb, createUser, findUserByUsername, listUsers, ensureDefaultAdmin } from '../src/db.mjs';
test('hashPassword/verifyPassword aceita a senha certa e rejeita a errada', () => {
const stored = hashPassword('minhaSenha123');
assert.ok(verifyPassword('minhaSenha123', stored));
assert.ok(!verifyPassword('senhaErrada', stored));
});
test('hashPassword gera salt diferente a cada chamada (hashes distintos p/ mesma senha)', () => {
assert.notEqual(hashPassword('abc'), hashPassword('abc'));
});
test('ensureDefaultAdmin cria admin/admin somente no primeiro acesso', () => {
const db = openDb(':memory:');
ensureDefaultAdmin(db);
const admin = findUserByUsername(db, 'admin');
assert.ok(admin);
assert.equal(admin.role, 'admin');
assert.ok(verifyPassword('admin', admin.password_hash));
createUser(db, { username: 'vendedor1', passwordHash: hashPassword('123456'), role: 'comercial' });
ensureDefaultAdmin(db); // nao deve recriar nem duplicar
assert.equal(listUsers(db).length, 2);
db.close();
});
test('createUser rejeita username duplicado (constraint UNIQUE)', () => {
const db = openDb(':memory:');
createUser(db, { username: 'joao', passwordHash: hashPassword('123456'), role: 'comercial' });
assert.throws(() => createUser(db, { username: 'joao', passwordHash: hashPassword('outra'), role: 'admin' }));
db.close();
});
+90
View File
@@ -0,0 +1,90 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { splitCsvLine, csvEscape, excelTextCell, streamCsvRows, createCsvWriter, stripBom, BOM } from '../src/util/csv.mjs';
test('splitCsvLine separa campos com separador dentro de aspas', () => {
assert.deepEqual(splitCsvLine('a;"b;c";d'), ['a', 'b;c', 'd']);
});
test('splitCsvLine trata aspas duplas escapadas', () => {
assert.deepEqual(splitCsvLine('"x""y";z'), ['x"y', 'z']);
});
test('splitCsvLine aceita separador virgula', () => {
assert.deepEqual(splitCsvLine('a,b,"c,d"', ','), ['a', 'b', 'c,d']);
});
test('csvEscape protege valores com separador, aspas e quebra de linha', () => {
assert.equal(csvEscape('sem risco'), 'sem risco');
assert.equal(csvEscape('a;b'), '"a;b"');
assert.equal(csvEscape('diz "oi"'), '"diz ""oi"""');
assert.equal(csvEscape(null), '');
});
test('excelTextCell gera formula de texto para numeros longos', () => {
assert.equal(excelTextCell('557193335451'), '"=""557193335451"""');
});
test('stripBom remove BOM apenas quando presente', () => {
assert.equal(stripBom('abc'), 'abc');
assert.equal(stripBom('abc'), 'abc');
});
test('streamCsvRows le arquivo com BOM, auto-detecta separador e normaliza headers', async () => {
const file = path.join(os.tmpdir(), `gti-test-csv-${Date.now()}.csv`);
await fs.writeFile(file, `${BOM}Nome Empresa;Telefone 1\n"ACME; Ltda";7599999\nBeta;`, 'utf8');
try {
const rows = [];
for await (const row of streamCsvRows(file, {
normalizeHeader: (h) => h.toLowerCase().replace(/\s+/g, '_')
})) {
rows.push(row);
}
assert.equal(rows.length, 2);
assert.equal(rows[0].nome_empresa, 'ACME; Ltda');
assert.equal(rows[0].telefone_1, '7599999');
assert.equal(rows[1].telefone_1, '');
} finally {
await fs.rm(file, { force: true });
}
});
test('streamCsvRows suporta campo com quebra de linha dentro de aspas', async () => {
const file = path.join(os.tmpdir(), `gti-test-csvml-${Date.now()}.csv`);
await fs.writeFile(file, 'a;b\n1;"linha um\nlinha dois"\n2;simples', 'utf8');
try {
const rows = [];
for await (const row of streamCsvRows(file)) rows.push(row);
assert.equal(rows.length, 2);
assert.equal(rows[0].b, 'linha um\nlinha dois');
assert.equal(rows[1].b, 'simples');
} finally {
await fs.rm(file, { force: true });
}
});
test('createCsvWriter grava BOM, header e linhas', async () => {
const file = path.join(os.tmpdir(), `gti-test-csvw-${Date.now()}.csv`);
const writer = createCsvWriter(file, ['a', 'b']);
writer.writeRow(['1', csvEscape('x;y')]);
writer.writeRow(['2', excelTextCell('123')]);
await writer.end();
try {
const content = await fs.readFile(file, 'utf8');
assert.ok(content.startsWith(BOM));
const lines = stripBom(content).trim().split('\n');
assert.equal(lines[0], 'a;b');
assert.equal(lines[1], '1;"x;y"');
assert.equal(lines[2], '2;"=""123"""');
} finally {
await fs.rm(file, { force: true });
}
});
+139
View File
@@ -0,0 +1,139 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb, createLeadInserter, setSetting } from '../src/db.mjs';
import { dashboardSummary, dashboardDailyEvolution, dashboardMaiorOportunidade } from '../src/treatment/dashboard.mjs';
function seedLeads(db) {
const insert = createLeadInserter(db);
const base = {
legal_name: '', whatsapp: '', ddd: '', email: '', segmento: '', segmento_label: '',
interesse: '', cnpj: '', cnae: '', endereco: '', website: '', pains_json: '[]',
origem: 'cnpj-base', telefone_valido: 0, cnpj_valido: 0, empresa_valida: 1,
score: 50, classificacao: 'medio', motivo_classificacao: '', capture_run_id: null,
captured_at: null
};
const hoje = new Date().toISOString();
insert({ ...base, lead_uid: 'A', dedup_key: 'k1', empresa: 'A', cidade: 'Salvador', uf: 'BA', prioridade: 1, exported_at: hoje });
insert({ ...base, lead_uid: 'B', dedup_key: 'k2', empresa: 'B', cidade: 'Salvador', uf: 'BA', prioridade: 1, exported_at: null });
insert({ ...base, lead_uid: 'C', dedup_key: 'k3', empresa: 'C', cidade: 'Ilheus', uf: 'BA', prioridade: 2, exported_at: null });
insert({ ...base, lead_uid: 'D', dedup_key: 'k4', empresa: 'D', cidade: 'Ilheus', uf: 'BA', prioridade: 4, exported_at: null });
}
test('dashboardSummary calcula totais, percentuais e meta diaria sobre a base toda', () => {
const db = openDb(':memory:');
seedLeads(db);
setSetting(db, 'meta_diaria', '2');
const s = dashboardSummary(db, {});
assert.equal(s.total, 4);
assert.equal(s.p1, 2);
assert.equal(s.p2, 1);
assert.equal(s.p3, 0);
assert.equal(s.p4, 1);
assert.equal(s.potencial_pct, 50);
assert.equal(s.p1_pct, 50);
assert.equal(s.p4_pct, 25);
assert.equal(s.exportados, 1);
assert.equal(s.disponiveis, 3);
assert.equal(s.cobertura_pct, 25);
assert.equal(s.hoje_exportados, 1);
assert.equal(s.meta_diaria, 2);
assert.equal(s.meta_pct, 50);
assert.equal(s.dias_estimados, 2);
db.close();
});
test('dashboardSummary respeita o filtro (cidade) ao calcular percentuais', () => {
const db = openDb(':memory:');
seedLeads(db);
const s = dashboardSummary(db, { cidade: 'Ilheus' });
assert.equal(s.total, 2);
assert.equal(s.p1, 0);
assert.equal(s.p2, 1);
assert.equal(s.p4, 1);
assert.equal(s.p2_pct, 50);
assert.equal(s.p4_pct, 50);
assert.equal(s.exportados, 0);
assert.equal(s.cobertura_pct, 0);
db.close();
});
test('dashboardSummary com base vazia nao divide por zero', () => {
const db = openDb(':memory:');
const s = dashboardSummary(db, {});
assert.equal(s.total, 0);
assert.equal(s.potencial_pct, 0);
assert.equal(s.cobertura_pct, 0);
assert.equal(s.meta_pct, 0);
assert.equal(s.dias_estimados, 0);
db.close();
});
function seedLeadsComProdutos(db) {
const insert = createLeadInserter(db);
const base = {
legal_name: '', whatsapp: '', ddd: '', email: '', segmento: '', segmento_label: '',
interesse: '', cnpj: '', cnae: '', endereco: '', website: '', pains_json: '[]',
origem: 'cnpj-base', telefone_valido: 0, cnpj_valido: 0, empresa_valida: 1,
score: 50, classificacao: 'medio', motivo_classificacao: '', capture_run_id: null,
captured_at: null, prioridade: 1, produtos_json: '[]'
};
insert({ ...base, lead_uid: 'A', dedup_key: 'k1', empresa: 'A', cidade: 'Salvador', uf: 'BA', produto_principal: 'ERP', exported_at: '2026-07-10T10:00:00.000Z' });
insert({ ...base, lead_uid: 'B', dedup_key: 'k2', empresa: 'B', cidade: 'Salvador', uf: 'BA', produto_principal: 'ERP', exported_at: '2026-07-10T11:00:00.000Z' });
insert({ ...base, lead_uid: 'C', dedup_key: 'k3', empresa: 'C', cidade: 'Salvador', uf: 'BA', produto_principal: 'G-Chat', exported_at: '2026-07-11T09:00:00.000Z' });
insert({ ...base, lead_uid: 'D', dedup_key: 'k4', empresa: 'D', cidade: 'Ilheus', uf: 'BA', produto_principal: 'ERP', exported_at: null });
}
test('dashboardDailyEvolution agrupa exportacoes por dia em ordem cronologica', () => {
const db = openDb(':memory:');
seedLeadsComProdutos(db);
const evolution = dashboardDailyEvolution(db, {}, 14).map((r) => ({ ...r }));
assert.deepEqual(evolution, [
{ dia: '2026-07-10', total: 2 },
{ dia: '2026-07-11', total: 1 }
]);
db.close();
});
test('dashboardDailyEvolution respeita o filtro', () => {
const db = openDb(':memory:');
seedLeadsComProdutos(db);
const evolution = dashboardDailyEvolution(db, { cidade: 'Ilheus' }, 14);
assert.deepEqual(evolution, []);
db.close();
});
test('dashboardMaiorOportunidade retorna a combinacao cidade+produto com mais empresas', () => {
const db = openDb(':memory:');
seedLeadsComProdutos(db);
const oportunidade = dashboardMaiorOportunidade(db, {});
assert.deepEqual({ ...oportunidade }, { cidade: 'Salvador', produto: 'ERP', total: 2 });
db.close();
});
test('dashboardMaiorOportunidade retorna null quando nao ha dados', () => {
const db = openDb(':memory:');
assert.equal(dashboardMaiorOportunidade(db, {}), null);
db.close();
});
+112
View File
@@ -0,0 +1,112 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb, createLeadInserter, transaction, getSetting, setSetting, seedSegments, seedMunicipios, seedGeoCache, seedSettings } from '../src/db.mjs';
function sampleLead(overrides = {}) {
return {
lead_uid: 'GTI-TEST-1',
dedup_key: 'cnpj:57557263000113',
empresa: 'PREMIUM EXPRESS',
legal_name: 'PREMIUM EXPRESS LTDA',
whatsapp: '557193335451',
ddd: '71',
email: '',
cidade: 'Salvador',
uf: 'BA',
segmento: 'restaurantes',
segmento_label: 'Restaurantes',
interesse: 'Autoatendimento para WhatsApp',
cnpj: '57557263000113',
cnae: '5611203',
endereco: 'RUA X, 1',
website: '',
pains_json: '["atendimento"]',
origem: 'cnpj-base',
telefone_valido: 1,
cnpj_valido: 1,
empresa_valida: 1,
score: 100,
classificacao: 'bom',
motivo_classificacao: 'ok',
capture_run_id: null,
captured_at: '2026-07-09T14:26:18.560Z',
exported_at: null,
...overrides
};
}
test('openDb cria esquema e aplica migrations uma unica vez', () => {
const db = openDb(':memory:');
const version = db.prepare('SELECT MAX(version) AS v FROM schema_migrations').get().v;
assert.ok(version >= 1);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((r) => r.name);
for (const table of ['leads', 'capture_runs', 'extractions', 'exports', 'export_leads', 'segments', 'municipios', 'geo_cache', 'settings']) {
assert.ok(tables.includes(table), `tabela ${table} ausente`);
}
db.close();
});
test('createLeadInserter insere e ignora duplicata pela dedup_key', () => {
const db = openDb(':memory:');
const insert = createLeadInserter(db);
assert.equal(insert(sampleLead()), 'inserted');
assert.equal(insert(sampleLead({ empresa: 'OUTRO NOME' })), 'duplicate');
const total = db.prepare('SELECT COUNT(*) AS t FROM leads').get().t;
assert.equal(total, 1);
db.close();
});
test('transaction faz rollback em erro', () => {
const db = openDb(':memory:');
const insert = createLeadInserter(db);
assert.throws(() =>
transaction(db, () => {
insert(sampleLead());
throw new Error('boom');
})
);
assert.equal(db.prepare('SELECT COUNT(*) AS t FROM leads').get().t, 0);
db.close();
});
test('settings: get/set e seed com defaults Z-PRO', () => {
const db = openDb(':memory:');
seedSettings(db);
assert.equal(getSetting(db, 'zpro.pipelineId'), '1');
assert.equal(getSetting(db, 'zpro.stageId'), '1');
assert.equal(getSetting(db, 'zpro.responsibleId'), '15');
setSetting(db, 'zpro.pipelineId', '9');
assert.equal(getSetting(db, 'zpro.pipelineId'), '9');
// seed nao sobrescreve valor ja definido pelo usuario
seedSettings(db);
assert.equal(getSetting(db, 'zpro.pipelineId'), '9');
db.close();
});
test('seeds de segments, municipios e geo_cache', () => {
const db = openDb(':memory:');
seedSegments(db, [
{ name: 'clinicas_medicas', queries: ['clinica'], cnaeCodes: ['8630501'], overpassTags: [{ key: 'amenity', value: 'clinic' }], interest: 'gestao de TI', pains: ['agenda'] }
]);
const segment = db.prepare('SELECT * FROM segments WHERE name = ?').get('clinicas_medicas');
assert.equal(segment.label, 'Clinicas Medicas');
assert.deepEqual(JSON.parse(segment.cnae_codes_json), ['8630501']);
seedMunicipios(db, [{ codigo_ibge: '2928703', nome: 'Santo Antônio de Jesus', uf: 'BA' }]);
const municipio = db.prepare('SELECT * FROM municipios WHERE uf = ?').get('BA');
assert.equal(municipio.nome_clean, 'santo antonio de jesus');
seedGeoCache(db, [{ name: 'Santo Antonio de Jesus', state: 'BA', bbox: [-12.995, -39.325, -12.925, -39.225] }]);
const bbox = db.prepare('SELECT * FROM geo_cache WHERE key = ?').get('bbox:BA:santo antonio de jesus');
assert.equal(bbox.south, -12.995);
assert.equal(bbox.provider, 'config-legacy');
db.close();
});
+119
View File
@@ -0,0 +1,119 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { openDb, createLeadInserter, seedSettings } from '../src/db.mjs';
import { exportExcel } from '../src/export/excel.mjs';
import { exportOportunidades, exportBulkDataInput } from '../src/export/zpro.mjs';
import { stripBom } from '../src/util/csv.mjs';
function seedLeads(db) {
const insert = createLeadInserter(db);
insert({
lead_uid: 'GTI-1', dedup_key: 'cnpj:57557263000113', empresa: 'PREMIUM EXPRESS',
legal_name: 'PREMIUM EXPRESS LTDA', whatsapp: '557193335451', ddd: '71', email: '',
cidade: 'Salvador', uf: 'BA', segmento: 'restaurantes', segmento_label: 'Restaurantes',
interesse: 'Autoatendimento para WhatsApp', cnpj: '57557263000113', cnae: '5611203',
endereco: '', website: '', pains_json: '[]', origem: 'cnpj-base',
telefone_valido: 1, cnpj_valido: 1, empresa_valida: 1, score: 100,
classificacao: 'bom', motivo_classificacao: 'ok', capture_run_id: null,
captured_at: null, exported_at: null
});
insert({
lead_uid: 'GTI-2', dedup_key: 'name:sem telefone:salvador', empresa: 'SEM TELEFONE',
legal_name: '', whatsapp: '', ddd: '', email: '', cidade: 'Salvador', uf: 'BA',
segmento: 'restaurantes', segmento_label: 'Restaurantes', interesse: '', cnpj: '',
cnae: '', endereco: '', website: '', pains_json: '[]', origem: 'cnpj-base',
telefone_valido: 0, cnpj_valido: 0, empresa_valida: 1, score: 35,
classificacao: 'ruim', motivo_classificacao: 'score baixo', capture_run_id: null,
captured_at: null, exported_at: null
});
}
async function withTempDir(fn) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gti-export-'));
try {
return await fn(dir);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
}
test('exportExcel gera CSV com formulas de texto e xls HTML', async () => {
const db = openDb(':memory:');
seedLeads(db);
await withTempDir(async (dir) => {
const ids = [];
const result = await exportExcel({ db, filtro: { uf: 'BA' }, dir, baseName: 'teste', collectIds: ids });
assert.equal(result.total, 2);
assert.deepEqual(result.files, ['teste.csv', 'teste.xls']);
assert.equal(ids.length, 2);
const csv = stripBom(await fs.readFile(path.join(dir, 'teste.csv'), 'utf8'));
const lines = csv.trim().split('\n');
assert.ok(lines[0].startsWith('empresa;whatsapp;ddd;cidade;uf;segmento;segmento_legivel'));
assert.ok(lines[1].includes('"=""557193335451"""'), 'whatsapp deve ser formula de texto');
assert.ok(lines[1].includes('"=""57557263000113"""'), 'cnpj deve ser formula de texto');
assert.ok(lines[1].includes('cnpj:57557263000113'), 'chave_duplicidade presente');
const xls = await fs.readFile(path.join(dir, 'teste.xls'), 'utf8');
assert.ok(xls.includes('mso-number-format'));
assert.ok(xls.includes('<td class="text">557193335451</td>'));
});
db.close();
});
test('exportOportunidades gera JSONL no formato Z-PRO testado', async () => {
const db = openDb(':memory:');
seedSettings(db);
seedLeads(db);
await withTempDir(async (dir) => {
const result = await exportOportunidades({
db, filtro: { classificacao: 'bom' }, params: {}, dir, baseName: 'op'
});
assert.equal(result.total, 1);
const lines = (await fs.readFile(path.join(dir, 'op.jsonl'), 'utf8')).trim().split('\n');
const payload = JSON.parse(lines[0]);
assert.equal(payload.number, '557193335451');
assert.equal(payload.contactName, 'PREMIUM EXPRESS');
assert.equal(payload.name, 'PREMIUM EXPRESS - Autoatendimento para WhatsApp');
assert.equal(payload.pipelineId, 1);
assert.equal(payload.stageId, 1);
assert.equal(payload.responsibleId, 15);
assert.equal(payload.status, 'open');
assert.equal(payload.validateNumber, true);
assert.match(payload.closingForecast, /^\d{4}-\d{2}-\d{2}$/);
assert.ok(payload.description.includes('CNPJ: 57557263000113'));
});
db.close();
});
test('exportBulkDataInput inclui apenas leads com whatsapp', async () => {
const db = openDb(':memory:');
seedSettings(db);
seedLeads(db);
await withTempDir(async (dir) => {
const result = await exportBulkDataInput({
db, filtro: {}, params: { mensagem: 'Ola {empresa}!' }, dir, baseName: 'bulk'
});
assert.equal(result.total, 1, 'lead sem whatsapp deve ficar de fora');
const csv = (await fs.readFile(path.join(dir, 'bulk-datainput.csv'), 'utf8')).trim();
assert.equal(csv, '557193335451,PREMIUM EXPRESS,Salvador,Restaurantes');
const info = await fs.readFile(path.join(dir, 'bulk-instrucoes.txt'), 'utf8');
assert.ok(info.includes('Ola {empresa}!'));
assert.ok(info.includes('bulkSendMessageWithVariable'));
});
db.close();
});
+157
View File
@@ -0,0 +1,157 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
buildNextQuestion,
detectService,
normalizePhone,
processLead
} from '../src/lead-engine.mjs';
describe('lead engine', () => {
it('normaliza telefone brasileiro para formato internacional', () => {
assert.equal(normalizePhone('(75) 99999-1111'), '5575999991111');
assert.equal(normalizePhone('+55 75 99999-1111'), '5575999991111');
});
it('detecta servico por texto livre', () => {
assert.equal(detectService('quero trocar meu sistema de estoque'), 'erp');
assert.equal(detectService('preciso de suporte em servidor e backup'), 'ti');
assert.equal(detectService('automatizar atendimento no WhatsApp'), 'whatsapp');
});
it('prioriza lead com WhatsApp para tratamento manual', () => {
const result = processLead({
source: 'site',
contact: { name: 'Carlos Souza', phone: '(75) 99999-1111', email: 'carlos@mercado.com' },
company: {
name: 'Mercado Sao Joao',
city: 'Santo Antonio de Jesus',
state: 'BA',
segment: 'mercado',
employees: 18
},
interest: 'ERP',
current_system: 'uso outro sistema, mas quero trocar',
pains: ['estoque', 'financeiro', 'emissao fiscal'],
urgency: 'alta',
consent: true
});
assert.equal(result.lead.service, 'erp');
assert.equal(result.priority.priority, 'com_whatsapp');
assert.equal(result.priority.hasWhatsapp, true);
assert.ok(result.whatsapp.internal.message.includes('Novo lead captado - COM WHATSAPP'));
assert.equal(result.nextAction, 'tratamento_manual');
});
it('mantem lead de TI com dados parciais como captado', () => {
const result = processLead({
origem: 'indicacao',
nome: 'Ana Lima',
telefone: '75988887777',
empresa: 'Clinica Vida',
cidade: 'Santo Antonio de Jesus',
uf: 'BA',
segmento: 'clinica',
servico: 'gestao de TI',
computadores: 8,
dor: 'backup; suporte tecnico',
urgencia: 'media'
});
assert.equal(result.lead.service, 'ti');
assert.equal(result.priority.priority, 'com_whatsapp');
assert.ok(result.crmRecord.whatsapp.startsWith('55'));
});
it('mantem lead sem WhatsApp abaixo na prioridade', () => {
const result = processLead({
name: 'Lead Sem Servico',
message: 'quero mais informacoes'
});
assert.equal(result.lead.service, 'indefinido');
assert.equal(result.priority.priority, 'sem_whatsapp');
assert.equal(result.priority.hasWhatsapp, false);
assert.ok(buildNextQuestion(result.lead).includes('1 - Sistema ERP'));
});
it('reconhece segmentos compostos da prospeccao automatica como segmento-alvo', () => {
const compostos = processLead({
name: 'Lead Prospeccao',
whatsapp: '75999993333',
company_name: 'Restaurante Sabor da Terra',
city: 'Amargosa',
state: 'BA',
segment: 'restaurante_pizzaria'
});
assert.equal(compostos.lead.isTargetSegment, true);
const materialConstrucao = processLead({
name: 'Lead Prospeccao',
whatsapp: '75999994444',
company_name: 'Deposito Central',
segment: 'material_construcao_cimento_blocos'
});
assert.equal(materialConstrucao.lead.isTargetSegment, true);
const clinica = processLead({
name: 'Lead Prospeccao',
whatsapp: '75999995555',
company_name: 'Clinica Odonto Vida',
segment: 'clinica_laboratorio_odonto_otica'
});
assert.equal(clinica.lead.isTargetSegment, true);
});
it('nao marca como segmento-alvo um segmento fora da lista', () => {
const result = processLead({
name: 'Lead Prospeccao',
whatsapp: '75999996666',
company_name: 'Posto Boa Vista',
segment: 'posto_combustivel'
});
assert.equal(result.lead.isTargetSegment, false);
});
it('reconhece os novos nichos (beleza, celulares, transportadora) como segmento-alvo', () => {
const beleza = processLead({
name: 'Lead Prospeccao',
whatsapp: '75999997777',
company_name: 'Studio Beleza Bahia',
segment: 'beleza_estetica_barbearia'
});
assert.equal(beleza.lead.isTargetSegment, true);
const celulares = processLead({
name: 'Lead Prospeccao',
whatsapp: '75999998888',
company_name: 'Cell Center',
segment: 'celulares_eletronicos_assistencia'
});
assert.equal(celulares.lead.isTargetSegment, true);
const transportadora = processLead({
name: 'Lead Prospeccao',
whatsapp: '75999999999',
company_name: 'Transportadora Recôncavo',
segment: 'transportadora_logistica'
});
assert.equal(transportadora.lead.isTargetSegment, true);
});
it('direciona lead de WhatsApp para pergunta de volume quando a dor ja existe', () => {
const result = processLead({
name: 'Mariana',
whatsapp: '75999992222',
company_name: 'Restaurante Sabor',
city: 'Santo Antonio de Jesus',
state: 'BA',
interest: 'autoatendimento WhatsApp',
pains: ['pedidos', 'triagem']
});
assert.equal(result.lead.service, 'whatsapp');
assert.ok(buildNextQuestion(result.lead).includes('quantas mensagens'));
});
});
+69
View File
@@ -0,0 +1,69 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb } from '../src/db.mjs';
import { runCapture, createCaptureRun } from '../src/capture/runner.mjs';
const SEGMENTS = [
{ name: 'mercados_de_bairro', label: 'Mercados De Bairro', interest: 'ERP / sistema de gestao', queries: ['mercado'], cnaeCodes: ['4711302'], overpassTags: [], pains: ['estoque'] },
{ name: 'clinicas_medicas', label: 'Clinicas Medicas', interest: 'gestao de TI', queries: ['clinica'], cnaeCodes: ['8630501'], overpassTags: [], pains: ['agenda'] },
{ name: 'restaurantes', label: 'Restaurantes', interest: 'autoatendimento WhatsApp', queries: ['restaurante'], cnaeCodes: ['5611201'], overpassTags: [], pains: ['pedidos'] }
];
function captureParams() {
return {
sources: ['mock'],
uf: 'BA',
cities: ['Santo Antonio de Jesus'],
segments: SEGMENTS,
limits: { maxPerQuery: 10, queryLimit: 0 }
};
}
test('runCapture insere leads do mock e deduplica no rerun', async () => {
const db = openDb(':memory:');
const runId = createCaptureRun(db, {});
const first = await runCapture({ db, runId, params: captureParams() });
assert.equal(first.raw, 3);
assert.equal(first.new, 3);
assert.equal(first.dup, 0);
assert.equal(first.status, 'done');
const run = db.prepare('SELECT * FROM capture_runs WHERE id = ?').get(runId);
assert.equal(run.status, 'done');
assert.equal(run.new_count, 3);
assert.ok(run.finished_at);
const lead = db.prepare("SELECT * FROM leads WHERE segmento = 'restaurantes'").get();
assert.equal(lead.uf, 'BA');
assert.equal(lead.cidade, 'Santo Antonio de Jesus');
assert.equal(lead.origem, 'mock');
assert.equal(lead.telefone_valido, 1);
assert.ok(lead.dedup_key.startsWith('phone:'));
assert.equal(lead.capture_run_id, runId);
// rerun: tudo duplicado
const runId2 = createCaptureRun(db, {});
const second = await runCapture({ db, runId: runId2, params: captureParams() });
assert.equal(second.new, 0);
assert.equal(second.dup, 3);
assert.equal(db.prepare('SELECT COUNT(*) AS t FROM leads').get().t, 3);
db.close();
});
test('runCapture respeita cancelamento', async () => {
const db = openDb(':memory:');
const runId = createCaptureRun(db, {});
const result = await runCapture({
db,
runId,
params: captureParams(),
isCancelled: () => true
});
assert.equal(result.status, 'cancelled');
assert.equal(db.prepare('SELECT status FROM capture_runs WHERE id = ?').get(runId).status, 'cancelled');
db.close();
});
+122
View File
@@ -0,0 +1,122 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
validatePhone, validateCnpj, validateEmpresa, dedupKey, extractDdd, segmentLabel,
scoreQualidade, scoreComercial, scoreGeral, classificarScoreGeral
} from '../src/scoring.mjs';
test('validatePhone aceita celular e fixo com 55', () => {
assert.equal(validatePhone('557193335451').valid, true);
assert.equal(validatePhone('5571993335451').valid, true);
});
test('validatePhone rejeita vazio, curto e ddd invalido', () => {
assert.deepEqual(validatePhone(''), { valid: false, motivo: 'telefone vazio' });
assert.equal(validatePhone('7199999').valid, false);
assert.equal(validatePhone('550599999999').valid, false);
assert.equal(validatePhone('5571000000000').valid, false);
});
test('validateCnpj valida digitos verificadores', () => {
// CNPJ real da base legada (PREMIUM EXPRESS)
assert.equal(validateCnpj('57557263000113'), true);
assert.equal(validateCnpj('57557263000114'), false);
assert.equal(validateCnpj('11111111111111'), false);
assert.equal(validateCnpj('123'), false);
});
test('validateEmpresa aplica regra critica do relatorio legado', () => {
assert.equal(validateEmpresa('PREMIUM EXPRESS').valid, true);
assert.deepEqual(validateEmpresa('57.563.316 JHONATAS SOUZA'), { valid: false, motivo: 'empresa comeca com numero' });
assert.deepEqual(validateEmpresa('-'), { valid: false, motivo: 'empresa vazia ou com lixo' });
assert.deepEqual(validateEmpresa(''), { valid: false, motivo: 'empresa vazia ou com lixo' });
assert.equal(validateEmpresa('...***...').valid, false);
});
test('scoreQualidade: lead completo com website bate 100', () => {
const result = scoreQualidade({
whatsapp: '557193335451',
cnpj: '57557263000113',
empresa: 'PREMIUM EXPRESS',
cidade: 'Salvador',
segmento: 'restaurantes',
cnae: '5611203',
website: 'https://premiumexpress.com.br'
});
assert.equal(result.score, 100);
assert.equal(result.telefoneValido, true);
assert.equal(result.cnpjValido, true);
assert.equal(result.empresaValida, true);
});
test('scoreQualidade: sem telefone/website perde 30+5 pontos', () => {
const result = scoreQualidade({
whatsapp: '',
cnpj: '57557263000113',
empresa: 'PREMIUM EXPRESS',
cidade: 'Salvador',
segmento: 'restaurantes',
cnae: '5611203',
website: ''
});
assert.equal(result.score, 65);
assert.equal(result.telefoneValido, false);
});
test('scoreComercial: nicho alta aderencia + tudo valido bate 100', () => {
const score = scoreComercial({
whatsapp: '557193335451',
cnpj: '57557263000113',
website: 'https://premiumexpress.com.br',
email: 'contato@premiumexpress.com.br',
affinity: 'alta'
});
assert.equal(score, 100);
});
test('scoreComercial: nicho baixa aderencia sem website/email/whatsapp/cnpj fica so com os pontos do nicho', () => {
const score = scoreComercial({ whatsapp: '', cnpj: '', website: '', email: '', affinity: 'baixa' });
assert.equal(score, 20);
});
test('scoreGeral combina 60% comercial + 40% qualidade', () => {
assert.equal(scoreGeral(100, 100), 100);
assert.equal(scoreGeral(50, 50), 50);
assert.equal(scoreGeral(0, 100), 60);
assert.equal(scoreGeral(100, 0), 40);
});
test('classificarScoreGeral aplica as 5 faixas', () => {
assert.equal(classificarScoreGeral(95).id, 'excelente');
assert.equal(classificarScoreGeral(80).id, 'bom');
assert.equal(classificarScoreGeral(60).id, 'medio');
assert.equal(classificarScoreGeral(40).id, 'baixo');
assert.equal(classificarScoreGeral(10).id, 'muito_baixo');
});
test('dedupKey segue precedencia cnpj > phone > empresa+cidade', () => {
assert.equal(
dedupKey({ cnpj: '57557263000113', whatsapp: '557193335451', empresa: 'X', cidade: 'Y' }),
'cnpj:57557263000113'
);
assert.equal(
dedupKey({ cnpj: '123', whatsapp: '557193335451', empresa: 'X', cidade: 'Y' }),
'phone:557193335451'
);
assert.equal(
dedupKey({ cnpj: '', whatsapp: '', empresa: 'Premium Express', cidade: 'Salvador' }),
'name:premium express:salvador'
);
});
test('extractDdd retira o ddd do numero normalizado', () => {
assert.equal(extractDdd('557193335451'), '71');
assert.equal(extractDdd('999'), '');
});
test('segmentLabel formata igual ao segmento_legivel legado', () => {
assert.equal(segmentLabel('lojas_de_calcados'), 'Lojas De Calcados');
assert.equal(segmentLabel('saloes_de_beleza'), 'Saloes De Beleza');
});
+70
View File
@@ -0,0 +1,70 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { streamJsonArray } from '../src/util/stream-json.mjs';
async function collect(filePath, options) {
const items = [];
for await (const item of streamJsonArray(filePath, options)) {
items.push(item);
}
return items;
}
async function withTempFile(content, fn) {
const file = path.join(os.tmpdir(), `gti-test-json-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
await fs.writeFile(file, content, 'utf8');
try {
return await fn(file);
} finally {
await fs.rm(file, { force: true });
}
}
test('streamJsonArray le array pretty-printed com chaves e aspas dentro de strings', async () => {
const data = [
{ nome: 'Empresa {teste} "aspas"', valor: 10, aninhado: { lista: [1, 2, { x: '}' }] } },
{ nome: 'Segunda \\ barra', vazio: null }
];
await withTempFile(JSON.stringify(data, null, 2), async (file) => {
const items = await collect(file);
assert.deepEqual(items, data);
});
});
test('streamJsonArray funciona com JSON compacto e BOM', async () => {
const data = [{ a: 1 }, { b: '[{teste}]' }, { c: [{ d: 2 }] }];
await withTempFile('' + JSON.stringify(data), async (file) => {
const items = await collect(file);
assert.deepEqual(items, data);
});
});
test('streamJsonArray processa elementos maiores que o chunk', async () => {
const data = [
{ texto: 'x'.repeat(500), fim: 'ok' },
{ texto: 'y'.repeat(300) }
];
await withTempFile(JSON.stringify(data, null, 2), async (file) => {
const items = await collect(file, { highWaterMark: 64 });
assert.deepEqual(items, data);
});
});
test('streamJsonArray retorna vazio para array vazio', async () => {
await withTempFile('[]', async (file) => {
const items = await collect(file);
assert.deepEqual(items, []);
});
});
test('streamJsonArray falha com erro claro em arquivo truncado', async () => {
await withTempFile('[\n { "a": 1 },\n { "b": 2', async (file) => {
await assert.rejects(() => collect(file), /JSON truncado/);
});
});