chore: import maquina-comercial-pessoal history via subtree

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

git-subtree-dir: maquina-comercial-pessoal
git-subtree-mainline: f5af038e19
git-subtree-split: a9ef4d776d
This commit is contained in:
Gustavo
2026-09-28 15:50:17 -03:00
co-authored by Claude Sonnet 5
150 changed files with 33449 additions and 0 deletions
@@ -0,0 +1,76 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { DatabaseSync } from 'node:sqlite';
import { paths } from '../src/env.mjs';
export async function createBackup({ dataDir = paths.data, now = () => new Date(), retention = 14 } = {}) {
const stamp = now().toISOString().slice(0, 10);
const backupsDir = path.join(dataDir, 'backups');
const target = path.join(backupsDir, `maquina-pessoal-${stamp}`);
const dbPath = dataDir === paths.data ? paths.db : path.join(dataDir, 'maquina-pessoal.db');
const authPath = dataDir === paths.data ? paths.whatsappAuth : path.join(dataDir, 'runtime', 'whatsapp-auth');
await fs.mkdir(target, { recursive: true });
await copyDatabaseBackup(dbPath, path.join(target, 'maquina-pessoal.db'));
await fs.cp(authPath, path.join(target, 'whatsapp-auth'), { recursive: true });
await pruneBackups(backupsDir, retention);
return { file: target };
}
async function copyDatabaseBackup(source, destination) {
try {
const db = new DatabaseSync(source, { readOnly: true });
try {
db.exec(`VACUUM INTO ${sqlString(destination)};`);
return;
} finally {
db.close();
}
} catch {
await copyDatabaseFiles(source, destination);
}
}
async function copyDatabaseFiles(source, destination) {
await fs.copyFile(source, destination);
for (const suffix of ['-wal', '-shm']) {
try {
await fs.copyFile(`${source}${suffix}`, `${destination}${suffix}`);
} catch (error) {
if (error?.code !== 'ENOENT') {
throw error;
}
}
}
}
function sqlString(value) {
return `'${String(value).replaceAll("'", "''")}'`;
}
async function pruneBackups(backupsDir, retention) {
const keep = Math.max(0, retention);
const entries = await fs.readdir(backupsDir, { withFileTypes: true });
const backupDirs = entries
.filter((entry) => entry.isDirectory() && entry.name.startsWith('maquina-pessoal-'))
.map((entry) => entry.name)
.sort();
for (const name of backupDirs.slice(0, Math.max(0, backupDirs.length - keep))) {
await fs.rm(path.join(backupsDir, name), { recursive: true, force: true });
}
}
export function isCliEntryPoint(moduleUrl = import.meta.url, argvPath = process.argv[1]) {
return Boolean(argvPath) && moduleUrl === pathToFileURL(argvPath).href;
}
if (isCliEntryPoint()) {
createBackup().then((result) => console.log(`backup criado: ${result.file}`)).catch((error) => {
console.error(`backup falhou: ${error.message}`);
process.exit(1);
});
}
@@ -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;
});
@@ -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"
@@ -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);
});
@@ -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();
@@ -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);
});
@@ -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,97 @@
import { loadDotEnv, APP_HOST, APP_PORT, paths, telegramConfig } from '../src/env.mjs';
import { createTelegramAlerter } from '../src/alerts/telegram.mjs';
import {
hasCampaignsPausedByRestart,
notifyStartupState,
startInitialWhatsAppReconnect
} from '../src/alerts/runtime-alerts.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);
const campaignsPausedByRestart = hasCampaignsPausedByRestart(db);
recoverReservedCampaignQueue(db);
const tg = telegramConfig();
const alerter = createTelegramAlerter({ enabled: tg.enabled, token: tg.botToken, chatId: tg.chatId, log });
const jobs = createJobManager({ db });
const whatsapp = createWhatsAppManager({
authDir: paths.whatsappAuth,
log,
onMessageStatus: (event) => updateOutreachMessageStatus(db, event),
onInboundMessage: (event) => recordInboundReply(db, event)
});
void notifyStartupState({ alerter, whatsappStatus: whatsapp.getStatus().state, campaignsPausedByRestart, log });
await startInitialWhatsAppReconnect({
hasSession: () => hasWhatsAppSession(paths.whatsappAuth),
whatsapp,
alerter,
log
});
const server = createAppServer({ db, jobs, whatsapp, log });
const stopScheduler = startOutreachScheduler({ db, whatsapp, log });
const port = APP_PORT;
server.listen(port, APP_HOST, () => {
log(`Maquina Comercial Pessoal rodando em http://${APP_HOST}:${port}`);
if (APP_HOST === '127.0.0.1') log('somente este computador tem acesso (127.0.0.1). Ctrl+C para encerrar.');
else log('modo container/rede: proteja o acesso com proxy HTTPS e senha forte.');
});
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);