import sys import os import subprocess import pandas as pd import datetime import re from fpdf import FPDF import gradio as gr from playwright.sync_api import sync_playwright import time import random import requests from PIL import Image import io import traceback # Silenciador de consola def silenciador_errores_basura(unraisable): if unraisable.exc_type == ValueError and "Invalid file descriptor: -1" in str(unraisable.exc_value): pass else: sys.__unraisablehook__(unraisable) sys.unraisablehook = silenciador_errores_basura try: subprocess.run(["playwright", "install", "chromium"], check=True) except: pass try: from fake_useragent import UserAgent except ImportError: subprocess.run(["pip", "install", "fake-useragent"], check=True) from fake_useragent import UserAgent # --- URL DEL GOBIERNO --- URL_LOGO_GOBIERNO = "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c3/Escudo_de_Colombia.svg/250px-Escudo_de_Colombia.svg.png" def descargar_recurso(url, nombre_archivo): try: headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"} r = requests.get(url, timeout=10, headers=headers) if r.status_code == 200: with open(nombre_archivo, 'wb') as f: f.write(r.content) return nombre_archivo except: pass return None def preparar_entorno_pdf(): if not os.path.exists("logo_gob.png"): descargar_recurso(URL_LOGO_GOBIERNO, "logo_gob.png") # --- FUNCIONES DE SOPORTE --- def sanear_texto(texto): if not isinstance(texto, str): return "" return texto.encode('latin-1', 'ignore').decode('latin-1').strip() def descargar_imagen(url, idx): if not url or len(url) < 5 or url.startswith("data:"): return None try: headers = {"User-Agent": "Mozilla/5.0", "Accept": "image/*", "Referer": "https://www.fincaraiz.com.co/"} r = requests.get(url, timeout=8, headers=headers) if r.status_code == 200: img = Image.open(io.BytesIO(r.content)) if img.mode != 'RGB': img = img.convert('RGB') path = f"temp_img_{idx}.jpg" img.save(path, format="JPEG") return path except: return None return None def construir_urls_final(operacion, barrio, ciudad, tipo, hab, ban, park, antiguedad, m2_min, m2_max, ascensor, piscina): mapa_ant = {"Menos de 1 año": "de-0-a-1-anos", "1 a 8 años": "de-1-a-8-anos", "9 a 15 años": "de-9-a-15-anos", "16 a 30 años": "de-16-a-30-anos", "Más de 30 años": "mas-de-30-anos"} slug_ant = mapa_ant.get(antiguedad, "de-1-a-8-anos") slug_park = f"{int(park)}-parqueadero" if int(park) == 1 else f"{int(park)}-parqueaderos" b_slug = barrio.lower().strip().replace(" ", "-") c_slug = ciudad.lower().strip().replace(" ", "-") op_slug = operacion.lower().strip() tipo_slug = tipo.lower().strip() tipo_fr = "casas-y-apartamentos-y-apartaestudios" if tipo_slug in ["apartamento", "casa"] else tipo_slug + "s" url_fr_base = f"https://www.fincaraiz.com.co/{op_slug}/{tipo_fr}/{b_slug}/{c_slug}/{int(hab)}-o-mas-habitaciones/{int(ban)}-o-mas-banos/{slug_park}/{slug_ant}/m2-desde-{int(m2_min)}/m2-hasta-{int(m2_max)}" if ascensor: url_fr_base += "/con-ascensor" if piscina: url_fr_base += "/con-piscina" url_mc = f"https://www.metrocuadrado.com/{tipo_slug}-casa-oficina/{op_slug}/{c_slug}/{b_slug}/{int(ban)}-banos-{int(hab)}-habitaciones/?search=form" return url_fr_base, url_mc def extraer_precio(texto, operacion): patron = r'\$\s?(\d{1,3}(?:[.,]\d{3})*)' coincidencias = re.findall(patron, texto) if coincidencias: precios = [int(p.replace('.', '').replace(',', '')) for p in coincidencias] precios_validos = [p for p in precios if (600000 <= p <= 40000000 if operacion == "Arriendo" else p >= 40000000)] if precios_validos: return precios_validos[0] return 0 def extraer_ubicacion(texto): lineas = [l.strip() for l in texto.split('\n') if len(l.strip()) > 4] for linea in lineas: if "$" in linea or re.search(r'(?i)(hab|baño|m2|m²)', linea): continue if "," in linea or " en " in linea.lower(): limpio = re.sub(r'(?i)(apartamento|casa|bodega|lote|oficina)\s+en\s+(arriendo|venta)\s+(en\s+)?', '', linea) return limpio[:60].strip() for linea in lineas[1:4]: if "$" not in linea and not re.search(r'\d', linea): return linea[:60] return "Ubicacion en la zona" def es_inmueble_valido(href, portal): if not href or "javascript" in href or "blog" in href or "proyectos" in href: return False if portal == "FR": if re.search(r'/\d{7,10}$', href) or "arriendo-en" in href or "venta-en" in href: return True elif portal == "MC": if "/inmueble/" in href or "-id-" in href: return True return False # --- CLASE PDF MANUAL SAE ACTUALIZADA --- class PDF_SAE(FPDF): def header(self): if os.path.exists("logo_gob.png"): try: self.image("logo_gob.png", x=15, y=8, h=16) except: pass if os.path.exists("logo_sae.png"): try: self.image("logo_sae.png", x=155, y=8, w=40) except: pass elif os.path.exists("logo_sae.jpg"): try: self.image("logo_sae.jpg", x=155, y=8, w=40) except: pass self.ln(25) def footer(self): self.set_y(-25) try: self.set_font('Arial', '', 7) self.set_text_color(137, 137, 137) texto_pie = "Direccion General: Carrera 7 # 32-42 Centro Comercial San Martin Local 107 / PBX: 7431444\nLinea Gratuita Nacional: 01 8000 111612 - atencionalciudadano@saesas.gov.co - www.saesas.gov.co" self.multi_cell(0, 3, sanear_texto(texto_pie), align='C') except: pass self.set_y(-15) self.set_font('Arial', 'I', 8) self.cell(0, 10, f'Pagina {self.page_no()}', 0, 0, 'C') # --- MOTOR PRINCIPAL --- def motor_tramitia_visual(operacion, barrio, ciudad, area, m2_min, m2_max, tipo, hab, ban, park, antiguedad, ascensor, piscina): resultados = []; log_visible = ""; urls_vistas = set() try: url_fr, url_mc = construir_urls_final(operacion, barrio, ciudad, tipo, hab, ban, park, antiguedad, m2_min, m2_max, ascensor, piscina) log_visible = f"✅ INICIANDO EXTRACCIÓN:\nFR: {url_fr}\nMC: {url_mc}\n\n" ua = UserAgent() with sync_playwright() as p: browser = p.chromium.launch(headless=True, args=['--disable-blink-features=AutomationControlled', '--no-sandbox']) context = browser.new_context(viewport={'width': 1366, 'height': 768}, user_agent=ua.random) # FINCA RAÍZ try: page = context.new_page(); page.goto(url_fr, wait_until="domcontentloaded", timeout=60000) try: page.wait_for_load_state("networkidle", timeout=10000) except: pass for _ in range(4): page.mouse.wheel(0, 1000); page.wait_for_timeout(2000) elementos = page.query_selector_all("a"); cont_fr = 0 for el in elementos: if cont_fr >= 12: break try: href = el.get_attribute("href") if not es_inmueble_valido(href, "FR"): continue full_url = f"https://www.fincaraiz.com.co{href}" if href.startswith("/") else href if full_url in urls_vistas: continue card = el.evaluate_handle("el => el.closest('article') || el.closest('[class*=\"card\"]') || el.parentElement.parentElement") if not card: continue txt = card.inner_text(); precio = extraer_precio(txt, operacion) if precio > 0: img_url = "" img_el = card.query_selector("img") if img_el: img_url = img_el.get_attribute("src") or img_el.get_attribute("data-src") or "" if img_url.startswith("/"): img_url = "https://www.fincaraiz.com.co" + img_url resultados.append({"Portal": "Finca Raiz", "Precio": precio, "Precio_M2": precio / area, "Ubicacion": extraer_ubicacion(txt), "Descripcion": txt.replace('\n', ' | ')[:120] + "...", "URL": full_url, "Imagen": img_url}) urls_vistas.add(full_url); cont_fr += 1 except: continue page.close(); log_visible += f"✅ FR: {cont_fr} inmuebles.\n" except Exception as e: log_visible += f"⚠️ Error FR.\n" # METROCUADRADO try: page = context.new_page(); page.goto(url_mc, wait_until="domcontentloaded", timeout=60000) try: page.wait_for_load_state("networkidle", timeout=10000) except: pass for _ in range(4): page.mouse.wheel(0, 1000); page.wait_for_timeout(2000) elementos = page.query_selector_all("a"); cont_mc = 0 for el in elementos: if cont_mc >= 12: break try: href = el.get_attribute("href") if not es_inmueble_valido(href, "MC"): continue full_url = f"https://www.metrocuadrado.com{href}" if href.startswith("/") else href if full_url in urls_vistas: continue card = el.evaluate_handle("el => el.closest('li') || el.closest('[class*=\"card\"]') || el.closest('[class*=\"property\"]') || el.parentElement.parentElement.parentElement") if not card: continue txt = card.inner_text(); precio = extraer_precio(txt, operacion) if precio > 0: img_url = "" img_el = card.query_selector("img") if img_el: img_url = img_el.get_attribute("src") or img_el.get_attribute("data-src") or "" if img_url.startswith("/"): img_url = "https://www.metrocuadrado.com" + img_url resultados.append({"Portal": "Metrocuadrado", "Precio": precio, "Precio_M2": precio / area, "Ubicacion": extraer_ubicacion(txt), "Descripcion": txt.replace('\n', ' | ')[:120] + "...", "URL": full_url, "Imagen": img_url}) urls_vistas.add(full_url); cont_mc += 1 except: continue page.close(); log_visible += f"✅ MC: {cont_mc} inmuebles.\n" except Exception as e: log_visible += f"⚠️ Error MC.\n" browser.close() if not resultados: return f"{log_visible}\n❌ NO HAY DATOS.", pd.DataFrame(), None, "---" df_final_completo = pd.DataFrame(resultados) df_fr = df_final_completo[df_final_completo['Portal'] == 'Finca Raiz'].head(6) df_mc = df_final_completo[df_final_completo['Portal'] == 'Metrocuadrado'].head(6) df_final = pd.concat([df_fr, df_mc]).reset_index(drop=True) if df_final.empty: return f"{log_visible}\n❌ DATOS VACÍOS.", pd.DataFrame(), None, "---" # --- CÁLCULOS TÉCNICOS Y RANGOS DE NEGOCIACIÓN --- mediana_m2 = df_final['Precio_M2'].median() promedio_m2 = df_final['Precio_M2'].mean() minimo_zona = df_final['Precio'].min() maximo_zona = df_final['Precio'].max() mediana_total = mediana_m2 * area # Bandas de negociación (Aplicadas sobre la Mediana del mercado) valor_maximo_neg = mediana_total * 0.95 # Techo (Castigo 5%) valor_optimo_neg = mediana_total * 0.92 # Centro (Castigo 8%) valor_minimo_neg = mediana_total * 0.90 # Piso (Castigo 10%) # --- GENERACIÓN DEL PDF --- preparar_entorno_pdf() pdf_path = f"Estudio_Mercado_SAE_{int(time.time())}.pdf" pdf = PDF_SAE() COLOR_ROSADO = (254, 25, 120) COLOR_GRIS = (137, 137, 137) COLOR_NEGRO = (0, 0, 0) COLOR_VERDE = (0, 128, 0) COLOR_ROJO = (200, 0, 0) # PÁGINA 1 pdf.add_page() pdf.set_font("Arial", 'B', 18); pdf.set_text_color(*COLOR_ROSADO) pdf.cell(0, 15, sanear_texto("ESTUDIO DE MERCADO INMOBILIARIO"), ln=True, align='C') pdf.set_font("Arial", 'B', 14); pdf.set_text_color(*COLOR_GRIS) pdf.cell(0, 8, sanear_texto(f"METODOLOGIA COMPARATIVA DE MERCADO"), ln=True, align='C') pdf.line(20, 65, 190, 65) pdf.ln(15) pdf.set_font("Arial", 'B', 12); pdf.set_text_color(*COLOR_ROSADO) pdf.cell(0, 10, sanear_texto("1. DATOS DEL INMUEBLE SUJETO"), ln=True) pdf.set_font("Arial", '', 11); pdf.set_text_color(*COLOR_NEGRO) pdf.cell(0, 6, sanear_texto(f"- Operacion: {operacion.capitalize()}"), ln=True) pdf.cell(0, 6, sanear_texto(f"- Tipo de Inmueble: {tipo.capitalize()}"), ln=True) pdf.cell(0, 6, sanear_texto(f"- Ubicacion: Barrio {barrio.title()}, {ciudad.title()}"), ln=True) pdf.cell(0, 6, sanear_texto(f"- Area: {area} m2"), ln=True) pdf.cell(0, 6, sanear_texto(f"- Caracteristicas: {hab} Hab, {ban} Banos, {park} Parqueaderos"), ln=True) pdf.ln(10) pdf.set_font("Arial", 'B', 12); pdf.set_text_color(*COLOR_ROSADO) pdf.cell(0, 10, sanear_texto("2. METODOLOGIA Y COMERCIALIZACION"), ln=True) pdf.set_font("Arial", '', 11); pdf.set_text_color(*COLOR_NEGRO) pdf.multi_cell(0, 5, sanear_texto("Estudio deducido mediante la comparacion sistematica de ofertas recientes de inmuebles similares en la zona. Para aislar los precios especulativos de oferta en internet y llegar al valor real de transaccion, se aplican bandas de negociacion sobre la mediana del mercado (Techo del -5%, Optimo del -8% y Piso del -10%).")) # PÁGINA 2: CONCLUSIONES Y BANDAS DE NEGOCIACIÓN pdf.add_page() pdf.set_font("Arial", 'B', 12); pdf.set_fill_color(*COLOR_ROSADO); pdf.set_text_color(255, 255, 255) pdf.cell(0, 10, sanear_texto(" 3. RESULTADOS ESTADISTICOS Y RANGOS"), ln=True, fill=True) pdf.set_text_color(*COLOR_NEGRO); pdf.ln(5); pdf.set_font("Arial", '', 11) ancho_col = 80 pdf.cell(ancho_col, 8, sanear_texto("Total Testigos Analizados:"), border=1) pdf.cell(50, 8, sanear_texto(f"{len(df_final)}"), border=1, ln=True, align='C') pdf.cell(ancho_col, 8, sanear_texto("Oferta mas economica de la zona:"), border=1) pdf.cell(50, 8, sanear_texto(f"${minimo_zona:,.0f}"), border=1, ln=True, align='C') pdf.cell(ancho_col, 8, sanear_texto("Oferta mas costosa de la zona:"), border=1) pdf.cell(50, 8, sanear_texto(f"${maximo_zona:,.0f}"), border=1, ln=True, align='C') pdf.set_font("Arial", 'B', 11) pdf.cell(ancho_col, 8, sanear_texto("Mediana Bruta (Sin Descuentos):"), border=1) pdf.cell(50, 8, sanear_texto(f"${mediana_total:,.0f}"), border=1, ln=True, align='C') pdf.ln(10) # BLOQUE DE RANGOS DE NEGOCIACIÓN pdf.set_font("Arial", 'B', 14); pdf.set_text_color(*COLOR_ROSADO) pdf.cell(0, 10, sanear_texto("RANGOS DE NEGOCIACION AUTORIZADOS (CIERRE):"), ln=True) pdf.set_font("Arial", 'B', 12); pdf.set_text_color(*COLOR_GRIS) pdf.cell(0, 8, sanear_texto(f"TECHO MAXIMO ESPERADO (Castigo 5%): ${valor_maximo_neg:,.0f} COP"), ln=True) pdf.set_font("Arial", 'B', 16); pdf.set_text_color(*COLOR_VERDE) pdf.cell(0, 12, sanear_texto(f"VALOR OPTIMO SUGERIDO (Castigo 8%): ${valor_optimo_neg:,.0f} COP"), ln=True) pdf.set_font("Arial", 'B', 12); pdf.set_text_color(*COLOR_ROJO) pdf.cell(0, 8, sanear_texto(f"PISO MINIMO ACEPTABLE (Castigo 10%): ${valor_minimo_neg:,.0f} COP"), ln=True) pdf.set_text_color(*COLOR_GRIS); pdf.ln(15); pdf.set_font("Arial", '', 8) pdf.multi_cell(0, 4, sanear_texto("Directriz Comercial: Ninguna propuesta por debajo del piso minimo aceptable debe ser tramitada sin revision especial del comite de avalúos.")) # PÁGINA 3+: ANEXOS pdf.add_page() pdf.set_font("Arial", 'B', 12); pdf.set_fill_color(*COLOR_GRIS); pdf.set_text_color(255, 255, 255) pdf.cell(0, 10, sanear_texto(" 4. ANEXO TECNICO: TESTIGOS COMPARABLES"), ln=True, fill=True) pdf.set_text_color(*COLOR_NEGRO); pdf.ln(5) for idx, r in df_final.iterrows(): if pdf.get_y() > 240: pdf.add_page() y_start = pdf.get_y(); img_path = descargar_imagen(r['Imagen'], idx); text_x = 10 if img_path and os.path.exists(img_path): try: pdf.image(img_path, x=10, y=y_start, w=45, h=30); text_x = 60 except: pass pdf.set_xy(text_x, y_start); pdf.set_font("Arial", 'B', 11) pdf.cell(0, 6, f"${r['Precio']:,.0f} COP", ln=True) pdf.set_x(text_x); pdf.set_font("Arial", 'B', 8); pdf.set_text_color(*COLOR_ROSADO) pdf.cell(0, 4, f"Ubicacion: {sanear_texto(r['Ubicacion'])} | Fuente: {sanear_texto(r['Portal'])}", ln=True) pdf.set_x(text_x); pdf.set_font("Arial", '', 8); pdf.set_text_color(*COLOR_NEGRO) pdf.multi_cell(0, 4, sanear_texto(r['Descripcion'])) pdf.set_x(text_x); pdf.set_font("Arial", '', 8); pdf.set_text_color(0, 102, 204) pdf.cell(0, 4, ">> Ver publicacion original", link=r['URL'], ln=True) pdf.set_text_color(*COLOR_NEGRO); y_end = pdf.get_y(); pdf.set_y(max(y_start + 35, y_end + 5)) pdf.set_draw_color(200, 200, 200); pdf.line(10, pdf.get_y(), 200, pdf.get_y()); pdf.ln(4) if img_path and os.path.exists(img_path): try: os.remove(img_path) except: pass pdf.output(pdf_path) # --- CÁLCULOS INTERFAZ --- resumen = ( f"🏢 **DIRECTRIZ DE NEGOCIACIÓN SAE**\n" f"📈 **Techo Máximo (-5%):** ${valor_maximo_neg:,.0f}\n" f"✅ **VALOR ÓPTIMO SUGERIDO (-8%):** ${valor_optimo_neg:,.0f}\n" f"🛑 **Piso Mínimo Aceptable (-10%):** ${valor_minimo_neg:,.0f}\n\n" f"📊 *Ofertas del Mercado: Min ${minimo_zona:,.0f} | Max ${maximo_zona:,.0f}*" ) df_mostrar = df_final[['Portal', 'Precio', 'Precio_M2', 'Ubicacion', 'Descripcion', 'URL']].copy() df_mostrar['Precio'] = df_mostrar['Precio'].apply(lambda x: f"${x:,.0f}") df_mostrar['Precio_M2'] = df_mostrar['Precio_M2'].apply(lambda x: f"${x:,.0f}") return f"{log_visible}\n✅ Reporte con Bandas Generado.", df_mostrar, pdf_path, resumen except Exception as error_fatal: traza = traceback.format_exc() return f"❌ ERROR GRAVE DEL SISTEMA:\n{str(error_fatal)}", pd.DataFrame(), None, "⚠️ Falló la ejecución" # --- INTERFAZ GRÁFICA --- with gr.Blocks() as demo: gr.Markdown("## 🏢 TramitIA Pro: Analizador Inmobiliario (Bandas SAE)") with gr.Row(): with gr.Column(scale=1): op = gr.Radio(["Arriendo", "Venta"], label="Tipo de Operación", value="Arriendo") c = gr.Textbox(label="Ciudad", value="Barranquilla") b = gr.Textbox(label="Barrio (Ej: La Concepcion)", value="La Concepcion") with gr.Row(): t = gr.Dropdown(["Apartamento", "Casa", "Bodega", "Lote", "Oficina"], label="Tipo de Inmueble", value="Apartamento") a = gr.Number(label="Área M2 a tasar", value=70) with gr.Row(): m2_min = gr.Number(label="Filtro Mercado M2 Mínimo", value=10) m2_max = gr.Number(label="Filtro Mercado M2 Máximo", value=200) with gr.Row(): ascensor = gr.Checkbox(label="Con Ascensor") piscina = gr.Checkbox(label="Con Piscina") with gr.Row(): h = gr.Number(label="Habitaciones", value=3); ban = gr.Number(label="Baños", value=2); p = gr.Number(label="Parqueaderos", value=1) e = gr.Dropdown(["Menos de 1 año", "1 a 8 años", "9 a 15 años", "16 a 30 años", "Más de 30 años"], label="Antigüedad", value="1 a 8 años") btn = gr.Button("GENERAR REPORTE NORMATIVO", variant="primary") with gr.Column(scale=2): res_fin = gr.Markdown("### 💰 Directriz de Negociación...") with gr.Tabs(): with gr.TabItem("Descargar Estudio Técnico (PDF)"): out_pdf = gr.File() with gr.TabItem("Matriz de Datos"): out_df = gr.Dataframe() with gr.TabItem("Log de Sistema"): msg = gr.Textbox(lines=10) btn.click(motor_tramitia_visual, [op, b, c, a, m2_min, m2_max, t, h, ban, p, e, ascensor, piscina], [msg, out_df, out_pdf, res_fin]) demo.launch(theme=gr.themes.Soft())