""" ======================================================================== Parser de bases de convocatoria Buscador Multisearch — Intervención 4 (extracción del temario) ======================================================================== Procesa el PDF de unas bases de convocatoria y extrae: 1. Datos estructurados de la convocatoria: - Número de plazas por turno (libre, promoción interna, discapacidad) - Sistema selectivo (oposición, concurso-oposición, etc.) - Número y descripción de ejercicios - Tasa de derechos de examen - Composición del tribunal (si aparece) - Requisitos de titulación 2. Temario completo estructurado: - Localiza el ANEXO con el programa/temario - Separa parte general y parte específica - Extrae cada tema como (número, título) Uso típico: from parser_bases import ParserBases parser = ParserBases() resultado = parser.parsear('/ruta/al/pdf_bases.pdf') print(f"Plazas turno libre: {resultado.num_plazas_libre}") print(f"Temas extraídos: {len(resultado.temas)}") for tema in resultado.temas[:5]: print(f" {tema.parte} · Tema {tema.numero}: {tema.titulo[:80]}") Dependencias: pip install pdfplumber pdfminer.six python-dateutil """ import re import hashlib from dataclasses import dataclass, field from typing import Optional from datetime import date import pdfplumber # ======================================================================== # Estructuras de datos # ======================================================================== @dataclass class Tema: """Un tema individual del temario.""" numero: int titulo: str parte: str = "general" # general / especifica / idiomas / practica numero_completo: Optional[str] = None texto_completo: Optional[str] = None @dataclass class Ejercicio: """Un ejercicio de la fase de oposición.""" numero: int tipo: str # test, desarrollo, oral, practico, idioma, fisico descripcion: str preguntas: Optional[int] = None duracion_minutos: Optional[int] = None eliminatorio: Optional[bool] = None puntuacion_maxima: Optional[float] = None @dataclass class ResultadoParser: """Resultado completo del parsing del PDF de bases.""" # Datos de plazas num_plazas_total: Optional[int] = None num_plazas_libre: Optional[int] = None num_plazas_promo_int: Optional[int] = None num_plazas_discap: Optional[int] = None num_plazas_discap_int: Optional[int] = None # Proceso selectivo sistema_selectivo: Optional[str] = None ejercicios: list = field(default_factory=list) # Tasas tasa_examen: Optional[float] = None moneda_tasa: str = "EUR" # Tribunal tribunal_presidente: Optional[str] = None tribunal_secretario: Optional[str] = None tribunal_vocales: list = field(default_factory=list) # Requisitos requisitos_titulacion: list = field(default_factory=list) requisitos_otros: Optional[str] = None # Temario temas: list = field(default_factory=list) num_temas_total: int = 0 num_temas_parte_general: int = 0 num_temas_parte_especifica: int = 0 paginas_temario_inicio: Optional[int] = None paginas_temario_fin: Optional[int] = None # Metadatos paginas_pdf_total: int = 0 hash_estructura: Optional[str] = None parser_ok: bool = False errores: list = field(default_factory=list) # ======================================================================== # Parser principal # ======================================================================== class ParserBases: """Extrae datos estructurados y temario de un PDF de bases.""" PARSER_VERSION = "1.0" # Patrones para localizar secciones del temario PATRONES_INICIO_TEMARIO = [ r'ANEXO\s+I\s*[\.\:]?\s*(?:PROGRAMA|TEMARIO)', r'ANEXO\s+II\s*[\.\:]?\s*(?:PROGRAMA|TEMARIO)', r'ANEXO\s*[\.\:]?\s*(?:PROGRAMA|TEMARIO)', r'PROGRAMA\s+DE\s+LA\s+(?:FASE\s+DE\s+)?OPOSICI[OÓ]N', r'TEMARIO\s+DE\s+LA\s+(?:FASE\s+DE\s+)?OPOSICI[OÓ]N', r'^\s*PROGRAMA\s*$', r'^\s*TEMARIO\s*$', ] # Patrones para identificar cada tema PATRONES_TEMA = [ re.compile(r'^\s*Tema\s+(\d+)\s*[\.\-]\s*(.+?)(?=^\s*Tema\s+\d+|$)', re.MULTILINE | re.DOTALL | re.IGNORECASE), re.compile(r'^\s*T\s*\.?\s*(\d+)\s*[\.\-]\s*(.+?)(?=^\s*T\s*\.?\s*\d+|$)', re.MULTILINE | re.DOTALL | re.IGNORECASE), re.compile(r'^\s*(\d+)\s*[\.\-]\s+([A-ZÁÉÍÓÚÑ][^\n]{20,}?)$', re.MULTILINE), ] # Patrones para separar parte general/específica PATRONES_PARTE = { 'general': [ r'PARTE\s+(?:GENERAL|PRIMERA|I)\b', r'BLOQUE\s+(?:I|1|GENERAL)\b', r'MATERIAS?\s+COMUNES', ], 'especifica': [ r'PARTE\s+(?:ESPEC[IÍ]FICA|SEGUNDA|II)\b', r'BLOQUE\s+(?:II|2|ESPEC[IÍ]FICO)\b', r'MATERIAS?\s+ESPEC[IÍ]FICAS', ], 'practica': [ r'PARTE\s+PR[AÁ]CTICA', r'EJERCICIO\s+PR[AÁ]CTICO', ], 'idiomas': [ r'PRUEBA\s+DE\s+(?:CATAL[AÁ]N|VALENCIANO|EUSKERA|GALLEGO|IDIOMA)', r'CONOCIMIENTO\s+DE\s+(?:CATAL[AÁ]N|VALENCIANO|EUSKERA|GALLEGO)', ], } # ---------------------------------------------------------------- # API pública # ---------------------------------------------------------------- def parsear(self, ruta_pdf: str) -> ResultadoParser: """Punto de entrada principal.""" resultado = ResultadoParser() try: with pdfplumber.open(ruta_pdf) as pdf: resultado.paginas_pdf_total = len(pdf.pages) # Extraer todo el texto del PDF texto_completo, texto_por_pagina = self._extraer_texto(pdf) # 1. Datos estructurados de la convocatoria self._extraer_plazas(texto_completo, resultado) self._extraer_sistema_selectivo(texto_completo, resultado) self._extraer_ejercicios(texto_completo, resultado) self._extraer_tasa(texto_completo, resultado) self._extraer_tribunal(texto_completo, resultado) self._extraer_requisitos(texto_completo, resultado) # 2. Localizar y extraer el temario inicio, fin = self._localizar_temario(texto_por_pagina) if inicio is not None: resultado.paginas_temario_inicio = inicio + 1 # 1-indexed resultado.paginas_temario_fin = (fin + 1) if fin else None texto_temario = self._extraer_texto_temario( texto_por_pagina, inicio, fin) resultado.temas = self._extraer_temas(texto_temario) resultado.num_temas_total = len(resultado.temas) resultado.num_temas_parte_general = sum( 1 for t in resultado.temas if t.parte == 'general') resultado.num_temas_parte_especifica = sum( 1 for t in resultado.temas if t.parte == 'especifica') else: resultado.errores.append('No se localizó la sección de temario') # Hash de la estructura para deduplicación resultado.hash_estructura = self._calcular_hash_estructura( resultado.temas) resultado.parser_ok = ( resultado.num_temas_total > 0 or resultado.num_plazas_total is not None ) except Exception as e: resultado.errores.append(f'Error general: {type(e).__name__}: {e}') resultado.parser_ok = False return resultado # ---------------------------------------------------------------- # Extracción de texto # ---------------------------------------------------------------- def _extraer_texto(self, pdf) -> tuple: """Devuelve (texto_concatenado, lista_de_textos_por_pagina).""" por_pagina = [] for page in pdf.pages: try: t = page.extract_text() or '' except Exception: t = '' por_pagina.append(t) completo = '\n'.join(por_pagina) return completo, por_pagina # ---------------------------------------------------------------- # Extracción de plazas # ---------------------------------------------------------------- def _extraer_plazas(self, texto: str, r: ResultadoParser) -> None: """Extrae número de plazas por turno.""" # Total: "N plazas", "número total de plazas: N" for patron in [ r'(?:n[uú]mero\s+total\s+de\s+plazas|total\s+de\s+plazas)[\s:]+(\d+)', r'(?:se\s+convocan?|para\s+cubrir)\s+(\d+)\s+plazas?', r'(\d+)\s+plazas?\s+(?:vacantes|de\s+nuevo\s+ingreso)', ]: m = re.search(patron, texto, re.IGNORECASE) if m: r.num_plazas_total = int(m.group(1)) break # Turno libre for patron in [ r'(\d+)\s+plazas?\s+(?:de\s+)?(?:turno\s+libre|acceso\s+libre)', r'turno\s+libre[\s:]+(\d+)', r'acceso\s+libre[\s:]+(\d+)', ]: m = re.search(patron, texto, re.IGNORECASE) if m: r.num_plazas_libre = int(m.group(1)) break # Promoción interna for patron in [ r'(\d+)\s+plazas?\s+(?:de\s+)?promoci[oó]n\s+interna', r'promoci[oó]n\s+interna[\s:]+(\d+)', ]: m = re.search(patron, texto, re.IGNORECASE) if m: r.num_plazas_promo_int = int(m.group(1)) break # Reserva discapacidad for patron in [ r'(\d+)\s+plazas?.*reserva.*discapacidad', r'reserva.*discapacidad[\s:]+(\d+)', r'(\d+)\s+plazas?\s+para\s+personas\s+con\s+discapacidad', ]: m = re.search(patron, texto, re.IGNORECASE) if m: r.num_plazas_discap = int(m.group(1)) break # ---------------------------------------------------------------- # Extracción de sistema selectivo # ---------------------------------------------------------------- def _extraer_sistema_selectivo(self, texto: str, r: ResultadoParser) -> None: """Identifica oposición / concurso-oposición / concurso.""" texto_lower = texto.lower() # Buscar en los primeros 5000 caracteres (suele estar al principio) cabecera = texto_lower[:5000] if 'concurso-oposici' in cabecera or 'concurso oposici' in cabecera: r.sistema_selectivo = 'concurso_oposicion' elif re.search(r'\bsistema\s+de\s+concurso\b', cabecera) \ or 'concurso de m[eé]ritos' in cabecera: r.sistema_selectivo = 'concurso_meritos' elif 'oposici[oó]n libre' in cabecera or 'oposici[oó]n' in cabecera: r.sistema_selectivo = 'oposicion' # ---------------------------------------------------------------- # Extracción de ejercicios # ---------------------------------------------------------------- def _extraer_ejercicios(self, texto: str, r: ResultadoParser) -> None: """Extrae descripción de los ejercicios.""" # Patrones de tipo "Primer ejercicio", "Ejercicio 1", "Prueba primera" regex_ejercicio = re.compile( r'(?:^|\n)\s*(?:(?:Primer|Segundo|Tercer|Cuarto|Quinto)\s+ejercicio|' r'Ejercicio\s+(?:primero|segundo|tercero|cuarto|quinto|\d+)|' r'(?:Primera|Segunda|Tercera|Cuarta|Quinta)\s+prueba)\b' r'[\.:\-]?\s*(.{20,500}?)(?=(?:\n\s*(?:Primer|Segundo|Tercer|Cuarto|Quinto|Sexto)\s+ejercicio|' r'\n\s*Ejercicio\s+\w+|' r'\n\s*(?:Primera|Segunda|Tercera|Cuarta|Quinta|Sexta)\s+prueba|$))', re.IGNORECASE | re.DOTALL ) ejercicios_brutos = regex_ejercicio.findall(texto[:50000]) # Limitar análisis a primeros 50K chars (suele ser donde se describen) for i, descripcion in enumerate(ejercicios_brutos, 1): desc_lower = descripcion.lower() # Detectar tipo if 'test' in desc_lower or 'tipo test' in desc_lower: tipo = 'test' elif 'oral' in desc_lower or 'exposici[oó]n' in desc_lower: tipo = 'oral' elif 'pr[aá]ctico' in desc_lower or 'caso pr[aá]ctico' in desc_lower: tipo = 'practico' elif 'idioma' in desc_lower or 'catal[aá]n' in desc_lower or \ 'valenciano' in desc_lower or 'euskera' in desc_lower: tipo = 'idioma' elif 'f[ií]sic' in desc_lower: tipo = 'fisico' else: tipo = 'desarrollo' # Número de preguntas (si es test) preguntas = None m_preg = re.search(r'(\d+)\s+preguntas?', descripcion, re.IGNORECASE) if m_preg: preguntas = int(m_preg.group(1)) # Duración duracion = None m_dur = re.search( r'(?:duraci[oó]n|tiempo)[\s:]+(\d+)\s+(?:minutos|horas)', descripcion, re.IGNORECASE ) if m_dur: duracion = int(m_dur.group(1)) if 'hora' in m_dur.group(0).lower(): duracion *= 60 r.ejercicios.append(Ejercicio( numero=i, tipo=tipo, descripcion=descripcion.strip()[:500], preguntas=preguntas, duracion_minutos=duracion, )) # ---------------------------------------------------------------- # Extracción de tasa # ---------------------------------------------------------------- def _extraer_tasa(self, texto: str, r: ResultadoParser) -> None: """Extrae la tasa de derechos de examen.""" patrones = [ r'tasa\s+(?:de\s+)?(?:derechos\s+de\s+)?examen[\s:]+(\d+[,.]?\d*)\s*(?:€|euros)', r'derechos\s+de\s+examen[\s:]+(\d+[,.]?\d*)\s*(?:€|euros)', r'importe[\s:]+(\d+[,.]?\d*)\s*(?:€|euros)', r'(\d+[,.]?\d*)\s+euros?\s+en\s+concepto\s+de\s+(?:tasa|derechos)', ] for p in patrones: m = re.search(p, texto, re.IGNORECASE) if m: valor = m.group(1).replace(',', '.') try: r.tasa_examen = float(valor) except ValueError: pass break # ---------------------------------------------------------------- # Extracción de tribunal # ---------------------------------------------------------------- def _extraer_tribunal(self, texto: str, r: ResultadoParser) -> None: """Extrae composición del tribunal si aparece en las bases.""" # Buscar sección "Tribunal" o "Composición del tribunal" regex_seccion = re.compile( r'(?:composici[oó]n\s+del\s+)?tribunal\s+(?:calificador|de\s+selecci[oó]n)[\s\S]{0,3000}', re.IGNORECASE ) m = regex_seccion.search(texto) if not m: return seccion = m.group(0) # Presidente m_pres = re.search( r'presidente[\s:]+([A-ZÁÉÍÓÚÑa-záéíóúñ\s,\.]{5,80}?)(?=\n|;|secretari)', seccion, re.IGNORECASE ) if m_pres: r.tribunal_presidente = m_pres.group(1).strip() # Secretario m_sec = re.search( r'secretari[oa][\s:]+([A-ZÁÉÍÓÚÑa-záéíóúñ\s,\.]{5,80}?)(?=\n|;|vocal)', seccion, re.IGNORECASE ) if m_sec: r.tribunal_secretario = m_sec.group(1).strip() # Vocales: lista regex_vocales = re.findall( r'vocal(?:es)?[\s:]+([A-ZÁÉÍÓÚÑa-záéíóúñ\s,\.]{5,80}?)(?=\n|;)', seccion, re.IGNORECASE ) r.tribunal_vocales = [v.strip() for v in regex_vocales] # ---------------------------------------------------------------- # Extracción de requisitos # ---------------------------------------------------------------- def _extraer_requisitos(self, texto: str, r: ResultadoParser) -> None: """Extrae requisitos de titulación.""" # Buscar sección de requisitos regex_seccion = re.compile( r'(?:requisitos|condiciones)\s+(?:de\s+los\s+aspirantes|para\s+participar)[\s\S]{0,3000}', re.IGNORECASE ) m = regex_seccion.search(texto) if not m: return seccion = m.group(0) # Buscar menciones a titulaciones titulaciones = re.findall( r'(?:grado|licenciatura|diplomatura|t[ií]tulo\s+(?:de|en|universitario))\s+(?:en|de)?\s*([A-ZÁÉÍÓÚÑ][^,\.\n]{5,80})', seccion, re.IGNORECASE ) r.requisitos_titulacion = [t.strip() for t in titulaciones[:10]] # ---------------------------------------------------------------- # Localización del temario # ---------------------------------------------------------------- def _localizar_temario(self, texto_por_pagina: list) -> tuple: """Encuentra las páginas donde empieza y termina el temario. Devuelve (indice_pagina_inicio, indice_pagina_fin) — None si no lo encuentra. """ inicio = None for i, texto_pagina in enumerate(texto_por_pagina): for patron in self.PATRONES_INICIO_TEMARIO: if re.search(patron, texto_pagina, re.IGNORECASE | re.MULTILINE): inicio = i break if inicio is not None: break if inicio is None: return None, None # El temario suele acabar al final del documento o cuando aparece # otro ANEXO distinto. Buscamos siguiente "ANEXO" tras el de temario. fin = len(texto_por_pagina) - 1 for i in range(inicio + 1, len(texto_por_pagina)): texto_pagina = texto_por_pagina[i] # Otro ANEXO que NO sea el de temario if re.search(r'^\s*ANEXO\s+(?:II|III|IV|V|VI)\b', texto_pagina, re.MULTILINE | re.IGNORECASE) \ and not re.search(r'PROGRAMA|TEMARIO', texto_pagina[:500], re.IGNORECASE): fin = i - 1 break return inicio, fin def _extraer_texto_temario(self, texto_por_pagina: list, inicio: int, fin: int) -> str: """Concatena el texto de las páginas del temario.""" if fin is None: fin = len(texto_por_pagina) - 1 return '\n'.join(texto_por_pagina[inicio:fin + 1]) # ---------------------------------------------------------------- # Extracción de temas individuales # ---------------------------------------------------------------- def _extraer_temas(self, texto_temario: str) -> list: """Extrae lista de Tema objects del texto del temario.""" temas = [] # Detectar marcadores de parte general/específica marcadores_parte = self._detectar_marcadores_parte(texto_temario) # Intentar cada patrón hasta encontrar uno que dé resultados for regex in self.PATRONES_TEMA: coincidencias = regex.findall(texto_temario) if len(coincidencias) >= 3: # al menos 3 temas para ser válido for numero_str, titulo_bruto in coincidencias: numero = int(numero_str) # Limpiar título: quitar saltos de línea internos, espacios titulo = re.sub(r'\s+', ' ', titulo_bruto).strip() # Truncar si es excesivamente largo (probable solapamiento) if len(titulo) > 500: titulo = titulo[:500] + '...' # Determinar parte en función de la posición en el texto parte = self._determinar_parte( numero, titulo, texto_temario, marcadores_parte ) temas.append(Tema( numero=numero, titulo=titulo, parte=parte, )) break # Si hay temas con número duplicado, probablemente se mezclaron # parte general y específica. Re-numerar. temas = self._normalizar_numeracion(temas) return temas def _detectar_marcadores_parte(self, texto: str) -> dict: """Devuelve dict {parte: posicion_en_texto} para cada parte detectada.""" marcadores = {} for parte, patrones in self.PATRONES_PARTE.items(): for patron in patrones: m = re.search(patron, texto, re.IGNORECASE) if m: marcadores[parte] = m.start() break return marcadores def _determinar_parte(self, numero: int, titulo: str, texto: str, marcadores: dict) -> str: """Determina a qué parte pertenece un tema según su posición.""" if not marcadores: return 'general' # Buscar posición del tema en el texto (aproximada) # Esto es heurístico: usa el número del tema y el título como ancla try: posicion = texto.find(titulo[:30]) except Exception: posicion = -1 if posicion < 0: return 'general' # Determinar qué marcador es el último que apareció antes de la posición partes_ordenadas = sorted(marcadores.items(), key=lambda kv: kv[1]) parte_actual = 'general' for parte, pos in partes_ordenadas: if pos < posicion: parte_actual = parte return parte_actual def _normalizar_numeracion(self, temas: list) -> list: """Detecta y corrige numeraciones duplicadas entre partes.""" # Si hay temas con mismo número y misma parte = error de parsing # Si hay temas con mismo número y distinta parte = normal # (cada parte tiene su numeración propia) return temas # ---------------------------------------------------------------- # Hash de estructura # ---------------------------------------------------------------- def _calcular_hash_estructura(self, temas: list) -> str: """Hash SHA-256 de los títulos normalizados. Permite detectar temarios que son idénticos al carácter.""" if not temas: return '' contenido = '\n'.join( f'{t.parte}|{t.numero}|{t.titulo.lower().strip()}' for t in temas ) return hashlib.sha256(contenido.encode('utf-8')).hexdigest() # ======================================================================== # Ejemplo de uso # ======================================================================== if __name__ == '__main__': import sys import json if len(sys.argv) < 2: print("Uso: python 04_parser_bases.py /ruta/al/pdf_bases.pdf") sys.exit(1) parser = ParserBases() resultado = parser.parsear(sys.argv[1]) print(f"=== RESUMEN DEL PARSING ===") print(f"Parser OK: {resultado.parser_ok}") print(f"Páginas PDF: {resultado.paginas_pdf_total}") print(f"") print(f"Plazas: total={resultado.num_plazas_total}, " f"libre={resultado.num_plazas_libre}, " f"PI={resultado.num_plazas_promo_int}, " f"discap={resultado.num_plazas_discap}") print(f"Sistema: {resultado.sistema_selectivo}") print(f"Ejercicios: {len(resultado.ejercicios)}") for ej in resultado.ejercicios: print(f" {ej.numero}. {ej.tipo}: {ej.descripcion[:80]}") print(f"Tasa: {resultado.tasa_examen} {resultado.moneda_tasa}") print(f"") print(f"=== TEMARIO ===") print(f"Páginas: {resultado.paginas_temario_inicio}-{resultado.paginas_temario_fin}") print(f"Total temas: {resultado.num_temas_total}") print(f" Parte general: {resultado.num_temas_parte_general}") print(f" Parte específica: {resultado.num_temas_parte_especifica}") print(f"") for t in resultado.temas[:10]: print(f" [{t.parte}] Tema {t.numero}: {t.titulo[:80]}") if len(resultado.temas) > 10: print(f" ... y {len(resultado.temas) - 10} más") if resultado.errores: print(f"") print(f"=== ERRORES ===") for e in resultado.errores: print(f" - {e}")