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

353 lines
12 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 ArgenProp.com.ar Bienes Raices Argentina
Estrategia: iterar URLs del sitemap de listing (por barrio/tipo) y extraer
listing cards con datos SSR completos en atributos del <a class="card">.
DB: argenprop_ar
Checkpoint: /opt/scrapers/argenprop_ar/state.json
{"sitemap_idx": N, "url_idx": M, "total_new": X}
"""
import os
import re
import time
import json
import gzip
import logging
from datetime import datetime
import requests
from bs4 import BeautifulSoup
import psycopg2
from psycopg2.extras import execute_values
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(levelname)s %(message)s',
datefmt='%Y-%m-%d %H:%M:%S',
)
log = logging.getLogger('argenprop')
PROXY = 'socks5h://127.0.0.1:1090'
PROXIES = {'http': PROXY, 'https': PROXY}
UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124 Safari/537.36'
BASE = 'https://www.argenprop.com'
DELAY = 1.5
DB_HOST = '100.75.240.87'
DB_PORT = 5432
DB_NAME = 'argenprop_ar'
DB_USER = 'pgadmin'
DB_PASS = 'J5BVlq65JvedWxZVrcY96OQX'
DAILY = bool(os.environ.get('ARGENPROP_DAILY')) # True en modo daily, False en backfill
MAX_SITEMAPS = int(os.environ.get('MAX_SITEMAPS', '99')) # cuantos sub-sitemaps procesar
STATE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'state.json')
# Sub-sitemaps de listing (se descubren dinamicamente desde el índice)
SITEMAP_INDEX = 'https://www.argenprop.com/sitemap.xml'
def load_state():
if os.path.exists(STATE_FILE):
with open(STATE_FILE) as f:
st = json.load(f)
log.info('Checkpoint: sitemap_idx=%d url_idx=%d total_new=%d',
st['sitemap_idx'], st['url_idx'], st.get('total_new', 0))
return st
return {'sitemap_idx': 0, 'url_idx': 0, 'total_new': 0}
def save_state(si, ui, total_new):
with open(STATE_FILE, 'w') as f:
json.dump({'sitemap_idx': si, 'url_idx': ui, 'total_new': total_new,
'ts': datetime.utcnow().isoformat()}, f)
def get_session():
sess = requests.Session()
sess.proxies.update(PROXIES)
sess.headers.update({
'User-Agent': UA,
'Accept': 'text/html,application/xhtml+xml,*/*;q=0.8',
'Accept-Language': 'es-AR,es;q=0.9',
'Accept-Encoding': 'gzip, deflate',
'Connection': 'keep-alive',
})
return sess
def get_conn():
return psycopg2.connect(host=DB_HOST, port=DB_PORT, dbname=DB_NAME,
user=DB_USER, password=DB_PASS, connect_timeout=15)
def create_db(conn):
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS listings (
listing_id TEXT PRIMARY KEY,
titulo TEXT,
precio BIGINT,
moneda TEXT DEFAULT 'USD',
tipo TEXT,
ubicacion TEXT,
url TEXT UNIQUE,
superficie_m2 DECIMAL,
ambientes INT,
scraped_at TIMESTAMP DEFAULT NOW()
)
""")
conn.commit()
cur.close()
def upsert(conn, rows):
if not rows:
return 0
deduped = list({r['listing_id']: r for r in rows}.values())
cur = conn.cursor()
ids = [r['listing_id'] for r in deduped]
cur.execute('SELECT listing_id FROM listings WHERE listing_id = ANY(%s)', (ids,))
existing = {rec[0] for rec in cur.fetchall()}
nuevos = sum(1 for r in deduped if r['listing_id'] not in existing)
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,
precio = EXCLUDED.precio,
moneda = EXCLUDED.moneda,
tipo = EXCLUDED.tipo,
ubicacion = EXCLUDED.ubicacion,
url = EXCLUDED.url,
superficie_m2 = EXCLUDED.superficie_m2,
ambientes = EXCLUDED.ambientes,
scraped_at = EXCLUDED.scraped_at
""", [(r['listing_id'], r.get('titulo'), r.get('precio'), r.get('moneda', 'USD'),
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
def fetch_gz(sess, url, retries=3):
"""Descarga un .xml.gz y retorna lista de <loc> URLs."""
for attempt in range(retries):
try:
r = sess.get(url, timeout=30)
if r.status_code in (404, 410):
return []
r.raise_for_status()
try:
content = gzip.decompress(r.content).decode('utf-8', errors='replace')
except Exception:
content = r.text
return re.findall(r'<loc>(.*?)</loc>', content)
except Exception as e:
log.warning('sitemap %s intento %d: %s', url, attempt+1, e)
if attempt < retries - 1:
time.sleep([5, 15][attempt % 2])
return []
def fetch_page(sess, url, retries=4):
"""Descarga una página de listado y extrae listing items."""
backoffs = [2, 5, 15, 30]
for attempt in range(retries):
try:
r = sess.get(url, timeout=35)
# HTTP 202 = AWS WAF challenge, reintentar
if r.status_code == 202:
log.debug('HTTP 202 en %s (intento %d)', url, attempt+1)
if attempt < retries - 1:
time.sleep(backoffs[attempt])
continue
return []
if r.status_code in (403, 429):
log.warning('HTTP %d en %s', r.status_code, url)
time.sleep(45)
continue
if r.status_code in (404, 410):
return []
if r.status_code != 200:
log.warning('HTTP %d en %s', r.status_code, url)
return []
break
except requests.RequestException as e:
log.warning('Red %s intento %d: %s', url, attempt+1, e)
if attempt < retries - 1:
time.sleep(backoffs[attempt])
else:
return []
soup = BeautifulSoup(r.text, 'lxml')
items = soup.find_all('div', class_='listing__item')
if not items:
return []
results = []
seen = set()
for item in items:
try:
card = item.find('a', class_='card')
if not card:
continue
listing_id = card.get('idaviso', '').strip()
if not listing_id or listing_id in seen:
continue
seen.add(listing_id)
href = card.get('href', '')
full_url = BASE + href if href.startswith('/') else href
# Precio en USD desde atributo montonormalizado
try:
precio = int(card.get('montonormalizado', 0) or 0)
except (ValueError, TypeError):
precio = None
moneda = 'USD' # argenprop lista en USD
# Dormitorios
try:
ambientes = int(card.get('dormitorios', 0) or 0) or None
except (ValueError, TypeError):
ambientes = None
# Tipo de inmueble desde la URL
tipo = 'venta'
if 'arriendo' in href.lower() or 'alquiler' in href.lower():
tipo = 'arriendo'
# Tipo de propiedad
tipo_prop = None
for t in ('casa', 'departamento', 'terreno', 'campo', 'cochera', 'oficina', 'local', 'ph'):
if t in href.lower():
tipo_prop = t
break
# Ubicación desde URL: /casa-en-venta-en-palermo-... → palermo
loc_m = re.search(r'-en-(?:venta|alquiler|arriendo)-en-([a-z\-]+?)(?:-\d|-\-|$)', href)
ubicacion = loc_m.group(1).replace('-', ' ').title() if loc_m else None
# Título y m2 del texto del card
card_text = card.get_text(separator=' ', strip=True)
titulo_m = re.search(r'((?:Casa|Departamento|Terreno|Campo|Oficina|Local|PH|Cochera)[^|]{5,80})', card_text, re.I)
titulo = titulo_m.group(1).strip()[:255] if titulo_m else card_text[:100]
m2_m = re.search(r'(\d+(?:[,\.]\d+)?)\s*m[2²]', card_text, re.I)
superficie_m2 = None
if m2_m:
try:
superficie_m2 = float(m2_m.group(1).replace(',', '.'))
except ValueError:
pass
results.append({
'listing_id': listing_id,
'titulo': titulo or 'Sin titulo',
'precio': precio or None,
'moneda': moneda,
'tipo': tipo_prop or tipo,
'ubicacion': ubicacion,
'url': full_url,
'superficie_m2': superficie_m2,
'ambientes': ambientes,
})
except Exception as e:
log.debug('Error card: %s', e)
return results
def get_listing_sitemaps(sess):
"""Retorna lista de sub-sitemaps de tipo listing."""
r = sess.get(SITEMAP_INDEX, timeout=20)
all_sms = re.findall(r'<loc>(.*?)</loc>', r.text)
# Solo los sitemaps de listing (no ficha)
listing_sms = [u for u in all_sms if 'sitemap-listing' in u]
log.info('Sitemaps de listing: %d', len(listing_sms))
return listing_sms
def main():
state = load_state()
si_start = state['sitemap_idx']
ui_start = state['url_idx']
total_new = state.get('total_new', 0)
log.info('=== Scraper ArgenProp AR — modo %s ===',
'DAILY' if DAILY else 'BACKFILL')
log.info('Desde sitemap %d url %d', si_start, ui_start)
sess = get_session()
conn = get_conn()
create_db(conn)
# Descubrir sitemaps de listing
listing_sms = get_listing_sitemaps(sess)
listing_sms = listing_sms[:MAX_SITEMAPS]
for si in range(si_start, len(listing_sms)):
sm_url = listing_sms[si]
log.info('== Sitemap %d/%d: %s ==', si+1, len(listing_sms), sm_url)
# Descargar el índice del sub-sitemap (puede tener sub-sub-sitemaps)
sm_urls = fetch_gz(sess, sm_url)
if not sm_urls:
# Intentar sin gz
sm_urls = [sm_url.replace('.gz', '')]
log.info(' sub-sitemaps/urls: %d', len(sm_urls))
# Si son sub-sitemaps (terminan en .xml.gz), expandir
final_urls = []
for u in sm_urls:
if u.endswith('.xml.gz') or u.endswith('.xml'):
inner = fetch_gz(sess, u)
final_urls.extend(inner)
time.sleep(0.5)
else:
final_urls.append(u)
log.info(' URLs totales a procesar: %d', len(final_urls))
ui_ini = ui_start if si == si_start else 0
for ui in range(ui_ini, len(final_urls)):
page_url = final_urls[ui]
rows = fetch_page(sess, page_url)
if rows:
n = upsert(conn, rows)
total_new += n
if n > 0:
log.info('[%d/%d] %s: %d nuevos (acum %d)',
ui+1, len(final_urls), page_url[-60:], n, total_new)
else:
log.debug('[%d/%d] %s: sin resultados', ui+1, len(final_urls), page_url[-60:])
save_state(si, ui + 1, total_new)
time.sleep(DELAY)
# En modo daily parar temprano si no hay nuevos (ya cubierto)
if DAILY and ui > 200 and total_new == 0:
log.info('Daily: 200 URLs sin nuevos — deteniendo')
break
# Avanzar al siguiente sitemap
save_state(si + 1, 0, total_new)
time.sleep(DELAY * 2)
conn.close()
log.info('=== Finalizado: %d nuevos ===', total_new)
print(f'Finalizado: {total_new} nuevos')
if __name__ == '__main__':
main()