---
name: monitor_freelance_projects
kind: pipeline
lang: py
domain: pipelines
version: "1.0.0"
purity: impure
signature: "def monitor_freelance_projects(category: str = 'it-programming', language: str = 'es', query: str = '', pages: int = 1, include_upwork: bool = False, upwork_query: str = 'custom software', duckdb_path: str = '', xlsx_path: str = '', port: int = 9334, timeout_s: float = 25.0) -> dict"
description: "Monitor de captacion de clientes freelance: scrapea proyectos nuevos de Workana (+ Upwork opcional) via CDP, los persiste en DuckDB con dedup por url, marca los de software a medida y exporta a Excel (hojas Nuevos y Todos)."
tags: [market-intel, recon, launcher, pipelines, freelance, workana, upwork, duckdb, excel]
uses_functions:
  - scrape_workana_projects_py_browser
  - scrape_upwork_projects_py_browser
  - duckdb_execute_py_infra
  - duckdb_upsert_py_infra
  - duckdb_query_readonly_py_infra
  - write_xlsx_sheets_py_infra
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/pipelines/monitor_freelance_projects.py"
params:
  - name: category
    desc: "Categoria de Workana (segmento ?category= de la URL de listado). Default 'it-programming'."
  - name: language
    desc: "Idioma de los proyectos de Workana (?language=). Default 'es'."
  - name: query
    desc: "Query libre aplicada a ambas fuentes. En Workana va como extra_query; en Upwork sobrescribe upwork_query si no esta vacia."
  - name: pages
    desc: "Numero de paginas de listado a recorrer por fuente. Default 1."
  - name: include_upwork
    desc: "Si True, scrapea Upwork ademas de Workana. Default False (selectores Upwork sin validar en vivo + requiere login); si Upwork falla, el pipeline sigue solo con Workana."
  - name: upwork_query
    desc: "Query para Upwork cuando include_upwork. Default 'custom software'. El param 'query' lo sobrescribe si se pasa."
  - name: duckdb_path
    desc: "Ruta del archivo DuckDB de persistencia. Si vacia, usa ~/.fn_freelance/freelance.duckdb (crea el directorio)."
  - name: xlsx_path
    desc: "Ruta del .xlsx de salida. Si vacia, usa ~/.fn_freelance/freelance_projects.xlsx (crea el directorio). Se sobrescribe en cada corrida."
  - name: port
    desc: "Puerto de remote debugging del Chrome que usan los scrapers (CDP). Default 9334 (perfil headless dedicado del scraping). NUNCA 9222 por defecto: ese es el chromium-personal del usuario. Para la corrida programada usa el wrapper monitor_freelance_projects_headless (levanta el Chrome headless en 9334 y lo cierra). 9333 = Chrome aislado interactivo del browser_mcp."
  - name: timeout_s
    desc: "Timeout en segundos por pagina para los scrapers (navegacion + espera de cards). Default 25.0."
output: "dict. En exito: {status:'ok', new_count:int (proyectos nuevos de esta corrida), total_in_db:int, new_projects:[...], xlsx_path:'<abs>', duckdb_path:'<abs>', sources:{workana:{count,status}, upwork:{count,status}|'skipped'}}. En error (sin lanzar): {status:'error', error:str, sources:{...}}."
---

## Ejemplo

```bash
# Para la corrida programada usa el wrapper headless (levanta Chrome en 9334 y lo
# cierra): fn run monitor_freelance_projects_headless. Este pipeline asume que YA hay
# un Chrome con remote debugging vivo en `port`.

# Contra el perfil headless dedicado (port 9334 por defecto), paths por defecto:
fn run monitor_freelance_projects

# Probar contra el Chrome aislado interactivo del browser_mcp (port 9333), paths efimeros:
fn run monitor_freelance_projects --port 9333 \
  --duckdb-path /tmp/freelance.duckdb --xlsx-path /tmp/freelance.xlsx
```

```python
import os, sys
sys.path.insert(0, os.path.join("python", "functions"))
from pipelines.monitor_freelance_projects import monitor_freelance_projects

out = monitor_freelance_projects(
    category="it-programming",
    language="es",
    pages=1,
    port=9222,  # chromium-personal logueado
)
print(out["new_count"], "proyectos nuevos;", out["total_in_db"], "en la DB")
print("Excel:", out["xlsx_path"])
```

## Cuando usarla

Monitor de captacion de clientes: detecta proyectos freelance NUEVOS de Workana
(programacion / software a medida) y los deja en DuckDB + Excel para revisar de un
vistazo. Resalta los que pintan a "software a medida" (`is_custom_software`) sin
filtrar el resto. Idempotente por `url`: re-correrlo no duplica ni pisa el
`first_seen_at`. Agendable con dag_engine (step `function:`) para una foto diaria de
oportunidades nuevas.

## Gotchas

- **Requiere un Chrome con CDP vivo en `port`**: los scrapers (Workana/Upwork son
  SPAs) renderizan via Chrome DevTools Protocol. Sin remote debugging escuchando en
  ese puerto el pipeline devuelve `status:'error'` con el detalle. Por defecto 9334
  (perfil headless dedicado, lo levanta/cierra `monitor_freelance_projects_headless`).
  NO usa 9222 (chromium-personal del usuario) por defecto. 9333 = browser_mcp para
  smoke interactivo.
- **Upwork OFF por defecto**: sus selectores no estan validados en vivo (sin sesion
  Upwork). Con `include_upwork=True`, si Upwork devuelve `status:'error'` el pipeline
  loguea un WARN a stderr y sigue solo con Workana — nunca aborta por Upwork.
- **El Excel se sobrescribe** por completo en cada corrida (`write_xlsx_sheets`). La
  fuente de verdad acumulativa es la DuckDB, no el .xlsx.
- **`first_seen_at` lo posee la DB**: el upsert usa ownership selectivo (no esta en
  `update_cols`), asi que una re-corrida conserva la primera vez que se vio cada
  proyecto. `new_count` cuenta solo urls que no existian antes de esta corrida.
- **Rate-limit / anti-bot**: scrapear muchas paginas seguidas puede disparar
  defensas de las plataformas. Mantener `pages` bajo y espaciar las corridas.
- **Skills se guardan como `skills_json`** (TEXT con JSON) porque DuckDB no usa una
  columna lista aqui; en el Excel se re-expanden a una cadena separada por comas.

## Notas

Pipeline impuro: compone seis funciones del registry sin reescribir su logica
(2 scrapers CDP del dominio browser + 3 primitivas del grupo `duckdb` + el exporter
`write_xlsx_sheets`). El flag `is_custom_software` se calcula con la constante
`CUSTOM_SW_KEYWORDS` (keywords fuertes de desarrollo a medida) sobre title + snippet
+ skills, normalizados a minusculas y sin acentos.

Validado end-to-end contra Workana real (CDP 9333) el 17/06/2026:
- Golden: `new_count=9`, `total_in_db=9`, 4 proyectos `is_custom_software=True`,
  .xlsx con hojas "Nuevos" (9 filas + cabecera) y "Todos", DuckDB con 9 filas.
- Edge dedup: 2a corrida identica -> `new_count=0`, `total_in_db` sigue en 9 (no
  duplica) y `first_seen_at` preservado (ownership del upsert por `url`).
