feat(browser): auto-commit con 178 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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))
|
||||
Reference in New Issue
Block a user