dane-data 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dane_data/__init__.py +26 -0
- dane_data/__main__.py +7 -0
- dane_data/_hooks.py +24 -0
- dane_data/_io.py +128 -0
- dane_data/app/.streamlit/config.toml +8 -0
- dane_data/app/Inicio.py +22 -0
- dane_data/app/_ui.py +361 -0
- dane_data/app/paginas/descargar.py +101 -0
- dane_data/app/paginas/diccionario.py +62 -0
- dane_data/app/paginas/eam_industria.py +129 -0
- dane_data/app/paginas/eam_microdatos.py +56 -0
- dane_data/app/paginas/geih_explorar.py +219 -0
- dane_data/app/paginas/geih_mercado.py +152 -0
- dane_data/app/paginas/inicio.py +79 -0
- dane_data/cli.py +149 -0
- dane_data/config.py +80 -0
- dane_data/data/__init__.py +0 -0
- dane_data/data/eam_diccionario.json +3235 -0
- dane_data/data/geih_diccionario.json +8159 -0
- dane_data/eam/__init__.py +49 -0
- dane_data/eam/downloader.py +402 -0
- dane_data/eam/indicadores.py +135 -0
- dane_data/eam/loader.py +219 -0
- dane_data/geih/__init__.py +62 -0
- dane_data/geih/agregados.py +358 -0
- dane_data/geih/downloader.py +676 -0
- dane_data/geih/indicadores.py +82 -0
- dane_data/geih/loader.py +600 -0
- dane_data/referencias.py +79 -0
- dane_data/stats.py +109 -0
- dane_data-0.2.1.dist-info/METADATA +265 -0
- dane_data-0.2.1.dist-info/RECORD +36 -0
- dane_data-0.2.1.dist-info/WHEEL +5 -0
- dane_data-0.2.1.dist-info/entry_points.txt +2 -0
- dane_data-0.2.1.dist-info/licenses/LICENSE +21 -0
- dane_data-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GEIH Downloader & Extractor
|
|
3
|
+
===========================
|
|
4
|
+
Automated pipeline to discover, download, and extract microdata from Colombia's
|
|
5
|
+
Gran Encuesta Integrada de Hogares (GEIH) and its official Empalme Series (CNPV 2018)
|
|
6
|
+
from DANE's National Data Archive (ANDA).
|
|
7
|
+
|
|
8
|
+
Features:
|
|
9
|
+
- Exclusive CSV extraction for key modules (Housing, Demographics, Labor force,
|
|
10
|
+
Employed, Other forms of work).
|
|
11
|
+
- Preserves household, individual, and geographic identifiers (DIRECTORIO,
|
|
12
|
+
SECUENCIA_P, ORDEN, HOGAR, DPTO, MPIO, CLASE, AREA, FEX_C).
|
|
13
|
+
- Automatic handling of nested archives, non-breaking spaces, and historical naming typos.
|
|
14
|
+
- Resilient downloads with retry backoff and incremental caching (skips already extracted months).
|
|
15
|
+
|
|
16
|
+
Autor original: Luis Ramírez (adaptado para dane-data)
|
|
17
|
+
License: MIT
|
|
18
|
+
Version: 1.0.0
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import io
|
|
23
|
+
import re
|
|
24
|
+
import sys
|
|
25
|
+
import json
|
|
26
|
+
import time
|
|
27
|
+
import zipfile
|
|
28
|
+
import logging
|
|
29
|
+
import argparse
|
|
30
|
+
import unicodedata
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from urllib.parse import urljoin
|
|
33
|
+
import requests
|
|
34
|
+
from bs4 import BeautifulSoup
|
|
35
|
+
|
|
36
|
+
from .._hooks import ProgressMixin
|
|
37
|
+
from .loader import EXTENSIONES, _pick_files, MesGEIH, month_from_folder
|
|
38
|
+
|
|
39
|
+
MARCADOR = ".dane_data_ok" # se escribe tras una extracción exitosa
|
|
40
|
+
|
|
41
|
+
# Nombre del módulo en el descargador -> nombre en el cargador / en MesGEIH.faltantes
|
|
42
|
+
_MODULO_CARGADOR = {
|
|
43
|
+
"vivienda": "vivienda", "caracteristicas": "caracteristicas", "fuerza_trabajo": "fuerza",
|
|
44
|
+
"ocupados": "ocupados", "desocupados": "desocupados", "inactivos": None, # se deduce de PET
|
|
45
|
+
"otras_formas_trabajo": "otras",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def faltantes_mes(month_dir: Path, modules) -> list[str]:
|
|
50
|
+
"""
|
|
51
|
+
Módulos pedidos que no están en ``month_dir`` (búsqueda recursiva).
|
|
52
|
+
|
|
53
|
+
Reutiliza la lógica del cargador: en 2020 y en la serie de empalme las
|
|
54
|
+
marcas OCI/DSI vienen dentro de Características, así que la ausencia del
|
|
55
|
+
archivo de Desocupados no cuenta como faltante.
|
|
56
|
+
"""
|
|
57
|
+
files = [f for f in month_dir.rglob("*") if f.is_file() and f.suffix.lower() in EXTENSIONES] if month_dir.is_dir() else []
|
|
58
|
+
if not files:
|
|
59
|
+
return ["todos"]
|
|
60
|
+
mes = MesGEIH(0, 0, month_dir, _pick_files(files))
|
|
61
|
+
pedidos = {_MODULO_CARGADOR[m] for m in modules} - {None}
|
|
62
|
+
falta = set(mes.faltantes)
|
|
63
|
+
falta |= {m for m in pedidos & {"vivienda", "otras"} if m not in mes.archivos}
|
|
64
|
+
return sorted(falta & pedidos)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _marcador_cubre(dest: Path, modules) -> bool:
|
|
68
|
+
"""
|
|
69
|
+
El marcador existe y la extracción que lo escribió incluyó todos los ``modules``.
|
|
70
|
+
Los marcadores anteriores no registran módulos: no se confía en ellos.
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
data = json.loads((dest / MARCADOR).read_text(encoding="utf-8"))
|
|
74
|
+
except (OSError, ValueError):
|
|
75
|
+
return False
|
|
76
|
+
return set(modules) <= set(data.get("modulos", []))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def mes_completo(month_dir: Path, modules) -> bool:
|
|
80
|
+
return _marcador_cubre(month_dir, modules) or not faltantes_mes(month_dir, modules)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _escribir_marcador(dest: Path, zip_name: str, n: int, modules) -> None:
|
|
84
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
data = {"zip": zip_name, "csv_extraidos": n, "modulos": sorted(modules)}
|
|
86
|
+
(dest / MARCADOR).write_text(json.dumps(data), encoding="utf-8")
|
|
87
|
+
|
|
88
|
+
__version__ = "1.0.0"
|
|
89
|
+
|
|
90
|
+
log = logging.getLogger("dane_data.geih")
|
|
91
|
+
|
|
92
|
+
# Catálogos oficiales verificados en ANDA (DANE) para la GEIH Principal Nacional
|
|
93
|
+
KNOWN_CATALOGS = {
|
|
94
|
+
"2007": "317",
|
|
95
|
+
"2008": "206",
|
|
96
|
+
"2009": "207",
|
|
97
|
+
"2010": "205",
|
|
98
|
+
"2011": "182",
|
|
99
|
+
"2012": "77",
|
|
100
|
+
"2013": "68",
|
|
101
|
+
"2014": "328",
|
|
102
|
+
"2015": "356",
|
|
103
|
+
"2016": "427",
|
|
104
|
+
"2017": "458",
|
|
105
|
+
"2018": "547",
|
|
106
|
+
"2019": "599",
|
|
107
|
+
"2020": "780",
|
|
108
|
+
"2021": "701",
|
|
109
|
+
"2022": "771",
|
|
110
|
+
"2023": "782",
|
|
111
|
+
"2024": "819",
|
|
112
|
+
"2025": "853",
|
|
113
|
+
"2026": "900",
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
# Catálogos oficiales verificados en ANDA (DANE) para la Serie de Empalme (Marco CNPV 2018)
|
|
117
|
+
EMPALME_CATALOGS = {
|
|
118
|
+
"2010": "765",
|
|
119
|
+
"2011": "755",
|
|
120
|
+
"2012": "760",
|
|
121
|
+
"2013": "761",
|
|
122
|
+
"2014": "756",
|
|
123
|
+
"2015": "762",
|
|
124
|
+
"2016": "757",
|
|
125
|
+
"2017": "763",
|
|
126
|
+
"2018": "758",
|
|
127
|
+
"2019": "759",
|
|
128
|
+
"2020": "764",
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# Patrones por módulo (nombres normalizados sin tildes). Se aceptan variaciones
|
|
132
|
+
# frecuentes de los nombres publicados por DANE.
|
|
133
|
+
MODULE_PATTERNS = {
|
|
134
|
+
"vivienda": ("vivienda", "hogar"),
|
|
135
|
+
"caracteristicas": ("caracteristica", "caracterica", "personas"),
|
|
136
|
+
"fuerza_trabajo": ("fuerza de trabajo",),
|
|
137
|
+
"ocupados": ("ocupados",),
|
|
138
|
+
"desocupados": ("desocupados", "deocupados"),
|
|
139
|
+
"inactivos": ("inactivos", "no ocupado"),
|
|
140
|
+
"otras_formas_trabajo": ("otras formas de trabajo", "otras actividades"),
|
|
141
|
+
}
|
|
142
|
+
DEFAULT_MODULES = tuple(MODULE_PATTERNS)
|
|
143
|
+
|
|
144
|
+
# CSV del ANDA (serie principal) que no se pueden leer bien: para esos meses se baja otro
|
|
145
|
+
# formato del mismo catálogo. Ver README, "Diferencias con la documentación del DANE".
|
|
146
|
+
# Año -> {mes: formato}; el mes None vale para todo el año.
|
|
147
|
+
CSV_DANADOS = {
|
|
148
|
+
"2008": {None: "txt"}, # FEX_C_2011 sin punto decimal ("849.491.032.387.525")
|
|
149
|
+
"2011": {None: "txt"},
|
|
150
|
+
"2021": {9: "dta"}, # separado por espacios, y el texto libre también los tiene
|
|
151
|
+
}
|
|
152
|
+
EXCLUDE_MODULE_PATTERNS = ("otros ingresos", "migracion", "tipo de investigacion")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def normalize_text(text: str) -> str:
|
|
156
|
+
"""Elimina acentos/diacríticos y normaliza espacios no separables (NBSP)."""
|
|
157
|
+
text = text.replace("\xa0", " ").replace("\u202f", " ").replace("\u200b", "")
|
|
158
|
+
return "".join(
|
|
159
|
+
c for c in unicodedata.normalize("NFD", text)
|
|
160
|
+
if unicodedata.category(c) != "Mn"
|
|
161
|
+
).lower()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _formato(nombre: str) -> str | None:
|
|
165
|
+
"""Formato de un ZIP del ANDA según su nombre ("Marzo_txt.zip" -> "txt"); None si no lo dice."""
|
|
166
|
+
n = normalize_text(nombre)
|
|
167
|
+
for formato, marcas in (("csv", ("csv",)), ("txt", ("txt",)), ("sav", ("sav", "spss")), ("dta", ("dta",))):
|
|
168
|
+
if any(m in n for m in marcas):
|
|
169
|
+
return formato
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def is_target_csv(filename: str, modules=DEFAULT_MODULES, extensiones=(".csv",)) -> bool:
|
|
174
|
+
"""
|
|
175
|
+
Determina si un archivo dentro del ZIP es un CSV correspondiente a uno de los
|
|
176
|
+
módulos solicitados:
|
|
177
|
+
- Identificación de vivienda y hogar
|
|
178
|
+
- Características generales (Personas)
|
|
179
|
+
- Fuerza de trabajo
|
|
180
|
+
- Ocupados
|
|
181
|
+
- Otras formas de trabajo / Otras actividades
|
|
182
|
+
"""
|
|
183
|
+
if not filename.lower().endswith(tuple(extensiones)):
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
norm_name = normalize_text(filename)
|
|
187
|
+
|
|
188
|
+
# Si coincide con exclusiones explícitas (e.g. 'no ocupados', 'desocupados')
|
|
189
|
+
if any(ex in norm_name for ex in EXCLUDE_MODULE_PATTERNS):
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
# Debe contener alguno de los patrones de los módulos objetivo
|
|
193
|
+
return any(
|
|
194
|
+
pattern in norm_name
|
|
195
|
+
for module in modules
|
|
196
|
+
for pattern in MODULE_PATTERNS[module]
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def sanitize_member_name(raw_name: str) -> str:
|
|
201
|
+
"""Limpia caracteres corruptos habituales de codificación DOS/Windows en ZIPs."""
|
|
202
|
+
name = Path(raw_name).name
|
|
203
|
+
# Mapeo de caracteres comunes distorsionados por CP437 en ZIPs antiguos de Windows
|
|
204
|
+
mojibake_map = {
|
|
205
|
+
"╡": "A", "µ": "A", "Á": "A",
|
|
206
|
+
"Ý": "i", "¡": "i", "í": "i",
|
|
207
|
+
"¾": "o", "¢": "o", "ó": "o",
|
|
208
|
+
"é": "e", "ú": "u", "ñ": "n", "Ñ": "N"
|
|
209
|
+
}
|
|
210
|
+
# Intentar decodificación limpia
|
|
211
|
+
try:
|
|
212
|
+
clean = name.encode("cp437").decode("utf-8")
|
|
213
|
+
except Exception:
|
|
214
|
+
clean = name
|
|
215
|
+
|
|
216
|
+
# Reemplazar posibles caracteres de dibujo de cajas o fallos de tabla OEM
|
|
217
|
+
for bad, good in [("╡rea", "Area"), ("µrea", "Area"), ("CaracterÝsticas", "Caracteristicas"), ("Caracter¡sticas", "Caracteristicas"), ("educaci¾n", "educacion"), ("educaci¢n", "educacion")]:
|
|
218
|
+
clean = clean.replace(bad, good)
|
|
219
|
+
|
|
220
|
+
return clean
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class GEIHDownloader(ProgressMixin):
|
|
224
|
+
def __init__(self, base_dir="data", keep_zips=True, extract_csv=True, is_empalme=False, modules=None,
|
|
225
|
+
progress=None, should_stop=None):
|
|
226
|
+
self.progress = progress
|
|
227
|
+
self.should_stop = should_stop
|
|
228
|
+
self.is_empalme = is_empalme
|
|
229
|
+
self.base_url = "https://microdatos.dane.gov.co"
|
|
230
|
+
# A diferencia del script original, base_dir ya es la raíz de la serie
|
|
231
|
+
# (la serie de empalme no se anida automáticamente en base_dir/empalme).
|
|
232
|
+
self.base_dir = Path(base_dir)
|
|
233
|
+
self.metadata_dir = self.base_dir / "metadata"
|
|
234
|
+
self.raw_dir = self.base_dir / "raw_zips"
|
|
235
|
+
self.csv_dir = self.base_dir / "datos_csv"
|
|
236
|
+
self.keep_zips = keep_zips
|
|
237
|
+
self.extract_csv = extract_csv
|
|
238
|
+
self.modules = tuple(modules or DEFAULT_MODULES)
|
|
239
|
+
|
|
240
|
+
self.session = requests.Session()
|
|
241
|
+
self.session.headers.update({
|
|
242
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
self._setup_directories()
|
|
246
|
+
|
|
247
|
+
def _setup_directories(self):
|
|
248
|
+
"""Crea la estructura de carpetas local."""
|
|
249
|
+
self.metadata_dir.mkdir(parents=True, exist_ok=True)
|
|
250
|
+
self.raw_dir.mkdir(parents=True, exist_ok=True)
|
|
251
|
+
if self.extract_csv:
|
|
252
|
+
self.csv_dir.mkdir(parents=True, exist_ok=True)
|
|
253
|
+
series_label = "Serie de Empalme (2010-2020)" if self.is_empalme else "Serie Principal (2007-2026)"
|
|
254
|
+
log.info(f"Directorio base configurado para [{series_label}] en: {self.base_dir.resolve()}")
|
|
255
|
+
|
|
256
|
+
def discover_catalogs(self) -> dict:
|
|
257
|
+
"""
|
|
258
|
+
Descubre dinámicamente los catálogos en ANDA mediante búsqueda AJAX.
|
|
259
|
+
Si la búsqueda falla o está incompleta, usa los catálogos verificados como respaldo.
|
|
260
|
+
"""
|
|
261
|
+
if self.is_empalme:
|
|
262
|
+
log.info("Buscando catálogos de la Serie de Empalme GEIH en el portal del DANE...")
|
|
263
|
+
discovered = {}
|
|
264
|
+
for page in [1, 2]:
|
|
265
|
+
search_url = f"{self.base_url}/index.php/catalog/search"
|
|
266
|
+
params = {"sk": "Empalme", "ps": 100, "page": page}
|
|
267
|
+
headers = {"X-Requested-With": "XMLHttpRequest"}
|
|
268
|
+
try:
|
|
269
|
+
res = self.session.get(search_url, params=params, headers=headers, timeout=20)
|
|
270
|
+
res.raise_for_status()
|
|
271
|
+
soup = BeautifulSoup(res.text, "html.parser")
|
|
272
|
+
for a in soup.find_all("a", href=True):
|
|
273
|
+
m = re.search(r"/catalog/(\d+)$", a["href"])
|
|
274
|
+
if not m:
|
|
275
|
+
continue
|
|
276
|
+
cat_id = m.group(1)
|
|
277
|
+
title = a.get_text(strip=True)
|
|
278
|
+
if "GEIH" in title.upper() and "EMPALME" in title.upper():
|
|
279
|
+
yr_match = re.search(r"\b(20\d\d)\b", title)
|
|
280
|
+
if yr_match:
|
|
281
|
+
discovered[yr_match.group(1)] = cat_id
|
|
282
|
+
except Exception as e:
|
|
283
|
+
log.warning(f"Error buscando catálogos de empalme en página {page}: {e}")
|
|
284
|
+
final_catalogs = dict(EMPALME_CATALOGS)
|
|
285
|
+
final_catalogs.update(discovered)
|
|
286
|
+
log.info(f"Catálogos de Empalme identificados para {len(final_catalogs)} años: {sorted(final_catalogs.keys())}")
|
|
287
|
+
return final_catalogs
|
|
288
|
+
|
|
289
|
+
log.info("Buscando catálogos de la GEIH Nacional en el portal del DANE...")
|
|
290
|
+
discovered = {}
|
|
291
|
+
page = 1
|
|
292
|
+
max_pages = 5
|
|
293
|
+
|
|
294
|
+
while page <= max_pages:
|
|
295
|
+
search_url = f"{self.base_url}/index.php/catalog/search"
|
|
296
|
+
params = {"sk": "GEIH", "ps": 100, "page": page}
|
|
297
|
+
headers = {"X-Requested-With": "XMLHttpRequest"}
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
res = self.session.get(search_url, params=params, headers=headers, timeout=20)
|
|
301
|
+
res.raise_for_status()
|
|
302
|
+
soup = BeautifulSoup(res.text, "html.parser")
|
|
303
|
+
|
|
304
|
+
found_on_page = 0
|
|
305
|
+
for a in soup.find_all("a", href=True):
|
|
306
|
+
href = a["href"]
|
|
307
|
+
m = re.search(r"/catalog/(\d+)$", href)
|
|
308
|
+
if not m:
|
|
309
|
+
continue
|
|
310
|
+
cat_id = m.group(1)
|
|
311
|
+
title = a.get_text(strip=True)
|
|
312
|
+
|
|
313
|
+
if not title or not ("GEIH" in title.upper() or "GRAN ENCUESTA" in title.upper()):
|
|
314
|
+
continue
|
|
315
|
+
|
|
316
|
+
# Filtrar módulos satélite y submuestras territoriales
|
|
317
|
+
norm_title = normalize_text(title)
|
|
318
|
+
is_satellite = any(w in norm_title for w in [
|
|
319
|
+
"san andres", "amazon", "orinoqu", "ciudades intermedias",
|
|
320
|
+
"infantil", "mti", "mfpt", "formacion", "migracion",
|
|
321
|
+
"tic", "mtic", "etnia", "empalme", "factores de expansion", "ciiu"
|
|
322
|
+
])
|
|
323
|
+
|
|
324
|
+
if not is_satellite:
|
|
325
|
+
year_match = re.search(r"\b(200[6-9]|20[1-3][0-9])\b", title)
|
|
326
|
+
if year_match:
|
|
327
|
+
year = year_match.group(1)
|
|
328
|
+
if year not in discovered:
|
|
329
|
+
discovered[year] = cat_id
|
|
330
|
+
found_on_page += 1
|
|
331
|
+
|
|
332
|
+
if found_on_page == 0:
|
|
333
|
+
break
|
|
334
|
+
page += 1
|
|
335
|
+
|
|
336
|
+
except Exception as e:
|
|
337
|
+
log.warning(f"No se pudo completar la búsqueda en página {page}: {e}")
|
|
338
|
+
break
|
|
339
|
+
|
|
340
|
+
# Combinar con los catálogos verificados para garantizar cobertura completa
|
|
341
|
+
final_catalogs = dict(KNOWN_CATALOGS)
|
|
342
|
+
final_catalogs.update(discovered)
|
|
343
|
+
|
|
344
|
+
log.info(f"Catálogos identificados para {len(final_catalogs)} años: {sorted(final_catalogs.keys())}")
|
|
345
|
+
return final_catalogs
|
|
346
|
+
|
|
347
|
+
def fetch_metadata(self, year: str, catalog_id: str) -> dict:
|
|
348
|
+
"""Obtiene y almacena el JSON metodológico de ANDA."""
|
|
349
|
+
metadata_path = self.metadata_dir / f"{year}.json"
|
|
350
|
+
if metadata_path.exists():
|
|
351
|
+
try:
|
|
352
|
+
with open(metadata_path, "r", encoding="utf-8") as f:
|
|
353
|
+
return json.load(f)
|
|
354
|
+
except Exception:
|
|
355
|
+
pass
|
|
356
|
+
|
|
357
|
+
json_url = f"{self.base_url}/index.php/metadata/export/{catalog_id}/json"
|
|
358
|
+
try:
|
|
359
|
+
res = self.session.get(json_url, timeout=20)
|
|
360
|
+
if res.status_code == 200:
|
|
361
|
+
data = res.json()
|
|
362
|
+
with open(metadata_path, "w", encoding="utf-8") as f:
|
|
363
|
+
json.dump(data, f, indent=4, ensure_ascii=False)
|
|
364
|
+
log.info(f"Metadata metodológica guardada: {metadata_path.name}")
|
|
365
|
+
return data
|
|
366
|
+
else:
|
|
367
|
+
log.warning(f"Endpoint metadata JSON retornó HTTP {res.status_code} para catálogo {catalog_id}")
|
|
368
|
+
except Exception as e:
|
|
369
|
+
log.warning(f"No se pudo descargar metadata JSON para {year}: {e}")
|
|
370
|
+
return {}
|
|
371
|
+
|
|
372
|
+
def _enlaces(self, catalog_id: str) -> dict:
|
|
373
|
+
"""``{url: nombre}`` de todos los archivos de /get-microdata del catálogo."""
|
|
374
|
+
microdata_url = f"{self.base_url}/index.php/catalog/{catalog_id}/get-microdata"
|
|
375
|
+
try:
|
|
376
|
+
res = self.session.get(microdata_url, timeout=20)
|
|
377
|
+
res.raise_for_status()
|
|
378
|
+
except Exception as e:
|
|
379
|
+
log.error(f"Error accediendo a página de microdatos del catálogo {catalog_id}: {e}")
|
|
380
|
+
return {}
|
|
381
|
+
|
|
382
|
+
# Extraer parámetros de onclick="mostrarModal('filename', 'download_url')"
|
|
383
|
+
matches = re.findall(r"mostrarModal\(\s*'([^']+)'\s*,\s*'([^']+)'\s*\)", res.text)
|
|
384
|
+
if not matches:
|
|
385
|
+
log.warning(f"No se encontraron enlaces de descarga en catálogo {catalog_id}")
|
|
386
|
+
return {}
|
|
387
|
+
|
|
388
|
+
# Deduplicar por URL de descarga
|
|
389
|
+
unique_files = {}
|
|
390
|
+
for name, url in matches:
|
|
391
|
+
clean_url = url.strip()
|
|
392
|
+
clean_name = name.strip()
|
|
393
|
+
if clean_url not in unique_files:
|
|
394
|
+
unique_files[clean_url] = clean_name
|
|
395
|
+
return unique_files
|
|
396
|
+
|
|
397
|
+
def get_csv_download_links(self, catalog_id: str) -> list:
|
|
398
|
+
"""
|
|
399
|
+
Extrae los enlaces de descarga para archivos CSV desde /get-microdata.
|
|
400
|
+
Maneja dos épocas del DANE:
|
|
401
|
+
1. 2007-2021: ZIPs separados por formato (*.csv.zip)
|
|
402
|
+
2. 2022-2026: ZIPs mensuales completos que contienen CSV, DTA y SAV en su interior
|
|
403
|
+
"""
|
|
404
|
+
unique_files = self._enlaces(catalog_id)
|
|
405
|
+
if not unique_files:
|
|
406
|
+
return []
|
|
407
|
+
|
|
408
|
+
# 1. Verificar si existen archivos con extensión específica de CSV (ej. Enero.csv.zip)
|
|
409
|
+
csv_specific = [
|
|
410
|
+
{"name": name, "url": url}
|
|
411
|
+
for url, name in unique_files.items()
|
|
412
|
+
if "csv" in name.lower()
|
|
413
|
+
]
|
|
414
|
+
|
|
415
|
+
if csv_specific:
|
|
416
|
+
return csv_specific
|
|
417
|
+
|
|
418
|
+
# 2. Si no hay archivos específicos de CSV (años 2022 en adelante),
|
|
419
|
+
# los datos vienen empaquetados mensualmente (ej. Ene_2024.zip, Enero 2025.zip).
|
|
420
|
+
# Descartamos archivos de documentación o complementarios no mensuales.
|
|
421
|
+
monthly_bundles = []
|
|
422
|
+
for url, name in unique_files.items():
|
|
423
|
+
norm_name = normalize_text(name)
|
|
424
|
+
# Ignorar FEX u otros zips de apoyo si no son los datos mensuales
|
|
425
|
+
if "fex" in norm_name or "proyecciones" in norm_name:
|
|
426
|
+
continue
|
|
427
|
+
if norm_name.endswith(".zip"):
|
|
428
|
+
monthly_bundles.append({"name": name, "url": url})
|
|
429
|
+
|
|
430
|
+
return monthly_bundles
|
|
431
|
+
|
|
432
|
+
def get_download_links(self, year: str, catalog_id: str) -> list:
|
|
433
|
+
"""
|
|
434
|
+
Enlaces del año: el CSV de cada mes, salvo los meses de ``CSV_DANADOS``, que se
|
|
435
|
+
cambian por otro formato del mismo catálogo (``item["formato"]``).
|
|
436
|
+
"""
|
|
437
|
+
files = self.get_csv_download_links(catalog_id)
|
|
438
|
+
danados = {} if self.is_empalme else CSV_DANADOS.get(str(year), {})
|
|
439
|
+
if not danados:
|
|
440
|
+
return files
|
|
441
|
+
|
|
442
|
+
def formato_de(mes):
|
|
443
|
+
return danados.get(mes, danados.get(None))
|
|
444
|
+
|
|
445
|
+
alternos = {}
|
|
446
|
+
for url, nombre in self._enlaces(catalog_id).items():
|
|
447
|
+
mes = month_from_folder(nombre)
|
|
448
|
+
buscado = formato_de(mes) if mes else None
|
|
449
|
+
# Los ZIP sin formato en el nombre (p. ej. "Septiembre.zip" en 2021) traen el Stata.
|
|
450
|
+
if buscado and (_formato(nombre) or "dta") == buscado and mes not in alternos:
|
|
451
|
+
alternos[mes] = {"name": nombre, "url": url, "formato": buscado}
|
|
452
|
+
out = []
|
|
453
|
+
for item in files:
|
|
454
|
+
mes = month_from_folder(item["name"])
|
|
455
|
+
if mes and formato_de(mes) and mes in alternos:
|
|
456
|
+
out.append(alternos[mes])
|
|
457
|
+
else:
|
|
458
|
+
if mes and formato_de(mes):
|
|
459
|
+
log.warning(f"El CSV de {item['name']} ({year}) está dañado y el ANDA no ofrece el formato "
|
|
460
|
+
f"{formato_de(mes)}: se baja el CSV.")
|
|
461
|
+
out.append(item)
|
|
462
|
+
return out
|
|
463
|
+
|
|
464
|
+
def download_file(self, url: str, dest_path: Path, max_retries: int = 3) -> bool:
|
|
465
|
+
"""
|
|
466
|
+
Descarga un archivo con streaming y verificación de integridad.
|
|
467
|
+
"""
|
|
468
|
+
if dest_path.exists() and dest_path.stat().st_size > 0:
|
|
469
|
+
log.info(f"Archivo ya existente: {dest_path.name} ({dest_path.stat().st_size / 1024 / 1024:.1f} MB). Saltando descarga.")
|
|
470
|
+
return True
|
|
471
|
+
|
|
472
|
+
temp_dest = dest_path.with_suffix(dest_path.suffix + ".part")
|
|
473
|
+
|
|
474
|
+
for attempt in range(1, max_retries + 1):
|
|
475
|
+
try:
|
|
476
|
+
log.info(f"Descargando {dest_path.name} (intento {attempt}/{max_retries})...")
|
|
477
|
+
with self.session.get(url, stream=True, timeout=45) as r:
|
|
478
|
+
r.raise_for_status()
|
|
479
|
+
total_size = int(r.headers.get("content-length", 0))
|
|
480
|
+
downloaded = 0
|
|
481
|
+
|
|
482
|
+
with open(temp_dest, "wb") as f:
|
|
483
|
+
for chunk in r.iter_content(chunk_size=65536):
|
|
484
|
+
self._check_stop()
|
|
485
|
+
if chunk:
|
|
486
|
+
f.write(chunk)
|
|
487
|
+
downloaded += len(chunk)
|
|
488
|
+
if total_size:
|
|
489
|
+
self._emit(None, f"{dest_path.name}: {downloaded / 1e6:.1f} / {total_size / 1e6:.1f} MB")
|
|
490
|
+
|
|
491
|
+
if total_size > 0 and downloaded < total_size:
|
|
492
|
+
log.warning(f"Descarga incompleta ({downloaded}/{total_size} bytes). Reintentando...")
|
|
493
|
+
continue
|
|
494
|
+
|
|
495
|
+
temp_dest.rename(dest_path)
|
|
496
|
+
size_mb = dest_path.stat().st_size / 1024 / 1024
|
|
497
|
+
log.info(f"✓ Descarga completada: {dest_path.name} ({size_mb:.2f} MB)")
|
|
498
|
+
return True
|
|
499
|
+
|
|
500
|
+
except Exception as e:
|
|
501
|
+
if type(e).__name__ == "DownloadCancelled":
|
|
502
|
+
if temp_dest.exists():
|
|
503
|
+
temp_dest.unlink()
|
|
504
|
+
raise
|
|
505
|
+
log.error(f"Error descargando {dest_path.name} (intento {attempt}): {e}")
|
|
506
|
+
if temp_dest.exists():
|
|
507
|
+
temp_dest.unlink()
|
|
508
|
+
time.sleep(2 * attempt)
|
|
509
|
+
|
|
510
|
+
return False
|
|
511
|
+
|
|
512
|
+
def _extract_from_zip(self, z_obj: zipfile.ZipFile, dest_dir: Path, extensiones=(".csv",)) -> int:
|
|
513
|
+
count = 0
|
|
514
|
+
for member in z_obj.infolist():
|
|
515
|
+
if member.is_dir():
|
|
516
|
+
continue
|
|
517
|
+
|
|
518
|
+
# Soporte para ZIPs anidados (ej. CSV.zip dentro de paquetes mensuales 2022-2024 o 1. Enero.zip en Empalme)
|
|
519
|
+
if member.filename.lower().endswith(".zip") and not any(fmt in member.filename.lower() for fmt in ["dta", "sav", "spss"]):
|
|
520
|
+
try:
|
|
521
|
+
nested_bytes = io.BytesIO(z_obj.read(member))
|
|
522
|
+
with zipfile.ZipFile(nested_bytes) as nested_z:
|
|
523
|
+
inner_stem = Path(member.filename).stem
|
|
524
|
+
# Solo el paquete anual del empalme trae un ZIP por mes (ej. "1. Enero.zip"):
|
|
525
|
+
# esos van a su subcarpeta. Cualquier otro ZIP anidado ("CSV.zip",
|
|
526
|
+
# "CSV 13.zip", "CSV_4.zip"...) se aplana en la carpeta del mes.
|
|
527
|
+
if self.is_empalme and month_from_folder(inner_stem) is not None:
|
|
528
|
+
target_dest = dest_dir / inner_stem
|
|
529
|
+
else:
|
|
530
|
+
target_dest = dest_dir
|
|
531
|
+
target_dest.mkdir(parents=True, exist_ok=True)
|
|
532
|
+
count += self._extract_from_zip(nested_z, target_dest, extensiones)
|
|
533
|
+
except Exception as e:
|
|
534
|
+
log.warning(f"Error abriendo ZIP anidado {member.filename}: {e}")
|
|
535
|
+
continue
|
|
536
|
+
|
|
537
|
+
if is_target_csv(member.filename, self.modules, extensiones):
|
|
538
|
+
clean_filename = sanitize_member_name(member.filename)
|
|
539
|
+
target_file = dest_dir / clean_filename
|
|
540
|
+
|
|
541
|
+
if target_file.exists() and target_file.stat().st_size > 0:
|
|
542
|
+
count += 1
|
|
543
|
+
continue
|
|
544
|
+
|
|
545
|
+
with z_obj.open(member) as source, open(target_file, "wb") as dest:
|
|
546
|
+
dest.write(source.read())
|
|
547
|
+
count += 1
|
|
548
|
+
return count
|
|
549
|
+
|
|
550
|
+
def extract_requested_modules(self, zip_path: Path, dest_dir: Path, extensiones=(".csv",)) -> int:
|
|
551
|
+
"""
|
|
552
|
+
Inspecciona el archivo ZIP descargado y extrae EXCLUSIVAMENTE los archivos CSV
|
|
553
|
+
de los módulos solicitados:
|
|
554
|
+
- Vivienda y Hogares / Datos del hogar y la vivienda
|
|
555
|
+
- Características generales (Personas)
|
|
556
|
+
- Fuerza de trabajo
|
|
557
|
+
- Ocupados
|
|
558
|
+
- Otras formas de trabajo / Otras actividades
|
|
559
|
+
Conserva los identificadores de personas (DIRECTORIO, SECUENCIA_P, ORDEN)
|
|
560
|
+
y geográficos (DPTO, MPIO, CLASE, AREA) ya incluidos en dichas tablas.
|
|
561
|
+
Soporta además archivos ZIP anidados internamente (ej. CSV.zip o meses).
|
|
562
|
+
"""
|
|
563
|
+
if not zip_path.exists():
|
|
564
|
+
return 0
|
|
565
|
+
|
|
566
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
567
|
+
extracted_count = 0
|
|
568
|
+
|
|
569
|
+
try:
|
|
570
|
+
with zipfile.ZipFile(zip_path, "r") as z:
|
|
571
|
+
extracted_count = self._extract_from_zip(z, dest_dir, extensiones)
|
|
572
|
+
|
|
573
|
+
log.info(f" → Extraídos {extracted_count} CSVs de módulos solicitados desde {zip_path.name}")
|
|
574
|
+
except zipfile.BadZipFile:
|
|
575
|
+
log.error(f"El archivo {zip_path.name} está corrupto o no es un ZIP válido.")
|
|
576
|
+
except Exception as e:
|
|
577
|
+
log.error(f"Error extrayendo módulos de {zip_path.name}: {e}")
|
|
578
|
+
|
|
579
|
+
return extracted_count
|
|
580
|
+
|
|
581
|
+
def process_year(self, year: str, catalog_id: str, limit_files: int = None):
|
|
582
|
+
"""Descarga y procesa un año específico de la GEIH o de la Serie de Empalme."""
|
|
583
|
+
series_label = f"Empalme Año {year}" if self.is_empalme else f"Año {year}"
|
|
584
|
+
log.info(f"\n{'='*20} Procesando GEIH {series_label} (Catálogo {catalog_id}) {'='*20}")
|
|
585
|
+
|
|
586
|
+
# 1. Metadata metodológica
|
|
587
|
+
self.fetch_metadata(year, catalog_id)
|
|
588
|
+
|
|
589
|
+
# 2. Obtener lista de archivos a descargar (CSV, u otro formato si el CSV está dañado)
|
|
590
|
+
files = self.get_download_links(year, catalog_id)
|
|
591
|
+
if not files:
|
|
592
|
+
log.warning(f"No se encontraron enlaces válidos de microdatos para {year}")
|
|
593
|
+
return
|
|
594
|
+
|
|
595
|
+
if limit_files and limit_files > 0:
|
|
596
|
+
files = files[:limit_files]
|
|
597
|
+
|
|
598
|
+
log.info(f"Se procesarán {len(files)} archivos para {year}.")
|
|
599
|
+
year_raw_dir = self.raw_dir / year
|
|
600
|
+
year_raw_dir.mkdir(parents=True, exist_ok=True)
|
|
601
|
+
|
|
602
|
+
year_csv_dir = self.csv_dir / year
|
|
603
|
+
if self.extract_csv:
|
|
604
|
+
year_csv_dir.mkdir(parents=True, exist_ok=True)
|
|
605
|
+
|
|
606
|
+
# 3. Descargar y extraer selectivamente
|
|
607
|
+
for item in files:
|
|
608
|
+
self._check_stop()
|
|
609
|
+
self._emit(None, f"{year}: {item['name']}")
|
|
610
|
+
safe_name = "".join(c for c in item["name"] if c.isalnum() or c in (" ", ".", "_", "-")).strip()
|
|
611
|
+
zip_dest = year_raw_dir / safe_name
|
|
612
|
+
month_stem = safe_name.replace(".csv.zip", "").replace(".zip", "").strip()
|
|
613
|
+
|
|
614
|
+
if self.is_empalme:
|
|
615
|
+
# En la serie de empalme, el archivo anual empaqueta los 12 meses adentro
|
|
616
|
+
meses = [d for d in year_csv_dir.iterdir() if d.is_dir() and month_from_folder(d.name)] \
|
|
617
|
+
if year_csv_dir.is_dir() else []
|
|
618
|
+
if _marcador_cubre(year_csv_dir, self.modules) or (
|
|
619
|
+
len(meses) >= 12 and all(mes_completo(d, self.modules) for d in meses)):
|
|
620
|
+
log.info(f"Año {year} de Empalme ya está completo. Saltando.")
|
|
621
|
+
continue
|
|
622
|
+
|
|
623
|
+
success = self.download_file(item["url"], zip_dest)
|
|
624
|
+
if success and self.extract_csv:
|
|
625
|
+
n = self.extract_requested_modules(zip_dest, year_csv_dir)
|
|
626
|
+
if n:
|
|
627
|
+
_escribir_marcador(year_csv_dir, item["name"], n, self.modules)
|
|
628
|
+
if not self.keep_zips and zip_dest.exists():
|
|
629
|
+
zip_dest.unlink()
|
|
630
|
+
else:
|
|
631
|
+
month_csv_dir = year_csv_dir / month_stem
|
|
632
|
+
# Se salta solo si una extracción anterior de estos mismos módulos terminó
|
|
633
|
+
# (marcador) o, si no, si ya están todos los módulos pedidos. Contar archivos no
|
|
634
|
+
# basta: así se colaron meses sin "No ocupados" (p. ej. marzo 2024).
|
|
635
|
+
if mes_completo(month_csv_dir, self.modules):
|
|
636
|
+
log.info(f"Mes {month_stem} ({year}) ya está completo. Saltando.")
|
|
637
|
+
continue
|
|
638
|
+
if month_csv_dir.is_dir():
|
|
639
|
+
falta = faltantes_mes(month_csv_dir, self.modules)
|
|
640
|
+
log.warning(f"Mes {month_stem} ({year}) incompleto (falta: {', '.join(falta)}). Se vuelve a extraer.")
|
|
641
|
+
# Un ZIP ya en disco puede ser el que produjo la extracción incompleta:
|
|
642
|
+
# se reutiliza (download_file lo salta) y se re-extrae con la lógica corregida.
|
|
643
|
+
|
|
644
|
+
success = self.download_file(item["url"], zip_dest)
|
|
645
|
+
if success and self.extract_csv:
|
|
646
|
+
n = self.extract_requested_modules(zip_dest, month_csv_dir, (f".{item.get('formato', 'csv')}",))
|
|
647
|
+
if n:
|
|
648
|
+
_escribir_marcador(month_csv_dir, item["name"], n, self.modules)
|
|
649
|
+
falta = faltantes_mes(month_csv_dir, self.modules)
|
|
650
|
+
if falta:
|
|
651
|
+
log.warning(f"El ZIP del DANE para {month_stem} ({year}) no trae: {', '.join(falta)}.")
|
|
652
|
+
if not self.keep_zips and zip_dest.exists():
|
|
653
|
+
zip_dest.unlink()
|
|
654
|
+
|
|
655
|
+
time.sleep(1)
|
|
656
|
+
|
|
657
|
+
def run_pipeline(self, target_years=None, limit_files: int = None):
|
|
658
|
+
"""Ejecuta el pipeline de descarga para todos los años o los años seleccionados."""
|
|
659
|
+
catalogs = self.discover_catalogs()
|
|
660
|
+
|
|
661
|
+
years_to_process = sorted(catalogs.keys(), key=lambda y: int(y))
|
|
662
|
+
if target_years:
|
|
663
|
+
target_years = [str(y) for y in target_years]
|
|
664
|
+
years_to_process = [y for y in years_to_process if y in target_years]
|
|
665
|
+
|
|
666
|
+
series_name = "Empalme" if self.is_empalme else "Principal"
|
|
667
|
+
log.info(f"Iniciando descarga de la serie [{series_name}] para {len(years_to_process)} años: {years_to_process}")
|
|
668
|
+
|
|
669
|
+
for i, year in enumerate(years_to_process):
|
|
670
|
+
self._check_stop()
|
|
671
|
+
self._emit(i / max(len(years_to_process), 1), f"Procesando año {year}")
|
|
672
|
+
self.process_year(year, catalogs[year], limit_files=limit_files)
|
|
673
|
+
self._emit(1.0, "Descarga terminada")
|
|
674
|
+
|
|
675
|
+
log.info(f"\n¡Pipeline [{series_name}] finalizado exitosamente!")
|
|
676
|
+
log.info(f"Datos CSV organizados en: {self.csv_dir.resolve()}")
|