Files
portales-hispanohablantes/inmuebles/yapo_cl/scraper.py
T

537 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()