feat: scraper Metrocuadrado CO via RSC Next.js
This commit is contained in:
@@ -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()
|
||||||
Reference in New Issue
Block a user