feat: Agente-Marketing initial commit
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import requests
|
||||
import json
|
||||
|
||||
# Supports Ollama (local) or Anthropic API
|
||||
# Set OLLAMA_URL to use local AI, or ANTHROPIC_KEY for Claude API
|
||||
|
||||
OLLAMA_URL = "http://localhost:11434"
|
||||
OLLAMA_MODEL = "qwen2.5:14b"
|
||||
|
||||
USER_PROFILE = """
|
||||
Perfil del comprador:
|
||||
- Residente permanente en EE.UU., menos de 2 años en el país
|
||||
- Soltero
|
||||
- Trabajo independiente: electricista naval/marino ($120/hora)
|
||||
- Dinero disponible para down payment: $50,000 dólares
|
||||
- Presupuesto máximo: $200,000
|
||||
- Busca: costas de Florida (Stuart hacia el norte hasta Jacksonville)
|
||||
- Objetivo: casa para vivir, oportunidad de remate o precio bajo de mercado
|
||||
- Situación crediticia: construyendo historial en USA, no califica para préstamo convencional aún
|
||||
- Tipo de préstamo ideal: Non-QM, Bank Statement, o ITIN loan
|
||||
"""
|
||||
|
||||
ANALYSIS_PROMPT = """Eres un asesor experto en bienes raíces y finanzas para compradores en Florida.
|
||||
|
||||
{profile}
|
||||
|
||||
Analiza esta propiedad y responde en español en máximo 4 oraciones:
|
||||
1. ¿Es una buena oportunidad para este comprador?
|
||||
2. ¿Qué tipo de préstamo le conviene para esta propiedad específica?
|
||||
3. ¿Hay algo que deba investigar o que sea una señal de alerta?
|
||||
|
||||
Propiedad:
|
||||
- Dirección: {address}
|
||||
- Ciudad/Condado: {city}
|
||||
- Precio: ${price:,}
|
||||
- Tipo: {prop_type}
|
||||
- Estado: {status}
|
||||
- Habitaciones: {beds} | Baños: {baths}
|
||||
- Metros cuadrados: {sqft}
|
||||
- Fuente: {source}
|
||||
|
||||
Responde de forma directa y práctica, como si le hablaras a un amigo."""
|
||||
|
||||
|
||||
def analyze_with_ollama(prop: dict) -> str:
|
||||
prompt = ANALYSIS_PROMPT.format(
|
||||
profile=USER_PROFILE,
|
||||
address=prop.get("address", "N/A"),
|
||||
city=f"{prop.get('city', '')} {prop.get('county', '')}".strip(),
|
||||
price=prop.get("price", 0),
|
||||
prop_type=prop.get("property_type", "Residencial"),
|
||||
status=prop.get("status", "Foreclosure"),
|
||||
beds=prop.get("beds", "N/A"),
|
||||
baths=prop.get("baths", "N/A"),
|
||||
sqft=prop.get("sqft", "N/A"),
|
||||
source=prop.get("source", "N/A")
|
||||
)
|
||||
try:
|
||||
r = requests.post(
|
||||
f"{OLLAMA_URL}/api/generate",
|
||||
json={"model": OLLAMA_MODEL, "prompt": prompt, "stream": False},
|
||||
timeout=60
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return r.json().get("response", "").strip()
|
||||
except Exception as e:
|
||||
return f"IA local no disponible: {e}"
|
||||
return "Análisis no disponible."
|
||||
|
||||
|
||||
def analyze_with_anthropic(prop: dict, api_key: str) -> str:
|
||||
prompt = ANALYSIS_PROMPT.format(
|
||||
profile=USER_PROFILE,
|
||||
address=prop.get("address", "N/A"),
|
||||
city=f"{prop.get('city', '')} {prop.get('county', '')}".strip(),
|
||||
price=prop.get("price", 0),
|
||||
prop_type=prop.get("property_type", "Residencial"),
|
||||
status=prop.get("status", "Foreclosure"),
|
||||
beds=prop.get("beds", "N/A"),
|
||||
baths=prop.get("baths", "N/A"),
|
||||
sqft=prop.get("sqft", "N/A"),
|
||||
source=prop.get("source", "N/A")
|
||||
)
|
||||
try:
|
||||
r = requests.post(
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
headers={"x-api-key": api_key, "anthropic-version": "2023-06-01", "content-type": "application/json"},
|
||||
json={"model": "claude-haiku-4-5-20251001", "max_tokens": 300, "messages": [{"role": "user", "content": prompt}]},
|
||||
timeout=30
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return r.json()["content"][0]["text"].strip()
|
||||
except Exception as e:
|
||||
return f"Error API: {e}"
|
||||
return "Análisis no disponible."
|
||||
|
||||
|
||||
def analyze_property(prop: dict, ollama_model: str = None, anthropic_key: str = None) -> str:
|
||||
global OLLAMA_MODEL
|
||||
if ollama_model:
|
||||
OLLAMA_MODEL = ollama_model
|
||||
if anthropic_key:
|
||||
return analyze_with_anthropic(prop, anthropic_key)
|
||||
return analyze_with_ollama(prop)
|
||||
@@ -0,0 +1,58 @@
|
||||
FLORIDA_CITIES = [
|
||||
# Treasure Coast
|
||||
"Stuart", "Palm City", "Hobe Sound", "Jensen Beach", "Port Salerno",
|
||||
"Indiantown", "Port St. Lucie", "Fort Pierce", "Tradition", "St. Lucie West",
|
||||
"Vero Beach", "Sebastian", "Fellsmere", "Roseland", "Indian River Shores",
|
||||
"Orchid", "Gifford", "Wabasso",
|
||||
# Space Coast / Brevard
|
||||
"Melbourne", "Palm Bay", "Titusville", "Cocoa", "Cocoa Beach",
|
||||
"Rockledge", "Merritt Island", "Cape Canaveral", "Satellite Beach",
|
||||
"Indian Harbour Beach", "Indialantic", "Melbourne Beach", "Grant",
|
||||
"Valkaria", "Malabar", "Palm Shores", "Micco", "West Melbourne",
|
||||
# Volusia
|
||||
"Daytona Beach", "Daytona Beach Shores", "Ormond Beach", "Holly Hill",
|
||||
"South Daytona", "Port Orange", "Ponce Inlet", "New Smyrna Beach",
|
||||
"Edgewater", "Oak Hill", "Deltona", "DeLand", "Orange City",
|
||||
"DeBary", "Lake Helen", "Pierson", "Flagler Beach",
|
||||
# Flagler
|
||||
"Palm Coast", "Bunnell", "Flagler Beach", "Beverly Beach", "Marineland",
|
||||
# St. Johns
|
||||
"St. Augustine", "St. Augustine Beach", "Ponte Vedra Beach",
|
||||
"Ponte Vedra", "Palm Valley", "Nocatee", "World Golf Village",
|
||||
"Hastings", "Crescent Beach", "Vilano Beach",
|
||||
# Duval / Jacksonville
|
||||
"Jacksonville", "Jacksonville Beach", "Atlantic Beach", "Neptune Beach",
|
||||
"Fernandina Beach", "Yulee", "Callahan", "Baldwin", "Mandarin",
|
||||
"Southside", "Westside", "Arlington", "Baymeadows", "Ponte Vedra",
|
||||
"Orange Park", "Fleming Island",
|
||||
# Nassau
|
||||
"Fernandina Beach", "Yulee", "Hilliard", "Callahan",
|
||||
# Palm Beach / South (just in case they expand)
|
||||
"West Palm Beach", "Boca Raton", "Delray Beach", "Lake Worth",
|
||||
"Boynton Beach", "Riviera Beach", "Palm Beach Gardens",
|
||||
"Jupiter", "Tequesta", "North Palm Beach",
|
||||
# Broward
|
||||
"Fort Lauderdale", "Hollywood", "Pompano Beach", "Deerfield Beach",
|
||||
"Hallandale Beach", "Coral Springs", "Margate", "Coconut Creek",
|
||||
"Tamarac", "Plantation", "Davie", "Miramar", "Pembroke Pines",
|
||||
"Lauderdale-by-the-Sea", "Dania Beach",
|
||||
# Miami-Dade
|
||||
"Miami", "Miami Beach", "Coral Gables", "Hialeah", "Homestead",
|
||||
"Aventura", "Sunny Isles Beach", "North Miami", "North Miami Beach",
|
||||
"Doral", "Kendall", "Cutler Bay", "Palmetto Bay",
|
||||
# Gulf Coast
|
||||
"Naples", "Marco Island", "Bonita Springs", "Estero", "Fort Myers",
|
||||
"Fort Myers Beach", "Cape Coral", "Punta Gorda", "Port Charlotte",
|
||||
"Englewood", "Venice", "Sarasota", "Bradenton", "Anna Maria",
|
||||
"Holmes Beach", "Palmetto", "St. Petersburg", "Clearwater",
|
||||
"Dunedin", "Tarpon Springs", "New Port Richey", "Port Richey",
|
||||
"Hudson", "Spring Hill", "Brooksville",
|
||||
# Central
|
||||
"Orlando", "Kissimmee", "Sanford", "Altamonte Springs", "Casselberry",
|
||||
"Winter Park", "Maitland", "Apopka", "Ocoee", "Winter Garden",
|
||||
"Clermont", "Leesburg", "The Villages", "Ocala", "Gainesville",
|
||||
"Tallahassee", "Pensacola", "Panama City", "Destin", "Fort Walton Beach",
|
||||
]
|
||||
|
||||
# Remove duplicates and sort
|
||||
FLORIDA_CITIES = sorted(list(set(FLORIDA_CITIES)))
|
||||
@@ -0,0 +1,122 @@
|
||||
LENDERS = [
|
||||
{
|
||||
"name": "Heart Mortgage",
|
||||
"loan_type": "ITIN / Non-QM / Bank Statement",
|
||||
"specialties": "Residentes nuevos, ITIN, sin historial crediticio USA, self-employed",
|
||||
"min_down_pct": 15.0,
|
||||
"accepts_new_resident": True,
|
||||
"accepts_self_employed": True,
|
||||
"requires_2yr_history": False,
|
||||
"phone": "",
|
||||
"email": "",
|
||||
"website": "https://blog.heartmortgage.com",
|
||||
"match_score": 98,
|
||||
"contact_script": "Hola, me llamo [TU NOMBRE]. Soy residente permanente, llevo menos de 2 años en EE.UU. Trabajo como contratista independiente en eléctrica marina y tengo $50,000 para down payment. Estoy buscando propiedades hasta $200,000 en la costa de Florida. ¿Tienen programas Bank Statement o Non-QM para mi situación?",
|
||||
"notes": "Top lender FL 2026 para residentes nuevos e ITIN. No requieren 2 años de historial."
|
||||
},
|
||||
{
|
||||
"name": "Jhenesis Mortgage",
|
||||
"loan_type": "ITIN / Sin score crediticio",
|
||||
"specialties": "Sin score requerido, hasta 85% LTV, 1 año empleo suficiente",
|
||||
"min_down_pct": 15.0,
|
||||
"accepts_new_resident": True,
|
||||
"accepts_self_employed": True,
|
||||
"requires_2yr_history": False,
|
||||
"phone": "",
|
||||
"email": "",
|
||||
"website": "https://jhenesismortgage.com",
|
||||
"match_score": 97,
|
||||
"contact_script": "Hola, me llamo [TU NOMBRE]. Soy residente permanente con menos de 2 años en EE.UU. Trabajo independiente en instalaciones eléctricas marinas, ingresos de $120/hora. Tengo $50,000 de down payment y busco hasta $200,000. ¿Manejan préstamos ITIN o sin score crediticio americano?",
|
||||
"notes": "Aceptan sin score crediticio USA. Solo necesitan 1 año de historial laboral."
|
||||
},
|
||||
{
|
||||
"name": "1st Florida Lending",
|
||||
"loan_type": "ITIN / DACA / Residentes",
|
||||
"specialties": "ITIN, DACA, residentes nuevos, toda Florida",
|
||||
"min_down_pct": 10.0,
|
||||
"accepts_new_resident": True,
|
||||
"accepts_self_employed": True,
|
||||
"requires_2yr_history": False,
|
||||
"phone": "",
|
||||
"email": "",
|
||||
"website": "https://www.1floridalending.com",
|
||||
"match_score": 95,
|
||||
"contact_script": "Hola, llamo porque necesito un préstamo hipotecario. Soy residente permanente, menos de 2 años en EE.UU., trabajo contratista independiente en eléctrica marina. Down payment disponible: $50,000. Propiedad buscada: hasta $200,000 en costa de Florida. ¿Trabajan con residentes nuevos y self-employed?",
|
||||
"notes": "Especializados en ITIN y residentes nuevos en toda Florida. Down payment desde 10%."
|
||||
},
|
||||
{
|
||||
"name": "Bennett Capital Partners",
|
||||
"loan_type": "Non-QM / Bank Statement",
|
||||
"specialties": "Bank Statement loans, residentes nuevos, self-employed, Miami y Florida",
|
||||
"min_down_pct": 20.0,
|
||||
"accepts_new_resident": True,
|
||||
"accepts_self_employed": True,
|
||||
"requires_2yr_history": False,
|
||||
"phone": "",
|
||||
"email": "",
|
||||
"website": "https://www.bcpmortgage.com",
|
||||
"match_score": 92,
|
||||
"contact_script": "Hola, mi nombre es [TU NOMBRE]. Soy residente permanente trabajando como contratista independiente en eléctrica naval, ganando $120 por hora. Tengo $50,000 líquidos para down payment y busco propiedad hasta $200,000 en costa de Florida. ¿Tienen Bank Statement loans para alguien con menos de 2 años de historial en EE.UU.?",
|
||||
"notes": "Fuertes en Non-QM y Bank Statement. Acceso a múltiples lenders portfolio."
|
||||
},
|
||||
{
|
||||
"name": "The Doce Group",
|
||||
"loan_type": "Non-QM / Foreign National / Residente",
|
||||
"specialties": "Residentes nuevos, extranjeros, sur de Florida",
|
||||
"min_down_pct": 25.0,
|
||||
"accepts_new_resident": True,
|
||||
"accepts_self_employed": True,
|
||||
"requires_2yr_history": False,
|
||||
"phone": "",
|
||||
"email": "",
|
||||
"website": "https://thedocegroup.com",
|
||||
"match_score": 88,
|
||||
"contact_script": "Hola, soy residente permanente, menos de 2 años en EE.UU., work as independent marine electrical contractor. $50,000 down payment disponible, busco hasta $200,000. ¿Tienen programas para mi perfil?",
|
||||
"notes": "Especializados en perfiles internacionales y residentes nuevos en Florida."
|
||||
},
|
||||
{
|
||||
"name": "Griffin Funding",
|
||||
"loan_type": "Bank Statement / Non-QM",
|
||||
"specialties": "Self-employed, Bank Statement loans, toda Florida",
|
||||
"min_down_pct": 10.0,
|
||||
"accepts_new_resident": True,
|
||||
"accepts_self_employed": True,
|
||||
"requires_2yr_history": False,
|
||||
"phone": "",
|
||||
"email": "",
|
||||
"website": "https://griffinfunding.com",
|
||||
"match_score": 90,
|
||||
"contact_script": "Hola, busco un Bank Statement loan. Soy contratista independiente en eléctrica marina, residente permanente, menos de 2 años en EE.UU. Tengo $50,000 de down payment y busco propiedad hasta $200,000 en costas de Florida. ¿Califico con bank statements de 12 meses?",
|
||||
"notes": "Líderes en Bank Statement loans para self-employed. Desde 10% down."
|
||||
},
|
||||
{
|
||||
"name": "DAK Mortgage",
|
||||
"loan_type": "Non-QM / Internacional",
|
||||
"specialties": "Compradores internacionales, residentes nuevos, Miami y Florida",
|
||||
"min_down_pct": 20.0,
|
||||
"accepts_new_resident": True,
|
||||
"accepts_self_employed": True,
|
||||
"requires_2yr_history": False,
|
||||
"phone": "",
|
||||
"email": "",
|
||||
"website": "https://davidakrebs.com",
|
||||
"match_score": 85,
|
||||
"contact_script": "Hola, soy residente permanente con menos de 2 años aquí. Trabajo independiente en instalaciones eléctricas marinas. $50,000 down payment, busco hasta $200,000 en costas de Florida. ¿Tienen opciones para mi perfil?",
|
||||
"notes": "Especialistas en Miami para perfiles internacionales y residentes nuevos."
|
||||
},
|
||||
{
|
||||
"name": "Angel Oak Mortgage",
|
||||
"loan_type": "Non-QM / Foreign National",
|
||||
"specialties": "Non-QM, residentes, bank statement, toda USA",
|
||||
"min_down_pct": 20.0,
|
||||
"accepts_new_resident": True,
|
||||
"accepts_self_employed": True,
|
||||
"requires_2yr_history": False,
|
||||
"phone": "",
|
||||
"email": "",
|
||||
"website": "https://angeloakms.com",
|
||||
"match_score": 87,
|
||||
"contact_script": "Hola, necesito información sobre Non-QM loans. Soy residente permanente, self-employed en eléctrica marina, menos de 2 años en EE.UU., $50,000 down payment, busco hasta $200,000 en Florida. ¿Tienen programas para este perfil?",
|
||||
"notes": "Pioneros en Non-QM. Tienen programa específico Foreign National y residentes nuevos."
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,337 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import sys, io
|
||||
if sys.stdout.encoding != 'utf-8':
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
from flask import Flask, render_template, request, redirect, url_for, jsonify, flash
|
||||
from models import db, Property, Lender, ScrapeLog, SearchCity, SearchConfig
|
||||
from lenders_data import LENDERS
|
||||
from scrapers import run_all_scrapers
|
||||
from ai_analyzer import analyze_property
|
||||
from florida_cities import FLORIDA_CITIES
|
||||
import os, subprocess, json, sys
|
||||
from datetime import datetime
|
||||
|
||||
app = Flask(__name__)
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
STATUS_FILE = os.path.join(BASE_DIR, "scan_status.json")
|
||||
RESULTS_FILE = os.path.join(BASE_DIR, "scan_results.json")
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get(
|
||||
"DATABASE_URL",
|
||||
f"sqlite:///{os.path.join(BASE_DIR, 'casahunter.db')}"
|
||||
)
|
||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||
_secret = os.environ.get("SECRET_KEY")
|
||||
if not _secret:
|
||||
import secrets as _secrets
|
||||
_secret = _secrets.token_hex(32)
|
||||
app.secret_key = _secret
|
||||
|
||||
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5:14b")
|
||||
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||||
ANTHROPIC_KEY = os.environ.get("ANTHROPIC_KEY", "")
|
||||
|
||||
db.init_app(app)
|
||||
|
||||
DEFAULT_CITIES = [
|
||||
"Vero Beach", "Sebastian", "Stuart", "Jensen Beach",
|
||||
"Melbourne", "Cocoa Beach", "Titusville",
|
||||
"Daytona Beach", "Ormond Beach", "New Smyrna Beach",
|
||||
"St. Augustine", "Jacksonville", "Jacksonville Beach", "Fernandina Beach"
|
||||
]
|
||||
DEFAULT_MAX_PRICE = 200000
|
||||
DEFAULT_DOWN_PAYMENT = 50000
|
||||
|
||||
|
||||
def get_config(key, default):
|
||||
row = SearchConfig.query.filter_by(key=key).first()
|
||||
return row.value if row else default
|
||||
|
||||
|
||||
def set_config(key, value):
|
||||
row = SearchConfig.query.filter_by(key=key).first()
|
||||
if row:
|
||||
row.value = str(value)
|
||||
else:
|
||||
db.session.add(SearchConfig(key=key, value=str(value)))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def seed_defaults():
|
||||
if Lender.query.count() == 0:
|
||||
for l in LENDERS:
|
||||
db.session.add(Lender(**l))
|
||||
if SearchCity.query.count() == 0:
|
||||
for c in DEFAULT_CITIES:
|
||||
db.session.add(SearchCity(city=c, active=True))
|
||||
if not SearchConfig.query.filter_by(key="max_price").first():
|
||||
db.session.add(SearchConfig(key="max_price", value=str(DEFAULT_MAX_PRICE)))
|
||||
if not SearchConfig.query.filter_by(key="down_payment").first():
|
||||
db.session.add(SearchConfig(key="down_payment", value=str(DEFAULT_DOWN_PAYMENT)))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@app.before_request
|
||||
def init_db():
|
||||
if not hasattr(app, "_db_ready"):
|
||||
db.create_all()
|
||||
seed_defaults()
|
||||
app._db_ready = True
|
||||
|
||||
|
||||
# ── API: cities autocomplete ──────────────────────────────────────────────────
|
||||
@app.route("/api/cities")
|
||||
def api_cities():
|
||||
q = request.args.get("q", "").lower()
|
||||
matches = [c for c in FLORIDA_CITIES if q in c.lower()] if q else FLORIDA_CITIES[:20]
|
||||
return jsonify(matches[:15])
|
||||
|
||||
|
||||
# ── Dashboard ─────────────────────────────────────────────────────────────────
|
||||
@app.route("/")
|
||||
def index():
|
||||
max_price = int(get_config("max_price", DEFAULT_MAX_PRICE))
|
||||
top_props = Property.query.order_by(Property.score.desc()).limit(6).all()
|
||||
total = Property.query.count()
|
||||
favorites = Property.query.filter_by(is_favorite=True).count()
|
||||
last_scan = ScrapeLog.query.order_by(ScrapeLog.ran_at.desc()).first()
|
||||
top_lenders = Lender.query.order_by(Lender.match_score.desc()).limit(3).all()
|
||||
active_cities = SearchCity.query.filter_by(active=True).order_by(SearchCity.city).all()
|
||||
return render_template("index.html",
|
||||
properties=top_props, total=total, favorites=favorites,
|
||||
last_scan=last_scan, top_lenders=top_lenders,
|
||||
active_cities=active_cities, max_price=max_price
|
||||
)
|
||||
|
||||
|
||||
# ── Properties list ───────────────────────────────────────────────────────────
|
||||
@app.route("/properties")
|
||||
def properties():
|
||||
sort = request.args.get("sort", "score")
|
||||
source = request.args.get("source", "")
|
||||
min_score = int(request.args.get("min_score", 0))
|
||||
favorites_only = request.args.get("favorites") == "1"
|
||||
city_filter = request.args.get("city", "")
|
||||
max_price = int(request.args.get("max_price", get_config("max_price", DEFAULT_MAX_PRICE)))
|
||||
|
||||
q = Property.query
|
||||
if source:
|
||||
q = q.filter(Property.source == source)
|
||||
if min_score > 0:
|
||||
q = q.filter(Property.score >= min_score)
|
||||
if favorites_only:
|
||||
q = q.filter(Property.is_favorite == True)
|
||||
if city_filter:
|
||||
q = q.filter(Property.city.ilike(f"%{city_filter}%"))
|
||||
if max_price:
|
||||
q = q.filter(Property.price <= max_price)
|
||||
|
||||
order_map = {
|
||||
"price_asc": Property.price.asc(),
|
||||
"price_desc": Property.price.desc(),
|
||||
"newest": Property.created_at.desc(),
|
||||
}
|
||||
q = q.order_by(order_map.get(sort, Property.score.desc()))
|
||||
|
||||
props = q.all()
|
||||
sources = db.session.query(Property.source).distinct().all()
|
||||
return render_template("properties.html",
|
||||
properties=props, sources=[s[0] for s in sources],
|
||||
current_sort=sort, current_source=source,
|
||||
city_filter=city_filter, max_price=max_price,
|
||||
config_max_price=int(get_config("max_price", DEFAULT_MAX_PRICE))
|
||||
)
|
||||
|
||||
|
||||
# ── Property detail ───────────────────────────────────────────────────────────
|
||||
@app.route("/property/<int:pid>")
|
||||
def property_detail(pid):
|
||||
prop = Property.query.get_or_404(pid)
|
||||
lenders = Lender.query.order_by(Lender.match_score.desc()).all()
|
||||
return render_template("property_detail.html", prop=prop, lenders=lenders)
|
||||
|
||||
|
||||
@app.route("/property/<int:pid>/analyze", methods=["POST"])
|
||||
def analyze(pid):
|
||||
prop = Property.query.get_or_404(pid)
|
||||
import ai_analyzer
|
||||
ai_analyzer.OLLAMA_URL = OLLAMA_URL
|
||||
analysis = analyze_property(
|
||||
{"address": prop.address, "city": prop.city, "county": prop.county,
|
||||
"price": prop.price, "beds": prop.beds, "baths": prop.baths,
|
||||
"sqft": prop.sqft, "property_type": prop.property_type,
|
||||
"status": prop.status, "source": prop.source},
|
||||
OLLAMA_MODEL, ANTHROPIC_KEY or None
|
||||
)
|
||||
prop.ai_analysis = analysis
|
||||
db.session.commit()
|
||||
return jsonify({"analysis": analysis})
|
||||
|
||||
|
||||
@app.route("/property/<int:pid>/favorite", methods=["POST"])
|
||||
def toggle_favorite(pid):
|
||||
prop = Property.query.get_or_404(pid)
|
||||
prop.is_favorite = not prop.is_favorite
|
||||
db.session.commit()
|
||||
return jsonify({"is_favorite": prop.is_favorite})
|
||||
|
||||
|
||||
@app.route("/property/<int:pid>/notes", methods=["POST"])
|
||||
def save_notes(pid):
|
||||
prop = Property.query.get_or_404(pid)
|
||||
prop.notes = request.json.get("notes", "")
|
||||
db.session.commit()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
# ── Lenders ───────────────────────────────────────────────────────────────────
|
||||
@app.route("/lenders")
|
||||
def lenders():
|
||||
return render_template("lenders.html",
|
||||
lenders=Lender.query.order_by(Lender.match_score.desc()).all()
|
||||
)
|
||||
|
||||
|
||||
# ── Settings ──────────────────────────────────────────────────────────────────
|
||||
@app.route("/settings", methods=["GET", "POST"])
|
||||
def settings():
|
||||
if request.method == "POST":
|
||||
action = request.form.get("action")
|
||||
|
||||
if action == "add_city":
|
||||
city = request.form.get("city", "").strip()
|
||||
if city and not SearchCity.query.filter_by(city=city).first():
|
||||
db.session.add(SearchCity(city=city, active=True))
|
||||
db.session.commit()
|
||||
flash(f"Ciudad agregada: {city}", "success")
|
||||
elif city:
|
||||
existing = SearchCity.query.filter_by(city=city).first()
|
||||
existing.active = True
|
||||
db.session.commit()
|
||||
flash(f"{city} reactivada", "info")
|
||||
|
||||
elif action == "remove_city":
|
||||
city_id = int(request.form.get("city_id"))
|
||||
c = SearchCity.query.get(city_id)
|
||||
if c:
|
||||
db.session.delete(c)
|
||||
db.session.commit()
|
||||
flash(f"Ciudad eliminada: {c.city}", "warning")
|
||||
|
||||
elif action == "toggle_city":
|
||||
city_id = int(request.form.get("city_id"))
|
||||
c = SearchCity.query.get(city_id)
|
||||
if c:
|
||||
c.active = not c.active
|
||||
db.session.commit()
|
||||
|
||||
elif action == "save_config":
|
||||
max_price = request.form.get("max_price", "").strip()
|
||||
down_payment = request.form.get("down_payment", "").strip()
|
||||
if max_price and max_price.isdigit():
|
||||
set_config("max_price", max_price)
|
||||
if down_payment and down_payment.isdigit():
|
||||
set_config("down_payment", down_payment)
|
||||
if max_price or down_payment:
|
||||
flash("Preferencias guardadas", "success")
|
||||
|
||||
return redirect(url_for("settings"))
|
||||
|
||||
cities = SearchCity.query.order_by(SearchCity.city).all()
|
||||
max_price = int(get_config("max_price", DEFAULT_MAX_PRICE))
|
||||
down_payment = int(get_config("down_payment", DEFAULT_DOWN_PAYMENT))
|
||||
return render_template("settings.html",
|
||||
cities=cities, max_price=max_price,
|
||||
down_payment=down_payment, florida_cities=FLORIDA_CITIES
|
||||
)
|
||||
|
||||
|
||||
# ── Scan helpers ──────────────────────────────────────────────────────────────
|
||||
def _read_status():
|
||||
try:
|
||||
with open(STATUS_FILE, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {"running": False, "progress": ""}
|
||||
|
||||
def _save_results_to_db():
|
||||
"""Lee scan_results.json y guarda en la base de datos."""
|
||||
try:
|
||||
with open(RESULTS_FILE, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except Exception:
|
||||
return 0, {}
|
||||
new_count = 0
|
||||
for prop_data in data.get("properties", []):
|
||||
existing = Property.query.filter_by(
|
||||
address=prop_data.get("address", ""),
|
||||
source=prop_data.get("source", "")
|
||||
).first()
|
||||
if not existing and prop_data.get("address"):
|
||||
db.session.add(Property(
|
||||
source=prop_data.get("source"),
|
||||
address=prop_data.get("address", ""),
|
||||
city=prop_data.get("city", ""),
|
||||
county=prop_data.get("county", ""),
|
||||
zipcode=prop_data.get("zipcode", ""),
|
||||
price=prop_data.get("price", 0),
|
||||
beds=prop_data.get("beds"),
|
||||
baths=prop_data.get("baths"),
|
||||
sqft=prop_data.get("sqft"),
|
||||
property_type=prop_data.get("property_type", "Residential"),
|
||||
status=prop_data.get("status", ""),
|
||||
url=prop_data.get("url", ""),
|
||||
image_url=prop_data.get("image_url", ""),
|
||||
score=prop_data.get("score", 50)
|
||||
))
|
||||
new_count += 1
|
||||
for source, info in data.get("log", {}).items():
|
||||
db.session.add(ScrapeLog(
|
||||
source=source, found=info.get("found", 0),
|
||||
new=new_count, status=info.get("status", "ok"), message=str(info)
|
||||
))
|
||||
db.session.commit()
|
||||
return new_count, data.get("log", {})
|
||||
|
||||
|
||||
# ── Scan routes ────────────────────────────────────────────────────────────────
|
||||
@app.route("/scan", methods=["POST"])
|
||||
def scan():
|
||||
st = _read_status()
|
||||
if st.get("running"):
|
||||
return jsonify({"status": "already_running", "progress": st["progress"]})
|
||||
|
||||
active_cities = [c.city for c in SearchCity.query.filter_by(active=True).all()]
|
||||
max_price = int(get_config("max_price", DEFAULT_MAX_PRICE))
|
||||
cities_arg = ",".join(active_cities)
|
||||
|
||||
# Write initial status
|
||||
with open(STATUS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump({"running": True, "progress": "Iniciando..."}, f)
|
||||
|
||||
# Launch scan_runner.py as independent subprocess
|
||||
runner = os.path.join(BASE_DIR, "scan_runner.py")
|
||||
subprocess.Popen(
|
||||
[sys.executable, runner, "--cities", cities_arg, "--max_price", str(max_price)],
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
|
||||
)
|
||||
return jsonify({"status": "started", "cities": active_cities, "max_price": max_price})
|
||||
|
||||
|
||||
@app.route("/scan/status")
|
||||
def scan_status():
|
||||
st = _read_status()
|
||||
# If scan finished, import results to DB
|
||||
if not st.get("running") and st.get("progress", "").startswith("Completado") and os.path.exists(RESULTS_FILE):
|
||||
new_count, log = _save_results_to_db()
|
||||
# Mark results as imported
|
||||
try:
|
||||
os.rename(RESULTS_FILE, RESULTS_FILE + ".imported")
|
||||
except Exception:
|
||||
pass
|
||||
st["new"] = new_count
|
||||
st["log"] = log
|
||||
return jsonify(st)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=5105, debug=False, threaded=True)
|
||||
@@ -0,0 +1,69 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from datetime import datetime
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
class Property(db.Model):
|
||||
__tablename__ = 'properties'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
source = db.Column(db.String(50))
|
||||
address = db.Column(db.String(200))
|
||||
city = db.Column(db.String(100))
|
||||
county = db.Column(db.String(100))
|
||||
state = db.Column(db.String(10), default='FL')
|
||||
zipcode = db.Column(db.String(10))
|
||||
price = db.Column(db.Float)
|
||||
beds = db.Column(db.Integer)
|
||||
baths = db.Column(db.Float)
|
||||
sqft = db.Column(db.Integer)
|
||||
property_type = db.Column(db.String(50))
|
||||
status = db.Column(db.String(50))
|
||||
url = db.Column(db.Text)
|
||||
image_url = db.Column(db.Text)
|
||||
score = db.Column(db.Integer, default=0)
|
||||
ai_analysis = db.Column(db.Text)
|
||||
is_favorite = db.Column(db.Boolean, default=False)
|
||||
notes = db.Column(db.Text)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
class Lender(db.Model):
|
||||
__tablename__ = 'lenders'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100))
|
||||
loan_type = db.Column(db.String(100))
|
||||
specialties = db.Column(db.Text)
|
||||
min_down_pct = db.Column(db.Float)
|
||||
accepts_new_resident = db.Column(db.Boolean, default=True)
|
||||
accepts_self_employed = db.Column(db.Boolean, default=True)
|
||||
requires_2yr_history = db.Column(db.Boolean, default=False)
|
||||
phone = db.Column(db.String(30))
|
||||
email = db.Column(db.String(100))
|
||||
website = db.Column(db.String(200))
|
||||
contact_script = db.Column(db.Text)
|
||||
match_score = db.Column(db.Integer, default=0)
|
||||
notes = db.Column(db.Text)
|
||||
|
||||
class SearchCity(db.Model):
|
||||
__tablename__ = 'search_cities'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
city = db.Column(db.String(100), unique=True)
|
||||
search_type = db.Column(db.String(20), default='city') # city, county, zip
|
||||
active = db.Column(db.Boolean, default=True)
|
||||
added_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
class SearchConfig(db.Model):
|
||||
__tablename__ = 'search_config'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
key = db.Column(db.String(50), unique=True)
|
||||
value = db.Column(db.String(200))
|
||||
|
||||
class ScrapeLog(db.Model):
|
||||
__tablename__ = 'scrape_logs'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
ran_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
source = db.Column(db.String(50))
|
||||
found = db.Column(db.Integer, default=0)
|
||||
new = db.Column(db.Integer, default=0)
|
||||
status = db.Column(db.String(20))
|
||||
message = db.Column(db.Text)
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Proceso independiente que ejecuta el scan y escribe resultados a JSON.
|
||||
Llamado por Flask via subprocess.
|
||||
"""
|
||||
# -*- coding: utf-8 -*-
|
||||
import sys, io, json, os, argparse
|
||||
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
STATUS_FILE = os.path.join(BASE_DIR, "scan_status.json")
|
||||
RESULTS_FILE = os.path.join(BASE_DIR, "scan_results.json")
|
||||
|
||||
def write_status(msg, running=True):
|
||||
with open(STATUS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump({"running": running, "progress": msg}, f)
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--cities", default="Vero Beach,Jacksonville,Melbourne,Stuart,Daytona Beach,St. Augustine,Palm Coast,New Smyrna Beach")
|
||||
parser.add_argument("--max_price", type=int, default=230000)
|
||||
args = parser.parse_args()
|
||||
|
||||
cities = [c.strip() for c in args.cities.split(",") if c.strip()]
|
||||
max_price = args.max_price
|
||||
|
||||
write_status(f"Iniciando scan en {len(cities)} ciudades...")
|
||||
sys.path.insert(0, BASE_DIR)
|
||||
from scrapers import scrape_zillow, scrape_realtor, scrape_hud, scrape_homepath, score_property
|
||||
|
||||
all_results = []
|
||||
log = {}
|
||||
|
||||
# Zillow
|
||||
write_status(f"Zillow: buscando en {len(cities)} ciudades... (Chrome se abrira)")
|
||||
try:
|
||||
z_results = scrape_zillow(cities, max_price)
|
||||
log["Zillow"] = {"found": len(z_results), "status": "ok"}
|
||||
all_results.extend(z_results)
|
||||
write_status(f"Zillow: {len(z_results)} encontradas. Buscando en Realtor...")
|
||||
except Exception as e:
|
||||
log["Zillow"] = {"found": 0, "status": f"error: {e}"}
|
||||
write_status(f"Zillow error: {e}. Continuando con Realtor...")
|
||||
|
||||
# Realtor.com
|
||||
try:
|
||||
r_results = scrape_realtor(cities, max_price)
|
||||
log["Realtor.com"] = {"found": len(r_results), "status": "ok"}
|
||||
all_results.extend(r_results)
|
||||
write_status(f"Realtor: {len(r_results)} encontradas. Procesando...")
|
||||
except Exception as e:
|
||||
log["Realtor.com"] = {"found": 0, "status": f"error: {e}"}
|
||||
|
||||
# HUD + HomePath (rapidos, sin browser)
|
||||
try:
|
||||
h_results = scrape_hud(cities, max_price)
|
||||
log["HUD"] = {"found": len(h_results), "status": "ok"}
|
||||
all_results.extend(h_results)
|
||||
except Exception as e:
|
||||
log["HUD"] = {"found": 0, "status": f"error: {e}"}
|
||||
|
||||
try:
|
||||
hp_results = scrape_homepath(cities, max_price)
|
||||
log["HomePath"] = {"found": len(hp_results), "status": "ok"}
|
||||
all_results.extend(hp_results)
|
||||
except Exception as e:
|
||||
log["HomePath"] = {"found": 0, "status": f"error: {e}"}
|
||||
|
||||
# Deduplicar
|
||||
seen, unique = set(), []
|
||||
for r in all_results:
|
||||
key = (r.get("address","").lower().strip(), r.get("price",0))
|
||||
if key[0] and key not in seen:
|
||||
seen.add(key)
|
||||
r["score"] = score_property(r, cities, max_price)
|
||||
unique.append(r)
|
||||
|
||||
unique.sort(key=lambda x: x.get("score", 0), reverse=True)
|
||||
|
||||
with open(RESULTS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump({"properties": unique, "log": log, "total": len(unique)}, f, ensure_ascii=False)
|
||||
|
||||
write_status(
|
||||
f"Completado: {len(unique)} propiedades unicas encontradas",
|
||||
running=False
|
||||
)
|
||||
print(f"SCAN DONE: {len(unique)} propiedades")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
{"running": false, "progress": "Completado: 179 propiedades unicas encontradas"}
|
||||
@@ -0,0 +1,461 @@
|
||||
import re, json, time, random, shutil, os
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from datetime import datetime
|
||||
|
||||
# ── Config ────────────────────────────────────────────────────────────────────
|
||||
CHROME_PATH = r"C:\Program Files\Google\Chrome\Application\chrome.exe"
|
||||
CHROME_PROFILE = r"C:\Users\aerom\AppData\Local\Google\Chrome\User Data"
|
||||
TEMP_PROFILE = r"C:\Temp\chrome_casa_hunter"
|
||||
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
}
|
||||
|
||||
CITY_COUNTY_MAP = {
|
||||
"vero beach": "Indian River", "sebastian": "Indian River",
|
||||
"fellsmere": "Indian River", "indian river shores": "Indian River",
|
||||
"stuart": "Martin", "jensen beach": "Martin", "hobe sound": "Martin",
|
||||
"palm city": "Martin", "port salerno": "Martin",
|
||||
"fort pierce": "St. Lucie", "port st. lucie": "St. Lucie",
|
||||
"melbourne": "Brevard", "palm bay": "Brevard", "titusville": "Brevard",
|
||||
"cocoa": "Brevard", "cocoa beach": "Brevard", "rockledge": "Brevard",
|
||||
"merritt island": "Brevard", "cape canaveral": "Brevard",
|
||||
"satellite beach": "Brevard", "west melbourne": "Brevard",
|
||||
"daytona beach": "Volusia", "ormond beach": "Volusia",
|
||||
"new smyrna beach": "Volusia", "edgewater": "Volusia",
|
||||
"port orange": "Volusia", "deltona": "Volusia",
|
||||
"palm coast": "Flagler", "flagler beach": "Flagler", "bunnell": "Flagler",
|
||||
"st. augustine": "St. Johns", "ponte vedra beach": "St. Johns",
|
||||
"nocatee": "St. Johns", "st. augustine beach": "St. Johns",
|
||||
"jacksonville": "Duval", "jacksonville beach": "Duval",
|
||||
"atlantic beach": "Duval", "neptune beach": "Duval",
|
||||
"fernandina beach": "Nassau", "yulee": "Nassau",
|
||||
}
|
||||
|
||||
COUNTY_CODES = {
|
||||
"Brevard": "9", "Duval": "31", "Flagler": "35", "Indian River": "61",
|
||||
"Martin": "86", "Nassau": "89", "St. Johns": "109", "St. Lucie": "111",
|
||||
"Volusia": "127",
|
||||
}
|
||||
|
||||
|
||||
def get_county_for_city(city: str) -> str:
|
||||
return CITY_COUNTY_MAP.get(city.lower().strip(), "")
|
||||
|
||||
|
||||
def score_property(prop: dict, search_cities: list, max_price: int) -> int:
|
||||
score = 40
|
||||
price = prop.get("price", 0)
|
||||
if not price or price <= 0:
|
||||
return 0
|
||||
|
||||
ratio = price / max_price
|
||||
if ratio <= 0.60:
|
||||
score += 35
|
||||
elif ratio <= 0.75:
|
||||
score += 25
|
||||
elif ratio <= 0.90:
|
||||
score += 15
|
||||
elif ratio <= 1.0:
|
||||
score += 8
|
||||
|
||||
city = (prop.get("city") or "").lower()
|
||||
county = (prop.get("county") or "").lower()
|
||||
for s in [c.lower() for c in search_cities]:
|
||||
if s in city or s in county or city in s or county in s:
|
||||
score += 12
|
||||
break
|
||||
|
||||
beds = prop.get("beds") or 0
|
||||
if beds >= 3:
|
||||
score += 8
|
||||
elif beds >= 2:
|
||||
score += 4
|
||||
|
||||
status = (prop.get("status") or "").lower()
|
||||
if any(w in status for w in ["foreclosure", "reo", "bank owned", "hud", "price reduced"]):
|
||||
score += 10
|
||||
elif any(w in status for w in ["new construction", "newly built"]):
|
||||
score += 5
|
||||
|
||||
return min(score, 100)
|
||||
|
||||
|
||||
# ── Chrome profile setup ───────────────────────────────────────────────────────
|
||||
def ensure_chrome_profile():
|
||||
"""Copia el perfil de Chrome si no existe el temporal."""
|
||||
if os.path.exists(os.path.join(TEMP_PROFILE, "Default")):
|
||||
return True
|
||||
if not os.path.exists(CHROME_PROFILE):
|
||||
return False
|
||||
try:
|
||||
os.makedirs(os.path.join(TEMP_PROFILE, "Default"), exist_ok=True)
|
||||
src = os.path.join(CHROME_PROFILE, "Default")
|
||||
dst = os.path.join(TEMP_PROFILE, "Default")
|
||||
for item in ["Cookies", "Login Data", "Web Data", "Preferences"]:
|
||||
s = os.path.join(src, item)
|
||||
if os.path.exists(s):
|
||||
shutil.copy2(s, dst)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" Profile copy warning: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ── Playwright helpers ─────────────────────────────────────────────────────────
|
||||
def _hd(a=1.2, b=3.0):
|
||||
time.sleep(random.uniform(a, b))
|
||||
|
||||
def _scroll(page, steps=4):
|
||||
for _ in range(steps):
|
||||
page.mouse.wheel(0, random.randint(250, 600))
|
||||
time.sleep(random.uniform(0.4, 0.9))
|
||||
|
||||
def _type_human(page, text):
|
||||
for ch in text:
|
||||
page.keyboard.type(ch)
|
||||
time.sleep(random.uniform(0.07, 0.16))
|
||||
|
||||
def _parse_zillow_html(html, min_p=40000, max_p=230000):
|
||||
results = []
|
||||
m = re.search(r'<script[^>]*id="__NEXT_DATA__"[^>]*>(.*?)</script>', html, re.DOTALL)
|
||||
if not m:
|
||||
return results
|
||||
try:
|
||||
data = json.loads(m.group(1))
|
||||
list_results = (data.get("props", {}).get("pageProps", {})
|
||||
.get("searchPageState", {}).get("cat1", {})
|
||||
.get("searchResults", {}).get("listResults", []))
|
||||
for p in list_results:
|
||||
price = p.get("unformattedPrice", 0)
|
||||
if min_p <= price <= max_p:
|
||||
city_val = p.get("addressCity", "")
|
||||
results.append({
|
||||
"source": "Zillow",
|
||||
"address": p.get("address", ""),
|
||||
"price": price,
|
||||
"beds": p.get("beds", 0),
|
||||
"baths": p.get("baths", 0),
|
||||
"sqft": p.get("area", 0),
|
||||
"city": city_val,
|
||||
"state": p.get("addressState", "FL"),
|
||||
"county": get_county_for_city(city_val),
|
||||
"zipcode": str(p.get("addressZipcode", "")),
|
||||
"status": p.get("statusType", "For Sale"),
|
||||
"url": "https://www.zillow.com" + p.get("detailUrl", ""),
|
||||
"image_url": p.get("imgSrc", ""),
|
||||
"property_type": p.get("hdpData", {}).get("homeInfo", {}).get("homeType", ""),
|
||||
})
|
||||
except Exception as e:
|
||||
print(f" JSON parse error: {e}")
|
||||
return results
|
||||
|
||||
|
||||
# ── Zillow via Playwright + Chrome profile ────────────────────────────────────
|
||||
def scrape_zillow(cities: list, max_price: int) -> list:
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError:
|
||||
print(" Playwright no instalado — saltando Zillow")
|
||||
return []
|
||||
|
||||
ensure_chrome_profile()
|
||||
all_results = []
|
||||
MIN_PRICE = 40000
|
||||
|
||||
with sync_playwright() as p:
|
||||
ctx = p.chromium.launch_persistent_context(
|
||||
user_data_dir=TEMP_PROFILE,
|
||||
executable_path=CHROME_PATH,
|
||||
headless=False,
|
||||
args=[
|
||||
"--profile-directory=Default",
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--start-maximized",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
],
|
||||
viewport={"width": 1366, "height": 768},
|
||||
)
|
||||
page = ctx.new_page()
|
||||
|
||||
for city in cities:
|
||||
city_q = f"{city}, FL"
|
||||
print(f" Zillow: buscando {city_q}...")
|
||||
try:
|
||||
page.goto("https://www.zillow.com", wait_until="load", timeout=30000)
|
||||
_hd(1.5, 2.5)
|
||||
|
||||
search = page.query_selector(
|
||||
"#search-box-input, input[id*='search'], "
|
||||
"input[placeholder*='address'], input[placeholder*='city']"
|
||||
)
|
||||
if search:
|
||||
search.click()
|
||||
_hd(0.3, 0.6)
|
||||
page.keyboard.down("Control")
|
||||
page.keyboard.press("a")
|
||||
page.keyboard.up("Control")
|
||||
page.keyboard.press("Delete")
|
||||
_hd(0.2, 0.4)
|
||||
_type_human(page, city_q)
|
||||
_hd(0.8, 1.5)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_load_state("load", timeout=30000)
|
||||
_hd(2, 3)
|
||||
_scroll(page, 4)
|
||||
_hd(1, 2)
|
||||
else:
|
||||
# fallback: URL directa
|
||||
slug = re.sub(r"[^a-z0-9\s-]", "", city.lower()).strip().replace(" ", "-")
|
||||
url = (f"https://www.zillow.com/homes/for_sale/{slug}-fl/"
|
||||
f"?searchQueryState=%7B%22filterState%22%3A%7B"
|
||||
f"%22price%22%3A%7B%22max%22%3A{max_price}%2C%22min%22%3A{MIN_PRICE}%7D%7D%7D")
|
||||
page.goto(url, wait_until="load", timeout=45000)
|
||||
_hd(2, 3)
|
||||
_scroll(page, 4)
|
||||
|
||||
html = page.content()
|
||||
title = page.title()
|
||||
# Si Cloudflare bloqueó, esperar hasta que el usuario lo resuelva (max 90s)
|
||||
if "denied" in title.lower() or "px-captcha" in html or "cf-browser-verification" in html:
|
||||
print(f" >> Cloudflare en {city}: resuelve el challenge en el browser (90s max)...")
|
||||
deadline = time.time() + 90
|
||||
while time.time() < deadline:
|
||||
time.sleep(4)
|
||||
html = page.content()
|
||||
t2 = page.title()
|
||||
if "denied" not in t2.lower() and "px-captcha" not in html:
|
||||
print(f" Challenge resuelto!")
|
||||
break
|
||||
else:
|
||||
print(f" Timeout esperando challenge - saltando {city}")
|
||||
continue
|
||||
|
||||
listings = _parse_zillow_html(html, MIN_PRICE, max_price)
|
||||
print(f" -> {len(listings)} encontradas")
|
||||
all_results.extend(listings)
|
||||
|
||||
except Exception as e:
|
||||
print(f" ERROR {city}: {e}")
|
||||
|
||||
_hd(4, 7) # pausa entre ciudades para evitar bloqueo
|
||||
|
||||
ctx.close()
|
||||
|
||||
# Deduplicar
|
||||
seen, unique = set(), []
|
||||
for r in all_results:
|
||||
key = r["address"].lower().strip()
|
||||
if key and key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(r)
|
||||
return unique
|
||||
|
||||
|
||||
# ── Realtor.com via Playwright + Chrome profile ───────────────────────────────
|
||||
def scrape_realtor(cities: list, max_price: int) -> list:
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError:
|
||||
return []
|
||||
|
||||
ensure_chrome_profile()
|
||||
all_results = []
|
||||
|
||||
with sync_playwright() as p:
|
||||
ctx = p.chromium.launch_persistent_context(
|
||||
user_data_dir=TEMP_PROFILE,
|
||||
executable_path=CHROME_PATH,
|
||||
headless=False,
|
||||
args=[
|
||||
"--profile-directory=Default",
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--start-maximized",
|
||||
"--no-first-run",
|
||||
],
|
||||
viewport={"width": 1366, "height": 768},
|
||||
)
|
||||
page = ctx.new_page()
|
||||
|
||||
for city in cities:
|
||||
city_slug = re.sub(r"[^a-z0-9\s]", "", city.lower()).strip().replace(" ", "_")
|
||||
url = f"https://www.realtor.com/realestateandhomes-search/{city_slug}_FL/price-na-{max_price}"
|
||||
print(f" Realtor.com: buscando {city}...")
|
||||
try:
|
||||
page.goto(url, wait_until="load", timeout=45000)
|
||||
_hd(2, 3)
|
||||
_scroll(page, 4)
|
||||
_hd(1, 2)
|
||||
|
||||
html = page.content()
|
||||
m = re.search(r'<script[^>]*id="__NEXT_DATA__"[^>]*>(.*?)</script>', html, re.DOTALL)
|
||||
if not m:
|
||||
continue
|
||||
data = json.loads(m.group(1))
|
||||
properties = (data.get("props", {}).get("pageProps", {})
|
||||
.get("properties", []))
|
||||
for item in properties:
|
||||
price = item.get("list_price", 0)
|
||||
if not isinstance(price, (int, float)):
|
||||
continue
|
||||
price = int(price)
|
||||
if 40000 <= price <= max_price:
|
||||
loc = item.get("location", {}).get("address", {})
|
||||
city_val = loc.get("city", city)
|
||||
desc = item.get("description", {})
|
||||
all_results.append({
|
||||
"source": "Realtor.com",
|
||||
"address": loc.get("line", ""),
|
||||
"city": city_val,
|
||||
"county": get_county_for_city(city_val),
|
||||
"zipcode": str(loc.get("postal_code", "")),
|
||||
"price": price,
|
||||
"beds": desc.get("beds", 0),
|
||||
"baths": desc.get("baths_consolidated", 0),
|
||||
"sqft": desc.get("sqft", 0),
|
||||
"status": item.get("status", "For Sale"),
|
||||
"url": "https://www.realtor.com" + item.get("permalink", ""),
|
||||
"image_url": item.get("primary_photo", {}).get("href", ""),
|
||||
"property_type": desc.get("type", ""),
|
||||
})
|
||||
print(f" -> {len(properties)} revisadas")
|
||||
|
||||
except Exception as e:
|
||||
print(f" ERROR {city}: {e}")
|
||||
|
||||
_hd(3, 5)
|
||||
|
||||
ctx.close()
|
||||
|
||||
seen, unique = set(), []
|
||||
for r in all_results:
|
||||
key = r["address"].lower().strip()
|
||||
if key and key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(r)
|
||||
return unique
|
||||
|
||||
|
||||
# ── HUD Homes (requests - gobierno, sin anti-bot) ─────────────────────────────
|
||||
def scrape_hud(cities: list, max_price: int) -> list:
|
||||
results = []
|
||||
counties = set()
|
||||
for city in cities:
|
||||
c = get_county_for_city(city)
|
||||
if c:
|
||||
counties.add(c)
|
||||
if not counties:
|
||||
counties = {"Brevard", "Indian River", "Duval", "St. Johns", "Volusia"}
|
||||
|
||||
for county in list(counties)[:6]:
|
||||
code = COUNTY_CODES.get(county, "")
|
||||
if not code:
|
||||
continue
|
||||
url = (f"https://www.hudhomestore.gov/HudHomes/Index.aspx"
|
||||
f"?sState=FL&sCounty={code}&sPriceMax={max_price}&sPriceMin=30000")
|
||||
try:
|
||||
r = requests.get(url, headers=HEADERS, timeout=15)
|
||||
soup = BeautifulSoup(r.text, "html.parser")
|
||||
rows = soup.select("tr.propRow, .property-row, tr[id^='prop']")
|
||||
for row in rows[:8]:
|
||||
text = row.get_text(" ", strip=True)
|
||||
price_m = re.search(r'\$[\d,]+', text)
|
||||
if not price_m:
|
||||
continue
|
||||
price = int(re.sub(r'[^\d]', '', price_m.group()))
|
||||
if 0 < price <= max_price:
|
||||
addr_el = row.select_one("a")
|
||||
prop = {
|
||||
"source": "HUD Homes",
|
||||
"address": addr_el.get_text(strip=True) if addr_el else text[:80],
|
||||
"city": county, "county": county,
|
||||
"price": price, "url": url,
|
||||
"status": "HUD Foreclosure",
|
||||
}
|
||||
prop["score"] = score_property(prop, cities, max_price)
|
||||
results.append(prop)
|
||||
except Exception as e:
|
||||
print(f" HUD {county}: {e}")
|
||||
return results
|
||||
|
||||
|
||||
# ── Fannie Mae HomePath (REO) ──────────────────────────────────────────────────
|
||||
def scrape_homepath(cities: list, max_price: int) -> list:
|
||||
results = []
|
||||
for term in cities[:8]:
|
||||
url = (f"https://www.homepath.fanniemae.com/listings"
|
||||
f"?searchTerm={requests.utils.quote(term + ' FL')}"
|
||||
f"&maxPrice={max_price}&state=FL")
|
||||
try:
|
||||
r = requests.get(url, headers=HEADERS, timeout=15)
|
||||
data = r.json() if "json" in r.headers.get("content-type", "") else {}
|
||||
listings = data.get("listings", data.get("results", []))
|
||||
for item in listings[:6]:
|
||||
price = item.get("listPrice", item.get("price", 0))
|
||||
if 0 < price <= max_price:
|
||||
city_val = item.get("city", term)
|
||||
prop = {
|
||||
"source": "Fannie Mae HomePath",
|
||||
"address": item.get("address", item.get("streetAddress", "")),
|
||||
"city": city_val,
|
||||
"county": get_county_for_city(city_val),
|
||||
"zipcode": str(item.get("postalCode", "")),
|
||||
"price": price,
|
||||
"beds": item.get("bedrooms", 0),
|
||||
"baths": item.get("bathrooms", 0),
|
||||
"sqft": item.get("squareFeet", 0),
|
||||
"url": f"https://www.homepath.fanniemae.com/listings/{item.get('id','')}",
|
||||
"status": "Fannie Mae REO",
|
||||
}
|
||||
prop["score"] = score_property(prop, cities, max_price)
|
||||
results.append(prop)
|
||||
except Exception as e:
|
||||
print(f" HomePath {term}: {e}")
|
||||
return results
|
||||
|
||||
|
||||
# ── Main runner ───────────────────────────────────────────────────────────────
|
||||
def run_all_scrapers(cities: list = None, max_price: int = 230000) -> dict:
|
||||
if not cities:
|
||||
cities = ["Vero Beach", "Jacksonville", "Melbourne", "St. Augustine"]
|
||||
|
||||
all_props = []
|
||||
log = {}
|
||||
|
||||
sources = {
|
||||
"Zillow (Browser)": lambda: scrape_zillow(cities, max_price),
|
||||
"Realtor.com (Browser)": lambda: scrape_realtor(cities, max_price),
|
||||
"HUD Homes": lambda: scrape_hud(cities, max_price),
|
||||
"Fannie Mae HomePath": lambda: scrape_homepath(cities, max_price),
|
||||
}
|
||||
|
||||
for name, fn in sources.items():
|
||||
try:
|
||||
print(f"\n[{name}]")
|
||||
props = fn()
|
||||
seen, unique = set(), []
|
||||
for p in props:
|
||||
key = ((p.get("address") or "").lower().strip(), p.get("price", 0))
|
||||
if key[0] and key not in seen:
|
||||
seen.add(key)
|
||||
p["score"] = score_property(p, cities, max_price)
|
||||
unique.append(p)
|
||||
all_props.extend(unique)
|
||||
log[name] = {"found": len(unique), "status": "ok"}
|
||||
print(f" -> {len(unique)} propiedades validas")
|
||||
except Exception as e:
|
||||
log[name] = {"found": 0, "status": f"error: {e}"}
|
||||
print(f" ERROR {name}: {e}")
|
||||
|
||||
all_props.sort(key=lambda x: x.get("score", 0), reverse=True)
|
||||
return {
|
||||
"properties": all_props,
|
||||
"log": log,
|
||||
"cities_searched": cities,
|
||||
"max_price": max_price,
|
||||
"ran_at": datetime.utcnow().isoformat()
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Casa Hunter FL{% endblock %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--primary: #1a3a5c;
|
||||
--accent: #e8a020;
|
||||
--light-bg: #f4f7fb;
|
||||
}
|
||||
body { background: var(--light-bg); font-family: 'Segoe UI', sans-serif; }
|
||||
.navbar { background: var(--primary) !important; }
|
||||
.navbar-brand { color: var(--accent) !important; font-weight: 700; font-size: 1.3rem; }
|
||||
.nav-link { color: rgba(255,255,255,.85) !important; }
|
||||
.nav-link:hover, .nav-link.active { color: var(--accent) !important; }
|
||||
.card { border: none; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,.07); }
|
||||
.score-badge { font-size: .75rem; font-weight: 700; padding: 4px 10px; border-radius: 20px; }
|
||||
.score-high { background: #d4edda; color: #155724; }
|
||||
.score-mid { background: #fff3cd; color: #856404; }
|
||||
.score-low { background: #f8d7da; color: #721c24; }
|
||||
.prop-price { font-size: 1.4rem; font-weight: 700; color: var(--primary); }
|
||||
.source-tag { font-size: .7rem; background: var(--primary); color: #fff; padding: 2px 8px; border-radius: 10px; }
|
||||
.lender-card .match-pct { font-size: 2rem; font-weight: 800; color: var(--accent); }
|
||||
.btn-primary { background: var(--primary); border-color: var(--primary); }
|
||||
.btn-primary:hover { background: #0f2540; }
|
||||
.btn-accent { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.stat-card { text-align: center; padding: 1.2rem; }
|
||||
.stat-card .number { font-size: 2rem; font-weight: 800; color: var(--primary); }
|
||||
.stat-card .label { font-size: .8rem; color: #6c757d; text-transform: uppercase; letter-spacing: 1px; }
|
||||
.action-kit { background: #e8f4fd; border-left: 4px solid var(--primary); border-radius: 8px; padding: 1rem; }
|
||||
footer { background: var(--primary); color: rgba(255,255,255,.6); padding: 1rem 0; margin-top: 3rem; font-size: .85rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="/"><i class="fas fa-home me-2"></i>Casa Hunter FL</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#nav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="nav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item"><a class="nav-link {% if request.path=='/' %}active{% endif %}" href="/"><i class="fas fa-chart-line me-1"></i>Dashboard</a></li>
|
||||
<li class="nav-item"><a class="nav-link {% if '/properties' in request.path %}active{% endif %}" href="/properties"><i class="fas fa-building me-1"></i>Propiedades</a></li>
|
||||
<li class="nav-item"><a class="nav-link {% if '/lenders' in request.path %}active{% endif %}" href="/lenders"><i class="fas fa-handshake me-1"></i>Lenders</a></li>
|
||||
<li class="nav-item"><a class="nav-link {% if '/settings' in request.path %}active{% endif %}" href="/settings"><i class="fas fa-sliders-h me-1"></i>Preferencias</a></li>
|
||||
<li class="nav-item ms-2">
|
||||
<button class="btn btn-sm btn-accent" onclick="runScan()"><i class="fas fa-sync me-1"></i>Buscar Ahora</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container my-4">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ cat }} alert-dismissible fade show"><i class="fas fa-info-circle me-2"></i>{{ msg }}<button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<footer><div class="container text-center">Casa Hunter FL — Tu buscador personal de oportunidades inmobiliarias • Prisa Yachts LLC</div></footer>
|
||||
|
||||
<div class="modal fade" id="scanModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-body text-center py-4">
|
||||
<div class="spinner-border text-primary mb-3"></div>
|
||||
<p class="mb-1 fw-bold">Buscando oportunidades...</p>
|
||||
<small class="text-muted">HUD · Fannie Mae · Zillow · Remates</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
let _scanPoll = null;
|
||||
|
||||
function runScan() {
|
||||
const btn = document.querySelector('[onclick="runScan()"]');
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Buscando...'; }
|
||||
const prog = document.getElementById('scan-progress');
|
||||
const msg = document.getElementById('scan-msg');
|
||||
if (prog) prog.style.display = 'block';
|
||||
if (msg) msg.textContent = 'Abriendo Chrome y buscando propiedades...';
|
||||
|
||||
fetch('/scan', {method:'POST'})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (msg) msg.textContent = data.status === 'already_running'
|
||||
? 'Ya hay un scan en progreso...'
|
||||
: 'Chrome abierto — buscando en ' + (data.cities || []).length + ' ciudades...';
|
||||
_scanPoll = setInterval(pollScanStatus, 5000);
|
||||
})
|
||||
.catch(e => {
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-search me-2"></i>Buscar Ahora'; }
|
||||
if (msg) msg.textContent = 'Error: ' + e;
|
||||
});
|
||||
}
|
||||
|
||||
function pollScanStatus() {
|
||||
fetch('/scan/status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const msg = document.getElementById('scan-msg');
|
||||
const bar = document.getElementById('scan-bar');
|
||||
const btn = document.querySelector('[onclick="runScan()"]');
|
||||
if (msg) msg.textContent = data.progress || 'Buscando...';
|
||||
if (!data.running) {
|
||||
clearInterval(_scanPoll);
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-search me-2"></i>Buscar Ahora'; }
|
||||
if (bar) bar.classList.remove('progress-bar-animated');
|
||||
if (data.new > 0) {
|
||||
if (msg) msg.textContent = data.new + ' propiedades nuevas! Recargando...';
|
||||
setTimeout(() => location.reload(), 2000);
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
const p = document.getElementById('scan-progress');
|
||||
if (p) p.style.display = 'none';
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,186 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard — Casa Hunter FL{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2 class="fw-bold mb-1" style="color:var(--primary)"><i class="fas fa-map-marker-alt me-2" style="color:var(--accent)"></i>Oportunidades en Costa de Florida</h2>
|
||||
<p class="text-muted mb-0">Stuart → Vero Beach → Melbourne → Daytona → St. Augustine → Jacksonville • Hasta $200,000</p>
|
||||
</div>
|
||||
<button class="btn btn-accent" onclick="runScan()"><i class="fas fa-search me-2"></i>Buscar Ahora</button>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card stat-card">
|
||||
<div class="number">{{ total }}</div>
|
||||
<div class="label">Propiedades</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card stat-card">
|
||||
<div class="number">{{ favorites }}</div>
|
||||
<div class="label">Favoritas</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card stat-card">
|
||||
<div class="number">$50K</div>
|
||||
<div class="label">Down Payment</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card stat-card">
|
||||
<div class="number">8</div>
|
||||
<div class="label">Lenders Listos</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Properties -->
|
||||
<h5 class="fw-bold mb-3" style="color:var(--primary)"><i class="fas fa-star me-2" style="color:var(--accent)"></i>Mejores Oportunidades</h5>
|
||||
{% if properties %}
|
||||
<div class="row g-3 mb-4">
|
||||
{% for p in properties %}
|
||||
<div class="col-12 col-md-6 col-lg-4">
|
||||
<div class="card h-100">
|
||||
{% if p.image_url %}
|
||||
<img src="{{ p.image_url }}" class="card-img-top" style="height:160px;object-fit:cover;border-radius:12px 12px 0 0" alt="">
|
||||
{% else %}
|
||||
<div style="height:100px;background:linear-gradient(135deg,#1a3a5c,#2d6a9f);border-radius:12px 12px 0 0;display:flex;align-items:center;justify-content:center">
|
||||
<i class="fas fa-home fa-2x text-white opacity-50"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<span class="source-tag">{{ p.source }}</span>
|
||||
<span class="score-badge {% if p.score >= 75 %}score-high{% elif p.score >= 50 %}score-mid{% else %}score-low{% endif %}">
|
||||
Score: {{ p.score }}/100
|
||||
</span>
|
||||
</div>
|
||||
<p class="prop-price mb-1">${{ "{:,.0f}".format(p.price) }}</p>
|
||||
<p class="text-muted small mb-1"><i class="fas fa-map-marker-alt me-1"></i>{{ p.address }}</p>
|
||||
<p class="text-muted small mb-2">{{ p.city }}{% if p.county %}, {{ p.county }} Co.{% endif %}</p>
|
||||
<div class="d-flex gap-2 text-muted small mb-3">
|
||||
{% if p.beds %}<span><i class="fas fa-bed me-1"></i>{{ p.beds }} hab.</span>{% endif %}
|
||||
{% if p.baths %}<span><i class="fas fa-bath me-1"></i>{{ p.baths }} baños</span>{% endif %}
|
||||
{% if p.sqft %}<span><i class="fas fa-ruler-combined me-1"></i>{{ p.sqft }} sf</span>{% endif %}
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="/property/{{ p.id }}" class="btn btn-primary btn-sm flex-fill">Ver Detalle</a>
|
||||
{% if p.url %}
|
||||
<a href="{{ p.url }}" target="_blank" class="btn btn-outline-secondary btn-sm"><i class="fas fa-external-link-alt"></i></a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<a href="/properties" class="btn btn-outline-primary">Ver todas las propiedades <i class="fas fa-arrow-right ms-1"></i></a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card text-center py-5">
|
||||
<div class="card-body">
|
||||
<i class="fas fa-search fa-3x mb-3" style="color:var(--accent)"></i>
|
||||
<h5>No hay propiedades aún</h5>
|
||||
<p class="text-muted">Haz clic en <strong>Buscar Ahora</strong> para encontrar oportunidades en Florida</p>
|
||||
<button class="btn btn-primary" onclick="runScan()"><i class="fas fa-sync me-2"></i>Iniciar Búsqueda</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Top Lenders -->
|
||||
<h5 class="fw-bold mt-4 mb-3" style="color:var(--primary)"><i class="fas fa-handshake me-2" style="color:var(--accent)"></i>Lenders Recomendados para Tu Perfil</h5>
|
||||
<div class="row g-3">
|
||||
{% for l in top_lenders %}
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card lender-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="fw-bold mb-0" style="color:var(--primary)">{{ l.name }}</h6>
|
||||
<span class="match-pct">{{ l.match_score }}%</span>
|
||||
</div>
|
||||
<span class="badge bg-light text-dark border mb-2">{{ l.loan_type }}</span>
|
||||
<p class="text-muted small">{{ l.specialties[:100] }}{% if l.specialties|length > 100 %}...{% endif %}</p>
|
||||
<a href="/lenders" class="btn btn-sm btn-primary w-100">Ver Script de Contacto</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if last_scan %}
|
||||
<p class="text-muted text-end small mt-3"><i class="fas fa-clock me-1"></i>Última búsqueda: {{ last_scan.ran_at.strftime('%d/%m/%Y %H:%M') }}</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Scan progress bar -->
|
||||
<div id="scan-progress" class="mt-3" style="display:none">
|
||||
<div class="alert alert-info d-flex align-items-center gap-2">
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
<span id="scan-msg">Iniciando búsqueda...</span>
|
||||
</div>
|
||||
<div class="progress" style="height:6px">
|
||||
<div id="scan-bar" class="progress-bar progress-bar-striped progress-bar-animated bg-success" style="width:100%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
let _scanPoll = null;
|
||||
|
||||
function runScan() {
|
||||
const btn = document.querySelector('[onclick="runScan()"]');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Buscando...';
|
||||
document.getElementById('scan-progress').style.display = 'block';
|
||||
document.getElementById('scan-msg').textContent = 'Abriendo Chrome y buscando propiedades...';
|
||||
|
||||
fetch('/scan', {method:'POST'})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'already_running') {
|
||||
document.getElementById('scan-msg').textContent = 'Ya hay un scan en progreso...';
|
||||
}
|
||||
// Poll for status every 5 seconds
|
||||
_scanPoll = setInterval(pollScanStatus, 5000);
|
||||
})
|
||||
.catch(e => {
|
||||
document.getElementById('scan-msg').textContent = 'Error iniciando scan: ' + e;
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-search me-2"></i>Buscar Ahora';
|
||||
});
|
||||
}
|
||||
|
||||
function pollScanStatus() {
|
||||
fetch('/scan/status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
document.getElementById('scan-msg').textContent = data.progress || 'Buscando...';
|
||||
if (!data.running) {
|
||||
clearInterval(_scanPoll);
|
||||
const btn = document.querySelector('[onclick="runScan()"]');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-search me-2"></i>Buscar Ahora';
|
||||
if (data.new > 0) {
|
||||
document.getElementById('scan-msg').textContent =
|
||||
`✓ ${data.new} propiedades nuevas encontradas. Recargando...`;
|
||||
setTimeout(() => location.reload(), 2000);
|
||||
} else {
|
||||
document.getElementById('scan-msg').textContent =
|
||||
data.progress || 'Busqueda completada.';
|
||||
document.getElementById('scan-bar').classList.remove('progress-bar-animated');
|
||||
setTimeout(() => {
|
||||
document.getElementById('scan-progress').style.display = 'none';
|
||||
}, 4000);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,104 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Lenders — Casa Hunter FL{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<h2 class="fw-bold mb-1" style="color:var(--primary)"><i class="fas fa-handshake me-2" style="color:var(--accent)"></i>Lenders Recomendados</h2>
|
||||
<p class="text-muted mb-4">Ordenados por compatibilidad con tu perfil: residente nuevo, self-employed, $50K down, hasta $200K</p>
|
||||
|
||||
<div class="row g-3">
|
||||
{% for l in lenders %}
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<div>
|
||||
<h5 class="fw-bold mb-0" style="color:var(--primary)">{{ l.name }}</h5>
|
||||
<span class="badge border text-dark bg-light">{{ l.loan_type }}</span>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<div class="lender-card"><span class="match-pct">{{ l.match_score }}%</span></div>
|
||||
<small class="text-muted">compatibilidad</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="small text-muted mb-2"><i class="fas fa-check-circle text-success me-1"></i>{{ l.specialties }}</p>
|
||||
|
||||
<div class="row text-center g-1 mb-3">
|
||||
<div class="col-4">
|
||||
<div class="bg-light rounded p-1">
|
||||
<small class="text-muted d-block">Down mín.</small>
|
||||
<strong class="small">{{ l.min_down_pct|int }}%</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="bg-light rounded p-1">
|
||||
<small class="text-muted d-block">Residente nuevo</small>
|
||||
<strong class="small">{% if l.accepts_new_resident %}<i class="fas fa-check text-success"></i>{% else %}<i class="fas fa-times text-danger"></i>{% endif %}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="bg-light rounded p-1">
|
||||
<small class="text-muted d-block">Self-employed</small>
|
||||
<strong class="small">{% if l.accepts_self_employed %}<i class="fas fa-check text-success"></i>{% else %}<i class="fas fa-times text-danger"></i>{% endif %}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if l.notes %}
|
||||
<p class="small text-muted mb-2"><i class="fas fa-info-circle me-1"></i>{{ l.notes }}</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Contact Script -->
|
||||
<div class="action-kit small mb-3">
|
||||
<strong><i class="fas fa-phone me-1"></i>Guión de contacto:</strong><br>
|
||||
{{ l.contact_script }}
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ l.website }}" target="_blank" class="btn btn-primary btn-sm flex-fill">
|
||||
<i class="fas fa-globe me-1"></i>Visitar Web
|
||||
</a>
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="copyText('script-{{ l.id }}')">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
<span id="script-{{ l.id }}" class="d-none">{{ l.contact_script }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- My Profile Box -->
|
||||
<div class="card mt-4" style="border-left:4px solid var(--accent)">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-3" style="color:var(--primary)"><i class="fas fa-user me-2" style="color:var(--accent)"></i>Tu Perfil — Lo que presentas a cada lender</h6>
|
||||
<div class="row g-2 small">
|
||||
<div class="col-12 col-md-6">
|
||||
<ul class="mb-0">
|
||||
<li><strong>Estatus:</strong> Residente permanente</li>
|
||||
<li><strong>Tiempo en USA:</strong> Menos de 2 años</li>
|
||||
<li><strong>Trabajo:</strong> Contratista independiente — Eléctrica marina/naval</li>
|
||||
<li><strong>Tarifa:</strong> $120/hora</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<ul class="mb-0">
|
||||
<li><strong>Down payment disponible:</strong> $50,000</li>
|
||||
<li><strong>Presupuesto máximo:</strong> $200,000</li>
|
||||
<li><strong>Zona:</strong> Costa de FL — Stuart hasta Jacksonville</li>
|
||||
<li><strong>Préstamo ideal:</strong> Non-QM, Bank Statement, o ITIN</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function copyText(id) {
|
||||
const text = document.getElementById(id).textContent;
|
||||
navigator.clipboard.writeText(text).then(() => alert('Script copiado'));
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,99 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Propiedades — Casa Hunter FL{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="fw-bold mb-0" style="color:var(--primary)"><i class="fas fa-building me-2" style="color:var(--accent)"></i>Propiedades en Oportunidad</h4>
|
||||
<button class="btn btn-accent btn-sm" onclick="runScan()"><i class="fas fa-sync me-1"></i>Actualizar</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body py-2">
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-6 col-md-3">
|
||||
<label class="form-label small fw-bold mb-1">Ciudad</label>
|
||||
<input type="text" name="city" class="form-control form-control-sm" placeholder="Vero Beach..." value="{{ city_filter }}">
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<label class="form-label small fw-bold mb-1">Fuente</label>
|
||||
<select name="source" class="form-select form-select-sm">
|
||||
<option value="">Todas</option>
|
||||
{% for s in sources %}<option value="{{ s }}" {% if s==current_source %}selected{% endif %}>{{ s }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<label class="form-label small fw-bold mb-1">Score mín.</label>
|
||||
<select name="min_score" class="form-select form-select-sm">
|
||||
<option value="0">Todos</option>
|
||||
<option value="70">70+</option>
|
||||
<option value="80">80+</option>
|
||||
<option value="90">90+</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<label class="form-label small fw-bold mb-1">Ordenar</label>
|
||||
<select name="sort" class="form-select form-select-sm">
|
||||
<option value="score" {% if current_sort=='score' %}selected{% endif %}>Mejor score</option>
|
||||
<option value="price_asc" {% if current_sort=='price_asc' %}selected{% endif %}>Precio ↑</option>
|
||||
<option value="price_desc" {% if current_sort=='price_desc' %}selected{% endif %}>Precio ↓</option>
|
||||
<option value="newest" {% if current_sort=='newest' %}selected{% endif %}>Más reciente</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-1">
|
||||
<button type="submit" class="btn btn-primary btn-sm w-100"><i class="fas fa-filter"></i></button>
|
||||
</div>
|
||||
<div class="col-6 col-md-1">
|
||||
<a href="/properties?favorites=1" class="btn btn-outline-warning btn-sm w-100"><i class="fas fa-star"></i></a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if properties %}
|
||||
<p class="text-muted small mb-3"><strong>{{ properties|length }}</strong> propiedades encontradas</p>
|
||||
<div class="row g-3">
|
||||
{% for p in properties %}
|
||||
<div class="col-12 col-md-6 col-xl-4">
|
||||
<div class="card h-100 {% if p.is_favorite %}border-warning{% endif %}">
|
||||
{% if p.image_url %}
|
||||
<img src="{{ p.image_url }}" class="card-img-top" style="height:150px;object-fit:cover;border-radius:12px 12px 0 0" alt="">
|
||||
{% else %}
|
||||
<div style="height:80px;background:linear-gradient(135deg,#1a3a5c,#2d6a9f);border-radius:12px 12px 0 0;display:flex;align-items:center;justify-content:center">
|
||||
<i class="fas fa-home fa-2x text-white opacity-50"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="source-tag">{{ p.source }}</span>
|
||||
<span class="score-badge {% if p.score >= 75 %}score-high{% elif p.score >= 50 %}score-mid{% else %}score-low{% endif %}">{{ p.score }}/100</span>
|
||||
</div>
|
||||
<div class="prop-price mb-1">${{ "{:,.0f}".format(p.price) }}</div>
|
||||
<p class="text-muted small mb-1">{{ p.address }}</p>
|
||||
<p class="text-muted small mb-2">{{ p.city }}{% if p.county %}, {{ p.county }}{% endif %}</p>
|
||||
<div class="d-flex gap-2 text-muted small mb-3">
|
||||
{% if p.beds %}<span><i class="fas fa-bed me-1"></i>{{ p.beds }}</span>{% endif %}
|
||||
{% if p.baths %}<span><i class="fas fa-bath me-1"></i>{{ p.baths }}</span>{% endif %}
|
||||
{% if p.sqft %}<span><i class="fas fa-ruler-combined me-1"></i>{{ p.sqft }}sf</span>{% endif %}
|
||||
</div>
|
||||
<div class="d-flex gap-1">
|
||||
<a href="/property/{{ p.id }}" class="btn btn-primary btn-sm flex-fill">Ver Detalle</a>
|
||||
{% if p.url %}<a href="{{ p.url }}" target="_blank" class="btn btn-outline-secondary btn-sm"><i class="fas fa-external-link-alt"></i></a>{% endif %}
|
||||
{% if p.is_favorite %}<span class="btn btn-warning btn-sm"><i class="fas fa-star"></i></span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card text-center py-5">
|
||||
<div class="card-body">
|
||||
<i class="fas fa-search fa-3x mb-3" style="color:var(--accent)"></i>
|
||||
<h5>No hay propiedades</h5>
|
||||
<p class="text-muted">Haz clic en Buscar Ahora para encontrar oportunidades</p>
|
||||
<button class="btn btn-primary" onclick="runScan()"><i class="fas fa-sync me-2"></i>Buscar Ahora</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,224 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ prop.address }} — Casa Hunter FL{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="/properties">Propiedades</a></li>
|
||||
<li class="breadcrumb-item active">Detalle</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- Left Column -->
|
||||
<div class="col-12 col-lg-8">
|
||||
<div class="card mb-3">
|
||||
{% if prop.image_url %}
|
||||
<img src="{{ prop.image_url }}" class="card-img-top" style="height:280px;object-fit:cover;border-radius:12px 12px 0 0" alt="">
|
||||
{% else %}
|
||||
<div style="height:180px;background:linear-gradient(135deg,#1a3a5c,#2d6a9f);border-radius:12px 12px 0 0;display:flex;align-items:center;justify-content:center">
|
||||
<i class="fas fa-home fa-4x text-white opacity-40"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2 mb-3">
|
||||
<div>
|
||||
<span class="source-tag me-2">{{ prop.source }}</span>
|
||||
<span class="badge bg-secondary">{{ prop.status }}</span>
|
||||
</div>
|
||||
<span class="score-badge {% if prop.score >= 75 %}score-high{% elif prop.score >= 50 %}score-mid{% else %}score-low{% endif %} fs-6">
|
||||
Score: {{ prop.score }}/100
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 class="prop-price mb-1">${{ "{:,.0f}".format(prop.price) }}</h3>
|
||||
<p class="text-muted mb-1"><i class="fas fa-map-marker-alt me-1"></i>{{ prop.address }}</p>
|
||||
<p class="text-muted small mb-3">{{ prop.city }}{% if prop.county %}, {{ prop.county }} County{% endif %}, FL {{ prop.zipcode }}</p>
|
||||
|
||||
<div class="row text-center g-2 mb-3">
|
||||
{% if prop.beds %}
|
||||
<div class="col-4">
|
||||
<div class="border rounded p-2">
|
||||
<i class="fas fa-bed text-muted d-block mb-1"></i>
|
||||
<strong>{{ prop.beds }}</strong><br><small class="text-muted">Habitaciones</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if prop.baths %}
|
||||
<div class="col-4">
|
||||
<div class="border rounded p-2">
|
||||
<i class="fas fa-bath text-muted d-block mb-1"></i>
|
||||
<strong>{{ prop.baths }}</strong><br><small class="text-muted">Baños</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if prop.sqft %}
|
||||
<div class="col-4">
|
||||
<div class="border rounded p-2">
|
||||
<i class="fas fa-ruler-combined text-muted d-block mb-1"></i>
|
||||
<strong>{{ prop.sqft }}</strong><br><small class="text-muted">Sq Ft</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
{% if prop.url %}
|
||||
<a href="{{ prop.url }}" target="_blank" class="btn btn-primary"><i class="fas fa-external-link-alt me-2"></i>Ver en {{ prop.source }}</a>
|
||||
{% endif %}
|
||||
<button class="btn {% if prop.is_favorite %}btn-warning{% else %}btn-outline-warning{% endif %}" onclick="toggleFav({{ prop.id }}, this)">
|
||||
<i class="fas fa-star me-1"></i>{% if prop.is_favorite %}Guardada{% else %}Guardar{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI Analysis -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-3" style="color:var(--primary)"><i class="fas fa-robot me-2" style="color:var(--accent)"></i>Análisis con IA (qwen2.5)</h6>
|
||||
<div id="ai-result">
|
||||
{% if prop.ai_analysis %}
|
||||
<div class="action-kit">{{ prop.ai_analysis }}</div>
|
||||
{% else %}
|
||||
<p class="text-muted">Aún no se ha analizado esta propiedad.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary mt-3" onclick="runAnalysis({{ prop.id }})">
|
||||
<i class="fas fa-magic me-1"></i>{% if prop.ai_analysis %}Re-analizar{% else %}Analizar con IA{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-2" style="color:var(--primary)"><i class="fas fa-sticky-note me-2"></i>Mis Notas</h6>
|
||||
<textarea class="form-control mb-2" id="notes" rows="3" placeholder="Agrega notas sobre esta propiedad...">{{ prop.notes or '' }}</textarea>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="saveNotes({{ prop.id }})"><i class="fas fa-save me-1"></i>Guardar Notas</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Action Kit -->
|
||||
<div class="col-12 col-lg-4">
|
||||
<!-- Action Kit -->
|
||||
<div class="card mb-3" style="border-left:4px solid var(--accent)">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-3" style="color:var(--primary)"><i class="fas fa-clipboard-list me-2" style="color:var(--accent)"></i>Kit de Acción</h6>
|
||||
|
||||
<p class="small fw-bold text-muted mb-2">DOCUMENTOS A PREPARAR</p>
|
||||
<ul class="small mb-3">
|
||||
<li>Green card o visa + pasaporte</li>
|
||||
<li>12-24 meses de estados de cuenta bancarios</li>
|
||||
<li>Comprobante de ingresos (facturas de trabajo)</li>
|
||||
<li>Carta del landlord confirmando pagos de renta</li>
|
||||
<li>Carta explicando tu situación laboral (self-employed)</li>
|
||||
<li>Evidencia de los $50,000 disponibles (bank statement)</li>
|
||||
</ul>
|
||||
|
||||
<p class="small fw-bold text-muted mb-2">PASOS A SEGUIR</p>
|
||||
<ol class="small mb-3">
|
||||
<li>Llama a 2-3 lenders de la lista (empieza con Heart Mortgage y Jhenesis)</li>
|
||||
<li>Pide una pre-qualification letter</li>
|
||||
<li>Contrata un inspector de propiedades ($300-500)</li>
|
||||
<li>Si es HUD/Fannie Mae, necesitas un agente de bienes raíces aprobado</li>
|
||||
<li>Presenta oferta con pre-qual letter y prueba de fondos</li>
|
||||
</ol>
|
||||
|
||||
<div class="bg-light rounded p-2 small">
|
||||
<strong><i class="fas fa-calculator me-1"></i>Estimado para esta propiedad:</strong><br>
|
||||
Down payment (25%): <strong>${{ "{:,.0f}".format(prop.price * 0.25) }}</strong><br>
|
||||
Préstamo estimado: <strong>${{ "{:,.0f}".format(prop.price * 0.75) }}</strong><br>
|
||||
Cuota aprox (7.5%, 30 años): <strong>${{ "{:,.0f}".format(prop.price * 0.75 * 0.007) }}/mes</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recommended Lenders -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-3" style="color:var(--primary)"><i class="fas fa-handshake me-2" style="color:var(--accent)"></i>Lenders para Esta Compra</h6>
|
||||
{% for l in lenders[:4] %}
|
||||
<div class="border rounded p-2 mb-2">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<strong class="small">{{ l.name }}</strong>
|
||||
<span class="badge" style="background:var(--accent);color:#fff">{{ l.match_score }}%</span>
|
||||
</div>
|
||||
<small class="text-muted">{{ l.loan_type }}</small><br>
|
||||
<a href="{{ l.website }}" target="_blank" class="btn btn-xs btn-outline-primary btn-sm mt-1" style="font-size:.7rem;padding:2px 8px">
|
||||
<i class="fas fa-globe me-1"></i>Visitar
|
||||
</a>
|
||||
<button class="btn btn-xs btn-outline-secondary btn-sm mt-1 ms-1" style="font-size:.7rem;padding:2px 8px"
|
||||
onclick="showScript('{{ l.name|e }}', `{{ l.contact_script|e }}`)">
|
||||
<i class="fas fa-phone me-1"></i>Script
|
||||
</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<a href="/lenders" class="btn btn-sm btn-outline-primary w-100 mt-1">Ver todos los lenders</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Script Modal -->
|
||||
<div class="modal fade" id="scriptModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title fw-bold" id="scriptTitle"></h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-muted small mb-2">Usa este guión cuando los contactes:</p>
|
||||
<div class="action-kit" id="scriptContent"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button onclick="copyScript()" class="btn btn-sm btn-primary"><i class="fas fa-copy me-1"></i>Copiar</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cerrar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function toggleFav(id, btn) {
|
||||
fetch(`/property/${id}/favorite`, {method:'POST'})
|
||||
.then(r=>r.json())
|
||||
.then(d => {
|
||||
btn.className = d.is_favorite ? 'btn btn-warning' : 'btn btn-outline-warning';
|
||||
btn.innerHTML = `<i class="fas fa-star me-1"></i>${d.is_favorite ? 'Guardada' : 'Guardar'}`;
|
||||
});
|
||||
}
|
||||
function runAnalysis(id) {
|
||||
document.getElementById('ai-result').innerHTML = '<div class="text-center py-3"><div class="spinner-border spinner-border-sm me-2"></div>Analizando con qwen2.5...</div>';
|
||||
fetch(`/property/${id}/analyze`, {method:'POST'})
|
||||
.then(r=>r.json())
|
||||
.then(d => {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'action-kit';
|
||||
container.textContent = d.analysis;
|
||||
const wrapper = document.getElementById('ai-result');
|
||||
wrapper.innerHTML = '';
|
||||
wrapper.appendChild(container);
|
||||
});
|
||||
}
|
||||
function saveNotes(id) {
|
||||
const notes = document.getElementById('notes').value;
|
||||
fetch(`/property/${id}/notes`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({notes})})
|
||||
.then(()=>alert('Notas guardadas'));
|
||||
}
|
||||
function showScript(name, script) {
|
||||
document.getElementById('scriptTitle').textContent = 'Script: ' + name;
|
||||
document.getElementById('scriptContent').textContent = script;
|
||||
new bootstrap.Modal(document.getElementById('scriptModal')).show();
|
||||
}
|
||||
function copyScript() {
|
||||
navigator.clipboard.writeText(document.getElementById('scriptContent').textContent);
|
||||
alert('Script copiado al portapapeles');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,181 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Preferencias — Casa Hunter FL{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<h2 class="fw-bold mb-1" style="color:var(--primary)"><i class="fas fa-sliders-h me-2" style="color:var(--accent)"></i>Preferencias de Búsqueda</h2>
|
||||
<p class="text-muted mb-4">Configura dónde y hasta cuánto buscar. La próxima búsqueda usará estos ajustes.</p>
|
||||
|
||||
<div class="row g-4">
|
||||
|
||||
<!-- Presupuesto y Down Payment -->
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-3" style="color:var(--primary)"><i class="fas fa-dollar-sign me-2" style="color:var(--accent)"></i>Mi Presupuesto</h6>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="save_config">
|
||||
|
||||
<label class="form-label small fw-bold">Precio máximo de compra (USD)</label>
|
||||
<div class="input-group mb-1">
|
||||
<span class="input-group-text">$</span>
|
||||
<input type="number" name="max_price" id="max_price_input" class="form-control form-control-lg fw-bold"
|
||||
value="{{ max_price }}" min="50000" max="1000000" step="5000"
|
||||
oninput="document.getElementById('price-display').textContent='$'+Number(this.value).toLocaleString('en-US')">
|
||||
</div>
|
||||
<input type="range" class="form-range mb-1" min="50000" max="500000" step="5000" value="{{ max_price }}"
|
||||
oninput="document.getElementById('max_price_input').value=this.value;document.getElementById('price-display').textContent='$'+Number(this.value).toLocaleString('en-US')">
|
||||
<div class="d-flex justify-content-between text-muted" style="font-size:.7rem">
|
||||
<span>$50K</span><span>$250K</span><span>$500K</span>
|
||||
</div>
|
||||
<div class="text-center my-2 fw-bold" style="color:var(--accent)" id="price-display">${{ "{:,}".format(max_price) }}</div>
|
||||
|
||||
<hr class="my-3">
|
||||
|
||||
<label class="form-label small fw-bold">Down Payment disponible (USD)</label>
|
||||
<div class="input-group mb-1">
|
||||
<span class="input-group-text">$</span>
|
||||
<input type="number" name="down_payment" id="down_input" class="form-control form-control-lg fw-bold"
|
||||
value="{{ down_payment }}" min="5000" max="500000" step="1000"
|
||||
oninput="document.getElementById('dp-display').textContent='$'+Number(this.value).toLocaleString('en-US')">
|
||||
</div>
|
||||
<input type="range" class="form-range mb-1" min="5000" max="200000" step="1000" value="{{ down_payment }}"
|
||||
oninput="document.getElementById('down_input').value=this.value;document.getElementById('dp-display').textContent='$'+Number(this.value).toLocaleString('en-US')">
|
||||
<div class="text-center fw-bold mb-3" style="color:var(--accent)" id="dp-display">${{ "{:,}".format(down_payment) }}</div>
|
||||
|
||||
<div class="bg-light rounded p-2 small mb-3">
|
||||
<i class="fas fa-calculator me-1"></i>
|
||||
Down del <strong id="pct-display">{{ ((down_payment / max_price) * 100)|round(1) }}%</strong> sobre precio máximo
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100"><i class="fas fa-save me-2"></i>Guardar</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Agregar ciudad -->
|
||||
<div class="col-12 col-md-8">
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-3" style="color:var(--primary)"><i class="fas fa-plus-circle me-2" style="color:var(--accent)"></i>Agregar Ciudad</h6>
|
||||
<form method="post" class="d-flex gap-2">
|
||||
<input type="hidden" name="action" value="add_city">
|
||||
<div class="flex-fill position-relative">
|
||||
<input type="text" name="city" id="city-input" class="form-control"
|
||||
placeholder="Escribe una ciudad de Florida..." autocomplete="off" required>
|
||||
<ul id="city-suggestions" class="list-group position-absolute w-100 shadow-sm"
|
||||
style="z-index:1000;display:none;max-height:200px;overflow-y:auto;top:100%"></ul>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-accent"><i class="fas fa-plus me-1"></i>Agregar</button>
|
||||
</form>
|
||||
<p class="text-muted small mt-2 mb-0"><i class="fas fa-info-circle me-1"></i>Puedes agregar cualquier ciudad de Florida — Vero Beach, Palm Coast, Ponte Vedra, etc.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cities list -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-3" style="color:var(--primary)">
|
||||
<i class="fas fa-map-marker-alt me-2" style="color:var(--accent)"></i>
|
||||
Ciudades en tu búsqueda
|
||||
<span class="badge ms-1" style="background:var(--accent)">{{ cities|selectattr('active')|list|length }} activas</span>
|
||||
</h6>
|
||||
{% if cities %}
|
||||
<div class="row g-2">
|
||||
{% for c in cities %}
|
||||
<div class="col-12 col-sm-6 col-md-4">
|
||||
<div class="d-flex align-items-center justify-content-between border rounded px-3 py-2 {% if not c.active %}opacity-50{% endif %}">
|
||||
<div>
|
||||
<i class="fas fa-map-pin me-2 {% if c.active %}text-success{% else %}text-muted{% endif %}"></i>
|
||||
<span class="small fw-bold">{{ c.city }}</span>
|
||||
</div>
|
||||
<div class="d-flex gap-1">
|
||||
<form method="post" class="d-inline">
|
||||
<input type="hidden" name="action" value="toggle_city">
|
||||
<input type="hidden" name="city_id" value="{{ c.id }}">
|
||||
<button type="submit" class="btn btn-xs p-1 border-0 bg-transparent" title="{% if c.active %}Desactivar{% else %}Activar{% endif %}">
|
||||
<i class="fas fa-{% if c.active %}eye{% else %}eye-slash{% endif %} text-muted" style="font-size:.8rem"></i>
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" class="d-inline" onsubmit="return confirm('¿Eliminar {{ c.city }}?')">
|
||||
<input type="hidden" name="action" value="remove_city">
|
||||
<input type="hidden" name="city_id" value="{{ c.id }}">
|
||||
<button type="submit" class="btn btn-xs p-1 border-0 bg-transparent">
|
||||
<i class="fas fa-times text-danger" style="font-size:.8rem"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">No hay ciudades configuradas.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick add coastal cities -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-3" style="color:var(--primary)"><i class="fas fa-water me-2" style="color:var(--accent)"></i>Agregar rápido — Ciudades costeras populares</h6>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% set quick_cities = ["Vero Beach","Sebastian","Melbourne","Cocoa Beach","Titusville","Stuart","Jensen Beach",
|
||||
"Daytona Beach","Ormond Beach","New Smyrna Beach","Flagler Beach","Palm Coast",
|
||||
"St. Augustine","St. Augustine Beach","Ponte Vedra Beach",
|
||||
"Jacksonville Beach","Atlantic Beach","Neptune Beach","Fernandina Beach",
|
||||
"Port St. Lucie","Fort Pierce","Palm City","Hobe Sound"] %}
|
||||
{% for qc in quick_cities %}
|
||||
{% set already = cities|selectattr('city','equalto',qc)|list %}
|
||||
{% if not already %}
|
||||
<form method="post" class="d-inline">
|
||||
<input type="hidden" name="action" value="add_city">
|
||||
<input type="hidden" name="city" value="{{ qc }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="fas fa-plus me-1" style="font-size:.7rem"></i>{{ qc }}
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="btn btn-sm btn-success disabled" style="font-size:.8rem">
|
||||
<i class="fas fa-check me-1"></i>{{ qc }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const input = document.getElementById('city-input');
|
||||
const suggestions = document.getElementById('city-suggestions');
|
||||
|
||||
input.addEventListener('input', async function() {
|
||||
const q = this.value.trim();
|
||||
if (q.length < 2) { suggestions.style.display='none'; return; }
|
||||
const res = await fetch(`/api/cities?q=${encodeURIComponent(q)}`);
|
||||
const cities = await res.json();
|
||||
suggestions.innerHTML = '';
|
||||
if (cities.length === 0) { suggestions.style.display='none'; return; }
|
||||
cities.forEach(city => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'list-group-item list-group-item-action py-2 small';
|
||||
li.textContent = city + ', FL';
|
||||
li.style.cursor = 'pointer';
|
||||
li.addEventListener('click', () => {
|
||||
input.value = city;
|
||||
suggestions.style.display = 'none';
|
||||
});
|
||||
suggestions.appendChild(li);
|
||||
});
|
||||
suggestions.style.display = 'block';
|
||||
});
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
if (!input.contains(e.target)) suggestions.style.display = 'none';
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user