feat: AR-House initial commit
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
"""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))
|
||||
Reference in New Issue
Block a user