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:
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user