#!/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()