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