#!/usr/bin/env python3 """ Scraper: Encuentra24.com - Bienes Raices Centroamerica (5 paises) Paises: Costa Rica, Guatemala, Honduras, Nicaragua, El Salvador DB: encuentra24_ca Paginacion: pagina 1 = URL base, pagina N>=2 = URL + '.{N}' """ import os import re import time import logging from datetime import datetime import requests from bs4 import BeautifulSoup import psycopg2 from psycopg2.extras import execute_values 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_ca' 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 = 1.2 MAX_PAGES = int(os.environ.get('MAX_PAGES', '15')) NEW_STREAK = 3 BASE_URL = 'https://www.encuentra24.com' COUNTRIES = [ ('cr', 'costa-rica'), ('gt', 'guatemala'), ('hn', 'honduras'), ('ni', 'nicaragua'), ('sv', 'el-salvador'), ] logging.basicConfig( level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S', ) log = logging.getLogger(__name__) def get_session(): 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-CR,es;q=0.9,en;q=0.8', 'Connection': 'keep-alive', 'Cache-Control': 'no-cache', 'Upgrade-Insecure-Requests':'1', }) return sess def create_table(conn): cur = conn.cursor() cur.execute(""" CREATE TABLE IF NOT EXISTS listings ( listing_id TEXT PRIMARY KEY, titulo TEXT, precio BIGINT, moneda TEXT, tipo TEXT, ubicacion TEXT, url TEXT UNIQUE, pais TEXT, superficie_m2 DECIMAL, scraped_at TIMESTAMP DEFAULT NOW() ) """) conn.commit() cur.close() log.info("Tabla listings verificada/creada OK") def upsert(conn, rows): if not rows: return 0, 0 deduped = list({r['listing_id']: r for r in rows}.values()) cur = conn.cursor() ids = [r['listing_id'] for r in deduped] cur.execute('SELECT listing_id FROM listings WHERE listing_id = ANY(%s)', (ids,)) existing = {rec[0] for rec in cur.fetchall()} nuevos = sum(1 for r in deduped if r['listing_id'] not in existing) actualizados = len(deduped) - nuevos now = datetime.utcnow() execute_values(cur, """ INSERT INTO listings (listing_id, titulo, precio, moneda, tipo, ubicacion, url, pais, superficie_m2, scraped_at) VALUES %s ON CONFLICT (listing_id) DO UPDATE SET titulo = EXCLUDED.titulo, precio = EXCLUDED.precio, moneda = EXCLUDED.moneda, tipo = EXCLUDED.tipo, ubicacion = EXCLUDED.ubicacion, url = EXCLUDED.url, pais = EXCLUDED.pais, superficie_m2 = EXCLUDED.superficie_m2, scraped_at = EXCLUDED.scraped_at """, [ (r['listing_id'], r.get('titulo'), r.get('precio'), r.get('moneda'), r.get('tipo'), r.get('ubicacion'), r.get('url'), r.get('pais'), r.get('superficie_m2'), now) for r in deduped ]) conn.commit() cur.close() return nuevos, actualizados def page_url(cc, country_slug, page): base = f'{BASE_URL}/{country_slug}-es/bienes-raices-venta-de-propiedades' if page == 1: return base return f'{base}.{page}' def _detect_tipo(text): tl = text.lower() if any(k in tl for k in ('alquiler', 'arriendo', 'arrendar', 'renta', 'rento')): return 'arriendo' if any(k in tl for k in ('venta', 'vender', 'en venta', 'se vende')): return 'venta' return 'venta' def _clean_precio(text): m = re.search(r'([\d\.,]+)', text.replace('\xa0', '').replace(' ', '')) if not m: return None try: return int(m.group(1).replace(',', '').replace('.', '').rstrip('0') or '0') except ValueError: return None def _extract_listing_id(href): path = href.split('?')[0].split('#')[0].rstrip('/') segments = [s for s in path.split('/') if s] if not segments: return None last = segments[-1] m = re.search(r'(\d{5,})', last) if m: return m.group(1) if len(last) > 4: return last return None def scrape_page(sess, url, cc): backoffs = [3, 8, 20] resp = None for attempt, wait in enumerate(backoffs, start=1): try: resp = sess.get(url, timeout=35) except requests.RequestException as exc: log.warning("Red intento %d/3 [%s]: %s", attempt, url, exc) if attempt < 3: time.sleep(wait) continue return [] if resp.status_code in (403, 429): log.warning("HTTP %d en %s — espera 45s", resp.status_code, url) time.sleep(45) if attempt >= 3: return [] continue if resp.status_code == 404: log.info("404 — fin de paginacion en %s", url) return None if resp.status_code != 200: log.warning("HTTP %d inesperado en %s", resp.status_code, url) return [] break if resp is None or not resp.text.strip(): return [] soup = BeautifulSoup(resp.text, 'lxml') card_selectors = [ 'div.listing-card', 'article.listing', 'div[class*="listing-card"]', 'div[class*="result-item"]', 'li[class*="listing-item"]', 'div[class*="ad-card"]', 'article[class*="property"]', 'div[class*="property-card"]', ] cards = [] for sel in card_selectors: found = soup.select(sel) if found: log.debug("Cards con '%s': %d", sel, len(found)) cards = found break if not cards: links = soup.select('a[href*="/bienes-raices"]') if links: cards = links if not cards: log.info("Sin cards en %s", url) return [] results = [] seen = set() for card in cards: try: if card.name == 'a': link_tag = card else: link_tag = ( card.select_one('a[href*="/bienes-raices"]') or card.select_one('a[href*="/inmuebles"]') or card.select_one('a') ) href = (link_tag.get('href', '') if link_tag else '').strip() if not href: continue full_url = href if href.startswith('http') else BASE_URL + href listing_id = _extract_listing_id(href) if not listing_id or listing_id in seen: continue seen.add(listing_id) card_text = card.get_text(separator=' ', strip=True) titulo = None for sel in ('h2', 'h3', 'h1', '[class*="title"]', '[class*="titulo"]', '[class*="heading"]'): el = card.select_one(sel) if el: t = el.get_text(strip=True) if t: titulo = t[:255] break if not titulo and link_tag: titulo = link_tag.get_text(strip=True)[:255] titulo = titulo or 'Sin titulo' precio = None moneda = 'USD' for sel in ('[class*="price"]', '[class*="precio"]', '[class*="valor"]', '.amount', '.value'): el = card.select_one(sel) if el: t = el.get_text(strip=True) if 'USD' in t or '$' in t: moneda = 'USD' precio = _clean_precio(t) if precio and precio > 100: break tipo = _detect_tipo(href + ' ' + card_text) ubicacion = None for sel in ('[class*="location"]', '[class*="ubicacion"]', '[class*="city"]', '[class*="ciudad"]', '[class*="address"]', '[class*="region"]'): el = card.select_one(sel) if el: t = el.get_text(strip=True) if t: ubicacion = t[:200] break superficie_m2 = None m2 = re.search(r'(\d+(?:[,\.]\d+)?)\s*m[²2]', card_text, re.IGNORECASE) if m2: try: superficie_m2 = float(m2.group(1).replace(',', '.')) except ValueError: pass results.append({ 'listing_id': listing_id, 'titulo': titulo, 'precio': precio, 'moneda': moneda, 'tipo': tipo, 'ubicacion': ubicacion, 'url': full_url, 'pais': cc, 'superficie_m2': superficie_m2, }) except Exception as exc: log.debug("Error parseando card: %s", exc) log.info(" -> %d listings en %s", len(results), url) return results def main(): total_nuevos = 0 total_actualizados = 0 log.info("=== Scraper Encuentra24 CA Bienes Raices iniciado ===") try: conn = psycopg2.connect(host=DB_HOST, port=DB_PORT, dbname=DB_NAME, user=DB_USER, password=DB_PASS, connect_timeout=15) 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, country_slug in COUNTRIES: log.info("== Pais: %s (%s) ==", country_slug.upper(), cc) streak_sin_nuevos = 0 for page in range(1, MAX_PAGES + 1): url = page_url(cc, country_slug, page) log.info("[%s] Pagina %d/%d -> %s", cc, page, MAX_PAGES, url) rows = scrape_page(sess, url, cc) if rows is None: log.info("[%s] Pagina %d devolvio 404 — fin para este pais", cc, page) break if not rows: streak_sin_nuevos += 1 if streak_sin_nuevos >= NEW_STREAK: log.info("[%s] %d paginas sin resultados — siguiente pais", cc, NEW_STREAK) break time.sleep(DELAY) continue nuevos, actualizados = upsert(conn, rows) total_nuevos += nuevos total_actualizados += actualizados log.info("[%s] Pag %d: %d nuevos, %d actualizados (acum %d/%d)", cc, page, nuevos, actualizados, total_nuevos, total_actualizados) if nuevos == 0: streak_sin_nuevos += 1 if streak_sin_nuevos >= NEW_STREAK: log.info("[%s] %d paginas sin nuevos — siguiente pais", cc, NEW_STREAK) break else: streak_sin_nuevos = 0 time.sleep(DELAY) time.sleep(DELAY * 2) finally: try: conn.close() log.info("Conexion PostgreSQL cerrada") except Exception: pass print(f"Finalizado: {total_nuevos} nuevos, {total_actualizados} actualizados") log.info("=== Scraper finalizado. Nuevos: %d | Actualizados: %d ===", total_nuevos, total_actualizados) if __name__ == '__main__': main()