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

375 lines
13 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 Metrocuadrado.com Bienes Raíces Colombia
Estrategia: HTTP plain + parseo del payload RSC de Next.js AppRouter.
Cada página embebe en script self.__next_f.push([1,"..."]) los datos SSR
completos de los ~66 listados visibles.
Itera: tipo × operación × ciudad × barrio(suggestions)
DB: metrocuadrado_co
Checkpoint: /opt/scrapers/metrocuadrado_co/state.json
{"cat_idx": N, "barrio_idx": M, "total_new": X}
"""
import os
import re
import time
import json
import logging
from datetime import datetime
import requests
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('metrocuadrado')
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.metrocuadrado.com'
DELAY = 2.0
DB_HOST = '100.75.240.87'
DB_PORT = 5432
DB_NAME = 'metrocuadrado_co'
DB_USER = 'pgadmin'
DB_PASS = 'J5BVlq65JvedWxZVrcY96OQX'
DAILY = bool(os.environ.get('METRO_DAILY'))
MAX_CATS = int(os.environ.get('MAX_CATS', '999')) # limite para tests
STATE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'state.json')
# Combinaciones tipo × operacion × ciudad a scrapear
TIPOS = ['casas', 'apartamentos', 'apartaestudios', 'lotes', 'locales', 'oficinas']
OPERACIONES = ['venta', 'arriendo']
CIUDADES = [
'bogota', 'medellin', 'cali', 'barranquilla', 'bucaramanga',
'cartagena', 'cucuta', 'ibague', 'pereira', 'manizales',
'santa-marta', 'chia', 'cajica',
]
def build_categories():
cats = []
for tipo in TIPOS:
for op in OPERACIONES:
for ciudad in CIUDADES:
cats.append({'tipo': tipo, 'operacion': op, 'ciudad': ciudad,
'url': f'/{tipo}/{op}/{ciudad}/'})
return cats
def load_state():
if os.path.exists(STATE_FILE):
with open(STATE_FILE) as f:
st = json.load(f)
log.info('Checkpoint: cat_idx=%d barrio_idx=%d total_new=%d',
st['cat_idx'], st['barrio_idx'], st.get('total_new', 0))
return st
return {'cat_idx': 0, 'barrio_idx': 0, 'total_new': 0}
def save_state(ci, bi, total_new):
with open(STATE_FILE, 'w') as f:
json.dump({'cat_idx': ci, 'barrio_idx': bi, '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-CO,es;q=0.9',
'Accept-Encoding': 'gzip, deflate, br',
'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 'COP',
tipo_negocio TEXT,
tipo_inmueble TEXT,
ciudad TEXT,
barrio TEXT,
url TEXT UNIQUE,
superficie_m2 DECIMAL,
habitaciones INT,
banos INT,
garajes INT,
estrato INT,
estado TEXT,
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_negocio, tipo_inmueble,
ciudad, barrio, url, superficie_m2, habitaciones, banos,
garajes, estrato, estado, scraped_at)
VALUES %s
ON CONFLICT (listing_id) DO UPDATE SET
titulo = EXCLUDED.titulo,
precio = EXCLUDED.precio,
tipo_negocio = EXCLUDED.tipo_negocio,
tipo_inmueble = EXCLUDED.tipo_inmueble,
ciudad = EXCLUDED.ciudad,
barrio = EXCLUDED.barrio,
url = EXCLUDED.url,
superficie_m2 = EXCLUDED.superficie_m2,
habitaciones = EXCLUDED.habitaciones,
banos = EXCLUDED.banos,
garajes = EXCLUDED.garajes,
estrato = EXCLUDED.estrato,
estado = EXCLUDED.estado,
scraped_at = EXCLUDED.scraped_at
""", [(r['listing_id'], r.get('titulo'), r.get('precio'), r.get('moneda', 'COP'),
r.get('tipo_negocio'), r.get('tipo_inmueble'), r.get('ciudad'), r.get('barrio'),
r.get('url'), r.get('superficie_m2'), r.get('habitaciones'), r.get('banos'),
r.get('garajes'), r.get('estrato'), r.get('estado'), now) for r in deduped])
conn.commit()
cur.close()
return nuevos
def unescape_rsc(s):
return s.replace('\\"', '"').replace('\\\\', '\\').replace('\\n', '\n').replace('\\/', '/')
def parse_listings_from_html(html):
"""Extraer listados del payload RSC de Next.js."""
scripts = re.findall(r'self\.__next_f\.push\(\[1,"(.*?)"\]\)', html, re.DOTALL)
if not scripts:
return [], None
# El chunk más grande tiene los datos de listings
big_chunk = unescape_rsc(max(scripts, key=len))
# Extraer totalOfList
total = None
m = re.search(r'"totalOfList"\s*:\s*(\d+)', big_chunk)
if m:
total = int(m.group(1))
listings = []
seen_ids = set()
# Encontrar todos los bloques de listados por el campo midinmueble
for m in re.finditer(r'"midinmueble"\s*:\s*"([^"]+)"', big_chunk):
listing_id = m.group(1)
if listing_id in seen_ids:
continue
seen_ids.add(listing_id)
# Extraer contexto alrededor del ID (el bloque de datos completo)
start = m.start()
# Buscar el inicio del objeto (retroceder hasta el { anterior al title o link cercano)
prefix = big_chunk[max(0, start - 2000):start]
obj_start = prefix.rfind(',"contactPhone"')
if obj_start < 0:
obj_start = prefix.rfind('{"contactPhone"')
context_start = max(0, start - 2000) + max(0, obj_start)
context = big_chunk[context_start:start + 3000]
def get_str(key, ctx=context):
mm = re.search(r'"' + key + r'"\s*:\s*"([^"]*)"', ctx)
return mm.group(1) if mm else None
def get_int(key, ctx=context):
# Acepta tanto "key": 4 como "key": "4"
mm = re.search(r'"' + key + r'"\s*:\s*"?(\d+)"?', ctx)
return int(mm.group(1)) if mm else None
def get_float(key, ctx=context):
mm = re.search(r'"' + key + r'"\s*:\s*"?([\d.]+)"?', ctx)
return float(mm.group(1)) if mm else None
link = get_str('link')
if not link or '/inmueble/' not in link:
continue
titulo = get_str('title')
tipo_negocio = get_str('mtiponegocio')
barrio = get_str('mnombrecomunbarrio') or get_str('mbarrio')
ciudad_str = None
# mciudad.nombre
ciudad_m = re.search(r'"mciudad"\s*:\s*\{"id"\s*:\s*"[^"]*"\s*,\s*"nombre"\s*:\s*"([^"]+)"', context)
if ciudad_m:
ciudad_str = ciudad_m.group(1)
tipo_inmueble_m = re.search(r'"mtipoinmueble"\s*:\s*\{[^}]*"nombre"\s*:\s*"([^"]+)"', context)
tipo_inmueble = tipo_inmueble_m.group(1) if tipo_inmueble_m else None
precio_venta = get_int('mvalorventa')
precio_arriendo = get_int('mvalorarriendo')
precio = (precio_venta or precio_arriendo or None)
if precio == 0:
precio = None
listings.append({
'listing_id': listing_id,
'titulo': titulo,
'precio': precio,
'moneda': 'COP',
'tipo_negocio': tipo_negocio,
'tipo_inmueble': tipo_inmueble,
'ciudad': ciudad_str,
'barrio': barrio,
'url': BASE + link,
'superficie_m2': get_float('marea'),
'habitaciones': get_int('mnrocuartos'),
'banos': get_int('mnrobanos'),
'garajes': get_int('mnrogarajes'),
'estrato': get_int('estrato'),
'estado': get_str('mestadoinmueble'),
})
return listings, total
def extract_barrio_suggestions(html):
"""Extraer URLs de barrios desde las sugerencias del RSC (busca en TODOS los chunks)."""
all_chunks = re.findall(r'self\.__next_f\.push\(\[1,"(.*?)"\]\)', html, re.DOTALL)
barrios = []
for raw in all_chunks:
chunk = unescape_rsc(raw)
if '"suggestions"' not in chunk and '"urls"' not in chunk:
continue
for m in re.finditer(r'"url"\s*:\s*"(/[^"]+/[^"]+/[^"]+/[^"]+/)"', chunk):
url = m.group(1)
parts = [p for p in url.split('/') if p]
if len(parts) == 4: # tipo/operacion/ciudad/barrio/
barrios.append(url)
return list(dict.fromkeys(barrios)) # dedup preservando orden
def fetch_page(sess, url, retries=3):
backoffs = [5, 15, 30]
for attempt in range(retries):
try:
r = sess.get(BASE + url, timeout=35)
if r.status_code == 200:
return r.text
if r.status_code in (404, 410):
return None
log.warning('HTTP %d en %s', r.status_code, url)
if attempt < retries - 1:
time.sleep(backoffs[attempt])
except requests.RequestException as e:
log.warning('Red %s intento %d: %s', url, attempt+1, e)
if attempt < retries - 1:
time.sleep(backoffs[attempt])
return None
def main():
state = load_state()
ci_start = state['cat_idx']
bi_start = state['barrio_idx']
total_new = state.get('total_new', 0)
log.info('=== Scraper Metrocuadrado CO — modo %s ===',
'DAILY' if DAILY else 'BACKFILL')
log.info('Desde categoría %d barrio_idx %d', ci_start, bi_start)
categories = build_categories()
sess = get_session()
conn = get_conn()
create_db(conn)
for ci in range(ci_start, min(len(categories), MAX_CATS)):
cat = categories[ci]
log.info('== Categoría %d/%d: %s ==', ci+1, len(categories), cat['url'])
# Cargar página principal de la categoría
html = fetch_page(sess, cat['url'])
if not html:
log.warning('Sin HTML para %s, saltando', cat['url'])
save_state(ci + 1, 0, total_new)
time.sleep(DELAY)
continue
# Extraer listings de la página principal
listings, total = parse_listings_from_html(html)
if listings:
n = upsert(conn, listings)
total_new += n
log.info(' Pagina principal: %d listings → %d nuevos (total %d acum)',
len(listings), n, total_new)
# Extraer URLs de barrios desde las sugerencias
barrio_urls = extract_barrio_suggestions(html)
log.info(' Barrios sugeridos: %d', len(barrio_urls))
bi_ini = bi_start if ci == ci_start else 0
for bi in range(bi_ini, len(barrio_urls)):
barrio_url = barrio_urls[bi]
html_b = fetch_page(sess, barrio_url)
if not html_b:
log.debug('Sin HTML para barrio %s', barrio_url)
save_state(ci, bi + 1, total_new)
time.sleep(DELAY)
continue
listings_b, _ = parse_listings_from_html(html_b)
if listings_b:
nb = upsert(conn, listings_b)
total_new += nb
if nb > 0:
log.info(' Barrio [%d/%d] %s: %d listings → %d nuevos (acum %d)',
bi+1, len(barrio_urls), barrio_url[-40:], len(listings_b), nb, total_new)
save_state(ci, bi + 1, total_new)
time.sleep(DELAY)
# En modo daily, parar la categoría si lleva más de 50 barrios sin nuevos
if DAILY and bi > 50 and total_new == 0:
log.info('Daily: 50 barrios sin nuevos en categoría, saltando')
break
save_state(ci + 1, 0, total_new)
time.sleep(DELAY * 2)
# En daily, parar si la categoría no produjo nada nuevo (ya cubierta)
# (no se limita el número de categorías — MAX_CATS cubre eso)
conn.close()
log.info('=== Finalizado: %d nuevos ===', total_new)
print('Finalizado: {} nuevos'.format(total_new))
if __name__ == '__main__':
main()