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.jsonen skillliteparse-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)
- Ej:
- 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)
- Ej:
- 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:
- Pre-RD 810/2007 (2007-2008): Formato libre, secciones variables (Antecedentes/Hechos/Análisis)
- RD 810/2007 (2009-2013): Secciones 1-5 (Resumen, Descripción, Análisis, Conclusiones, Recomendaciones)
- 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.jsonligero (~50KB para 500 registros) - relations.json para cruzar entidades × informes × recomendaciones
- Imágenes extraídas de PDFs con
pdfimages(poppler) +pdftoppmcomo fallback - Coherencia entre memorias anuales e informes individuales — verificar que los datos coinciden
- Auto-import: pipeline
sync.pyque 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.jsonse carga una vez para mapa + filtros (50KB para 500 registros)reports/YYYY.jsonse 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
# 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:
recursos_mfom/paginabasica/recursos/XXXX-YY-ZZZZ-if-*.pdf(2017-2025)recursos_mfom/pdf/UUID-UUID/UUID/FILENAME.pdf(2015-2016)recursos_mfom/YYMMDD-YYMMYY-if-*.pdf(2015-2016)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 sistemaimport fitz doc = fitz.open(str(pdf_path)) text = "\n".join(page.get_text() for page in doc) doc.close() - Fallback:
markitdowncon extras:pip install "markitdown[pdf]"(requierepymupdfcomo backend de PDF) - ⚠️
pdftotext(poppler) frágil en entornos containerizados: puede fallar porlibpoppler.so.XXXfaltante. Si apareceNo such file or directory: 'pdftotext', instalarlibpoppler-devO cambiar a PyMuPDF. - Extracción de imágenes:
PyMuPDFtambién extrae imágenes incrustadas (page.get_images()+doc.extract_image(xref)). Si no hay imágenes incrustadas, renderizar páginas como PNG conpage.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:
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":
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:
# 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"
m = re.search(r'expediente\s+n[º°]\s*(\d+/\d{4}).*?(\d{1,2})[/.-](\d{1,2})[/.-](\d{4})', text[:2000], re.IGNORECASE)
# Patrón 4 (2022+): "Investigación del accidente Nº X/XXXX — Descripción"
m = re.search(r'N[º°]\s*(\d+/\d{4})\s*[—–-]\s*(.+?)(?:\n|$)', text[:2000])
⚠️ NO confundir patrones: Patrón 1 tiene fecha en el título; Patrón 2 tiene "CIAF" explícito; Patrón 3 tiene "expediente"; Patrón 4 tiene "—" separador. El orden de detección importa.
Extracción de título en PDFs antiguos sin título en portada (2007-2013) (VERIFICADO 2026-06-26): Los PDFs pre-2014 no tienen título descriptivo en la portada — solo dicen "Investigación del accidente nº XXXX". La descripción está en el cuerpo. Estrategia multi-pass:
# Pass 1: Buscar sección RESUMEN
for heading in ['RESUMEN DEL ANÁLISIS', 'RESUMEN DEL ACCIDENTE', 'RESUMEN']:
idx = text.find(heading)
if idx >= 0:
# Extraer primera línea significativa después del heading
body = text[idx+len(heading):idx+len(heading)+500]
lines = [l.strip() for l in body.split('\n') if len(l.strip()) > 20]
if lines:
title = lines[0][:120]
break
# Pass 2: Buscar patrón "El día DD de MM de AAAA"
m = re.search(r'[Ee]l\s+d[íi]a\s+\d{1,2}\s+de\s+\w+\s+de\s+\d{4}', text[:3000])
if m:
# Capturar hasta el siguiente punto
end = text.find('.', m.start())
title = text[m.start():end][:120] if end > 0 else text[m.start():m.start()+120]
# Pass 3: Buscar primera frase descriptiva del cuerpo
for pattern in [
r'(?:accidente|incidente|avería|descarrilamiento|arrollamiento|colisión)\s+.{20,100}',
]:
m = re.search(pattern, text[1000:4000], re.IGNORECASE)
if m:
title = m.group(0)[:120]
break
# Pass 4: Fallback — usar solo expediente (ya no es "sin título")
title = f"Informe {expediente}"
Resultado verificado: 126 informes antiguos (2007-2019) pasaron de "Informe NN/YYYY" a título descriptivo extraído del PDF. 270/270 informes con título (100%).
Métricas del parser v2 (ACTUALIZADO 2026-06-27, 270 informes):
Con LLM (pdf-llm-extraction, v4.0 — RECOMENDADO):
| Campo | Éxito | % |
|---|---|---|
| Conclusiones | 270/270 | 100% |
| Recomendaciones | 268/270 | 99% |
| Trenes | 270/270 | 100% |
| Víctimas | 270/270 | 100% (517 total) |
Con regex (parser v2, referencia histórica):
| Campo | Éxito | % |
|---|---|---|
| Fechas | 268/270 | 99.3% |
| Títulos | 270/270 | 100% |
| Estaciones | 195/270 | 72.2% |
| Conclusiones | 194/270 | 71.9% |
| Recomendaciones | 149/270 | 55.2% |
Estación/ubicación — múltiples patrones por era:
patterns = [
r'estación\s+de\s+([A-ZÁÉÍÓÚÑ][^\.]+?)(?:\s*[,\.]|\s+el\s)', # "estación de Madrid Chamartín"
r'EN\s+LA\s+ESTACI[ÓO]N\s+DE\s+([A-ZÁÉÍÓÚÑ\s]+)', # Mayúsculas
r'en\s+el\s+apeadero\s+de\s+([A-ZÁÉÍÓÚÑ][^\.]+?)(?:\s*,)', # "apeadero de X"
r'(?:Lugar|Ubicación)[:\s]+([^\n]+)', # Campo directo
]
Provincia — extraer del cuerpo, NO del encabezado:
# 1. PRIORIZAR nombre de estación ("Cuenca-Fernando" → Cuenca)
# 2. Buscar "Provincia: X" en contexto del accidente
# 3. Buscar primera mención de provincia DESPUÉS del encabezado (>posición 1000)
# ⚠️ NO confundir "Madrid" en dirección de sede CIAF con provincia del accidente
# ⚠️ "Albacete" en texto puede ser "CRC de Albacete" (centro de regulación), NO la provincia
def extract_province(text, station):
# Primero: check si el nombre de estación contiene una provincia
if station:
for prov in PROVINCIAS:
if prov.upper() in station.upper():
return prov
# Luego: buscar en el cuerpo (saltar primeros 500 chars del header)
search_text = text[500:] if len(text) > 500 else text
for prov in PROVINCIAS:
if re.search(rf'\b{re.escape(prov)}\b', search_text, re.IGNORECASE):
return prov
return ""
Entidades — normalizar variants (VERIFICADO 2026-06-26):
ENTITY_MAP = {
'renfe operaciones': 'Renfe',
'renfe viajeros': 'Renfe Viajeros',
'renfe mercancías': 'Renfe Mercancías',
'adif': 'ADIF',
'adif av': 'ADIF AV', # ⚠️ ADIF AV es distinto de ADIF
'continental rail': 'Continental Rail',
'acciona rail': 'Acciona Rail',
}
# Buscar: "empresa ferroviaria X", "operador X", "gestor de infraestructura X"
# Normalización final:case-insensitive dedup
# "RENFE" y "Renfe" → fusionar a la forma canónica
# Orden de precedencia: Renfe Viajeros > Renfe Mercancías > RENFE
def normalize_entities(entities):
dedup = {}
for e in entities:
key = e.upper()
if key not in dedup:
dedup[key] = e
return sorted(dedup.values())
Recomendaciones — patrón split-by-number (VERIFICADO 2026-06-26): El patrón más robusto NO es regex complejo, sino dividir el texto por los números de recomendación:
# 1. Encontrar la ÚLTIMA sección "RECOMENDACIONES FINALES" (no el TOC)
all_matches = list(re.finditer(r'RECOMENDACIONES\s+FINALES', text, re.IGNORECASE))
for m in all_matches:
start = m.end()
next_section = re.search(r'\n\s*\d+\s*\.\s+[A-ZÁÉÍÓÚÑ]{3,}', text[start:start+5000])
end = start + next_section.start() if next_section else min(start + 5000, len(text))
candidate = text[start:end].strip()
if 'Destinatario' in candidate or re.search(r'\d{2,}/\d{2,4}[-–]\d', candidate):
rec_section = candidate
break
# 2. Limpiar headers de tabla, TOC, y APPENDIX: ENGLISH
rec_section = re.sub(r'Destinatario\s+...', '', rec_section)
rec_section = re.sub(r'APPENDIX.*$', '', rec_section, flags=re.DOTALL | re.IGNORECASE)
rec_section = re.sub(r'^[.\\.·]+\s*\d+\s*$', '', rec_section, flags=re.MULTILINE)
# 3. Encontrar TODOS los números y dividir entre ellos
num_pattern = re.compile(r'((?:IF[-\s]?)?\d+/\d{2,4}[–-]\d+)')
all_nums = list(num_pattern.finditer(rec_section))
for i, m in enumerate(all_nums):
num = m.group(1)
text_start = m.end()
text_end = all_nums[i+1].start() if i+1 < len(all_nums) else len(rec_section)
texto = re.sub(r'\s+', ' ', rec_section[text_start:text_end].strip())
# Extraer implementador del contexto anterior
ctx = rec_section[max(0, m.start()-300):m.start()]
ent_match = re.findall(r'(AESF|ADIF|RENFE|CAF|FEVE|FGC|Euskotren)', ctx, re.IGNORECASE)
implementador = ent_match[-1] if ent_match else ""
⚠️ NO usar regex monolítico — las tablas CIAF tienen formato inconsistente (saltos de línea, columnas separadas). El patrón split-by-number captura 7/7 recomendaciones vs 1/7 con regex complejo.
Formatos de número de recomendación: 11/2023-1, 065/2007-1, IF-300109-1, 0011/2009-1, 11/09-1 — el regex debe aceptar 2-4 dígitos en el año: \d+/\d{2,4}-\d+
Resumen del análisis — extraer del cuerpo, NO del TOC (VERIFICADO 2026-06-26):
# 1. Buscar "RESUMEN DEL ANÁLISIS Y CONCLUSIONES" o "RESUMEN DEL ANÁLISIS"
for heading in ['RESUMEN DEL ANÁLISIS Y CONCLUSIONES', 'RESUMEN DEL ANÁLISIS',
'RESUMEN DEL ACCIDENTE', '1. RESUMEN', '0.1. RESUMEN']:
idx = text.find(heading)
if idx >= 0:
start = idx + len(heading)
# Buscar siguiente sección (número + título)
next_sec = re.search(r'\n\s*\d+\s*\.\s+[A-ZÁÉÍÓÚÑ]', text[start:start+3000])
end = start + next_sec.start() if next_sec else min(start + 2000, len(text))
candidate = text[start:end].strip()
# FILTROS: eliminar TOC (líneas con puntos), headers de página, appendix inglés
lines = [l.strip() for l in candidate.split('\n')
if '.....' not in l
and not re.match(r'^\d+\s*$', l)
and not re.match(r'^Comisión de Investigación', l, re.I)
and not re.match(r'^[a-z\s]*(?:the |a |an |this )', l.strip(), re.I)] # inglés
if lines:
return ' '.join(lines)[:2000]
⚠️ PITFALL: Muchos informes tienen "RESUMEN DEL ANÁLISIS" como entrada de TOC (con puntos .....39). El filtro '.....' not in l elimina estas entradas.
Enlace directo a PDF (VERIFICADO 2026-06-26):
Los PDFs de CIAF están en: https://www.transportes.gob.es/recursos_mfom/paginabasica/recursos/{filename}.pdf
# Generar enlace directo al PDF original
pagina_ciaf = f"https://www.transportes.gob.es/recursos_mfom/paginabasica/recursos/{pdf_path.name}"
⚠️ NO usar la URL del listing page (/organos-colegiados/ciaf/informes-finales-de-sucesos-investigados) — el usuario quiere enlaces directos al PDF.
Trenes — extraer IDs y tipo (VERIFICADO 2026-06-26):
# Patrones para IDs de tren: "tren de viajeros 8604", "tren 28467", "serie S 120.050"
train_patterns = [
r'tren\s+(?:de\s+)?(?:viajeros|mercanc[ií]as|mantenimiento)\s+(\d{3,6})',
r'tren\s+n[º°]?\s*(\d{3,6})',
r'serie\s+(S?\s*\d{3,5}(?:\.\d{1,3})?)', # "serie S 120.050"
r'(?:locomotora|coche|motive)\s+(?:n[º°]?\s*)?(\d{3,6})',
]
⚠️ NO confundir con texto suelto — el regex anterior capturaba "de", "diésel", "sin" como IDs.
Daños materiales — booleano limpio:
# Si el texto dice "Sí" o "Se produjeron daños" → True
# Si dice "No" o está vacío → False
# NO extraer texto basura del TOC
damages = bool(re.search(r'\b[Ss]í\b|\b[Ss]e\s+produjeron\s+daños', text))
Víctimas — verificar negación primero:
# "sin víctimas mortales" → 0
# "no se produjeron víctimas" → 0
# "una persona fallecida" → 1
# "N fallecidos" → N
if re.search(r'sin\s+v[íi]ctimas|no\s+se\s+produjeron', text_lower):
return 0
Paso 4: Estructuración con LLM
- Schema fijo: Definir antes de procesar. Cada sección del PDF mapea a un campo JSON.
- Prompt: "Estructura el siguiente informe en este schema JSON. No inventes datos."
- Modelo: qwen3.6 vía NaN API (barato y bueno con estructura)
- Validación: JSON schema validation en Python (pydantic)
Paso 5: Almacenamiento
- Opción rápida: SQLite (un archivo, fácil de backup)
- Opción producción: PostgreSQL + PostGIS (geolocalización)
- Tablas mínimas:
informes(id, fecha, tipo, resumo, url_pdf)eventos(informe_id, tipo, descripción, consecuencias)causas(informe_id, tipo, texto)recomendaciones(informe_id, destinatario, contenido, estado)
Paso 6: Visualización
- Mapa: Leaflet.js + puntos geolocalizados
- Gráficos: Chart.js para tendencias temporales
- Filtros: Año, tipo, ubicación, causa, operador
- Drill-down: Clic en punto → ver informe completo
- Vías de tren (si ferroviario): Usar WMS de Tramificación ADIF (
https://ideadif.adif.es/gservices/Tramificacion/wms, capaTramificacion:TramificacionComun) — mucho más detallado que el INSPIRE WMS. Para LTV (limitaciones de velocidad), usar FeatureServer ArcGIS (services7.arcgis.com/.../LTV_2/FeatureServer/0) — ⚠️outSR=4326hace NULL las属性 X/Y, usarf.geometry.coordinates. Verreferences/adif-spatial-data-apis.mdpara URLs, esquemas, pitfalls y código completo. Fallback: OpenStreetMap vía Overpass API → GeoJSON local. No llamar Overpass en tiempo real desde el frontend (lento, rate-limited).
Simplificación de GeoJSON grande (PATRÓN VERIFICADO)
Cuando el GeoJSON de referencia (vías de tren, carreteras, etc.) supera 10MB:
- Reducir precisión de coordenadas a 4 decimales (~11m) — suficiente para visualización
- Eliminar features con <3 coordenadas (tramos irrelevantes)
- Para features largas (>10 coords), keep every 3rd coordinate
- Eliminar propiedades innecesarias (mantener solo: color, weight, name, usage)
- Usar
json.dump(data, f, separators=(',', ':'))para JSON compacto
- Resultado típico: 24MB → 7MB (70% reducción)
- NO servir GeoJSON >10MB en frontend — causa lag en móviles y cargas lentas
Mapeo de campos parser → frontend
El parser genera campos en español (tipo, fecha_suceso, ubicacion.estacion), pero el frontend puede usar campos en inglés (type, date, city). Patrón: añadir capa de mapeo en loadData():
const mapped = yearData.map(r => ({
id: r.id,
year: r.year,
type: r.tipo || 'desconocido',
severity: r.gravedad || 'leve',
date: r.fecha_suceso || '',
city: r.ubicacion?.estacion || r.ubicacion?.provincia || '',
entity: (r.entidades && r.entidades[0]) || '',
lat: r.ubicacion?.lat,
lng: r.ubicacion?.lng,
// ... resto de campos
}));
Esto desacopla el parser del frontend — cada uno puede evolucionar independientemente.
Schema design patterns
Patrón A: Documentos con estructura fija
Cuando el PDF siempre tiene las mismas secciones (título, número, fecha, cuerpo):
{
"meta": { "id": "STRING", "fecha": "DATE", "tipo": "ENUM" },
"contenido": { /* mapeo directo secciones → campos */ },
"análisis": { "causas": [], "recomendaciones": [] }
}
Patrón B: Documentos con estructura variable
Cuando el formato cambia ligeramente entre documentos:
{
"raw_text": "MARCADO: texto original completo",
"extracted_fields": { "campos_confiables": [...] },
"confidence": 0.0-1.0,
"llm_notes": "Notas sobre ambigüedades"
}
Patrón C: Documentos con tablas
Cuando el PDF contiene tablas (estadísticas, presupuestos):
- Extraer tabla con
markitdown→pandas - Validar columnas con schema
- Normalizar tipos de datos
Geolocalización
Extracción de coordenadas
- Nombre de lugar → coordenadas: Nominatim (OpenStreetMap)
- Referencia directa en PDF: regex para "lat: X.X, lon: Y.Y"
- Dirección postal: Nominatim o Google Maps API
⚠️ Geocodificación de estaciones de tren con Nominatim (PATRÓN VERIFICADO)
Nominatim NO encuentra estaciones de tren con queries simples. Patrón que funciona:
import urllib.request, urllib.parse, json, re, time
NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
USER_AGENT = "CIAF-Visor/1.0" # SIN paréntesis — Nominatim rechaza UAs con ()
def geocode_station(station_name: str, province: str = "") -> tuple[float|None, float|None]:
"""Patrón verificado: limit=5 + filtro por tipo railway/train_station."""
clean_name = re.sub(r'\s+', ' ', station_name.strip())
# Estrategia 1: "Estación Nombre España"
queries = [f'{clean_name} España']
# Estrategia 2: Sin calificadores ("Clasificación", "Terminal", etc.)
simplified = re.sub(r'\b(Clasificación|Clasificacion|Terminal|Central|Norte|Sur)\b', '', clean_name).strip()
if simplified and simplified != clean_name:
queries.append(f'{simplified} España')
# Estrategia 3: Solo nombre + provincia
if province:
queries.append(f'{clean_name} {province} España')
for query in queries:
try:
params = urllib.parse.urlencode({
'q': query, 'format': 'json',
'limit': 5, # ⚠️ NO limit=1 — el primer resultado suele ser el barrio/distrito
'countrycodes': 'es',
})
url = f"{NOMINATIM_URL}?{params}"
req = urllib.request.Request(url, headers={'User-Agent': USER_AGENT})
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode())
if data:
# ⚠️ FILTRO CLAVE: priorizar railway/station, NO el primer resultado
best = None
for d in data:
t = d.get('type', '')
c = d.get('class', '')
if t in ('station', 'train_station') and c in ('railway', 'building'):
best = d; break
if c == 'railway':
best = d; break
if 'train' in t.lower() or 'rail' in c.lower():
best = d; break
if not best:
best = data[0] # Fallback al primero
lat = float(best['lat'])
lng = float(best['lon'])
return lat, lng
except Exception:
time.sleep(0.5)
return None, None
Pitfalls Nominatim verificados:
limit=1devuelve el barrio/administración, NO la estación → usarlimit=5y filtrarfeaturecodesparameter NO funciona como se espera → ignorar- Queries complejas ("estacion de tren Madrid Chamartin Madrid") devuelven 0 resultados → queries simples mejor
- "Madrid-Chamartín-Clara Campoamor" tiene
class=building, type=train_station→ buscar ambos - Rate limiting: Nominatim ToS requiere 1 req/seg. Usar
time.sleep(1.1)entre requests - In-memory cache: Usar dict en memoria para no repetir queries. ⚠️ NO cachear None values entre runs del parser — limpiar cache al re-ejecutar
Almacenamiento geográfico
- SQLite:
spatialiteextension para consultas geográficas - PostgreSQL: PostGIS para consultas avanzadas (radio, distancia, buffers)
Pitfalls
- 🔴 La extracción semántica es lo difícil, no la de texto: PyMuPDF/markitdown extraen texto limpio de PDFs, pero convertir texto libre a campos estructurados (fechas, estaciones, entidades, recomendaciones) requiere regex robustos o LLM. Los campos
fecha_suceso,ubicacion.estacion,entidadesyrecomendacionessuelen venir vacíos si el parser solo hace split por secciones. Solución: validar que cada informe tenga ≥3 campos no vacíos; si no, usar LLM para extracción asistida. - 🔴 Extracción por páginas > regex sobre texto completo (VERIFICADO 2026-06-26): El regex sobre texto completo falla porque el TOC se confunde con contenido, headers/footers se mezclan, y secciones en inglés contaminan. Solución: extraer por páginas, detectar secciones por página, limpiar cada página individualmente. Resultado: 100% títulos vs ~60% con regex completo.
- 🔴 Detección de TOC: Líneas con 10+ puntos consecutivos (
.{10,}) son entradas de índice, NO contenido real. Saltar páginas que contengan estas líneas al detectar secciones. - 🔴 SIEMPRE verificar el alcance real antes de proponer arquitectura: No asumir el número de registros basándose solo en lo que hay descargado localmente. Contar los PDFs/disponibles en la web ANTES de diseñar la arquitectura. Ejemplo: asumir 38 PDFs cuando hay 219 en la web → arquitectura equivocada.
- 🔴 Browser tools en webs gubernamentales: La mayoría bloquean browser tools (403). Siempre usar curl con User-Agent:
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" - 🔴 PDFs que no son texto, son imágenes: Si markitdown devuelve texto vacío, el PDF es una escaneada. Usar
ocrmypdf input.pdf output.pdfantes de procesar. - 🔴 Nombres de lugares ambiguos: "Cortes" puede ser de Navarra, de La Muela, etc. Usar contexto del PDF (provincia, región) para desambiguar.
- ⚠️ User-Agent con paréntesis causa 403 en Nominatim (VERIFICADO 2026-06-26):
"CIAF-Visor/1.0 (proyecto educativo)"→ 403 Forbidden."CIAF-Visor/1.0"→ 200 OK. Nominatim rechaza UAs con paréntesis. - ⚠️ Rate limiting Nominatim bloquea IP: Tras ~50 requests en poco tiempo, la IP queda bloqueada (429). Solución:
station-coords.jsonhardcodeado como fuente primaria de geocoding. Nominatim como fallback solo cuando la estación no está en el JSON local. - ⚠️ Cache de geocoding persiste None values: Si el parser cachea resultados de Nominatim en memoria y se ejecuta sin limpiar el cache, los None values de la sesión anterior persisten. Siempre limpiar
__pycache__y el dict de cache al re-ejecutar el parser. - 🔴 Servidor HTTP y rutas relativas: Si el frontend usa
fetch('../data/index.json'), el servidor HTTP DEBE arrancar desde la raíz del proyecto (no desdefrontend/). Si arranca desdefrontend/,../data/queda fuera del root y devuelve 404/HTML. - 🔴 GeoJSON de Overpass API muy grande: 50K features de vías de tren = 24MB raw. Siempre simplificar antes de servir al frontend (reducir coords, eliminar features cortas). Técnica: precision 4 decimales (~11m), eliminar features <3 coords, subsamplear features >10 coords.
- ⚠️ Mapeo de campos parser → frontend: El parser genera campos en español (
tipo,fecha_suceso,ubicacion.estacion), pero el frontend puede usar campos en inglés (type,date,city). Añadir capa de mapeo enloadData()para desacoplar parser de frontend. - ⚠️ Nombres de estación en mayúsculas + provincias (VERIFICADO 2026-06-29): El visor puede tener estaciones en MAYÚSCULAS COMPLETAS ("MADRID-CHAMARTÍN") o con provincias entre paréntesis ("Pradell de la Teca (Tarragona)"). Limpieza:
.title()+ strip de paréntesis provinciales + truncar en puntos/comas + limpiar patrones PK. Verreferences/station-name-cleanup.md. - ⚠️ Geocodificación por PK via LTV (VERIFICADO 2026-06-29): El FeatureServer LTV de ADIF tiene ~1162 puntos con PKINI/PKFIN + coordenadas. Para un PK dado, encontrar el punto cuyo rango lo contiene. 49/61 líneas CIAF tienen cobertura LTV → 71% geocodificación. Las líneas sin cobertura son regionales cortas (Cercanías, FEVE, ramales). Pitfall:
outSR=4326hace NULL las propiedades X/Y — usar SIEMPREgeometry.coordinates. Verreferences/adif-spatial-data-apis.mdyreferences/excel-json-cross-reference.md. - ⚠️ Estrategia de geocodificación multi-fuente (VERIFICADO 2026-06-29): Cuando la geolocalización es crítica pero la precisión varía: (1) Excel con lat/lng reales → usar directo, (2) PK + línea → interpolar LTV (precisión ~100m), (3) nombre estación → DB local (station-coords.json, 328 entradas) + Nominatim fallback, (4) fix manual para los últimos 4-5. Orden de prioridad siempre: fuente primaria > interpolación > geocoding > manual. Resultado CIAF: de 3% a 100% geolocalizados.
- 🔴 Campo
titulono mapeado en frontend (VERIFICADO 2026-06-26): El parser escribetituloen JSON pero el frontend no lo incluye en el data mapping (loadData()). Resultado: panel de detalle muestra"Informe 64/2024"en vez del título descriptivo. Regla: cuando se añade un campo nuevo al parser, SIEMPRE verificar que el frontend lo mapea enloadData(). Patrón: añadirtitulo: r.titulo || ''en el objeto mapped. - 🔴 SUBAGENTE QUE INVENTA DATOS PARA AÑOS SIN FUENTE (VERIFICADO 2026-06-26): Cuando se delega parseo de PDFs a subagentes, estos pueden crear JSONs con datos fabricados para años donde no existe el PDF fuente. Ejemplo: 17 memorias reales (2008-2024) + subagent genera 2025.json fabricado (sin PDF) y 2007.json con números inventados. Detección: después de delegar, verificar que cada JSON generado tiene un PDF correspondiente en
pdfs/.os.path.exists()para cada año. Corrección: eliminar JSONs sin fuente. Regla: SIEMPRE validar que el subagente tiene acceso al PDF antes de confiar en su output. Los subagentes no distinguen "no pude parsear" de "inventé datos". - ⚠️ Contar solo la primera entidad en array (VERIFICADO 2026-06-26): Cuando un informe tiene
entities: ["ADIF", "Renfe"], usar solor.entity(=r.entities[0]) para KPIs subcuenta entidades. KPI mostraba 2 entidades cuando había 17 reales. Corrección: iterar sobre TODO el arrayr.entities, no solo el primero. Patrón:new Set(reports.flatMap(r => r.entities || [r.entity])). - 🔴 LLM fragmenta párrafos en líneas sueltas (VERIFICADO 2026-06-28): Cuando el pipeline LLM extrae texto de conclusiones/recomendaciones, puede partir cada párrafo en líneas individuales de ~60 chars. El 49.8% de los informes CIAF tenía exactamente 20 items (corte artificial del pipeline), cada uno era un bullet suelto en vez de un párrafo coherente. Detección: items < 100 chars que no terminan en punto, o exactamente 20 items (corte del pipeline). Solución: script de re-combinación que une líneas por puntuación (línea termina sin punto → siguiente es continuación), detecta párrafos por mayúsculas tras punto, y limpia headers embebidos (5.1. RESUMEN, RECOMENDACIONES). Ver
references/llm-text-recombination.mdpara el algoritmo completo. Multi-pass: pasada 1 (re-combinación), pasada 2 (limpieza de headers), pasada 3 (limpieza final de artefactos). Resultado CIAF: de 1022 items sueltos a ~600 párrafos coherentes (230 chars/item promedio). - ⚠️ Fusión de datasets con esquemas diferentes (VERIFICADO 2026-06-27): Cuando existen dos fuentes de datos para el mismo conjunto (ej: CIAF-visor con esquema rico + ciaf-data con esquema simple), fusionar selectivamente: (1) usar el esquema más rico como base, (2) mejorar solo los campos débiles del dataset base con los valores del otro, (3) NUNCA sobrescribir campos que ya están completos. Resultado CIAF: 62/270 informes mejorados, +111 conclusiones, +59 recomendaciones. Clave de emparejamiento fiable: número de expediente (ej:
0062/2007), extraído con regexNº?\\\\s*(\\\\d{3,4}/\\\\d{4})del título. Verreferences/data-enrichment-merging.mdpara el patrón completo con código. - ⚠️ Dos conjuntos de datos para el mismo proyecto (VERIFICADO 2026-06-29): El visor CIAF lee de
data/reports/YYYY.json, mientras el pipeline generaindividual/*.json. Son estructuras diferentes (schema del visor vs schema del pipeline). Si se corrigen los individual, HAY QUE propagar los cambios al visor. No asumir sincronización automática. Script de sync: cruzar por expediente, aplicar correcciones al visor, backup antes de batch. Verreferences/visor-data-batch-fix.md. - ⚠️ Severidad "fatal"/"leve" del visor vs RD 929/2022 (VERIFICADO 2026-06-29): El visor original usa vocabulario legacy ("fatal"=199, "leve"=68, "grave"=2) pero la taxonomía oficial del RD 929/2022 es "muy grave"/"grave"/"menor". Siempre recalcular desde datos numéricos (víctimas) cuando haya fuente Excel. Mapeo: fatal→muy grave, leve→menor, grave→grave. El index.json del visor TAMBIÉN contiene gravedad — actualizar ahí también.
- ⚠️ LIMPIEZA AGRESIVA DESTRUYÓ NOMBRES REALES (VERIFICADO 2026-06-29): La limpieza de estaciones que elimina paréntesis y trunca en "de" destruyó nombres reales: "Vila-real (Castellón)" → "Vila", "Sama de Langreo (Asturias)" → "La". Reglas: (1) eliminar SOLO provincia entre paréntesis al FINAL, (2) preservar "de", "del", "la", "el", (3) si el resultado es < 4 chars, devolver el original y extraer del resumen. Ver
references/station-name-cleanup.mdsección "LIMPIEZA DEMASIADO AGRESIVA". - ⚠️ Excel tiene provincias equivocadas (VERIFICADO 2026-06-29): El Excel tiene la provincia incorrecta para ~25% de los registros. La fuente fiable es el resumen del informe PDF. SIEMPRE cruzar provincia desde el resumen cuando esté disponible. Ver
references/excel-json-cross-reference.mdsección 8. - **⚠️ Cruce Excel ↔
…(truncated)