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 #!/usr/bin/env python3
""" """
Scraper Yapo.cl Bienes Raíces (Chile / Schibsted clasificados) Scraper Yapo.cl Bienes Raices Chile (Schibsted)
Portal : https://www.yapo.cl Iteracion por subcategorias: ~30 tiles SSR por subcategoria (~150 unicos/dia)
DB : yapo_cl DB: yapo_cl
""" """
import os
import re import re
import time import time
import logging import logging
@@ -16,9 +15,6 @@ from bs4 import BeautifulSoup
import psycopg2 import psycopg2
from psycopg2.extras import execute_values from psycopg2.extras import execute_values
# ---------------------------------------------------------------------------
# CONFIG
# ---------------------------------------------------------------------------
PROXY = 'socks5h://127.0.0.1:1090' PROXY = 'socks5h://127.0.0.1:1090'
PROXIES = {'http': PROXY, 'https': PROXY} PROXIES = {'http': PROXY, 'https': PROXY}
@@ -34,23 +30,19 @@ UA = (
'Chrome/124.0.0.0 Safari/537.36' 'Chrome/124.0.0.0 Safari/537.36'
) )
DELAY = 0.8 # segundos entre páginas DELAY = 1.5
MAX_PAGES = 30 # límite de páginas
NEW_STREAK = 2 # páginas consecutivas sin nuevos para detener
BASE_URL = 'https://www.yapo.cl' BASE_URL = 'https://www.yapo.cl'
# Patrones de URL en orden de preferencia SUBCATEGORIES = [
URL_PATTERNS = [ '/bienes-raices-venta-de-propiedades',
BASE_URL + '/bienes_raices/departamentos/{n}.html', '/bienes-raices-venta-de-propiedades-casas',
BASE_URL + '/bienes_raices/{n}.html', '/bienes-raices-venta-de-propiedades-apartamentos',
BASE_URL + '/region_metropolitana/bienes_raices.{n}.html', '/bienes-raices-alquiler',
BASE_URL + '/bienes_raices.{n}.html', '/bienes-raices-alquiler-casas',
'/bienes-raices-alquiler-apartamentos',
'/bienes-raices-proyectos-nuevos',
] ]
# ---------------------------------------------------------------------------
# LOGGING
# ---------------------------------------------------------------------------
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s', format='%(asctime)s [%(levelname)s] %(message)s',
@@ -59,109 +51,53 @@ logging.basicConfig(
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# SESSION
# ---------------------------------------------------------------------------
def get_session(): def get_session():
"""Crea y devuelve una requests.Session con proxy y headers configurados."""
sess = requests.Session() sess = requests.Session()
sess.proxies.update(PROXIES) sess.proxies.update(PROXIES)
sess.headers.update({ sess.headers.update({
'User-Agent': UA, '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-Language': 'es-CL,es;q=0.9,en;q=0.8',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive', 'Connection': 'keep-alive',
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
'DNT': '1',
}) })
return sess return sess
# ---------------------------------------------------------------------------
# BASE DE DATOS
# ---------------------------------------------------------------------------
def create_table(conn): def create_table(conn):
"""Crea la tabla listings si no existe y aplica migraciones de columnas."""
cur = conn.cursor() cur = conn.cursor()
cur.execute(""" cur.execute("""
CREATE TABLE IF NOT EXISTS listings ( CREATE TABLE IF NOT EXISTS listings (
listing_id TEXT UNIQUE NOT NULL, listing_id TEXT PRIMARY KEY,
titulo TEXT, titulo TEXT,
precio BIGINT, precio DECIMAL,
moneda TEXT DEFAULT 'CLP', moneda TEXT DEFAULT 'UF',
tipo TEXT, tipo TEXT,
ubicacion TEXT, ubicacion TEXT,
url TEXT UNIQUE, url TEXT UNIQUE,
ambientes INT,
superficie_m2 DECIMAL, superficie_m2 DECIMAL,
ambientes INT,
scraped_at TIMESTAMP DEFAULT NOW() 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() conn.commit()
cur.close() cur.close()
log.info('Tabla listings verificada/creada OK') log.info("Tabla listings verificada/creada OK")
def upsert(conn, rows): def upsert(conn, rows):
"""
Inserta o actualiza filas en listings.
Devuelve (nuevos, actualizados).
"""
if not rows: if not rows:
return 0, 0 return 0, 0
deduped = list({r['listing_id']: r for r in rows}.values())
cur = conn.cursor() cur = conn.cursor()
ids = [r['listing_id'] for r in deduped]
# 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,)) cur.execute('SELECT listing_id FROM listings WHERE listing_id = ANY(%s)', (ids,))
existing = {row[0] for row in cur.fetchall()} existing = {rec[0] for rec in cur.fetchall()}
nuevos = sum(1 for r in deduped if r['listing_id'] not in existing)
nuevos = sum(1 for r in rows if r['listing_id'] not in existing) actualizados = len(deduped) - nuevos
actualizados = len(rows) - nuevos now = datetime.utcnow()
execute_values(cur, """
now = datetime.now() INSERT INTO listings (listing_id, titulo, precio, moneda, tipo, ubicacion, url, superficie_m2, ambientes, scraped_at)
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 VALUES %s
ON CONFLICT (listing_id) DO UPDATE SET ON CONFLICT (listing_id) DO UPDATE SET
titulo = EXCLUDED.titulo, titulo = EXCLUDED.titulo,
@@ -170,366 +106,177 @@ def upsert(conn, rows):
tipo = EXCLUDED.tipo, tipo = EXCLUDED.tipo,
ubicacion = EXCLUDED.ubicacion, ubicacion = EXCLUDED.ubicacion,
url = EXCLUDED.url, url = EXCLUDED.url,
ambientes = EXCLUDED.ambientes,
superficie_m2 = EXCLUDED.superficie_m2, superficie_m2 = EXCLUDED.superficie_m2,
ambientes = EXCLUDED.ambientes,
scraped_at = EXCLUDED.scraped_at 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() conn.commit()
cur.close() cur.close()
return nuevos, actualizados return nuevos, actualizados
# --------------------------------------------------------------------------- def _detect_tipo(href):
# HELPERS DE PARSING if 'alquiler' in href or 'arriendo' in href or 'renta' in href:
# ---------------------------------------------------------------------------
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' 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 'venta'
return None return None
def _clean_precio(text): def scrape_subcategory(sess, path):
"""Extrae un entero de precio desde un string con formato CLP.""" url = BASE_URL + path
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): for attempt in range(3):
try: try:
r = sess.get(url, timeout=30) 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): 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) time.sleep(30)
if attempt < 2: continue
continue if r.status_code == 404:
log.error('HTTP %s persiste tras espera, devolviendo []', r.status_code) log.info("404 en %s — ignorando", url)
return [] return []
r.raise_for_status() r.raise_for_status()
resp = r
break break
except requests.RequestException as exc: 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: if attempt < 2:
time.sleep(backoff_delays[attempt]) time.sleep([3, 8][attempt])
else: else:
log.error('Todos los reintentos fallaron para %s', url)
return [] 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 [] return []
# Detectar bloqueo por Cloudflare / CAPTCHA results = []
if any(k in resp.text for k in ('cf-browser-verification', 'challenge-form', 'Pardon Our Interruption')): seen = set()
log.warning('Posible bloqueo / CAPTCHA detectado en %s', url)
return []
soup = BeautifulSoup(resp.text, 'lxml') for tile in tiles:
try:
# Selectores de cards en orden de especificidad (Yapo.cl / Schibsted) fav = tile.select_one('[data-adid]')
cards = ( if not fav:
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 continue
lid = m.group(1) listing_id = fav.get('data-adid', '').strip()
if lid in seen_ids: if not listing_id or listing_id in seen:
continue continue
seen_ids.add(lid) seen.add(listing_id)
full_url = href if href.startswith('http') else BASE_URL + href
titulo = a.get_text(strip=True) or None precio_uf = None
parent_text = a.parent.get_text(separator=' ', strip=True) if a.parent else '' raw_price = fav.get('data-price', '')
items.append({ if raw_price:
'listing_id': lid, try:
'titulo': titulo, precio_uf = float(raw_price)
'precio': None, except ValueError:
'moneda': 'CLP', pass
'tipo': _detect_tipo(href + ' ' + (titulo or '') + ' ' + parent_text),
'ubicacion': None, links = tile.select('a[href]')
'url': full_url, href = ''
'ambientes': None, for lnk in links:
'superficie_m2': None, 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: 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)) log.info("%s: %d listings", path, len(results))
return items 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(): def main():
log.info('=== Scraper Yapo.cl iniciado ===') log.info("=== Scraper Yapo.cl iniciado ===")
sess = get_session()
conn = None
try: try:
conn = psycopg2.connect( conn = psycopg2.connect(host=DB_HOST, port=DB_PORT, dbname=DB_NAME,
host=DB_HOST, port=DB_PORT, dbname=DB_NAME, user=DB_USER, password=DB_PASS, connect_timeout=15)
user=DB_USER, password=DB_PASS, log.info("Conexion DB OK (%s@%s/%s)", DB_USER, DB_HOST, DB_NAME)
connect_timeout=15,
)
log.info('Conexión DB OK (%s@%s/%s)', DB_USER, DB_HOST, DB_NAME)
except Exception as exc: 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: try:
create_table(conn) create_table(conn)
sess = get_session()
url_pattern = detect_url_pattern(sess) total_nuevos = 0
log.info('Patrón de URL seleccionado: %s', url_pattern)
total_nuevos = 0
total_actualizados = 0 total_actualizados = 0
streak_sin_nuevos = 0
for page in range(1, MAX_PAGES + 1): for path in SUBCATEGORIES:
url = url_pattern.format(n=page) rows = scrape_subcategory(sess, path)
log.info('--- Página %d/%d: %s ---', page, MAX_PAGES, url) if rows:
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) nuevos, actualizados = upsert(conn, rows)
except Exception as exc: total_nuevos += nuevos
log.error('Error en upsert página %d: %s', page, exc) total_actualizados += actualizados
try: log.info("[%s] %d nuevos, %d actualizados", path, nuevos, actualizados)
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) time.sleep(DELAY)
finally: finally:
if conn: conn.close()
conn.close() log.info("Conexion DB cerrada")
log.info('Conexión DB cerrada')
log.info('=== Scraper Yapo.cl finalizado ===') print(f"Finalizado: {total_nuevos} nuevos, {total_actualizados} actualizados")
print('Finalizado: {} nuevos, {} actualizados'.format(total_nuevos, total_actualizados)) log.info("=== Scraper Yapo.cl finalizado ===")
if __name__ == '__main__': if __name__ == '__main__':