PROCURA / sli_scraper.py
tokiospg's picture
Upload 3 files
3002962 verified
Raw
History Blame Contribute Delete
26.2 kB
"""
SLI Scraper - Radar de licitaciones abiertas de la ACP.
Consulta el portal publico del SLI, recorre todas las paginas de resultados
y guarda las licitaciones abiertas en la tabla radar_licitaciones.
"""
import re
from datetime import datetime
from urllib.parse import parse_qs, urljoin, urlparse
import requests
import urllib3
from bs4 import BeautifulSoup
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
SLI_BASE = "https://apps.pancanal.com/sli"
SLI_SEARCH_URL = f"{SLI_BASE}/LicitacionesBusqueda/LicitacionesBusquedaParametros"
SLI_RESULTS_URL = f"{SLI_BASE}/LicitacionesBusqueda/BusquedaLicitacionesResultados"
CATEGORIAS_PRIORITARIAS = [
"Electrical & Electronics",
"Fabricated (ACP)",
"Communications Equipment",
"Pumps/Compressors",
"Hydraulic",
"Mechanical Power Transmission",
"Engines & related component(non-vehicle)",
"Construction/Mining",
"Construction Material",
"Alarm, Signal & Detection",
"Instruments/Lab Equip",
"Metalworking",
]
MESES_SLI = {
"ene": 1,
"feb": 2,
"mar": 3,
"abr": 4,
"may": 5,
"jun": 6,
"jul": 7,
"ago": 8,
"sep": 9,
"oct": 10,
"nov": 11,
"dic": 12,
}
def parse_sli_datetime(value):
if not value:
return None
text = re.sub(r"\s+", " ", str(value).replace("\xa0", " ")).strip().lower()
match = re.search(
r"(\d{1,2})-([a-záéíóúñ]{3,})-(\d{4})\s+(\d{1,2}):(\d{2})\s*([ap])\.?m\.?",
text,
re.IGNORECASE,
)
if not match:
return None
dia, mes_txt, anio, hora, minuto, ampm = match.groups()
mes = MESES_SLI.get(mes_txt[:3])
if not mes:
return None
hora = int(hora)
if ampm.lower() == "p" and hora != 12:
hora += 12
if ampm.lower() == "a" and hora == 12:
hora = 0
try:
return datetime(int(anio), mes, int(dia), hora, int(minuto))
except ValueError:
return None
def _extraer_monto_texto(texto):
if not texto:
return 0.0
patterns = [
r"[\$B/\.]+\s*([\d,]+(?:\.\d{2})?)",
r"([\d,]+(?:\.\d{2})?)\s*(?:USD|PAB|B/\.)",
]
for pattern in patterns:
match = re.search(pattern, texto)
if match:
try:
return float(match.group(1).replace(",", ""))
except ValueError:
continue
return 0.0
def _extraer_max_pagina(soup):
paginas = {1}
for link in soup.find_all("a", href=True):
href = link.get("href") or ""
if "BusquedaLicitacionesResultados" not in href or "pagina=" not in href:
continue
qs = parse_qs(urlparse(href).query)
for value in qs.get("pagina", []):
if str(value).isdigit():
paginas.add(int(value))
return max(paginas) if paginas else 1
def _extraer_urls_paginacion(soup):
urls = {}
for link in soup.find_all("a", href=True):
href = link.get("href") or ""
text = link.get_text(" ", strip=True)
if "BusquedaLicitacionesResultados" not in href and "pagina=" not in href:
continue
qs = parse_qs(urlparse(href).query)
page_num = None
for key in ["pagina", "page", "Page", "PageNumber"]:
values = qs.get(key, [])
if values and str(values[0]).isdigit():
page_num = int(values[0])
break
if page_num is None and text.isdigit():
page_num = int(text)
if page_num and page_num > 1:
urls[page_num] = urljoin(SLI_BASE, href)
return urls
def _contar_resultados_sli(soup):
return len(soup.find_all("a", id="link_BiddingNumber"))
def _scan_meta(metodo, paginas_recorridas=0, total_detectadas_portal=0, escaneo_completo=False, errores=""):
return {
"metodo": metodo,
"paginas_recorridas": int(paginas_recorridas or 0),
"total_detectadas_portal": int(total_detectadas_portal or 0),
"escaneo_completo": bool(escaneo_completo),
"errores": errores or "",
}
def _click_visible_page_link(page, page_number):
selectors = [
f".pagination a[href*='pagina={page_number}']",
f".pagination a:has-text('{page_number}')",
f"nav a[href*='pagina={page_number}']",
f"a[href*='pagina={page_number}']",
]
for selector in selectors:
locator = page.locator(selector).filter(visible=True)
if locator.count() > 0:
locator.first.click()
return True
return bool(page.evaluate(
"""pageNumber => {
const links = Array.from(document.querySelectorAll('a'));
const target = links.find(a => {
const text = (a.textContent || '').trim();
const href = a.getAttribute('href') || '';
const rect = a.getBoundingClientRect();
const style = window.getComputedStyle(a);
const visible = rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none';
const inHiddenMenu = !!a.closest('.bootstrap-select, .dropdown-menu, [aria-hidden="true"]');
return visible && !inHiddenMenu && (text === String(pageNumber) || href.includes('pagina=' + pageNumber));
});
if (!target) return false;
target.click();
return true;
}""",
int(page_number)
))
def _parsear_resultados_sli(soup):
resultados = []
items = soup.find_all("a", id="link_BiddingNumber")
for item in items:
numero = item.text.strip()
link = item.get("href", "")
if link and not link.startswith("http"):
link = urljoin("https://apps.pancanal.com", link)
container = item.find_parent("div", class_="col-lg-9") or item.find_parent("div")
if not container:
continue
objeto_tag = container.find("p", class_="title")
objeto = objeto_tag.text.strip() if objeto_tag else ""
if not objeto:
textos = [t.strip() for t in container.stripped_strings if t.strip()]
try:
idx = textos.index(numero)
objeto = textos[idx + 1] if idx + 1 < len(textos) else ""
except ValueError:
objeto = ""
texto_completo = container.text
apertura = ""
apertura_match = re.search(
r"Fecha\s+de\s+publicaci\S+n\s*([\d\-A-Za-z]+\s+[\d:]+\s+[APM]+)",
texto_completo,
re.IGNORECASE,
)
if apertura_match:
apertura = apertura_match.group(1).strip()
cierre = ""
cierre_match = re.search(
r"Fecha\s+y\s+hora\s+de\s+cierre\s*([\d\-A-Za-z]+\s+[\d:]+\s+[APM]+)",
texto_completo,
re.IGNORECASE,
)
if cierre_match:
cierre = re.sub(r"\s+", " ", cierre_match.group(1).replace("\xa0", " ")).strip()
enmienda = ""
enmienda_match = re.search(
r"#\s*Enmienda\s*([A-Za-z0-9\-_/]*)",
texto_completo,
re.IGNORECASE,
)
if enmienda_match:
enmienda = re.sub(r"\s+", " ", enmienda_match.group(1).replace("\xa0", " ")).strip()
objeto_lower = objeto.lower()
es_prioritaria = any(
key in objeto_lower
for key in [
"elect",
"cable",
"motor",
"bomba",
"panel",
"transformador",
"breaker",
"sensor",
"hidraul",
"valvula",
"repuesto",
]
)
resultados.append(
{
"numero_licitacion": numero,
"objeto": objeto,
"categoria": "General (Autodetectado)",
"monto_estimado": _extraer_monto_texto(objeto),
"moneda": "USD",
"fecha_apertura": apertura,
"fecha_cierre": cierre,
"numero_enmienda": enmienda,
"link_sli": link,
"es_prioritaria": es_prioritaria,
"fecha_descubierta": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}
)
return resultados
def _set_input_value(page, selectors, value):
for selector in selectors:
locator = page.locator(selector)
if locator.count() > 0:
try:
locator.first.fill(str(value or ""))
return True
except Exception:
try:
page.evaluate(
"""([selector, value]) => {
const el = document.querySelector(selector);
if (el) {
el.value = value || '';
el.dispatchEvent(new Event('input', {bubbles: true}));
el.dispatchEvent(new Event('change', {bubbles: true}));
}
}""",
[selector, str(value or "")]
)
return True
except Exception:
continue
return False
def _select_or_set_value(page, selectors, value):
for selector in selectors:
locator = page.locator(selector)
if locator.count() > 0:
try:
locator.first.select_option(str(value))
return True
except Exception:
try:
page.evaluate(
"""([selector, value]) => {
const el = document.querySelector(selector);
if (el) {
el.value = value;
el.dispatchEvent(new Event('change', {bubbles: true}));
}
}""",
[selector, str(value)]
)
return True
except Exception:
continue
return False
def _parse_soups(soups):
unicos = {}
now = datetime.now()
for soup in soups:
for lic in _parsear_resultados_sli(soup):
cierre_dt = parse_sli_datetime(lic.get("fecha_cierre"))
if cierre_dt and cierre_dt < now:
continue
numero = lic["numero_licitacion"]
if numero not in unicos:
unicos[numero] = lic
resultados = list(unicos.values())
def sort_key(lic):
cierre_dt = parse_sli_datetime(lic.get("fecha_cierre"))
apertura_dt = parse_sli_datetime(lic.get("fecha_apertura"))
return (
cierre_dt or datetime.max,
apertura_dt or datetime.max,
str(lic.get("numero_licitacion", "")),
)
resultados.sort(key=sort_key)
return resultados
def escanear_licitaciones_abiertas_playwright(
palabra_clave="",
numero_licitacion="",
categoria="TODOS",
max_paginas=50,
):
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
from playwright.sync_api import sync_playwright
palabra_clave = (palabra_clave or "").strip()
numero_licitacion = re.sub(r"\D", "", str(numero_licitacion or ""))
categoria = (categoria or "TODOS").strip() or "TODOS"
errores = []
soups = []
paginas_visitadas = set()
print("[RADAR] Escaneo Playwright: abriendo SLI...")
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=["--no-sandbox", "--disable-dev-shm-usage"])
context = browser.new_context(ignore_https_errors=True, locale="es-PA")
page = context.new_page()
page.set_default_timeout(20000)
try:
page.goto(SLI_BASE, wait_until="domcontentloaded", timeout=45000)
page.wait_for_timeout(1000)
_set_input_value(page, ["input[name='Descripcion']", "#Descripcion", "input[placeholder*='Palabra']"], palabra_clave)
_set_input_value(page, ["input[name='NumeroLicitacion']", "#NumeroLicitacion", "input[placeholder*='licitaci']"], numero_licitacion)
_select_or_set_value(page, ["select[name='status']", "#status", "#hfEstatusSeleccionadoID"], "AN")
_select_or_set_value(page, ["select[name='EstatusSeleccionadoID']", "#EstatusSeleccionadoID"], "AN")
_select_or_set_value(page, ["select[name='categorias']", "#categorias", "select[name='CategoriaSeleccionadaID']"], categoria)
_select_or_set_value(page, ["select[name='Text']", "#Text"], "100")
page.evaluate(
"""() => {
for (const [selector, value] of [
['input[name="status"]', 'AN'],
['input[name="EstatusSeleccionadoID"]', 'AN'],
['input[name="Text"]', '100'],
['#hfEstatusSeleccionadoID', 'AN']
]) {
const el = document.querySelector(selector);
if (el) el.value = value;
}
}"""
)
clicked = False
for selector in ["input[name='submitBusqueda3']", "button[type='submit']", "input[type='submit']", "button:has-text('Buscar')"]:
locator = page.locator(selector)
if locator.count() > 0:
locator.first.click()
clicked = True
break
if not clicked:
page.evaluate("document.querySelector('form')?.submit()")
try:
page.wait_for_selector("#link_BiddingNumber, a[id='link_BiddingNumber']", timeout=45000)
except PlaywrightTimeoutError:
errores.append("No aparecieron resultados de licitaciones en el SLI.")
pagina_actual = 1
while pagina_actual <= int(max_paginas or 50):
page.wait_for_timeout(700)
soup = BeautifulSoup(page.content(), "html.parser")
if pagina_actual not in paginas_visitadas:
soups.append(soup)
paginas_visitadas.add(pagina_actual)
next_page = pagina_actual + 1
max_detected = min(_extraer_max_pagina(soup), int(max_paginas or 50))
if pagina_actual >= max_detected:
break
try:
current_first = page.locator("#link_BiddingNumber").first.inner_text(timeout=3000)
except Exception:
current_first = ""
if not _click_visible_page_link(page, next_page):
errores.append(f"No se pudo abrir la pagina {next_page} de {max_detected}.")
break
try:
page.wait_for_load_state("networkidle", timeout=12000)
except Exception:
pass
try:
if current_first:
page.wait_for_function(
"""first => {
const el = document.querySelector('#link_BiddingNumber');
return el && el.innerText.trim() !== first.trim();
}""",
current_first,
timeout=8000,
)
except Exception:
pass
pagina_actual = next_page
finally:
browser.close()
resultados = _parse_soups(soups)
total_detectadas = sum(_contar_resultados_sli(soup) for soup in soups)
completo = bool(soups) and not errores
meta = _scan_meta(
"playwright",
paginas_recorridas=len(paginas_visitadas),
total_detectadas_portal=total_detectadas,
escaneo_completo=completo,
errores=" | ".join(errores),
)
print(f"[RADAR] Playwright completo={completo}: {len(resultados)} unicas, {len(paginas_visitadas)} paginas.")
return resultados, meta
def _escanear_licitaciones_abiertas_requests(
palabra_clave="",
numero_licitacion="",
categoria="TODOS",
max_paginas=30,
):
session = requests.Session()
session.verify = False
errores = []
palabra_clave = (palabra_clave or "").strip()
numero_licitacion = re.sub(r"\D", "", str(numero_licitacion or ""))
categoria = (categoria or "TODOS").strip() or "TODOS"
print("[RADAR] Obteniendo token de sesion del SLI...")
try:
resp_home = session.get(SLI_BASE, timeout=30)
resp_home.raise_for_status()
except Exception as exc:
print(f"[RADAR] Error accediendo al SLI: {exc}")
return [], _scan_meta("requests", errores=f"Error accediendo al SLI: {exc}")
soup_home = BeautifulSoup(resp_home.text, "html.parser")
token_input = soup_home.find("input", {"name": "__RequestVerificationToken"})
if not token_input:
print("[RADAR] No se encontro __RequestVerificationToken en la pagina principal.")
return [], _scan_meta("requests", errores="No se encontro token de sesion del SLI.")
payload = {
"Descripcion": [palabra_clave, palabra_clave],
"NumeroLicitacion": numero_licitacion,
"status": "AN",
"EstatusSeleccionadoID": "AN",
"categorias": categoria,
"CategoriaSeleccionadaID": categoria,
"Text": "100",
"__RequestVerificationToken": token_input["value"],
"submitBusqueda3": "Buscar",
}
print("[RADAR] Buscando licitaciones abiertas...")
try:
resp = session.post(SLI_SEARCH_URL, data=payload, timeout=60)
resp.raise_for_status()
except Exception as exc:
print(f"[RADAR] Error enviando formulario de busqueda: {exc}")
return [], _scan_meta("requests", errores=f"Error enviando formulario de busqueda: {exc}")
soups = [BeautifulSoup(resp.text, "html.parser")]
max_pagina = min(_extraer_max_pagina(soups[0]), int(max_paginas or 30))
page_urls = _extraer_urls_paginacion(soups[0])
print(f"[RADAR] Paginas detectadas: {max_pagina}")
for pagina in range(2, max_pagina + 1):
page_url = page_urls.get(pagina) or f"{SLI_RESULTS_URL}?pagina={pagina}"
try:
page_resp = session.get(page_url, timeout=45)
page_resp.raise_for_status()
soups.append(BeautifulSoup(page_resp.text, "html.parser"))
except Exception as exc:
print(f"[RADAR] Error obteniendo pagina {pagina}: {exc}")
errores.append(f"Error obteniendo pagina {pagina}: {exc}")
unicos = {}
now = datetime.now()
for soup in soups:
for lic in _parsear_resultados_sli(soup):
cierre_dt = parse_sli_datetime(lic.get("fecha_cierre"))
if cierre_dt and cierre_dt < now:
continue
numero = lic["numero_licitacion"]
if numero not in unicos:
unicos[numero] = lic
resultados = list(unicos.values())
def sort_key(lic):
cierre_dt = parse_sli_datetime(lic.get("fecha_cierre"))
apertura_dt = parse_sli_datetime(lic.get("fecha_apertura"))
return (
cierre_dt or datetime.max,
apertura_dt or datetime.max,
str(lic.get("numero_licitacion", "")),
)
resultados.sort(key=sort_key)
print(f"[RADAR] Parseo completado: {len(resultados)} licitaciones abiertas unicas listas.")
meta = _scan_meta(
"requests",
paginas_recorridas=len(soups),
total_detectadas_portal=sum(_contar_resultados_sli(soup) for soup in soups),
escaneo_completo=(len(soups) >= max_pagina and not errores),
errores=" | ".join(errores),
)
return resultados, meta
def escanear_licitaciones_abiertas_con_metadata(
palabra_clave="",
numero_licitacion="",
categoria="TODOS",
max_paginas=50,
):
es_escaneo_global = not (palabra_clave or "").strip() and not re.sub(r"\D", "", str(numero_licitacion or "")) and (categoria or "TODOS").strip() == "TODOS"
try:
licitaciones, meta = escanear_licitaciones_abiertas_playwright(
palabra_clave=palabra_clave,
numero_licitacion=numero_licitacion,
categoria=categoria,
max_paginas=max_paginas,
)
if es_escaneo_global and len(licitaciones) < 20:
raise RuntimeError(
f"Playwright devolvio solo {len(licitaciones)} licitaciones en escaneo global; se validara con POST directo."
)
return licitaciones, meta
except Exception as exc:
print(f"[RADAR] Playwright no disponible o fallo el escaneo completo: {exc}")
licitaciones, meta = _escanear_licitaciones_abiertas_requests(
palabra_clave=palabra_clave,
numero_licitacion=numero_licitacion,
categoria=categoria,
max_paginas=max_paginas,
)
if meta.get("escaneo_completo") and not meta.get("errores"):
return licitaciones, meta
if not meta.get("errores"):
meta["errores"] = f"Playwright no completo: {exc}. Se uso POST directo del SLI."
else:
meta["errores"] = f"Playwright no completo: {exc}. {meta.get('errores')}"
return licitaciones, meta
def escanear_licitaciones_abiertas(
palabra_clave="",
numero_licitacion="",
categoria="TODOS",
max_paginas=50,
):
licitaciones, _ = escanear_licitaciones_abiertas_con_metadata(
palabra_clave=palabra_clave,
numero_licitacion=numero_licitacion,
categoria=categoria,
max_paginas=max_paginas,
)
return licitaciones
def ejecutar_radar_detallado(db_module=None, palabra_clave="", numero_licitacion="", categoria="TODOS"):
if db_module is None:
import database as db_module
errores = ""
meta = _scan_meta("sin_ejecutar")
try:
licitaciones, meta = escanear_licitaciones_abiertas_con_metadata(
palabra_clave=palabra_clave,
numero_licitacion=numero_licitacion,
categoria=categoria,
)
errores = str(meta.get("errores", "") or "")
except Exception as exc:
licitaciones = []
errores = str(exc)
meta = _scan_meta("error", errores=errores)
nuevas = 0
total = len(licitaciones)
if hasattr(db_module, "guardar_licitaciones_radar_bulk"):
nuevas, total = db_module.guardar_licitaciones_radar_bulk(licitaciones)
else:
for lic in licitaciones:
fue_nueva = db_module.guardar_licitacion_radar(
numero_licitacion=lic["numero_licitacion"],
objeto=lic["objeto"],
categoria=lic["categoria"],
monto_estimado=lic["monto_estimado"],
moneda=lic["moneda"],
fecha_apertura=lic["fecha_apertura"],
fecha_cierre=lic["fecha_cierre"],
link_sli=lic["link_sli"],
es_prioritaria=lic["es_prioritaria"],
)
if fue_nueva:
nuevas += 1
obsoletas_eliminadas = 0
es_escaneo_completo = not palabra_clave and not numero_licitacion and categoria == "TODOS"
cobertura_confirmada = bool(meta.get("escaneo_completo"))
if es_escaneo_completo and licitaciones and cobertura_confirmada and hasattr(db_module, "eliminar_radar_fuera_de_numeros"):
numeros_abiertos = [lic["numero_licitacion"] for lic in licitaciones]
obsoletas_eliminadas = db_module.eliminar_radar_fuera_de_numeros(numeros_abiertos)
elif es_escaneo_completo and licitaciones and not cobertura_confirmada:
warning = "Cobertura SLI no confirmada; no se eliminaron licitaciones fuera del escaneo."
errores = f"{errores} | {warning}".strip(" |")
if hasattr(db_module, "registrar_escaneo_radar"):
try:
db_module.registrar_escaneo_radar(
total,
nuevas,
errores,
paginas_recorridas=meta.get("paginas_recorridas", 0),
total_detectadas_portal=meta.get("total_detectadas_portal", 0),
metodo=meta.get("metodo", ""),
escaneo_completo=meta.get("escaneo_completo", False),
)
except Exception as exc:
errores = f"{errores} | Error registrando escaneo: {exc}".strip(" |")
print(f"[RADAR] Resultados: {nuevas} nuevas de {total} totales")
return {
"total": total,
"nuevas": nuevas,
"actualizadas": max(total - nuevas, 0),
"obsoletas_eliminadas": obsoletas_eliminadas,
"errores": errores,
"licitaciones": licitaciones,
"paginas_recorridas": meta.get("paginas_recorridas", 0),
"total_detectadas_portal": meta.get("total_detectadas_portal", 0),
"metodo": meta.get("metodo", ""),
"escaneo_completo": meta.get("escaneo_completo", False),
}
def ejecutar_radar(db_module=None):
resultado = ejecutar_radar_detallado(db_module=db_module)
return resultado["nuevas"], resultado["total"]
if __name__ == "__main__":
import io
import sys
from dotenv import load_dotenv
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
load_dotenv()
print("[RADAR] Ejecutando Radar SLI manualmente...")
lics = escanear_licitaciones_abiertas()
if lics:
print(f"\n[RADAR] {len(lics)} licitaciones encontradas:\n")
for i, lic in enumerate(lics[:30], 1):
prio = "[PRIO]" if lic["es_prioritaria"] else " "
monto_str = f"${lic['monto_estimado']:,.2f}" if lic["monto_estimado"] > 0 else "Sin monto"
print(f"{prio} {i}. [{lic['numero_licitacion']}] {lic['objeto'][:90]}")
print(f" Monto: {monto_str} | Apertura: {lic['fecha_apertura']} | Cierre: {lic['fecha_cierre']}")
print()
else:
print("[RADAR] No se encontraron licitaciones. Verificar la conexion al SLI.")