La Plataforma de Contratación del Sector Público (PLACSP) es el nodo central de transparencia en España. Para garantizar que los datos sean procesables por máquinas de forma inequívoca, la plataforma utiliza el estándar CODICE, basado en UBL (Universal Business Language).
Cuando extraemos datos de la PLACSP, nos encontramos con columnas que contienen códigos técnicos en lugar de descripciones humanas. Por ejemplo:
- En la columna "Tipo de Contrato", vemos un
1en lugar de "Suministros". - En la columna "Estado", vemos
ADJen lugar de "Adjudicada". - En la columna "Financiación", vemos
EUen lugar de "Fondos de la Unión Europea".
Estos códigos, por sí solos, son "ruido" para un analista de negocio. Sin embargo, en el XML original, estos códigos no vienen solos; están asociados a un atributo llamado listURI. Este atributo es una dirección URL que apunta a un archivo con extensión .gc, conocido como Genericode.
Un Genericode es un estándar de OASIS que define una lista de códigos de forma tabular en formato XML. Es, esencialmente, un "diccionario" externo que dice: "Para esta lista específica, el código '1' significa 'Suministros'".
Nuestro objetivo es automatizar la descarga de estos diccionarios y su aplicación masiva sobre los datos de licitaciones.
Para respetar estrictamente la Arquitectura Medallion de cuatro capas, la gestión de los Genericodes se divide en dos scripts y dos capas:
- Capa Raw (
download_gc_codes.py): descarga los archivos.gcoriginales del repositorio CODICE y los almacena enraw/data/gc_codes/sin modificarlos. - Capa Bronze (
extract_gc_codes.py): lee esos archivos desde Raw, parsea su contenido conlxmly produce el catálogo consolidadobronze/data/gc_codes.parquet.
Esta separación aplica el mismo principio que rige el resto del pipeline: Raw contiene los datos originales inmutables; Bronze es la primera capa de procesamiento. Si el repositorio CODICE actualiza un catálogo, basta con volver a ejecutar download_gc_codes.py y después extract_gc_codes.py sin tocar ninguna otra capa.
Aunque nuestro proyecto es un Data Lake de Big Data, para estas dos tareas específicas hemos evitado el uso de Apache Spark, optando por Python nativo (requests + lxml + Pandas) por tres razones clave.
- Overhead (Sobrecarga): Spark es un motor distribuido. Para funcionar, necesita iniciar una Máquina Virtual de Java (JVM), coordinar varios procesos y gestionar memoria en clúster. Si el volumen de datos es pequeño (como los 10MB de los diccionarios), Spark tarda más tiempo en "arrancar" que Python en procesar todo el trabajo. Es lo que llamamos "matar moscas a cañonazos".
- Namespaces (Espacios de Nombres): Los archivos XML de los Genericodes son complejos y utilizan múltiples namespaces (prefijos que evitan colisiones de nombres). La librería
lxmlpermite gestionar estos prefijos de forma nativa y muy eficiente. - XPath: Es un lenguaje de consultas para XML (como SQL para tablas).
lxmltiene un motor XPath muy rápido que nos permite ir directamente al dato que queremos sin leer todo el archivo línea a línea.
El script raw/scripts/download_gc_codes.py realiza una exploración dinámica del repositorio oficial de CODICE y descarga todos los archivos .gc a raw/data/gc_codes/. Concretamente:
-
Navegación por carpetas de versiones: El script accede a la URL base de CODICE (
https://contrataciondelestado.es/codice/cl/). Como los diccionarios están organizados por versiones (1.04/,2.07/,2.08/...), el script descubre todas las subcarpetas y dentro de cada una localiza los archivos que terminan en.gc. -
Descarga del contenido: Para cada archivo
.gcencontrado, realiza una petición HTTP y recupera el contenido en memoria. -
Corrección de
LocationUriconlxml: Antes de guardar el fichero en disco, el script lo parsea conlxml, localiza el nodo//Identification/LocationUriy lo sobreescribe con la URL real de descarga:tree = etree.parse(BytesIO(resp.content)) nodes = tree.xpath('.//Identification/LocationUri') if nodes: nodes[0].text = gc_uri # URL real de descarga tree.write(path, encoding='utf-8', xml_declaration=True, pretty_print=True)
Este paso es necesario porque algunos ficheros
.gcdel repositorio CODICE contienen unaLocationUriinterna incorrecta que apunta a un fichero diferente al que fue realmente descargado (ver §4.5 para el detalle de los ocho casos afectados). Corregir el campo en origen garantiza que todos los ficheros almacenados en Raw son autocongruentes: suLocationUrisiempre coincide con la URL real desde la que fueron obtenidos. -
Almacenamiento en disco: El fichero se guarda en
raw/data/gc_codes/con su nombre original.
El resultado es un directorio raw/data/gc_codes/ con todos los archivos .gc del repositorio CODICE, con LocationUri corregida y listos para ser procesados por la capa Bronze.
El script bronze/scripts/extract_gc_codes.py lee los archivos .gc almacenados en Raw, parsea su contenido XML con lxml y produce un catálogo consolidado en bronze/data/gc_codes.parquet. Los pasos son:
- Lectura desde Raw: El script itera sobre todos los archivos
.gcpresentes enraw/data/gc_codes/sin volver a realizar ninguna petición HTTP. - Parseo de Metadatos: Para cada archivo, extrae:
ShortName: El nombre corto que identifica al catálogo (ej.ContractFolderStatusCode).Version: La versión del catálogo (ej.2.07). Combinado conShortNameproduce la clave{ShortName}_{Version}(ej.NUTS_2021).LongName: La descripción humana del catálogo, buscando específicamente la etiqueta en español (xml:lang="es").LocationUri: La URI canónica del catálogo, de la que se extrae la ruta relativa para el join posterior.
- Extracción de Filas (
Row): Dentro del XML, cada pareja código-descripción está dentro de un nodo<Row>. El script busca las columnas que contienen la clave (codeocodeKey) y el valor (nombre). - Serialización JSON: Una decisión de diseño importante fue guardar la columna de códigos como un String JSON.
- ¿Por qué? Porque cada catálogo tiene un número distinto de códigos. Usar un String JSON nos permite guardar diccionarios complejos dentro de una celda de una tabla de forma flexible, sin tener que definir una estructura rígida de base de datos para cada catálogo diferente.
- Formato Parquet: Finalmente, guardamos todo en un archivo Parquet. Parquet es un formato columnar y comprimido que es el estándar de oro en Big Data por su alta velocidad de lectura.
Una vez que tenemos nuestro diccionario en Parquet, debemos aplicarlo a millones de licitaciones en la Capa Silver. Aquí sí usamos Spark, porque el volumen de licitaciones sí justifica la computación distribuida. El reto aquí es el rendimiento: traducir más de 30 columnas mediante JOINs tradicionales provocaría un shuffling masivo de datos, degradando el rendimiento del clúster de Spark.
Esta función prepara los datos para que Spark pueda usarlos eficientemente.
from_json: Como guardamos los códigos en JSON en la capa Bronze, aquí le decimos a Spark: "Coge este texto y conviértelo otra vez en un mapa de claves y valores".explode: Esta función "explota" los mapas. Si un catálogo tiene 10 códigos,explodecrea 10 filas, una para cada código. Al final, tenemos una tabla simple con tres columnas:cat_uri(la URL del diccionario),cat_codigo(ej. '1') ycat_descripcion(ej. 'Suministros').cache()(Persistencia en Memoria): Este es uno de los conceptos más importantes. Spark normalmente no guarda los datos en memoria; los lee, los procesa y los olvida. Al llamar a.cache(), obligamos a Spark a mantener el diccionario en la RAM de todos los nodos. Como vamos a usar este diccionario para traducir cuatro tablas distintas, cachearlo ahorra tiempo de lectura y computación en cada paso.
Esta función realiza la traducción real. Para evitar el Shuffling (el lento intercambio de datos entre nodos a través de la red), utilizamos una estrategia de Broadcast Join.
map_from_entries: Convertimos nuestra tabla de catálogo en un "Mapa de Búsqueda" global. La clave es una combinación deURI|CÓDIGO. Esto es crucial porque el código1puede significar cosas distintas en diferentes diccionarios. Al concatenar la URI, creamos una clave unívoca.broadcast: Spark envía una copia completa de este mapa a cada servidor del clúster.- Búsqueda O(1) (Tiempo Constante): En lugar de hacer una unión de tablas tradicional (JOIN), cada servidor consulta su copia local del mapa en memoria. Esto es instantáneo (complejidad O(1)), ya que no hay que buscar en disco ni preguntar a otros servidores.
coalesce: Es una red de seguridad. Le decimos a Spark: "Busca la descripción en el mapa; si no la encuentras (porque el código es nuevo o el diccionario ha cambiado), mantén la URI original para no perder información".
Las licitaciones tienen estructuras anidadas (como una lista de documentos dentro de una lista de notificaciones).
transform: Es una "Función de Orden Superior". Nos permite aplicar la lógica de traducción dentro de una lista sin tener que "desmontarla" (hacer explode). Es como decirle a Spark: "Para cada elemento de esta bolsa, cambia el código por su nombre sin vaciar la bolsa".withField: Nos permite actualizar un campo específico dentro de una estructura compleja (Struct) manteniendo el resto de campos intactos.
Durante la validación del pipeline se detectaron dos bugs que impedían la traducción de la mayoría de columnas _description. Su diagnóstico y solución son una pieza importante de la arquitectura final.
El script download_gc_codes.py navega desde https://contrataciondelestado.es/codice/cl/ para descargar los archivos .gc, por lo que las LocationUri almacenadas en esos archivos usaban https://. Sin embargo, la mayoría de los atributos _listURI en los XML de las licitaciones usan http:// (sin la 's').
La clave de búsqueda del mapa es "URI|CÓDIGO". Por tanto, "http://...gc|1" nunca coincidía con "https://...gc|1". Solo los campos cuyos XML usaban https:// por casualidad (como SyndicationContractFolderStatusCode) funcionaban. El resto — project_type_code_description, country_code_description, contracting_system_code_description, submission_type_code_description y más de 25 columnas adicionales — fallaban silenciosamente, conservando la URI original en lugar de la descripción.
El catálogo NUTS de códigos territoriales ha sido actualizado en el repositorio CODICE. La versión activa en el servidor es NUTS-2024.gc (carpeta 2.12), pero las licitaciones históricas referencian NUTS-2021.gc (carpeta 2.08). Al ser paths completamente distintos (/2.08/NUTS-2021.gc ≠ /2.12/NUTS-2024.gc), corregir el protocolo no era suficiente. Adicionalmente, el script usaba el ShortName como clave del diccionario data, de modo que si el servidor tuviese ambas versiones, la segunda sobrescribiría a la primera.
La solución definitiva actúa en dos capas para separar responsabilidades:
Fix en download_gc_codes.py (Raw) — corrección de LocationUri en el momento de la descarga:
En lugar de asumir que el campo LocationUri es correcto, el script lo sobreescribe con la URL real de descarga antes de guardar el fichero. Así, la causa raíz de todos los mismatches (ver §4.5) queda eliminada en el origen:
tree = etree.parse(BytesIO(resp.content))
nodes = tree.xpath('.//Identification/LocationUri')
if nodes:
nodes[0].text = gc_uri # URL real: "https://contrataciondelestado.es/codice/cl/2.04/SyndicationContractFolderStatusCode-2.04.gc"
tree.write(path, encoding='utf-8', xml_declaration=True, pretty_print=True)Fix en extract_gc_codes.py (Bronze) — dos cambios:
-
Ruta relativa derivada de
LocationUri: Con laLocationUriya corregida en Raw, Bronze puede leerla directamente. Para hacer el catálogo agnóstico al protocolo y al dominio (ellinknunca debe contenerhttp://ni el dominio del servidor), se extraen los dos últimos segmentos de la URL:res = root.xpath('.//Identification/LocationUri/text()', namespaces=ns) uri = res[0] if res else None if uri: uri_parts = uri.rstrip('/').rsplit('/', 2) link = f"/{uri_parts[-2]}/{uri_parts[-1]}" # produce "/2.04/SyndicationContractFolderStatusCode-2.04.gc" else: link = f"/{version}/{gc_uri.name}" # fallback si el campo está ausente
El fallback
/{version}/{filename}usa el campoVersiondel propio XML (que contiene la versión CODICE del catálogo) combinado con el nombre real del fichero descargado, lo que produce el path correcto incluso para ficheros sinLocationUri. -
Clave
{name}_{version}para preservar versiones: La clave del diccionariodataesf"{name}_{version}". Así,NUTS_2021yNUTS_2024son entradas independientes con sus respectivos paths, y ninguna versión más nueva puede silenciosamente sobreescribir a una anterior que todavía usan los datos históricos.
Fix en extraction.py (Silver) — normalización en origen:
Se aplica el mismo patrón de normalización a todos los campos _listURI en el momento de la extracción, antes de que los valores lleguen al join:
_CODICE_PREFIX_PATTERN = r"^https?://contrataciondelestado\.es/codice/cl"
# Campos escalares (StringType):
regexp_replace(col(f"...._listURI"), _CODICE_PREFIX_PATTERN, "").alias("..._description")
# Campos en arrays (ArrayType<StringType>):
# cac:RequiredCommodityClassification.cbc:ItemClassificationCode._listURI (CPV)
# cbc:FundingProgramCode._listURI
transform(col(f"...._listURI"), lambda x: regexp_replace(x, _CODICE_PREFIX_PATTERN, "")).alias("..._description")Los campos CPV y FundingProgramCode están definidos en el schema como ArrayType, por lo que acceder a ._listURI devuelve ARRAY<STRING>. Aplicar regexp_replace directamente sobre un array genera un error de tipo (DATATYPE_MISMATCH); la función transform resuelve esto aplicando el reemplazo elemento a elemento dentro del array.
Durante la validación de la capa Silver se detectó que algunas descripciones del catálogo presentaban caracteres corruptos (mojibake), como Administración en lugar de Administración o Público en lugar de Público.
Causa raíz: Los archivos .gc del repositorio CODICE declaran en su cabecera XML encoding="ISO-8859-1". Sin embargo, el contenido real de los archivos está codificado en UTF-8. Por defecto, lxml respeta la declaración de codificación del propio documento. Al interpretar los bytes UTF-8 como si fueran ISO-8859-1, cada carácter acentuado de dos bytes (p.ej. 0xC3 0xB3 para ó) se decodificaba como dos caracteres distintos (ó), produciendo el mojibake.
Fix en extract_gc_codes.py:
Se añade el parámetro encoding='utf-8' al parser de lxml, que anula la declaración interna del documento y fuerza la lectura directamente como UTF-8:
root = etree.parse(
str(gc_uri.absolute()),
parser=etree.XMLParser(encoding='utf-8')
)Este es el patrón correcto cuando el servidor publica archivos con una declaración de codificación incorrecta: el parser externo tiene más información que el propio XML sobre cómo están realmente codificados sus bytes en disco.
Durante la implementación de la traducción de columnas _description en Silver se detectó que varias columnas no se traducían correctamente a pesar de que sus Genericodes existían en el catálogo. La investigación reveló que el campo LocationUri interno de algunos ficheros .gc del repositorio CODICE no coincide con la URL real desde la que se descargó el fichero.
Causa raíz: La LocationUri interna de un fichero .gc es una referencia canónica definida por CODICE que puede apuntar a un fichero diferente — de nombre distinto, versión anterior o incluso de un dominio externo — al que fue realmente publicado y descargado. Se identificaron ocho ficheros afectados:
| Fichero descargado | LocationUri interna (incorrecta) |
|---|---|
CountryIdentificationCode-2.0.gc |
http://docs.oasis-open.org/.../CountryIdentificationCode-2.0.gc |
NUTS2007-12-1.04.gc |
.../NUTS2007-1.04.gc |
TaxCategoryID.gc |
http://www.cen.eu/.../TaxCategoryID.gc |
TaxSchemeID.gc |
http://www.cen.eu/.../TaxSchemeID.gc |
SyndicationContractFolderStatusCode-2.04.gc |
.../ContractFolderStatusCode-2.04.gc |
GeneralContractDocuments-2.08.gc |
.../GeneralContractDocuments-2.07.gc |
TenderingNoticeTypeCode-2.08.gc |
.../2.04/TenderingNoticeTypeCode-2.08.gc |
PreliminaryMarketConsultationDocument-2.09.gc |
.../PreliminaryMarketConsultationDocument-2.08.gc |
En todos los casos, el ATOM feed referencia los ficheros por su URL real de descarga. Después de la normalización del Bug 1 en extraction.py, los valores almacenados en las columnas _description son rutas relativas derivadas de esa URL real (ej. /2.04/SyndicationContractFolderStatusCode-2.04.gc). Si el link en gc_codes.parquet se derivaba de una LocationUri incorrecta, el lookup link|código nunca coincidía y el coalesce devolvía silenciosamente la URI original sin traducir.
Fix: Corregir LocationUri en el origen, dentro del propio script de descarga Raw, antes de guardar el fichero en disco (ver §2.2 y §4.3). Con esta corrección aplicada aguas arriba, el script Bronze puede leer LocationUri con total confianza para derivar el link, sin necesidad de heurísticas sobre el nombre del fichero.
La implementación de este sistema de gestión de Genericodes aporta tres beneficios fundamentales:
- Legibilidad Inmediata: Los analistas y científicos de datos ya no ven códigos crípticos. Las tablas en la capa Silver y Gold son "auto-explicativas".
- Rendimiento Extremo: Gracias al uso de
broadcast,cachey funciones vectorizadas de Spark, se reduce el tiempo de ejecución para el proceso de traducción de miles de códigos. - Mantenimiento Desacoplado: Si el Gobierno añade un nuevo tipo de contrato o cambia una descripción, solo tenemos que volver a ejecutar el script de la Capa Bronze. El código de la Capa Silver no necesita ser modificado, ya que es agnóstico al contenido de los diccionarios; simplemente aplica lo que encuentra en el Parquet de referencia.
Este desarrollo es el cimiento que permite que el Data Lakehouse pase de ser un repositorio de logs técnicos a una herramienta de Inteligencia de Negocio real.