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,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))
|
||||
Reference in New Issue
Block a user