Compare commits

...

15 commits

7 changed files with 177 additions and 32 deletions

1
.gitignore vendored
View file

@ -4,3 +4,4 @@ log
prueba prueba
datos.gob.ar* datos.gob.ar*
data/ data/
*.zip

View file

@ -1,6 +1,6 @@
FROM docker.io/alpine:3.18 as build FROM docker.io/alpine:3.18 as build
RUN apk add --no-cache npm esbuild RUN apk add --no-cache npm
RUN npm install -g esbuild RUN npm install -g esbuild
WORKDIR /tmp/build WORKDIR /tmp/build
@ -8,7 +8,7 @@ COPY package.json .
RUN npm install RUN npm install
COPY download_json.js . COPY download_json.js .
RUN esbuild --bundle --format=cjs --platform=node --outfile=build.js download_json.js RUN esbuild --bundle --format=cjs --platform=node --outfile=build.js --sourcemap=inline download_json.js
FROM docker.io/alpine:3.18 FROM docker.io/alpine:3.18
RUN apk add --no-cache nodejs-current tini RUN apk add --no-cache nodejs-current tini
@ -16,4 +16,4 @@ COPY pki/ca_intermediate_root_bundle.pem /usr/lib/ca_intermediate_root_bundle.pe
COPY --from=build /tmp/build/build.js /usr/local/bin/download_json.js COPY --from=build /tmp/build/build.js /usr/local/bin/download_json.js
ENV NODE_EXTRA_CA_CERTS=/usr/lib/ca_intermediate_root_bundle.pem ENV NODE_EXTRA_CA_CERTS=/usr/lib/ca_intermediate_root_bundle.pem
WORKDIR /data WORKDIR /data
CMD ["/sbin/tini", "node", "/usr/local/bin/download_json.js", "https://datos.gob.ar/data.json", "http://datos.energia.gob.ar/data.json", "https://datos.magyp.gob.ar/data.json", "https://datos.acumar.gov.ar/data.json", "https://datasets.datos.mincyt.gob.ar/data.json", "https://datos.arsat.com.ar/data.json", "https://datos.cultura.gob.ar/data.json", "https://datos.mininterior.gob.ar/data.json", "https://datos.produccion.gob.ar/data.json", "https://datos.salud.gob.ar/data.json", "https://datos.transporte.gob.ar/data.json", "https://ckan.ciudaddemendoza.gov.ar/data.json", "https://datos.santafe.gob.ar/data.json", "https://datosabiertos.chaco.gob.ar/data.json", "https://datosabiertos.gualeguaychu.gov.ar/data.json", "https://datosabiertos.mercedes.gob.ar/data.json", "http://luj-bue-datos.paisdigital.innovacion.gob.ar/data.json", "https://datosabiertos.desarrollosocial.gob.ar", "http://datos.mindef.gov.ar/data.json"] CMD ["/sbin/tini", "node", "--enable-source-maps", "/usr/local/bin/download_json.js"]

View file

