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:f5af038e19git-subtree-split:a9ef4d776d
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user