"""data_fetchers/property_appraiser.py — Unified PA router. Source of Truth para CUALQUIER propiedad en USA. El pre-screening llama a esta funcion COMO PRIMER paso para evitar inferencias erroneas sobre listing data (Zillow puede mentir, ser viejo, o estar incompleto). USAGE: from data_fetchers.property_appraiser import fetch_pa_record, is_pa_supported if is_pa_supported(county_name, state): record = fetch_pa_record( address="2352 SCENIC VIEW CT", parcel_id=None, county_name="Duval", state="FL", zip_code="32218", ) UNIFIED RETURN SCHEMA (cada adapter llena lo que pueda; campos faltantes = None): { # Identity "parcel_id": str, # County-specific RE#/folio "owner_name": str, # Primary owner "co_owners": [str], # If multiple "mailing_address": str, "site_address": str, # Property address "owner_address_mismatch": bool, # mailing != site (absentee owner) # Building "year_built": int, "effective_year_built": int | None, # If county exposes renovations "sqft_heated": int, "sqft_total": int, "lot_acres": float, "lot_total_sqft": int, "bedrooms": int, "baths": float, "stories": float, "building_type": str, "roof_type": str, "exterior_wall": str, "interior_wall": str, "int_flooring": str, # Values "just_value_current": int, "assessed_value_current": int, "taxable_value_current": int, "exemption_current": int, "just_value_last": int, "assessed_value_last": int, "taxes_paid_last": float, "tax_year_current": int, "tax_year_last": int, # Owner signals "homestead_active": bool, # primary residence flag "homestead_amount": int, # Sales history (most recent first) "sales_history": [ { "date": "MM/DD/YYYY", "price": int, "deed_type": str, # "Warranty Deed", "Quit Claim", etc. "qualified": str, # "Qualified" | "Unqualified" "vacant_improved": str, # "Vacant" | "Improved" "book_page": str, }, ... ], # Improvements / permits (when county exposes) "improvements": [ {"year": int, "type": str, "permit": str | None}, ... ], # Computed signals "most_recent_qualified_sale": dict | None, "renovation_signal": { # Heuristic flip/renov detection "is_flip_pattern": bool, "evidence": str, "value_increase_pct": float, "months_between": int, }, # Land "zoning": str, "use_code": str, "use_description": str, # Metadata "county": str, "state": str, "source": str, # "Duval PA (paopropertysearch.coj.net)" etc. "source_url": str, "fetched_at": ISO timestamp, "errors": [str], } """ from __future__ import annotations from typing import Optional # ════════════════════════════════════════════════════════════════════════════ # County → adapter mapping # ════════════════════════════════════════════════════════════════════════════ # Counties with FULL extractors (returning rich unified schema). # Cuando se agreguen Palm Beach, Orange, Hillsborough, etc → sumarlos aca. _SUPPORTED_COUNTIES: dict[str, str] = { # county_lowercase: state "duval": "FL", "broward": "FL", "miami-dade": "FL", "palm beach": "FL", } def is_pa_supported(county_name: Optional[str], state: Optional[str]) -> bool: """True si hay full PA extractor para este county.""" if not county_name or not state: return False key = county_name.lower().replace(" county", "").strip() return _SUPPORTED_COUNTIES.get(key) == state.upper() def list_supported_counties() -> list[tuple[str, str]]: """Returns [(county_lower, state_upper), ...] de counties con PA full.""" return [(c, s) for c, s in _SUPPORTED_COUNTIES.items()] # ════════════════════════════════════════════════════════════════════════════ # Main entry point # ════════════════════════════════════════════════════════════════════════════ def fetch_pa_record( *, county_name: Optional[str], state: Optional[str] = "FL", address: Optional[str] = None, parcel_id: Optional[str] = None, zip_code: Optional[str] = None, timeout_seconds: int = 45, listing_price: Optional[float] = None, ) -> Optional[dict]: """Fetch PA record for a property using the county-specific adapter. Args: listing_price: optional — enables flip-in-progress detection (recent qualified sale << listing → owner is flipping) Returns: Unified dict with PA data, or None if county not supported. """ if not is_pa_supported(county_name, state): return None key = county_name.lower().replace(" county", "").strip() if key == "duval": return _fetch_duval(address=address, parcel_id=parcel_id, zip_code=zip_code, timeout_seconds=timeout_seconds, listing_price=listing_price) if key == "broward": return _fetch_broward(parcel_id=parcel_id, timeout_seconds=timeout_seconds) if key == "miami-dade" or key == "miami dade": return _fetch_miami_dade(parcel_id=parcel_id, address=address, timeout_seconds=timeout_seconds, listing_price=listing_price) if key == "palm beach": return _fetch_palm_beach(parcel_id=parcel_id, timeout_seconds=timeout_seconds, listing_price=listing_price) return None # ════════════════════════════════════════════════════════════════════════════ # Adapter wrappers (normalize per-county output to unified schema) # ════════════════════════════════════════════════════════════════════════════ def _fetch_duval( *, address: Optional[str], parcel_id: Optional[str], zip_code: Optional[str], timeout_seconds: int, listing_price: Optional[float] = None, ) -> dict: """Duval adapter wrapper: pa_duval.fetch_duval_pa_record → unified schema.""" try: from data_fetchers.pa_duval import fetch_duval_pa_record except ImportError as e: return { "county": "Duval", "state": "FL", "errors": [f"pa_duval module import failed: {e}"], } raw = fetch_duval_pa_record( address=address, parcel_id=parcel_id, zip_code=zip_code, timeout_seconds=timeout_seconds, listing_price=listing_price, ) # Normalize raw → unified schema site_addr = " ".join(filter(None, [raw.get("site_address_line1"), raw.get("site_address_line2")])) return { # Identity "parcel_id": raw.get("parcel_id"), "owner_name": raw.get("owner_name"), "co_owners": [], # Duval shows one owner; multi-owner detection pending "mailing_address": None, # not in current detail extraction "site_address": site_addr.strip(), "owner_address_mismatch": None, # Building "year_built": raw.get("year_built"), "effective_year_built": None, # Duval doesn't expose explicitly "sqft_heated": raw.get("sqft_heated"), "sqft_total": raw.get("sqft_gross"), "lot_acres": None, "lot_total_sqft": raw.get("lot_total_sqft"), "bedrooms": int(raw.get("bedrooms") or 0) or None, "baths": raw.get("baths"), "stories": raw.get("stories"), "building_type": raw.get("building_type"), "roof_type": raw.get("roof_struct"), "roofing_cover": raw.get("roofing_cover"), "exterior_wall": raw.get("exterior_wall"), "interior_wall": raw.get("interior_wall"), "int_flooring": raw.get("int_flooring"), # Values "just_value_current": raw.get("tax_current_year_just"), "assessed_value_current": raw.get("tax_current_year_assessed"), "taxable_value_current": raw.get("tax_current_year_taxable"), "exemption_current": raw.get("tax_current_year_exemptions"), "just_value_last": raw.get("tax_last_year_just"), "assessed_value_last": raw.get("tax_last_year_assessed"), "taxable_value_last": raw.get("tax_last_year_taxable"), "taxes_paid_last": None, # Duval doesn't show direct tax amount here "tax_year_current": None, "tax_year_last": None, # Owner signals "homestead_active": raw.get("homestead_active"), "homestead_amount": raw.get("homestead_amount_current"), # Sales history "sales_history": raw.get("sales_history", []), "most_recent_qualified_sale": raw.get("most_recent_qualified_sale"), # Computed signals "renovation_signal": raw.get("renovation_signal"), # Improvements (Duval no expone formales; backlog para Acclaim integration) "improvements": [], # Land "zoning": (raw.get("land") or {}).get("zoning"), "use_code": raw.get("property_use") or "", "use_description": (raw.get("land") or {}).get("use_description"), "subdivision": raw.get("subdivision"), "legal_description": None, # raw has gridLegal but not parsed to flat string here # Metadata "county": "Duval", "state": "FL", "source": raw.get("source"), "source_url": raw.get("source_url"), "fetched_at": raw.get("fetched_at"), "errors": raw.get("errors", []), # Raw passthrough for advanced consumers "_raw": raw, } def _fetch_broward(*, parcel_id: Optional[str], timeout_seconds: int) -> dict: """Broward adapter wrapper: pa_broward.fetch_broward_pa_record → unified schema.""" if not parcel_id: return { "county": "Broward", "state": "FL", "errors": ["Broward PA needs parcel_id (folio); address search not yet supported"], } try: from data_fetchers.pa_broward import fetch_broward_pa_record except ImportError as e: return { "county": "Broward", "state": "FL", "errors": [f"pa_broward module import failed: {e}"], } raw = fetch_broward_pa_record(parcel_id, timeout_seconds=timeout_seconds) cy = raw.get("current_year") or {} ly = raw.get("last_year") or {} # Concatenate owner names if continuation owner_full = (raw.get("owner_name") or "") if raw.get("owner_name_2"): owner_full = f"{owner_full} {raw['owner_name_2']}".strip() # Detect address mismatch mailing = (raw.get("mailing_address") or "").upper() site = (raw.get("situs_address") or "").upper() owner_addr_mismatch = bool(mailing and site and mailing.split()[0] != site.split()[0]) # Parse beds/baths from units_beds_baths beds = baths = None ubb = (raw.get("units_beds_baths") or "").split("/") if len(ubb) >= 3: for raw_v, key in [(ubb[1], "beds"), (ubb[2], "baths")]: v = raw_v.strip() try: if key == "beds": beds = int(v) if v.replace(".", "").isdigit() else None else: baths = float(v) if v.replace(".", "").isdigit() else None except (ValueError, IndexError): pass return { "parcel_id": raw.get("folio_number"), "owner_name": owner_full, "co_owners": [], "mailing_address": raw.get("mailing_address"), "site_address": raw.get("situs_address"), "owner_address_mismatch": owner_addr_mismatch, # Building "year_built": raw.get("year_built"), "effective_year_built": raw.get("effective_year"), "sqft_heated": raw.get("under_air_sqft"), "sqft_total": raw.get("adj_bldg_sqft"), "lot_acres": None, "lot_total_sqft": None, "bedrooms": beds, "baths": baths, "stories": None, "building_type": raw.get("use_code"), "roof_type": None, "roofing_cover": None, "exterior_wall": None, "interior_wall": None, "int_flooring": None, # Values "just_value_current": cy.get("just_value"), "assessed_value_current": cy.get("assessed_value"), "taxable_value_current": (raw.get("tax_breakdown") or {}).get("county", {}).get("taxable"), "exemption_current": (raw.get("tax_breakdown") or {}).get("county", {}).get("homestead", 0), "just_value_last": ly.get("just_value"), "assessed_value_last": ly.get("assessed_value"), "taxable_value_last": None, "taxes_paid_last": ly.get("taxes_paid"), "tax_year_current": cy.get("tax_year"), "tax_year_last": ly.get("tax_year"), # Owner signals "homestead_active": raw.get("homestead_active"), "homestead_amount": (raw.get("tax_breakdown") or {}).get("county", {}).get("homestead", 0), # Sales history "sales_history": raw.get("sales_history", []), "most_recent_qualified_sale": None, # not separately calculated in pa_broward # Computed "renovation_signal": None, # pa_broward doesn't compute this yet "improvements": [], # Land "zoning": None, "use_code": raw.get("use_code"), "use_description": raw.get("use_code"), "subdivision": raw.get("neighborhood"), "legal_description": raw.get("legal_description"), # Metadata "county": "Broward", "state": "FL", "source": "Broward Property Appraiser (bcpa.net)", "source_url": raw.get("source_url"), "fetched_at": raw.get("fetched_at"), "errors": raw.get("errors", []), "_raw": raw, } def _fetch_palm_beach( *, parcel_id: Optional[str], timeout_seconds: int, listing_price: Optional[float] = None, ) -> dict: """Palm Beach wrapper: pa_palm_beach.fetch_palm_beach_pa_record → unified.""" if not parcel_id: return { "county": "Palm Beach", "state": "FL", "errors": ["Palm Beach PA needs parcel_id (PCN); address search not yet supported"], } try: from data_fetchers.pa_palm_beach import fetch_palm_beach_pa_record except ImportError as e: return { "county": "Palm Beach", "state": "FL", "errors": [f"pa_palm_beach module import failed: {e}"], } raw = fetch_palm_beach_pa_record( parcel_id=parcel_id, timeout_seconds=timeout_seconds, listing_price=listing_price, ) # Mailing/site address mismatch heuristic: PB doesn't expose mailing # separately in flat parser; site_address may include "Municipality" noise. return { "parcel_id": raw.get("parcel_id"), "owner_name": raw.get("owner_name"), "co_owners": [], "mailing_address": None, "site_address": raw.get("site_address"), "owner_address_mismatch": None, # Building "year_built": raw.get("year_built"), "effective_year_built": None, "sqft_heated": raw.get("sqft_heated"), "sqft_total": raw.get("sqft_total"), "lot_acres": raw.get("lot_acres"), "lot_total_sqft": None, "bedrooms": raw.get("bedrooms"), "baths": raw.get("baths"), "stories": None, "building_type": raw.get("use_code"), "roof_type": raw.get("roof_struct"), "roofing_cover": raw.get("roof_cover"), "exterior_wall": None, "interior_wall": raw.get("interior_wall"), "int_flooring": None, # Values "just_value_current": raw.get("just_value_current"), "assessed_value_current": raw.get("assessed_value_current"), "taxable_value_current": None, "exemption_current": None, "just_value_last": raw.get("just_value_last"), "assessed_value_last": raw.get("assessed_value_last"), "taxable_value_last": None, "taxes_paid_last": None, "tax_year_current": raw.get("tax_year_current"), "tax_year_last": raw.get("tax_year_last"), # Owner signals "homestead_active": raw.get("homestead_active"), "homestead_amount": None, # Sales "sales_history": raw.get("sales_history", []), "most_recent_qualified_sale": raw.get("most_recent_qualified_sale"), "renovation_signal": raw.get("renovation_signal"), "improvements": [], # Land "zoning": raw.get("zoning"), "use_code": raw.get("use_code"), "use_description": raw.get("use_code"), "subdivision": raw.get("subdivision"), "legal_description": raw.get("legal_description"), # Metadata "county": "Palm Beach", "state": "FL", "source": raw.get("source"), "source_url": raw.get("source_url"), "fetched_at": raw.get("fetched_at"), "errors": raw.get("errors", []), "_raw": raw, } def _fetch_miami_dade( *, parcel_id: Optional[str], address: Optional[str], timeout_seconds: int, listing_price: Optional[float] = None, ) -> dict: """Miami-Dade adapter wrapper: pa_miami_dade.fetch_miami_dade_pa_record → unified schema.""" try: from data_fetchers.pa_miami_dade import fetch_miami_dade_pa_record except ImportError as e: return { "county": "Miami-Dade", "state": "FL", "errors": [f"pa_miami_dade module import failed: {e}"], } raw = fetch_miami_dade_pa_record( parcel_id=parcel_id, address=address, timeout_seconds=timeout_seconds, listing_price=listing_price, ) # Normalize to unified schema return { # Identity "parcel_id": raw.get("parcel_id"), "owner_name": raw.get("owner_name"), "co_owners": raw.get("co_owners", []), "mailing_address": raw.get("mailing_address"), "site_address": raw.get("site_address"), # Owner address mismatch — primitive heuristic (Miami-Dade doesn't expose # separate site/mailing comparison cleanly; can compute later if needed) "owner_address_mismatch": None, # Building "year_built": raw.get("year_built"), "effective_year_built": None, # Miami-Dade doesn't expose explicitly "sqft_heated": raw.get("sqft_heated"), "sqft_total": raw.get("sqft_total"), "lot_acres": None, "lot_total_sqft": raw.get("lot_total_sqft"), "bedrooms": raw.get("bedrooms"), "baths": raw.get("baths"), "stories": raw.get("floors"), "building_type": raw.get("use_code"), "roof_type": None, "roofing_cover": None, "exterior_wall": None, "interior_wall": None, "int_flooring": None, # Values "just_value_current": raw.get("just_value_current"), "assessed_value_current": raw.get("assessed_value_current"), "taxable_value_current": None, # in pa-taxablevalueinformation, not parsed yet "exemption_current": None, "just_value_last": raw.get("just_value_last"), "assessed_value_last": raw.get("assessed_value_last"), "taxable_value_last": None, "taxes_paid_last": None, "tax_year_current": raw.get("tax_year_current"), "tax_year_last": raw.get("tax_year_last"), # Owner signals "homestead_active": raw.get("homestead_active"), "homestead_amount": None, # Sales history "sales_history": raw.get("sales_history", []), "most_recent_qualified_sale": raw.get("most_recent_qualified_sale"), # Renovation "renovation_signal": raw.get("renovation_signal"), "improvements": [], # Land "zoning": raw.get("pa_primary_zone"), "use_code": raw.get("use_code"), "use_description": raw.get("use_description"), "subdivision": raw.get("subdivision"), "legal_description": raw.get("legal_description"), # Metadata "county": "Miami-Dade", "state": "FL", "source": raw.get("source"), "source_url": raw.get("source_url"), "fetched_at": raw.get("fetched_at"), "errors": raw.get("errors", []), "_raw": raw, }