feat: actualizar scraper Yapo.cl v2 (subcategorias d3-ad-tile, sin Accept-Encoding)
This commit is contained in:
+139
-392
@@ -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__':
|
||||
|
||||
Reference in New Issue
Block a user