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
+139 -392
View File
@@ -1,11 +1,10 @@
#!/usr/bin/env python3
"""
Scraper Yapo.cl Bienes Raíces (Chile / Schibsted clasificados)
Portal : https://www.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,
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:
def scrape_subcategory(sess, path):
url = BASE_URL + path
for attempt in range(3):
try:
return int(nums)
r = sess.get(url, timeout=30)
if r.status_code in (403, 429):
log.warning("HTTP %d en %s — esperando 30s", r.status_code, url)
time.sleep(30)
continue
if r.status_code == 404:
log.info("404 en %s — ignorando", url)
return []
r.raise_for_status()
break
except requests.RequestException as exc:
log.warning("Intento %d/3 fallido: %s", attempt + 1, exc)
if attempt < 2:
time.sleep([3, 8][attempt])
else:
return []
soup = BeautifulSoup(r.text, 'lxml')
tiles = soup.select('.d3-ad-tile')
if not tiles:
log.info("Sin tiles en %s", path)
return []
results = []
seen = set()
for tile in tiles:
try:
fav = tile.select_one('[data-adid]')
if not fav:
continue
listing_id = fav.get('data-adid', '').strip()
if not listing_id or listing_id in seen:
continue
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
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', '')
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:
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]
for lnk in links:
h = lnk.get('href', '')
if h and h != '#' and '/bienes-raices' in h:
href = h
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))
full_url = BASE_URL + href if href.startswith('/') else href
# --- Tipo ---
tipo = _detect_tipo(href + ' ' + card_text)
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 ---
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
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 / 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
)
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
# --- 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 {
results.append({
'listing_id': listing_id,
'titulo': titulo,
'precio': precio,
'moneda': 'CLP',
'titulo': titulo or 'Sin titulo',
'precio': precio_uf,
'moneda': 'UF',
'tipo': tipo,
'ubicacion': ubicacion,
'url': full_url,
'ambientes': ambientes,
'url': full_url if full_url != BASE_URL else None,
'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,
'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)
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
log.info("[%s] %d nuevos, %d actualizados", path, nuevos, actualizados)
time.sleep(DELAY)
finally:
if conn:
conn.close()
log.info('Conexión DB cerrada')
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__':