diff --git a/empleos/encuentra24_empleos/scraper.py b/empleos/encuentra24_empleos/scraper.py new file mode 100644 index 0000000..dd9465e --- /dev/null +++ b/empleos/encuentra24_empleos/scraper.py @@ -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()