diff --git a/inmuebles/yapo_cl/scraper.py b/inmuebles/yapo_cl/scraper.py new file mode 100644 index 0000000..03cfa22 --- /dev/null +++ b/inmuebles/yapo_cl/scraper.py @@ -0,0 +1,536 @@ +#!/usr/bin/env python3 +""" +Scraper Yapo.cl – Bienes Raíces (Chile / Schibsted clasificados) +Portal : https://www.yapo.cl +DB : yapo_cl +""" + +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 + +# --------------------------------------------------------------------------- +# CONFIG +# --------------------------------------------------------------------------- +PROXY = 'socks5h://127.0.0.1:1090' +PROXIES = {'http': PROXY, 'https': PROXY} + +DB_HOST = '100.75.240.87' +DB_PORT = 5432 +DB_NAME = 'yapo_cl' +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 páginas +MAX_PAGES = 30 # límite de páginas +NEW_STREAK = 2 # páginas consecutivas sin nuevos para detener + +BASE_URL = 'https://www.yapo.cl' + +# Patrones de URL en orden de preferencia +URL_PATTERNS = [ + BASE_URL + '/bienes_raices/departamentos/{n}.html', + BASE_URL + '/bienes_raices/{n}.html', + BASE_URL + '/region_metropolitana/bienes_raices.{n}.html', + BASE_URL + '/bienes_raices.{n}.html', +] + +# --------------------------------------------------------------------------- +# LOGGING +# --------------------------------------------------------------------------- +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s [%(levelname)s] %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', +) +log = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# SESSION +# --------------------------------------------------------------------------- +def get_session(): + """Crea y devuelve una requests.Session con proxy y headers configurados.""" + sess = requests.Session() + sess.proxies.update(PROXIES) + sess.headers.update({ + 'User-Agent': UA, + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept-Language': 'es-CL,es;q=0.9,en;q=0.8', + 'Accept-Encoding': 'gzip, deflate, br', + 'Connection': 'keep-alive', + 'Cache-Control': 'no-cache', + 'DNT': '1', + }) + return sess + + +# --------------------------------------------------------------------------- +# BASE DE DATOS +# --------------------------------------------------------------------------- +def create_table(conn): + """Crea la tabla listings si no existe y aplica migraciones de columnas.""" + cur = conn.cursor() + cur.execute(""" + CREATE TABLE IF NOT EXISTS listings ( + listing_id TEXT UNIQUE NOT NULL, + titulo TEXT, + precio BIGINT, + moneda TEXT DEFAULT 'CLP', + tipo TEXT, + ubicacion TEXT, + url TEXT UNIQUE, + ambientes INT, + superficie_m2 DECIMAL, + scraped_at TIMESTAMP DEFAULT NOW() + ) + """) + # Migraciones: añadir columnas que pudieran faltar en tablas ya existentes + migration_cols = [ + ('titulo', 'TEXT'), + ('precio', 'BIGINT'), + ('moneda', "TEXT DEFAULT 'CLP'"), + ('tipo', 'TEXT'), + ('ubicacion', 'TEXT'), + ('url', 'TEXT'), + ('ambientes', 'INT'), + ('superficie_m2', 'DECIMAL'), + ('scraped_at', 'TIMESTAMP DEFAULT NOW()'), + ] + for col, col_type in migration_cols: + try: + cur.execute( + 'ALTER TABLE listings ADD COLUMN IF NOT EXISTS {} {}'.format(col, col_type) + ) + except Exception as exc: + conn.rollback() + log.debug('ALTER TABLE %s ignorado: %s', col, exc) + conn.commit() + cur.close() + log.info('Tabla listings verificada/creada OK') + + +def upsert(conn, rows): + """ + Inserta o actualiza filas en listings. + Devuelve (nuevos, actualizados). + """ + if not rows: + return 0, 0 + + cur = conn.cursor() + + # Determinar qué listing_ids ya existen para contar nuevos vs actualizados + ids = [r['listing_id'] for r in rows] + cur.execute('SELECT listing_id FROM listings WHERE listing_id = ANY(%s)', (ids,)) + existing = {row[0] for row in cur.fetchall()} + + nuevos = sum(1 for r in rows if r['listing_id'] not in existing) + actualizados = len(rows) - nuevos + + now = datetime.now() + tuples = [ + ( + r['listing_id'], + r.get('titulo'), + r.get('precio'), + r.get('moneda', 'CLP'), + r.get('tipo'), + r.get('ubicacion'), + r.get('url'), + r.get('ambientes'), + r.get('superficie_m2'), + now, + ) + for r in rows + ] + + sql = """ + INSERT INTO listings + (listing_id, titulo, precio, moneda, tipo, ubicacion, url, + ambientes, 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, + ambientes = EXCLUDED.ambientes, + superficie_m2 = EXCLUDED.superficie_m2, + scraped_at = EXCLUDED.scraped_at + """ + execute_values(cur, sql, tuples) + conn.commit() + cur.close() + return nuevos, actualizados + + +# --------------------------------------------------------------------------- +# HELPERS DE PARSING +# --------------------------------------------------------------------------- +def _detect_tipo(text): + """Infiere 'venta' o 'arriendo' desde texto.""" + tl = text.lower() + if any(k in tl for k in ('arriendo', 'arrendar', 'alquiler', 'alquilar', 'renta')): + return 'arriendo' + if any(k in tl for k in ('venta', 'vender', 'en venta')): + return 'venta' + return None + + +def _clean_precio(text): + """Extrae un entero de precio desde un string con formato CLP.""" + nums = re.sub(r'[^\d]', '', text) + if nums and len(nums) >= 3: + try: + return int(nums) + except ValueError: + pass + return None + + +def _parse_card(card): + """ + Parsea un elemento HTML de anuncio y devuelve un dict o None si no es válido. + Robusto: usa .get() y try/except por cada campo. + """ + # --- URL y listing_id --- + link = card.find('a', href=True) + if not link: + return None + href = link.get('href', '') + if not href: + return None + full_url = href if href.startswith('http') else BASE_URL + href + + # ID numérico desde la URL (mínimo 5 dígitos) + m = re.search(r'/(\d{5,})', href) + if not m: + m = re.search(r'[_\-](\d{4,})', href) + if not m: + return None + listing_id = m.group(1) + + card_text = card.get_text(separator=' ', strip=True) + + # --- Título --- + titulo = None + for sel in ('h2', 'h3', 'h4', + '.item__title', '.item-title', '.ad-title', '.title', + '[class*="title"]', '[class*="Title"]'): + el = card.select_one(sel) + if el: + t = el.get_text(strip=True) + if t: + titulo = t[:255] + break + if not titulo: + titulo = card_text[:120] or None + + # --- Precio --- + precio = None + for sel in ('.item__price', '.price', '.ad-price', + '[class*="price"]', '[class*="Price"]', + '[class*="precio"]', '.value', '.amount'): + el = card.select_one(sel) + if el: + precio = _clean_precio(el.get_text()) + if precio: + break + if not precio: + # Buscar patrón "$NNN" en el texto completo de la card + m_p = re.search(r'\$\s*([\d\.\,]+)', card_text) + if m_p: + precio = _clean_precio(m_p.group(1)) + + # --- Tipo --- + tipo = _detect_tipo(href + ' ' + card_text) + + # --- Ubicacion --- + ubicacion = None + for sel in ('.item__location', '.item-location', '.location', '.commune', + '.city', '.region', '[class*="location"]', '[class*="Location"]', + '[class*="address"]', '[class*="Address"]'): + el = card.select_one(sel) + if el: + t = el.get_text(strip=True) + if t: + ubicacion = t[:200] + break + + # --- Ambientes / dormitorios --- + ambientes = None + m_amb = re.search( + r'(\d+)\s*(?:amb(?:iente)?s?|dorm(?:itorio)?s?|hab(?:itaci[oó]n)?(?:es)?)', + card_text, re.IGNORECASE + ) + if m_amb: + try: + ambientes = int(m_amb.group(1)) + except ValueError: + pass + + # --- Superficie m² --- + superficie_m2 = None + m_sup = re.search(r'(\d+(?:[,\.]\d+)?)\s*m[²2]', card_text, re.IGNORECASE) + if m_sup: + try: + superficie_m2 = float(m_sup.group(1).replace(',', '.')) + except ValueError: + pass + + return { + 'listing_id': listing_id, + 'titulo': titulo, + 'precio': precio, + 'moneda': 'CLP', + 'tipo': tipo, + 'ubicacion': ubicacion, + 'url': full_url, + 'ambientes': ambientes, + 'superficie_m2': superficie_m2, + } + + +# --------------------------------------------------------------------------- +# SCRAPING DE PÁGINA +# --------------------------------------------------------------------------- +def scrape_page(sess, url): + """ + Obtiene una página de listados con reintentos y backoff. + Devuelve lista de dicts (vacía si no hay resultados o hay error). + """ + backoff_delays = [2, 5, 15] + resp = None + + for attempt in range(3): + try: + r = sess.get(url, timeout=30) + + if r.status_code == 404: + log.info('404 – página inexistente: %s', url) + return [] + + if r.status_code in (403, 429): + log.warning('HTTP %s en %s – esperando 30 s antes de reintentar...', r.status_code, url) + time.sleep(30) + if attempt < 2: + continue + log.error('HTTP %s persiste tras espera, devolviendo []', r.status_code) + return [] + + r.raise_for_status() + resp = r + break + + except requests.RequestException as exc: + log.warning('Intento %d/3 fallido (%s): %s', attempt + 1, url, exc) + if attempt < 2: + time.sleep(backoff_delays[attempt]) + else: + log.error('Todos los reintentos fallaron para %s', url) + return [] + + if resp is None: + return [] + + # Detectar bloqueo por Cloudflare / CAPTCHA + if any(k in resp.text for k in ('cf-browser-verification', 'challenge-form', 'Pardon Our Interruption')): + log.warning('Posible bloqueo / CAPTCHA detectado en %s', url) + return [] + + soup = BeautifulSoup(resp.text, 'lxml') + + # Selectores de cards en orden de especificidad (Yapo.cl / Schibsted) + cards = ( + soup.select('article.item') + or soup.select('li.item') + or soup.select('.ad-listing-item') + or soup.select('article[data-ad-id]') + or soup.select('[data-listing-id]') + or soup.select('.listing-card') + or soup.select('.aditem') + or soup.select('ul.items > li') + or soup.select('.item') + or [] + ) + + if not cards: + # Fallback: recoger links directos a anuncios con ID numérico largo + all_links = soup.select('a[href*="/bienes_raices/"]') + seen_ids = set() + items = [] + for a in all_links: + href = a.get('href', '') + m = re.search(r'/(\d{6,})', href) + if not m: + continue + lid = m.group(1) + if lid in seen_ids: + continue + seen_ids.add(lid) + full_url = href if href.startswith('http') else BASE_URL + href + titulo = a.get_text(strip=True) or None + parent_text = a.parent.get_text(separator=' ', strip=True) if a.parent else '' + items.append({ + 'listing_id': lid, + 'titulo': titulo, + 'precio': None, + 'moneda': 'CLP', + 'tipo': _detect_tipo(href + ' ' + (titulo or '') + ' ' + parent_text), + 'ubicacion': None, + 'url': full_url, + 'ambientes': None, + 'superficie_m2': None, + }) + if items: + log.info('%s: %d anuncios (modo fallback links)', url, len(items)) + else: + log.info('Sin resultados en %s', url) + return items + + items = [] + for card in cards: + try: + item = _parse_card(card) + if item: + items.append(item) + except Exception as exc: + log.debug('Error parseando card: %s', exc) + + log.info('%s: %d anuncios parseados', url, len(items)) + return items + + +# --------------------------------------------------------------------------- +# DETECCIÓN DE PATRÓN DE URL +# --------------------------------------------------------------------------- +def detect_url_pattern(sess): + """ + Prueba los patrones de URL con la página 1 y devuelve el primero funcional. + """ + for pattern in URL_PATTERNS: + url = pattern.format(n=1) + try: + r = sess.get(url, timeout=30) + if r.status_code == 200 and len(r.text) > 2000: + soup = BeautifulSoup(r.text, 'lxml') + has_content = bool( + soup.select('article') + or soup.select('.item') + or soup.select('a[href*="/bienes_raices/"]') + ) + if has_content: + log.info('Patrón URL activo: %s', pattern) + return pattern + log.debug('Patrón descartado (%s): status=%s', pattern, r.status_code) + except Exception as exc: + log.debug('Patrón %s falló: %s', pattern, exc) + time.sleep(1.0) + + log.warning('No se detectó patrón funcional; usando fallback: %s', URL_PATTERNS[1]) + return URL_PATTERNS[1] + + +# --------------------------------------------------------------------------- +# MAIN +# --------------------------------------------------------------------------- +def main(): + log.info('=== Scraper Yapo.cl iniciado ===') + + sess = get_session() + conn = None + + try: + conn = psycopg2.connect( + host=DB_HOST, port=DB_PORT, dbname=DB_NAME, + user=DB_USER, password=DB_PASS, + connect_timeout=15, + ) + log.info('Conexión DB OK (%s@%s/%s)', DB_USER, DB_HOST, DB_NAME) + except Exception as exc: + raise RuntimeError('Error conectando a PostgreSQL: {}'.format(exc)) from exc + + try: + create_table(conn) + + url_pattern = detect_url_pattern(sess) + log.info('Patrón de URL seleccionado: %s', url_pattern) + + total_nuevos = 0 + total_actualizados = 0 + streak_sin_nuevos = 0 + + for page in range(1, MAX_PAGES + 1): + url = url_pattern.format(n=page) + log.info('--- Página %d/%d: %s ---', page, MAX_PAGES, url) + + try: + rows = scrape_page(sess, url) + except Exception as exc: + log.error('Error inesperado en scrape_page(%s): %s', url, exc) + rows = [] + + if not rows: + streak_sin_nuevos += 1 + log.info('Sin resultados en página %d (racha: %d/%d)', + page, streak_sin_nuevos, NEW_STREAK) + if streak_sin_nuevos >= NEW_STREAK: + log.info('Deteniendo scraping — %d páginas consecutivas sin resultados', NEW_STREAK) + break + time.sleep(DELAY) + continue + + try: + nuevos, actualizados = upsert(conn, rows) + except Exception as exc: + log.error('Error en upsert página %d: %s', page, exc) + try: + conn.rollback() + except Exception: + pass + nuevos, actualizados = 0, 0 + + total_nuevos += nuevos + total_actualizados += actualizados + log.info( + 'Página %d: %d nuevos, %d actualizados (acum: %d nuevos / %d actualizados)', + page, nuevos, actualizados, total_nuevos, total_actualizados + ) + + if nuevos == 0: + streak_sin_nuevos += 1 + if streak_sin_nuevos >= NEW_STREAK: + log.info('Deteniendo — %d páginas sin nuevos registros', NEW_STREAK) + break + else: + streak_sin_nuevos = 0 + + time.sleep(DELAY) + + finally: + if conn: + conn.close() + log.info('Conexión DB cerrada') + + log.info('=== Scraper Yapo.cl finalizado ===') + print('Finalizado: {} nuevos, {} actualizados'.format(total_nuevos, total_actualizados)) + + +if __name__ == '__main__': + main()