763e06c127
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
309 lines
12 KiB
Python
309 lines
12 KiB
Python
"""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))
|