feat: scraper ArgenProp AR via sitemaps geograficos

This commit is contained in:
2026-08-02 18:05:19 +02:00
parent 64775cf52b
commit 10cb6a6bc5
+352
View File
@@ -0,0 +1,352 @@
#!/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()