From 784d388ceea6489e13eefaeab91b7661fc52c5be Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 2 Aug 2026 04:22:34 +0200 Subject: [PATCH] feat: actualizar scraper Yapo.cl v2 (subcategorias d3-ad-tile, sin Accept-Encoding) --- inmuebles/yapo_cl/scraper.py | 551 ++++++++++------------------------- 1 file changed, 149 insertions(+), 402 deletions(-) diff --git a/inmuebles/yapo_cl/scraper.py b/inmuebles/yapo_cl/scraper.py index 03cfa22..29b752b 100644 --- a/inmuebles/yapo_cl/scraper.py +++ b/inmuebles/yapo_cl/scraper.py @@ -1,11 +1,10 @@ #!/usr/bin/env python3 """ -Scraper Yapo.cl – Bienes Raíces (Chile / Schibsted clasificados) -Portal : https://www.yapo.cl -DB : yapo_cl +Scraper Yapo.cl – Bienes Raices Chile (Schibsted) +Iteracion por subcategorias: ~30 tiles SSR por subcategoria (~150 unicos/dia) +DB: yapo_cl """ -import os import re import time import logging @@ -16,9 +15,6 @@ 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} @@ -34,23 +30,19 @@ UA = ( '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 - +DELAY = 1.5 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', +SUBCATEGORIES = [ + '/bienes-raices-venta-de-propiedades', + '/bienes-raices-venta-de-propiedades-casas', + '/bienes-raices-venta-de-propiedades-apartamentos', + '/bienes-raices-alquiler', + '/bienes-raices-alquiler-casas', + '/bienes-raices-alquiler-apartamentos', + '/bienes-raices-proyectos-nuevos', ] -# --------------------------------------------------------------------------- -# LOGGING -# --------------------------------------------------------------------------- logging.basicConfig( level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s', @@ -59,109 +51,53 @@ logging.basicConfig( 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': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;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, + listing_id TEXT PRIMARY KEY, titulo TEXT, - precio BIGINT, - moneda TEXT DEFAULT 'CLP', + precio DECIMAL, + moneda TEXT DEFAULT 'UF', tipo TEXT, ubicacion TEXT, - url TEXT UNIQUE, - ambientes INT, + url TEXT UNIQUE, superficie_m2 DECIMAL, + ambientes INT, 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') + 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 - + deduped = list({r['listing_id']: r for r in rows}.values()) cur = conn.cursor() - - # Determinar qué listing_ids ya existen para contar nuevos vs actualizados - ids = [r['listing_id'] for r in rows] + ids = [r['listing_id'] for r in deduped] 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) + 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, superficie_m2, ambientes, scraped_at) VALUES %s ON CONFLICT (listing_id) DO UPDATE SET titulo = EXCLUDED.titulo, @@ -170,366 +106,177 @@ def upsert(conn, rows): tipo = EXCLUDED.tipo, ubicacion = EXCLUDED.ubicacion, url = EXCLUDED.url, - ambientes = EXCLUDED.ambientes, superficie_m2 = EXCLUDED.superficie_m2, + ambientes = EXCLUDED.ambientes, scraped_at = EXCLUDED.scraped_at - """ - execute_values(cur, sql, tuples) + """, [ + (r['listing_id'], r.get('titulo'), r.get('precio'), r.get('moneda', 'UF'), + r.get('tipo'), r.get('ubicacion'), r.get('url'), + r.get('superficie_m2'), r.get('ambientes'), now) + for r in deduped + ]) 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')): +def _detect_tipo(href): + if 'alquiler' in href or 'arriendo' in href or 'renta' in href: return 'arriendo' - if any(k in tl for k in ('venta', 'vender', 'en venta')): + if 'venta' in href or 'vender' in href: 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 - +def scrape_subcategory(sess, path): + url = BASE_URL + path 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) + log.warning("HTTP %d en %s — esperando 30s", r.status_code, url) time.sleep(30) - if attempt < 2: - continue - log.error('HTTP %s persiste tras espera, devolviendo []', r.status_code) + continue + if r.status_code == 404: + log.info("404 en %s — ignorando", url) 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) + log.warning("Intento %d/3 fallido: %s", attempt + 1, exc) if attempt < 2: - time.sleep(backoff_delays[attempt]) + time.sleep([3, 8][attempt]) else: - log.error('Todos los reintentos fallaron para %s', url) return [] - if resp is None: + soup = BeautifulSoup(r.text, 'lxml') + tiles = soup.select('.d3-ad-tile') + if not tiles: + log.info("Sin tiles en %s", path) 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 [] + results = [] + seen = set() - 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: + for tile in tiles: + try: + fav = tile.select_one('[data-adid]') + if not fav: continue - lid = m.group(1) - if lid in seen_ids: + listing_id = fav.get('data-adid', '').strip() + if not listing_id or listing_id in seen: 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, + seen.add(listing_id) + + precio_uf = None + raw_price = fav.get('data-price', '') + if raw_price: + try: + precio_uf = float(raw_price) + except ValueError: + pass + + links = tile.select('a[href]') + href = '' + for lnk in links: + h = lnk.get('href', '') + if listing_id in h and '/bienes-raices' in h: + href = h + break + if not href: + for lnk in links: + h = lnk.get('href', '') + if h and h != '#' and '/bienes-raices' in h: + href = h + break + + full_url = BASE_URL + href if href.startswith('/') else href + + tipo = _detect_tipo(href) + + title_el = tile.select_one('h2, h3, [class*=title]') + titulo = title_el.get_text(strip=True)[:255] if title_el else None + + desc_el = tile.select_one('.d3-ad-tile__description, [class*=description]') + if not titulo and desc_el: + titulo = desc_el.get_text(strip=True)[:255] + + card_text = tile.get_text(separator=' ', strip=True) + + ubicacion = None + loc_el = tile.select_one('[class*=location], [class*=ubicacion], [class*=address]') + if loc_el: + ubicacion = loc_el.get_text(strip=True)[:200] + else: + m_loc = re.search(r'(Santiago|Providencia|Las Condes|Ñuñoa|Vitacura|Maipú|La Florida|Viña del Mar|Valparaíso|Concepción|Antofagasta|La Serena|Rancagua|Temuco|Puerto Montt|Valdivia|Calama|Iquique|Arica)', card_text, re.IGNORECASE) + if m_loc: + ubicacion = m_loc.group(0) + + 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 + + ambientes = None + m_amb = re.search(r'(\d+)\s*(?:dorm|hab|amb)', card_text, re.IGNORECASE) + if m_amb: + try: + ambientes = int(m_amb.group(1)) + except ValueError: + pass + + results.append({ + 'listing_id': listing_id, + 'titulo': titulo or 'Sin titulo', + 'precio': precio_uf, + 'moneda': 'UF', + 'tipo': tipo, + 'ubicacion': ubicacion, + 'url': full_url if full_url != BASE_URL else None, + 'superficie_m2': superficie_m2, + 'ambientes': ambientes, }) - 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.debug("Error parseando tile: %s", exc) - log.info('%s: %d anuncios parseados', url, len(items)) - return items + log.info("%s: %d listings", path, len(results)) + return results -# --------------------------------------------------------------------------- -# 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 + log.info("=== Scraper Yapo.cl 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('Conexión DB OK (%s@%s/%s)', DB_USER, DB_HOST, DB_NAME) + conn = psycopg2.connect(host=DB_HOST, port=DB_PORT, dbname=DB_NAME, + user=DB_USER, password=DB_PASS, connect_timeout=15) + log.info("Conexion 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 + raise RuntimeError(f"Error conectando a PostgreSQL: {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 + sess = get_session() + 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: + for path in SUBCATEGORIES: + rows = scrape_subcategory(sess, path) + if rows: 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 - + total_nuevos += nuevos + total_actualizados += actualizados + log.info("[%s] %d nuevos, %d actualizados", path, nuevos, actualizados) time.sleep(DELAY) finally: - if conn: - conn.close() - log.info('Conexión DB cerrada') + conn.close() + log.info("Conexion DB cerrada") - log.info('=== Scraper Yapo.cl finalizado ===') - print('Finalizado: {} nuevos, {} actualizados'.format(total_nuevos, total_actualizados)) + print(f"Finalizado: {total_nuevos} nuevos, {total_actualizados} actualizados") + log.info("=== Scraper Yapo.cl finalizado ===") if __name__ == '__main__':