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.
@@ -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()}")