338 lines
13 KiB
Python
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)
|