- name
- government-data-pipelines
- version
- 1.4.0
- description
- Patrones para ingerir, estructurar y visualizar datos de fuentes gubernamentales — scraping de webs institucionales, parsers de PDFs con estructura fija, normalización a base de datos, dashboards interactivos. Catálogo de 35+ APIs de datos abiertos españolas (BORME, AEMET, INE, ESIOS, etc.). Incluye casos CIAF (ferroviario), CIAIAC (aviación), CIAIM (marítimo). Patrón Agregador Multi-Fuente para combinar múltiples APIs en dashboards unificados.
- tags
- ["government","scraping","pdf-parsing","data-ingestion","dashboard","geolocation"]
# Government Data Pipelines — Patrones de ingestión de datos gubernamentales
## Resumen
Procedimiento sistémico para convertir documentos públicos gubernamentales (informes, estadísticas, registros) en bases de datos estructuradas + visualizaciones interactivas. Los documentos oficiales españoles suelen tener estructura fija y URLs predecibles.
## Casos de uso
### 1. Informes de la CIAF (ferroviario)
- **Fuente:** https://www.transportes.gob.es/organos-colegiados/ciaf
- **URLs PDF:** Varios patrones (ver `references/ciaf-scraping.md` — 4 patrones según año)
- **Estructura fija:** Resumen → Descripción → Análisis → Conclusiones → Recomendaciones
- **Schema:** Ver `templates/ciaf-report-schema.json` en skill `liteparse-document-ai-parsing`
- **Scraping:** Ver `references/ciaf-scraping.md` (dentro de este skill)
- **⚠️ Disponibilidad:** 2007-2025 están publicados. **277 informes** (ya descargados en `/root/workspace/CIAF/`) + 17 memorias + 7 normativas = 301 PDFs total.
**⚠️ PATRÓN DE URL POR AÑOS (CRÍTICO — verificado 2026-06-26):**
- **2007-2016:** `/MFOM/LANG_CASTELLANO/ORGANOS_COLEGIADOS/CIAF/INFORMES/YYYY/`
- Ej: `/MFOM/LANG_CASTELLANO/ORGANOS_COLEGIADOS/CIAF/INFORMES/2009/`
- Los PDFs están en el HTML estático (no AJAX)
- Patrón de PDF: `href="(/recursos_mfom/pdf/UUID/ID/FILENAME.pdf)"` (comillas dobles)
- Total: ~181 PDFs (2009-2016; 2007-2008 vacíos)
- **2017-2025:** `/organos-colegiados/ciaf/informes-finales-de-sucesos-investigados/infofin-YYYY`
- Ej: `/infofin-2025`
- PDFs en `/recursos_mfom/paginabasica/recursos/XXXX-YY-ZZZZ-if-*.pdf`
- Total: ~38 PDFs (ya descargados)
- **NO usar filtros GET** (`?field_ciaf_anyo_value=2015`) — no funcionan, solo devuelven menú.
- **TOTAL REAL: 277 informes** (ya descargados en `/root/workspace/CIAF/YYYY/` desde 2007 hasta 2025). Distribución: 2007:4, 2008:53, 2009:43, 2010:28, 2011:24, 2012:22, 2013:23, 2014:14, 2015:10, 2016:11, 2017:12, 2018:2, 2019:3, 2020:3, 2021:6, 2022:5, 2023:3, 2024:3, 2025:1.
**⚠️ TRES ERAS DE FORMATO (CRÍTICO para el parser):**
Los informes tienen 3 formatos distintos según la normativa vigente:
1. **Pre-RD 810/2007 (2007-2008):** Formato libre, secciones variables (Antecedentes/Hechos/Análisis)
2. **RD 810/2007 (2009-2013):** Secciones 1-5 (Resumen, Descripción, Análisis, Conclusiones, Recomendaciones)
3. **RD 623/2014 (2014-2025):** Secciones 0-6 (Abreviaturas, Resumen, Descripción, Análisis, Conclusiones, Recomendaciones, Anexos)
- **Normativa de referencia:** `/root/workspace/CIAF/normativa/04-RD_623_2014_ciaf.pdf` — Art. 15 define la estructura obligatoria del informe
- **Detectar era por año** antes de parsear. Los 2007-2008 son los más irregulares.
**📐 Calidad profesional — requisito mínimo para herramientas de equipo (VERIFICADO 2026-06-26):**
Cuando el usuario dice "tengo que enviárselo al equipo y crearles una herramienta que puedan usar ellos", el estándar es **calidad de producción, no prototype**. Checklist mínimo:
- **100% de informes con título descriptivo** (no "Informe NN/YYYY")
- **0% HTML como texto plano** en el frontend ( `<strong>` se renderiza como bold, no como literal)
- **0% etiquetas duplicadas** en el panel de detalle
- **Nombres de campos consistentes** entre parser→index→frontend (mismo idioma, mismo nombre)
- **Estaciones limpias** (sin frases del PDF como nombre)
- **Fechas completas** (sin campos vacíos en informes con PDF fuente)
- **Enlaces funcionales** (PDF local + enlace oficial CIAF)
Si algún dato es visible para el usuario final, debe ser correcto y presentable. No vale "más o menos" — el usuario lo envía a su equipo y se juega su credibility.
**📊 Memorias anuales:**
- URL: `/organos-colegiados/ciaf/memorias-anuales/memoriasanuales`
- 17 memorias (2008-2024), guardadas en `/root/workspace/CIAF/memorias/`
- PDFs en el HTML directamente (no AJAX) — buscar `href='(/recursos_mfom/[^']+\\.pdf)'`
- Pattern de PDFs: múltiples rutas (`/recursos_mfom/`, `/recursos_mfom/pdf/UUID/`, `/recursos_mfom/listado/recursos/`)
- **NO hay memoria de 2011** — sí existe, pero se saltó en script anterior. Verificar siempre.
- **📄 Normativa:** 7 PDFs en `/root/workspace/CIAF/normativa/` — incluye RD 623/2014 que define estructura de informes
**📐 Preferencia de arquitectura (verificado):**
- **JSON como fuente de verdad** (no MD, no SQLite): GitHub Pages sirve JSON estático
- **JSON particionado por año**: `data/reports/YYYY.json` + `data/index.json` ligero (~50KB para 500 registros)
- **relations.json** para cruzar entidades × informes × recomendaciones
- **Imágenes extraídas** de PDFs con `pdfimages` (poppler) + `pdftoppm` como fallback
- **Coherencia** entre memorias anuales e informes individuales — verificar que los datos coinciden
- **Auto-import**: pipeline `sync.py` que detecta nuevos PDFs en la web y los procesa automáticamente
### 2. Informes de la CIAIAC (aviación)
- **Fuente:** https://www.transportes.gob.es/organos-colegiados/ciaiac
- **Mismo patrón** que CIAF pero para accidentes aéreos
### 3. Informes de la CIAIM (marítimo)
- **Fuente:** https://www.transportes.gob.es/organos-colegiados/ciaim
- **Investigaciones organizadas por año:** `/organos-colegiados/ciaim/investigaciones/2024`
### Arquitectura para datasets grandes (>100 registros, GitHub Pages)
Cuando hay 200+ registros, NO usar un solo JSON gigante. Usar **JSON particionado por año**:
```
data/
├── index.json ← Índice ligero (todos los IDs, metadatos mínimos, ~50KB)
├── reports/
│ ├── 2009.json ← Registros del 2009
│ ├── 2010.json ← Registros del 2010
│ └── ...
├── relations.json ← Entidades × registros × relaciones
└── images/
├── 2009/
│ └── IF-001-2009-fig01.png
└── ...
```
**Por qué funciona:**
- `index.json` se carga una vez para mapa + filtros (50KB para 500 registros)
- `reports/YYYY.json` se carga solo al hacer clic en un registro de ese año
- GitHub Pages sirve JSON estático sin backend
- Escalable a 500+ registros sin problemas de rendimiento
**Por qué NO SQLite en navegador:**
- GitHub Pages no sirve SQLite — necesitarías sql.js (500KB) + WebAssembly
- Para <1000 registros, JSON con fetch es suficiente
- JSON es más fácil de mantener y versionar con git
### Pipeline genérico (paso a paso)
### Paso 1: Descubrimiento de fuentes
```bash
# Acceder a la web con curl (NO browser tool — 403 en transportes.gob.es)
curl -sL 'https://www.transportes.gob.es/organos-colegiados/ciaf/informes-finales-de-sucesos-investigados' \
-H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36'
# Extraer enlaces a PDFs
curl ... | grep -oP 'href="[^"]*\.pdf"' | sort -u
```
**⚠️ 4 patrones de URL para PDFs en transportes.gob.es:**
1. `recursos_mfom/paginabasica/recursos/XXXX-YY-ZZZZ-if-*.pdf` (2017-2025)
2. `recursos_mfom/pdf/UUID-UUID/UUID/FILENAME.pdf` (2015-2016)
3. `recursos_mfom/YYMMDD-YYMMYY-if-*.pdf` (2015-2016)
4. `recursos_mfom/comodin/recursos/YYMMDDYYMMYYif*.pdf` (2016)
**⚠️ 2 patrones de URL por año:**
- 2015-2016: `/informes-finales-de-sucesos-investigados/AÑO`
- 2017-2025: `/informes-finales-de-sucesos-investigados/infofin-AÑO`
- **NO usar filtros GET** (`?field_ciaf_anyo_value=2015`) — no funcionan.
### Paso 2: Scraping masivo
- **Frecuencia:** Cron semanal (domingo 06:00 UTC)
- **Detección de nuevos:** Comparar hashes de URLs con base de datos local
- **Velocidad:** 1 PDF/sec es suficiente para cientos de informes
### Paso 3: Extracción de texto
- **Herramienta principal:** `PyMuPDF` (`fitz`) — más fiable que poppler/markitdown, puro Python, sin dependencias del sistema
```python
import fitz
doc = fitz.open(str(pdf_path))
text = "\n".join(page.get_text() for page in doc)
doc.close()
```
- **Fallback:** `markitdown` con extras: `pip install "markitdown[pdf]"` (requiere `pymupdf` como backend de PDF)
- **⚠️ `pdftotext` (poppler) frágil en entornos containerizados:** puede fallar por `libpoppler.so.XXX` faltante. Si aparece `No such file or directory: 'pdftotext'`, instalar `libpoppler-dev` O cambiar a PyMuPDF.
- **Extracción de imágenes:** `PyMuPDF` también extrae imágenes incrustadas (`page.get_images()` + `doc.extract_image(xref)`). Si no hay imágenes incrustadas, renderizar páginas como PNG con `page.get_pixmap(dpi=150)`.
- **OCR:** Si el PDF es imagen (no texto), usar `ocrmypdf`
### Paso 3b: Extracción por páginas vs regex completo (VERIFICADO 2026-06-26)
**⚠️ PATRÓN CRÍTICO: Extraer por páginas, NO por regex sobre texto completo.**
El enfoque regex sobre texto completo falla porque:
- El TOC (índice con puntos `.....39`) se confunde con contenido real
- Headers/footers de cada página se mezclan con el contenido
- Secciones de diferentes idiomas (inglés al final) contaminan la extracción
**Enfoque correcto — extracción por páginas:**
```python
def extract_pages(pdf_path: str) -> list[str]:
"""Extrae texto de cada página por separado."""
doc = fitz.open(pdf_path)
pages = [page.get_text() for page in doc]
doc.close()
return pages
def find_section_pages(pages: list[str]) -> dict[int, int]:
"""Encuentra en qué página empieza cada sección (saltando TOC)."""
section_pages = {}
for i, text in enumerate(pages):
if i < 2: # Skip cover + warning
continue
if re.search(r'\.{10,}', text): # Skip TOC pages
continue
for m in re.finditer(r'(?:^|\n)\s*(\d+)\.\s+([A-ZÁÉÍÓÚÑ][A-ZÁÉÍÓÚÑ\s]{5,40})', text):
num = int(m.group(1))
if num not in section_pages:
section_pages[num] = i
return section_pages
def get_pages_text(pages, start_page, end_page):
"""Concatena páginas limpiando headers/footers individuales."""
text = ""
for i in range(start_page, min(end_page, len(pages))):
page_text = pages[i]
# Limpiar headers/footers de CADA página
page_text = re.sub(r'Comisión de Investigación de\s*Accidentes Ferroviarios', '', page_text)
page_text = re.sub(r'Informe Final de la CIAF\s+\d+/\d{4}', '', page_text)
page_text = re.sub(r'^\s*\d{1,2}\s*$', '', page_text, flags=re.MULTILINE) # page numbers
page_text = re.sub(r'^.*\.{10,}.*$', '', page_text, flags=re.MULTILINE) # TOC remnants
text += page_text + "\n"
return text.strip()
```
**Ventajas comprobadas:**
- 2024: 3/3 títulos, 3/3 conclusiones, 3/3 recomendaciones (vs 0/3 con regex completo)
- 2009: 43/43 títulos, 38/43 conclusiones, 25/43 recomendaciones
- Maneja 3 eras de formato automáticamente
**Detección de TOC:** Líneas con 10+ puntos consecutivos (`.{10,}`) → saltar página completa
**Manejo de bilingüismo:** Los informes 2014+ tienen sección en inglés al final. Cortar extracción de recomendaciones antes de "SAFETY RECOMMENDATIONS" o "English summary":
```python
for i in range(start + 1, min(start + 5, len(pages))):
if re.search(r'SAFETY\s+RECOMMENDATIONS|English\s+summary', pages[i], re.IGNORECASE):
end = i
break
```
**Script completo:** `/root/workspace/CIAF-visor/scripts/parse_year_v2.py` — parser funcional con extracción por páginas, geocoding local, y manejo de 3 eras. Ver también: `references/page-based-pdf-extraction.md`, `references/station-coords-geocoding.md`, y `references/ciaf-memoria-parsing.md` (parseo de memorias anuales, diferente de informes individuales).
### Paso 3c: Extracción semántica de campos
El mayor reto NO es extraer texto del PDF (PyMuPDF lo hace bien), sino **estructurar el texto libre en campos JSON**.
**⚠️ PARADIGMA SHIFT (2026-06):** Para PDFs digitales con texto seleccionable, el enfoque LLM (`pdf-llm-extraction`) supera cualitativamente a los regex:
- **LLM:** 270/270 conclusiones (100%), 268/270 recomendaciones (99%), 381 trenes
- **Regex:** 194/270 conclusiones (72%), 149/270 recomendaciones (55%), 0 trenes
**Usar `pdf-llm-extraction` para batch processing de PDFs digitales.** Los regex de abajo quedan como referencia para entender la estructura de los informes, pero no son la herramienta principal recomendada.
Patrones verificados con CIAF (regex, referencia histórica):
**Expediente/informe — 4 patrones de título según era (VERIFICADO 2026-06-26):**
Los informes CIAF tienen 4 formatos de título distintos. Detectar por orden de prioridad:
```python
# Patrón 1 (2007-2008): "Investigación del accidente ferroviario ocurrido en..."
m = re.search(r'Investigaci[oó]n del accidente.*?ocurrido.*?(\d{1,2})[/.-](\d{1,2})[/.-](\d{4})', text[:2000])
# Patrón 2 (2015-2018): "CIAF Nº X/XXXX" o "IF X/XXXX"
m = re.search(r'(?:CIAF|Informe)\s+(?:N[º°]|n[º°])\s*(\d+/\d{4})', text[:2000])
# Patrón 3 (2019-2021): "expediente nº X/XXXX ocurrido el DD.MM.YYYY"
GitHub에서 보기