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,100 @@
import path from 'node:path';
import { loadDotEnv, paths } from '../env.mjs';
import { openDb } from '../db.mjs';
import { extractUf } from './rfb-extractor.mjs';
import { downloadRfbDataset, validateRfbRawFiles } from './rfb-downloader.mjs';
import { createJobIO } from '../jobs/protocol.mjs';
import { randomUUID } from 'node:crypto';
import { createJobLease } from '../jobs/lease.mjs';
// Processo filho de extracao RFB: le a UF da linha em extractions e roda a
// extracao completa, reportando progresso via stdout.
await loadDotEnv();
const args = new Map(
process.argv.slice(2).map((arg) => {
const [key, value = 'true'] = arg.replace(/^--/, '').split('=');
return [key, value];
})
);
const io = createJobIO();
async function main() {
const extractionId = Number(args.get('extraction-id'));
if (!extractionId) throw new Error('extract-job: --extraction-id obrigatorio');
const db = openDb();
let lease;
try {
const extraction = db.prepare('SELECT * FROM extractions WHERE id = ?').get(extractionId);
if (!extraction) throw new Error(`extraction #${extractionId} nao encontrada`);
const uf = extraction.uf;
lease = createJobLease({ db, table: 'extractions', id: extractionId, ownerToken: args.get('owner-token') || randomUUID() });
const outCsv = path.join(paths.cnpjDir, `estabelecimentos-${uf}.csv`);
const checkpointPath = path.join(paths.cnpjDir, `checkpoint-estabelecimentos-${uf}.ndjson`);
const download = await downloadRfbDataset({
rawDir: paths.rfbRaw,
isCancelled: io.isCancelled,
onProgress: (progress) => {
const mapped = { ...progress, pct: Math.round((progress.pct || 0) * 0.45) };
io.emit(mapped);
db.prepare('UPDATE extractions SET progress_json = ? WHERE id = ?')
.run(JSON.stringify(mapped), extractionId);
}
});
if (download.cancelled) {
db.prepare("UPDATE extractions SET status = 'cancelled', finished_at = datetime('now') WHERE id = ?").run(extractionId);
io.done({ cancelled: true, snapshot: download.snapshot, downloaded: download.downloaded.length });
return;
}
await validateRfbRawFiles(paths.rfbRaw);
const result = await extractUf({
uf,
rawDir: paths.rfbRaw,
outCsv,
checkpointPath,
db,
log: (m) => io.log(m),
isCancelled: io.isCancelled,
onProgress: (p) => {
const mapped = { ...p, pct: 45 + Math.round((p.pct || 0) * 0.55) };
io.emit(mapped);
db.prepare('UPDATE extractions SET progress_json = ? WHERE id = ?')
.run(JSON.stringify(mapped), extractionId);
}
});
if (result.cancelled) {
db.prepare("UPDATE extractions SET status = 'cancelled', finished_at = datetime('now') WHERE id = ?")
.run(extractionId);
io.done({ cancelled: true });
} 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);
io.done({
snapshot: download.snapshot,
downloaded: download.downloaded.length,
skipped: download.skipped.length,
totalRows: result.totalRows,
withPhone: result.withPhone,
municipios: result.municipiosCount
});
}
} catch (error) {
db.prepare("UPDATE extractions SET status = 'error', error = ?, finished_at = datetime('now') WHERE id = ? AND status IN ('pending','running')")
.run(String(error.message || error), extractionId);
throw error;
} finally {
lease?.close();
db.close();
}
}
main().catch((error) => {
io.fail(error);
process.exit(1);
});
@@ -0,0 +1,576 @@
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import { Readable, Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { fetchTextWithTimeout } from '../util/fetch.mjs';
export const REQUIRED_RFB_FILES = [
'Municipios.zip',
...Array.from({ length: 10 }, (_, index) => `Empresas${index}.zip`),
...Array.from({ length: 10 }, (_, index) => `Estabelecimentos${index}.zip`)
];
export const DEFAULT_RFB_INDEX_URL = 'https://dados-abertos-rf-cnpj.casadosdados.com.br/arquivos/';
const DEFAULT_MIN_ZIP_BYTES = 1024;
const MANIFEST_FILE = '.rfb-snapshot.json';
const BACKUP_DIR = '.rfb-backup';
const INCOMPLETE_DIR = '.rfb-incomplete';
const EXTRACTION_MARGIN_BYTES = 2 * 1024 * 1024 * 1024;
// Covers the compressed download, extraction checkpoints and generated CSV.
export const DEFAULT_MINIMUM_FREE_BYTES = 8 * 1024 * 1024 * 1024;
class CancelledError extends Error {}
export function parseLatestSnapshot(html) {
const dates = [...String(html).matchAll(/href\s*=\s*["'](\d{4}-\d{2}-\d{2})\/["']/gi)]
.map((match) => match[1])
.filter((date) => !Number.isNaN(Date.parse(`${date}T00:00:00Z`)));
if (!dates.length) throw new Error('nenhum snapshot YYYY-MM-DD encontrado no indice da RFB');
return dates.sort().at(-1);
}
async function fetchResponse(fetchImpl, url, options = {}, timeoutMs = 25_000) {
const controller = new AbortController();
const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
const timer = setTimeout(() => controller.abort(new Error(`timeout apos ${timeoutMs}ms`)), timeoutMs);
timer.unref?.();
try {
return await fetchImpl(url, { ...options, signal });
} finally {
clearTimeout(timer);
}
}
export async function discoverLatestSnapshot(indexUrl = DEFAULT_RFB_INDEX_URL, { fetch: fetchImpl = globalThis.fetch } = {}) {
const normalizedUrl = indexUrl.endsWith('/') ? indexUrl : `${indexUrl}/`;
const { response, text } = await fetchTextWithTimeout(normalizedUrl, { fetch: fetchImpl }, 25_000, { maxBytes: 2_000_000 });
if (!response.ok) throw new Error(`indice da RFB respondeu HTTP ${response.status}`);
return parseLatestSnapshot(text);
}
async function inspectZip(file, minZipBytes, expectedSize) {
const stat = await fsp.stat(file);
if (stat.size < minZipBytes) throw new Error(`${path.basename(file)} invalido: tamanho ${stat.size}, minimo ${minZipBytes}`);
if (expectedSize != null && stat.size !== expectedSize) {
throw new Error(`${path.basename(file)} incompleto: ${stat.size} bytes, esperado ${expectedSize}`);
}
const handle = await fsp.open(file, 'r');
try {
const signature = Buffer.alloc(2);
await handle.read(signature, 0, 2, 0);
if (signature.toString('ascii') !== 'PK') throw new Error(`${path.basename(file)} invalido: assinatura ZIP PK ausente`);
const tailSize = Math.min(stat.size, 65_557);
const tail = Buffer.alloc(tailSize);
await handle.read(tail, 0, tailSize, stat.size - tailSize);
let eocdOffset = -1;
for (let offset = tail.length - 22; offset >= 0; offset -= 1) {
if (tail.readUInt32LE(offset) !== 0x06054b50) continue;
const commentLength = tail.readUInt16LE(offset + 20);
if (offset + 22 + commentLength === tail.length) {
eocdOffset = offset;
break;
}
}
if (eocdOffset < 0) throw new Error(`${path.basename(file)} invalido: registro EOCD ausente ou truncado`);
const disk = tail.readUInt16LE(eocdOffset + 4);
const centralDisk = tail.readUInt16LE(eocdOffset + 6);
const entriesOnDisk = tail.readUInt16LE(eocdOffset + 8);
const entriesTotal = tail.readUInt16LE(eocdOffset + 10);
const centralSize = tail.readUInt32LE(eocdOffset + 12);
const centralOffset = tail.readUInt32LE(eocdOffset + 16);
if (disk === 0xffff || centralDisk === 0xffff || entriesOnDisk === 0xffff || entriesTotal === 0xffff
|| centralSize === 0xffffffff || centralOffset === 0xffffffff) {
throw new Error(`${path.basename(file)} invalido: ZIP64 nao suportado`);
}
if (disk !== 0 || centralDisk !== 0) throw new Error(`${path.basename(file)} invalido: ZIP multi-disco nao suportado`);
if (entriesOnDisk !== entriesTotal) throw new Error(`${path.basename(file)} invalido: contagem do diretorio central divergente`);
const absoluteEocd = stat.size - tailSize + eocdOffset;
if (centralOffset + centralSize > absoluteEocd) {
throw new Error(`${path.basename(file)} invalido: diretorio central ultrapassa o EOCD`);
}
if (entriesTotal > 0) {
const centralSignature = Buffer.alloc(4);
const { bytesRead } = await handle.read(centralSignature, 0, 4, centralOffset);
if (bytesRead !== 4 || centralSignature.readUInt32LE(0) !== 0x02014b50) {
throw new Error(`${path.basename(file)} invalido: assinatura do diretorio central ausente`);
}
}
} finally {
await handle.close();
}
return stat.size;
}
export async function validateRfbRawFiles(rawDir, { minZipBytes = DEFAULT_MIN_ZIP_BYTES, sizes = {} } = {}) {
const problems = [];
const files = [];
for (const name of REQUIRED_RFB_FILES) {
try {
const expected = typeof sizes[name] === 'number' ? sizes[name] : sizes[name]?.size;
const size = await inspectZip(path.join(rawDir, name), minZipBytes, expected);
files.push({ name, size });
} catch (error) {
problems.push(`${name}: ${error.code === 'ENOENT' ? 'ausente' : error.message}`);
}
}
if (problems.length) {
throw new Error(`base RFB invalida; exige os 21 arquivos ZIP: ${problems.join('; ')}`);
}
return files;
}
async function readManifest(rawDir) {
try {
return JSON.parse(await fsp.readFile(path.join(rawDir, MANIFEST_FILE), 'utf8'));
} catch {
return null;
}
}
async function writeManifest(rawDir, manifest) {
const manifestPath = path.join(rawDir, MANIFEST_FILE);
const tempPath = `${manifestPath}.tmp`;
await fsp.writeFile(tempPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
await fsp.rename(tempPath, manifestPath);
}
function totalFromResponse(response, offset) {
const range = response.headers.get('content-range');
if (range) {
const match = /\/([0-9]+)$/.exec(range);
if (match) return Number(match[1]);
}
const length = Number(response.headers.get('content-length'));
return Number.isFinite(length) && length >= 0 ? offset + length : null;
}
function responseValidator(response) {
const etag = response.headers.get('etag');
const lastModified = response.headers.get('last-modified');
return { validator: etag || lastModified || null, etag, lastModified };
}
function numericContentLength(response) {
const raw = response.headers.get('content-length');
if (!/^\d+$/.test(raw || '')) return null;
const value = Number(raw);
return Number.isSafeInteger(value) && value >= 0 ? value : null;
}
async function downloadOne({ rawDir, snapshotUrl, name, fetchImpl, minZipBytes, isCancelled, onProgress, idleTimeoutMs, manifest }) {
const finalPath = path.join(rawDir, name);
const partPath = `${finalPath}.part`;
let offset = 0;
try {
offset = (await fsp.stat(partPath)).size;
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
manifest.partials ||= {};
const saved = manifest.partials[name];
const mayResume = offset > 0 && saved?.validator && Number.isSafeInteger(saved?.totalSize)
&& saved.totalSize > offset;
if (!mayResume) offset = 0;
let response;
let expectedSize;
let metadata;
for (let attempt = 0; attempt < 2; attempt += 1) {
const headers = offset ? { Range: `bytes=${offset}-`, 'If-Range': saved.validator } : {};
response = await fetchResponse(fetchImpl, new URL(name, snapshotUrl), { headers });
if (!response.ok) throw new Error(`${name}: HTTP ${response.status}`);
if (!response.body) throw new Error(`${name}: resposta sem corpo`);
const current = responseValidator(response);
let invalid = false;
let reason = '';
if (offset) {
const range = response.headers.get('content-range');
const match = /^bytes (\d+)-(\d+)\/(\d+)$/i.exec(range || '');
const length = numericContentLength(response);
expectedSize = match ? Number(match[3]) : null;
invalid = response.status !== 206
|| !match
|| Number(match[1]) !== offset
|| Number(match[2]) < Number(match[1])
|| expectedSize !== saved.totalSize
|| length !== Number(match[2]) - Number(match[1]) + 1
|| current.validator !== saved.validator;
reason = `Content-Range/validator invalido para retomada: ${range || 'ausente'}`;
} else {
const length = numericContentLength(response);
invalid = response.status !== 200 || length === null;
expectedSize = length;
reason = response.status === 206
? `${name}: Content-Range inesperado sem retomada`
: `${name}: resposta final sem Content-Length numerico confiavel`;
}
if (!invalid) {
metadata = { ...current, totalSize: expectedSize };
break;
}
await response.body.cancel?.();
if (!offset || attempt > 0) throw new Error(reason);
await fsp.rm(partPath, { force: true });
delete manifest.partials[name];
await writeManifest(rawDir, manifest);
offset = 0;
}
manifest.partials[name] = metadata;
await writeManifest(rawDir, manifest);
let received = offset;
let idleTimer;
let lastProgressAt = 0;
const emitProgress = (force = false) => {
const now = Date.now();
if (!force && now - lastProgressAt < 500) return;
lastProgressAt = now;
onProgress({ phase: 'download', file: name, bytes: received, totalBytes: expectedSize, message: `baixando ${name}` });
};
emitProgress(true);
const guard = new Transform({
transform(chunk, encoding, callback) {
if (isCancelled()) return callback(new CancelledError('download cancelado'));
clearTimeout(idleTimer);
idleTimer = setTimeout(
() => guard.destroy(new Error(`${name}: download sem dados por ${idleTimeoutMs}ms`)),
idleTimeoutMs
);
idleTimer.unref?.();
received += chunk.length;
emitProgress();
callback(null, chunk);
}
});
try {
idleTimer = setTimeout(
() => guard.destroy(new Error(`${name}: download sem dados por ${idleTimeoutMs}ms`)),
idleTimeoutMs
);
idleTimer.unref?.();
await pipeline(Readable.fromWeb(response.body), guard, fs.createWriteStream(partPath, { flags: offset ? 'a' : 'w' }));
} catch (error) {
if (error instanceof CancelledError || isCancelled()) throw new CancelledError('download cancelado');
throw error;
} finally {
clearTimeout(idleTimer);
}
await inspectZip(partPath, minZipBytes, expectedSize);
emitProgress(true);
await fsp.rename(partPath, finalPath);
delete manifest.partials[name];
return { name, size: received, etag: metadata.etag, lastModified: metadata.lastModified };
}
async function ensureFreeSpace(rawDir, statfs, minimumFreeBytes) {
const stats = await statfs(rawDir);
const free = BigInt(stats.bavail) * BigInt(stats.bsize);
if (free >= BigInt(minimumFreeBytes)) return;
const error = new Error(`espaco livre insuficiente: exige ${minimumFreeBytes} bytes`);
error.code = 'ENOSPC';
throw error;
}
async function readValidDataset(dir, minZipBytes) {
const manifest = await readManifest(dir);
if (!manifest?.complete) return null;
const hasSizes = REQUIRED_RFB_FILES.every((name) => {
const metadata = manifest.files?.[name];
const size = typeof metadata === 'number' ? metadata : metadata?.size;
return Number.isSafeInteger(size) && size >= minZipBytes;
});
if (!hasSizes) return null;
try {
await validateRfbRawFiles(dir, { minZipBytes, sizes: manifest.files });
return manifest;
} catch {
return null;
}
}
async function archiveIncompleteDataset(rawDir, { includeParts = false } = {}) {
let archivePath = path.join(rawDir, INCOMPLETE_DIR);
try {
await fsp.access(archivePath);
archivePath = path.join(rawDir, `${INCOMPLETE_DIR}-${Date.now()}`);
} catch {}
await fsp.mkdir(archivePath);
let moved = 0;
for (const name of [...REQUIRED_RFB_FILES, MANIFEST_FILE]) {
for (const suffix of ['', ...(includeParts && name !== MANIFEST_FILE ? ['.part'] : [])]) {
try {
await fsp.rename(path.join(rawDir, `${name}${suffix}`), path.join(archivePath, `${name}${suffix}`));
moved += 1;
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
}
}
if (!moved) await fsp.rm(archivePath, { recursive: true, force: true });
return moved ? archivePath : null;
}
async function backupPreviousDataset(rawDir, minZipBytes) {
const backupPath = path.join(rawDir, BACKUP_DIR);
if (await readValidDataset(backupPath, minZipBytes)) return backupPath;
await fsp.rm(backupPath, { recursive: true, force: true });
if (!await readValidDataset(rawDir, minZipBytes)) return null;
const staging = `${backupPath}.tmp`;
await fsp.rm(staging, { recursive: true, force: true });
await fsp.mkdir(staging);
const moved = [];
try {
for (const name of [...REQUIRED_RFB_FILES, MANIFEST_FILE]) {
const source = path.join(rawDir, name);
try {
await fsp.rename(source, path.join(staging, name));
moved.push(name);
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
}
await fsp.rename(staging, backupPath);
return backupPath;
} catch (error) {
for (const name of moved.reverse()) {
await fsp.rename(path.join(staging, name), path.join(rawDir, name)).catch(() => {});
}
await fsp.rm(staging, { recursive: true, force: true });
throw error;
}
}
async function restoreBackup(rawDir, minZipBytes) {
const backupPath = path.join(rawDir, BACKUP_DIR);
const manifest = await readValidDataset(backupPath, minZipBytes);
if (!manifest) return null;
await archiveIncompleteDataset(rawDir);
const staging = path.join(rawDir, `${BACKUP_DIR}-restore.tmp`);
await fsp.rm(staging, { recursive: true, force: true });
await fsp.mkdir(staging);
try {
for (const name of [...REQUIRED_RFB_FILES, MANIFEST_FILE]) {
await fsp.copyFile(path.join(backupPath, name), path.join(staging, name));
}
for (const name of REQUIRED_RFB_FILES) {
await fsp.rename(path.join(staging, name), path.join(rawDir, name));
}
await fsp.rename(path.join(staging, MANIFEST_FILE), path.join(rawDir, MANIFEST_FILE));
} finally {
await fsp.rm(staging, { recursive: true, force: true });
}
return manifest;
}
async function revalidateCachedFiles({ previous, snapshotUrl, fetchImpl }) {
for (const name of REQUIRED_RFB_FILES) {
const metadata = previous.files[name];
if (!metadata || typeof metadata !== 'object') continue;
const validator = metadata.etag || metadata.lastModified;
if (!validator) continue;
const headers = metadata.etag
? { 'If-None-Match': metadata.etag }
: { 'If-Modified-Since': metadata.lastModified };
const response = await fetchResponse(fetchImpl, new URL(name, snapshotUrl), { method: 'HEAD', headers });
if (response.status === 304) continue;
if (!response.ok) throw new Error(`${name}: HEAD respondeu HTTP ${response.status}`);
const length = numericContentLength(response);
const current = responseValidator(response);
const changed = length !== metadata.size
|| (metadata.etag && current.etag !== metadata.etag)
|| (metadata.lastModified && current.lastModified !== metadata.lastModified);
if (changed) return name;
}
return null;
}
async function usefulPresentBytes(rawDir, previous, sameSnapshot, minZipBytes) {
if (!sameSnapshot || !previous || previous.complete) return 0;
let useful = 0;
for (const name of REQUIRED_RFB_FILES) {
const metadata = previous.files?.[name];
const size = typeof metadata === 'number' ? metadata : metadata?.size;
if (Number.isSafeInteger(size) && size >= minZipBytes) {
try {
await inspectZip(path.join(rawDir, name), minZipBytes, size);
useful += size;
} catch {}
}
const partial = previous.partials?.[name];
if (!partial?.validator || !Number.isSafeInteger(partial.totalSize)) continue;
try {
const partSize = (await fsp.stat(path.join(rawDir, `${name}.part`))).size;
if (partSize > 0 && partSize < partial.totalSize) useful += partSize;
} catch {}
}
return useful;
}
export async function downloadRfbDataset({
rawDir,
indexUrl = DEFAULT_RFB_INDEX_URL,
fetch: fetchImpl = globalThis.fetch,
onProgress = () => {},
isCancelled = () => false,
concurrency = 1,
minZipBytes = DEFAULT_MIN_ZIP_BYTES,
idleTimeoutMs = 60_000,
statfs = fsp.statfs,
minimumFreeBytes = DEFAULT_MINIMUM_FREE_BYTES
}) {
if (!rawDir) throw new Error('rawDir obrigatorio');
if (concurrency < 1 || concurrency > 2) throw new Error('concurrency deve ser 1 ou 2');
await fsp.mkdir(rawDir, { recursive: true });
const previous = await readManifest(rawDir);
let cachedFiles = null;
const hasCompleteSizes = previous?.complete && REQUIRED_RFB_FILES.every((name) => {
const metadata = previous.files?.[name];
const size = typeof metadata === 'number' ? metadata : metadata?.size;
return Number.isSafeInteger(size) && size >= minZipBytes;
});
if (hasCompleteSizes) {
try {
cachedFiles = await validateRfbRawFiles(rawDir, { minZipBytes, sizes: previous.files });
} catch {
cachedFiles = null;
}
}
onProgress({ phase: 'download', pct: 0, message: 'verificando snapshot da RFB' });
let snapshot;
try {
snapshot = await discoverLatestSnapshot(indexUrl, { fetch: fetchImpl });
} catch (error) {
if (!cachedFiles) {
const restored = await restoreBackup(rawDir, minZipBytes);
if (!restored) throw error;
onProgress({ phase: 'download', pct: 100, message: `sem acesso ao indice; backup ${restored.snapshot} restaurado e validado` });
return {
cancelled: false,
offline: true,
snapshot: restored.snapshot,
downloaded: [],
skipped: REQUIRED_RFB_FILES.slice(),
files: restored.files
};
}
onProgress({ phase: 'download', pct: 100, message: `sem acesso ao indice; usando snapshot local validado ${previous.snapshot}` });
return {
cancelled: false,
offline: true,
snapshot: previous.snapshot,
downloaded: [],
skipped: REQUIRED_RFB_FILES.slice(),
files: previous.files
};
}
const normalizedIndexUrl = indexUrl.endsWith('/') ? indexUrl : `${indexUrl}/`;
const snapshotUrl = new URL(`${snapshot}/`, normalizedIndexUrl);
const sameSnapshot = previous?.snapshot === snapshot;
let forceRefresh = false;
if (sameSnapshot && cachedFiles) {
try {
forceRefresh = Boolean(await revalidateCachedFiles({ previous, snapshotUrl, fetchImpl }));
} catch {
onProgress({ phase: 'download', pct: 100, message: `revalidacao remota indisponivel; usando snapshot local validado ${snapshot}` });
return {
cancelled: false,
offline: true,
snapshot,
downloaded: [],
skipped: REQUIRED_RFB_FILES.slice(),
files: previous.files
};
}
}
if (sameSnapshot && cachedFiles && !forceRefresh) {
onProgress({ phase: 'download', pct: 100, message: `snapshot ${snapshot} ja esta completo e validado` });
return {
cancelled: false,
offline: false,
snapshot,
downloaded: [],
skipped: REQUIRED_RFB_FILES.slice(),
files: previous.files
};
}
const usefulBytes = await usefulPresentBytes(rawDir, previous, sameSnapshot && !forceRefresh, minZipBytes);
const requiredFree = sameSnapshot && !forceRefresh
? Math.max(EXTRACTION_MARGIN_BYTES, minimumFreeBytes - usefulBytes)
: minimumFreeBytes;
await ensureFreeSpace(rawDir, statfs, requiredFree);
onProgress({ phase: 'download', pct: 0, message: `verificando espaco necessario (${requiredFree} bytes)` });
const changed = !sameSnapshot || forceRefresh;
if (changed) {
await backupPreviousDataset(rawDir, minZipBytes);
if (previous && !previous.complete) await archiveIncompleteDataset(rawDir, { includeParts: true });
for (const name of REQUIRED_RFB_FILES) {
await fsp.rm(path.join(rawDir, name), { force: true });
await fsp.rm(path.join(rawDir, `${name}.part`), { force: true });
}
}
const manifest = {
snapshot,
complete: false,
files: sameSnapshot && !forceRefresh && previous?.files && typeof previous.files === 'object' ? { ...previous.files } : {},
partials: sameSnapshot && !forceRefresh && previous?.partials && typeof previous.partials === 'object' ? { ...previous.partials } : {}
};
await writeManifest(rawDir, manifest);
const downloaded = [];
const skipped = [];
try {
for (let index = 0; index < REQUIRED_RFB_FILES.length; index += 1) {
if (isCancelled()) throw new CancelledError('download cancelado');
const name = REQUIRED_RFB_FILES[index];
try {
if (!sameSnapshot || forceRefresh || !manifest.files[name]) throw new Error('arquivo sem origem de snapshot confirmada');
const expected = typeof manifest.files[name] === 'number' ? manifest.files[name] : manifest.files[name]?.size;
if (!Number.isSafeInteger(expected) || expected < 0) throw new Error('arquivo sem tamanho esperado no manifesto');
await inspectZip(path.join(rawDir, name), minZipBytes, expected);
skipped.push(name);
} catch {
const file = await downloadOne({
rawDir,
snapshotUrl,
name,
fetchImpl,
minZipBytes,
isCancelled,
onProgress,
idleTimeoutMs,
manifest
});
downloaded.push(file);
manifest.files[name] = { size: file.size, etag: file.etag, lastModified: file.lastModified };
await writeManifest(rawDir, manifest);
}
onProgress({
phase: 'download',
pct: Math.round(((index + 1) / REQUIRED_RFB_FILES.length) * 100),
message: `${snapshot}: ${index + 1}/${REQUIRED_RFB_FILES.length} arquivos prontos`
});
}
} catch (error) {
if (error instanceof CancelledError || isCancelled()) return { cancelled: true, snapshot, downloaded, skipped };
throw error;
}
const files = await validateRfbRawFiles(rawDir, { minZipBytes });
const finalFiles = Object.fromEntries(files.map((file) => {
const metadata = manifest.files[file.name];
return [file.name, typeof metadata === 'object' ? { ...metadata, size: file.size } : { size: file.size, etag: null, lastModified: null }];
}));
await writeManifest(rawDir, { snapshot, complete: true, files: finalFiles, partials: {} });
await fsp.rm(path.join(rawDir, BACKUP_DIR), { recursive: true, force: true });
const checkpointDir = path.dirname(rawDir);
for (const name of await fsp.readdir(checkpointDir)) {
if (/^checkpoint-estabelecimentos-.*\.ndjson(?:\.tmp)?$/i.test(name)) {
await fsp.rm(path.join(checkpointDir, name), { force: true });
}
}
return { cancelled: false, snapshot, downloaded: downloaded.map((file) => file.name), skipped, files: finalFiles };
}
@@ -0,0 +1,423 @@
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';
import { validateRfbRawFiles } from './rfb-downloader.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.
export function readZipLines(reader, zipPath, { spawn: spawnProcess = spawn } = {}) {
const child = spawnProcess(reader.name, reader.argsFor(zipPath), { stdio: ['ignore', 'pipe', 'pipe'] });
let stderr = '';
let intentionalClose = false;
let settled = false;
let rl;
let resolveDone;
let rejectDone;
const done = new Promise((resolve, reject) => {
resolveDone = resolve;
rejectDone = reject;
});
const settle = (error) => {
if (settled) return;
settled = true;
if (error) rejectDone(error);
else resolveDone();
};
child.stderr.setEncoding('utf8');
child.stderr.on('data', (chunk) => {
stderr = (stderr + chunk).slice(-4096);
});
child.on('error', (error) => {
const wrapped = new Error(`falha ao iniciar leitor ZIP para ${path.basename(zipPath)}: ${error.message}`);
settle(wrapped);
rl?.close();
});
child.on('exit', (code, signal) => {
if (intentionalClose) return settle();
if (code === 0) return settle();
const error = new Error(
`leitor ZIP falhou em ${path.basename(zipPath)} (codigo ${code ?? 'n/a'}${signal ? `, sinal ${signal}` : ''}): ${stderr.slice(-1000)}`
);
settle(error);
rl?.close();
});
child.stdout.setEncoding('latin1');
rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
const streamError = (error) => settle(new Error(`erro ao ler ${path.basename(zipPath)}: ${error.message}`));
rl.on('error', streamError);
child.stdout.on('error', streamError);
return {
rl,
done,
close() {
intentionalClose = true;
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));
}
await zip.done;
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])
};
}
await zip.done;
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;
}
await zip.done;
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();
await validateRfbRawFiles(rawDir);
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) && fs.statSync(checkpointPath).size > 0;
if (checkpointDone) {
log('checkpoint encontrado: pulando varredura de Estabelecimentos');
const input = fs.createReadStream(checkpointPath, { encoding: 'utf8' });
const checkpointLines = readline.createInterface({ input, crlfDelay: Infinity });
try {
for await (const line of checkpointLines) {
if (!line.trim()) continue;
const item = JSON.parse(line);
if (!item.b || !item.m) throw new Error('checkpoint sem campos obrigatorios');
cnpjBasicoSet.add(item.b);
tomSeen.add(item.m);
totalRows += 1;
if (item.t1 || item.t2) withPhone += 1;
}
} catch (error) {
await fsp.rm(checkpointPath, { force: true });
throw new Error(`checkpoint invalido: ${error.message}`);
}
} else {
const tmpPath = `${checkpointPath}.tmp`;
const out = fs.createWriteStream(tmpPath, { encoding: 'utf8' });
let outError = null;
out.on('error', (error) => { outError = error; });
onProgress({ phase: 'estabelecimentos', pct: 0, message: 'varrendo Estabelecimentos0-9.zip' });
try {
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')) {
if (outError) throw outError;
await new Promise((resolve, reject) => {
out.once('drain', resolve);
out.once('error', reject);
});
}
}
if (outError) throw outError;
await new Promise((resolve, reject) => {
out.once('error', reject);
out.end(resolve);
});
} catch (error) {
out.destroy();
await fsp.rm(tmpPath, { force: true });
throw error;
}
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 (!totalRows && !isCancelled()) {
await fsp.rm(checkpointPath, { force: true });
throw new Error(`nenhum estabelecimento ativo encontrado para ${ufUpper}`);
}
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 csvTmp = `${outCsv}.tmp`;
const csvOut = fs.createWriteStream(csvTmp, { encoding: 'utf8' });
let csvError = null;
csvOut.on('error', (error) => { csvError = error; });
try {
csvOut.write(BOM + headers.join(';') + '\n');
const input = fs.createReadStream(checkpointPath, { encoding: 'utf8' });
const rl = readline.createInterface({ input, crlfDelay: Infinity });
for await (const line of rl) {
if (!line.trim()) continue;
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')) {
if (csvError) throw csvError;
await new Promise((resolve, reject) => {
csvOut.once('drain', resolve);
csvOut.once('error', reject);
});
}
}
if (csvError) throw csvError;
await new Promise((resolve, reject) => {
csvOut.once('error', reject);
csvOut.end(resolve);
});
await fsp.rename(csvTmp, outCsv);
} catch (error) {
csvOut.destroy();
await fsp.rm(csvTmp, { force: true });
throw error;
}
// ---- 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 };
}