--- name: scrape_upwork_projects kind: function lang: py domain: browser version: "0.1.0" purity: impure signature: "def scrape_upwork_projects(query: str = '', sort: str = 'recency', pages: int = 1, port: int = 9222, timeout_s: float = 25.0) -> dict" description: "Scraper de ofertas de trabajo (jobs) de Upwork via Chrome DevTools Protocol sobre una pestana YA LOGUEADA del navegador diario (chromium-personal, port 9222). Upwork tiene anti-bot fuerte (Cloudflare + PerimeterX): HTTP puro recibe 403 y la busqueda real exige sesion. Por eso navega via CDP a /nx/search/jobs, hace polling hasta que montan las job tiles (SPA) y extrae con un solo eval. Pieza 2 (hermana de scrape_workana_projects) de un monitor de captacion de clientes. Devuelve el shape unificado: status, source='upwork', count, projects con job_id, url, title, budget, posted, bids, skills, snippet, country, scraped_at. NUNCA inventa datos: sin tiles devuelve status error." tags: [market-intel, recon, flow-replay, browser, cdp, upwork, scraper, jobs, freelance, captacion-clientes] uses_functions: ["cdp_open_url_and_wait_py_pipelines", "cdp_eval_py_browser"] uses_types: [] returns: [] returns_optional: false error_type: "error_py_core" imports: [] params: - name: query desc: "Busqueda libre, ej. 'custom software'. Se url-encodea. '' (vacio) = listado de jobs por defecto." - name: sort desc: "Orden de resultados: 'recency' (mas recientes, default) o 'relevance'. Cualquier otro valor cae a 'recency'." - name: pages desc: "Numero de paginas de resultados a recorrer (>=1). Default 1. Cada pagina = navegacion + extraccion." - name: port desc: "Puerto de remote debugging del Chrome LOGUEADO en Upwork. Default 9222 (chromium-personal, navegador diario con sesion activa). NO usar 9333 (Chrome aislado del browser_mcp, sin login)." - name: timeout_s desc: "Timeout por pagina en segundos para navegacion + aparicion de las job tiles (polling cada 1s). Default 25.0." output: "dict siempre (nunca lanza). En exito: {status:'ok', source:'upwork', count:N, projects:[{source:'upwork', job_id, url, title, budget, posted, bids, skills:list[str], snippet, country, scraped_at:ISO8601-UTC}, ...]}. En error (sin job tiles): {status:'error', error:, source:'upwork', projects:[]}. Shape IDENTICO al scraper de Workana para que un agregador downstream consuma ambas fuentes sin ramas. Campos no encontrados en el DOM quedan a null." tested: false tests: [] test_file_path: "" file_path: "python/functions/browser/scrape_upwork_projects.py" --- ## Ejemplo ```bash # Requiere chromium-personal LOGUEADO en Upwork escuchando en port 9222. fn run scrape_upwork_projects --query "custom software" --sort recency ``` ```python import sys, os, json sys.path.insert(0, os.path.join("python", "functions")) from browser.scrape_upwork_projects import scrape_upwork_projects # Navega a la busqueda logueada y extrae las job tiles de la primera pagina. res = scrape_upwork_projects(query="custom software", sort="recency", pages=1, port=9222) if res["status"] == "ok": print(f"{res['count']} jobs de Upwork") for job in res["projects"][:3]: print(job["title"], "|", job["budget"], "|", job["country"]) else: # Sin sesion logueada o selectores desfasados → error explicito, projects vacio. print("error:", res["error"]) ``` ## Cuando usarla Cuando necesites el feed de jobs/ofertas de Upwork para un monitor de captacion de clientes y tengas el navegador diario (chromium-personal) LOGUEADO en Upwork. Es la pieza 2 del par con `scrape_workana_projects`: ambas devuelven el mismo shape unificado, asi que un agregador downstream las consume sin ramas especiales. Usala cuando el HTTP puro no sirve (Upwork = 403 por Cloudflare + PerimeterX) y la busqueda exige sesion. Para una sola consulta puntual: `fn run scrape_upwork_projects --query "..."`. Para recorrer varias paginas: sube `pages`. ## Gotchas - **Selectores NO validados en vivo (sin sesion al crearla).** El extractor JS usa selectores best-effort de Upwork con cascada por campo. Estan declarados en la constante `SELECTORS` del `.py` para que corregirlos sea trivial. **Valida los selectores con una busqueda real ANTES de confiar en produccion**: Upwork cambia el DOM con frecuencia. Si un campo sale `null` de forma sistematica, el selector de ese campo esta desfasado. - **Requiere chromium-personal LOGUEADO en Upwork en `port` (9222).** Sin sesion la pagina de busqueda no muestra resultados reales (redirige a login / challenge). El servicio systemd `chromium-personal` debe estar vivo con remote debugging activo. Sin Chrome en el puerto: error claro, no lanza. - **NO usar `port=9333`** (Chrome aislado del browser_mcp): no tiene tu login de Upwork, asi que no veria los resultados logueados. - **Sin job tiles → `status:"error"` con `projects` vacio.** La funcion NUNCA inventa datos. El mensaje distingue las dos causas probables: sesion no logueada o selectores desactualizados ("Validar con sesion real"). - **Anti-bot puede mostrar un challenge** (Cloudflare/PerimeterX) en vez de los resultados aunque haya sesion. En ese caso no aparecen tiles y devuelve error: hay que resolver el challenge a mano en el navegador antes de reintentar. - **Mezcla tu sesion personal.** Con `port=9222` abre tabs en TU navegador diario (los cierra best-effort con `window.close()` al terminar). Respeta los terminos de servicio de Upwork y el scope legal del scraping. - **`scraped_at` y `source` los pone Python**, no el JS, para garantizar el sello UTC consistente en todas las filas de la misma corrida. ## Capability growth log - v0.1.0 (2026-06-17) — version inicial. Selectores best-effort PENDIENTES de validacion en vivo (no habia sesion Upwork logueada al crear la funcion). El extractor JS lee la constante `SELECTORS`; corregir alli tras validar con una busqueda real. Sin smoke test ejecutado contra Upwork.