← Todos los artículos

Monitorización de licitaciones: los anuncios que tu scraper nunca vio

Monitorear licitaciones por debajo del umbral de la UE implica extraer datos de una larga cola de portales pequeños. El fallo costoso no es un rechazo, sino una ejecución en verde que no guardó nada.

216.000 €. Por debajo de ese umbral, un ayuntamiento europeo que contrata servicios no está obligado a publicar su licitación en TED, el registro central de la UE.

Esto significa que los contratos a los que un licitador pequeño puede aspirar de forma realista se encuentran en otro lugar: en el portal del ayuntamiento, región, empresa de servicios públicos o ministerio que realiza la compra.

El desafío

Más de 250.000 autoridades públicas de la UE gastan alrededor de 2,6 billones de euros al año en obras, suministros y servicios, según el recuento de la Comisión Europea. Las directivas de la UE solo regulan los contratos que superan los umbrales, los cuales, a partir del 1 de enero de 2026, se sitúan en 140.000 € para suministros y servicios de la administración central, 216.000 € para autoridades locales y regionales, y 5.404.000 € para obras. Por debajo de estas cifras, se aplican las normativas nacionales y la publicación en TED es voluntaria.

Por eso el monitoreo de licitaciones es, en realidad, un trabajo de scraping. Si lo estás construyendo para una consultora de licitaciones o una empresa de construcción, la parte que supera el umbral es la más sencilla. El resto es una larga cola de portales que nadie diseñó para que los leyera una máquina.

BidPrime, que comercializa datos de licitaciones del sector público en Norteamérica, definió la versión estadounidense de esto como "miles de flujos de trabajo" en mayo de 2026: portales que limitan el acceso automatizado, sistemas de contratación que requieren una cuenta o una sesión, archivos adjuntos almacenados de forma independiente al anuncio y URL que cambian sin previo aviso. Nada en esa lista es exclusivo de Estados Unidos.

Sin embargo, el fallo más crítico en el monitoreo de licitaciones no es el portal que te bloquea. Ese aparece en tus logs. El problema real es la ejecución que responde en verde pero sin contenido.

El portal de un ayuntamiento puede responder a una sesión expirada con una página correcta en HTTP 200. Un aviso de mantenimiento puede renderizarse donde debería estar la tabla de resultados. Una búsqueda enviada ayer puede devolverte la página uno otra vez, porque la página dos solo existía para la cookie que la solicitó. Tu parser encuentra cero filas y registra "no hay licitaciones nuevas hoy". Y, a diferencia de un precio que no se capturó, una licitación perdida no se recupera: para cuando alguien detecta la omisión, el plazo de presentación ya venció y el anuncio no le sirve de nada al licitador.

El enfoque: hacer que cada fetch demuestre lo que vio

Empieza con lo que no necesita scraping. La API de TED ofrece acceso anónimo a las notificaciones publicadas, sin requerir clave, así que extrae los datos por encima del umbral desde ahí. En el Reino Unido, Find a Tender es la plataforma central donde todas las autoridades de contratación publican información de licitaciones desde el 24 de febrero de 2025, según GOV.UK. Revisa lo que ya cubre un registro central antes de escribir un parser para un portal que lo replica.

Para todo lo demás, tres reglas soportan la mayor parte del trabajo.

1. Define una respuesta real, incluida la vacía. Una página de resultados incluye su encabezado de columna ("Closing date", en las palabras del propio portal). Un día vacío legítimo incluye el texto propio de estado vacío del portal. Cualquier otra cosa es una brecha, no un cero. Añade ambas cadenas a la lista validate.data.accept del request, y una página de sesión expirada fallará el request en lugar de pasar como un día sin actividad. Nuestros motores ya se niegan a contar una página de verificación identificada como un éxito, pero ningún motor conoce cómo redacta cada municipio su página de tiempo de espera (esa parte debe venir de ti). La coincidencia es una subcadena simple que distingue mayúsculas de minúsculas, así que copia el texto de la página en lugar de volver a escribirlo. Explicamos la mecánica cuando las reglas de validación comenzaron a decidir el éxito, y el mismo problema desde el punto de vista de series temporales en Cuando un bloqueo parece un punto de datos.

