#!/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()