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,356 @@
"""Scraper de ofertas de trabajo (jobs) de Upwork via Chrome DevTools Protocol.
Funcion IMPURA: usa una pestana del navegador diario YA LOGUEADA en Upwork
(Chrome con remote debugging en `port`, normalmente 9222 / chromium-personal)
para ejecutar la busqueda de jobs y extraer las tarjetas de resultado.
POR QUE CDP Y NO HTTP PURO:
Upwork esta protegido por Cloudflare + PerimeterX. Un GET con urllib/requests
recibe 403 y la busqueda real (`/nx/search/jobs/`) exige SESION LOGUEADA. Por eso
vamos por CDP sobre el chromium diario del usuario, que ya tiene login: navegamos
a la URL de busqueda, esperamos a que monten las job tiles (la pagina es una SPA),
y extraemos con un solo `Runtime.evaluate`.
Es la PIEZA 2 (hermana de scrape_workana_projects) de un monitor de captacion de
clientes. Devuelve EXACTAMENTE el mismo shape unificado que el scraper de Workana
para que un agregador downstream consuma ambas fuentes sin ramas especiales.
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 busqueda y espera `Page.loadEventFired`. Devuelve tab_id.
2. `cdp_eval` (browser) — evalua el extractor JS en la pestana cuyo URL contiene
un substring (aqui: "upwork.com/nx/search/jobs").
NUNCA inventa datos: si tras el timeout no aparecen job tiles, devuelve
`{"status": "error", ...}` con `projects` vacio. Nunca lanza excepciones.
"""
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
# ---------------------------------------------------------------------------
# SELECTORES — best-effort, NO validados en vivo (sin sesion al crear la funcion).
#
# Upwork cambia el DOM con frecuencia. Cada campo usa una CASCADA de selectores
# (se prueba el primero que matchee; si ninguno → null). Para corregir tras una
# validacion real, edita SOLO este dict: el extractor JS lo lee tal cual.
#
# Referencia de los data-test conocidos (2025-2026):
# - tile contenedor: article.job-tile | [data-test="JobTile"]
# - lista de tiles: section[data-test="job-tile-list"]
# - titulo + link: a[data-test="job-tile-title-link"] | h2 a | h3 a
# - presupuesto: [data-test="job-type-label"] | [data-test="is-fixed-price"]
# | [data-test="budget"]
# - propuestas: [data-test="proposals-tier"] | [data-test="proposals"]
# - skills (tokens): [data-test="token"] | .air3-token | [data-test="attr-item"]
# - snippet: [data-test="job-description-text"] | [data-test="UpCLineClamp"]
# | p
# - pais: [data-test="location"] | [data-test="client-country"]
# - fecha publicada: [data-test="job-pubilshed-date"] | [data-test="posted-on"]
# | small[data-test="job-publish-time"]
# ---------------------------------------------------------------------------
SELECTORS = {
# Tarjetas (tiles). Se prueban en orden hasta que alguna devuelva >0 nodos.
"tile": [
'section[data-test="job-tile-list"] article.job-tile',
'section[data-test="job-tile-list"] [data-test="JobTile"]',
'article.job-tile',
'[data-test="JobTile"]',
'[data-test="job-tile"]',
],
# Dentro de cada tile — todos relativos al nodo de la tile.
"title_link": [
'a[data-test="job-tile-title-link"]',
'h2 a',
'h3 a',
'a[href*="/jobs/"]',
],
"budget": [
'[data-test="job-type-label"]',
'[data-test="is-fixed-price"]',
'[data-test="budget"]',
'[data-test="JobInfoByLine"]',
],
"bids": [
'[data-test="proposals-tier"]',
'[data-test="proposals"]',
'[data-test="ProposalsTier"]',
],
"skills": [
'[data-test="token"]',
'.air3-token',
'[data-test="attr-item"]',
],
"snippet": [
'[data-test="job-description-text"]',
'[data-test="UpCLineClamp"]',
'p',
],
"country": [
'[data-test="location"]',
'[data-test="client-country"]',
'small[data-test="client-location"]',
],
"posted": [
'[data-test="job-pubilshed-date"]',
'[data-test="posted-on"]',
'small[data-test="job-publish-time"]',
'[data-test="JobInfoByLine"] span',
],
}
def _build_extractor_js(selectors: dict) -> str:
"""Construye el extractor JS que lee las job tiles del DOM ya montado.
El JS recibe el dict de selectores serializado e implementa la cascada por
campo. Devuelve `JSON.stringify({tiles_found, projects})`. Si no encuentra
ninguna tile, `tiles_found` es 0 y `projects` queda vacio — el lado Python
decide entonces el error (sesion no logueada o selectores desfasados).
"""
sel_json = json.dumps(selectors)
return (
"(function(){"
f" var S = {sel_json};"
# firstMatch: primer nodo que matchee alguno de los selectores (en root).
" function firstMatch(root, list){"
" for (var i=0;i<list.length;i++){"
" try { var n = root.querySelector(list[i]); if (n) return n; } catch(e){}"
" }"
" return null;"
" }"
# allMatch: nodos del primer selector de la lista que devuelva >0.
" function allMatch(root, list){"
" for (var i=0;i<list.length;i++){"
" try { var ns = root.querySelectorAll(list[i]); if (ns && ns.length) return Array.prototype.slice.call(ns); } catch(e){}"
" }"
" return [];"
" }"
" function txt(node){ return node ? (node.textContent||'').replace(/\\s+/g,' ').trim() : null; }"
# Localizar las tiles probando los selectores de tile en orden.
" var tiles = [];"
" for (var t=0;t<S.tile.length;t++){"
" try { var found = document.querySelectorAll(S.tile[t]); if (found && found.length){ tiles = Array.prototype.slice.call(found); break; } } catch(e){}"
" }"
" var out = [];"
" for (var k=0;k<tiles.length;k++){"
" var tile = tiles[k];"
" var a = firstMatch(tile, S.title_link);"
" var url = null, title = null, jobId = null;"
" if (a){"
" title = txt(a);"
" var href = a.getAttribute('href') || '';"
" if (href){"
" url = href.indexOf('http') === 0 ? href : ('https://www.upwork.com' + href);"
# job_id = ultimo segmento ~XXXX de la URL del job, o el href crudo si no.
" var m = href.match(/~[0-9a-zA-Z]+/);"
" jobId = m ? m[0] : href;"
" }"
" }"
" var budget = txt(firstMatch(tile, S.budget));"
" var bids = txt(firstMatch(tile, S.bids));"
" var snippet = txt(firstMatch(tile, S.snippet));"
" var country = txt(firstMatch(tile, S.country));"
" var posted = txt(firstMatch(tile, S.posted));"
" var skillNodes = allMatch(tile, S.skills);"
" var skills = [];"
" for (var s=0;s<skillNodes.length;s++){ var st = txt(skillNodes[s]); if (st) skills.push(st); }"
" out.push({"
" job_id: jobId,"
" url: url,"
" title: title,"
" budget: budget,"
" posted: posted,"
" bids: bids,"
" skills: skills,"
" snippet: snippet,"
" country: country"
" });"
" }"
" return JSON.stringify({tiles_found: tiles.length, projects: out});"
"})()"
)
def scrape_upwork_projects(
query: str = "",
sort: str = "recency",
pages: int = 1,
port: int = 9222,
timeout_s: float = 25.0,
) -> dict:
"""Scrapea jobs de Upwork via CDP sobre una pestana YA LOGUEADA del navegador.
Funcion IMPURA: requiere un Chrome con remote debugging en `port` (normalmente
9222, el chromium-personal del usuario, con sesion de Upwork activa). Para cada
pagina: navega a la URL de busqueda, hace polling hasta que aparecen las job
tiles (SPA), y extrae con un solo eval. Nunca lanza: cualquier fallo devuelve
`{"status": "error", ...}`. NUNCA inventa datos: sin tiles → error.
Args:
query: Busqueda libre, ej. "custom software". "" = listado por defecto.
sort: Orden de resultados: "recency" (mas recientes) o "relevance".
pages: Numero de paginas de resultados a recorrer (>=1). Default 1.
port: Puerto de remote debugging del Chrome logueado. Default 9222.
timeout_s: Timeout por pagina (segundos) para navegacion + aparicion de
tiles. Default 25.0.
Returns:
dict con el shape unificado (identico al scraper de Workana). En exito::
{
"status": "ok",
"source": "upwork",
"count": <N>,
"projects": [
{
"source": "upwork",
"job_id": <id ~XXXX o href>,
"url": <url absoluta del job>,
"title": <titulo o None>,
"budget": <texto presupuesto/tipo o None>,
"posted": <fecha publicada o None>,
"bids": <propuestas/"Proposals" o None>,
"skills": [<skill>, ...],
"snippet": <descripcion corta o None>,
"country": <pais del cliente o None>,
"scraped_at": <ISO8601 UTC>,
},
...
],
}
En error::
{"status": "error", "error": <mensaje claro>, "source": "upwork", "projects": []}
"""
if pages < 1:
pages = 1
if sort not in ("recency", "relevance"):
sort = "recency"
extractor_js = _build_extractor_js(SELECTORS)
substr = "upwork.com/nx/search/jobs"
all_projects: list[dict] = []
last_error: str = ""
any_tiles_seen = False
for page_num in range(1, pages + 1):
params = {"q": query, "sort": sort, "page": page_num}
# url-encode de los params (la query libre puede llevar espacios/acentos).
qs = urllib.parse.urlencode({k: v for k, v in params.items() if v != ""})
url = f"https://www.upwork.com/nx/search/jobs/?{qs}"
# 1. Navegar: crea tab nuevo en el Chrome logueado 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 / chromium-personal activo?): {e}"
last_error = f"navegacion fallo (page {page_num}): {msg}"
# Sin navegacion no hay nada que extraer en esta pagina; continua a la siguiente.
continue
# 2. Polling hasta que aparezcan las tiles (la SPA monta el DOM en runtime).
# Se reintenta el extractor cada 1s hasta timeout_s; en cuanto encuentra
# tiles (o agota el tiempo) sale del bucle.
deadline = time.monotonic() + timeout_s
page_projects: list[dict] = []
page_tiles = 0
eval_error = ""
while True:
r = cdp_eval(
extractor_js,
port=port,
target_url_substr=substr,
timeout_s=max(10.0, timeout_s),
)
if not r.get("ok"):
eval_error = r.get("error") or "eval CDP fallo sin mensaje"
else:
raw_value = r.get("value")
try:
data = json.loads(raw_value) if isinstance(raw_value, str) else (raw_value or {})
except Exception: # noqa: BLE001 — JSON malformado del eval
data = {}
page_tiles = int(data.get("tiles_found") or 0)
page_projects = data.get("projects") or []
if page_tiles > 0:
break # ya hay resultados, no seguir esperando
if time.monotonic() >= deadline:
break
time.sleep(1.0)
# 3. (best-effort) cerrar el tab para no dejar pestanas abiertas.
try:
cdp_eval("window.close()", port=port, target_url_substr=substr, timeout_s=5.0)
except Exception: # noqa: BLE001 — cierre best-effort
pass
if page_tiles > 0:
any_tiles_seen = True
all_projects.extend(page_projects)
elif eval_error:
last_error = f"eval fallo (page {page_num}): {eval_error}"
# 4. Sin tiles en NINGUNA pagina → error explicito (no inventar datos).
if not any_tiles_seen:
err = (
"no job tiles — ¿sesion Upwork no logueada en port, o selectores "
"desactualizados? Validar con sesion real"
)
if last_error:
err = f"{err} | detalle: {last_error}"
return {
"status": "error",
"error": err,
"source": "upwork",
"projects": [],
}
# 5. Enriquecer cada fila: source + scraped_at (Python, no el JS).
scraped_at = datetime.now(timezone.utc).isoformat()
for p in all_projects:
p["source"] = "upwork"
p["scraped_at"] = scraped_at
return {
"status": "ok",
"source": "upwork",
"count": len(all_projects),
"projects": all_projects,
}
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Scrapea jobs de Upwork via CDP (sesion logueada).")
parser.add_argument("--query", default="custom software", help="busqueda libre")
parser.add_argument("--sort", default="recency", choices=["recency", "relevance"])
parser.add_argument("--pages", type=int, default=1)
parser.add_argument("--port", type=int, default=9222)
parser.add_argument("--timeout-s", type=float, default=25.0, dest="timeout_s")
args = parser.parse_args()
out = scrape_upwork_projects(
query=args.query,
sort=args.sort,
pages=args.pages,
port=args.port,
timeout_s=args.timeout_s,
)
# No volcar snippets enormes: resumen compacto.
print(json.dumps(out, ensure_ascii=False, indent=2))