Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> git-subtree-dir: maquina-comercial-pessoal git-subtree-mainline:f5af038e19git-subtree-split:a9ef4d776d
162 lines
6.6 KiB
JavaScript
162 lines
6.6 KiB
JavaScript
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})` });
|
|
});
|
|
}
|