"""Sub-agente: Valoración y mercado inmobiliario. Fuentes: Zillow (scraping) + County Property Appraiser oficial de Florida. Soporta todos los condados principales de Florida — se selecciona automáticamente según el condado geocodificado. """ from __future__ import annotations import re import sys import time from pathlib import Path import requests from bs4 import BeautifulSoup from data_fetchers.base import USER_AGENT, DEFAULT_TIMEOUT _PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent if str(_PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(_PROJECT_ROOT)) # ── Mapa de condados → fetcher / sitio PA oficial ──────────────────────────── # Clave: fragmento del nombre del condado en minúsculas (como devuelve el geocoder) # Valor: dict con 'fetcher' (función importable) o 'url' (sitio web PA para display) COUNTY_PA_MAP = { # Condados con fetcher dedicado en data_fetchers/ "broward": {"fetcher": "data_fetchers.pa_broward:fetch_pa_broward", "name": "Broward County PA", "site": "https://bcpa.net"}, "miami-dade": {"fetcher": "data_fetchers.pa_miami_dade:fetch_pa_miami_dade", "name": "Miami-Dade PA", "site": "https://www.miamidade.gov/Apps/PA/propertysearch"}, "palm beach": {"fetcher": "data_fetchers.pa_palm_beach:fetch_pa_palm_beach", "name": "Palm Beach County PA", "site": "https://pbcpao.gov"}, "duval": {"fetcher": "data_fetchers.pa_duval:fetch_pa_duval", "name": "Duval County PA (Jacksonville)", "site": "https://www.coj.net/departments/property-appraiser"}, # Condados con sitio PA oficial (scraping genérico o referencia) "st. johns": {"site": "https://www.sjcpa.us", "name": "St. Johns County PA (St. Augustine)"}, "saint johns":{"site": "https://www.sjcpa.us", "name": "St. Johns County PA (St. Augustine)"}, "volusia": {"site": "https://vcpa.volusia.org", "name": "Volusia County PA (Daytona Beach)"}, "orange": {"site": "https://www.ocpafl.org", "name": "Orange County PA (Orlando)"}, "hillsborough":{"site": "https://www.hcpafl.org", "name": "Hillsborough County PA (Tampa)"}, "pinellas": {"site": "https://www.pcpao.gov", "name": "Pinellas County PA (St. Petersburg/Clearwater)"}, "seminole": {"site": "https://www.scpafl.org", "name": "Seminole County PA (Sanford/Altamonte)"}, "osceola": {"site": "https://www.property-appraiser.org", "name": "Osceola County PA (Kissimmee)"}, "brevard": {"site": "https://www.bcpao.us", "name": "Brevard County PA (Melbourne/Cocoa)"}, "indian river":{"site": "https://www.ircpa.org", "name": "Indian River County PA (Vero Beach)"}, "martin": {"site": "https://www.pa.martin.fl.us", "name": "Martin County PA (Stuart/Hobe Sound)"}, "st. lucie": {"site": "https://www.paslc.gov", "name": "St. Lucie County PA (Port St. Lucie/Fort Pierce)"}, "saint lucie":{"site": "https://www.paslc.gov", "name": "St. Lucie County PA"}, "lee": {"site": "https://www.leepa.org", "name": "Lee County PA (Fort Myers/Cape Coral)"}, "collier": {"site": "https://www.collierappraiser.com", "name": "Collier County PA (Naples/Marco Island)"}, "charlotte": {"site": "https://www.ccappraiser.com", "name": "Charlotte County PA (Port Charlotte/Punta Gorda)"}, "sarasota": {"site": "https://www.sc-pa.com", "name": "Sarasota County PA"}, "manatee": {"site": "https://www.manateepao.gov", "name": "Manatee County PA (Bradenton)"}, "polk": {"site": "https://www.polkpa.org", "name": "Polk County PA (Lakeland/Winter Haven)"}, "pasco": {"site": "https://www.pascopa.com", "name": "Pasco County PA (New Port Richey/Wesley Chapel)"}, "hernando": {"site": "https://www.hernandopa-fl.us", "name": "Hernando County PA (Spring Hill/Brooksville)"}, "citrus": {"site": "https://www.citruspa.org", "name": "Citrus County PA (Crystal River/Inverness)"}, "marion": {"site": "https://www.pa.marion.fl.us", "name": "Marion County PA (Ocala)"}, "alachua": {"site": "https://www.acpafl.org", "name": "Alachua County PA (Gainesville)"}, "putnam": {"site": "https://www.putnam-fl.com/pa", "name": "Putnam County PA (Palatka)"}, "flagler": {"site": "https://www.flaglerpa.com", "name": "Flagler County PA (Palm Coast/Flagler Beach)"}, "clay": {"site": "https://www.ccpao.com", "name": "Clay County PA (Fleming Island/Orange Park)"}, "nassau": {"site": "https://www.nassauflpa.com", "name": "Nassau County PA (Fernandina Beach/Yulee)"}, "baker": {"site": "https://www.bakerpa.com", "name": "Baker County PA (Macclenny)"}, "columbia": {"site": "https://www.columbiapafl.com", "name": "Columbia County PA (Lake City)"}, "leon": {"site": "https://www.leonpa.org", "name": "Leon County PA (Tallahassee)"}, "escambia": {"site": "https://www.escpa.org", "name": "Escambia County PA (Pensacola)"}, "santa rosa": {"site": "https://www.srcpa.org", "name": "Santa Rosa County PA (Milton/Gulf Breeze)"}, "okaloosa": {"site": "https://www.okaloosapa.com", "name": "Okaloosa County PA (Fort Walton Beach/Destin)"}, "walton": {"site": "https://www.waltonpa.com", "name": "Walton County PA (Destin/30A/DeFuniak Springs)"}, "bay": {"site": "https://www.baycopa.com", "name": "Bay County PA (Panama City)"}, "monroe": {"site": "https://www.mcpafl.org", "name": "Monroe County PA (Florida Keys)"}, "lake": {"site": "https://www.lakepa.org", "name": "Lake County PA (Leesburg/Tavares/Mount Dora)"}, "sumter": {"site": "https://www.sumterpa.com", "name": "Sumter County PA (The Villages/Bushnell)"}, } def _match_county(county: str) -> dict | None: """Encuentra el PA info para un condado dado.""" c = county.lower().strip() # Coincidencia exacta primero if c in COUNTY_PA_MAP: return COUNTY_PA_MAP[c] # Coincidencia parcial for key, val in COUNTY_PA_MAP.items(): if key in c or c in key: return val return None def run(lat: float, lon: float, address: str, county: str = "") -> dict: """Recopila datos de valoración inmobiliaria.""" result = { "estimated_value": None, "price_per_sqft": None, "appreciation_1y": None, "appreciation_3y": None, "days_on_market": None, "median_list_price": None, "inventory": None, "county_assessed_value": None, "county_market_value": None, "pa_site": None, "pa_name": None, "sources": [], "errors": [], } # --- Zillow --- try: z = _zillow_neighborhood(lat, lon, address) result.update({k: v for k, v in z.items() if v is not None}) result["sources"].append("Zillow") except Exception as e: result["errors"].append(f"Zillow: {e}") # --- County Property Appraiser --- pa_info = _match_county(county) if pa_info: result["pa_name"] = pa_info.get("name", "") result["pa_site"] = pa_info.get("site", "") fetcher_ref = pa_info.get("fetcher") if fetcher_ref: try: module_path, func_name = fetcher_ref.split(":") import importlib mod = importlib.import_module(module_path) fetch_fn = getattr(mod, func_name) pa = fetch_fn(address) if pa: result["county_assessed_value"] = pa.get("assessed_value") result["county_market_value"] = pa.get("market_value") result["sources"].append(pa_info["name"]) except Exception as e: result["errors"].append(f"{pa_info.get('name','PA')}: {e}") else: # Sin fetcher dedicado — registrar el sitio como referencia result["sources"].append(f"{pa_info['name']} (referencia)") else: result["errors"].append( f"County Appraiser no mapeado para: '{county}'. " "Consulta manualemente en floridapropertytax.org" ) return result def _zillow_neighborhood(lat: float, lon: float, address: str) -> dict: """Scraping básico de Zillow para datos del vecindario.""" # Buscar ZIP de la dirección zip_m = re.search(r"\b(\d{5})\b", address) zip_code = zip_m.group(1) if zip_m else "" if not zip_code: raise ValueError("No se encontró ZIP code en la dirección") url = f"https://www.zillow.com/homes/{zip_code}_rb/" headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept-Language": "en-US,en;q=0.9", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", } time.sleep(3) # rate limiting r = requests.get(url, headers=headers, timeout=DEFAULT_TIMEOUT) r.raise_for_status() soup = BeautifulSoup(r.text, "html.parser") # Extraer datos estructurados si están disponibles (script tags con JSON) result = {} for script in soup.find_all("script", type="application/json"): try: import json data = json.loads(script.string or "") # Buscar datos de precio en la estructura JSON de Zillow if isinstance(data, dict): props = data.get("cat1", {}).get("searchResults", {}).get("listResults", []) if props: prices = [p.get("price", 0) for p in props if p.get("price")] if prices: result["median_list_price"] = sorted(prices)[len(prices) // 2] sqfts = [p.get("price", 0) / p.get("area", 1) for p in props if p.get("price") and p.get("area")] if sqfts: result["price_per_sqft"] = round(sum(sqfts) / len(sqfts)) doms = [p.get("daysOnZillow", 0) for p in props if p.get("daysOnZillow")] if doms: result["days_on_market"] = round(sum(doms) / len(doms)) result["inventory"] = len(props) except Exception: continue return result def score(data: dict) -> int: """Calcula score 0-100 de mercado inmobiliario.""" if not data: return 50 s = 50 # base # Apreciación 1 año (si disponible) app1 = data.get("appreciation_1y") if app1 is not None: if app1 >= 10: s += 20 elif app1 >= 5: s += 12 elif app1 >= 0: s += 5 else: s -= 10 # Días en mercado (menos días = mercado más activo) dom = data.get("days_on_market") if dom is not None: if dom <= 20: s += 15 elif dom <= 40: s += 8 elif dom <= 60: s += 0 else: s -= 10 # Precio por sqft como indicador de demanda ppsf = data.get("price_per_sqft") if ppsf is not None: if ppsf >= 300: s += 10 elif ppsf >= 200: s += 5 return min(100, max(0, s))