Baseline commit of the existing Maquina Comercial Pessoal project

Tracks the app that was already in place (capture, treatment, outreach,
WhatsApp integration, server, frontend, tests, docs) so future feature
work can branch and use isolated worktrees.
This commit is contained in:
Gustavo
2026-07-31 10:54:07 -03:00
parent 6cd336a41e
commit d330bb5ff0
132 changed files with 29969 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
import { loadDotEnv, paths } from '../src/env.mjs';
import { downloadRfbDataset } from '../src/extract/rfb-downloader.mjs';
await loadDotEnv();
downloadRfbDataset({
rawDir: paths.rfbRaw,
onProgress: (progress) => {
const pct = progress.pct == null ? '' : `${progress.pct}% `;
console.log(`[RFB] ${pct}${progress.message}`);
}
}).then((result) => {
if (result.cancelled) process.exitCode = 2;
else console.log(`[RFB] snapshot ${result.snapshot} pronto: ${result.downloaded.length} baixados, ${result.skipped.length} reutilizados.`);
}).catch((error) => {
console.error(`[RFB] erro: ${error.stack || error.message}`);
process.exitCode = 1;
});
+23
View File
@@ -0,0 +1,23 @@
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
$target = Join-Path $root "Maquina-Comercial-Pessoal.exe"
$source = Join-Path $root "launcher\Program.cs"
$dotnet = Get-Command dotnet -ErrorAction SilentlyContinue
$sdk = if ($dotnet) { & dotnet --list-sdks 2>$null } else { $null }
if ($sdk) {
$project = Join-Path $root "launcher\Maquina-Comercial-Pessoal.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 "Maquina-Comercial-Pessoal.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"
+123
View File
@@ -0,0 +1,123 @@
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';
import { randomUUID } from 'node:crypto';
import { createJobLease } from '../src/jobs/lease.mjs';
// CLI de captura (uso avancado / testes). A interface principal e o app:
// node scripts/servidor.mjs (ou o Maquina-Comercial-Pessoal.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 active = db.prepare("SELECT id FROM capture_runs WHERE status IN ('pending','running')").get();
if (active) throw new Error(`ja existe uma captura em andamento (#${active.id})`);
const runId = createCaptureRun(db, { ...params, segments: segments.map((s) => s.name) });
const lease = createJobLease({ db, table: 'capture_runs', id: runId, ownerToken: randomUUID() });
let summary;
try {
summary = await runCapture({
db,
runId,
params,
emit: (p) => console.log(`[captura] ${p.phase}: ${p.message || ''}`),
log: (m) => console.log(`[captura] ${m}`)
});
} finally {
lease.close();
}
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);
});
+23
View File
@@ -0,0 +1,23 @@
import { loadDotEnv, paths } from '../src/env.mjs';
import { openDb, ensureSeeds } from '../src/db.mjs';
import { runOutreachBatch } from '../src/outreach/run.mjs';
import { createWhatsAppManager, hasWhatsAppSession } from '../src/whatsapp/manager.mjs';
// Dispara um lote pela sessao local existente, respeitando as mesmas regras do servidor.
await loadDotEnv();
const log = (message) => console.log(`[outreach] ${message}`);
const db = openDb();
await ensureSeeds(db, { log });
const whatsapp = createWhatsAppManager({ authDir: paths.whatsappAuth, log });
if (await hasWhatsAppSession(paths.whatsappAuth)) {
await whatsapp.connect().catch((error) => log(`conexao local falhou: ${error.message}`));
}
const resumo = await runOutreachBatch({ db, whatsapp, log });
console.log(JSON.stringify(resumo, null, 2));
await whatsapp.close();
db.close();
+90
View File
@@ -0,0 +1,90 @@
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';
import { downloadRfbDataset, validateRfbRawFiles } from '../src/extract/rfb-downloader.mjs';
import { randomUUID } from 'node:crypto';
import { createJobLease } from '../src/jobs/lease.mjs';
// Extrai a base CNPJ de uma UF inteira a partir dos zips da Receita Federal:
// node scripts/extrair-base-cnpj.mjs --uf=BA
// Por padrao baixa/retoma os arquivos necessarios antes de extrair. Use
// --no-download somente quando a base local completa ja estiver disponivel.
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 active = db.prepare("SELECT id, uf FROM extractions WHERE status IN ('pending','running')").get();
if (active) throw new Error(`ja existe uma extracao em andamento (#${active.id}, ${active.uf})`);
const inserted = db.prepare(
"INSERT INTO extractions (uf, status, started_at) VALUES (?, 'running', datetime('now'))"
).run(uf);
const extractionId = Number(inserted.lastInsertRowid);
const lease = createJobLease({ db, table: 'extractions', id: extractionId, ownerToken: randomUUID() });
try {
if (!args.has('no-download')) {
await downloadRfbDataset({
rawDir,
onProgress: (p) => console.log(`[download] ${p.pct ?? 0}% ${p.message}`)
});
}
await validateRfbRawFiles(rawDir);
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 {
lease.close();
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);
});
+82
View File
@@ -0,0 +1,82 @@
import { loadDotEnv, APP_PORT, paths } from '../src/env.mjs';
import { openDb, ensureSeeds, recoverPendingOutreach, updateOutreachMessageStatus } from '../src/db.mjs';
import { createJobManager, recoverOrphans } from '../src/jobs/manager.mjs';
import { createAppServer } from '../src/server/index.mjs';
import { startOutreachScheduler } from '../src/outreach/scheduler.mjs';
import { pauseCampaignsAfterRestart, recoverReservedCampaignQueue } from '../src/outreach/campaign-worker.mjs';
import { recordInboundReply } from '../src/outreach/inbound.mjs';
import { createWhatsAppManager, hasWhatsAppSession } from '../src/whatsapp/manager.mjs';
// Servidor local da Maquina Comercial Pessoal.
await loadDotEnv();
const log = (message) => console.log(`[servidor] ${message}`);
const db = openDb();
await ensureSeeds(db, { log });
recoverOrphans(db, log);
recoverPendingOutreach(db);
pauseCampaignsAfterRestart(db);
recoverReservedCampaignQueue(db);
const jobs = createJobManager({ db });
const whatsapp = createWhatsAppManager({
authDir: paths.whatsappAuth,
log,
onMessageStatus: (event) => updateOutreachMessageStatus(db, event),
onInboundMessage: (event) => recordInboundReply(db, event)
});
if (await hasWhatsAppSession(paths.whatsappAuth)) {
whatsapp.connect().catch((error) => log(`whatsapp: reconexao inicial falhou: ${error.message}`));
}
const server = createAppServer({ db, jobs, whatsapp, log });
const stopScheduler = startOutreachScheduler({ db, whatsapp, log });
const port = APP_PORT;
server.listen(port, '127.0.0.1', () => {
log(`Maquina Comercial Pessoal rodando em http://127.0.0.1:${port}`);
log('somente este computador tem acesso (127.0.0.1). Ctrl+C para encerrar.');
});
let shuttingDown = false;
async function shutdown() {
if (shuttingDown) return;
shuttingDown = true;
log('encerrando...');
const watchdog = setTimeout(() => process.exit(1), 30_000);
watchdog.unref();
const shutdownTasks = [
stopScheduler().catch((error) => log(`outreach: falha ao encerrar scheduler: ${error.message}`)),
jobs.close().catch((error) => log(`jobs: falha ao encerrar: ${error.message}`)),
whatsapp.close().catch((error) => log(`whatsapp: falha ao encerrar: ${error.message}`))
];
let resourceTimer;
const resourcesTimedOut = await Promise.race([
Promise.allSettled(shutdownTasks).then(() => false),
new Promise((resolve) => {
resourceTimer = setTimeout(() => resolve(true), 20_000);
resourceTimer.unref();
})
]);
clearTimeout(resourceTimer);
if (resourcesTimedOut) log('encerramento dos recursos excedeu o prazo; prosseguindo');
recoverOrphans(db, log);
let httpTimer;
const httpTimedOut = await Promise.race([
new Promise((resolve) => server.close(resolve)).then(() => false),
new Promise((resolve) => {
httpTimer = setTimeout(() => resolve(true), 5_000);
httpTimer.unref();
})
]);
clearTimeout(httpTimer);
if (httpTimedOut) log('encerramento HTTP excedeu o prazo; prosseguindo');
db.close();
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);