feat: scraper Metrocuadrado CO via RSC Next.js

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