From bcc1fe17387d9081136d46ae9efdc2f1812171da Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Mon, 22 Jun 2026 20:11:26 +0200 Subject: [PATCH] feat(captacion_clientes): scraping freelance en perfil headless dedicado, no chromium-personal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../browser/scrape_workana_projects.md | 23 +- .../browser/scrape_workana_projects.py | 13 +- .../pipelines/monitor_freelance_projects.md | 19 +- .../pipelines/monitor_freelance_projects.py | 9 +- .../monitor_freelance_projects_headless.md | 92 +++++ .../monitor_freelance_projects_headless.py | 335 ++++++++++++++++++ 6 files changed, 466 insertions(+), 25 deletions(-) create mode 100644 python/functions/pipelines/monitor_freelance_projects_headless.md create mode 100644 python/functions/pipelines/monitor_freelance_projects_headless.py diff --git a/python/functions/browser/scrape_workana_projects.md b/python/functions/browser/scrape_workana_projects.md index e12fc233..0b83972b 100644 --- a/python/functions/browser/scrape_workana_projects.md +++ b/python/functions/browser/scrape_workana_projects.md @@ -5,7 +5,7 @@ lang: py domain: browser version: "1.0.0" 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." 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"] @@ -24,7 +24,7 @@ params: - name: pages desc: "Numero de paginas de listado a recorrer. Default 1. Cada pagina adicional se navega con &page=N." - 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 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:, 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). # 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): -fn run scrape_workana_projects it-programming es "" 1 9333 25 +# Perfil headless dedicado (port 9334, lo levanta el wrapper monitor_freelance_projects_headless): +fn run scrape_workana_projects it-programming es "" 1 9334 25 -# Produccion (chromium-personal, port 9222 por defecto): -fn run scrape_workana_projects it-programming es "" 1 9222 20 +# Smoke contra el Chrome aislado interactivo del browser_mcp (port 9333, sin login): +fn run scrape_workana_projects it-programming es "" 1 9333 25 ``` ```bash # Ejecucion directa del modulo SI acepta flags --... (argparse del __main__): 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 @@ -78,9 +78,12 @@ porque la pagina es una SPA Vue que monta los cards en runtime. ## Gotchas -- **Requiere un Chrome con remote debugging vivo en `port`**: 9222 (chromium-personal - de produccion, ya activado global) o 9333 (Chrome aislado del browser_mcp). Sin - Chrome escuchando devuelve `{status:'error', error:'no hay Chrome en el puerto N...'}` — no lanza. +- **Requiere un Chrome con remote debugging vivo en `port`**: por defecto 9334 (el + perfil headless dedicado del scraping, que levanta/cierra el wrapper + `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 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. diff --git a/python/functions/browser/scrape_workana_projects.py b/python/functions/browser/scrape_workana_projects.py index e046ca58..8bf9046a 100644 --- a/python/functions/browser/scrape_workana_projects.py +++ b/python/functions/browser/scrape_workana_projects.py @@ -198,7 +198,7 @@ def scrape_workana_projects( language: str = "es", extra_query: str = "", pages: int = 1, - port: int = 9222, + port: int = 9334, timeout_s: float = 20.0, ) -> dict: """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"). pages: Numero de paginas de listado a recorrer (1 por defecto). Cada pagina adicional se navega con &page=N. - port: Puerto de remote debugging del Chrome a usar. Default 9222 (el - chromium-personal de produccion). Para un Chrome aislado (smoke / recon - sin mezclar sesion personal) apunta a 9333 (el del browser_mcp). + port: Puerto de remote debugging del Chrome a usar. Default 9334 (el + 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 (el del browser_mcp). timeout_s: Timeout (segundos) por pagina, tanto para la navegacion como para 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("--extra-query", default="") 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) args = parser.parse_args() diff --git a/python/functions/pipelines/monitor_freelance_projects.md b/python/functions/pipelines/monitor_freelance_projects.md index 9585a7c9..29f6ca93 100644 --- a/python/functions/pipelines/monitor_freelance_projects.md +++ b/python/functions/pipelines/monitor_freelance_projects.md @@ -5,7 +5,7 @@ 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 = 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)." tags: [market-intel, recon, launcher, pipelines, freelance, workana, upwork, duckdb, excel] uses_functions: @@ -42,7 +42,7 @@ params: - 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 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 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:'', duckdb_path:'', 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 ```bash -# Requiere un Chrome con remote debugging vivo en el puerto indicado. -# Produccion (chromium-personal logueado, port 9222) con los paths por defecto: +# 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 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 \ --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 SPAs) renderizan via Chrome DevTools Protocol. Sin remote debugging escuchando en - ese puerto el pipeline devuelve `status:'error'` con el detalle. Produccion = 9222 - (chromium-personal logueado); Chrome aislado = 9333 (browser_mcp). + 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. diff --git a/python/functions/pipelines/monitor_freelance_projects.py b/python/functions/pipelines/monitor_freelance_projects.py index 68872e21..f1686f80 100644 --- a/python/functions/pipelines/monitor_freelance_projects.py +++ b/python/functions/pipelines/monitor_freelance_projects.py @@ -226,7 +226,7 @@ def monitor_freelance_projects( upwork_query: str = "custom software", duckdb_path: str = "", xlsx_path: str = "", - port: int = 9222, + port: int = 9334, timeout_s: float = 25.0, ) -> dict: """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 ~/.fn_freelance/freelance_projects.xlsx (creando el directorio). 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. Returns: @@ -454,7 +457,7 @@ def main() -> int: 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=9222) + ap.add_argument("--port", type=int, default=9334) ap.add_argument("--timeout-s", type=float, default=25.0) args = ap.parse_args() diff --git a/python/functions/pipelines/monitor_freelance_projects_headless.md b/python/functions/pipelines/monitor_freelance_projects_headless.md new file mode 100644 index 00000000..1387cb27 --- /dev/null +++ b/python/functions/pipelines/monitor_freelance_projects_headless.md @@ -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//.config/fn_scrape_chrome", +# "launched":true,"closed":true,"new_count":N,"total_in_db":M, +# "xlsx_path":"/home//.fn_freelance/freelance_projects.xlsx", +# "duckdb_path":"/home//.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_`); 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="`) 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). diff --git a/python/functions/pipelines/monitor_freelance_projects_headless.py b/python/functions/pipelines/monitor_freelance_projects_headless.py new file mode 100644 index 00000000..0f060f90 --- /dev/null +++ b/python/functions/pipelines/monitor_freelance_projects_headless.py @@ -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:/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())