feat(captacion_clientes): scraping freelance en perfil headless dedicado, no chromium-personal

El monitor de captación scrapeaba Workana sobre el navegador personal del
usuario (chromium-personal, CDP 9222), interfiriendo con su navegación. El
scraping CDP debe correr siempre en un perfil headless dedicado.

- Nuevo pipeline monitor_freelance_projects_headless: levanta un Chromium
  headless aislado con perfil dedicado (~/.config/fn_scrape_chrome, CDP 9334)
  vía systemd-run, ejecuta monitor_freelance_projects contra ese puerto y
  cierra la instancia al terminar (finally). Reutiliza el patrón de lifecycle
  de ingest_market_trends_headless. Reutiliza un CDP vivo si el puerto ya
  responde (no cierra lo ajeno).
- scrape_workana_projects y monitor_freelance_projects: default de `port`
  cambiado de 9222 (chromium-personal) a 9334 (perfil dedicado). Default seguro:
  correr a pelo sin Chrome en 9334 falla limpio, no contamina el 9222 personal.

Verificado: el wrapper arranca headless en 9334, scrapea 8 proyectos reales de
Workana, cierra la instancia (9334 muerto, sin proceso colgado) y deja el 9222
personal intacto.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 20:11:26 +02:00
parent c1f355ffa5
commit bcc1fe1738
6 changed files with 466 additions and 25 deletions
@@ -5,7 +5,7 @@ lang: py
domain: browser domain: browser
version: "1.0.0" version: "1.0.0"
purity: impure purity: impure
signature: "def scrape_workana_projects(category: str = 'it-programming', language: str = 'es', extra_query: str = '', pages: int = 1, port: int = 9222, timeout_s: float = 20.0) -> dict" signature: "def scrape_workana_projects(category: str = 'it-programming', language: str = 'es', extra_query: str = '', pages: int = 1, port: int = 9334, timeout_s: float = 20.0) -> dict"
description: "Scraper de proyectos freelance de Workana (https://www.workana.com/jobs) via Chrome DevTools Protocol (CDP). Workana es una SPA Vue: el GET HTTP NO trae los proyectos (0 cards en el HTML inicial), hay que renderizar con JS. Navega con un Chrome remoto, espera a que los cards monten async y extrae cada proyecto con un evaluador JS validado. Pieza 1 de un monitor de captacion de clientes: detecta proyectos freelance nuevos sin abrir el navegador a mano. Shape unificado con el scraper hermano de Upwork. Devuelve un dict con count + lista de proyectos; nunca lanza ni inventa datos." description: "Scraper de proyectos freelance de Workana (https://www.workana.com/jobs) via Chrome DevTools Protocol (CDP). Workana es una SPA Vue: el GET HTTP NO trae los proyectos (0 cards en el HTML inicial), hay que renderizar con JS. Navega con un Chrome remoto, espera a que los cards monten async y extrae cada proyecto con un evaluador JS validado. Pieza 1 de un monitor de captacion de clientes: detecta proyectos freelance nuevos sin abrir el navegador a mano. Shape unificado con el scraper hermano de Upwork. Devuelve un dict con count + lista de proyectos; nunca lanza ni inventa datos."
tags: [market-intel, recon, flow-replay, browser, cdp, workana, scraper, freelance, spa, vue, captacion] tags: [market-intel, recon, flow-replay, browser, cdp, workana, scraper, freelance, spa, vue, captacion]
uses_functions: ["cdp_open_url_and_wait_py_pipelines", "cdp_eval_py_browser"] uses_functions: ["cdp_open_url_and_wait_py_pipelines", "cdp_eval_py_browser"]
@@ -24,7 +24,7 @@ params:
- name: pages - name: pages
desc: "Numero de paginas de listado a recorrer. Default 1. Cada pagina adicional se navega con &page=N." desc: "Numero de paginas de listado a recorrer. Default 1. Cada pagina adicional se navega con &page=N."
- name: port - name: port
desc: "Puerto de remote debugging del Chrome a usar. Default 9222 (chromium-personal de produccion). Para un Chrome aislado (smoke / recon sin mezclar sesion personal) apuntar a 9333 (el del browser_mcp)." desc: "Puerto de remote debugging del Chrome a usar. Default 9334 (perfil headless dedicado del scraping, ~/.config/fn_scrape_chrome, que levanta y cierra el wrapper monitor_freelance_projects_headless). NUNCA 9222 por defecto: ese es el chromium-personal del usuario y el scraping no debe abrir pestanas ahi. Para un Chrome aislado interactivo (smoke/recon) tambien sirve 9333 (browser_mcp)."
- name: timeout_s - name: timeout_s
desc: "Timeout (segundos) por pagina, tanto para la navegacion como para el polling de aparicion de cards. Default 20.0." desc: "Timeout (segundos) por pagina, tanto para la navegacion como para el polling de aparicion de cards. Default 20.0."
output: "dict siempre (nunca lanza). En exito: {status:'ok', source:'workana', count:N, projects:[{...}]}. Cada project_dict con claves EXACTAS: source ('workana'), job_id (slug), url (absoluta), title, budget (str|None), posted (str ej 'Hace 4 horas'), bids (str|None nº propuestas), skills (list[str]), snippet (str), country (str|None), scraped_at (ISO8601 UTC). En error (sin cards tras timeout, Chrome muerto, DOM cambiado): {status:'error', error:<mensaje claro>, source:'workana', projects:[]}. NUNCA devuelve filas falsas." output: "dict siempre (nunca lanza). En exito: {status:'ok', source:'workana', count:N, projects:[{...}]}. Cada project_dict con claves EXACTAS: source ('workana'), job_id (slug), url (absoluta), title, budget (str|None), posted (str ej 'Hace 4 horas'), bids (str|None nº propuestas), skills (list[str]), snippet (str), country (str|None), scraped_at (ISO8601 UTC). En error (sin cards tras timeout, Chrome muerto, DOM cambiado): {status:'error', error:<mensaje claro>, source:'workana', projects:[]}. NUNCA devuelve filas falsas."
@@ -40,17 +40,17 @@ file_path: "python/functions/browser/scrape_workana_projects.py"
# fn run mapea args POSICIONALMENTE a la firma (category language extra_query pages port timeout_s). # fn run mapea args POSICIONALMENTE a la firma (category language extra_query pages port timeout_s).
# NO uses flags --category/--language con fn run: el runner los toma como valores posicionales. # NO uses flags --category/--language con fn run: el runner los toma como valores posicionales.
# Smoke contra el Chrome aislado del browser_mcp (port 9333, sin login): # Perfil headless dedicado (port 9334, lo levanta el wrapper monitor_freelance_projects_headless):
fn run scrape_workana_projects it-programming es "" 1 9333 25 fn run scrape_workana_projects it-programming es "" 1 9334 25
# Produccion (chromium-personal, port 9222 por defecto): # Smoke contra el Chrome aislado interactivo del browser_mcp (port 9333, sin login):
fn run scrape_workana_projects it-programming es "" 1 9222 20 fn run scrape_workana_projects it-programming es "" 1 9333 25
``` ```
```bash ```bash
# Ejecucion directa del modulo SI acepta flags --... (argparse del __main__): # Ejecucion directa del modulo SI acepta flags --... (argparse del __main__):
python/.venv/bin/python3 python/functions/browser/scrape_workana_projects.py \ python/.venv/bin/python3 python/functions/browser/scrape_workana_projects.py \
--category it-programming --language es --port 9222 --category it-programming --language es --port 9334
``` ```
```python ```python
@@ -78,9 +78,12 @@ porque la pagina es una SPA Vue que monta los cards en runtime.
## Gotchas ## Gotchas
- **Requiere un Chrome con remote debugging vivo en `port`**: 9222 (chromium-personal - **Requiere un Chrome con remote debugging vivo en `port`**: por defecto 9334 (el
de produccion, ya activado global) o 9333 (Chrome aislado del browser_mcp). Sin perfil headless dedicado del scraping, que levanta/cierra el wrapper
Chrome escuchando devuelve `{status:'error', error:'no hay Chrome en el puerto N...'}` — no lanza. `monitor_freelance_projects_headless`). NO usa 9222 (chromium-personal del usuario)
por defecto: el scraping no abre pestanas en el navegador diario. 9333 (browser_mcp)
sirve para smoke interactivo. Sin Chrome escuchando devuelve
`{status:'error', error:'no hay Chrome en el puerto N...'}` — no lanza.
- **Workana es una SPA Vue: los cards montan ASYNC** tras la hidratacion. El load - **Workana es una SPA Vue: los cards montan ASYNC** tras la hidratacion. El load
event NO garantiza que esten en el DOM, por eso la funcion hace polling de event NO garantiza que esten en el DOM, por eso la funcion hace polling de
`document.querySelectorAll('div.project-item.js-project').length` hasta >0 o timeout. `document.querySelectorAll('div.project-item.js-project').length` hasta >0 o timeout.
@@ -198,7 +198,7 @@ def scrape_workana_projects(
language: str = "es", language: str = "es",
extra_query: str = "", extra_query: str = "",
pages: int = 1, pages: int = 1,
port: int = 9222, port: int = 9334,
timeout_s: float = 20.0, timeout_s: float = 20.0,
) -> dict: ) -> dict:
"""Scrapea proyectos freelance de Workana renderizando la SPA via CDP. """Scrapea proyectos freelance de Workana renderizando la SPA via CDP.
@@ -217,9 +217,12 @@ def scrape_workana_projects(
filtrar por palabra clave (ej. "python", "scraping"). filtrar por palabra clave (ej. "python", "scraping").
pages: Numero de paginas de listado a recorrer (1 por defecto). Cada pagina pages: Numero de paginas de listado a recorrer (1 por defecto). Cada pagina
adicional se navega con &page=N. adicional se navega con &page=N.
port: Puerto de remote debugging del Chrome a usar. Default 9222 (el port: Puerto de remote debugging del Chrome a usar. Default 9334 (el
chromium-personal de produccion). Para un Chrome aislado (smoke / recon perfil headless dedicado del scraping, ~/.config/fn_scrape_chrome, que
sin mezclar sesion personal) apunta a 9333 (el del browser_mcp). levanta y cierra el wrapper monitor_freelance_projects_headless). NUNCA
9222 por defecto: ese es el chromium-personal del usuario y el scraping
no debe abrir pestanas ahi. Para un Chrome aislado interactivo (smoke /
recon) tambien sirve 9333 (el del browser_mcp).
timeout_s: Timeout (segundos) por pagina, tanto para la navegacion como para timeout_s: Timeout (segundos) por pagina, tanto para la navegacion como para
el polling de aparicion de cards. Default 20.0. el polling de aparicion de cards. Default 20.0.
@@ -293,7 +296,7 @@ if __name__ == "__main__":
parser.add_argument("--language", default="es") parser.add_argument("--language", default="es")
parser.add_argument("--extra-query", default="") parser.add_argument("--extra-query", default="")
parser.add_argument("--pages", type=int, default=1) parser.add_argument("--pages", type=int, default=1)
parser.add_argument("--port", type=int, default=9222) parser.add_argument("--port", type=int, default=9334)
parser.add_argument("--timeout-s", type=float, default=20.0) parser.add_argument("--timeout-s", type=float, default=20.0)
args = parser.parse_args() args = parser.parse_args()
@@ -5,7 +5,7 @@ lang: py
domain: pipelines domain: pipelines
version: "1.0.0" version: "1.0.0"
purity: impure 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 = 9222, timeout_s: float = 25.0) -> dict" 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)." 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] tags: [market-intel, recon, launcher, pipelines, freelance, workana, upwork, duckdb, excel]
uses_functions: uses_functions:
@@ -42,7 +42,7 @@ params:
- name: xlsx_path - 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." desc: "Ruta del .xlsx de salida. Si vacia, usa ~/.fn_freelance/freelance_projects.xlsx (crea el directorio). Se sobrescribe en cada corrida."
- name: port - name: port
desc: "Puerto de remote debugging del Chrome que usan los scrapers (CDP). Default 9222 (chromium-personal logueado). Usa 9333 para el Chrome aislado del browser_mcp." 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 - name: timeout_s
desc: "Timeout en segundos por pagina para los scrapers (navegacion + espera de cards). Default 25.0." 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:{...}}." 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:{...}}."
@@ -51,11 +51,14 @@ output: "dict. En exito: {status:'ok', new_count:int (proyectos nuevos de esta c
## Ejemplo ## Ejemplo
```bash ```bash
# Requiere un Chrome con remote debugging vivo en el puerto indicado. # Para la corrida programada usa el wrapper headless (levanta Chrome en 9334 y lo
# Produccion (chromium-personal logueado, port 9222) con los paths por defecto: # 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 fn run monitor_freelance_projects
# Probar contra el Chrome aislado del browser_mcp (port 9333) con paths efimeros: # Probar contra el Chrome aislado interactivo del browser_mcp (port 9333), paths efimeros:
fn run monitor_freelance_projects --port 9333 \ fn run monitor_freelance_projects --port 9333 \
--duckdb-path /tmp/freelance.duckdb --xlsx-path /tmp/freelance.xlsx --duckdb-path /tmp/freelance.duckdb --xlsx-path /tmp/freelance.xlsx
``` ```
@@ -88,8 +91,10 @@ oportunidades nuevas.
- **Requiere un Chrome con CDP vivo en `port`**: los scrapers (Workana/Upwork son - **Requiere un Chrome con CDP vivo en `port`**: los scrapers (Workana/Upwork son
SPAs) renderizan via Chrome DevTools Protocol. Sin remote debugging escuchando en SPAs) renderizan via Chrome DevTools Protocol. Sin remote debugging escuchando en
ese puerto el pipeline devuelve `status:'error'` con el detalle. Produccion = 9222 ese puerto el pipeline devuelve `status:'error'` con el detalle. Por defecto 9334
(chromium-personal logueado); Chrome aislado = 9333 (browser_mcp). (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 OFF por defecto**: sus selectores no estan validados en vivo (sin sesion
Upwork). Con `include_upwork=True`, si Upwork devuelve `status:'error'` el pipeline 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. loguea un WARN a stderr y sigue solo con Workana — nunca aborta por Upwork.
@@ -226,7 +226,7 @@ def monitor_freelance_projects(
upwork_query: str = "custom software", upwork_query: str = "custom software",
duckdb_path: str = "", duckdb_path: str = "",
xlsx_path: str = "", xlsx_path: str = "",
port: int = 9222, port: int = 9334,
timeout_s: float = 25.0, timeout_s: float = 25.0,
) -> dict: ) -> dict:
"""Detecta proyectos freelance nuevos, los persiste con dedup y exporta a Excel. """Detecta proyectos freelance nuevos, los persiste con dedup y exporta a Excel.
@@ -262,7 +262,10 @@ def monitor_freelance_projects(
xlsx_path: ruta del .xlsx de salida. Si "", usa xlsx_path: ruta del .xlsx de salida. Si "", usa
~/.fn_freelance/freelance_projects.xlsx (creando el directorio). ~/.fn_freelance/freelance_projects.xlsx (creando el directorio).
port: puerto de remote debugging del Chrome a usar por los scrapers. port: puerto de remote debugging del Chrome a usar por los scrapers.
Default 9222 (chromium-personal logueado). 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, que
levanta el Chrome headless en 9334 y lo cierra al terminar.
timeout_s: timeout en segundos por pagina para los scrapers. Default 25.0. timeout_s: timeout en segundos por pagina para los scrapers. Default 25.0.
Returns: Returns:
@@ -454,7 +457,7 @@ def main() -> int:
ap.add_argument("--upwork-query", default="custom software") ap.add_argument("--upwork-query", default="custom software")
ap.add_argument("--duckdb-path", default="") ap.add_argument("--duckdb-path", default="")
ap.add_argument("--xlsx-path", default="") ap.add_argument("--xlsx-path", default="")
ap.add_argument("--port", type=int, default=9222) ap.add_argument("--port", type=int, default=9334)
ap.add_argument("--timeout-s", type=float, default=25.0) ap.add_argument("--timeout-s", type=float, default=25.0)
args = ap.parse_args() args = ap.parse_args()
@@ -0,0 +1,92 @@
---
name: monitor_freelance_projects_headless
kind: pipeline
lang: py
domain: pipelines
version: "1.0.0"
purity: impure
signature: "def monitor_freelance_projects_headless(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, profile_dir: str = '', timeout_s: float = 25.0) -> dict"
description: "Monitor de captacion de clientes freelance (Workana + Upwork -> DuckDB + Excel) en un Chrome headless AISLADO con perfil dedicado, lanzandolo y cerrandolo en cada corrida. Evita abrir pestanas en el navegador diario del usuario (chromium-personal, CDP 9222). Wrapper de monitor_freelance_projects que solo gestiona el ciclo de vida del navegador. Proyecto captacion_clientes."
tags: [market-intel, captacion_clientes, headless, cdp, freelance, scraper, recon]
uses_functions: [monitor_freelance_projects_py_pipelines]
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_headless.py"
params:
- name: category
desc: "Categoria de Workana (?category=). Default 'it-programming'."
- name: language
desc: "Idioma de los proyectos de Workana (?language=). Default 'es'."
- name: query
desc: "Query libre aplicada a ambas fuentes (extra_query en Workana; sobrescribe upwork_query en Upwork si no esta vacia). Default vacio."
- 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 (tolerante a fallo). Default False (sus selectores no estan validados en vivo y requiere login)."
- name: upwork_query
desc: "Query para Upwork cuando include_upwork. Default 'custom software'. `query` lo sobrescribe si se pasa."
- name: duckdb_path
desc: "Ruta del archivo DuckDB de persistencia con dedup por url. Vacio -> ~/.fn_freelance/freelance.duckdb (se crea el directorio)."
- name: xlsx_path
desc: "Ruta del .xlsx de salida (hojas 'Nuevos' y 'Todos'). Vacio -> ~/.fn_freelance/freelance_projects.xlsx (se crea el directorio)."
- name: port
desc: "Puerto de remote-debugging del Chrome headless aislado que este wrapper lanza y al que apunta el monitor. Default 9334 (NO el 9222 del navegador diario)."
- name: profile_dir
desc: "user-data-dir dedicado del Chrome aislado. Vacio -> ~/.config/fn_scrape_chrome (se crea si no existe). Perfil persistente entre corridas."
- name: timeout_s
desc: "Timeout en segundos por pagina para los scrapers. Default 25.0."
output: "dict que SIEMPRE incluye {status: 'ok'|'error', port, profile_dir, launched: bool, closed: bool} y, en exito, las claves del resultado de monitor_freelance_projects (new_count, total_in_db, new_projects, xlsx_path, duckdb_path, sources). En error sin lanzar incluye `error`. El finally cierra siempre la instancia que lanzo (closed=True); si reutiliza un CDP ya vivo en el puerto, launched=False y closed=False (no cierra lo ajeno). Nunca lanza excepcion al caller."
---
## Ejemplo
```bash
# Monitor freelance en Chrome headless aislado (lanzar -> scrape -> cerrar).
# OJO: fn run pasa los args POSICIONALES, en el orden de la firma:
# category, language, query, pages, ...
fn run monitor_freelance_projects_headless it-programming es "" 1
# -> {"status":"ok","port":9334,"profile_dir":"/home/<user>/.config/fn_scrape_chrome",
# "launched":true,"closed":true,"new_count":N,"total_in_db":M,
# "xlsx_path":"/home/<user>/.fn_freelance/freelance_projects.xlsx",
# "duckdb_path":"/home/<user>/.fn_freelance/freelance.duckdb",
# "sources":{"workana":{"count":N,"status":"ok"},"upwork":"skipped"}}
```
Invocacion directa del modulo (acepta flags `--category`/`--language`/`--pages`/...):
```bash
python/.venv/bin/python3 python/functions/pipelines/monitor_freelance_projects_headless.py \
--category it-programming --language es --pages 2
```
## Cuando usarla
Usala para la ingesta diaria/programada (dag_engine) del monitor de captacion freelance del
proyecto captacion_clientes cuando NO quieras que el scraping abra pestanas en tu navegador
diario. Levanta su propio Chromium headless con perfil dedicado (puerto 9334) y lo cierra al
terminar — el navegador personal (`chromium-personal`, CDP 9222) queda intacto. Es el
reemplazo de llamar `monitor_freelance_projects` con `--port 9222` a pelo (que usaria el
navegador interactivo logueado).
## Gotchas
- **Impura: lanza y mata Chrome.** Arranca un Chromium headless via `systemd-run --user`
(scope `fnscrape_dag_<port>`); si `systemd-run` no esta, cae a `subprocess.Popen` con grupo
de proceso propio. Lanzarlo con `exec` directo desde el agente da **exit-144** — por eso
systemd-run. En el `finally` siempre cierra lo que lanzo (`systemctl --user stop` del
scope/service + respaldo `pkill -f "user-data-dir=<perfil>"`) y verifica con un GET final
que el puerto ya no responde (`closed`).
- **Perfil dedicado persistente.** `~/.config/fn_scrape_chrome` sobrevive entre corridas
(cookies/cache del scraping). No se borra. Borralo a mano si quieres sesion limpia.
- **Reutiliza CDP existente.** Si el puerto ya responde al arrancar, NO lanza otro Chrome:
reutiliza el vivo y `launched=False` + `closed=False` (no cierra algo que no abrio).
- **Workana puede cambiar selectores o bloquear.** Workana es una SPA Vue: si cambia sus
selectores o aplica anti-bot, el monitor devuelve `status: error` (sin inventar datos),
pero el Chrome aislado **igual se cierra** en el finally. Upwork esta en `skipped` por
defecto (selectores no validados en vivo + login).
@@ -0,0 +1,335 @@
"""monitor_freelance_projects_headless — monitor freelance en un Chrome headless aislado.
Wrapper de `monitor_freelance_projects` (pipeline del proyecto captacion_clientes) que lanza
un Chromium **headless** con un **perfil dedicado** y un puerto de remote-debugging propio,
corre el monitor de proyectos freelance apuntando a ESE puerto, y **cierra la instancia al
terminar** — siempre, incluso si el scraping falla.
Motivo: el scraping NO debe abrir pestañas en el navegador diario del usuario
(`chromium-personal`, puerto 9222). Norma: perfil dedicado + headless + cerrar al terminar.
El Chrome se lanza vía `systemd-run --user` (un scope transitorio), porque lanzar chromium
con un `exec`/`Popen` directo desde el proceso del agente da exit-144 cuando hereda el grupo
de control del agente. Si `systemd-run` no está disponible, se cae a `subprocess.Popen` en un
grupo de proceso nuevo (`start_new_session=True`).
A diferencia de `ingest_market_trends_headless` (que itera fuentes CDP), este wrapper llama
UNA sola vez al pipeline `monitor_freelance_projects`, pasándole el puerto del Chrome aislado.
El pipeline scrapea Workana (y opcionalmente Upwork) por CDP, deduplica en DuckDB y exporta a
Excel; este wrapper solo gestiona el ciclo de vida del navegador.
"""
import argparse
import json
import os
import shutil
import signal
import subprocess
import sys
import time
import urllib.request
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
sys.path.insert(0, os.path.join(ROOT, "python", "functions"))
from pipelines.monitor_freelance_projects import monitor_freelance_projects # noqa: E402
DEFAULT_PORT = 9334
DEFAULT_PROFILE = "~/.config/fn_scrape_chrome"
# Candidatos de binario chromium/chrome. shutil.which primero (respeta PATH), luego
# rutas absolutas conocidas del sistema (el `chromium` del usuario suele ser un alias de
# shell no visible a subprocess, y el binario real vive en /usr/lib/chromium/chromium).
_CHROME_NAMES = ("chromium", "chromium-browser", "google-chrome", "google-chrome-stable")
_CHROME_ABS = (
"/usr/bin/chromium",
"/usr/lib/chromium/chromium",
"/usr/bin/chromium-browser",
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/snap/bin/chromium",
)
def _find_chrome() -> str | None:
"""Devuelve la ruta a un binario chromium/chrome ejecutable, o None."""
for name in _CHROME_NAMES:
path = shutil.which(name)
if path:
return path
for path in _CHROME_ABS:
if os.path.isfile(path) and os.access(path, os.X_OK):
return path
return None
def _cdp_alive(port: int, timeout: float = 1.0) -> bool:
"""True si el endpoint CDP responde en 127.0.0.1:<port>/json/version."""
url = f"http://127.0.0.1:{port}/json/version"
try:
with urllib.request.urlopen(url, timeout=timeout) as resp:
return 200 <= resp.status < 300
except Exception: # noqa: BLE001
return False
def _wait_cdp(port: int, deadline_s: float = 12.0) -> bool:
"""Espera a que el CDP responda hasta `deadline_s` (sondea cada 0.5s)."""
end = time.time() + deadline_s
while time.time() < end:
if _cdp_alive(port):
return True
time.sleep(0.5)
return False
def _chrome_args(chrome_bin: str, port: int, profile_dir: str) -> list[str]:
return [
chrome_bin,
"--headless=new",
"--disable-gpu",
f"--remote-debugging-port={port}",
f"--user-data-dir={profile_dir}",
"--no-first-run",
"--no-default-browser-check",
"--remote-allow-origins=*",
"--disable-extensions",
]
def _launch(chrome_bin: str, port: int, profile_dir: str) -> tuple[str, int | None]:
"""Lanza Chrome headless aislado. Devuelve (mecanismo, pid).
mecanismo: 'systemd' (scope transitorio) o 'popen' (grupo de proceso propio).
pid: solo poblado en modo 'popen' (para poder matar el grupo en el cierre).
"""
unit = f"fnscrape_dag_{port}"
systemd_run = shutil.which("systemd-run")
if systemd_run:
cmd = [
systemd_run, "--user", "--quiet", "--collect", f"--unit={unit}",
*_chrome_args(chrome_bin, port, profile_dir),
]
try:
subprocess.run(cmd, check=True, timeout=15,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return "systemd", None
except Exception: # noqa: BLE001
# systemd-run falló (sin --user bus, etc.) -> fallback a Popen.
pass
proc = subprocess.Popen(
_chrome_args(chrome_bin, port, profile_dir),
start_new_session=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
return "popen", proc.pid
def _close(mechanism: str, pid: int | None, port: int, profile_dir: str) -> bool:
"""Cierra la instancia que ESTE wrapper lanzó. Devuelve True si el puerto ya no responde."""
unit = f"fnscrape_dag_{port}"
if mechanism == "systemd":
systemctl = shutil.which("systemctl")
if systemctl:
for kind in (f"{unit}.scope", f"{unit}.service"):
try:
subprocess.run([systemctl, "--user", "stop", kind],
timeout=10, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
except Exception: # noqa: BLE001
pass
elif mechanism == "popen" and pid is not None:
try:
pgid = os.getpgid(pid)
os.killpg(pgid, signal.SIGTERM)
for _ in range(20): # hasta ~2s para salida limpia
time.sleep(0.1)
if not _cdp_alive(port):
break
if _cdp_alive(port):
os.killpg(pgid, signal.SIGKILL)
except ProcessLookupError:
pass
except Exception: # noqa: BLE001
pass
# Respaldo: matar cualquier chromium colgado de este perfil concreto.
pkill = shutil.which("pkill")
if pkill:
try:
subprocess.run([pkill, "-f", f"user-data-dir={profile_dir}"],
timeout=10, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
except Exception: # noqa: BLE001
pass
# Esperar a que el puerto deje de responder (cierre asíncrono del cgroup).
for _ in range(20): # hasta ~2s
if not _cdp_alive(port):
return True
time.sleep(0.1)
return not _cdp_alive(port)
def monitor_freelance_projects_headless(
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 = DEFAULT_PORT,
profile_dir: str = "",
timeout_s: float = 25.0,
) -> dict:
"""Lanza un Chrome headless aislado, corre el monitor freelance y lo cierra al terminar.
Pipeline IMPURO: arranca su propio Chromium headless con perfil dedicado, ejecuta
`monitor_freelance_projects` apuntando a ESE puerto, y en el `finally` cierra la
instancia que lanzó. Nunca abre pestañas en el navegador diario del usuario
(`chromium-personal`, CDP 9222). NUNCA lanza excepción al caller: cualquier fallo se
refleja en `status`/`error` y el navegador se cierra igual.
Args:
category: categoría de Workana (?category=). Default "it-programming".
language: idioma de los proyectos de Workana (?language=). Default "es".
query: query libre aplicada a ambas fuentes (extra_query en Workana; sobrescribe
upwork_query en Upwork si no está vacía).
pages: número de páginas de listado a recorrer por fuente. Default 1.
include_upwork: si True, scrapea Upwork además de Workana. Default False.
upwork_query: query para Upwork cuando include_upwork. Default "custom software".
duckdb_path: ruta del archivo DuckDB. Vacío -> ~/.fn_freelance/freelance.duckdb.
xlsx_path: ruta del .xlsx de salida. Vacío -> ~/.fn_freelance/freelance_projects.xlsx.
port: puerto de remote-debugging del Chrome headless aislado. Default 9334.
profile_dir: user-data-dir dedicado. Vacío -> ~/.config/fn_scrape_chrome.
timeout_s: timeout en segundos por página para los scrapers. Default 25.0.
Returns:
dict que SIEMPRE incluye {status, port, profile_dir, launched, closed} y, en éxito,
las claves del resultado de `monitor_freelance_projects` (new_count, total_in_db,
new_projects, xlsx_path, duckdb_path, sources, ...). En error sin lanzar incluye
`error`. El finally cierra siempre la instancia que lanzó (no la que reutiliza).
"""
if not profile_dir:
profile_dir = os.path.expanduser(DEFAULT_PROFILE)
profile_dir = os.path.abspath(os.path.expanduser(profile_dir))
os.makedirs(profile_dir, exist_ok=True)
out: dict = {
"status": "error",
"port": port,
"profile_dir": profile_dir,
"launched": False,
"closed": False,
}
mechanism = ""
pid: int | None = None
reuse = False
# 1) Si ya hay un CDP vivo en el puerto, reutilizarlo (no lo cerraremos).
if _cdp_alive(port):
reuse = True
else:
chrome_bin = _find_chrome()
if not chrome_bin:
out["error"] = (
"no se encontró binario chromium/chrome "
f"(probados: {', '.join(_CHROME_NAMES)} + rutas absolutas conocidas)"
)
return out
try:
mechanism, pid = _launch(chrome_bin, port, profile_dir)
out["launched"] = True
except Exception as exc: # noqa: BLE001
out["error"] = f"fallo al lanzar chromium: {exc}"
return out
# 2) Esperar a que el CDP responda.
if not _wait_cdp(port, deadline_s=12.0):
out["error"] = f"el CDP no respondió en 127.0.0.1:{port} tras 12s"
out["closed"] = _close(mechanism, pid, port, profile_dir)
return out
# 3) Correr el monitor freelance contra el puerto del Chrome aislado.
try:
res = monitor_freelance_projects(
category=category,
language=language,
query=query,
pages=pages,
include_upwork=include_upwork,
upwork_query=upwork_query,
duckdb_path=duckdb_path,
xlsx_path=xlsx_path,
port=port,
timeout_s=timeout_s,
)
if isinstance(res, dict):
# Mezclar el resultado del monitor; las claves de lifecycle (status, port,
# profile_dir, launched, closed) se restauran/recalculan abajo.
out.update(res)
else:
out["error"] = f"monitor_freelance_projects devolvió un tipo inesperado: {type(res).__name__}"
out["status"] = "error"
except Exception as exc: # noqa: BLE001 — el wrapper nunca lanza al caller
out["error"] = f"{type(exc).__name__}: {exc}"
out["status"] = "error"
finally:
# 4) Restaurar las claves de lifecycle que `out.update(res)` pudo pisar.
out["port"] = port
out["profile_dir"] = profile_dir
out["launched"] = bool(out.get("launched"))
# 5) Cerrar SIEMPRE lo que nosotros lanzamos (no si reutilizamos uno ajeno).
if out["launched"] and not reuse:
out["closed"] = _close(mechanism, pid, port, profile_dir)
else:
out["closed"] = False
return out
def main() -> int:
ap = argparse.ArgumentParser(
description=(
"Monitor de captacion freelance (Workana + Upwork -> DuckDB + Excel) en un "
"Chrome headless AISLADO con perfil dedicado."
)
)
ap.add_argument("--category", default="it-programming")
ap.add_argument("--language", default="es")
ap.add_argument("--query", default="")
ap.add_argument("--pages", type=int, default=1)
ap.add_argument("--include-upwork", action="store_true")
ap.add_argument("--upwork-query", default="custom software")
ap.add_argument("--duckdb-path", default="")
ap.add_argument("--xlsx-path", default="")
ap.add_argument("--port", type=int, default=DEFAULT_PORT,
help="Puerto remote-debugging del Chrome aislado (default 9334).")
ap.add_argument("--profile-dir", default="",
help="user-data-dir dedicado (vacío -> ~/.config/fn_scrape_chrome).")
ap.add_argument("--timeout-s", type=float, default=25.0)
args = ap.parse_args()
result = monitor_freelance_projects_headless(
category=args.category,
language=args.language,
query=args.query,
pages=args.pages,
include_upwork=args.include_upwork,
upwork_query=args.upwork_query,
duckdb_path=args.duckdb_path,
xlsx_path=args.xlsx_path,
port=args.port,
profile_dir=args.profile_dir,
timeout_s=args.timeout_s,
)
print(json.dumps(result, ensure_ascii=False))
return 0 if result.get("status") == "ok" else 1
if __name__ == "__main__":
sys.exit(main())