feat(browser): auto-commit con 178 cambios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 18:22:23 +02:00
parent 7d100e7f3e
commit 763e06c127
178 changed files with 19917 additions and 317 deletions
@@ -0,0 +1,308 @@
"""Scraper de proyectos freelance de Workana via Chrome DevTools Protocol (CDP).
Funcion IMPURA: Workana (https://www.workana.com/jobs) es una SPA Vue cuyo GET
HTTP NO trae los proyectos (el HTML inicial tiene 0 cards: el framework los monta
en runtime tras hidratacion). Por eso esta funcion renderiza la pagina con un
Chrome con remote debugging, espera a que los cards monten async, y extrae cada
proyecto con un evaluador JS validado contra la pagina real.
Es la pieza 1 de un monitor de captacion de clientes: detecta proyectos freelance
nuevos sin abrir el navegador a mano. El shape de cada proyecto esta UNIFICADO con
el scraper hermano de Upwork para que ambos alimenten el mismo pipeline.
Compone DOS funciones del registry (no reescribe transporte CDP):
1. `cdp_open_url_and_wait` (pipeline) — crea tab nuevo en el Chrome remoto,
navega a la URL de listado y espera `Page.loadEventFired`.
2. `cdp_eval` (browser) — evalua JS en la pestana cuyo URL contiene un substring.
Devuelve SIEMPRE un dict (estilo del grupo recon/market-intel): nunca lanza.
NUNCA inventa datos: si no hay cards tras el timeout, devuelve status="error" con
la lista de proyectos vacia.
"""
import json
import os
import sys
import time
import urllib.parse
from datetime import datetime, timezone
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from browser.cdp_eval import cdp_eval
from pipelines.cdp_open_url_and_wait import cdp_open_url_and_wait
# Selector de los cards de proyecto en el DOM de Workana (SPA Vue).
_CARD_SELECTOR = "div.project-item.js-project"
# Extractor JS validado en vivo contra la pagina real (devolvio 9 proyectos
# correctos). Es una IIFE que devuelve un JSON string con el array de proyectos.
# El budget exige moneda (USD|EUR|R$) o "Menos de"/"Mas de" y excluye textos de
# fecha ("Publicado"/"Hace"/"Ayer") para no confundir presupuesto con fecha.
_EXTRACTOR_JS = r"""
(() => {
const cards = [...document.querySelectorAll('div.project-item.js-project')];
const ex = c => {
const a = c.querySelector('h2.project-title a[href^="/job/"]');
const titleSpan = c.querySelector('h2.project-title span[title]');
const dateEl = c.querySelector('.project-main-details .date') || c.querySelector('p.date strong');
const bidsEl = c.querySelector('.project-main-details .bids');
const descEl = c.querySelector('.html-desc .text-expander-content span');
const skills = [...c.querySelectorAll('.skills a.skill')].map(s => (s.textContent||'').trim()).filter(Boolean);
const cn = c.querySelector('.country-name, [class*="country"]');
let budget = null;
const cand = [...c.querySelectorAll('p, span, div')].find(e =>
e.childElementCount===0 &&
/(USD|EUR|R\$|Menos de|Más de)/.test((e.textContent||'')) &&
!/(Publicado|Hace|Ayer)/.test((e.textContent||'')) &&
(e.textContent||'').trim().length < 40);
if(cand) budget = cand.textContent.trim();
return {
job_id: a ? a.getAttribute('href').replace('/job/','') : null,
url: a ? 'https://www.workana.com'+a.getAttribute('href') : null,
title: titleSpan ? titleSpan.getAttribute('title') : (a? a.textContent.trim() : null),
budget,
posted: dateEl ? dateEl.textContent.replace('Publicado:','').trim() : null,
bids: bidsEl ? bidsEl.textContent.replace('Propuestas:','').trim() : null,
skills,
snippet: descEl ? descEl.textContent.trim().slice(0,300) : null,
country: cn ? cn.textContent.trim() : null,
};
};
return JSON.stringify(cards.map(ex));
})()
"""
def _build_url(category: str, language: str, extra_query: str, page: int) -> str:
"""Construye la URL de listado de Workana con sus query params.
Base: https://www.workana.com/jobs?category=...&language=...
Anade `&query=...` si extra_query no esta vacio, y `&page=N` si page > 1.
"""
params = [("category", category), ("language", language)]
if extra_query:
params.append(("query", extra_query))
if page > 1:
params.append(("page", str(page)))
qs = urllib.parse.urlencode(params)
return f"https://www.workana.com/jobs?{qs}"
def _wait_for_cards(port: int, deadline: float) -> int:
"""Polling de `document.querySelectorAll(selector).length` hasta >0 o deadline.
Los cards de la SPA montan async tras la hidratacion, asi que el load event NO
garantiza que esten en el DOM. Devuelve el numero de cards encontrados (0 si se
agota el deadline sin que aparezcan).
"""
count_expr = (
f"document.querySelectorAll('{_CARD_SELECTOR}').length"
)
while time.time() < deadline:
r = cdp_eval(
count_expr,
port=port,
target_url_substr="workana.com",
timeout_s=10.0,
)
if r.get("ok"):
try:
n = int(r.get("value") or 0)
except (TypeError, ValueError):
n = 0
if n > 0:
return n
time.sleep(0.5)
return 0
def _scrape_one_page(
category: str,
language: str,
extra_query: str,
page: int,
port: int,
timeout_s: float,
scraped_at: str,
) -> dict:
"""Navega a una pagina de listado, espera los cards y extrae los proyectos.
Devuelve {"ok": bool, "projects": [...], "error": str}. Cada proyecto lleva ya
`source="workana"` y `scraped_at` anadidos. Filtra filas con job_id null.
"""
url = _build_url(category, language, extra_query, page)
# 1. Navegar: crea tab nuevo en el Chrome remoto y espera el load event.
try:
cdp_open_url_and_wait(port, url, int(timeout_s))
except Exception as e: # noqa: BLE001 — RuntimeError de cdp_open_url_and_wait
msg = str(e)
if (
"no se pudo crear tab" in msg
or "URLError" in msg
or "Connection refused" in msg
):
msg = f"no hay Chrome en el puerto {port} (¿remote debugging activo?): {e}"
return {"ok": False, "projects": [], "error": msg}
# 2. Polling hasta que los cards monten (SPA Vue: render async tras hidratacion).
deadline = time.time() + timeout_s
n_cards = _wait_for_cards(port, deadline)
if n_cards == 0:
return {
"ok": False,
"projects": [],
"error": (
"no project cards (¿chromium en port no logueado / Workana cambió DOM?)"
),
}
# 3. Ejecutar el extractor JS y parsear el JSON resultante.
r = cdp_eval(
_EXTRACTOR_JS,
port=port,
target_url_substr="workana.com",
timeout_s=max(10.0, timeout_s),
)
if not r.get("ok"):
err = r.get("error") or "eval CDP fallo sin mensaje"
return {"ok": False, "projects": [], "error": f"no se pudo evaluar el extractor JS ({err})"}
raw_value = r.get("value")
try:
rows = json.loads(raw_value) if isinstance(raw_value, str) else (raw_value or [])
except Exception: # noqa: BLE001 — JSON malformado del eval
return {"ok": False, "projects": [], "error": "el extractor JS no devolvio JSON valido"}
if not isinstance(rows, list):
return {"ok": False, "projects": [], "error": "el extractor JS no devolvio una lista"}
# 4. Enriquecer: source + scraped_at; filtrar filas sin job_id.
projects = []
for row in rows:
if not isinstance(row, dict):
continue
if not row.get("job_id"):
continue
row["source"] = "workana"
row["scraped_at"] = scraped_at
projects.append(row)
return {"ok": True, "projects": projects, "error": ""}
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:
"""Scrapea proyectos freelance de Workana renderizando la SPA via CDP.
Funcion IMPURA: necesita un Chrome con remote debugging escuchando en `port`.
Por cada pagina navega a la URL de listado, espera a que los cards (SPA Vue)
monten async, y extrae cada proyecto con un evaluador JS validado. Nunca lanza:
cualquier fallo (Chrome muerto, DOM cambiado, eval con error) devuelve
``{"status": "error", ...}`` con la lista de proyectos vacia. NUNCA inventa datos.
Args:
category: Categoria de Workana (segmento de la URL ?category=). Default
"it-programming". Otros ejemplos: "design-multimedia", "writing-translation".
language: Idioma de los proyectos (?language=). Default "es".
extra_query: Query libre opcional (?query=...). Si "", se omite. Util para
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).
timeout_s: Timeout (segundos) por pagina, tanto para la navegacion como para
el polling de aparicion de cards. Default 20.0.
Returns:
dict. En exito::
{
"status": "ok",
"source": "workana",
"count": <N proyectos>,
"projects": [ {project_dict}, ... ],
}
donde cada project_dict tiene EXACTAMENTE las claves: source ("workana"),
job_id (slug), url (absoluta), title, budget (str|None), posted (str),
bids (str|None), skills (list[str]), snippet (str), country (str|None),
scraped_at (ISO8601 UTC).
En error::
{
"status": "error",
"error": <mensaje claro>,
"source": "workana",
"projects": [],
}
"""
scraped_at = datetime.now(timezone.utc).isoformat()
all_projects: list[dict] = []
last_error = ""
n_pages = max(1, int(pages))
for page in range(1, n_pages + 1):
res = _scrape_one_page(
category=category,
language=language,
extra_query=extra_query,
page=page,
port=port,
timeout_s=timeout_s,
scraped_at=scraped_at,
)
if res["ok"]:
all_projects.extend(res["projects"])
else:
last_error = res["error"]
# Si la PRIMERA pagina ya falla, no hay nada que devolver: error duro.
if page == 1:
return {
"status": "error",
"error": last_error,
"source": "workana",
"projects": [],
}
# Paginas posteriores: cortamos el recorrido pero conservamos lo extraido.
break
return {
"status": "ok",
"source": "workana",
"count": len(all_projects),
"projects": all_projects,
}
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Scraper de proyectos Workana via CDP.")
parser.add_argument("--category", default="it-programming")
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("--timeout-s", type=float, default=20.0)
args = parser.parse_args()
out = scrape_workana_projects(
category=args.category,
language=args.language,
extra_query=args.extra_query,
pages=args.pages,
port=args.port,
timeout_s=args.timeout_s,
)
print(json.dumps(out, ensure_ascii=False, indent=2))