---
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:<mensaje claro>, 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.
