Spaces:
Runtime error
Runtime error
| """ | |
| 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.") | |