wip: Encuentra24 CA (302 redirects, pendiente URL fix Ola2)
This commit is contained in:
@@ -0,0 +1,478 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Scraper: Encuentra24.com - Empleos Centroamerica (6 paises)
|
||||
Paises: Panama, Costa Rica, Guatemala, El Salvador, Honduras, Nicaragua
|
||||
DB: encuentra24_empleos
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import logging
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
import psycopg2
|
||||
from psycopg2.extras import execute_values
|
||||
|
||||
# -- Configuracion -------------------------------------------------------------
|
||||
PROXY_URL = 'socks5h://127.0.0.1:1090'
|
||||
PROXIES = {'http': PROXY_URL, 'https': PROXY_URL}
|
||||
|
||||
DB_HOST = '100.75.240.87'
|
||||
DB_PORT = 5432
|
||||
DB_NAME = 'encuentra24_empleos'
|
||||
DB_USER = 'pgadmin'
|
||||
DB_PASS = 'J5BVlq65JvedWxZVrcY96OQX'
|
||||
|
||||
UA = (
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
|
||||
'AppleWebKit/537.36 (KHTML, like Gecko) '
|
||||
'Chrome/124.0.0.0 Safari/537.36'
|
||||
)
|
||||
|
||||
DELAY = 0.8 # segundos entre requests
|
||||
MAX_PAGES = 15 # maximo de paginas por pais
|
||||
NEW_STREAK = 2 # paginas consecutivas sin nuevos -> parar por pais
|
||||
|
||||
BASE_URL = 'https://www.encuentra24.com'
|
||||
|
||||
# Paises: (codigo_url, nombre_pais)
|
||||
COUNTRIES = [
|
||||
('pa', 'panama'),
|
||||
('cr', 'costa-rica'),
|
||||
('gt', 'guatemala'),
|
||||
('sv', 'el-salvador'),
|
||||
('hn', 'honduras'),
|
||||
('ni', 'nicaragua'),
|
||||
]
|
||||
|
||||
# -- Logging -------------------------------------------------------------------
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s [%(levelname)s] %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S',
|
||||
)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -- Sesion HTTP ---------------------------------------------------------------
|
||||
def get_session() -> requests.Session:
|
||||
"""Crea y devuelve una sesion HTTP configurada con proxy y headers."""
|
||||
sess = requests.Session()
|
||||
sess.proxies.update(PROXIES)
|
||||
sess.headers.update({
|
||||
'User-Agent': UA,
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'es-ES,es;q=0.9,en;q=0.8',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'Connection': 'keep-alive',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
})
|
||||
return sess
|
||||
|
||||
|
||||
# -- Base de datos -------------------------------------------------------------
|
||||
def create_table(conn) -> None:
|
||||
"""Crea la tabla jobs si no existe y anade columnas faltantes."""
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
job_id TEXT PRIMARY KEY,
|
||||
titulo TEXT,
|
||||
empresa TEXT,
|
||||
ubicacion TEXT,
|
||||
url TEXT UNIQUE,
|
||||
pais TEXT,
|
||||
fecha_pub TEXT,
|
||||
scraped_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
# ALTER TABLE para garantizar columnas en tablas pre-existentes
|
||||
alter_stmts = [
|
||||
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS titulo TEXT",
|
||||
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS empresa TEXT",
|
||||
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS ubicacion TEXT",
|
||||
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS url TEXT",
|
||||
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS pais TEXT",
|
||||
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS fecha_pub TEXT",
|
||||
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS scraped_at TIMESTAMP DEFAULT NOW()",
|
||||
]
|
||||
for stmt in alter_stmts:
|
||||
try:
|
||||
cur.execute(stmt)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
conn.rollback()
|
||||
log.debug("ALTER ignorado (columna ya existe): %s", exc)
|
||||
|
||||
cur.close()
|
||||
log.info("Tabla 'jobs' verificada/creada. Script: %s", os.path.abspath(__file__))
|
||||
|
||||
|
||||
def upsert(conn, rows: list) -> tuple:
|
||||
"""
|
||||
Inserta o actualiza filas en la tabla jobs.
|
||||
Devuelve (nuevos, actualizados).
|
||||
"""
|
||||
if not rows:
|
||||
return 0, 0
|
||||
|
||||
cur = conn.cursor()
|
||||
|
||||
# Verificar cuales job_ids ya existen para contar nuevos vs actualizados
|
||||
ids = [r['job_id'] for r in rows]
|
||||
cur.execute("SELECT job_id FROM jobs WHERE job_id = ANY(%s)", (ids,))
|
||||
existing = {rec[0] for rec in cur.fetchall()}
|
||||
|
||||
nuevos = sum(1 for r in rows if r['job_id'] not in existing)
|
||||
actualizados = sum(1 for r in rows if r['job_id'] in existing)
|
||||
|
||||
tuples = [
|
||||
(r['job_id'], r['titulo'], r['empresa'],
|
||||
r['ubicacion'], r['url'], r['pais'], r['fecha_pub'])
|
||||
for r in rows
|
||||
]
|
||||
|
||||
execute_values(
|
||||
cur,
|
||||
"""
|
||||
INSERT INTO jobs (job_id, titulo, empresa, ubicacion, url, pais, fecha_pub, scraped_at)
|
||||
VALUES %s
|
||||
ON CONFLICT (job_id) DO UPDATE SET
|
||||
titulo = EXCLUDED.titulo,
|
||||
empresa = EXCLUDED.empresa,
|
||||
ubicacion = EXCLUDED.ubicacion,
|
||||
url = EXCLUDED.url,
|
||||
pais = EXCLUDED.pais,
|
||||
fecha_pub = EXCLUDED.fecha_pub,
|
||||
scraped_at = NOW()
|
||||
""",
|
||||
tuples,
|
||||
template="(%s, %s, %s, %s, %s, %s, %s, NOW())",
|
||||
)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return nuevos, actualizados
|
||||
|
||||
|
||||
# -- Helpers de parsing --------------------------------------------------------
|
||||
def _extract_text(tag, selectors: list) -> str:
|
||||
"""Prueba varios selectores CSS en el tag y devuelve el primer texto hallado."""
|
||||
if tag is None:
|
||||
return ''
|
||||
for sel in selectors:
|
||||
try:
|
||||
el = tag.select_one(sel)
|
||||
if el:
|
||||
return el.get_text(separator=' ', strip=True)
|
||||
except Exception:
|
||||
continue
|
||||
return ''
|
||||
|
||||
|
||||
def _extract_job_id(href: str) -> str:
|
||||
"""Extrae el slug/job_id de la URL de la oferta."""
|
||||
# Patron esperado: /{cc}/empleos/{slug} o /empleos/{slug}
|
||||
m = re.search(r'/empleos/([^/?#]+)', href)
|
||||
if m:
|
||||
return m.group(1).strip()
|
||||
# Fallback: ultimo segmento no vacio de la ruta
|
||||
path = href.split('?')[0].split('#')[0]
|
||||
segments = [s for s in path.rstrip('/').split('/') if s]
|
||||
return segments[-1] if segments else ''
|
||||
|
||||
|
||||
def _build_full_url(href: str) -> str:
|
||||
"""Convierte un href relativo en URL absoluta."""
|
||||
if href.startswith('http'):
|
||||
return href
|
||||
if href.startswith('//'):
|
||||
return 'https:' + href
|
||||
return BASE_URL + ('' if href.startswith('/') else '/') + href
|
||||
|
||||
|
||||
# -- Scraping ------------------------------------------------------------------
|
||||
def scrape_page(sess: requests.Session, url: str, pais: str) -> list:
|
||||
"""
|
||||
Descarga y parsea una pagina de listado de empleos de Encuentra24.
|
||||
- 3 intentos con backoff (2s, 5s, 15s) ante errores de red.
|
||||
- HTTP 403/429: espera 30s extra antes de reintentar; devuelve [] si persiste.
|
||||
- Parsing robusto con multiples selectores de fallback.
|
||||
Devuelve lista de dicts con los campos del job.
|
||||
"""
|
||||
backoffs = [2, 5, 15]
|
||||
resp = None
|
||||
|
||||
for attempt, wait in enumerate(backoffs, start=1):
|
||||
try:
|
||||
resp = sess.get(url, timeout=30)
|
||||
except requests.exceptions.RequestException as exc:
|
||||
log.warning(
|
||||
"Error de red intento %d/%d [%s]: %s",
|
||||
attempt, len(backoffs), url, exc,
|
||||
)
|
||||
if attempt < len(backoffs):
|
||||
time.sleep(wait)
|
||||
continue
|
||||
log.error("Agotados %d intentos de red para %s", len(backoffs), url)
|
||||
return []
|
||||
|
||||
# Bloqueo / rate-limit
|
||||
if resp.status_code in (403, 429):
|
||||
log.warning(
|
||||
"HTTP %d en %s (intento %d). Esperando 30s antes de reintentar.",
|
||||
resp.status_code, url, attempt,
|
||||
)
|
||||
time.sleep(30)
|
||||
if attempt >= len(backoffs):
|
||||
log.warning(
|
||||
"Sin mas reintentos tras HTTP %d -> devolviendo [].",
|
||||
resp.status_code,
|
||||
)
|
||||
return []
|
||||
continue
|
||||
|
||||
# Otros errores HTTP
|
||||
if resp.status_code != 200:
|
||||
log.warning("HTTP %d inesperado en %s. Devolviendo [].", resp.status_code, url)
|
||||
return []
|
||||
|
||||
break # 200 OK
|
||||
|
||||
if resp is None or not resp.text.strip():
|
||||
log.warning("Respuesta vacia para %s", url)
|
||||
return []
|
||||
|
||||
soup = BeautifulSoup(resp.text, 'lxml')
|
||||
jobs = []
|
||||
|
||||
# -- Selectores de cards (especifico -> generico) --------------------------
|
||||
card_selectors = [
|
||||
'div.listing-card',
|
||||
'article.listing',
|
||||
'div[class*="listing-card"]',
|
||||
'li[class*="listing-item"]',
|
||||
'div[class*="job-card"]',
|
||||
'div[class*="result-item"]',
|
||||
'div[class*="ad-card"]',
|
||||
'div[class*="search-result"]',
|
||||
'article[class*="job"]',
|
||||
]
|
||||
cards = []
|
||||
for sel in card_selectors:
|
||||
try:
|
||||
found = soup.select(sel)
|
||||
if found:
|
||||
log.debug("Cards con selector '%s': %d", sel, len(found))
|
||||
cards = found
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Ultimo recurso: todos los links de empleo de la pagina
|
||||
if not cards:
|
||||
try:
|
||||
link_tags = soup.select('a[href*="/empleos/"]')
|
||||
if link_tags:
|
||||
log.debug("Fallback a links directos: %d encontrados", len(link_tags))
|
||||
cards = link_tags
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not cards:
|
||||
log.info("Sin resultados en %s (ultima pagina?)", url)
|
||||
return []
|
||||
|
||||
seen_ids = set() # deduplicar dentro de la misma pagina
|
||||
|
||||
for card in cards:
|
||||
try:
|
||||
# -- Link principal ------------------------------------------------
|
||||
if card.name == 'a':
|
||||
link_tag = card
|
||||
else:
|
||||
link_tag = (
|
||||
card.select_one('a[href*="/empleos/"]')
|
||||
or card.select_one('a')
|
||||
)
|
||||
|
||||
href = (link_tag.get('href', '') if link_tag else '').strip()
|
||||
if not href:
|
||||
continue
|
||||
|
||||
full_url = _build_full_url(href)
|
||||
|
||||
# -- job_id --------------------------------------------------------
|
||||
job_id = _extract_job_id(href)
|
||||
if not job_id:
|
||||
log.debug("job_id vacio para href=%s; ignorando.", href)
|
||||
continue
|
||||
if job_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(job_id)
|
||||
|
||||
# -- Titulo --------------------------------------------------------
|
||||
titulo = _extract_text(card, [
|
||||
'h2', 'h3', 'h1',
|
||||
'[class*="title"]', '[class*="titulo"]',
|
||||
'[class*="heading"]', 'p[class*="name"]',
|
||||
])
|
||||
# Fallback: texto del link
|
||||
if not titulo and link_tag:
|
||||
titulo = link_tag.get_text(separator=' ', strip=True)
|
||||
titulo = titulo or 'Sin titulo'
|
||||
|
||||
# -- Empresa -------------------------------------------------------
|
||||
empresa = _extract_text(card, [
|
||||
'[class*="company"]',
|
||||
'[class*="empresa"]',
|
||||
'[class*="advertiser"]',
|
||||
'[class*="employer"]',
|
||||
'[class*="client"]',
|
||||
'[class*="brand"]',
|
||||
])
|
||||
|
||||
# -- Ubicacion -----------------------------------------------------
|
||||
ubicacion = _extract_text(card, [
|
||||
'[class*="location"]',
|
||||
'[class*="ubicacion"]',
|
||||
'[class*="ciudad"]',
|
||||
'[class*="city"]',
|
||||
'[class*="place"]',
|
||||
'[class*="address"]',
|
||||
'[class*="region"]',
|
||||
])
|
||||
|
||||
# -- Fecha publicacion ----------------------------------------------
|
||||
fecha_pub = ''
|
||||
try:
|
||||
f_tag = (
|
||||
card.select_one('time')
|
||||
or card.select_one('[datetime]')
|
||||
or card.select_one('[class*="date"]')
|
||||
or card.select_one('[class*="fecha"]')
|
||||
or card.select_one('[class*="published"]')
|
||||
or card.select_one('[class*="ago"]')
|
||||
)
|
||||
if f_tag:
|
||||
fecha_pub = (
|
||||
f_tag.get('datetime', '').strip()
|
||||
or f_tag.get_text(strip=True)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
jobs.append({
|
||||
'job_id': job_id,
|
||||
'titulo': titulo,
|
||||
'empresa': empresa,
|
||||
'ubicacion': ubicacion,
|
||||
'url': full_url,
|
||||
'pais': pais,
|
||||
'fecha_pub': fecha_pub,
|
||||
})
|
||||
|
||||
except Exception as exc:
|
||||
log.warning("Error parseando card: %s", exc)
|
||||
continue
|
||||
|
||||
log.info(" -> %d ofertas parseadas en %s", len(jobs), url)
|
||||
return jobs
|
||||
|
||||
|
||||
# -- Main ----------------------------------------------------------------------
|
||||
def main():
|
||||
total_nuevos = 0
|
||||
total_actualizados = 0
|
||||
|
||||
log.info("=== Inicio scraper Encuentra24 Empleos ===")
|
||||
|
||||
# Conexion a PostgreSQL
|
||||
try:
|
||||
conn = psycopg2.connect(
|
||||
host=DB_HOST,
|
||||
port=DB_PORT,
|
||||
dbname=DB_NAME,
|
||||
user=DB_USER,
|
||||
password=DB_PASS,
|
||||
)
|
||||
log.info("Conectado a PostgreSQL: %s@%s/%s", DB_USER, DB_HOST, DB_NAME)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"No se pudo conectar a PostgreSQL: {exc}") from exc
|
||||
|
||||
try:
|
||||
create_table(conn)
|
||||
sess = get_session()
|
||||
|
||||
for cc, nombre_pais in COUNTRIES:
|
||||
log.info("== Pais: %s (%s) ==", nombre_pais.upper(), cc)
|
||||
paginas_sin_nuevos = 0
|
||||
|
||||
for page in range(1, MAX_PAGES + 1):
|
||||
page_url = f"{BASE_URL}/{cc}/empleos?page={page}"
|
||||
log.info("[%s] Pagina %d/%d -> %s", cc, page, MAX_PAGES, page_url)
|
||||
|
||||
rows = scrape_page(sess, page_url, nombre_pais)
|
||||
|
||||
if not rows:
|
||||
log.info(
|
||||
"[%s] Pagina %d sin resultados -> fin para este pais.",
|
||||
cc, page,
|
||||
)
|
||||
break
|
||||
|
||||
nuevos, actualizados = upsert(conn, rows)
|
||||
total_nuevos += nuevos
|
||||
total_actualizados += actualizados
|
||||
|
||||
log.info(
|
||||
"[%s] Pag %d: %d nuevos, %d actualizados (acumulado: %d/%d)",
|
||||
cc, page, nuevos, actualizados,
|
||||
total_nuevos, total_actualizados,
|
||||
)
|
||||
|
||||
if nuevos == 0:
|
||||
paginas_sin_nuevos += 1
|
||||
log.info(
|
||||
"[%s] Paginas consecutivas sin nuevos: %d/%d",
|
||||
cc, paginas_sin_nuevos, NEW_STREAK,
|
||||
)
|
||||
if paginas_sin_nuevos >= NEW_STREAK:
|
||||
log.info(
|
||||
"[%s] Limite de %d paginas sin nuevos -> siguiente pais.",
|
||||
cc, NEW_STREAK,
|
||||
)
|
||||
break
|
||||
else:
|
||||
paginas_sin_nuevos = 0
|
||||
|
||||
time.sleep(DELAY)
|
||||
|
||||
# Pausa entre paises para no sobrecargar el servidor
|
||||
time.sleep(DELAY * 3)
|
||||
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"Error inesperado en scraper: {exc}") from exc
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
log.info("Conexion PostgreSQL cerrada.")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Salida requerida por el DAG para deteccion de exito
|
||||
print(f"Finalizado: {total_nuevos} nuevos, {total_actualizados} actualizados")
|
||||
log.info(
|
||||
"=== Scraper Encuentra24 completado. Nuevos: %d | Actualizados: %d ===",
|
||||
total_nuevos, total_actualizados,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user