Files
Agente-Marketing/casa-hunter/app/main.py
T

338 lines
13 KiB
Python

# -*- 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)