@ -4,13 +4,66 @@ import { Agent, fetch, request, setGlobalDispatcher } from "undici";
import { join, normalize } from "node:path"; import { join, normalize } from "node:path";
import pLimit from "p-limit"; import pLimit from "p-limit";
const sitiosPorDefecto = [
"https://datos.gob.ar/data.json",
"http://datos.energia.gob.ar/data.json",
"https://datos.magyp.gob.ar/data.json",
"https://datos.acumar.gov.ar/data.json",
"https://datasets.datos.mincyt.gob.ar/data.json",
"https://datos.arsat.com.ar/data.json",
"https://datos.cultura.gob.ar/data.json",
"https://datos.mininterior.gob.ar/data.json",
"https://datos.produccion.gob.ar/data.json",
"https://datos.salud.gob.ar/data.json",
"https://datos.transporte.gob.ar/data.json",
"https://ckan.ciudaddemendoza.gov.ar/data.json",
"https://datos.santafe.gob.ar/data.json",
"https://datosabiertos.chaco.gob.ar/data.json",
"https://datosabiertos.mercedes.gob.ar/data.json",
"http://luj-bue-datos.paisdigital.innovacion.gob.ar/data.json",
"https://datosabiertos.desarrollosocial.gob.ar/data.json",
"http://datos.mindef.gov.ar/data.json",
"https://monitoreo.datos.gob.ar/catalog/jgm/data.json",
// 'https://datosabiertos.enacom.gob.ar/data.json',
"https://monitoreo.datos.gob.ar/catalog/otros/data.json",
"https://monitoreo.datos.gob.ar/catalog/aaip/data.json",
"https://monitoreo.datos.gob.ar/media/catalog/sedronar/data.json",
"https://monitoreo.datos.gob.ar/catalog/modernizacion/data.json",
"https://monitoreo.datos.gob.ar/media/catalog/shn/data.json",
"https://monitoreo.datos.gob.ar/catalog/smn/data.json",
"https://monitoreo.datos.gob.ar/catalog/ign/data.json",
"https://monitoreo.datos.gob.ar/catalog/justicia/data.json",
"https://monitoreo.datos.gob.ar/catalog/seguridad/data.json",
"https://monitoreo.datos.gob.ar/media/catalog/ambiente/data.json",
// "http://andino.siu.edu.ar/data.json",
"https://monitoreo.datos.gob.ar/catalog/educacion/data.json",
"https://monitoreo.datos.gob.ar/media/catalog/inti/data.json",
"https://monitoreo.datos.gob.ar/catalog/ssprys/data.json",
"https://www.presupuestoabierto.gob.ar/sici/rest-api/catalog/public",
"https://transparencia.enargas.gob.ar/data.json",
"https://infra.datos.gob.ar/catalog/sspm/data.json",
"https://monitoreo.datos.gob.ar/catalog/ssprys/data.json",
"https://monitoreo.datos.gob.ar/catalog/siep/data.json",
"https://monitoreo.datos.gob.ar/catalog/exterior/data.json",
"http://datos.pami.org.ar/data.json",
"https://monitoreo.datos.gob.ar/media/catalog/trabajo/data.json",
"https://datos.yvera.gob.ar/data.json",
"https://monitoreo.datos.gob.ar/media/catalog/renaper/data.json",
"https://monitoreo.datos.gob.ar/media/catalog/dine/data.json",
"https://monitoreo.datos.gob.ar/media/catalog/obras/data.json",
"https://monitoreo.datos.gob.ar/media/catalog/generos/data.json",
];
// desactivado porque va MUY lento: datosabiertos.gualeguaychu.gov.ar
// FYI: al menos los siguientes dominios no tienen la cadena completa de certificados en HTTPS. tenemos que usar un hack (node_extra_ca_certs_mozilla_bundle) para conectarnos a estos sitios. (se puede ver con ssllabs.com) ojalá lxs administradorxs de estos servidores lo arreglen. // FYI: al menos los siguientes dominios no tienen la cadena completa de certificados en HTTPS. tenemos que usar un hack (node_extra_ca_certs_mozilla_bundle) para conectarnos a estos sitios. (se puede ver con ssllabs.com) ojalá lxs administradorxs de estos servidores lo arreglen.
// www.enargas.gov.ar, transparencia.enargas.gov.ar, www.energia.gob.ar, www.economia.gob.ar, datos.yvera.gob.ar // www.enargas.gov.ar, transparencia.enargas.gov.ar, www.energia.gob.ar, www.economia.gob.ar, datos.yvera.gob.ar
setGlobalDispatcher( setGlobalDispatcher(
new Agent({ new Agent({
pipelining: 0, pipelining: 0,
}) }),
); );
/** key es host /** key es host
@ -28,14 +81,14 @@ class StatusCodeError extends Error {
} }
} }
class TooManyRedirectsError extends Error {} class TooManyRedirectsError extends Error {}
const jsonUrls = process.argv.slice(2); let jsonUrls = process.argv.slice(2);
if (jsonUrls.length < 1) { if (jsonUrls.length < 1) {
console.error("Especificamente el url al json porfa"); jsonUrls = sitiosPorDefecto;
process.exit(1);
} }
await writeFile("readme.txt", generateReadme(jsonUrls));
for (const url of jsonUrls) for (const url of jsonUrls)
downloadFromData(url).catch((error) => downloadFromData(url).catch((error) =>
console.error(`${url} FALLÓ CON`, error) console.error(`${url} FALLÓ CON`, error),
); );
/** /**
@ -43,7 +96,7 @@ for (const url of jsonUrls)
*/ */
async function downloadFromData(jsonUrlString) { async function downloadFromData(jsonUrlString) {
const jsonUrl = new URL(jsonUrlString); const jsonUrl = new URL(jsonUrlString);
const outputPath = jsonUrl.host; const outputPath = `${jsonUrl.host}${jsonUrl.pathname}`.replaceAll("/", "_");
await mkdir(outputPath, { recursive: true }); await mkdir(outputPath, { recursive: true });
const errorFile = ( const errorFile = (
await open(join(outputPath, "errors.jsonl"), "w") await open(join(outputPath, "errors.jsonl"), "w")
@ -64,7 +117,7 @@ async function downloadFromData(jsonUrlString) {
return true; return true;
} catch (error) { } catch (error) {
errorFile.write( errorFile.write(
JSON.stringify(encodeError({ dataset, dist }, error)) + "\n" JSON.stringify(encodeError({ dataset, dist }, error)) + "\n",
); );
return false; return false;
} }
@ -75,14 +128,14 @@ async function downloadFromData(jsonUrlString) {
url: patchUrl(new URL(dist.downloadURL)), url: patchUrl(new URL(dist.downloadURL)),
outputPath, outputPath,
attempts: 0, attempts: 0,
})) })),
); );
const totalJobs = jobs.length; const totalJobs = jobs.length;
let nFinished = 0; let nFinished = 0;
let nErrors = 0; let nErrors = 0;
// por las dudas verificar que no hayan archivos duplicados // por las dudas verificar que no hayan archivos duplicados
chequearIdsDuplicados(jobs); chequearIdsDuplicados(jobs, outputPath);
shuffleArray(jobs); shuffleArray(jobs);
@ -96,7 +149,7 @@ async function downloadFromData(jsonUrlString) {
try { try {
await downloadDistWithRetries(job); await downloadDistWithRetries(job);
} catch (error) { } catch (error) {
await errorFile.write(JSON.stringify(job, encodeError(error)) + "\n"); errorFile.write(JSON.stringify(encodeError(job, error)) + "\n");
nErrors++; nErrors++;
} finally { } finally {
nFinished++; nFinished++;
@ -104,16 +157,16 @@ async function downloadFromData(jsonUrlString) {
}); });
}); });
process.stderr.write(`info[${jsonUrl.host}]: 0/${totalJobs} done\n`); process.stderr.write(`info[${outputPath}]: 0/${totalJobs} done\n`);
const interval = setInterval(() => { const interval = setInterval(() => {
process.stderr.write( process.stderr.write(
`info[${jsonUrl.host}]: ${nFinished}/${totalJobs} done\n` `info[${outputPath}]: ${nFinished}/${totalJobs} done\n`,
); );
}, 30000); }, 30000);
await Promise.all(promises); await Promise.all(promises);
clearInterval(interval); clearInterval(interval);
if (nErrors > 0) if (nErrors > 0)
console.error(`${jsonUrl.host}: Finished with ${nErrors} errors`); console.error(`${outputPath}: Finished with ${nErrors} errors`);
} finally { } finally {
errorFile.close(); errorFile.close();
} }
@ -139,13 +192,13 @@ async function downloadDistWithRetries(job, attempts = 0) {
await wait(15000); await wait(15000);
return await downloadDistWithRetries(job, attempts + 1); return await downloadDistWithRetries(job, attempts + 1);
} }
// si no fue un error de http, reintentar hasta 5 veces con 5 segundos de por medio // si no fue un error de http, reintentar hasta 3 veces con 5 segundos de por medio
else if ( else if (
!(error instanceof StatusCodeError) && !(error instanceof StatusCodeError) &&
!(error instanceof TooManyRedirectsError) && !(error instanceof TooManyRedirectsError) &&
attempts < 10 attempts < 3
) { ) {
await wait(5000); await wait(5000 + Math.random() * 10000);
return await downloadDistWithRetries(job, attempts + 1); return await downloadDistWithRetries(job, attempts + 1);
} else throw error; } else throw error;
} }
@ -175,12 +228,12 @@ async function downloadDist({ dist, dataset, url, outputPath }) {
const fileDirPath = join( const fileDirPath = join(
outputPath, outputPath,
sanitizeSuffix(dataset.identifier), sanitizeSuffix(dataset.identifier),
sanitizeSuffix(dist.identifier) sanitizeSuffix(dist.identifier),
); );
await mkdir(fileDirPath, { recursive: true }); await mkdir(fileDirPath, { recursive: true });
const filePath = join( const filePath = join(
fileDirPath, fileDirPath,
sanitizeSuffix(dist.fileName || dist.identifier) sanitizeSuffix(dist.fileName || dist.identifier),
); );
if (!res.body) throw new Error("no body"); if (!res.body) throw new Error("no body");
@ -215,14 +268,15 @@ function sanitizeSuffix(path) {
/** /**
* @param {DownloadJob[]} jobs * @param {DownloadJob[]} jobs
* @param {string} id
*/ */
function chequearIdsDuplicados(jobs) { function chequearIdsDuplicados(jobs, id) {
const duplicated = hasDuplicates( const duplicated = hasDuplicates(
jobs.map((j) => `${j.dataset.identifier}/${j.dist.identifier}`) jobs.map((j) => `${j.dataset.identifier}/${j.dist.identifier}`),
); );
if (duplicated) { if (duplicated) {
console.error( console.error(
"ADVERTENCIA: ¡encontré duplicados! es posible que se pisen archivos entre si" `ADVERTENCIA[${id}]: ¡encontré duplicados! es posible que se pisen archivos entre si`,
); );
} }
} }
@ -243,7 +297,7 @@ function wait(ms) {
*/ */
function encodeError(job, error) { function encodeError(job, error) {
const always = { const always = {
url: job.url?.toString || job.dist.downloadURL, url: job.url?.toString() || job.dist.downloadURL,
datasetIdentifier: job.dataset.identifier, datasetIdentifier: job.dataset.identifier,
distributionIdentifier: job.dist.identifier, distributionIdentifier: job.dist.identifier,
}; };
@ -280,3 +334,45 @@ function shuffleArray(array) {
[array[i], array[j]] = [array[j], array[i]]; [array[i], array[j]] = [array[j], array[i]];
} }
} }
/**
* @param {string[]} portales
*/
function generateReadme(portales) {
// basado en el readme de Patricio
return `Dumps de Portales de Datos Abiertos de la República Argentina
=============================================================
El zip contiene todo lo que se pudo descargar de los portales seleccionados, que fueron:
${portales.map((p) => `- ${p}`).join("\n")}
La carpeta está ordenada en subcarpetas cuyo nombre corresponde al ID del dataset/distribución del portal. De esta forma,
leyendo el data.json se puede programaticamente y de manera simple volver a mapear qué archivo le corresponde a cada
distribución.
Formato:
- {url de data.json sin protocolo y con / reemplazado por _}/
- data.json
- errors.jsonl: archivo con todos los errores que se obtuvieron al intentar descargar todo.
- {identifier de dataset}/
- {identifier de distribution}/
- {fileName (o, si no existe, identifier de distribution)}
Ejemplo:
- datos.gob.ar_data.json/
- data.json
- errors.jsonl
- turismo_fbc269ea-5f71-45b6-b70c-8eb38a03b8db/
  - turismo_0774a0bb-71c2-44d7-9ea6-780e6bd06d50/
  - cruceristas-por-puerto-residencia-desagregado-por-pais-mes.csv
- ...
- energia_0d4a18ee-9371-439a-8a94-4f53a9822664/
   - energia_9f602b6e-2bef-4ac4-895d-f6ecd6bb1866/
   - energia_9f602b6e-2bef-4ac4-895d-f6ecd6bb1866 (este archivo no tiene fileName en el data.json, entonces se reutiliza el identifier)
- ...
Este dump fue generado con transicion-desordenada-diablo: https://gitea.nulo.in/Nulo/transicion-desordenada-diablo
`;
}

View file

@ -5,7 +5,7 @@
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"run": "env NODE_EXTRA_CA_CERTS=pki/ca_intermediate_root_bundle.pem node download_json.js" "run": "mkdir -p data && cd data && env NODE_EXTRA_CA_CERTS=../pki/ca_intermediate_root_bundle.pem node ../download_json.js"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
@ -15,6 +15,7 @@
"undici": "^5.28.0" "undici": "^5.28.0"
}, },
"devDependencies": { "devDependencies": {
"@tsconfig/node20": "^20.1.2",
"@types/node": "^20.10.0" "@types/node": "^20.10.0"
} }
} }