2. Verifica el conteo que muestra el portal. La mayoría de las listas de resultados indican cuántas notificaciones coinciden ("1-20 of 143"). Extrae ese número y compáralo con las filas que almacenaste. Si difieren, la ejecución no ha terminado, sin importar lo que indiquen los códigos de estado.

3. Encuentra la ruta una vez, luego repítela. Los portales paginados y vinculados a sesiones suelen esperar páginas sucesivas desde la misma dirección y el mismo navegador. Con una plataforma como FourA, Auto encuentra una ruta que funciona para la primera página y devuelve su sesión: el proxy ID, el User-Agent y las cookies del intento que tuvo éxito. Cada página posterior pasa por Single en esa salida, que es la ruta de producción. Auto es solo el buscador de rutas.

import math, re, requests

API = "https://api.foura.ai/api"
H = {"X-API-Key": "pk_live_..."}
LIST = "https://tenders.example-council.gov/open?page={}"
PER_PAGE = 20

# A real answer is the results table or the portal's own empty-state line.
# An expired session or a maintenance notice served at 200 is neither.
CHECK = {"data": {"accept": ["Closing date", "No open tenders"]}}

def fetch(endpoint, body):
    r = requests.post(f"{API}/{endpoint}", headers=H, json=body).json()
    if r.get("error"):
        raise RuntimeError(r["error"])  # a gap to refill, not a quiet day
    return r

# Auto finds a route that works and hands back its session...
first = fetch("auto", {"url": LIST.format(1), "validate": CHECK})
proxy, ua = first["session"]["proxy"], first["session"]["userAgent"]
pages = [first["data"]]

# ...and Single replays it on the same exit for every other page.
m = re.search(r"\d+-\d+ of (\d+)", pages[0])
total = int(m.group(1)) if m else 0
for n in range(2, math.ceil(total / PER_PAGE) + 1):
    pages.append(fetch("single", {
        "method": "GET", "url": LIST.format(n), "proxy": proxy,
        "headers": [["User-Agent", ua]], "validate": CHECK,
    })["data"])

Si el portal mantiene el estado de tu búsqueda en su servidor, envía su cookie de sesión en la misma lista headers; Single devuelve cada header Set-Cookie por cada salto de redirección. Los documentos de licitación suelen estar detrás de un enlace de descarga como PDF, y el control de redirecciones y el modo de búfer raw de Single los devuelve como bytes en lugar de texto dañado. Un hábito más: consulta cada portal según su propio cronograma. Son servidores pequeños gestionados por municipios pequeños, y veinte requests en una sola ráfaga les resultan mucho más sospechosas que veinte distribuidas a lo largo de una tarde.

Resultados

  • 25% más de licitaciones capturadas. GroupBWT, una firma de servicios de scraping, reporta esto para un cliente que monitorea más de 50 portales de contratación frente a una línea base manual, "no porque existieran más licitaciones, sino porque dejaron de perderlas".
  • Sin scraper para la parte que supera el umbral. Los avisos de TED provienen de una API que no requiere clave, por lo que cada hora de trabajo de parsing se destina a portales que no tienen API.
  • Las omisiones se detectan mientras el aviso sigue abierto. Una página que no cumple la regla de aceptación genera un error el mismo día en que ocurre, y el job la reintenta en lugar de guardar un día vacío.
  • Un solo descubrimiento de ruta por portal, no por página. La búsqueda de Auto ocurre una sola vez; cada página de seguimiento es una llamada a Single sobre la salida que encontró.

Solo el primer punto es un resultado medido; los otros tres describen lo que cambia la configuración.

Conclusión principal

En el monitoreo de licitaciones, la fila más costosa que puedes almacenar es "nada nuevo hoy", porque es la única que no puedes corregir después. Los precios vuelven a estar mañana. Una licitación que cerró mientras tu scraper leía una página de sesión expirada, no.

Así que haz que el job se gane sus ceros. Una página de listado que no puede mostrarte la tabla, el propio mensaje de estado vacío del portal o un recuento que coincida todavía no te ha dicho nada, y tratar eso como una respuesta es la razón por la que un postor termina enterándose de un contrato a través de su aviso de adjudicación.