feat: actualizar scraper Yapo.cl v2 (subcategorias d3-ad-tile, sin Accept-Encoding)

This commit is contained in:
2026-08-02 04:22:34 +02:00
parent b15dc9923e
commit 784d388cee
+149 -402
View File
@@ -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__':