diff --git a/inmuebles/metrocuadrado_co/scraper.py b/inmuebles/metrocuadrado_co/scraper.py new file mode 100644 index 0000000..8851983 --- /dev/null +++ b/inmuebles/metrocuadrado_co/scraper.py @@ -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()