4 Commits

Author SHA1 Message Date
egutierrez bcc1fe1738 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>
2026-06-22 20:11:26 +02:00
egutierrez c1f355ffa5 Merge orq/fleet-detect: detect_fleet_context ($TMUX) + spawn auto-detecta socket + hook CONTEXTO FLEET + doctrina (report 0041) 2026-06-21 21:55:11 +02:00
egutierrez 237f763c19 Merge orq/img3d-registry-funcs: promover remove_background al registry + mask en depth_to_relief_glb (grupo img-to-3d, report 0040) 2026-06-21 21:51:13 +02:00
egutierrez 3cf8b21fea feat(datascience): promover remove_background al registry + mask en depth_to_relief_glb (grupo img-to-3d)
Completa la promoción del flujo imagen->3D al registry (grupo de capacidad
img-to-3d), extraído de la app img_to_3d_webapp.

- remove_background_py_datascience (nueva): elimina el fondo con cascada
  rembg/U2Net -> OpenCV GrabCut -> umbral NumPy, compone el objeto sobre gris
  neutro y devuelve image + mask + engine. Impura, nunca lanza. Adaptada de
  backend/bg_removal.py con firma de ruta (image_path) y salida dict, demo CLI
  JSON-serializable.
- depth_to_relief_glb_py_datascience (v1.1.0): añade el parámetro opcional mask
  para recortar la malla de relieve al objeto (descarta las caras del fondo),
  cerrando la cadena con remove_background. Aditivo (mask=None = comportamiento
  previo), fiel al original de backend/depth.py.
- docs/capabilities/img-to-3d.md: incorpora remove_background como paso 0
  (pre-proceso), actualiza el flujo a 3 pasos encadenados, la tabla de funciones
  (4), el ejemplo end-to-end con mask y las deps (rembg/opencv).
- docs/capabilities/INDEX.md: conteo del grupo 3 -> 4.

