Files
COMERCIAL-GTI/src/extract/rfb-extractor.mjs
T
DESKTOP-F2O4A80\UserandClaude b8d10b210c 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>
2026-07-12 14:22:21 -03:00

362 lines
12 KiB
JavaScript

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 };
}