"""Scraper de ofertas de trabajo (jobs) de Upwork via Chrome DevTools Protocol. Funcion IMPURA: usa una pestana del navegador diario YA LOGUEADA en Upwork (Chrome con remote debugging en `port`, normalmente 9222 / chromium-personal) para ejecutar la busqueda de jobs y extraer las tarjetas de resultado. POR QUE CDP Y NO HTTP PURO: Upwork esta protegido por Cloudflare + PerimeterX. Un GET con urllib/requests recibe 403 y la busqueda real (`/nx/search/jobs/`) exige SESION LOGUEADA. Por eso vamos por CDP sobre el chromium diario del usuario, que ya tiene login: navegamos a la URL de busqueda, esperamos a que monten las job tiles (la pagina es una SPA), y extraemos con un solo `Runtime.evaluate`. Es la PIEZA 2 (hermana de scrape_workana_projects) de un monitor de captacion de clientes. Devuelve EXACTAMENTE el mismo shape unificado que el scraper de Workana para que un agregador downstream consuma ambas fuentes sin ramas especiales. COMPONE dos funciones del registry (no reescribe transporte CDP): 1. `cdp_open_url_and_wait` (pipeline) — crea tab nuevo en el Chrome remoto, navega a la URL de busqueda y espera `Page.loadEventFired`. Devuelve tab_id. 2. `cdp_eval` (browser) — evalua el extractor JS en la pestana cuyo URL contiene un substring (aqui: "upwork.com/nx/search/jobs"). NUNCA inventa datos: si tras el timeout no aparecen job tiles, devuelve `{"status": "error", ...}` con `projects` vacio. Nunca lanza excepciones. """ import json import os import sys import time import urllib.parse from datetime import datetime, timezone sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from browser.cdp_eval import cdp_eval from pipelines.cdp_open_url_and_wait import cdp_open_url_and_wait # --------------------------------------------------------------------------- # SELECTORES — best-effort, NO validados en vivo (sin sesion al crear la funcion). # # Upwork cambia el DOM con frecuencia. Cada campo usa una CASCADA de selectores # (se prueba el primero que matchee; si ninguno → null). Para corregir tras una # validacion real, edita SOLO este dict: el extractor JS lo lee tal cual. # # Referencia de los data-test conocidos (2025-2026): # - tile contenedor: article.job-tile | [data-test="JobTile"] # - lista de tiles: section[data-test="job-tile-list"] # - titulo + link: a[data-test="job-tile-title-link"] | h2 a | h3 a # - presupuesto: [data-test="job-type-label"] | [data-test="is-fixed-price"] # | [data-test="budget"] # - propuestas: [data-test="proposals-tier"] | [data-test="proposals"] # - skills (tokens): [data-test="token"] | .air3-token | [data-test="attr-item"] # - snippet: [data-test="job-description-text"] | [data-test="UpCLineClamp"] # | p # - pais: [data-test="location"] | [data-test="client-country"] # - fecha publicada: [data-test="job-pubilshed-date"] | [data-test="posted-on"] # | small[data-test="job-publish-time"] # --------------------------------------------------------------------------- SELECTORS = { # Tarjetas (tiles). Se prueban en orden hasta que alguna devuelva >0 nodos. "tile": [ 'section[data-test="job-tile-list"] article.job-tile', 'section[data-test="job-tile-list"] [data-test="JobTile"]', 'article.job-tile', '[data-test="JobTile"]', '[data-test="job-tile"]', ], # Dentro de cada tile — todos relativos al nodo de la tile. "title_link": [ 'a[data-test="job-tile-title-link"]', 'h2 a', 'h3 a', 'a[href*="/jobs/"]', ], "budget": [ '[data-test="job-type-label"]', '[data-test="is-fixed-price"]', '[data-test="budget"]', '[data-test="JobInfoByLine"]', ], "bids": [ '[data-test="proposals-tier"]', '[data-test="proposals"]', '[data-test="ProposalsTier"]', ], "skills": [ '[data-test="token"]', '.air3-token', '[data-test="attr-item"]', ], "snippet": [ '[data-test="job-description-text"]', '[data-test="UpCLineClamp"]', 'p', ], "country": [ '[data-test="location"]', '[data-test="client-country"]', 'small[data-test="client-location"]', ], "posted": [ '[data-test="job-pubilshed-date"]', '[data-test="posted-on"]', 'small[data-test="job-publish-time"]', '[data-test="JobInfoByLine"] span', ], } def _build_extractor_js(selectors: dict) -> str: """Construye el extractor JS que lee las job tiles del DOM ya montado. El JS recibe el dict de selectores serializado e implementa la cascada por campo. Devuelve `JSON.stringify({tiles_found, projects})`. Si no encuentra ninguna tile, `tiles_found` es 0 y `projects` queda vacio — el lado Python decide entonces el error (sesion no logueada o selectores desfasados). """ sel_json = json.dumps(selectors) return ( "(function(){" f" var S = {sel_json};" # firstMatch: primer nodo que matchee alguno de los selectores (en root). " function firstMatch(root, list){" " for (var i=0;i0. " function allMatch(root, list){" " for (var i=0;i dict: """Scrapea jobs de Upwork via CDP sobre una pestana YA LOGUEADA del navegador. Funcion IMPURA: requiere un Chrome con remote debugging en `port` (normalmente 9222, el chromium-personal del usuario, con sesion de Upwork activa). Para cada pagina: navega a la URL de busqueda, hace polling hasta que aparecen las job tiles (SPA), y extrae con un solo eval. Nunca lanza: cualquier fallo devuelve `{"status": "error", ...}`. NUNCA inventa datos: sin tiles → error. Args: query: Busqueda libre, ej. "custom software". "" = listado por defecto. sort: Orden de resultados: "recency" (mas recientes) o "relevance". pages: Numero de paginas de resultados a recorrer (>=1). Default 1. port: Puerto de remote debugging del Chrome logueado. Default 9222. timeout_s: Timeout por pagina (segundos) para navegacion + aparicion de tiles. Default 25.0. Returns: dict con el shape unificado (identico al scraper de Workana). En exito:: { "status": "ok", "source": "upwork", "count": , "projects": [ { "source": "upwork", "job_id": , "url": , "title": , "budget": , "posted": , "bids": , "skills": [, ...], "snippet": , "country": , "scraped_at": , }, ... ], } En error:: {"status": "error", "error": , "source": "upwork", "projects": []} """ if pages < 1: pages = 1 if sort not in ("recency", "relevance"): sort = "recency" extractor_js = _build_extractor_js(SELECTORS) substr = "upwork.com/nx/search/jobs" all_projects: list[dict] = [] last_error: str = "" any_tiles_seen = False for page_num in range(1, pages + 1): params = {"q": query, "sort": sort, "page": page_num} # url-encode de los params (la query libre puede llevar espacios/acentos). qs = urllib.parse.urlencode({k: v for k, v in params.items() if v != ""}) url = f"https://www.upwork.com/nx/search/jobs/?{qs}" # 1. Navegar: crea tab nuevo en el Chrome logueado y espera el load event. try: cdp_open_url_and_wait(port, url, int(timeout_s)) except Exception as e: # noqa: BLE001 — RuntimeError de cdp_open_url_and_wait msg = str(e) if "no se pudo crear tab" in msg or "URLError" in msg or "Connection refused" in msg: msg = f"no hay Chrome en el puerto {port} (¿remote debugging / chromium-personal activo?): {e}" last_error = f"navegacion fallo (page {page_num}): {msg}" # Sin navegacion no hay nada que extraer en esta pagina; continua a la siguiente. continue # 2. Polling hasta que aparezcan las tiles (la SPA monta el DOM en runtime). # Se reintenta el extractor cada 1s hasta timeout_s; en cuanto encuentra # tiles (o agota el tiempo) sale del bucle. deadline = time.monotonic() + timeout_s page_projects: list[dict] = [] page_tiles = 0 eval_error = "" while True: r = cdp_eval( extractor_js, port=port, target_url_substr=substr, timeout_s=max(10.0, timeout_s), ) if not r.get("ok"): eval_error = r.get("error") or "eval CDP fallo sin mensaje" else: raw_value = r.get("value") try: data = json.loads(raw_value) if isinstance(raw_value, str) else (raw_value or {}) except Exception: # noqa: BLE001 — JSON malformado del eval data = {} page_tiles = int(data.get("tiles_found") or 0) page_projects = data.get("projects") or [] if page_tiles > 0: break # ya hay resultados, no seguir esperando if time.monotonic() >= deadline: break time.sleep(1.0) # 3. (best-effort) cerrar el tab para no dejar pestanas abiertas. try: cdp_eval("window.close()", port=port, target_url_substr=substr, timeout_s=5.0) except Exception: # noqa: BLE001 — cierre best-effort pass if page_tiles > 0: any_tiles_seen = True all_projects.extend(page_projects) elif eval_error: last_error = f"eval fallo (page {page_num}): {eval_error}" # 4. Sin tiles en NINGUNA pagina → error explicito (no inventar datos). if not any_tiles_seen: err = ( "no job tiles — ¿sesion Upwork no logueada en port, o selectores " "desactualizados? Validar con sesion real" ) if last_error: err = f"{err} | detalle: {last_error}" return { "status": "error", "error": err, "source": "upwork", "projects": [], } # 5. Enriquecer cada fila: source + scraped_at (Python, no el JS). scraped_at = datetime.now(timezone.utc).isoformat() for p in all_projects: p["source"] = "upwork" p["scraped_at"] = scraped_at return { "status": "ok", "source": "upwork", "count": len(all_projects), "projects": all_projects, } if __name__ == "__main__": import argparse parser = argparse.ArgumentParser(description="Scrapea jobs de Upwork via CDP (sesion logueada).") parser.add_argument("--query", default="custom software", help="busqueda libre") parser.add_argument("--sort", default="recency", choices=["recency", "relevance"]) parser.add_argument("--pages", type=int, default=1) parser.add_argument("--port", type=int, default=9222) parser.add_argument("--timeout-s", type=float, default=25.0, dest="timeout_s") args = parser.parse_args() out = scrape_upwork_projects( query=args.query, sort=args.sort, pages=args.pages, port=args.port, timeout_s=args.timeout_s, ) # No volcar snippets enormes: resumen compacto. print(json.dumps(out, ensure_ascii=False, indent=2))