375 lines
13 KiB
Python
375 lines
13 KiB
Python
#!/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()
|