View file

@ -13,6 +13,9 @@ dependencies:
version: 5.28.0 version: 5.28.0
devDependencies: devDependencies:
'@tsconfig/node20':
specifier: ^20.1.2
version: 20.1.2
'@types/node': '@types/node':
specifier: ^20.10.0 specifier: ^20.10.0
version: 20.10.0 version: 20.10.0
@ -24,6 +27,10 @@ packages:
engines: {node: '>=14'} engines: {node: '>=14'}
dev: false dev: false
/@tsconfig/node20@20.1.2:
resolution: {integrity: sha512-madaWq2k+LYMEhmcp0fs+OGaLFk0OenpHa4gmI4VEmCKX4PJntQ6fnnGADVFrVkBj0wIdAlQnK/MrlYTHsa1gQ==}
dev: true
/@types/node@20.10.0: /@types/node@20.10.0:
resolution: {integrity: sha512-D0WfRmU9TQ8I9PFx9Yc+EBHw+vSpIub4IDvQivcp26PtPrdMGAq5SDcpXEo/epqa/DXotVpekHiLNTg3iaKXBQ==} resolution: {integrity: sha512-D0WfRmU9TQ8I9PFx9Yc+EBHw+vSpIub4IDvQivcp26PtPrdMGAq5SDcpXEo/epqa/DXotVpekHiLNTg3iaKXBQ==}
dependencies: dependencies:

