import fs from 'node:fs'; import path from 'node:path'; import { randomUUID } from 'node:crypto'; import { paths } from '../../env.mjs'; import { UF_NAMES } from '../../sources/common.mjs'; import { getSource } from '../../sources/index.mjs'; import { isSqliteConstraintError } from '../../db.mjs'; function hasCnpjBaseForUf(uf) { return fs.existsSync(path.join(paths.cnpjDir, `estabelecimentos-${uf}.csv`)) || (uf === 'BA' && fs.existsSync(path.join(paths.cnpjDir, 'estabelecimentos-bahia.csv'))); } export function registerCaptureRoutes(router, { db, jobs }) { router.add('POST', '/api/capture/runs', async (req, res, { json, body }) => { const sources = Array.isArray(body?.sources) ? body.sources.map((value) => typeof value === 'string' ? value.trim() : value) : []; const uf = String(body?.uf || '').toUpperCase(); const cities = Array.isArray(body?.cities) ? body.cities.map((value) => typeof value === 'string' ? value.trim() : value) : []; const segments = Array.isArray(body?.segments) ? body.segments.map((value) => typeof value === 'string' ? value.trim() : value) : []; const limits = { maxPerQuery: Number(body?.limits?.maxPerQuery), queryLimit: Number(body?.limits?.queryLimit), googlePages: body?.limits?.googlePages === undefined ? 3 : Number(body.limits.googlePages) }; const invalidArray = (values, max) => !values.length || values.length > max || values.some((value) => typeof value !== 'string' || !value || value.length > 100); if (invalidArray(sources, 10) || new Set(sources).size !== sources.length) { json(400, { erro: 'fontes invalidas: selecione de 1 a 10 fontes unicas' }); return; } for (const id of sources) { if (!getSource(id)) { json(400, { erro: `fonte desconhecida: ${id}` }); return; } } if (!UF_NAMES[uf]) { json(400, { erro: 'informe uma UF valida' }); return; } if (invalidArray(cities, 1000) || (cities.includes('all') && cities.length !== 1)) { json(400, { erro: 'cidades invalidas: selecione de 1 a 1000 municipios ou somente "all"' }); return; } if (!cities.includes('all')) { const findCity = db.prepare('SELECT 1 FROM municipios WHERE uf = ? AND nome = ?'); const unknown = cities.find((city) => !findCity.get(uf, city)); if (unknown) { json(400, { erro: `municipio invalido para ${uf}: ${unknown}` }); return; } } if (invalidArray(segments, 250) || (segments.includes('all') && segments.some((segment) => segment !== 'all' && !segment.startsWith('cnae:')))) { json(400, { erro: 'segmentos invalidos: selecione de 1 a 250 nichos ou somente "all"' }); return; } const findSegment = db.prepare('SELECT 1 FROM segments WHERE name = ? AND active = 1'); const findCnae = db.prepare('SELECT 1 FROM cnaes WHERE codigo = ?'); for (const segment of segments) { if (segment === 'all') continue; if (/^cnae:\d{7}$/.test(segment)) { if (findCnae.get(segment.slice(5))) continue; } else if (findSegment.get(segment)) { continue; } json(400, { erro: `segmento ou CNAE invalido: ${segment}` }); return; } if (!Number.isInteger(limits.maxPerQuery) || limits.maxPerQuery < 1 || limits.maxPerQuery > 100 || !Number.isInteger(limits.queryLimit) || limits.queryLimit < 0 || limits.queryLimit > 10000 || !Number.isInteger(limits.googlePages) || limits.googlePages < 1 || limits.googlePages > 3) { json(400, { erro: 'limites invalidos: maxPerQuery 1..100, queryLimit 0..10000, googlePages 1..3' }); return; } if ((sources.includes('cnpj-base') || sources.includes('brasilapi')) && !hasCnpjBaseForUf(uf)) { json(409, { erro: `a base CNPJ de ${uf} ainda nao foi extraida`, precisaExtracao: true, uf }); return; } const running = db.prepare("SELECT id FROM capture_runs WHERE status IN ('pending','running')").get(); if (running) { json(409, { erro: `ja existe uma captura em andamento (#${running.id}). Aguarde ou cancele.` }); return; } const params = { sources, uf, cities, segments, limits }; let inserted; try { inserted = db.prepare("INSERT INTO capture_runs (params_json, status) VALUES (?, 'pending')") .run(JSON.stringify(params)); } catch (error) { if (!isSqliteConstraintError(error)) throw error; json(409, { erro: 'outra captura foi iniciada simultaneamente. Aguarde ou cancele.' }); return; } const runId = Number(inserted.lastInsertRowid); const ownerToken = randomUUID(); const jobId = jobs.startJob({ type: 'captura', script: 'src/capture/capture-job.mjs', args: [`--run-id=${runId}`], meta: { runId, uf, sources }, ownerToken }); db.prepare("UPDATE capture_runs SET job_id = ?, owner_token = ?, heartbeat_at = datetime('now') WHERE id = ?") .run(jobId, ownerToken, runId); json(202, { runId, jobId }); }); router.add('GET', '/api/capture/runs', async (req, res, { json, query }) => { const limit = Math.min(100, Math.max(1, Number(query.get('limit')) || 20)); const rows = db.prepare( `SELECT id, job_id, params_json, status, progress_json, raw_count, new_count, dup_count, error, started_at, finished_at, created_at FROM capture_runs ORDER BY id DESC LIMIT ?` ).all(limit); json(200, { runs: rows.map((row) => ({ ...row, params: JSON.parse(row.params_json || '{}'), progress: JSON.parse(row.progress_json || 'null'), params_json: undefined, progress_json: undefined })) }); }); router.add('GET', '/api/capture/runs/:id', async (req, res, { json, params }) => { const row = db.prepare('SELECT * FROM capture_runs WHERE id = ?').get(Number(params.id)); if (!row) { json(404, { erro: 'execucao nao encontrada' }); return; } json(200, { ...row, params: JSON.parse(row.params_json || '{}'), progress: JSON.parse(row.progress_json || 'null'), params_json: undefined, progress_json: undefined }); }); router.add('POST', '/api/capture/runs/:id/cancel', async (req, res, { json, params }) => { const row = db.prepare('SELECT job_id, status FROM capture_runs WHERE id = ?').get(Number(params.id)); if (!row) { json(404, { erro: 'execucao nao encontrada' }); return; } const ok = row.job_id ? jobs.cancelJob(row.job_id) : false; json(ok ? 202 : 409, ok ? { cancelando: true } : { erro: `nao ha job ativo para cancelar (status: ${row.status})` }); }); }