feat: Agente-Marketing initial commit

This commit is contained in:
2026-07-03 12:23:34 -04:00
commit 293522436a
52 changed files with 13522 additions and 0 deletions
+104
View File
@@ -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)
+58
View File
@@ -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)))
+122
View File
@@ -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."
}
]
+337
View File
@@ -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)
+69
View File
@@ -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
+91
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
{"running": false, "progress": "Completado: 179 propiedades unicas encontradas"}
+461
View File
@@ -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()
}
+135
View File
@@ -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 &mdash; Tu buscador personal de oportunidades inmobiliarias &bull; 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>
+186
View File
@@ -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 &bull; 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 %}
+104
View File
@@ -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 %}
+99
View File
@@ -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 %}
+181
View File
@@ -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 %}