Las dos funciones ya presentes (estimate_image_depth, depth_to_relief_glb) y el
pipeline build_relief_glb_from_image fueron promovidas en una ronda previa.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:43:08 +02:00
12 changed files with 834 additions and 46 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
| [sink](sink.md) | 11 | Funciones que escriben datos a destino externo (BD, dashboard, alerta, email). Nodos output |
| [validator](validator.md) | 6 | Funciones que verifican datos/config contra reglas. Pre-flight de sinks y gates en DAGs |
| [navegator](navegator.md) | 4 | Automatización de browser via CDP + AX tree + LLM: obtener, limpiar, chunkear AX tree y llamar a Claude CLI |
| [img-to-3d](img-to-3d.md) | 3 | Imagen 2D -> modelo 3D: profundidad monocular (Depth-Anything-V2) + malla de relieve texturizada exportada a .glb, con pipeline one-shot. Produce el glb que mesh-3d consume/renderiza |
| [img-to-3d](img-to-3d.md) | 4 | Imagen 2D -> modelo 3D: recorte de fondo (rembg/GrabCut/umbral) + profundidad monocular (Depth-Anything-V2) + malla de relieve texturizada exportada a .glb, con pipeline one-shot. Produce el glb que mesh-3d consume/renderiza |
| [whatsapp](whatsapp.md) | 3 | Operar WhatsApp Web por CDP sobre la pestaña existente (sin ventana ni foco): buscar/abrir chat, leer conversacion, enviar texto. Compone 4 primitivas CDP-Python (cdp_eval/type_chars/press_key/click_xy). No HTTP: WhatsApp usa WebSocket + cifrado E2E |
| [cpp-dashboard-viz](cpp-dashboard-viz.md) | 10 | Primitivas C++ ImGui para dashboards: kpi_card, sparkline, line/bar/scatter/pie/heatmap/histogram, panel containers |
| [agents](agents.md) | 3 | Orquestar agentes Claude headless en git worktrees: launch, cleanup, DoD evidence schema audit |
+30 -16
View File
@@ -10,24 +10,27 @@ partir de una sola foto se estima un mapa de profundidad monocular con un modelo
reconstruye una malla de relieve (heightmap) texturizada con la imagen original, exportada como
`.glb` cargable por cualquier visor glTF (three.js `useGLTF`/`GLTFLoader`, Babylon, model-viewer).
Promovido desde la app `img_to_3d_webapp` (su backend incrustaba estas dos funciones; ver su
`backend/depth.py`). El flujo canonico es de **dos pasos encadenados**:
Promovido desde la app `img_to_3d_webapp` (su backend incrustaba estas funciones; ver
`backend/depth.py` y `backend/bg_removal.py`). El flujo canonico encadena un pre-proceso opcional
de fondo con los dos pasos de reconstruccion:
```
estimate_image_depth (imagen -> depth+image) -> depth_to_relief_glb (depth+image -> .glb)
[remove_background (imagen -> rgb+mask)] -> estimate_image_depth (imagen -> depth+image) -> depth_to_relief_glb (depth+image[+mask] -> .glb)
```
## Funciones
| ID | Firma corta | Que hace |
|---|---|---|
| `remove_background_py_datascience` | `remove_background(image_path, engine?) -> dict` | **Pre-proceso (paso 0).** Elimina el fondo en cascada rembg -> GrabCut -> umbral y compone el objeto sobre gris neutro. Devuelve `image` PIL + `mask` ndarray. La `mask` se pasa a `depth_to_relief_glb` para recortar la malla al objeto. |
| `estimate_image_depth_py_datascience` | `estimate_image_depth(image_path, model_name?, device?, use_cache?) -> dict` | Estima profundidad monocular con Depth-Anything-V2 (GPU/CPU). Devuelve `depth` ndarray [0,1] + `image` PIL. Paso 1. |
| `depth_to_relief_glb_py_datascience` | `depth_to_relief_glb(image, depth, out_glb_path, z_scale?, max_dim?) -> dict` | Convierte `depth`+`image` en una malla de relieve texturizada y la exporta a `.glb`. Paso 2. |
| `build_relief_glb_from_image_py_pipelines` | `build_relief_glb_from_image(image_path, out_glb_path, model_name?, device?, z_scale?, max_dim?) -> dict` | **Pipeline one-shot**: compone los dos pasos en una sola llamada (imagen -> .glb). Salida JSON-serializable, apta para `fn run`. |
| `depth_to_relief_glb_py_datascience` | `depth_to_relief_glb(image, depth, out_glb_path, z_scale?, max_dim?, mask?) -> dict` | Convierte `depth`+`image` en una malla de relieve texturizada y la exporta a `.glb`. Con `mask` opcional recorta las caras del fondo. Paso 2. |
| `build_relief_glb_from_image_py_pipelines` | `build_relief_glb_from_image(image_path, out_glb_path, model_name?, device?, z_scale?, max_dim?) -> dict` | **Pipeline one-shot**: compone estimacion + relieve en una sola llamada (imagen -> .glb). Salida JSON-serializable, apta para `fn run`. |
Las tres son **impuras** (cargan modelo / GPU / escriben archivo), devuelven `dict` con `status`
Las cuatro son **impuras** (cargan modelo / GPU / escriben archivo), devuelven `dict` con `status`
(`ok`/`error`) y **nunca lanzan**: los fallos vuelven como `{status:'error', error:str}`. El
pipeline ademas marca `stage` (`estimate`/`relief`) en el error.
pipeline ademas marca `stage` (`estimate`/`relief`) en el error. `remove_background` en
`engine="auto"` nunca falla (cae al umbral NumPy puro sin deps externas).
## Ejemplo canonico (end-to-end imagen → glb)
@@ -37,17 +40,24 @@ pipeline ademas marca `stage` (`estimate`/`relief`) en el error.
# ausentes en el venv de vision. Ver "Fronteras / gotchas".
import sys
sys.path.insert(0, "python/functions/datascience")
from remove_background import remove_background
from estimate_image_depth import estimate_image_depth
from depth_to_relief_glb import depth_to_relief_glb
IMG = "apps/img_to_3d_webapp/samples/cats.jpg"
OUT = "/tmp/cats_relief.glb"
# Paso 0 (opcional pero recomendado): aislar el objeto del fondo. La mask recorta la malla.
cut = remove_background(IMG) # engine='auto' -> rembg -> grabcut -> umbral
assert cut["status"] == "ok"
print(cut["engine"], cut["fg_fraction"]) # p.ej. rembg:u2net 0.42
est = estimate_image_depth(IMG) # device='auto' -> GPU si hay
assert est["status"] == "ok"
# est["depth"]: ndarray HxW float32 [0,1] (1=mas cerca) | est["image"]: PIL.Image RGB
res = depth_to_relief_glb(est["image"], est["depth"], OUT, z_scale=0.35, max_dim=220)
# Pasando la mask del paso 0, las caras del fondo se descartan: malla solo del objeto.
res = depth_to_relief_glb(est["image"], est["depth"], OUT, z_scale=0.35, max_dim=220, mask=cut["mask"])
assert res["status"] == "ok"
print(res["glb_path"], res["vertices"], res["faces"]) # /tmp/cats_relief.glb 36300 71832
# OUT es un glTF binario valido: trimesh.load(OUT) devuelve una Scene texturizada.
@@ -70,15 +80,19 @@ O en una sola llamada con el pipeline (recomendado para fn run / Launcher TUI):
- **No cubre el render/visualizacion.** Producir el `.glb` es el limite del grupo. Cargarlo y
subirlo a GPU (OpenGL) en una app C++/ImGui es el grupo **`mesh-3d`** (`gltf_load_mesh_cpp_gfx`
carga justamente este tipo de `.glb`). img-to-3d **produce**; mesh-3d **consume/renderiza**.
- **Deps pesadas y de dos mundos.** Requiere `torch`+`transformers` (vision) y `trimesh` (mesh),
que hoy viven en el venv de `img_to_3d_webapp`, NO en el venv del registry. Ademas el
`datascience.__init__` arrastra deps de scrapers (`bs4`...) que no estan en el venv de vision,
por eso el import es **plano** (al modulo) y no via el paquete. `fn run` de estas funciones
exige un venv que combine ambos mundos (torch + transformers + trimesh + las deps del dominio
datascience). Ver gotchas en cada `.md`.
- **Deps pesadas y de dos mundos.** Requiere `torch`+`transformers` (vision), `trimesh` (mesh) y,
para `remove_background`, `rembg`+`onnxruntime` (segmentacion) y `opencv-python` (GrabCut) —
todas opcionales: el umbral de `remove_background` es NumPy puro. Hoy viven en el venv de
`img_to_3d_webapp`, NO en el venv del registry. Ademas el `datascience.__init__` arrastra deps
de scrapers (`bs4`...) que no estan en el venv de vision, por eso el import es **plano** (al
modulo) y no via el paquete. `fn run` de estas funciones exige un venv que combine ambos mundos
(torch + transformers + trimesh + rembg/opencv + las deps del dominio datascience). Ver gotchas
en cada `.md`.
## Prerequisitos
- GPU NVIDIA + CUDA recomendada (corre en CPU pero lento). Primera ejecucion descarga los pesos
del modelo a `~/.cache/huggingface/` (cientos de MB segun la variante).
- Paquetes: `torch`, `transformers`, `trimesh`, `pillow`, `numpy`.
del modelo de profundidad a `~/.cache/huggingface/` y el de `rembg` (U2Net ~170 MB) a su cache.
- Paquetes: `torch`, `transformers`, `trimesh`, `pillow`, `numpy`. Para el recorte de fondo de
mayor calidad: `rembg` (+`onnxruntime`) y `opencv-python` (ambos opcionales; sin ellos
`remove_background` cae al umbral NumPy).
@@ -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()
@@ -3,10 +3,10 @@ name: depth_to_relief_glb
kind: function
lang: py
domain: datascience
version: "1.0.0"
version: "1.1.0"
purity: impure
signature: "def depth_to_relief_glb(image: Image.Image, depth: np.ndarray, out_glb_path: str, z_scale: float = 0.35, max_dim: int = 220) -> dict"
description: "Construye una malla de relieve (heightmap) texturizada a partir de un mapa de profundidad + la imagen original y la exporta como glTF binario (.glb). El depth se vuelve el eje Z de un grid regular de vertices y la imagen se mapea como textura UV. Paso 2 del flujo img->3D (grupo img-to-3d): consume la salida de estimate_image_depth."
signature: "def depth_to_relief_glb(image: Image.Image, depth: np.ndarray, out_glb_path: str, z_scale: float = 0.35, max_dim: int = 220, mask: np.ndarray | None = None) -> dict"
description: "Construye una malla de relieve (heightmap) texturizada a partir de un mapa de profundidad + la imagen original y la exporta como glTF binario (.glb). El depth se vuelve el eje Z de un grid regular de vertices y la imagen se mapea como textura UV. Con mask opcional recorta la malla al objeto (descarta las caras del fondo). Paso 2 del flujo img->3D (grupo img-to-3d): consume la salida de estimate_image_depth y, opcionalmente, la mask de remove_background."
tags: [img-to-3d, datascience, mesh, glb, gltf, relief, heightmap, trimesh, 3d, texture]
uses_functions: []
uses_types: []
@@ -25,7 +25,9 @@ params:
desc: "Amplitud del relieve como fraccion del lado de la malla (default 0.35). Mayor = relieve mas pronunciado/exagerado."
- name: max_dim
desc: "Lado maximo del grid tras downsample bilineal (default 220, ~48k vertices / ~96k caras). Controla resolucion de la malla vs tamano del .glb. Imagenes mayores se reducen; menores se dejan igual."
output: "dict. Exito: {status:'ok', glb_path:str, vertices:int, faces:int, height:int, width:int}. Error: {status:'error', error:str} (depth con forma invalida, directorio de salida inexistente, fallo de trimesh.export). No lanza."
- name: mask
desc: "Mascara opcional HxW (0..255, 255=objeto), tipicamente la 'mask' de remove_background. Si se pasa, se reescala al grid (NEAREST), el fondo se aplana a Z=0 y las caras cuyos tres vertices caen en el fondo se descartan: la malla queda recortada al objeto. None (default) = malla del frame completo (relieve incluido el fondo)."
output: "dict. Exito: {status:'ok', glb_path:str, vertices:int, faces:int, height:int, width:int}. Con mask, 'faces' es menor (solo caras del objeto); 'vertices' no cambia (el grid completo se conserva). Error: {status:'error', error:str} (depth con forma invalida, directorio de salida inexistente, fallo de trimesh.export). No lanza."
tested: false
tests: []
test_file_path: ""
@@ -81,3 +83,14 @@ suavizar el relieve.
- **Import plano**: importa el modulo directo, NO `from datascience import ...` (el `__init__` del
paquete arrastra deps de otros dominios ausentes en el venv de vision). Ver misma gotcha en
`estimate_image_depth`.
- **mask opcional (v1.1.0)**: pasa la `mask` de `remove_background` para recortar la malla al
objeto. Se reescala con NEAREST (sin interpolar, preserva el borde binario), el fondo se aplana
a Z=0 y sus caras se eliminan. El nº de `vertices` no baja (el grid completo se conserva para no
romper el mapeo UV 1:1); solo baja `faces`. Una mask degenerada (todo objeto) deja la malla
intacta; una mask vacia (todo fondo) deja la malla sin caras (glb valido pero vacio).
## Capability growth log
- v1.1.0 (2026-06-21) — anade parametro opcional `mask` para recortar la malla al objeto
(descarta las caras del fondo), cerrando la cadena con `remove_background` del grupo img-to-3d.
Aditivo: `mask=None` mantiene el comportamiento previo. Fiel al original de `backend/depth.py`.
@@ -22,6 +22,7 @@ def depth_to_relief_glb(
out_glb_path: str,
z_scale: float = 0.35,
max_dim: int = 220,
mask: "np.ndarray | None" = None,
) -> dict:
"""
Construye una malla de relieve texturizada y la exporta como .glb.
@@ -33,6 +34,9 @@ def depth_to_relief_glb(
z_scale: amplitud del relieve (fracción del lado de la malla). Default 0.35.
max_dim: lado máximo del grid tras downsample (controla nº de vértices/caras).
Default 220 (~48k vértices, ~96k caras).
mask: máscara opcional HxW (0..255, 255 = objeto), típicamente la "mask" devuelta por
remove_background. Si se pasa, el fondo se aplana y las caras cuyos vértices caigan
en el fondo se descartan: la malla contiene solo el objeto, sin el plano de fondo.
Devuelve (dict, nunca lanza):
Éxito: {"status": "ok", "glb_path": out_glb_path, "vertices": int, "faces": int,
@@ -58,6 +62,14 @@ def depth_to_relief_glb(
depth = np.asarray(depth_img, dtype=np.float32) / 255.0
H, W = depth.shape
# Si se pasó máscara (objeto vs fondo), reescalarla al grid ya downsampleado: el fondo
# no aporta relieve (se aplana a 0) y luego sus caras se descartan, dejando solo el objeto.
fg = None
if mask is not None:
mask_img = Image.fromarray(np.asarray(mask).astype(np.uint8)).resize((W, H), Image.NEAREST)
fg = np.asarray(mask_img) >= 128
depth = np.where(fg, depth, 0.0).astype(np.float32)
# Coordenadas del grid: X corrige aspect ratio, Y hacia abajo, Z = profundidad.
aspect = W / float(H)
xs = np.linspace(-aspect / 2.0, aspect / 2.0, W, dtype=np.float32)
@@ -79,6 +91,12 @@ def depth_to_relief_glb(
]
)
# Con máscara: conservar solo las caras cuyos tres vértices son objeto. La malla queda
# recortada al objeto, sin el plano de fondo que deformaría el relieve.
if fg is not None:
keep = fg.ravel()[faces].all(axis=1)
faces = faces[keep]
# UV mapeando cada vértice al pixel de la imagen (V invertido para convención glTF).
u = np.linspace(0.0, 1.0, W, dtype=np.float32)
v = np.linspace(0.0, 1.0, H, dtype=np.float32)
@@ -0,0 +1,89 @@
---
name: remove_background
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def remove_background(image_path: str, engine: str = 'auto') -> dict"
description: "Elimina el fondo de una imagen con cascada de motores (rembg/U2Net -> OpenCV GrabCut -> umbral NumPy), compone el objeto sobre fondo gris neutro y devuelve image+mask+engine. Paso de pre-proceso del flujo img->3D (grupo img-to-3d): su mask alimenta depth_to_relief_glb para recortar la malla de relieve al objeto."
tags: [img-to-3d, datascience, background-removal, segmentation, rembg, grabcut, opencv, computer-vision, mask]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: image_path
desc: "Ruta a la imagen de entrada. Cualquier formato que PIL.Image.open abra (jpg, png, webp, RGBA...). Si no existe o no es imagen valida, se devuelve status error. Un PNG RGBA ya recortado se reaprovecha en modo auto (passthrough:alpha)."
- name: engine
desc: "Motor de segmentacion. 'auto' (default) prueba en cascada rembg:u2net -> opencv:grabcut -> threshold:border y NUNCA falla (cae al umbral NumPy puro sin deps externas). Forzar uno: 'rembg' (red neuronal U2Net, mejor calidad, deps pesadas), 'grabcut' (OpenCV, rectangulo central), 'threshold' (distancia al color medio de los bordes, NumPy puro, objeto centrado). Si se fuerza un motor y no esta disponible/falla o produce mascara degenerada -> status error."
output: "dict. Exito: {status:'ok', image: PIL.Image RGB del objeto compuesto sobre fondo gris neutro (127,127,127), mask: ndarray HxW uint8 (0..255, 255=objeto), engine: str del motor usado ('rembg:u2net' | 'opencv:grabcut' | 'threshold:border' | 'passthrough:alpha'), height:int, width:int, fg_fraction: float (fraccion de pixeles objeto, redondeada a 4 decimales)}. Error: {status:'error', error:str} (ruta invalida, motor desconocido, motor forzado no disponible/fallido, o ningun motor produjo una mascara valida). No lanza nunca. El demo CLI (__main__) imprime un resumen JSON sin el ndarray ni la imagen y, si se pasa out_dir, guarda rgb.png + mask.png."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/datascience/remove_background.py"
source_file: "apps/img_to_3d_webapp/backend/bg_removal.py"
---
## Ejemplo
```python
# Requiere un venv con pillow + numpy (rembg/opencv solo si fuerzas esos motores; el umbral es NumPy puro).
# Import PLANO al modulo: el paquete datascience.__init__ arrastra deps de otros dominios
# (bs4, duckdb...) que no estan en ese venv. Ver Gotchas.
import sys
sys.path.insert(0, "python/functions/datascience")
from remove_background import remove_background
res = remove_background("apps/img_to_3d_webapp/samples/cats.jpg", engine="auto")
assert res["status"] == "ok"
print(res["engine"]) # p.ej. "rembg:u2net" (o "opencv:grabcut" / "threshold:border")
print(res["height"], res["width"]) # p.ej. 1024 768
print(res["mask"].shape, res["mask"].dtype) # (1024, 768) uint8 (255=objeto)
assert 0.0 < res["fg_fraction"] < 1.0
# res["mask"] (ndarray HxW uint8) alimenta depth_to_relief_glb para recortar la malla al objeto.
# res["image"] es el objeto compuesto sobre gris neutro, listo para estimar profundidad.
```
Lanzable como demo (imprime resumen JSON, sin serializar el ndarray; guarda PNGs si das out_dir):
```bash
./fn run remove_background_py_datascience apps/img_to_3d_webapp/samples/cats.jpg auto /tmp/cut
# {"status": "ok", "engine": "rembg:u2net", "height": 1024, "width": 768,
# "fg_fraction": 0.4123, "rgb_path": "/tmp/cut/rgb.png", "mask_path": "/tmp/cut/mask.png"}
```
## Cuando usarla
Como pre-proceso ANTES de estimar profundidad en el flujo img->3D: aislar el objeto evita que el
modelo de profundidad estire el fondo plano, y la `mask` permite recortar la malla de relieve al
objeto (se pasa a `depth_to_relief_glb`). Tambien para segmentacion de primer plano generica
cuando necesitas separar un objeto de su fondo y componerlo sobre un color neutro (recortes para
catalogos, datasets, miniaturas).
## Gotchas
- **Impura**: segun el motor carga modelos neuronales y lee disco. `rembg`/`onnxruntime` (~170MB)
DESCARGA el modelo U2Net la primera vez a su cache (`~/.u2net/`), requiere red en esa primera
carga; `opencv-python` para GrabCut; el umbral (`threshold:border`) es NumPy puro sin deps externas.
- **Estado de proceso**: `_REMBG_SESSION` cachea la sesion rembg a nivel de modulo para no recargar
los pesos en cada llamada. Es estado mutable compartido del proceso y ocupa RAM hasta que el
interprete muere.
- **engine='auto' nunca lanza**: prueba rembg -> grabcut -> threshold y siempre cae al umbral NumPy
puro si los anteriores no estan disponibles o fallan. Forzar un motor concreto SI puede devolver
status error (motor no instalado, fallo, o mascara degenerada).
- **Mascara degenerada**: si la fraccion de objeto resulta `< 0.01` o `> 0.995` la mascara se
descarta (casi todo fondo o casi todo objeto) y en modo auto se prueba el siguiente motor.
- **threshold:border es de baja calidad**: asume objeto centrado con los bordes de la imagen siendo
fondo (calcula la distancia al color medio de los bordes). Es el fallback de ultimo recurso.
- **passthrough:alpha**: si la imagen ya viene recortada (PNG RGBA con alfa por debajo de 128) se
reutiliza su canal alfa como mascara, SOLO en modo auto. Si fuerzas un motor concreto se respeta
esa eleccion e ignora el alfa existente.
- **Import plano**: importa el modulo directo (`sys.path` a `python/functions/datascience` +
`from remove_background import remove_background`), NO `from datascience import ...`. El
`datascience.__init__` carga todo el dominio (scrapers con bs4, duckdb...) con deps ajenas a esta
funcion que romperian el import del paquete en el venv de vision.
- Nunca lanza: errores (ruta invalida, motor forzado no disponible, OOM) vuelven como
`{status:'error', error:str}`.
@@ -0,0 +1,213 @@
"""
Eliminación de fondo de una imagen con cascada de motores (rembg -> GrabCut -> umbral).
Función del registry (grupo de capacidad `img-to-3d`, dominio `datascience`). Promovida desde
la app `img_to_3d_webapp` (backend/bg_removal.py) para que cualquier artefacto pueda aislar el
objeto de primer plano sin reimplementar la cascada de segmentación ni la composición sobre fondo
neutro.
Impura: carga modelos neuronales (rembg/U2Net), usa GPU/CPU vía onnxruntime, lee disco y mantiene
una caché de sesión rembg a nivel de proceso para no recargar los pesos en cada llamada. Las deps
pesadas (rembg, opencv) se importan dentro de los helpers (lazy) para que el módulo se pueda
importar sin ellas; el motor de umbral es NumPy puro sin deps externas.
"""
from __future__ import annotations
import numpy as np
from PIL import Image
# Fondo gris neutro sobre el que se compone el objeto recortado.
NEUTRAL_BG = (127, 127, 127)
# Umbral de alfa para considerar un PNG RGBA "ya recortado" (passthrough).
_ALPHA_THRESH = 128
# Sesión rembg cacheada a nivel de proceso (estado mutable: ver .md "Gotchas").
_REMBG_SESSION = None
def _existing_alpha_mask(image):
"""Devuelve el canal alfa como máscara HxW uint8 si la imagen ya viene recortada, si no None."""
if image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info):
alpha = np.asarray(image.convert("RGBA"))[:, :, 3]
if alpha.min() < _ALPHA_THRESH:
return alpha
return None
def _composite_over_neutral(image_rgb, mask):
"""Compone la imagen RGB sobre el fondo gris neutro usando la máscara como alfa."""
rgb = np.asarray(image_rgb.convert("RGB"), dtype=np.float32)
alpha = (mask.astype(np.float32) / 255.0)[:, :, None]
bg = np.empty_like(rgb)
bg[:] = NEUTRAL_BG
out = rgb * alpha + bg * (1.0 - alpha)
return Image.fromarray(out.clip(0, 255).astype(np.uint8), mode="RGB")
def _remove_with_rembg(image):
"""Segmenta con rembg (modelo U2Net). Devuelve (mask HxW uint8, engine_str)."""
global _REMBG_SESSION
from rembg import new_session, remove
if _REMBG_SESSION is None:
_REMBG_SESSION = new_session("u2net")
cut = remove(image.convert("RGB"), session=_REMBG_SESSION)
mask = np.asarray(cut.convert("RGBA"))[:, :, 3]
return mask, "rembg:u2net"
def _remove_with_grabcut(image):
"""Segmenta con OpenCV GrabCut (rectángulo central). Devuelve (mask HxW uint8, engine_str)."""
import cv2
rgb = np.asarray(image.convert("RGB"))
h, w = rgb.shape[:2]
bgr = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
gc_mask = np.zeros((h, w), np.uint8)
bgd_model = np.zeros((1, 65), np.float64)
fgd_model = np.zeros((1, 65), np.float64)
margin_x, margin_y = int(0.08 * w), int(0.08 * h)
rect = (margin_x, margin_y, max(1, w - 2 * margin_x), max(1, h - 2 * margin_y))
cv2.grabCut(bgr, gc_mask, rect, bgd_model, fgd_model, 5, cv2.GC_INIT_WITH_RECT)
fg = np.where((gc_mask == cv2.GC_FGD) | (gc_mask == cv2.GC_PR_FGD), 255, 0).astype(np.uint8)
return fg, "opencv:grabcut"
def _remove_with_threshold(image):
"""Segmenta por distancia al color medio de los bordes (NumPy puro). Devuelve (mask, engine_str)."""
rgb = np.asarray(image.convert("RGB"), dtype=np.float32)
h, w = rgb.shape[:2]
border = np.concatenate([rgb[0, :, :], rgb[-1, :, :], rgb[:, 0, :], rgb[:, -1, :]], axis=0)
bg_color = border.mean(axis=0)
dist = np.linalg.norm(rgb - bg_color, axis=2)
thresh = max(30.0, float(dist.mean()))
fg = (dist > thresh).astype(np.uint8) * 255
return fg, "threshold:border"
def remove_background(image_path: str, engine: str = "auto") -> dict:
"""
Elimina el fondo de una imagen y compone el objeto sobre un fondo gris neutro.
Parámetros:
image_path: ruta a la imagen de entrada (cualquier formato que PIL abra).
engine: "auto" (default) prueba rembg -> GrabCut -> umbral en cascada y NUNCA falla
(cae al umbral NumPy puro sin deps externas); también admite forzar un motor concreto:
"rembg", "grabcut" o "threshold". Si se fuerza un motor y no está disponible/falla,
o la máscara resulta degenerada, se devuelve status error.
Devuelve (dict, nunca lanza):
Éxito: {"status": "ok", "image": PIL.Image RGB del objeto compuesto sobre gris neutro,
"mask": ndarray HxW uint8 (0..255, 255=objeto), "engine": str del motor usado
("rembg:u2net" | "opencv:grabcut" | "threshold:border" | "passthrough:alpha"),
"height": int, "width": int, "fg_fraction": float (fracción de píxeles objeto,
redondeada a 4 decimales)}.
Error: {"status": "error", "error": str} (ruta inválida, motor desconocido, motor forzado
no disponible/fallido, o ningún motor produjo una máscara válida).
"""
try:
image = Image.open(image_path)
# Passthrough: si la imagen ya viene recortada (PNG RGBA con alfa), reutiliza su alfa.
# Solo en modo auto; si se fuerza un motor concreto se respeta esa elección.
if engine == "auto":
existing = _existing_alpha_mask(image)
if existing is not None:
composed = _composite_over_neutral(image, existing)
frac = float((existing >= 128).mean())
h, w = existing.shape[:2]
return {
"status": "ok",
"image": composed,
"mask": existing,
"engine": "passthrough:alpha",
"height": int(h),
"width": int(w),
"fg_fraction": round(frac, 4),
}
# Construir la lista de motores a probar según el engine pedido.
if engine == "auto":
attempts = [_remove_with_rembg, _remove_with_grabcut, _remove_with_threshold]
elif engine == "rembg":
attempts = [_remove_with_rembg]
elif engine == "grabcut":
attempts = [_remove_with_grabcut]
elif engine == "threshold":
attempts = [_remove_with_threshold]
else:
attempts = []
if not attempts:
return {"status": "error", "error": f"Motor desconocido: {engine!r}"}
last_exc = None
for attempt in attempts:
try:
mask, used = attempt(image)
except Exception as e: # noqa: BLE001
last_exc = e
continue
# Rechazar máscaras degeneradas (casi todo fondo o casi todo objeto).
frac = float((mask >= 128).mean())
if frac < 0.01 or frac > 0.995:
last_exc = f"mascara degenerada (fg_fraction={round(frac, 4)}) con {used}"
continue
composed = _composite_over_neutral(image, mask)
h, w = mask.shape[:2]
return {
"status": "ok",
"image": composed,
"mask": mask,
"engine": used,
"height": int(h),
"width": int(w),
"fg_fraction": round(frac, 4),
}
return {
"status": "error",
"error": f"No se pudo eliminar el fondo con engine={engine!r}: {last_exc}",
}
except Exception as e: # noqa: BLE001
return {"status": "error", "error": str(e)}
if __name__ == "__main__":
# Demo runner para `fn run remove_background_py_datascience <image_path> [engine] [out_dir]`.
# Imprime un resumen JSON-serializable (el ndarray y la PIL.Image no se serializan).
import json
import os
import sys
if len(sys.argv) < 2:
print(json.dumps({"status": "error", "error": "uso: <image_path> [engine] [out_dir]"}))
sys.exit(1)
path = sys.argv[1]
eng = sys.argv[2] if len(sys.argv) > 2 else "auto"
out_dir = sys.argv[3] if len(sys.argv) > 3 else None
res = remove_background(path, engine=eng)
if res["status"] == "ok":
summary = {
"status": "ok",
"engine": res["engine"],
"height": res["height"],
"width": res["width"],
"fg_fraction": res["fg_fraction"],
}
if out_dir:
os.makedirs(out_dir, exist_ok=True)
rgb_path = os.path.join(out_dir, "rgb.png")
mask_path = os.path.join(out_dir, "mask.png")
res["image"].save(rgb_path)
Image.fromarray(res["mask"]).save(mask_path)
summary["rgb_path"] = rgb_path
summary["mask_path"] = mask_path
print(json.dumps(summary))
else:
print(json.dumps(res))
sys.exit(1)
@@ -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())