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
+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);