View file

@ -1,5 +1,9 @@
# WIP: descargador masivo de datos públicos # WIP: descargador masivo de datos públicos
descarga masivamente archivos de la mayoría de los portales de datos argentinos que tengan un archivo "data.json" ([DCAT](https://www.w3.org/TR/vocab-dcat-2/)). la idea es tener un espejo (mirror) lo más perfecto posible en el caso de que cualquiera de las fuentes se caiga.
## setup
require [Node.js](https://nodejs.org) y [pnpm](https://pnpm.io/) require [Node.js](https://nodejs.org) y [pnpm](https://pnpm.io/)
``` ```
@ -9,8 +13,13 @@ pnpm install
## correr ## correr
``` ```
pnpm run run download_json.js https://datos.gob.ar/data.json # descargar portal datos.gob.ar
# guarda en ./datos.gob.ar pnpm run run https://datos.gob.ar/data.json
# guarda en data/datos.gob.ar_data.json
# descargar todos los portales conocidos
pnpm run run
# guarda en data/*
``` ```
## contenedor ## contenedor
@ -22,9 +31,23 @@ docker run --rm -it -v ./data:/data gitea.nulo.in/nulo/transicion-desordenada-di
## formato de repo guardado ## formato de repo guardado
- `{dominio de repo}` - `{url de data.json sin protocolo y con / reemplazado por _}/`
- `data.json` - `data.json`
- `errors.jsonl`: archivo con todos los errores que se obtuvieron al intentar descargar todo. - `errors.jsonl`: archivo con todos los errores que se obtuvieron al intentar descargar todo.
- `{identifier de dataset}` - `{identifier de dataset}/`
- `{identifier de distribution}` - `{identifier de distribution}/`
- `{fileName (o, si no existe, identifier de distribution)}` - `{fileName (o, si no existe, identifier de distribution)}`
### ejemplo
- `datos.gob.ar_data.json/`
- `data.json`
- `errors.jsonl`
- `turismo_fbc269ea-5f71-45b6-b70c-8eb38a03b8db/`
  - `turismo_0774a0bb-71c2-44d7-9ea6-780e6bd06d50/`
  - `cruceristas-por-puerto-residencia-desagregado-por-pais-mes.csv`
- ...
- `energia_0d4a18ee-9371-439a-8a94-4f53a9822664/`
   - `energia_9f602b6e-2bef-4ac4-895d-f6ecd6bb1866/`
   - `energia_9f602b6e-2bef-4ac4-895d-f6ecd6bb1866` (este archivo no tiene fileName en el data.json, entonces se reutiliza el `identifier`)
- ...

17
tsconfig.json Normal file
View file

@ -0,0 +1,17 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Node 20",
"_version": "20.1.0",
"compilerOptions": {
"lib": ["es2023"],
"module": "node16",
"target": "es2022",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node16"
}
}