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:
@@ -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:<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).
|
||||
# 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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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:'<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
|
||||
|
||||
```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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user