feat: Agente-Marketing initial commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app/ .
|
||||
EXPOSE 5000
|
||||
CMD ["python", "main.py"]
|
||||
@@ -0,0 +1,104 @@
|
||||
import requests
|
||||
import json
|
||||
|
||||
# Supports Ollama (local) or Anthropic API
|
||||
# Set OLLAMA_URL to use local AI, or ANTHROPIC_KEY for Claude API
|
||||
|
||||
OLLAMA_URL = "http://localhost:11434"
|
||||
OLLAMA_MODEL = "qwen2.5:14b"
|
||||
|
||||
USER_PROFILE = """
|
||||
Perfil del comprador:
|
||||
- Residente permanente en EE.UU., menos de 2 años en el país
|
||||
- Soltero
|
||||
- Trabajo independiente: electricista naval/marino ($120/hora)
|
||||
- Dinero disponible para down payment: $50,000 dólares
|
||||
- Presupuesto máximo: $200,000
|
||||
- Busca: costas de Florida (Stuart hacia el norte hasta Jacksonville)
|
||||
- Objetivo: casa para vivir, oportunidad de remate o precio bajo de mercado
|
||||
- Situación crediticia: construyendo historial en USA, no califica para préstamo convencional aún
|
||||
- Tipo de préstamo ideal: Non-QM, Bank Statement, o ITIN loan
|
||||
"""
|
||||
|
||||
ANALYSIS_PROMPT = """Eres un asesor experto en bienes raíces y finanzas para compradores en Florida.
|
||||
|
||||
{profile}
|
||||
|
||||
Analiza esta propiedad y responde en español en máximo 4 oraciones:
|
||||
1. ¿Es una buena oportunidad para este comprador?
|
||||
2. ¿Qué tipo de préstamo le conviene para esta propiedad específica?
|
||||
3. ¿Hay algo que deba investigar o que sea una señal de alerta?
|
||||
|
||||
Propiedad:
|
||||
- Dirección: {address}
|
||||
- Ciudad/Condado: {city}
|
||||
- Precio: ${price:,}
|
||||
- Tipo: {prop_type}
|
||||
- Estado: {status}
|
||||
- Habitaciones: {beds} | Baños: {baths}
|
||||
- Metros cuadrados: {sqft}
|
||||
- Fuente: {source}
|
||||
|
||||
Responde de forma directa y práctica, como si le hablaras a un amigo."""
|
||||
|
||||
|
||||
def analyze_with_ollama(prop: dict) -> str:
|
||||
prompt = ANALYSIS_PROMPT.format(
|
||||
profile=USER_PROFILE,
|
||||
address=prop.get("address", "N/A"),
|
||||
city=f"{prop.get('city', '')} {prop.get('county', '')}".strip(),
|
||||
price=prop.get("price", 0),
|
||||
prop_type=prop.get("property_type", "Residencial"),
|
||||
status=prop.get("status", "Foreclosure"),
|
||||
beds=prop.get("beds", "N/A"),
|
||||
baths=prop.get("baths", "N/A"),
|
||||
sqft=prop.get("sqft", "N/A"),
|
||||
source=prop.get("source", "N/A")
|
||||
)
|
||||
try:
|
||||
r = requests.post(
|
||||
f"{OLLAMA_URL}/api/generate",
|
||||
json={"model": OLLAMA_MODEL, "prompt": prompt, "stream": False},
|
||||
timeout=60
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return r.json().get("response", "").strip()
|
||||
except Exception as e:
|
||||
return f"IA local no disponible: {e}"
|
||||
return "Análisis no disponible."
|
||||
|
||||
|
||||
def analyze_with_anthropic(prop: dict, api_key: str) -> str:
|
||||
prompt = ANALYSIS_PROMPT.format(
|
||||
profile=USER_PROFILE,
|
||||
address=prop.get("address", "N/A"),
|
||||
city=f"{prop.get('city', '')} {prop.get('county', '')}".strip(),
|
||||
price=prop.get("price", 0),
|
||||
prop_type=prop.get("property_type", "Residencial"),
|
||||
status=prop.get("status", "Foreclosure"),
|
||||
beds=prop.get("beds", "N/A"),
|
||||
baths=prop.get("baths", "N/A"),
|
||||
sqft=prop.get("sqft", "N/A"),
|
||||
source=prop.get("source", "N/A")
|
||||
)
|
||||
try:
|
||||
r = requests.post(
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
headers={"x-api-key": api_key, "anthropic-version": "2023-06-01", "content-type": "application/json"},
|
||||
json={"model": "claude-haiku-4-5-20251001", "max_tokens": 300, "messages": [{"role": "user", "content": prompt}]},
|
||||
timeout=30
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return r.json()["content"][0]["text"].strip()
|
||||
except Exception as e:
|
||||
return f"Error API: {e}"
|
||||
return "Análisis no disponible."
|
||||
|
||||
|
||||
def analyze_property(prop: dict, ollama_model: str = None, anthropic_key: str = None) -> str:
|
||||
global OLLAMA_MODEL
|
||||
if ollama_model:
|
||||
OLLAMA_MODEL = ollama_model
|
||||
if anthropic_key:
|
||||
return analyze_with_anthropic(prop, anthropic_key)
|
||||
return analyze_with_ollama(prop)
|
||||
@@ -0,0 +1,58 @@
|
||||
FLORIDA_CITIES = [
|
||||
# Treasure Coast
|
||||
"Stuart", "Palm City", "Hobe Sound", "Jensen Beach", "Port Salerno",
|
||||
"Indiantown", "Port St. Lucie", "Fort Pierce", "Tradition", "St. Lucie West",
|
||||
"Vero Beach", "Sebastian", "Fellsmere", "Roseland", "Indian River Shores",
|
||||
"Orchid", "Gifford", "Wabasso",
|
||||
# Space Coast / Brevard
|
||||
"Melbourne", "Palm Bay", "Titusville", "Cocoa", "Cocoa Beach",
|
||||
"Rockledge", "Merritt Island", "Cape Canaveral", "Satellite Beach",
|
||||
"Indian Harbour Beach", "Indialantic", "Melbourne Beach", "Grant",
|
||||
"Valkaria", "Malabar", "Palm Shores", "Micco", "West Melbourne",
|
||||
# Volusia
|
||||
"Daytona Beach", "Daytona Beach Shores", "Ormond Beach", "Holly Hill",
|
||||
"South Daytona", "Port Orange", "Ponce Inlet", "New Smyrna Beach",
|
||||
"Edgewater", "Oak Hill", "Deltona", "DeLand", "Orange City",
|
||||
"DeBary", "Lake Helen", "Pierson", "Flagler Beach",
|
||||
# Flagler
|
||||
"Palm Coast", "Bunnell", "Flagler Beach", "Beverly Beach", "Marineland",
|
||||
# St. Johns
|
||||
"St. Augustine", "St. Augustine Beach", "Ponte Vedra Beach",
|
||||
"Ponte Vedra", "Palm Valley", "Nocatee", "World Golf Village",
|
||||
"Hastings", "Crescent Beach", "Vilano Beach",
|
||||
# Duval / Jacksonville
|
||||
"Jacksonville", "Jacksonville Beach", "Atlantic Beach", "Neptune Beach",
|
||||
"Fernandina Beach", "Yulee", "Callahan", "Baldwin", "Mandarin",
|
||||
"Southside", "Westside", "Arlington", "Baymeadows", "Ponte Vedra",
|
||||
"Orange Park", "Fleming Island",
|
||||
# Nassau
|
||||
"Fernandina Beach", "Yulee", "Hilliard", "Callahan",
|
||||
# Palm Beach / South (just in case they expand)
|
||||
"West Palm Beach", "Boca Raton", "Delray Beach", "Lake Worth",
|
||||
"Boynton Beach", "Riviera Beach", "Palm Beach Gardens",
|
||||
"Jupiter", "Tequesta", "North Palm Beach",
|
||||
# Broward
|
||||
"Fort Lauderdale", "Hollywood", "Pompano Beach", "Deerfield Beach",
|
||||
"Hallandale Beach", "Coral Springs", "Margate", "Coconut Creek",
|
||||
"Tamarac", "Plantation", "Davie", "Miramar", "Pembroke Pines",
|
||||
"Lauderdale-by-the-Sea", "Dania Beach",
|
||||
# Miami-Dade
|
||||
"Miami", "Miami Beach", "Coral Gables", "Hialeah", "Homestead",
|
||||
"Aventura", "Sunny Isles Beach", "North Miami", "North Miami Beach",
|
||||
"Doral", "Kendall", "Cutler Bay", "Palmetto Bay",
|
||||
# Gulf Coast
|
||||
"Naples", "Marco Island", "Bonita Springs", "Estero", "Fort Myers",
|
||||
"Fort Myers Beach", "Cape Coral", "Punta Gorda", "Port Charlotte",
|
||||
"Englewood", "Venice", "Sarasota", "Bradenton", "Anna Maria",
|
||||
"Holmes Beach", "Palmetto", "St. Petersburg", "Clearwater",
|
||||
"Dunedin", "Tarpon Springs", "New Port Richey", "Port Richey",
|
||||
"Hudson", "Spring Hill", "Brooksville",
|
||||
# Central
|
||||
"Orlando", "Kissimmee", "Sanford", "Altamonte Springs", "Casselberry",
|
||||
"Winter Park", "Maitland", "Apopka", "Ocoee", "Winter Garden",
|
||||
"Clermont", "Leesburg", "The Villages", "Ocala", "Gainesville",
|
||||
"Tallahassee", "Pensacola", "Panama City", "Destin", "Fort Walton Beach",
|
||||
]
|
||||
|
||||
# Remove duplicates and sort
|
||||
FLORIDA_CITIES = sorted(list(set(FLORIDA_CITIES)))
|
||||
@@ -0,0 +1,122 @@
|
||||
LENDERS = [
|
||||
{
|
||||
"name": "Heart Mortgage",
|
||||
"loan_type": "ITIN / Non-QM / Bank Statement",
|
||||
"specialties": "Residentes nuevos, ITIN, sin historial crediticio USA, self-employed",
|
||||
"min_down_pct": 15.0,
|
||||
"accepts_new_resident": True,
|
||||
"accepts_self_employed": True,
|
||||
"requires_2yr_history": False,
|
||||
"phone": "",
|
||||
"email": "",
|
||||
"website": "https://blog.heartmortgage.com",
|
||||
"match_score": 98,
|
||||
"contact_script": "Hola, me llamo [TU NOMBRE]. Soy residente permanente, llevo menos de 2 años en EE.UU. Trabajo como contratista independiente en eléctrica marina y tengo $50,000 para down payment. Estoy buscando propiedades hasta $200,000 en la costa de Florida. ¿Tienen programas Bank Statement o Non-QM para mi situación?",
|
||||
"notes": "Top lender FL 2026 para residentes nuevos e ITIN. No requieren 2 años de historial."
|
||||
},
|
||||
{
|
||||
"name": "Jhenesis Mortgage",
|
||||
"loan_type": "ITIN / Sin score crediticio",
|
||||
"specialties": "Sin score requerido, hasta 85% LTV, 1 año empleo suficiente",
|
||||
"min_down_pct": 15.0,
|
||||
"accepts_new_resident": True,
|
||||
"accepts_self_employed": True,
|
||||
"requires_2yr_history": False,
|
||||
"phone": "",
|
||||
"email": "",
|
||||
"website": "https://jhenesismortgage.com",
|
||||
"match_score": 97,
|
||||
"contact_script": "Hola, me llamo [TU NOMBRE]. Soy residente permanente con menos de 2 años en EE.UU. Trabajo independiente en instalaciones eléctricas marinas, ingresos de $120/hora. Tengo $50,000 de down payment y busco hasta $200,000. ¿Manejan préstamos ITIN o sin score crediticio americano?",
|
||||
"notes": "Aceptan sin score crediticio USA. Solo necesitan 1 año de historial laboral."
|
||||
},
|
||||
{
|
||||
"name": "1st Florida Lending",
|
||||
"loan_type": "ITIN / DACA / Residentes",
|
||||
"specialties": "ITIN, DACA, residentes nuevos, toda Florida",
|
||||
"min_down_pct": 10.0,
|
||||
"accepts_new_resident": True,
|
||||
"accepts_self_employed": True,
|
||||
"requires_2yr_history": False,
|
||||
"phone": "",
|
||||
"email": "",
|
||||
"website": "https://www.1floridalending.com",
|
||||
"match_score": 95,
|
||||
"contact_script": "Hola, llamo porque necesito un préstamo hipotecario. Soy residente permanente, menos de 2 años en EE.UU., trabajo contratista independiente en eléctrica marina. Down payment disponible: $50,000. Propiedad buscada: hasta $200,000 en costa de Florida. ¿Trabajan con residentes nuevos y self-employed?",
|
||||
"notes": "Especializados en ITIN y residentes nuevos en toda Florida. Down payment desde 10%."
|
||||
},
|
||||
{
|
||||
"name": "Bennett Capital Partners",
|
||||
"loan_type": "Non-QM / Bank Statement",
|
||||
"specialties": "Bank Statement loans, residentes nuevos, self-employed, Miami y Florida",
|
||||
"min_down_pct": 20.0,
|
||||
"accepts_new_resident": True,
|
||||
"accepts_self_employed": True,
|
||||
"requires_2yr_history": False,
|
||||
"phone": "",
|
||||
"email": "",
|
||||
"website": "https://www.bcpmortgage.com",
|
||||
"match_score": 92,
|
||||
"contact_script": "Hola, mi nombre es [TU NOMBRE]. Soy residente permanente trabajando como contratista independiente en eléctrica naval, ganando $120 por hora. Tengo $50,000 líquidos para down payment y busco propiedad hasta $200,000 en costa de Florida. ¿Tienen Bank Statement loans para alguien con menos de 2 años de historial en EE.UU.?",
|
||||
"notes": "Fuertes en Non-QM y Bank Statement. Acceso a múltiples lenders portfolio."
|
||||
},
|
||||
{
|
||||
"name": "The Doce Group",
|
||||
"loan_type": "Non-QM / Foreign National / Residente",
|
||||
"specialties": "Residentes nuevos, extranjeros, sur de Florida",
|
||||
"min_down_pct": 25.0,
|
||||
"accepts_new_resident": True,
|
||||
"accepts_self_employed": True,
|
||||
"requires_2yr_history": False,
|
||||
"phone": "",
|
||||
"email": "",
|
||||
"website": "https://thedocegroup.com",
|
||||
"match_score": 88,
|
||||
"contact_script": "Hola, soy residente permanente, menos de 2 años en EE.UU., work as independent marine electrical contractor. $50,000 down payment disponible, busco hasta $200,000. ¿Tienen programas para mi perfil?",
|
||||
"notes": "Especializados en perfiles internacionales y residentes nuevos en Florida."
|
||||
},
|
||||
{
|
||||
"name": "Griffin Funding",
|
||||
"loan_type": "Bank Statement / Non-QM",
|
||||
"specialties": "Self-employed, Bank Statement loans, toda Florida",
|
||||
"min_down_pct": 10.0,
|
||||
"accepts_new_resident": True,
|
||||
"accepts_self_employed": True,
|
||||
"requires_2yr_history": False,
|
||||
"phone": "",
|
||||
"email": "",
|
||||
"website": "https://griffinfunding.com",
|
||||
"match_score": 90,
|
||||
"contact_script": "Hola, busco un Bank Statement loan. Soy contratista independiente en eléctrica marina, residente permanente, menos de 2 años en EE.UU. Tengo $50,000 de down payment y busco propiedad hasta $200,000 en costas de Florida. ¿Califico con bank statements de 12 meses?",
|
||||
"notes": "Líderes en Bank Statement loans para self-employed. Desde 10% down."
|
||||
},
|
||||
{
|
||||
"name": "DAK Mortgage",
|
||||
"loan_type": "Non-QM / Internacional",
|
||||
"specialties": "Compradores internacionales, residentes nuevos, Miami y Florida",
|
||||
"min_down_pct": 20.0,
|
||||
"accepts_new_resident": True,
|
||||
"accepts_self_employed": True,
|
||||
"requires_2yr_history": False,
|
||||
"phone": "",
|
||||
"email": "",
|
||||
"website": "https://davidakrebs.com",
|
||||
"match_score": 85,
|
||||
"contact_script": "Hola, soy residente permanente con menos de 2 años aquí. Trabajo independiente en instalaciones eléctricas marinas. $50,000 down payment, busco hasta $200,000 en costas de Florida. ¿Tienen opciones para mi perfil?",
|
||||
"notes": "Especialistas en Miami para perfiles internacionales y residentes nuevos."
|
||||
},
|
||||
{
|
||||
"name": "Angel Oak Mortgage",
|
||||
"loan_type": "Non-QM / Foreign National",
|
||||
"specialties": "Non-QM, residentes, bank statement, toda USA",
|
||||
"min_down_pct": 20.0,
|
||||
"accepts_new_resident": True,
|
||||
"accepts_self_employed": True,
|
||||
"requires_2yr_history": False,
|
||||
"phone": "",
|
||||
"email": "",
|
||||
"website": "https://angeloakms.com",
|
||||
"match_score": 87,
|
||||
"contact_script": "Hola, necesito información sobre Non-QM loans. Soy residente permanente, self-employed en eléctrica marina, menos de 2 años en EE.UU., $50,000 down payment, busco hasta $200,000 en Florida. ¿Tienen programas para este perfil?",
|
||||
"notes": "Pioneros en Non-QM. Tienen programa específico Foreign National y residentes nuevos."
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,337 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import sys, io
|
||||
if sys.stdout.encoding != 'utf-8':
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
from flask import Flask, render_template, request, redirect, url_for, jsonify, flash
|
||||
from models import db, Property, Lender, ScrapeLog, SearchCity, SearchConfig
|
||||
from lenders_data import LENDERS
|
||||
from scrapers import run_all_scrapers
|
||||
from ai_analyzer import analyze_property
|
||||
from florida_cities import FLORIDA_CITIES
|
||||
import os, subprocess, json, sys
|
||||
from datetime import datetime
|
||||
|
||||
app = Flask(__name__)
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
STATUS_FILE = os.path.join(BASE_DIR, "scan_status.json")
|
||||
RESULTS_FILE = os.path.join(BASE_DIR, "scan_results.json")
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get(
|
||||
"DATABASE_URL",
|
||||
f"sqlite:///{os.path.join(BASE_DIR, 'casahunter.db')}"
|
||||
)
|
||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||
_secret = os.environ.get("SECRET_KEY")
|
||||
if not _secret:
|
||||
import secrets as _secrets
|
||||
_secret = _secrets.token_hex(32)
|
||||
app.secret_key = _secret
|
||||
|
||||
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5:14b")
|
||||
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||||
ANTHROPIC_KEY = os.environ.get("ANTHROPIC_KEY", "")
|
||||
|
||||
db.init_app(app)
|
||||
|
||||
DEFAULT_CITIES = [
|
||||
"Vero Beach", "Sebastian", "Stuart", "Jensen Beach",
|
||||
"Melbourne", "Cocoa Beach", "Titusville",
|
||||
"Daytona Beach", "Ormond Beach", "New Smyrna Beach",
|
||||
"St. Augustine", "Jacksonville", "Jacksonville Beach", "Fernandina Beach"
|
||||
]
|
||||
DEFAULT_MAX_PRICE = 200000
|
||||
DEFAULT_DOWN_PAYMENT = 50000
|
||||
|
||||
|
||||
def get_config(key, default):
|
||||
row = SearchConfig.query.filter_by(key=key).first()
|
||||
return row.value if row else default
|
||||
|
||||
|
||||
def set_config(key, value):
|
||||
row = SearchConfig.query.filter_by(key=key).first()
|
||||
if row:
|
||||
row.value = str(value)
|
||||
else:
|
||||
db.session.add(SearchConfig(key=key, value=str(value)))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def seed_defaults():
|
||||
if Lender.query.count() == 0:
|
||||
for l in LENDERS:
|
||||
db.session.add(Lender(**l))
|
||||
if SearchCity.query.count() == 0:
|
||||
for c in DEFAULT_CITIES:
|
||||
db.session.add(SearchCity(city=c, active=True))
|
||||
if not SearchConfig.query.filter_by(key="max_price").first():
|
||||
db.session.add(SearchConfig(key="max_price", value=str(DEFAULT_MAX_PRICE)))
|
||||
if not SearchConfig.query.filter_by(key="down_payment").first():
|
||||
db.session.add(SearchConfig(key="down_payment", value=str(DEFAULT_DOWN_PAYMENT)))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@app.before_request
|
||||
def init_db():
|
||||
if not hasattr(app, "_db_ready"):
|
||||
db.create_all()
|
||||
seed_defaults()
|
||||
app._db_ready = True
|
||||
|
||||
|
||||
# ── API: cities autocomplete ──────────────────────────────────────────────────
|
||||
@app.route("/api/cities")
|
||||
def api_cities():
|
||||
q = request.args.get("q", "").lower()
|
||||
matches = [c for c in FLORIDA_CITIES if q in c.lower()] if q else FLORIDA_CITIES[:20]
|
||||
return jsonify(matches[:15])
|
||||
|
||||
|
||||
# ── Dashboard ─────────────────────────────────────────────────────────────────
|
||||
@app.route("/")
|
||||
def index():
|
||||
max_price = int(get_config("max_price", DEFAULT_MAX_PRICE))
|
||||
top_props = Property.query.order_by(Property.score.desc()).limit(6).all()
|
||||
total = Property.query.count()
|
||||
favorites = Property.query.filter_by(is_favorite=True).count()
|
||||
last_scan = ScrapeLog.query.order_by(ScrapeLog.ran_at.desc()).first()
|
||||
top_lenders = Lender.query.order_by(Lender.match_score.desc()).limit(3).all()
|
||||
active_cities = SearchCity.query.filter_by(active=True).order_by(SearchCity.city).all()
|
||||
return render_template("index.html",
|
||||
properties=top_props, total=total, favorites=favorites,
|
||||
last_scan=last_scan, top_lenders=top_lenders,
|
||||
active_cities=active_cities, max_price=max_price
|
||||
)
|
||||
|
||||
|
||||
# ── Properties list ───────────────────────────────────────────────────────────
|
||||
@app.route("/properties")
|
||||
def properties():
|
||||
sort = request.args.get("sort", "score")
|
||||
source = request.args.get("source", "")
|
||||
min_score = int(request.args.get("min_score", 0))
|
||||
favorites_only = request.args.get("favorites") == "1"
|
||||
city_filter = request.args.get("city", "")
|
||||
max_price = int(request.args.get("max_price", get_config("max_price", DEFAULT_MAX_PRICE)))
|
||||
|
||||
q = Property.query
|
||||
if source:
|
||||
q = q.filter(Property.source == source)
|
||||
if min_score > 0:
|
||||
q = q.filter(Property.score >= min_score)
|
||||
if favorites_only:
|
||||
q = q.filter(Property.is_favorite == True)
|
||||
if city_filter:
|
||||
q = q.filter(Property.city.ilike(f"%{city_filter}%"))
|
||||
if max_price:
|
||||
q = q.filter(Property.price <= max_price)
|
||||
|
||||
order_map = {
|
||||
"price_asc": Property.price.asc(),
|
||||
"price_desc": Property.price.desc(),
|
||||
"newest": Property.created_at.desc(),
|
||||
}
|
||||
q = q.order_by(order_map.get(sort, Property.score.desc()))
|
||||
|
||||
props = q.all()
|
||||
sources = db.session.query(Property.source).distinct().all()
|
||||
return render_template("properties.html",
|
||||
properties=props, sources=[s[0] for s in sources],
|
||||
current_sort=sort, current_source=source,
|
||||
city_filter=city_filter, max_price=max_price,
|
||||
config_max_price=int(get_config("max_price", DEFAULT_MAX_PRICE))
|
||||
)
|
||||
|
||||
|
||||
# ── Property detail ───────────────────────────────────────────────────────────
|
||||
@app.route("/property/<int:pid>")
|
||||
def property_detail(pid):
|
||||
prop = Property.query.get_or_404(pid)
|
||||
lenders = Lender.query.order_by(Lender.match_score.desc()).all()
|
||||
return render_template("property_detail.html", prop=prop, lenders=lenders)
|
||||
|
||||
|
||||
@app.route("/property/<int:pid>/analyze", methods=["POST"])
|
||||
def analyze(pid):
|
||||
prop = Property.query.get_or_404(pid)
|
||||
import ai_analyzer
|
||||
ai_analyzer.OLLAMA_URL = OLLAMA_URL
|
||||
analysis = analyze_property(
|
||||
{"address": prop.address, "city": prop.city, "county": prop.county,
|
||||
"price": prop.price, "beds": prop.beds, "baths": prop.baths,
|
||||
"sqft": prop.sqft, "property_type": prop.property_type,
|
||||
"status": prop.status, "source": prop.source},
|
||||
OLLAMA_MODEL, ANTHROPIC_KEY or None
|
||||
)
|
||||
prop.ai_analysis = analysis
|
||||
db.session.commit()
|
||||
return jsonify({"analysis": analysis})
|
||||
|
||||
|
||||
@app.route("/property/<int:pid>/favorite", methods=["POST"])
|
||||
def toggle_favorite(pid):
|
||||
prop = Property.query.get_or_404(pid)
|
||||
prop.is_favorite = not prop.is_favorite
|
||||
db.session.commit()
|
||||
return jsonify({"is_favorite": prop.is_favorite})
|
||||
|
||||
|
||||
@app.route("/property/<int:pid>/notes", methods=["POST"])
|
||||
def save_notes(pid):
|
||||
prop = Property.query.get_or_404(pid)
|
||||
prop.notes = request.json.get("notes", "")
|
||||
db.session.commit()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
# ── Lenders ───────────────────────────────────────────────────────────────────
|
||||
@app.route("/lenders")
|
||||
def lenders():
|
||||
return render_template("lenders.html",
|
||||
lenders=Lender.query.order_by(Lender.match_score.desc()).all()
|
||||
)
|
||||
|
||||
|
||||
# ── Settings ──────────────────────────────────────────────────────────────────
|
||||
@app.route("/settings", methods=["GET", "POST"])
|
||||
def settings():
|
||||
if request.method == "POST":
|
||||
action = request.form.get("action")
|
||||
|
||||
if action == "add_city":
|
||||
city = request.form.get("city", "").strip()
|
||||
if city and not SearchCity.query.filter_by(city=city).first():
|
||||
db.session.add(SearchCity(city=city, active=True))
|
||||
db.session.commit()
|
||||
flash(f"Ciudad agregada: {city}", "success")
|
||||
elif city:
|
||||
existing = SearchCity.query.filter_by(city=city).first()
|
||||
existing.active = True
|
||||
db.session.commit()
|
||||
flash(f"{city} reactivada", "info")
|
||||
|
||||
elif action == "remove_city":
|
||||
city_id = int(request.form.get("city_id"))
|
||||
c = SearchCity.query.get(city_id)
|
||||
if c:
|
||||
db.session.delete(c)
|
||||
db.session.commit()
|
||||
flash(f"Ciudad eliminada: {c.city}", "warning")
|
||||
|
||||
elif action == "toggle_city":
|
||||
city_id = int(request.form.get("city_id"))
|
||||
c = SearchCity.query.get(city_id)
|
||||
if c:
|
||||
c.active = not c.active
|
||||
db.session.commit()
|
||||
|
||||
elif action == "save_config":
|
||||
max_price = request.form.get("max_price", "").strip()
|
||||
down_payment = request.form.get("down_payment", "").strip()
|
||||
if max_price and max_price.isdigit():
|
||||
set_config("max_price", max_price)
|
||||
if down_payment and down_payment.isdigit():
|
||||
set_config("down_payment", down_payment)
|
||||
if max_price or down_payment:
|
||||
flash("Preferencias guardadas", "success")
|
||||
|
||||
return redirect(url_for("settings"))
|
||||
|
||||
cities = SearchCity.query.order_by(SearchCity.city).all()
|
||||
max_price = int(get_config("max_price", DEFAULT_MAX_PRICE))
|
||||
down_payment = int(get_config("down_payment", DEFAULT_DOWN_PAYMENT))
|
||||
return render_template("settings.html",
|
||||
cities=cities, max_price=max_price,
|
||||
down_payment=down_payment, florida_cities=FLORIDA_CITIES
|
||||
)
|
||||
|
||||
|
||||
# ── Scan helpers ──────────────────────────────────────────────────────────────
|
||||
def _read_status():
|
||||
try:
|
||||
with open(STATUS_FILE, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {"running": False, "progress": ""}
|
||||
|
||||
def _save_results_to_db():
|
||||
"""Lee scan_results.json y guarda en la base de datos."""
|
||||
try:
|
||||
with open(RESULTS_FILE, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except Exception:
|
||||
return 0, {}
|
||||
new_count = 0
|
||||
for prop_data in data.get("properties", []):
|
||||
existing = Property.query.filter_by(
|
||||
address=prop_data.get("address", ""),
|
||||
source=prop_data.get("source", "")
|
||||
).first()
|
||||
if not existing and prop_data.get("address"):
|
||||
db.session.add(Property(
|
||||
source=prop_data.get("source"),
|
||||
address=prop_data.get("address", ""),
|
||||
city=prop_data.get("city", ""),
|
||||
county=prop_data.get("county", ""),
|
||||
zipcode=prop_data.get("zipcode", ""),
|
||||
price=prop_data.get("price", 0),
|
||||
beds=prop_data.get("beds"),
|
||||
baths=prop_data.get("baths"),
|
||||
sqft=prop_data.get("sqft"),
|
||||
property_type=prop_data.get("property_type", "Residential"),
|
||||
status=prop_data.get("status", ""),
|
||||
url=prop_data.get("url", ""),
|
||||
image_url=prop_data.get("image_url", ""),
|
||||
score=prop_data.get("score", 50)
|
||||
))
|
||||
new_count += 1
|
||||
for source, info in data.get("log", {}).items():
|
||||
db.session.add(ScrapeLog(
|
||||
source=source, found=info.get("found", 0),
|
||||
new=new_count, status=info.get("status", "ok"), message=str(info)
|
||||
))
|
||||
db.session.commit()
|
||||
return new_count, data.get("log", {})
|
||||
|
||||
|
||||
# ── Scan routes ────────────────────────────────────────────────────────────────
|
||||
@app.route("/scan", methods=["POST"])
|
||||
def scan():
|
||||
st = _read_status()
|
||||
if st.get("running"):
|
||||
return jsonify({"status": "already_running", "progress": st["progress"]})
|
||||
|
||||
active_cities = [c.city for c in SearchCity.query.filter_by(active=True).all()]
|
||||
max_price = int(get_config("max_price", DEFAULT_MAX_PRICE))
|
||||
cities_arg = ",".join(active_cities)
|
||||
|
||||
# Write initial status
|
||||
with open(STATUS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump({"running": True, "progress": "Iniciando..."}, f)
|
||||
|
||||
# Launch scan_runner.py as independent subprocess
|
||||
runner = os.path.join(BASE_DIR, "scan_runner.py")
|
||||
subprocess.Popen(
|
||||
[sys.executable, runner, "--cities", cities_arg, "--max_price", str(max_price)],
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
|
||||
)
|
||||
return jsonify({"status": "started", "cities": active_cities, "max_price": max_price})
|
||||
|
||||
|
||||
@app.route("/scan/status")
|
||||
def scan_status():
|
||||
st = _read_status()
|
||||
# If scan finished, import results to DB
|
||||
if not st.get("running") and st.get("progress", "").startswith("Completado") and os.path.exists(RESULTS_FILE):
|
||||
new_count, log = _save_results_to_db()
|
||||
# Mark results as imported
|
||||
try:
|
||||
os.rename(RESULTS_FILE, RESULTS_FILE + ".imported")
|
||||
except Exception:
|
||||
pass
|
||||
st["new"] = new_count
|
||||
st["log"] = log
|
||||
return jsonify(st)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=5105, debug=False, threaded=True)
|
||||
@@ -0,0 +1,69 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from datetime import datetime
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
class Property(db.Model):
|
||||
__tablename__ = 'properties'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
source = db.Column(db.String(50))
|
||||
address = db.Column(db.String(200))
|
||||
city = db.Column(db.String(100))
|
||||
county = db.Column(db.String(100))
|
||||
state = db.Column(db.String(10), default='FL')
|
||||
zipcode = db.Column(db.String(10))
|
||||
price = db.Column(db.Float)
|
||||
beds = db.Column(db.Integer)
|
||||
baths = db.Column(db.Float)
|
||||
sqft = db.Column(db.Integer)
|
||||
property_type = db.Column(db.String(50))
|
||||
status = db.Column(db.String(50))
|
||||
url = db.Column(db.Text)
|
||||
image_url = db.Column(db.Text)
|
||||
score = db.Column(db.Integer, default=0)
|
||||
ai_analysis = db.Column(db.Text)
|
||||
is_favorite = db.Column(db.Boolean, default=False)
|
||||
notes = db.Column(db.Text)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
class Lender(db.Model):
|
||||
__tablename__ = 'lenders'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100))
|
||||
loan_type = db.Column(db.String(100))
|
||||
specialties = db.Column(db.Text)
|
||||
min_down_pct = db.Column(db.Float)
|
||||
accepts_new_resident = db.Column(db.Boolean, default=True)
|
||||
accepts_self_employed = db.Column(db.Boolean, default=True)
|
||||
requires_2yr_history = db.Column(db.Boolean, default=False)
|
||||
phone = db.Column(db.String(30))
|
||||
email = db.Column(db.String(100))
|
||||
website = db.Column(db.String(200))
|
||||
contact_script = db.Column(db.Text)
|
||||
match_score = db.Column(db.Integer, default=0)
|
||||
notes = db.Column(db.Text)
|
||||
|
||||
class SearchCity(db.Model):
|
||||
__tablename__ = 'search_cities'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
city = db.Column(db.String(100), unique=True)
|
||||
search_type = db.Column(db.String(20), default='city') # city, county, zip
|
||||
active = db.Column(db.Boolean, default=True)
|
||||
added_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
class SearchConfig(db.Model):
|
||||
__tablename__ = 'search_config'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
key = db.Column(db.String(50), unique=True)
|
||||
value = db.Column(db.String(200))
|
||||
|
||||
class ScrapeLog(db.Model):
|
||||
__tablename__ = 'scrape_logs'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
ran_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
source = db.Column(db.String(50))
|
||||
found = db.Column(db.Integer, default=0)
|
||||
new = db.Column(db.Integer, default=0)
|
||||
status = db.Column(db.String(20))
|
||||
message = db.Column(db.Text)
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Proceso independiente que ejecuta el scan y escribe resultados a JSON.
|
||||
Llamado por Flask via subprocess.
|
||||
"""
|
||||
# -*- coding: utf-8 -*-
|
||||
import sys, io, json, os, argparse
|
||||
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
STATUS_FILE = os.path.join(BASE_DIR, "scan_status.json")
|
||||
RESULTS_FILE = os.path.join(BASE_DIR, "scan_results.json")
|
||||
|
||||
def write_status(msg, running=True):
|
||||
with open(STATUS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump({"running": running, "progress": msg}, f)
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--cities", default="Vero Beach,Jacksonville,Melbourne,Stuart,Daytona Beach,St. Augustine,Palm Coast,New Smyrna Beach")
|
||||
parser.add_argument("--max_price", type=int, default=230000)
|
||||
args = parser.parse_args()
|
||||
|
||||
cities = [c.strip() for c in args.cities.split(",") if c.strip()]
|
||||
max_price = args.max_price
|
||||
|
||||
write_status(f"Iniciando scan en {len(cities)} ciudades...")
|
||||
sys.path.insert(0, BASE_DIR)
|
||||
from scrapers import scrape_zillow, scrape_realtor, scrape_hud, scrape_homepath, score_property
|
||||
|
||||
all_results = []
|
||||
log = {}
|
||||
|
||||
# Zillow
|
||||
write_status(f"Zillow: buscando en {len(cities)} ciudades... (Chrome se abrira)")
|
||||
try:
|
||||
z_results = scrape_zillow(cities, max_price)
|
||||
log["Zillow"] = {"found": len(z_results), "status": "ok"}
|
||||
all_results.extend(z_results)
|
||||
write_status(f"Zillow: {len(z_results)} encontradas. Buscando en Realtor...")
|
||||
except Exception as e:
|
||||
log["Zillow"] = {"found": 0, "status": f"error: {e}"}
|
||||
write_status(f"Zillow error: {e}. Continuando con Realtor...")
|
||||
|
||||
# Realtor.com
|
||||
try:
|
||||
r_results = scrape_realtor(cities, max_price)
|
||||
log["Realtor.com"] = {"found": len(r_results), "status": "ok"}
|
||||
all_results.extend(r_results)
|
||||
write_status(f"Realtor: {len(r_results)} encontradas. Procesando...")
|
||||
except Exception as e:
|
||||
log["Realtor.com"] = {"found": 0, "status": f"error: {e}"}
|
||||
|
||||
# HUD + HomePath (rapidos, sin browser)
|
||||
try:
|
||||
h_results = scrape_hud(cities, max_price)
|
||||
log["HUD"] = {"found": len(h_results), "status": "ok"}
|
||||
all_results.extend(h_results)
|
||||
except Exception as e:
|
||||
log["HUD"] = {"found": 0, "status": f"error: {e}"}
|
||||
|
||||
try:
|
||||
hp_results = scrape_homepath(cities, max_price)
|
||||
log["HomePath"] = {"found": len(hp_results), "status": "ok"}
|
||||
all_results.extend(hp_results)
|
||||
except Exception as e:
|
||||
log["HomePath"] = {"found": 0, "status": f"error: {e}"}
|
||||
|
||||
# Deduplicar
|
||||
seen, unique = set(), []
|
||||
for r in all_results:
|
||||
key = (r.get("address","").lower().strip(), r.get("price",0))
|
||||
if key[0] and key not in seen:
|
||||
seen.add(key)
|
||||
r["score"] = score_property(r, cities, max_price)
|
||||
unique.append(r)
|
||||
|
||||
unique.sort(key=lambda x: x.get("score", 0), reverse=True)
|
||||
|
||||
with open(RESULTS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump({"properties": unique, "log": log, "total": len(unique)}, f, ensure_ascii=False)
|
||||
|
||||
write_status(
|
||||
f"Completado: {len(unique)} propiedades unicas encontradas",
|
||||
running=False
|
||||
)
|
||||
print(f"SCAN DONE: {len(unique)} propiedades")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
{"running": false, "progress": "Completado: 179 propiedades unicas encontradas"}
|
||||
@@ -0,0 +1,461 @@
|
||||
import re, json, time, random, shutil, os
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from datetime import datetime
|
||||
|
||||
# ── Config ────────────────────────────────────────────────────────────────────
|
||||
CHROME_PATH = r"C:\Program Files\Google\Chrome\Application\chrome.exe"
|
||||
CHROME_PROFILE = r"C:\Users\aerom\AppData\Local\Google\Chrome\User Data"
|
||||
TEMP_PROFILE = r"C:\Temp\chrome_casa_hunter"
|
||||
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
}
|
||||
|
||||
CITY_COUNTY_MAP = {
|
||||
"vero beach": "Indian River", "sebastian": "Indian River",
|
||||
"fellsmere": "Indian River", "indian river shores": "Indian River",
|
||||
"stuart": "Martin", "jensen beach": "Martin", "hobe sound": "Martin",
|
||||
"palm city": "Martin", "port salerno": "Martin",
|
||||
"fort pierce": "St. Lucie", "port st. lucie": "St. Lucie",
|
||||
"melbourne": "Brevard", "palm bay": "Brevard", "titusville": "Brevard",
|
||||
"cocoa": "Brevard", "cocoa beach": "Brevard", "rockledge": "Brevard",
|
||||
"merritt island": "Brevard", "cape canaveral": "Brevard",
|
||||
"satellite beach": "Brevard", "west melbourne": "Brevard",
|
||||
"daytona beach": "Volusia", "ormond beach": "Volusia",
|
||||
"new smyrna beach": "Volusia", "edgewater": "Volusia",
|
||||
"port orange": "Volusia", "deltona": "Volusia",
|
||||
"palm coast": "Flagler", "flagler beach": "Flagler", "bunnell": "Flagler",
|
||||
"st. augustine": "St. Johns", "ponte vedra beach": "St. Johns",
|
||||
"nocatee": "St. Johns", "st. augustine beach": "St. Johns",
|
||||
"jacksonville": "Duval", "jacksonville beach": "Duval",
|
||||
"atlantic beach": "Duval", "neptune beach": "Duval",
|
||||
"fernandina beach": "Nassau", "yulee": "Nassau",
|
||||
}
|
||||
|
||||
COUNTY_CODES = {
|
||||
"Brevard": "9", "Duval": "31", "Flagler": "35", "Indian River": "61",
|
||||
"Martin": "86", "Nassau": "89", "St. Johns": "109", "St. Lucie": "111",
|
||||
"Volusia": "127",
|
||||
}
|
||||
|
||||
|
||||
def get_county_for_city(city: str) -> str:
|
||||
return CITY_COUNTY_MAP.get(city.lower().strip(), "")
|
||||
|
||||
|
||||
def score_property(prop: dict, search_cities: list, max_price: int) -> int:
|
||||
score = 40
|
||||
price = prop.get("price", 0)
|
||||
if not price or price <= 0:
|
||||
return 0
|
||||
|
||||
ratio = price / max_price
|
||||
if ratio <= 0.60:
|
||||
score += 35
|
||||
elif ratio <= 0.75:
|
||||
score += 25
|
||||
elif ratio <= 0.90:
|
||||
score += 15
|
||||
elif ratio <= 1.0:
|
||||
score += 8
|
||||
|
||||
city = (prop.get("city") or "").lower()
|
||||
county = (prop.get("county") or "").lower()
|
||||
for s in [c.lower() for c in search_cities]:
|
||||
if s in city or s in county or city in s or county in s:
|
||||
score += 12
|
||||
break
|
||||
|
||||
beds = prop.get("beds") or 0
|
||||
if beds >= 3:
|
||||
score += 8
|
||||
elif beds >= 2:
|
||||
score += 4
|
||||
|
||||
status = (prop.get("status") or "").lower()
|
||||
if any(w in status for w in ["foreclosure", "reo", "bank owned", "hud", "price reduced"]):
|
||||
score += 10
|
||||
elif any(w in status for w in ["new construction", "newly built"]):
|
||||
score += 5
|
||||
|
||||
return min(score, 100)
|
||||
|
||||
|
||||
# ── Chrome profile setup ───────────────────────────────────────────────────────
|
||||
def ensure_chrome_profile():
|
||||
"""Copia el perfil de Chrome si no existe el temporal."""
|
||||
if os.path.exists(os.path.join(TEMP_PROFILE, "Default")):
|
||||
return True
|
||||
if not os.path.exists(CHROME_PROFILE):
|
||||
return False
|
||||
try:
|
||||
os.makedirs(os.path.join(TEMP_PROFILE, "Default"), exist_ok=True)
|
||||
src = os.path.join(CHROME_PROFILE, "Default")
|
||||
dst = os.path.join(TEMP_PROFILE, "Default")
|
||||
for item in ["Cookies", "Login Data", "Web Data", "Preferences"]:
|
||||
s = os.path.join(src, item)
|
||||
if os.path.exists(s):
|
||||
shutil.copy2(s, dst)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" Profile copy warning: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ── Playwright helpers ─────────────────────────────────────────────────────────
|
||||
def _hd(a=1.2, b=3.0):
|
||||
time.sleep(random.uniform(a, b))
|
||||
|
||||
def _scroll(page, steps=4):
|
||||
for _ in range(steps):
|
||||
page.mouse.wheel(0, random.randint(250, 600))
|
||||
time.sleep(random.uniform(0.4, 0.9))
|
||||
|
||||
def _type_human(page, text):
|
||||
for ch in text:
|
||||
page.keyboard.type(ch)
|
||||
time.sleep(random.uniform(0.07, 0.16))
|
||||
|
||||
def _parse_zillow_html(html, min_p=40000, max_p=230000):
|
||||
results = []
|
||||
m = re.search(r'<script[^>]*id="__NEXT_DATA__"[^>]*>(.*?)</script>', html, re.DOTALL)
|
||||
if not m:
|
||||
return results
|
||||
try:
|
||||
data = json.loads(m.group(1))
|
||||
list_results = (data.get("props", {}).get("pageProps", {})
|
||||
.get("searchPageState", {}).get("cat1", {})
|
||||
.get("searchResults", {}).get("listResults", []))
|
||||
for p in list_results:
|
||||
price = p.get("unformattedPrice", 0)
|
||||
if min_p <= price <= max_p:
|
||||
city_val = p.get("addressCity", "")
|
||||
results.append({
|
||||
"source": "Zillow",
|
||||
"address": p.get("address", ""),
|
||||
"price": price,
|
||||
"beds": p.get("beds", 0),
|
||||
"baths": p.get("baths", 0),
|
||||
"sqft": p.get("area", 0),
|
||||
"city": city_val,
|
||||
"state": p.get("addressState", "FL"),
|
||||
"county": get_county_for_city(city_val),
|
||||
"zipcode": str(p.get("addressZipcode", "")),
|
||||
"status": p.get("statusType", "For Sale"),
|
||||
"url": "https://www.zillow.com" + p.get("detailUrl", ""),
|
||||
"image_url": p.get("imgSrc", ""),
|
||||
"property_type": p.get("hdpData", {}).get("homeInfo", {}).get("homeType", ""),
|
||||
})
|
||||
except Exception as e:
|
||||
print(f" JSON parse error: {e}")
|
||||
return results
|
||||
|
||||
|
||||
# ── Zillow via Playwright + Chrome profile ────────────────────────────────────
|
||||
def scrape_zillow(cities: list, max_price: int) -> list:
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError:
|
||||
print(" Playwright no instalado — saltando Zillow")
|
||||
return []
|
||||
|
||||
ensure_chrome_profile()
|
||||
all_results = []
|
||||
MIN_PRICE = 40000
|
||||
|
||||
with sync_playwright() as p:
|
||||
ctx = p.chromium.launch_persistent_context(
|
||||
user_data_dir=TEMP_PROFILE,
|
||||
executable_path=CHROME_PATH,
|
||||
headless=False,
|
||||
args=[
|
||||
"--profile-directory=Default",
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--start-maximized",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
],
|
||||
viewport={"width": 1366, "height": 768},
|
||||
)
|
||||
page = ctx.new_page()
|
||||
|
||||
for city in cities:
|
||||
city_q = f"{city}, FL"
|
||||
print(f" Zillow: buscando {city_q}...")
|
||||
try:
|
||||
page.goto("https://www.zillow.com", wait_until="load", timeout=30000)
|
||||
_hd(1.5, 2.5)
|
||||
|
||||
search = page.query_selector(
|
||||
"#search-box-input, input[id*='search'], "
|
||||
"input[placeholder*='address'], input[placeholder*='city']"
|
||||
)
|
||||
if search:
|
||||
search.click()
|
||||
_hd(0.3, 0.6)
|
||||
page.keyboard.down("Control")
|
||||
page.keyboard.press("a")
|
||||
page.keyboard.up("Control")
|
||||
page.keyboard.press("Delete")
|
||||
_hd(0.2, 0.4)
|
||||
_type_human(page, city_q)
|
||||
_hd(0.8, 1.5)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_load_state("load", timeout=30000)
|
||||
_hd(2, 3)
|
||||
_scroll(page, 4)
|
||||
_hd(1, 2)
|
||||
else:
|
||||
# fallback: URL directa
|
||||
slug = re.sub(r"[^a-z0-9\s-]", "", city.lower()).strip().replace(" ", "-")
|
||||
url = (f"https://www.zillow.com/homes/for_sale/{slug}-fl/"
|
||||
f"?searchQueryState=%7B%22filterState%22%3A%7B"
|
||||
f"%22price%22%3A%7B%22max%22%3A{max_price}%2C%22min%22%3A{MIN_PRICE}%7D%7D%7D")
|
||||
page.goto(url, wait_until="load", timeout=45000)
|
||||
_hd(2, 3)
|
||||
_scroll(page, 4)
|
||||
|
||||
html = page.content()
|
||||
title = page.title()
|
||||
# Si Cloudflare bloqueó, esperar hasta que el usuario lo resuelva (max 90s)
|
||||
if "denied" in title.lower() or "px-captcha" in html or "cf-browser-verification" in html:
|
||||
print(f" >> Cloudflare en {city}: resuelve el challenge en el browser (90s max)...")
|
||||
deadline = time.time() + 90
|
||||
while time.time() < deadline:
|
||||
time.sleep(4)
|
||||
html = page.content()
|
||||
t2 = page.title()
|
||||
if "denied" not in t2.lower() and "px-captcha" not in html:
|
||||
print(f" Challenge resuelto!")
|
||||
break
|
||||
else:
|
||||
print(f" Timeout esperando challenge - saltando {city}")
|
||||
continue
|
||||
|
||||
listings = _parse_zillow_html(html, MIN_PRICE, max_price)
|
||||
print(f" -> {len(listings)} encontradas")
|
||||
all_results.extend(listings)
|
||||
|
||||
except Exception as e:
|
||||
print(f" ERROR {city}: {e}")
|
||||
|
||||
_hd(4, 7) # pausa entre ciudades para evitar bloqueo
|
||||
|
||||
ctx.close()
|
||||
|
||||
# Deduplicar
|
||||
seen, unique = set(), []
|
||||
for r in all_results:
|
||||
key = r["address"].lower().strip()
|
||||
if key and key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(r)
|
||||
return unique
|
||||
|
||||
|
||||
# ── Realtor.com via Playwright + Chrome profile ───────────────────────────────
|
||||
def scrape_realtor(cities: list, max_price: int) -> list:
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError:
|
||||
return []
|
||||
|
||||
ensure_chrome_profile()
|
||||
all_results = []
|
||||
|
||||
with sync_playwright() as p:
|
||||
ctx = p.chromium.launch_persistent_context(
|
||||
user_data_dir=TEMP_PROFILE,
|
||||
executable_path=CHROME_PATH,
|
||||
headless=False,
|
||||
args=[
|
||||
"--profile-directory=Default",
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--start-maximized",
|
||||
"--no-first-run",
|
||||
],
|
||||
viewport={"width": 1366, "height": 768},
|
||||
)
|
||||
page = ctx.new_page()
|
||||
|
||||
for city in cities:
|
||||
city_slug = re.sub(r"[^a-z0-9\s]", "", city.lower()).strip().replace(" ", "_")
|
||||
url = f"https://www.realtor.com/realestateandhomes-search/{city_slug}_FL/price-na-{max_price}"
|
||||
print(f" Realtor.com: buscando {city}...")
|
||||
try:
|
||||
page.goto(url, wait_until="load", timeout=45000)
|
||||
_hd(2, 3)
|
||||
_scroll(page, 4)
|
||||
_hd(1, 2)
|
||||
|
||||
html = page.content()
|
||||
m = re.search(r'<script[^>]*id="__NEXT_DATA__"[^>]*>(.*?)</script>', html, re.DOTALL)
|
||||
if not m:
|
||||
continue
|
||||
data = json.loads(m.group(1))
|
||||
properties = (data.get("props", {}).get("pageProps", {})
|
||||
.get("properties", []))
|
||||
for item in properties:
|
||||
price = item.get("list_price", 0)
|
||||
if not isinstance(price, (int, float)):
|
||||
continue
|
||||
price = int(price)
|
||||
if 40000 <= price <= max_price:
|
||||
loc = item.get("location", {}).get("address", {})
|
||||
city_val = loc.get("city", city)
|
||||
desc = item.get("description", {})
|
||||
all_results.append({
|
||||
"source": "Realtor.com",
|
||||
"address": loc.get("line", ""),
|
||||
"city": city_val,
|
||||
"county": get_county_for_city(city_val),
|
||||
"zipcode": str(loc.get("postal_code", "")),
|
||||
"price": price,
|
||||
"beds": desc.get("beds", 0),
|
||||
"baths": desc.get("baths_consolidated", 0),
|
||||
"sqft": desc.get("sqft", 0),
|
||||
"status": item.get("status", "For Sale"),
|
||||
"url": "https://www.realtor.com" + item.get("permalink", ""),
|
||||
"image_url": item.get("primary_photo", {}).get("href", ""),
|
||||
"property_type": desc.get("type", ""),
|
||||
})
|
||||
print(f" -> {len(properties)} revisadas")
|
||||
|
||||
except Exception as e:
|
||||
print(f" ERROR {city}: {e}")
|
||||
|
||||
_hd(3, 5)
|
||||
|
||||
ctx.close()
|
||||
|
||||
seen, unique = set(), []
|
||||
for r in all_results:
|
||||
key = r["address"].lower().strip()
|
||||
if key and key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(r)
|
||||
return unique
|
||||
|
||||
|
||||
# ── HUD Homes (requests - gobierno, sin anti-bot) ─────────────────────────────
|
||||
def scrape_hud(cities: list, max_price: int) -> list:
|
||||
results = []
|
||||
counties = set()
|
||||
for city in cities:
|
||||
c = get_county_for_city(city)
|
||||
if c:
|
||||
counties.add(c)
|
||||
if not counties:
|
||||
counties = {"Brevard", "Indian River", "Duval", "St. Johns", "Volusia"}
|
||||
|
||||
for county in list(counties)[:6]:
|
||||
code = COUNTY_CODES.get(county, "")
|
||||
if not code:
|
||||
continue
|
||||
url = (f"https://www.hudhomestore.gov/HudHomes/Index.aspx"
|
||||
f"?sState=FL&sCounty={code}&sPriceMax={max_price}&sPriceMin=30000")
|
||||
try:
|
||||
r = requests.get(url, headers=HEADERS, timeout=15)
|
||||
soup = BeautifulSoup(r.text, "html.parser")
|
||||
rows = soup.select("tr.propRow, .property-row, tr[id^='prop']")
|
||||
for row in rows[:8]:
|
||||
text = row.get_text(" ", strip=True)
|
||||
price_m = re.search(r'\$[\d,]+', text)
|
||||
if not price_m:
|
||||
continue
|
||||
price = int(re.sub(r'[^\d]', '', price_m.group()))
|
||||
if 0 < price <= max_price:
|
||||
addr_el = row.select_one("a")
|
||||
prop = {
|
||||
"source": "HUD Homes",
|
||||
"address": addr_el.get_text(strip=True) if addr_el else text[:80],
|
||||
"city": county, "county": county,
|
||||
"price": price, "url": url,
|
||||
"status": "HUD Foreclosure",
|
||||
}
|
||||
prop["score"] = score_property(prop, cities, max_price)
|
||||
results.append(prop)
|
||||
except Exception as e:
|
||||
print(f" HUD {county}: {e}")
|
||||
return results
|
||||
|
||||
|
||||
# ── Fannie Mae HomePath (REO) ──────────────────────────────────────────────────
|
||||
def scrape_homepath(cities: list, max_price: int) -> list:
|
||||
results = []
|
||||
for term in cities[:8]:
|
||||
url = (f"https://www.homepath.fanniemae.com/listings"
|
||||
f"?searchTerm={requests.utils.quote(term + ' FL')}"
|
||||
f"&maxPrice={max_price}&state=FL")
|
||||
try:
|
||||
r = requests.get(url, headers=HEADERS, timeout=15)
|
||||
data = r.json() if "json" in r.headers.get("content-type", "") else {}
|
||||
listings = data.get("listings", data.get("results", []))
|
||||
for item in listings[:6]:
|
||||
price = item.get("listPrice", item.get("price", 0))
|
||||
if 0 < price <= max_price:
|
||||
city_val = item.get("city", term)
|
||||
prop = {
|
||||
"source": "Fannie Mae HomePath",
|
||||
"address": item.get("address", item.get("streetAddress", "")),
|
||||
"city": city_val,
|
||||
"county": get_county_for_city(city_val),
|
||||
"zipcode": str(item.get("postalCode", "")),
|
||||
"price": price,
|
||||
"beds": item.get("bedrooms", 0),
|
||||
"baths": item.get("bathrooms", 0),
|
||||
"sqft": item.get("squareFeet", 0),
|
||||
"url": f"https://www.homepath.fanniemae.com/listings/{item.get('id','')}",
|
||||
"status": "Fannie Mae REO",
|
||||
}
|
||||
prop["score"] = score_property(prop, cities, max_price)
|
||||
results.append(prop)
|
||||
except Exception as e:
|
||||
print(f" HomePath {term}: {e}")
|
||||
return results
|
||||
|
||||
|
||||
# ── Main runner ───────────────────────────────────────────────────────────────
|
||||
def run_all_scrapers(cities: list = None, max_price: int = 230000) -> dict:
|
||||
if not cities:
|
||||
cities = ["Vero Beach", "Jacksonville", "Melbourne", "St. Augustine"]
|
||||
|
||||
all_props = []
|
||||
log = {}
|
||||
|
||||
sources = {
|
||||
"Zillow (Browser)": lambda: scrape_zillow(cities, max_price),
|
||||
"Realtor.com (Browser)": lambda: scrape_realtor(cities, max_price),
|
||||
"HUD Homes": lambda: scrape_hud(cities, max_price),
|
||||
"Fannie Mae HomePath": lambda: scrape_homepath(cities, max_price),
|
||||
}
|
||||
|
||||
for name, fn in sources.items():
|
||||
try:
|
||||
print(f"\n[{name}]")
|
||||
props = fn()
|
||||
seen, unique = set(), []
|
||||
for p in props:
|
||||
key = ((p.get("address") or "").lower().strip(), p.get("price", 0))
|
||||
if key[0] and key not in seen:
|
||||
seen.add(key)
|
||||
p["score"] = score_property(p, cities, max_price)
|
||||
unique.append(p)
|
||||
all_props.extend(unique)
|
||||
log[name] = {"found": len(unique), "status": "ok"}
|
||||
print(f" -> {len(unique)} propiedades validas")
|
||||
except Exception as e:
|
||||
log[name] = {"found": 0, "status": f"error: {e}"}
|
||||
print(f" ERROR {name}: {e}")
|
||||
|
||||
all_props.sort(key=lambda x: x.get("score", 0), reverse=True)
|
||||
return {
|
||||
"properties": all_props,
|
||||
"log": log,
|
||||
"cities_searched": cities,
|
||||
"max_price": max_price,
|
||||
"ran_at": datetime.utcnow().isoformat()
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Casa Hunter FL{% endblock %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--primary: #1a3a5c;
|
||||
--accent: #e8a020;
|
||||
--light-bg: #f4f7fb;
|
||||
}
|
||||
body { background: var(--light-bg); font-family: 'Segoe UI', sans-serif; }
|
||||
.navbar { background: var(--primary) !important; }
|
||||
.navbar-brand { color: var(--accent) !important; font-weight: 700; font-size: 1.3rem; }
|
||||
.nav-link { color: rgba(255,255,255,.85) !important; }
|
||||
.nav-link:hover, .nav-link.active { color: var(--accent) !important; }
|
||||
.card { border: none; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,.07); }
|
||||
.score-badge { font-size: .75rem; font-weight: 700; padding: 4px 10px; border-radius: 20px; }
|
||||
.score-high { background: #d4edda; color: #155724; }
|
||||
.score-mid { background: #fff3cd; color: #856404; }
|
||||
.score-low { background: #f8d7da; color: #721c24; }
|
||||
.prop-price { font-size: 1.4rem; font-weight: 700; color: var(--primary); }
|
||||
.source-tag { font-size: .7rem; background: var(--primary); color: #fff; padding: 2px 8px; border-radius: 10px; }
|
||||
.lender-card .match-pct { font-size: 2rem; font-weight: 800; color: var(--accent); }
|
||||
.btn-primary { background: var(--primary); border-color: var(--primary); }
|
||||
.btn-primary:hover { background: #0f2540; }
|
||||
.btn-accent { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.stat-card { text-align: center; padding: 1.2rem; }
|
||||
.stat-card .number { font-size: 2rem; font-weight: 800; color: var(--primary); }
|
||||
.stat-card .label { font-size: .8rem; color: #6c757d; text-transform: uppercase; letter-spacing: 1px; }
|
||||
.action-kit { background: #e8f4fd; border-left: 4px solid var(--primary); border-radius: 8px; padding: 1rem; }
|
||||
footer { background: var(--primary); color: rgba(255,255,255,.6); padding: 1rem 0; margin-top: 3rem; font-size: .85rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="/"><i class="fas fa-home me-2"></i>Casa Hunter FL</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#nav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="nav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item"><a class="nav-link {% if request.path=='/' %}active{% endif %}" href="/"><i class="fas fa-chart-line me-1"></i>Dashboard</a></li>
|
||||
<li class="nav-item"><a class="nav-link {% if '/properties' in request.path %}active{% endif %}" href="/properties"><i class="fas fa-building me-1"></i>Propiedades</a></li>
|
||||
<li class="nav-item"><a class="nav-link {% if '/lenders' in request.path %}active{% endif %}" href="/lenders"><i class="fas fa-handshake me-1"></i>Lenders</a></li>
|
||||
<li class="nav-item"><a class="nav-link {% if '/settings' in request.path %}active{% endif %}" href="/settings"><i class="fas fa-sliders-h me-1"></i>Preferencias</a></li>
|
||||
<li class="nav-item ms-2">
|
||||
<button class="btn btn-sm btn-accent" onclick="runScan()"><i class="fas fa-sync me-1"></i>Buscar Ahora</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container my-4">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ cat }} alert-dismissible fade show"><i class="fas fa-info-circle me-2"></i>{{ msg }}<button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<footer><div class="container text-center">Casa Hunter FL — Tu buscador personal de oportunidades inmobiliarias • Prisa Yachts LLC</div></footer>
|
||||
|
||||
<div class="modal fade" id="scanModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-body text-center py-4">
|
||||
<div class="spinner-border text-primary mb-3"></div>
|
||||
<p class="mb-1 fw-bold">Buscando oportunidades...</p>
|
||||
<small class="text-muted">HUD · Fannie Mae · Zillow · Remates</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
let _scanPoll = null;
|
||||
|
||||
function runScan() {
|
||||
const btn = document.querySelector('[onclick="runScan()"]');
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Buscando...'; }
|
||||
const prog = document.getElementById('scan-progress');
|
||||
const msg = document.getElementById('scan-msg');
|
||||
if (prog) prog.style.display = 'block';
|
||||
if (msg) msg.textContent = 'Abriendo Chrome y buscando propiedades...';
|
||||
|
||||
fetch('/scan', {method:'POST'})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (msg) msg.textContent = data.status === 'already_running'
|
||||
? 'Ya hay un scan en progreso...'
|
||||
: 'Chrome abierto — buscando en ' + (data.cities || []).length + ' ciudades...';
|
||||
_scanPoll = setInterval(pollScanStatus, 5000);
|
||||
})
|
||||
.catch(e => {
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-search me-2"></i>Buscar Ahora'; }
|
||||
if (msg) msg.textContent = 'Error: ' + e;
|
||||
});
|
||||
}
|
||||
|
||||
function pollScanStatus() {
|
||||
fetch('/scan/status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const msg = document.getElementById('scan-msg');
|
||||
const bar = document.getElementById('scan-bar');
|
||||
const btn = document.querySelector('[onclick="runScan()"]');
|
||||
if (msg) msg.textContent = data.progress || 'Buscando...';
|
||||
if (!data.running) {
|
||||
clearInterval(_scanPoll);
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-search me-2"></i>Buscar Ahora'; }
|
||||
if (bar) bar.classList.remove('progress-bar-animated');
|
||||
if (data.new > 0) {
|
||||
if (msg) msg.textContent = data.new + ' propiedades nuevas! Recargando...';
|
||||
setTimeout(() => location.reload(), 2000);
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
const p = document.getElementById('scan-progress');
|
||||
if (p) p.style.display = 'none';
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,186 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard — Casa Hunter FL{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2 class="fw-bold mb-1" style="color:var(--primary)"><i class="fas fa-map-marker-alt me-2" style="color:var(--accent)"></i>Oportunidades en Costa de Florida</h2>
|
||||
<p class="text-muted mb-0">Stuart → Vero Beach → Melbourne → Daytona → St. Augustine → Jacksonville • Hasta $200,000</p>
|
||||
</div>
|
||||
<button class="btn btn-accent" onclick="runScan()"><i class="fas fa-search me-2"></i>Buscar Ahora</button>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card stat-card">
|
||||
<div class="number">{{ total }}</div>
|
||||
<div class="label">Propiedades</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card stat-card">
|
||||
<div class="number">{{ favorites }}</div>
|
||||
<div class="label">Favoritas</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card stat-card">
|
||||
<div class="number">$50K</div>
|
||||
<div class="label">Down Payment</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card stat-card">
|
||||
<div class="number">8</div>
|
||||
<div class="label">Lenders Listos</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Properties -->
|
||||
<h5 class="fw-bold mb-3" style="color:var(--primary)"><i class="fas fa-star me-2" style="color:var(--accent)"></i>Mejores Oportunidades</h5>
|
||||
{% if properties %}
|
||||
<div class="row g-3 mb-4">
|
||||
{% for p in properties %}
|
||||
<div class="col-12 col-md-6 col-lg-4">
|
||||
<div class="card h-100">
|
||||
{% if p.image_url %}
|
||||
<img src="{{ p.image_url }}" class="card-img-top" style="height:160px;object-fit:cover;border-radius:12px 12px 0 0" alt="">
|
||||
{% else %}
|
||||
<div style="height:100px;background:linear-gradient(135deg,#1a3a5c,#2d6a9f);border-radius:12px 12px 0 0;display:flex;align-items:center;justify-content:center">
|
||||
<i class="fas fa-home fa-2x text-white opacity-50"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<span class="source-tag">{{ p.source }}</span>
|
||||
<span class="score-badge {% if p.score >= 75 %}score-high{% elif p.score >= 50 %}score-mid{% else %}score-low{% endif %}">
|
||||
Score: {{ p.score }}/100
|
||||
</span>
|
||||
</div>
|
||||
<p class="prop-price mb-1">${{ "{:,.0f}".format(p.price) }}</p>
|
||||
<p class="text-muted small mb-1"><i class="fas fa-map-marker-alt me-1"></i>{{ p.address }}</p>
|
||||
<p class="text-muted small mb-2">{{ p.city }}{% if p.county %}, {{ p.county }} Co.{% endif %}</p>
|
||||
<div class="d-flex gap-2 text-muted small mb-3">
|
||||
{% if p.beds %}<span><i class="fas fa-bed me-1"></i>{{ p.beds }} hab.</span>{% endif %}
|
||||
{% if p.baths %}<span><i class="fas fa-bath me-1"></i>{{ p.baths }} baños</span>{% endif %}
|
||||
{% if p.sqft %}<span><i class="fas fa-ruler-combined me-1"></i>{{ p.sqft }} sf</span>{% endif %}
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="/property/{{ p.id }}" class="btn btn-primary btn-sm flex-fill">Ver Detalle</a>
|
||||
{% if p.url %}
|
||||
<a href="{{ p.url }}" target="_blank" class="btn btn-outline-secondary btn-sm"><i class="fas fa-external-link-alt"></i></a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<a href="/properties" class="btn btn-outline-primary">Ver todas las propiedades <i class="fas fa-arrow-right ms-1"></i></a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card text-center py-5">
|
||||
<div class="card-body">
|
||||
<i class="fas fa-search fa-3x mb-3" style="color:var(--accent)"></i>
|
||||
<h5>No hay propiedades aún</h5>
|
||||
<p class="text-muted">Haz clic en <strong>Buscar Ahora</strong> para encontrar oportunidades en Florida</p>
|
||||
<button class="btn btn-primary" onclick="runScan()"><i class="fas fa-sync me-2"></i>Iniciar Búsqueda</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Top Lenders -->
|
||||
<h5 class="fw-bold mt-4 mb-3" style="color:var(--primary)"><i class="fas fa-handshake me-2" style="color:var(--accent)"></i>Lenders Recomendados para Tu Perfil</h5>
|
||||
<div class="row g-3">
|
||||
{% for l in top_lenders %}
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card lender-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="fw-bold mb-0" style="color:var(--primary)">{{ l.name }}</h6>
|
||||
<span class="match-pct">{{ l.match_score }}%</span>
|
||||
</div>
|
||||
<span class="badge bg-light text-dark border mb-2">{{ l.loan_type }}</span>
|
||||
<p class="text-muted small">{{ l.specialties[:100] }}{% if l.specialties|length > 100 %}...{% endif %}</p>
|
||||
<a href="/lenders" class="btn btn-sm btn-primary w-100">Ver Script de Contacto</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if last_scan %}
|
||||
<p class="text-muted text-end small mt-3"><i class="fas fa-clock me-1"></i>Última búsqueda: {{ last_scan.ran_at.strftime('%d/%m/%Y %H:%M') }}</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Scan progress bar -->
|
||||
<div id="scan-progress" class="mt-3" style="display:none">
|
||||
<div class="alert alert-info d-flex align-items-center gap-2">
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
<span id="scan-msg">Iniciando búsqueda...</span>
|
||||
</div>
|
||||
<div class="progress" style="height:6px">
|
||||
<div id="scan-bar" class="progress-bar progress-bar-striped progress-bar-animated bg-success" style="width:100%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
let _scanPoll = null;
|
||||
|
||||
function runScan() {
|
||||
const btn = document.querySelector('[onclick="runScan()"]');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Buscando...';
|
||||
document.getElementById('scan-progress').style.display = 'block';
|
||||
document.getElementById('scan-msg').textContent = 'Abriendo Chrome y buscando propiedades...';
|
||||
|
||||
fetch('/scan', {method:'POST'})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'already_running') {
|
||||
document.getElementById('scan-msg').textContent = 'Ya hay un scan en progreso...';
|
||||
}
|
||||
// Poll for status every 5 seconds
|
||||
_scanPoll = setInterval(pollScanStatus, 5000);
|
||||
})
|
||||
.catch(e => {
|
||||
document.getElementById('scan-msg').textContent = 'Error iniciando scan: ' + e;
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-search me-2"></i>Buscar Ahora';
|
||||
});
|
||||
}
|
||||
|
||||
function pollScanStatus() {
|
||||
fetch('/scan/status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
document.getElementById('scan-msg').textContent = data.progress || 'Buscando...';
|
||||
if (!data.running) {
|
||||
clearInterval(_scanPoll);
|
||||
const btn = document.querySelector('[onclick="runScan()"]');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-search me-2"></i>Buscar Ahora';
|
||||
if (data.new > 0) {
|
||||
document.getElementById('scan-msg').textContent =
|
||||
`✓ ${data.new} propiedades nuevas encontradas. Recargando...`;
|
||||
setTimeout(() => location.reload(), 2000);
|
||||
} else {
|
||||
document.getElementById('scan-msg').textContent =
|
||||
data.progress || 'Busqueda completada.';
|
||||
document.getElementById('scan-bar').classList.remove('progress-bar-animated');
|
||||
setTimeout(() => {
|
||||
document.getElementById('scan-progress').style.display = 'none';
|
||||
}, 4000);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,104 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Lenders — Casa Hunter FL{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<h2 class="fw-bold mb-1" style="color:var(--primary)"><i class="fas fa-handshake me-2" style="color:var(--accent)"></i>Lenders Recomendados</h2>
|
||||
<p class="text-muted mb-4">Ordenados por compatibilidad con tu perfil: residente nuevo, self-employed, $50K down, hasta $200K</p>
|
||||
|
||||
<div class="row g-3">
|
||||
{% for l in lenders %}
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<div>
|
||||
<h5 class="fw-bold mb-0" style="color:var(--primary)">{{ l.name }}</h5>
|
||||
<span class="badge border text-dark bg-light">{{ l.loan_type }}</span>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<div class="lender-card"><span class="match-pct">{{ l.match_score }}%</span></div>
|
||||
<small class="text-muted">compatibilidad</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="small text-muted mb-2"><i class="fas fa-check-circle text-success me-1"></i>{{ l.specialties }}</p>
|
||||
|
||||
<div class="row text-center g-1 mb-3">
|
||||
<div class="col-4">
|
||||
<div class="bg-light rounded p-1">
|
||||
<small class="text-muted d-block">Down mín.</small>
|
||||
<strong class="small">{{ l.min_down_pct|int }}%</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="bg-light rounded p-1">
|
||||
<small class="text-muted d-block">Residente nuevo</small>
|
||||
<strong class="small">{% if l.accepts_new_resident %}<i class="fas fa-check text-success"></i>{% else %}<i class="fas fa-times text-danger"></i>{% endif %}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="bg-light rounded p-1">
|
||||
<small class="text-muted d-block">Self-employed</small>
|
||||
<strong class="small">{% if l.accepts_self_employed %}<i class="fas fa-check text-success"></i>{% else %}<i class="fas fa-times text-danger"></i>{% endif %}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if l.notes %}
|
||||
<p class="small text-muted mb-2"><i class="fas fa-info-circle me-1"></i>{{ l.notes }}</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Contact Script -->
|
||||
<div class="action-kit small mb-3">
|
||||
<strong><i class="fas fa-phone me-1"></i>Guión de contacto:</strong><br>
|
||||
{{ l.contact_script }}
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ l.website }}" target="_blank" class="btn btn-primary btn-sm flex-fill">
|
||||
<i class="fas fa-globe me-1"></i>Visitar Web
|
||||
</a>
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="copyText('script-{{ l.id }}')">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
<span id="script-{{ l.id }}" class="d-none">{{ l.contact_script }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- My Profile Box -->
|
||||
<div class="card mt-4" style="border-left:4px solid var(--accent)">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-3" style="color:var(--primary)"><i class="fas fa-user me-2" style="color:var(--accent)"></i>Tu Perfil — Lo que presentas a cada lender</h6>
|
||||
<div class="row g-2 small">
|
||||
<div class="col-12 col-md-6">
|
||||
<ul class="mb-0">
|
||||
<li><strong>Estatus:</strong> Residente permanente</li>
|
||||
<li><strong>Tiempo en USA:</strong> Menos de 2 años</li>
|
||||
<li><strong>Trabajo:</strong> Contratista independiente — Eléctrica marina/naval</li>
|
||||
<li><strong>Tarifa:</strong> $120/hora</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<ul class="mb-0">
|
||||
<li><strong>Down payment disponible:</strong> $50,000</li>
|
||||
<li><strong>Presupuesto máximo:</strong> $200,000</li>
|
||||
<li><strong>Zona:</strong> Costa de FL — Stuart hasta Jacksonville</li>
|
||||
<li><strong>Préstamo ideal:</strong> Non-QM, Bank Statement, o ITIN</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function copyText(id) {
|
||||
const text = document.getElementById(id).textContent;
|
||||
navigator.clipboard.writeText(text).then(() => alert('Script copiado'));
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,99 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Propiedades — Casa Hunter FL{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="fw-bold mb-0" style="color:var(--primary)"><i class="fas fa-building me-2" style="color:var(--accent)"></i>Propiedades en Oportunidad</h4>
|
||||
<button class="btn btn-accent btn-sm" onclick="runScan()"><i class="fas fa-sync me-1"></i>Actualizar</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body py-2">
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-6 col-md-3">
|
||||
<label class="form-label small fw-bold mb-1">Ciudad</label>
|
||||
<input type="text" name="city" class="form-control form-control-sm" placeholder="Vero Beach..." value="{{ city_filter }}">
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<label class="form-label small fw-bold mb-1">Fuente</label>
|
||||
<select name="source" class="form-select form-select-sm">
|
||||
<option value="">Todas</option>
|
||||
{% for s in sources %}<option value="{{ s }}" {% if s==current_source %}selected{% endif %}>{{ s }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<label class="form-label small fw-bold mb-1">Score mín.</label>
|
||||
<select name="min_score" class="form-select form-select-sm">
|
||||
<option value="0">Todos</option>
|
||||
<option value="70">70+</option>
|
||||
<option value="80">80+</option>
|
||||
<option value="90">90+</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<label class="form-label small fw-bold mb-1">Ordenar</label>
|
||||
<select name="sort" class="form-select form-select-sm">
|
||||
<option value="score" {% if current_sort=='score' %}selected{% endif %}>Mejor score</option>
|
||||
<option value="price_asc" {% if current_sort=='price_asc' %}selected{% endif %}>Precio ↑</option>
|
||||
<option value="price_desc" {% if current_sort=='price_desc' %}selected{% endif %}>Precio ↓</option>
|
||||
<option value="newest" {% if current_sort=='newest' %}selected{% endif %}>Más reciente</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-1">
|
||||
<button type="submit" class="btn btn-primary btn-sm w-100"><i class="fas fa-filter"></i></button>
|
||||
</div>
|
||||
<div class="col-6 col-md-1">
|
||||
<a href="/properties?favorites=1" class="btn btn-outline-warning btn-sm w-100"><i class="fas fa-star"></i></a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if properties %}
|
||||
<p class="text-muted small mb-3"><strong>{{ properties|length }}</strong> propiedades encontradas</p>
|
||||
<div class="row g-3">
|
||||
{% for p in properties %}
|
||||
<div class="col-12 col-md-6 col-xl-4">
|
||||
<div class="card h-100 {% if p.is_favorite %}border-warning{% endif %}">
|
||||
{% if p.image_url %}
|
||||
<img src="{{ p.image_url }}" class="card-img-top" style="height:150px;object-fit:cover;border-radius:12px 12px 0 0" alt="">
|
||||
{% else %}
|
||||
<div style="height:80px;background:linear-gradient(135deg,#1a3a5c,#2d6a9f);border-radius:12px 12px 0 0;display:flex;align-items:center;justify-content:center">
|
||||
<i class="fas fa-home fa-2x text-white opacity-50"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="source-tag">{{ p.source }}</span>
|
||||
<span class="score-badge {% if p.score >= 75 %}score-high{% elif p.score >= 50 %}score-mid{% else %}score-low{% endif %}">{{ p.score }}/100</span>
|
||||
</div>
|
||||
<div class="prop-price mb-1">${{ "{:,.0f}".format(p.price) }}</div>
|
||||
<p class="text-muted small mb-1">{{ p.address }}</p>
|
||||
<p class="text-muted small mb-2">{{ p.city }}{% if p.county %}, {{ p.county }}{% endif %}</p>
|
||||
<div class="d-flex gap-2 text-muted small mb-3">
|
||||
{% if p.beds %}<span><i class="fas fa-bed me-1"></i>{{ p.beds }}</span>{% endif %}
|
||||
{% if p.baths %}<span><i class="fas fa-bath me-1"></i>{{ p.baths }}</span>{% endif %}
|
||||
{% if p.sqft %}<span><i class="fas fa-ruler-combined me-1"></i>{{ p.sqft }}sf</span>{% endif %}
|
||||
</div>
|
||||
<div class="d-flex gap-1">
|
||||
<a href="/property/{{ p.id }}" class="btn btn-primary btn-sm flex-fill">Ver Detalle</a>
|
||||
{% if p.url %}<a href="{{ p.url }}" target="_blank" class="btn btn-outline-secondary btn-sm"><i class="fas fa-external-link-alt"></i></a>{% endif %}
|
||||
{% if p.is_favorite %}<span class="btn btn-warning btn-sm"><i class="fas fa-star"></i></span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card text-center py-5">
|
||||
<div class="card-body">
|
||||
<i class="fas fa-search fa-3x mb-3" style="color:var(--accent)"></i>
|
||||
<h5>No hay propiedades</h5>
|
||||
<p class="text-muted">Haz clic en Buscar Ahora para encontrar oportunidades</p>
|
||||
<button class="btn btn-primary" onclick="runScan()"><i class="fas fa-sync me-2"></i>Buscar Ahora</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,224 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ prop.address }} — Casa Hunter FL{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="/properties">Propiedades</a></li>
|
||||
<li class="breadcrumb-item active">Detalle</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- Left Column -->
|
||||
<div class="col-12 col-lg-8">
|
||||
<div class="card mb-3">
|
||||
{% if prop.image_url %}
|
||||
<img src="{{ prop.image_url }}" class="card-img-top" style="height:280px;object-fit:cover;border-radius:12px 12px 0 0" alt="">
|
||||
{% else %}
|
||||
<div style="height:180px;background:linear-gradient(135deg,#1a3a5c,#2d6a9f);border-radius:12px 12px 0 0;display:flex;align-items:center;justify-content:center">
|
||||
<i class="fas fa-home fa-4x text-white opacity-40"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2 mb-3">
|
||||
<div>
|
||||
<span class="source-tag me-2">{{ prop.source }}</span>
|
||||
<span class="badge bg-secondary">{{ prop.status }}</span>
|
||||
</div>
|
||||
<span class="score-badge {% if prop.score >= 75 %}score-high{% elif prop.score >= 50 %}score-mid{% else %}score-low{% endif %} fs-6">
|
||||
Score: {{ prop.score }}/100
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 class="prop-price mb-1">${{ "{:,.0f}".format(prop.price) }}</h3>
|
||||
<p class="text-muted mb-1"><i class="fas fa-map-marker-alt me-1"></i>{{ prop.address }}</p>
|
||||
<p class="text-muted small mb-3">{{ prop.city }}{% if prop.county %}, {{ prop.county }} County{% endif %}, FL {{ prop.zipcode }}</p>
|
||||
|
||||
<div class="row text-center g-2 mb-3">
|
||||
{% if prop.beds %}
|
||||
<div class="col-4">
|
||||
<div class="border rounded p-2">
|
||||
<i class="fas fa-bed text-muted d-block mb-1"></i>
|
||||
<strong>{{ prop.beds }}</strong><br><small class="text-muted">Habitaciones</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if prop.baths %}
|
||||
<div class="col-4">
|
||||
<div class="border rounded p-2">
|
||||
<i class="fas fa-bath text-muted d-block mb-1"></i>
|
||||
<strong>{{ prop.baths }}</strong><br><small class="text-muted">Baños</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if prop.sqft %}
|
||||
<div class="col-4">
|
||||
<div class="border rounded p-2">
|
||||
<i class="fas fa-ruler-combined text-muted d-block mb-1"></i>
|
||||
<strong>{{ prop.sqft }}</strong><br><small class="text-muted">Sq Ft</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
{% if prop.url %}
|
||||
<a href="{{ prop.url }}" target="_blank" class="btn btn-primary"><i class="fas fa-external-link-alt me-2"></i>Ver en {{ prop.source }}</a>
|
||||
{% endif %}
|
||||
<button class="btn {% if prop.is_favorite %}btn-warning{% else %}btn-outline-warning{% endif %}" onclick="toggleFav({{ prop.id }}, this)">
|
||||
<i class="fas fa-star me-1"></i>{% if prop.is_favorite %}Guardada{% else %}Guardar{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI Analysis -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-3" style="color:var(--primary)"><i class="fas fa-robot me-2" style="color:var(--accent)"></i>Análisis con IA (qwen2.5)</h6>
|
||||
<div id="ai-result">
|
||||
{% if prop.ai_analysis %}
|
||||
<div class="action-kit">{{ prop.ai_analysis }}</div>
|
||||
{% else %}
|
||||
<p class="text-muted">Aún no se ha analizado esta propiedad.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary mt-3" onclick="runAnalysis({{ prop.id }})">
|
||||
<i class="fas fa-magic me-1"></i>{% if prop.ai_analysis %}Re-analizar{% else %}Analizar con IA{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-2" style="color:var(--primary)"><i class="fas fa-sticky-note me-2"></i>Mis Notas</h6>
|
||||
<textarea class="form-control mb-2" id="notes" rows="3" placeholder="Agrega notas sobre esta propiedad...">{{ prop.notes or '' }}</textarea>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="saveNotes({{ prop.id }})"><i class="fas fa-save me-1"></i>Guardar Notas</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Action Kit -->
|
||||
<div class="col-12 col-lg-4">
|
||||
<!-- Action Kit -->
|
||||
<div class="card mb-3" style="border-left:4px solid var(--accent)">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-3" style="color:var(--primary)"><i class="fas fa-clipboard-list me-2" style="color:var(--accent)"></i>Kit de Acción</h6>
|
||||
|
||||
<p class="small fw-bold text-muted mb-2">DOCUMENTOS A PREPARAR</p>
|
||||
<ul class="small mb-3">
|
||||
<li>Green card o visa + pasaporte</li>
|
||||
<li>12-24 meses de estados de cuenta bancarios</li>
|
||||
<li>Comprobante de ingresos (facturas de trabajo)</li>
|
||||
<li>Carta del landlord confirmando pagos de renta</li>
|
||||
<li>Carta explicando tu situación laboral (self-employed)</li>
|
||||
<li>Evidencia de los $50,000 disponibles (bank statement)</li>
|
||||
</ul>
|
||||
|
||||
<p class="small fw-bold text-muted mb-2">PASOS A SEGUIR</p>
|
||||
<ol class="small mb-3">
|
||||
<li>Llama a 2-3 lenders de la lista (empieza con Heart Mortgage y Jhenesis)</li>
|
||||
<li>Pide una pre-qualification letter</li>
|
||||
<li>Contrata un inspector de propiedades ($300-500)</li>
|
||||
<li>Si es HUD/Fannie Mae, necesitas un agente de bienes raíces aprobado</li>
|
||||
<li>Presenta oferta con pre-qual letter y prueba de fondos</li>
|
||||
</ol>
|
||||
|
||||
<div class="bg-light rounded p-2 small">
|
||||
<strong><i class="fas fa-calculator me-1"></i>Estimado para esta propiedad:</strong><br>
|
||||
Down payment (25%): <strong>${{ "{:,.0f}".format(prop.price * 0.25) }}</strong><br>
|
||||
Préstamo estimado: <strong>${{ "{:,.0f}".format(prop.price * 0.75) }}</strong><br>
|
||||
Cuota aprox (7.5%, 30 años): <strong>${{ "{:,.0f}".format(prop.price * 0.75 * 0.007) }}/mes</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recommended Lenders -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-3" style="color:var(--primary)"><i class="fas fa-handshake me-2" style="color:var(--accent)"></i>Lenders para Esta Compra</h6>
|
||||
{% for l in lenders[:4] %}
|
||||
<div class="border rounded p-2 mb-2">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<strong class="small">{{ l.name }}</strong>
|
||||
<span class="badge" style="background:var(--accent);color:#fff">{{ l.match_score }}%</span>
|
||||
</div>
|
||||
<small class="text-muted">{{ l.loan_type }}</small><br>
|
||||
<a href="{{ l.website }}" target="_blank" class="btn btn-xs btn-outline-primary btn-sm mt-1" style="font-size:.7rem;padding:2px 8px">
|
||||
<i class="fas fa-globe me-1"></i>Visitar
|
||||
</a>
|
||||
<button class="btn btn-xs btn-outline-secondary btn-sm mt-1 ms-1" style="font-size:.7rem;padding:2px 8px"
|
||||
onclick="showScript('{{ l.name|e }}', `{{ l.contact_script|e }}`)">
|
||||
<i class="fas fa-phone me-1"></i>Script
|
||||
</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<a href="/lenders" class="btn btn-sm btn-outline-primary w-100 mt-1">Ver todos los lenders</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Script Modal -->
|
||||
<div class="modal fade" id="scriptModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title fw-bold" id="scriptTitle"></h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-muted small mb-2">Usa este guión cuando los contactes:</p>
|
||||
<div class="action-kit" id="scriptContent"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button onclick="copyScript()" class="btn btn-sm btn-primary"><i class="fas fa-copy me-1"></i>Copiar</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cerrar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function toggleFav(id, btn) {
|
||||
fetch(`/property/${id}/favorite`, {method:'POST'})
|
||||
.then(r=>r.json())
|
||||
.then(d => {
|
||||
btn.className = d.is_favorite ? 'btn btn-warning' : 'btn btn-outline-warning';
|
||||
btn.innerHTML = `<i class="fas fa-star me-1"></i>${d.is_favorite ? 'Guardada' : 'Guardar'}`;
|
||||
});
|
||||
}
|
||||
function runAnalysis(id) {
|
||||
document.getElementById('ai-result').innerHTML = '<div class="text-center py-3"><div class="spinner-border spinner-border-sm me-2"></div>Analizando con qwen2.5...</div>';
|
||||
fetch(`/property/${id}/analyze`, {method:'POST'})
|
||||
.then(r=>r.json())
|
||||
.then(d => {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'action-kit';
|
||||
container.textContent = d.analysis;
|
||||
const wrapper = document.getElementById('ai-result');
|
||||
wrapper.innerHTML = '';
|
||||
wrapper.appendChild(container);
|
||||
});
|
||||
}
|
||||
function saveNotes(id) {
|
||||
const notes = document.getElementById('notes').value;
|
||||
fetch(`/property/${id}/notes`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({notes})})
|
||||
.then(()=>alert('Notas guardadas'));
|
||||
}
|
||||
function showScript(name, script) {
|
||||
document.getElementById('scriptTitle').textContent = 'Script: ' + name;
|
||||
document.getElementById('scriptContent').textContent = script;
|
||||
new bootstrap.Modal(document.getElementById('scriptModal')).show();
|
||||
}
|
||||
function copyScript() {
|
||||
navigator.clipboard.writeText(document.getElementById('scriptContent').textContent);
|
||||
alert('Script copiado al portapapeles');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,181 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Preferencias — Casa Hunter FL{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<h2 class="fw-bold mb-1" style="color:var(--primary)"><i class="fas fa-sliders-h me-2" style="color:var(--accent)"></i>Preferencias de Búsqueda</h2>
|
||||
<p class="text-muted mb-4">Configura dónde y hasta cuánto buscar. La próxima búsqueda usará estos ajustes.</p>
|
||||
|
||||
<div class="row g-4">
|
||||
|
||||
<!-- Presupuesto y Down Payment -->
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-3" style="color:var(--primary)"><i class="fas fa-dollar-sign me-2" style="color:var(--accent)"></i>Mi Presupuesto</h6>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="save_config">
|
||||
|
||||
<label class="form-label small fw-bold">Precio máximo de compra (USD)</label>
|
||||
<div class="input-group mb-1">
|
||||
<span class="input-group-text">$</span>
|
||||
<input type="number" name="max_price" id="max_price_input" class="form-control form-control-lg fw-bold"
|
||||
value="{{ max_price }}" min="50000" max="1000000" step="5000"
|
||||
oninput="document.getElementById('price-display').textContent='$'+Number(this.value).toLocaleString('en-US')">
|
||||
</div>
|
||||
<input type="range" class="form-range mb-1" min="50000" max="500000" step="5000" value="{{ max_price }}"
|
||||
oninput="document.getElementById('max_price_input').value=this.value;document.getElementById('price-display').textContent='$'+Number(this.value).toLocaleString('en-US')">
|
||||
<div class="d-flex justify-content-between text-muted" style="font-size:.7rem">
|
||||
<span>$50K</span><span>$250K</span><span>$500K</span>
|
||||
</div>
|
||||
<div class="text-center my-2 fw-bold" style="color:var(--accent)" id="price-display">${{ "{:,}".format(max_price) }}</div>
|
||||
|
||||
<hr class="my-3">
|
||||
|
||||
<label class="form-label small fw-bold">Down Payment disponible (USD)</label>
|
||||
<div class="input-group mb-1">
|
||||
<span class="input-group-text">$</span>
|
||||
<input type="number" name="down_payment" id="down_input" class="form-control form-control-lg fw-bold"
|
||||
value="{{ down_payment }}" min="5000" max="500000" step="1000"
|
||||
oninput="document.getElementById('dp-display').textContent='$'+Number(this.value).toLocaleString('en-US')">
|
||||
</div>
|
||||
<input type="range" class="form-range mb-1" min="5000" max="200000" step="1000" value="{{ down_payment }}"
|
||||
oninput="document.getElementById('down_input').value=this.value;document.getElementById('dp-display').textContent='$'+Number(this.value).toLocaleString('en-US')">
|
||||
<div class="text-center fw-bold mb-3" style="color:var(--accent)" id="dp-display">${{ "{:,}".format(down_payment) }}</div>
|
||||
|
||||
<div class="bg-light rounded p-2 small mb-3">
|
||||
<i class="fas fa-calculator me-1"></i>
|
||||
Down del <strong id="pct-display">{{ ((down_payment / max_price) * 100)|round(1) }}%</strong> sobre precio máximo
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100"><i class="fas fa-save me-2"></i>Guardar</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Agregar ciudad -->
|
||||
<div class="col-12 col-md-8">
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-3" style="color:var(--primary)"><i class="fas fa-plus-circle me-2" style="color:var(--accent)"></i>Agregar Ciudad</h6>
|
||||
<form method="post" class="d-flex gap-2">
|
||||
<input type="hidden" name="action" value="add_city">
|
||||
<div class="flex-fill position-relative">
|
||||
<input type="text" name="city" id="city-input" class="form-control"
|
||||
placeholder="Escribe una ciudad de Florida..." autocomplete="off" required>
|
||||
<ul id="city-suggestions" class="list-group position-absolute w-100 shadow-sm"
|
||||
style="z-index:1000;display:none;max-height:200px;overflow-y:auto;top:100%"></ul>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-accent"><i class="fas fa-plus me-1"></i>Agregar</button>
|
||||
</form>
|
||||
<p class="text-muted small mt-2 mb-0"><i class="fas fa-info-circle me-1"></i>Puedes agregar cualquier ciudad de Florida — Vero Beach, Palm Coast, Ponte Vedra, etc.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cities list -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-3" style="color:var(--primary)">
|
||||
<i class="fas fa-map-marker-alt me-2" style="color:var(--accent)"></i>
|
||||
Ciudades en tu búsqueda
|
||||
<span class="badge ms-1" style="background:var(--accent)">{{ cities|selectattr('active')|list|length }} activas</span>
|
||||
</h6>
|
||||
{% if cities %}
|
||||
<div class="row g-2">
|
||||
{% for c in cities %}
|
||||
<div class="col-12 col-sm-6 col-md-4">
|
||||
<div class="d-flex align-items-center justify-content-between border rounded px-3 py-2 {% if not c.active %}opacity-50{% endif %}">
|
||||
<div>
|
||||
<i class="fas fa-map-pin me-2 {% if c.active %}text-success{% else %}text-muted{% endif %}"></i>
|
||||
<span class="small fw-bold">{{ c.city }}</span>
|
||||
</div>
|
||||
<div class="d-flex gap-1">
|
||||
<form method="post" class="d-inline">
|
||||
<input type="hidden" name="action" value="toggle_city">
|
||||
<input type="hidden" name="city_id" value="{{ c.id }}">
|
||||
<button type="submit" class="btn btn-xs p-1 border-0 bg-transparent" title="{% if c.active %}Desactivar{% else %}Activar{% endif %}">
|
||||
<i class="fas fa-{% if c.active %}eye{% else %}eye-slash{% endif %} text-muted" style="font-size:.8rem"></i>
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" class="d-inline" onsubmit="return confirm('¿Eliminar {{ c.city }}?')">
|
||||
<input type="hidden" name="action" value="remove_city">
|
||||
<input type="hidden" name="city_id" value="{{ c.id }}">
|
||||
<button type="submit" class="btn btn-xs p-1 border-0 bg-transparent">
|
||||
<i class="fas fa-times text-danger" style="font-size:.8rem"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">No hay ciudades configuradas.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick add coastal cities -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-3" style="color:var(--primary)"><i class="fas fa-water me-2" style="color:var(--accent)"></i>Agregar rápido — Ciudades costeras populares</h6>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% set quick_cities = ["Vero Beach","Sebastian","Melbourne","Cocoa Beach","Titusville","Stuart","Jensen Beach",
|
||||
"Daytona Beach","Ormond Beach","New Smyrna Beach","Flagler Beach","Palm Coast",
|
||||
"St. Augustine","St. Augustine Beach","Ponte Vedra Beach",
|
||||
"Jacksonville Beach","Atlantic Beach","Neptune Beach","Fernandina Beach",
|
||||
"Port St. Lucie","Fort Pierce","Palm City","Hobe Sound"] %}
|
||||
{% for qc in quick_cities %}
|
||||
{% set already = cities|selectattr('city','equalto',qc)|list %}
|
||||
{% if not already %}
|
||||
<form method="post" class="d-inline">
|
||||
<input type="hidden" name="action" value="add_city">
|
||||
<input type="hidden" name="city" value="{{ qc }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="fas fa-plus me-1" style="font-size:.7rem"></i>{{ qc }}
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="btn btn-sm btn-success disabled" style="font-size:.8rem">
|
||||
<i class="fas fa-check me-1"></i>{{ qc }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const input = document.getElementById('city-input');
|
||||
const suggestions = document.getElementById('city-suggestions');
|
||||
|
||||
input.addEventListener('input', async function() {
|
||||
const q = this.value.trim();
|
||||
if (q.length < 2) { suggestions.style.display='none'; return; }
|
||||
const res = await fetch(`/api/cities?q=${encodeURIComponent(q)}`);
|
||||
const cities = await res.json();
|
||||
suggestions.innerHTML = '';
|
||||
if (cities.length === 0) { suggestions.style.display='none'; return; }
|
||||
cities.forEach(city => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'list-group-item list-group-item-action py-2 small';
|
||||
li.textContent = city + ', FL';
|
||||
li.style.cursor = 'pointer';
|
||||
li.addEventListener('click', () => {
|
||||
input.value = city;
|
||||
suggestions.style.display = 'none';
|
||||
});
|
||||
suggestions.appendChild(li);
|
||||
});
|
||||
suggestions.style.display = 'block';
|
||||
});
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
if (!input.contains(e.target)) suggestions.style.display = 'none';
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,29 @@
|
||||
services:
|
||||
casahunter:
|
||||
build: .
|
||||
container_name: casahunter
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:5050:5000"
|
||||
environment:
|
||||
- DATABASE_URL=${DATABASE_URL:-mysql+pymysql://casahunter:CHANGE_ME@mariadb:3306/casahunter}
|
||||
- SECRET_KEY=${SECRET_KEY:?SECRET_KEY must be set in .env}
|
||||
- OLLAMA_URL=http://host.docker.internal:11434
|
||||
- OLLAMA_MODEL=qwen2.5:14b
|
||||
- ANTHROPIC_KEY=
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
logging:
|
||||
driver: local
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
networks:
|
||||
- web
|
||||
- mariadb_default
|
||||
|
||||
networks:
|
||||
web:
|
||||
external: true
|
||||
mariadb_default:
|
||||
external: true
|
||||
@@ -0,0 +1,58 @@
|
||||
import requests, re, json
|
||||
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124 Safari/537.36",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
}
|
||||
|
||||
# Fetch Redfin search for FL cities and extract real region IDs
|
||||
test_urls = [
|
||||
("Vero Beach", "https://www.redfin.com/FL/Vero-Beach"),
|
||||
("Jacksonville", "https://www.redfin.com/FL/Jacksonville"),
|
||||
("Melbourne", "https://www.redfin.com/FL/Melbourne"),
|
||||
("Stuart", "https://www.redfin.com/FL/Stuart"),
|
||||
("Daytona Beach", "https://www.redfin.com/FL/Daytona-Beach"),
|
||||
]
|
||||
|
||||
print("=== Extracting real Redfin region IDs ===")
|
||||
for city, url in test_urls:
|
||||
try:
|
||||
r = requests.get(url, headers=headers, timeout=15, allow_redirects=True)
|
||||
# Extract region_id from JS or URL
|
||||
final_url = r.url
|
||||
match = re.search(r'/city/(\d+)/FL/', final_url)
|
||||
if not match:
|
||||
match = re.search(r'"region_id"[:\s]+(\d+)', r.text)
|
||||
if not match:
|
||||
match = re.search(r'"regionId"[:\s]+(\d+)', r.text)
|
||||
if match:
|
||||
rid = match.group(1)
|
||||
print(f" {city}: region_id={rid} (url={final_url})")
|
||||
else:
|
||||
print(f" {city}: redirected to {final_url}, id not found, status={r.status_code}")
|
||||
except Exception as e:
|
||||
print(f" {city}: ERROR {e}")
|
||||
|
||||
# Try direct Redfin search API (county-based = more reliable)
|
||||
print("\n=== Redfin by ZIP code (more reliable) ===")
|
||||
fl_zips = {
|
||||
"Vero Beach": "32960",
|
||||
"Melbourne": "32901",
|
||||
"Jacksonville": "32202",
|
||||
"St. Augustine": "32080",
|
||||
"Daytona Beach": "32114",
|
||||
}
|
||||
for city, zipcode in fl_zips.items():
|
||||
try:
|
||||
url = (f"https://www.redfin.com/stingray/api/gis?"
|
||||
f"al=1&market=florida®ion_id={zipcode}®ion_type=2"
|
||||
f"&status=9&uipt=1,2,3,4&max_price=230000&num_homes=5&start=0&v=8")
|
||||
r = requests.get(url, headers=headers, timeout=15)
|
||||
data = json.loads(r.text.replace("{}&&", ""))
|
||||
homes = data.get("payload", {}).get("homes", [])
|
||||
fl_homes = [h for h in homes if h.get("state") == "FL"]
|
||||
print(f" {city} (zip={zipcode}): {len(homes)} total, {len(fl_homes)} in FL")
|
||||
for h in fl_homes[:2]:
|
||||
print(f" ${h.get('price',{}).get('value',0):,} | {h.get('streetLine',{}).get('value','?')}, {h.get('city','?')}, FL")
|
||||
except Exception as e:
|
||||
print(f" {city}: ERROR {e}")
|
||||
@@ -0,0 +1,25 @@
|
||||
@echo off
|
||||
echo ========================================
|
||||
echo Casa Hunter FL - Iniciando...
|
||||
echo ========================================
|
||||
|
||||
cd /d "%~dp0"
|
||||
|
||||
REM Crear entorno virtual si no existe
|
||||
if not exist "venv" (
|
||||
echo Creando entorno virtual...
|
||||
python -m venv venv
|
||||
)
|
||||
|
||||
REM Activar entorno e instalar dependencias
|
||||
call venv\Scripts\activate.bat
|
||||
pip install -q -r requirements.txt
|
||||
|
||||
REM Iniciar la app
|
||||
echo.
|
||||
echo Abriendo en tu navegador: http://localhost:5105
|
||||
echo Presiona Ctrl+C para detener.
|
||||
echo.
|
||||
start "" "http://localhost:5105"
|
||||
cd app
|
||||
python main.py
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"agent-browser": "^0.26.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
# Prisa Yachts LLC — Marketing Content
|
||||
## Category: Marine Engines & Filtration Systems
|
||||
## Generated: 2026-05-04
|
||||
|
||||
---
|
||||
|
||||
# TIP 1: THE IMPELLER
|
||||
|
||||
## Titulo del Tip
|
||||
**The $30 Part That Can Kill a $15,000 Engine**
|
||||
|
||||
---
|
||||
|
||||
### Explicacion Tecnica
|
||||
|
||||
The impeller is a small rubber wheel inside your raw-water pump that moves cooling water from the ocean or river into your engine's heat exchanger. It typically costs between $20 and $50 depending on your engine brand. When it fails — and it will fail — your engine loses cooling water circulation in seconds. Without coolant flow, temperatures spike fast. On a hot Florida day, running even 5 minutes without a functioning impeller can warp cylinder heads, seize bearings, or crack heat exchangers. The repair bill starts at $2,000 and can reach $15,000 or more on larger inboard diesels.
|
||||
|
||||
The failure mode is deceptive. The impeller does not snap suddenly like a belt — it degrades. The rubber vanes harden and crack from heat, age, or running dry even briefly. Florida's warm saltwater accelerates this degradation compared to northern climates. Fragments of a failed impeller can also travel downstream into the heat exchanger and block cooling passages, which means replacing the impeller alone is not enough — you must inspect and flush the entire raw-water circuit.
|
||||
|
||||
The correct replacement interval for most marine engines in Florida saltwater is every 200 hours of operation or once per season — whichever comes first. If your boat sits for months between uses, replace the impeller regardless of hours because rubber hardens when static. Always have a spare impeller onboard. Carry the correct size for your engine model, the right gasket, and the proper grease. A 15-minute dock-side replacement is infinitely better than a tow and a ruined engine.
|
||||
|
||||
---
|
||||
|
||||
### Caption EN (Instagram)
|
||||
|
||||
That small rubber wheel inside your raw-water pump? It costs $30. When it fails while you're underway in Florida heat, your engine can overheat in under 5 minutes — we're talking warped heads, seized bearings, cracked heat exchangers. The repair? Easily $5,000 to $15,000.
|
||||
|
||||
The impeller is the single most overlooked part in marine engine maintenance. Florida's warm saltwater makes rubber degrade faster than you'd expect. Best practice: replace every 200 hours OR once per season — whichever comes first. And always carry a spare onboard.
|
||||
|
||||
Signs it's failing: rising engine temperature, reduced raw-water flow from the exhaust, or rubber fragments in your strainer basket. Don't wait for an alarm. By then, the damage is done.
|
||||
|
||||
At Prisa Yachts LLC, impeller replacement is part of every seasonal service we perform — from Stuart to Jacksonville. We check the full raw-water circuit, not just the pump.
|
||||
|
||||
DM us for preventive maintenance — Stuart to Jacksonville.
|
||||
|
||||
#PrisaYachts #MarineMaintenance #ImpellerService #BoatMaintenance #FloridaBoating
|
||||
|
||||
---
|
||||
|
||||
### Caption ES (Instagram)
|
||||
|
||||
Esa pequeña rueda de goma dentro de tu bomba de agua bruta cuesta $30. Cuando falla mientras navegas en el calor de Florida, tu motor puede sobrecalentarse en menos de 5 minutos — estamos hablando de culatas dobladas, cojinetes agarrotados, intercambiadores fisurados. La reparacion? Facil $5,000 a $15,000.
|
||||
|
||||
El impeller es la pieza mas ignorada en el mantenimiento de motores marinos. El agua salada caliente de Florida degrada el caucho mucho mas rapido de lo que imaginas. Lo correcto: cambiarlo cada 200 horas O una vez por temporada — lo que ocurra primero. Y siempre lleva uno de repuesto a bordo.
|
||||
|
||||
Señales de fallo: temperatura del motor subiendo, poco flujo de agua en el escape, o fragmentos de goma en la canastilla del filtro. No esperes la alarma. Para entonces, el daño ya esta hecho.
|
||||
|
||||
En Prisa Yachts LLC, el cambio de impeller es parte de todo servicio de temporada — de Stuart a Jacksonville. Revisamos todo el circuito de agua bruta, no solo la bomba.
|
||||
|
||||
Escríbenos para mantenimiento preventivo — de Stuart a Jacksonville.
|
||||
|
||||
#PrisaYachts #MantenimientoMarino #Impeller #BarcosEnFlorida #MotorMarino
|
||||
|
||||
---
|
||||
|
||||
### 10 Hashtags
|
||||
```
|
||||
#ImpellerReplacement #MarineEngine #RawWaterPump #BoatMaintenance #FloridaBoating #MarineMechanic #PreventiveMaintenance #SaltwaterBoating #YachtMaintenance #PrisaYachtsLLC
|
||||
```
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
# TIP 2: ZINCS / ANODES
|
||||
|
||||
## Titulo del Tip
|
||||
**Zincs: The Sacrificial Guards Your Engine Needs**
|
||||
|
||||
---
|
||||
|
||||
### Explicacion Tecnica
|
||||
|
||||
Galvanic corrosion is one of the most destructive forces in the marine environment, and most boat owners do not understand it until something expensive has already been eaten away. When two dissimilar metals are submerged in saltwater — an electrolyte — an electrochemical reaction occurs. One metal becomes the anode and corrodes rapidly; the other becomes the cathode and is protected. Your aluminum lower unit, stainless shaft, bronze through-hulls, and bronze impeller housing are all at risk.
|
||||
|
||||
Zinc anodes work by being the most reactive metal in that circuit. Zinc corrodes first, sacrificially, protecting your more expensive components. Aluminum anodes are used in saltwater and are actually slightly more protective than zinc in full saltwater environments — many professionals now recommend aluminum anodes in Florida. Magnesium anodes are for freshwater only. Using the wrong anode material or allowing anodes to become fully depleted leaves your underwater gear completely exposed to accelerated galvanic attack.
|
||||
|
||||
The rule of thumb is to inspect anodes every 3 to 6 months in Florida saltwater and replace them when they are 50% consumed. Do not wait until they are gone — by then, your outdrive, shaft, or lower unit has already been attacked. On outboard motors, check the anode on the lower unit, the trim tab anode, and any bracket anodes. On inboard diesels, check shaft zincs, rudder zincs, keel bolt zincs, and any sacrificial plates on the hull. After extended haul-outs or dock periods, always check for stray current corrosion, which can eat through an anode in days rather than months.
|
||||
|
||||
---
|
||||
|
||||
### Caption EN (Instagram)
|
||||
|
||||
Saltwater is relentless. The moment your boat touches it, electrochemical reactions start attacking your underwater metals — aluminum lower units, stainless shafts, bronze fittings. The only thing standing between your engine and accelerated corrosion is a small piece of zinc or aluminum worth a few dollars.
|
||||
|
||||
Anodes work by corroding first, sacrificing themselves so your expensive components don't. But here's what most people miss: a depleted anode is worse than no anode — because it gives you false confidence. In Florida's warm, conductive saltwater, anodes can go from full to 50% consumed in a single season.
|
||||
|
||||
Inspection schedule: every 3–6 months. Replace at 50% consumed — not 100%. On outboards, check your lower unit anode AND your trim tab. On inboard diesels, inspect shaft zincs, rudder zincs, and hull plates.
|
||||
|
||||
Saltwater doesn't take days off. Neither do we.
|
||||
|
||||
DM us for preventive maintenance — Stuart to Jacksonville.
|
||||
|
||||
#PrisaYachts #ZincAnodes #GalvanicCorrosion #MarineMaintenance #FloridaBoating
|
||||
|
||||
---
|
||||
|
||||
### Caption ES (Instagram)
|
||||
|
||||
El agua salada no para. Desde el momento en que tu barca toca el agua, reacciones electroquimicas atacan tus metales sumergidos — unidades inferiores de aluminio, ejes de acero inoxidable, accesorios de bronce. Lo unico que esta entre tu motor y la corrosion acelerada es un pequeño trozo de zinc o aluminio que cuesta unos pocos dolares.
|
||||
|
||||
Los anodos funcionan corroiendose primero, sacrificandose para que tus piezas caras no lo hagan. Pero esto es lo que la mayoria ignora: un anodo agotado es peor que no tenerlo — porque te da una falsa sensacion de seguridad. En el agua salada calida y conductora de Florida, un anodo puede pasar de nuevo a 50% consumido en una sola temporada.
|
||||
|
||||
Frecuencia de inspeccion: cada 3 a 6 meses. Reemplaza cuando este al 50% — no al 100%. En fuera de borda, revisa el anodo de la unidad inferior Y el trim tab. En diesel inboard, inspecciona los zincs del eje, del timon y las placas del casco.
|
||||
|
||||
El agua salada no descansa. Nosotros tampoco.
|
||||
|
||||
Escríbenos para mantenimiento preventivo — de Stuart a Jacksonville.
|
||||
|
||||
#PrisaYachts #AnodesMarinos #CorrosionGalvanica #MantenimientoMarino #BarcosEnFlorida
|
||||
|
||||
---
|
||||
|
||||
### 10 Hashtags
|
||||
```
|
||||
#ZincAnodes #GalvanicCorrosion #MarineCorrosionProtection #AnodeReplacement #SaltwaterBoating #OutboardMaintenance #InboardDiesel #FloridaBoating #MarineMechanic #PrisaYachtsLLC
|
||||
```
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
# TIP 3: FUEL FILTER — PRIMARY vs. SECONDARY
|
||||
|
||||
## Titulo del Tip
|
||||
**Two Filters, One Mission: Keeping Bad Fuel Out of Your Engine**
|
||||
|
||||
---
|
||||
|
||||
### Explicacion Tecnica
|
||||
|
||||
Marine fuel filtration is a two-stage system, and confusing or neglecting either stage can result in injector failure, fuel pump damage, or a dead engine offshore. The primary fuel filter — most commonly a Racor or equivalent bowl-type separator — sits between the fuel tank and the lift pump. Its job is the heavy work: separating water from diesel (or gasoline), catching large sediment, and providing a visual inspection point through its clear or translucent bowl. This is the filter that saves you when you pick up a tank of contaminated fuel from a questionable marina. It handles particles down to 10 or 30 microns depending on the element installed.
|
||||
|
||||
The secondary fuel filter is mounted directly on the engine, between the lift pump and the high-pressure injection pump or injectors. It is the final defense — it catches particles down to 2 or 10 microns, protecting precision-machined injector components that can be damaged by particles invisible to the naked eye. Modern common-rail diesel engines are especially sensitive here: injector tolerances are measured in thousandths of a millimeter. A single episode of contaminated fuel reaching the injectors without proper secondary filtration can result in injector replacement costs of $300 to $800 per injector.
|
||||
|
||||
In Florida's saltwater environment, condensation inside fuel tanks is a year-round problem due to temperature swings and humidity. Water accumulation in diesel tanks also promotes microbial growth — the dark, slimy contamination known as diesel algae or diesel bug. This biological contamination clogs both filter stages rapidly. Best practice: inspect the Racor bowl every 50 hours or monthly, drain any water accumulation immediately, and replace the primary element every 100 to 200 hours. Replace the secondary filter at every engine oil change. If you find dark, coffee-ground-like sediment in your bowl, address the tank contamination before it overruns both filters.
|
||||
|
||||
---
|
||||
|
||||
### Caption EN (Instagram)
|
||||
|
||||
Your diesel engine has two lines of defense against bad fuel — and most boat owners only know about one of them.
|
||||
|
||||
The PRIMARY filter (Racor bowl-type) does the heavy lifting: it pulls water out of your diesel and catches large sediment before it reaches the lift pump. That clear bowl? Inspect it every 50 hours. If you see water or dark sludge — stop. Deal with it before you go offshore.
|
||||
|
||||
The SECONDARY filter is mounted on the engine itself. It catches particles down to 2 microns — protecting your injection pump and injectors. Modern common-rail diesels can have injector tolerances tighter than a human hair. Contaminated fuel reaching those injectors means a $300–$800 repair per injector. Replace this filter at every oil change.
|
||||
|
||||
Florida's humidity and temperature swings cause constant condensation inside fuel tanks. That means water, and water means diesel algae. Two-stage filtration is not optional here — it's survival.
|
||||
|
||||
DM us for preventive maintenance — Stuart to Jacksonville.
|
||||
|
||||
#PrisaYachts #FuelFilter #RacorFilter #MarineDiesel #FloridaBoating
|
||||
|
||||
---
|
||||
|
||||
### Caption ES (Instagram)
|
||||
|
||||
Tu motor diesel tiene dos lineas de defensa contra el combustible contaminado — y la mayoria de los dueños de embarcaciones solo conoce una.
|
||||
|
||||
El filtro PRIMARIO (tipo bowl Racor) hace el trabajo pesado: separa el agua del diesel y captura sedimentos grandes antes de que lleguen a la bomba de cebado. Ese bowl transparente? Inspeccionalo cada 50 horas. Si ves agua o lodo oscuro — para. Resuelve eso antes de salir a mar abierto.
|
||||
|
||||
El filtro SECUNDARIO esta montado en el motor mismo. Captura particulas de hasta 2 micrones, protegiendo tu bomba de inyeccion y los inyectores. Los dieseles modern common-rail tienen tolerancias en los inyectores mas finas que un cabello humano. Combustible contaminado llegando a esos inyectores significa $300 a $800 de reparacion por inyector. Cambia este filtro en cada cambio de aceite.
|
||||
|
||||
La humedad y los cambios de temperatura en Florida causan condensacion constante dentro de los depositos. Eso significa agua, y el agua significa algas en el diesel. La filtracion en dos etapas no es opcional aqui — es supervivencia.
|
||||
|
||||
Escríbenos para mantenimiento preventivo — de Stuart a Jacksonville.
|
||||
|
||||
#PrisaYachts #FiltroCombustible #FiltroRacor #DieselMarino #BarcosEnFlorida
|
||||
|
||||
---
|
||||
|
||||
### 10 Hashtags
|
||||
```
|
||||
#FuelFilter #RacorFilter #MarineDiesel #FuelFiltration #DieselMaintenance #MarineEngine #FloridaBoating #BoatMaintenance #PreventiveMaintenance #PrisaYachtsLLC
|
||||
```
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
# TIP 4: MARINE OIL CHANGE vs. CAR OIL CHANGE
|
||||
|
||||
## Titulo del Tip
|
||||
**Your Boat Engine Is Not Your Car — Oil Changes Are Not the Same**
|
||||
|
||||
---
|
||||
|
||||
### Explicacion Tecnica
|
||||
|
||||
One of the most dangerous assumptions a boat owner can make is treating their marine engine like an automobile engine for maintenance purposes. The oil change interval on your car — 5,000 to 7,500 miles or 6 months — has no direct equivalent in a marine context. Marine engines operate under fundamentally different conditions: they run at sustained high loads for hours at a time, they rarely have the benefit of air cooling from vehicle movement, they operate in high-humidity environments, and many are cooled by raw water systems that can introduce moisture into the engine compartment. All of these factors accelerate oil degradation.
|
||||
|
||||
For most gasoline inboard and sterndrive engines, the manufacturer recommendation is an oil change every 100 hours of operation or once per season — whichever comes first. For diesel inboard engines, the interval is typically 100 to 150 hours depending on the engine model and whether a bypass filtration system is installed. Critically, hour-based intervals are far more relevant than calendar-based ones in marine applications. A boat that runs 20 hours per month needs oil changes far more frequently than its owner might assume. Conversely, a boat that sits 8 months in a slip still needs an oil change before the next season even if the oil shows no visible contamination, because moisture and acidic combustion byproducts accumulate in stored oil.
|
||||
|
||||
The oil specification also matters. Marine diesel engines require oils meeting specific marine diesel engine ratings — look for API CJ-4 or the manufacturer's specified rating. Many automotive diesel oils lack the additives required for the high sulfur content and load conditions of marine diesels. For gasoline marine engines, use oil meeting the manufacturer's specification, and never use automotive oil with "Energy Conserving" designations in marine applications — the friction modifiers added to these oils to improve car fuel economy can cause clutch slippage in marine transmissions. Always change the oil filter at every oil change, drain the raw-water side of the cooling system if applicable, and inspect the oil for any milky appearance that would indicate water intrusion.
|
||||
|
||||
---
|
||||
|
||||
### Caption EN (Instagram)
|
||||
|
||||
Your car tells you when to change the oil. Your boat doesn't. And the consequences of getting this wrong are far more expensive on the water.
|
||||
|
||||
Marine engines run at sustained high loads for hours. They sit in humid environments. Raw-water cooling systems can push moisture into the bilge and engine compartment. All of that destroys oil faster than a highway commute ever could.
|
||||
|
||||
The rules are different here:
|
||||
- Gasoline inboard: every 100 hours OR once per season
|
||||
- Diesel inboard: every 100–150 hours (check your manual)
|
||||
- Stored for the season? Change the oil before AND after storage — not just one.
|
||||
|
||||
Also critical: do NOT use automotive "Energy Conserving" oil in a marine gasoline engine. Those friction modifiers can cause your marine transmission clutch to slip. Use oil rated and specified for your exact engine.
|
||||
|
||||
And always — always — change the filter at the same time.
|
||||
|
||||
We perform complete engine oil service from Stuart to Jacksonville. We bring the right oil, the right filter, and the technical knowledge to do it correctly.
|
||||
|
||||
DM us for preventive maintenance — Stuart to Jacksonville.
|
||||
|
||||
#PrisaYachts #MarineOilChange #BoatMaintenance #MarineEngine #FloridaBoating
|
||||
|
||||
---
|
||||
|
||||
### Caption ES (Instagram)
|
||||
|
||||
Tu carro te avisa cuando cambiar el aceite. Tu barca no. Y las consecuencias de equivocarse son mucho mas caras en el agua.
|
||||
|
||||
Los motores marinos trabajan a cargas altas sostenidas durante horas. Estan en ambientes humidos. Los sistemas de refrigeracion de agua bruta pueden empujar humedad al compartimento del motor. Todo eso destruye el aceite mucho mas rapido que un viaje en autopista.
|
||||
|
||||
Las reglas son distintas aqui:
|
||||
- Gasolina inboard: cada 100 horas O una vez por temporada
|
||||
- Diesel inboard: cada 100 a 150 horas (revisa tu manual)
|
||||
- Guardo la barca por la temporada? Cambia el aceite ANTES y DESPUES — no solo una vez.
|
||||
|
||||
Tambien critico: NO uses aceite automotriz "Energy Conserving" en un motor marino de gasolina. Los modificadores de friccion de esos aceites pueden hacer que el embrague de la transmision marina patine. Usa aceite especificado para tu motor exacto.
|
||||
|
||||
Y siempre — siempre — cambia el filtro al mismo tiempo.
|
||||
|
||||
Hacemos servicio completo de aceite de motor de Stuart a Jacksonville. Llevamos el aceite correcto, el filtro correcto y el conocimiento tecnico para hacerlo bien.
|
||||
|
||||
Escríbenos para mantenimiento preventivo — de Stuart a Jacksonville.
|
||||
|
||||
#PrisaYachts #CambioAceiteMarino #MantenimientoMarino #MotorMarino #BarcosEnFlorida
|
||||
|
||||
---
|
||||
|
||||
### 10 Hashtags
|
||||
```
|
||||
#MarineOilChange #OilChange #MarineEngine #InboardDiesel #BoatMaintenance #MarineMechanic #PreventiveMaintenance #FloridaBoating #SaltwaterBoating #PrisaYachtsLLC
|
||||
```
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
# TIP 5: EXHAUST SMOKE COLORS
|
||||
|
||||
## Titulo del Tip
|
||||
**Read the Smoke Before the Engine Can't Talk Anymore**
|
||||
|
||||
---
|
||||
|
||||
### Explicacion Tecnica
|
||||
|
||||
Your engine communicates constantly through its exhaust. A puff of smoke at startup in humid Florida air is normal — that is condensation burning off. But persistent exhaust smoke during normal operation is your engine telling you something is wrong, and each color carries a specific diagnostic message. Learning to read exhaust smoke color is one of the highest-value skills a boat owner or captain can develop, because it often provides early warning before an alarm triggers or damage occurs.
|
||||
|
||||
White smoke during normal operation indicates water or coolant entering the combustion chamber. This is a serious finding. The most common causes are a leaking head gasket, a cracked cylinder head, or a failing heat exchanger allowing raw water into the combustion circuit. In Florida's saltwater environment, a heat exchanger that introduces saltwater into the engine will cause internal corrosion and deposit buildup rapidly. White smoke accompanied by a sweet smell and milky residue in the oil confirms coolant contamination of the oil circuit — shut down immediately and do not restart the engine until it is inspected. On raw-water-cooled engines, white smoke can also result from a faulty thermostat allowing overcooling, which prevents complete combustion.
|
||||
|
||||
Black smoke indicates an overly rich fuel mixture — too much fuel relative to available air. Common causes include clogged air filters, dirty or failing injectors delivering excessive fuel, a malfunctioning turbocharger on turbocharged diesels, or a restricted air intake. Black smoke is most visible at hard acceleration or under heavy load. While it does not always indicate imminent catastrophic failure, it does signal poor combustion efficiency, increased carbon buildup, and fuel waste. Persistent black smoke on a diesel should trigger an injector inspection and service. Blue smoke is the most unambiguous signal: it means engine oil is burning in the combustion chamber. Causes include worn piston rings, worn valve stem seals, or in turbocharged engines, a failing turbo seal allowing oil into the intake charge. Blue smoke that appears only at startup and clears quickly often points to valve stem seals — oil pools on top of closed valves overnight. Blue smoke that is persistent under load indicates piston ring wear and is a more serious mechanical finding requiring immediate evaluation.
|
||||
|
||||
---
|
||||
|
||||
### Caption EN (Instagram)
|
||||
|
||||
Your engine can't text you. But it can talk through its exhaust — if you know how to listen.
|
||||
|
||||
WHITE SMOKE (persistent, not startup condensation): Water or coolant in the combustion chamber. Head gasket, cracked head, or heat exchanger failure. If your oil looks milky, shut down NOW. Do not restart.
|
||||
|
||||
BLACK SMOKE: Too much fuel, not enough air. Clogged air filter, dirty injectors, failing turbo, restricted intake. Less urgent than white, but fix it — carbon buildup adds up fast and fuel efficiency drops.
|
||||
|
||||
BLUE SMOKE: Oil burning in the combustion chamber. Worn piston rings, valve seals, or a turbo seal failure. Startup blue that clears? Likely valve seals. Persistent blue under load? That's a mechanical inspection — now.
|
||||
|
||||
Florida heat, saltwater exposure, and high humidity accelerate every one of these failure modes compared to northern boating environments. Read your exhaust at startup, during acceleration, and under load. Make it a habit every single time you leave the dock.
|
||||
|
||||
DM us for preventive maintenance — Stuart to Jacksonville.
|
||||
|
||||
#PrisaYachts #ExhaustSmoke #MarineEngine #DiagnosticTips #FloridaBoating
|
||||
|
||||
---
|
||||
|
||||
### Caption ES (Instagram)
|
||||
|
||||
Tu motor no puede mandarte un mensaje. Pero puede hablar a traves de su escape — si sabes escuchar.
|
||||
|
||||
HUMO BLANCO (persistente, no condensacion al arranque): Agua o refrigerante en la camara de combustion. Junta de culata, culata fisurada, o fallo del intercambiador de calor. Si el aceite se ve lechoso, APAGA el motor YA. No lo vuelvas a encender.
|
||||
|
||||
HUMO NEGRO: Demasiado combustible, poco aire. Filtro de aire obstruido, inyectores sucios, turbo fallando, toma de aire restringida. Menos urgente que el blanco, pero solucionalo — la acumulacion de carbon es costosa y el rendimiento cae.
|
||||
|
||||
HUMO AZUL: Aceite quemandose en la camara de combustion. Anillos de piston desgastados, retenes de valvulas, o fallo del sello del turbo. Azul al arranque que desaparece? Probablemente retenes de valvulas. Azul persistente bajo carga? Inspeccion mecanica — ahora.
|
||||
|
||||
El calor de Florida, la exposicion al agua salada y la humedad aceleran todos estos modos de fallo comparado con la navegacion en climas del norte. Lee tu escape al arrancar, durante la aceleracion y bajo carga. Haz un habito de esto cada vez que salgas del muelle.
|
||||
|
||||
Escríbenos para mantenimiento preventivo — de Stuart a Jacksonville.
|
||||
|
||||
#PrisaYachts #HumoEscape #MotorMarino #DiagnosticoMarino #BarcosEnFlorida
|
||||
|
||||
---
|
||||
|
||||
### 10 Hashtags
|
||||
```
|
||||
#ExhaustSmoke #MarineEngineDiagnostics #WhiteSmoke #BlackSmoke #BlueSmoke #MarineEngine #BoatMaintenance #FloridaBoating #MarineMechanic #PrisaYachtsLLC
|
||||
```
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
# CARRUSEL: ANNUAL ENGINE SERVICE CHECKLIST — FLORIDA SALTWATER
|
||||
|
||||
## Format: 5 Slides para Instagram Carousel
|
||||
|
||||
---
|
||||
|
||||
### SLIDE 1 — Cover / Hook
|
||||
|
||||
**VISUAL DIRECTION:** Dark navy background, bold white type, Prisa Yachts LLC logo. Engine close-up photo or graphic of checklist icon.
|
||||
|
||||
**HEADLINE:**
|
||||
Annual Engine Service Checklist
|
||||
Florida Saltwater Edition
|
||||
|
||||
**SUBTEXT:**
|
||||
10 things your engine needs every year to survive Florida's saltwater environment.
|
||||
(Most boat owners skip at least 3 of these.)
|
||||
|
||||
**Bottom tag:**
|
||||
@prisayachtsllc | Stuart to Jacksonville
|
||||
|
||||
---
|
||||
|
||||
### SLIDE 2 — Fluid & Filter Service
|
||||
|
||||
**HEADLINE:**
|
||||
Step 1: Fluids & Filters
|
||||
|
||||
**BODY TEXT:**
|
||||
- Change engine oil and oil filter (per manufacturer hours)
|
||||
- Replace primary fuel filter element (Racor)
|
||||
- Replace secondary engine fuel filter
|
||||
- Check and replace gear oil in transmission/outdrive
|
||||
- Inspect coolant concentration and condition (50/50 mix minimum)
|
||||
- Drain and inspect raw-water strainer basket
|
||||
|
||||
**CALLOUT BOX:**
|
||||
Florida tip: Diesel tanks collect condensation year-round. Inspect Racor bowl every 50 hours — never skip it.
|
||||
|
||||
---
|
||||
|
||||
### SLIDE 3 — Raw Water Cooling System
|
||||
|
||||
**HEADLINE:**
|
||||
Step 2: Raw Water Cooling Circuit
|
||||
|
||||
**BODY TEXT:**
|
||||
- Replace raw-water impeller (every 200 hours or annually)
|
||||
- Inspect and flush heat exchanger
|
||||
- Check all raw-water hoses for cracking, softness, or swelling
|
||||
- Inspect thermostat — replace every 2 seasons in saltwater
|
||||
- Clean seacock strainer and test seacock operation
|
||||
- Check exhaust water flow at startup (look for steady stream)
|
||||
|
||||
**CALLOUT BOX:**
|
||||
Florida tip: A failed impeller in summer heat can destroy a motor in under 5 minutes. Replace it — do not test it.
|
||||
|
||||
---
|
||||
|
||||
### SLIDE 4 — Belts, Hoses & Corrosion Protection
|
||||
|
||||
**HEADLINE:**
|
||||
Step 3: Belts, Hoses & Anodes
|
||||
|
||||
**BODY TEXT:**
|
||||
- Inspect and replace serpentine/V-belts (look for cracking, glazing, fraying)
|
||||
- Replace all zincs/anodes: lower unit, trim tab, shaft, rudder, hull plates
|
||||
- Inspect all coolant hoses — squeeze test for hardness or sponginess
|
||||
- Check all fuel hose connections for seepage or cracking
|
||||
- Inspect motor mounts for deterioration
|
||||
- Check engine alignment (inboard)
|
||||
|
||||
**CALLOUT BOX:**
|
||||
Florida tip: Replace anodes when 50% consumed — not 100%. Aluminum anodes outperform zinc in full saltwater.
|
||||
|
||||
---
|
||||
|
||||
### SLIDE 5 — Ignition, Electrical & Final Checks
|
||||
|
||||
**HEADLINE:**
|
||||
Step 4: Ignition, Electrical & Final Inspection
|
||||
|
||||
**BODY TEXT:**
|
||||
- Inspect and clean battery terminals — check electrolyte levels (AGM/flooded)
|
||||
- Test alternator output voltage (13.8–14.4V at charge)
|
||||
- Inspect spark plugs or glow plugs depending on engine type
|
||||
- Test all engine alarms: overheat, low oil pressure, no raw-water flow
|
||||
- Run engine to full operating temperature — observe exhaust color
|
||||
- Log engine hours and record all service performed with dates
|
||||
|
||||
**CALLOUT BOX:**
|
||||
Florida tip: Test your alarms with every service. An alarm that doesn't sound gives you zero warning when it matters most.
|
||||
|
||||
**BOTTOM CTA (all slides footer):**
|
||||
Need this done right? DM us for your annual engine service.
|
||||
Prisa Yachts LLC — Stuart to Jacksonville
|
||||
@prisayachtsllc
|
||||
|
||||
---
|
||||
|
||||
### Hashtags for Carousel Post
|
||||
```
|
||||
#AnnualBoatService #MarineEngineService #BoatMaintenance #FloridaBoating #SaltwaterBoating #MarineMechanic #EngineChecklist #PreventiveMaintenance #YachtMaintenance #PrisaYachtsLLC
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*End of content block — Prisa Yachts LLC / Marine Engines & Filtration / 2026-05-04*
|
||||
@@ -0,0 +1,397 @@
|
||||
# Prisa Yachts LLC — Teca Marina & Detailing
|
||||
## Instagram Content Pack — 5 Technical Tips + 3 Before/After Captions
|
||||
Generated: 2026-05-04
|
||||
|
||||
---
|
||||
|
||||
## TIP 1 — CAN YOUR TEAK BE SAVED? (Visual Evaluation)
|
||||
|
||||
### Technical Explanation
|
||||
|
||||
Teak on a boat deck faces a punishment that few wood species can survive without help: UV radiation, salt spray, standing water, foot traffic, and constant thermal expansion. Before spending money on any product or labor, the first question is always the same — can this wood be recovered, or does it need to go?
|
||||
|
||||
The answer lives in the wood itself. Healthy teak that has simply been neglected shows a consistent gray or silver patina across the surface, with wood fibers that are intact, tight-grained, and firm to pressure. Run your thumbnail across the grain — if the wood feels dense and you get a clean scrape without fiber lift, you are looking at a candidate for recovery. The gray color is oxidized surface oil and UV-bleached lignin, not damage. That layer sands off cleanly and the honey-brown color underneath is waiting.
|
||||
|
||||
Teak that cannot be saved tells a different story. Look for soft spots that compress under thumb pressure, caulking seams that have pulled away from the wood by more than 3mm, planks with longitudinal cracking along the grain (not just surface checking), or areas where the wood has gone thin from previous aggressive sandings. In Florida's climate, two-season neglect is often the threshold — after that, recovery becomes a gamble. When more than 30% of plank thickness is compromised, replacement is the more honest conversation.
|
||||
|
||||
---
|
||||
|
||||
### Caption EN (Instagram)
|
||||
|
||||
Your teak is gray and looks rough — but is it actually dead?
|
||||
|
||||
Here's the honest evaluation we do before touching a single plank. Run your thumbnail across the grain: if the wood is dense, firm, and fibers don't lift, that gray surface is just oxidation. The honey-brown color is still underneath, waiting. That's a recovery job.
|
||||
|
||||
What tells us replacement is the real answer: soft spots that compress under pressure, caulking seams that have pulled away from the wood by more than an inch, planks cracking along the grain from the inside out, or wood that's gone thin from years of aggressive sanding.
|
||||
|
||||
In Florida, two seasons of zero maintenance is often the turning point. We've recovered teak that looked destroyed — and we've also had the honest conversation when replacement was the smarter investment.
|
||||
|
||||
If you're not sure which side of that line you're on, send us a few photos. We'll give you a straight answer before you spend a dollar.
|
||||
|
||||
DM us for a quote — Stuart to Jacksonville.
|
||||
|
||||
#PrisaYachts #TeakRecovery #MarineTeak #BoatMaintenance #FloridaBoating #YachtDetailing #TeakDeck #MarineServices #BoatCare #SouthFloridaYachts
|
||||
|
||||
---
|
||||
|
||||
### Caption ES (Instagram)
|
||||
|
||||
Tu teca está gris y se ve mal — pero, ¿realmente está muerta?
|
||||
|
||||
Esta es la evaluación honesta que hacemos antes de tocar una sola tabla. Pasa la uña a contrapelo: si la madera está densa, firme y las fibras no se levantan, esa superficie gris es solo oxidación. El color miel todavía está adentro, esperando. Eso es trabajo de recuperación.
|
||||
|
||||
Lo que nos dice que el reemplazo es la respuesta real: zonas blandas que ceden bajo presión, costuras de sellado separadas más de 2 cm, tablas con grietas a lo largo de la veta desde adentro, o madera que se ha adelgazado demasiado por lijados agresivos previos.
|
||||
|
||||
En Florida, dos temporadas sin mantenimiento suelen ser el punto de quiebre. Hemos recuperado teca que parecía destruida — y también hemos tenido la conversación honesta cuando el reemplazo era la inversión más inteligente.
|
||||
|
||||
Si no sabes de qué lado estás, mándanos unas fotos. Te damos una respuesta directa antes de que gastes un dólar.
|
||||
|
||||
Escríbenos para un presupuesto — Stuart to Jacksonville.
|
||||
|
||||
#PrisaYachts #RecuperacionDeTeca #TecaMarina #MantenimientoNautico #YachtsFlorida #DetallingMarino #TecaNautica #ServiciosMarinos #CuidadoDeBarco #YachtsSurFlorida
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
## TIP 2 — THE TEAK RECOVERY PROCESS: CLEAN → SAND → OIL → SEAL
|
||||
|
||||
### Technical Explanation
|
||||
|
||||
Teak recovery is a sequence, not a product. The biggest mistake boat owners make is applying a new coat of oil or sealer over a neglected surface and expecting results. What actually happens is that the new product sits on top of old oxidized oil, salt residue, and mildew — and fails within weeks. The process only works when it follows the correct order.
|
||||
|
||||
Step one is a dedicated teak cleaner, typically a two-part system: an oxalic acid-based Part A that breaks down oxidation and gray surface cells, followed by a neutralizing Part B that restores the wood's natural pH before sanding. Allow the wood to dry completely — in Florida's humidity, 24 to 48 hours is the minimum. Step two is mechanical: 80-grit to open the grain, followed by 120-grit to smooth without closing the pores. Always sand with the grain. Never use a random orbit sander across teak — you will create circular scratch patterns that telegraphed through any finish and collect water.
|
||||
|
||||
Step three is penetrating oil — tung-based or a dedicated marine teak oil — applied in thin coats with a rag or brush, wiping off the excess before it skins on the surface. This feeds the wood from inside. Step four, the sealer, is optional but critical for high-traffic areas in Florida: a quality teak sealer adds UV protection and slows the reoxidation cycle. The honest timeline for this full process on a mid-size deck? One solid day of labor. The result lasts 12 to 18 months before the next maintenance cycle.
|
||||
|
||||
---
|
||||
|
||||
### Caption EN (Instagram)
|
||||
|
||||
Teak recovery isn't magic — it's a sequence. And most DIY jobs fail because they skip a step.
|
||||
|
||||
Here's the actual process we follow:
|
||||
|
||||
CLEAN — Two-part teak cleaner. Part A breaks down oxidation and gray surface cells. Part B neutralizes the wood's pH. You're starting with a clean canvas, not covering problems.
|
||||
|
||||
SAND — 80-grit to open the grain, 120-grit to smooth. Always with the grain. Never cross-grain with a random orbit sander — you'll create water traps invisible to the eye.
|
||||
|
||||
OIL — Penetrating marine teak oil, thin coats, wiped before it skins. This feeds the wood from the inside. You're not painting it, you're conditioning it.
|
||||
|
||||
SEAL — UV-blocking teak sealer for high-traffic areas. Florida sun will reoxidize bare teak in one season. The sealer buys you 12 to 18 months before the next maintenance cycle.
|
||||
|
||||
Skip any of these steps and you're spending money to fail faster.
|
||||
|
||||
DM us for a quote — Stuart to Jacksonville.
|
||||
|
||||
#PrisaYachts #TeakRestoration #MarineTeak #BoatDetailingFlorida #TeakDeckCare #MarineWoodcare #FloridaYacht #BoatMaintenance #YachtCare #MarineDetailing
|
||||
|
||||
---
|
||||
|
||||
### Caption ES (Instagram)
|
||||
|
||||
La recuperación de teca no es magia — es una secuencia. Y la mayoría de los trabajos fallan porque se salta un paso.
|
||||
|
||||
Este es el proceso real que seguimos:
|
||||
|
||||
LIMPIAR — Limpiador de teca en dos partes. La parte A rompe la oxidación y las células grises de la superficie. La parte B neutraliza el pH de la madera. Empiezas con una superficie limpia, no cubriendo problemas.
|
||||
|
||||
LIJAR — Lija 80 para abrir el grano, 120 para alisar. Siempre a favor de la veta. Nunca transversal con lijadora orbital — creas trampas de agua invisibles al ojo.
|
||||
|
||||
ACEITAR — Aceite marino de teca penetrante, capas finas, limpiando el exceso antes de que forme película. Nutres la madera desde adentro. No la estás pintando, la estás acondicionando.
|
||||
|
||||
SELLAR — Sellador UV para zonas de alto tráfico. El sol de Florida reoxida la teca sin protección en una sola temporada. El sellador te da 12 a 18 meses hasta el próximo ciclo de mantenimiento.
|
||||
|
||||
Sáltate cualquiera de estos pasos y estás gastando dinero para fallar más rápido.
|
||||
|
||||
Escríbenos para un presupuesto — Stuart to Jacksonville.
|
||||
|
||||
#PrisaYachts #RestauracionTeca #TecaMarina #DetallingFlorida #CuidadoTeca #MaderaMaritima #YachtFlorida #MantenimientoNautico #CuidadoYacht #DetallingNautico
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
## TIP 3 — TEAK OIL VS. TEAK SEALER: WHICH ONE AND WHEN
|
||||
|
||||
### Technical Explanation
|
||||
|
||||
This is the most common technical confusion in teak maintenance, and it matters because using the wrong product in the wrong situation wastes both the product and your labor. Teak oil and teak sealer are not interchangeable — they solve different problems at different stages of the wood's condition.
|
||||
|
||||
Teak oil is a penetrating product. It goes into the wood, not onto it. Chemically, quality marine teak oils are tung oil or linseed oil derivatives, sometimes blended with UV inhibitors. Their job is to feed dry, depleted wood — replenishing the natural oils that Florida sun and salt air strip out over months. Oil is the right choice after a full sand-and-clean recovery job, when the wood is porous and thirsty. Applied correctly, it disappears into the grain. If it sits on the surface and doesn't absorb within 20 minutes, the wood is still too wet or the pores are too tight from over-sanding.
|
||||
|
||||
Teak sealer is a surface-forming barrier product. It does not feed the wood — it seals the surface to slow moisture movement, UV penetration, and the oxidation cycle. Sealer is the right follow-up product after oiling, particularly on horizontal surfaces like cockpit soles and side decks that take direct Florida sun and standing water. The key distinction for application: oil goes on depleted wood first; sealer goes on conditioned wood after. Applying sealer to bone-dry, un-oiled teak in Florida will produce a surface that looks great for 90 days and then begins to peel as the wood moves beneath it without any internal moisture buffer.
|
||||
|
||||
---
|
||||
|
||||
### Caption EN (Instagram)
|
||||
|
||||
Teak oil and teak sealer are not the same product. Using the wrong one at the wrong time is one of the most expensive mistakes in boat maintenance.
|
||||
|
||||
Here's the difference:
|
||||
|
||||
TEAK OIL is a penetrating product. It goes INTO the wood, not onto it. It feeds depleted wood — replacing the natural oils that Florida sun and salt air pull out month after month. Use it after a full clean and sand, when the wood is porous and dry. If it doesn't absorb within 20 minutes, something is wrong with the prep.
|
||||
|
||||
TEAK SEALER is a surface barrier. It doesn't feed the wood — it seals the surface against UV, moisture movement, and reoxidation. Use it AFTER oiling, as the final layer on horizontal surfaces that take direct sun and standing water.
|
||||
|
||||
The order matters: oil first, sealer second. Putting sealer on dry, un-oiled teak in Florida gives you 90 days of results before it starts peeling — because the wood is moving underneath and there's nothing holding it stable.
|
||||
|
||||
Two products. Two jobs. Both necessary. Neither optional in this climate.
|
||||
|
||||
DM us for a quote — Stuart to Jacksonville.
|
||||
|
||||
#PrisaYachts #TeakOil #TeakSealer #MarineTeak #BoatWoodcare #FloridaBoating #TeakMaintenance #YachtCare #MarineDetailing #BoatMaintenance
|
||||
|
||||
---
|
||||
|
||||
### Caption ES (Instagram)
|
||||
|
||||
El aceite de teca y el sellador de teca no son el mismo producto. Usar el equivocado en el momento equivocado es uno de los errores más caros en el mantenimiento de embarcaciones.
|
||||
|
||||
La diferencia:
|
||||
|
||||
ACEITE DE TECA es un producto penetrante. Va DENTRO de la madera, no sobre ella. Nutre la madera agotada — reemplazando los aceites naturales que el sol de Florida y el aire salado extraen mes a mes. Úsalo después de una limpieza y lijado completo, cuando la madera está porosa y seca. Si no absorbe en 20 minutos, algo está mal con la preparación.
|
||||
|
||||
SELLADOR DE TECA es una barrera superficial. No nutre la madera — sella la superficie contra los rayos UV, el movimiento de humedad y la reoxidación. Úsalo DESPUÉS del aceite, como capa final en superficies horizontales que reciben sol directo y agua estancada.
|
||||
|
||||
El orden importa: aceite primero, sellador después. Poner sellador en teca seca sin aceite en Florida te da 90 días de resultados antes de que empiece a despegarse — porque la madera se está moviendo por debajo y no hay nada que la estabilice.
|
||||
|
||||
Dos productos. Dos funciones. Ambos necesarios. Ninguno es opcional en este clima.
|
||||
|
||||
Escríbenos para un presupuesto — Stuart to Jacksonville.
|
||||
|
||||
#PrisaYachts #AceiteDeTeca #SelladorTeca #TecaMarina #MaderaMaritima #NavegacionFlorida #MantenimientoTeca #CuidadoYacht #DetallingMarino #MantenimientoNautico
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
## TIP 4 — WHY FLORIDA SUN DESTROYS TEAK IN 2 SEASONS WITHOUT MAINTENANCE
|
||||
|
||||
### Technical Explanation
|
||||
|
||||
Florida is not a normal marine environment for teak. The combination of UV index, ambient temperature, salt air humidity, and daily thermal cycling creates a degradation rate that boat owners from northern climates consistently underestimate. A well-maintained teak deck in Maine might hold its finish for two years with minimal intervention. The same deck in Stuart or Jacksonville without maintenance will be visibly compromised in 18 months and structurally thin in three seasons.
|
||||
|
||||
The mechanism works in three simultaneous channels. UV radiation at Florida latitudes hits a UV index of 10 to 11 from March through October — among the highest in the continental US. This bleaches the lignin (the natural binder holding teak fibers together) and oxidizes the surface oils that give teak its density and water resistance. Salt air adds hygroscopic stress: salt crystals deposit in the wood grain, absorb moisture, and expand and contract with temperature, physically opening the grain from the inside. Thermal cycling — the 35-40°F daily temperature swings on a dark deck surface from 7 AM to 2 PM — causes wood movement that accelerates both UV and salt damage simultaneously.
|
||||
|
||||
The result of all three combined is a deck that doesn't just look gray — it actually loses structural mass. Teak planks start at roughly 5/8 inch from the factory. Each aggressive sanding to address neglect removes material that doesn't come back. Most recoverable decks have 3/8 to 7/16 inch remaining. Below 1/4 inch, the structural argument for replacement becomes unanswerable. One properly executed maintenance cycle per year in Florida — clean, oil, seal — interrupts all three degradation channels and extends plank life by years. The math on prevention versus replacement is not close.
|
||||
|
||||
---
|
||||
|
||||
### Caption EN (Instagram)
|
||||
|
||||
Florida is not a forgiving environment for teak. And a lot of boat owners don't understand why until they're looking at a replacement quote.
|
||||
|
||||
Here's what's actually happening to your deck every season you skip maintenance:
|
||||
|
||||
UV INDEX 10-11 from March through October. That's among the highest in the continental US. It bleaches the lignin — the natural binder that holds teak fibers together — and oxidizes the surface oils that give the wood its density. Once the lignin is gone, the fibers start separating.
|
||||
|
||||
SALT AIR deposits salt crystals in the open grain. Those crystals absorb moisture and expand and contract with the daily heat cycle, physically prying the wood open from the inside.
|
||||
|
||||
THERMAL CYCLING on a dark deck surface means 35-40°F swings between 7 AM and 2 PM. That's constant wood movement — and it accelerates everything above.
|
||||
|
||||
Teak planks start at 5/8 inch from the factory. Every aggressive sanding to fix neglect removes material that doesn't come back. Two seasons of zero maintenance in Florida can take a recoverable deck and make it a replacement conversation.
|
||||
|
||||
One maintenance cycle per year stops all three damage channels. The math on prevention versus replacement is not close.
|
||||
|
||||
DM us for a quote — Stuart to Jacksonville.
|
||||
|
||||
#PrisaYachts #FloridaBoating #TeakDeck #UVDamage #MarineTeak #BoatMaintenance #TeakProtection #FloridaYacht #MarineDetailing #YachtCare
|
||||
|
||||
---
|
||||
|
||||
### Caption ES (Instagram)
|
||||
|
||||
Florida no es un ambiente generoso con la teca. Y muchos dueños de embarcaciones no lo entienden hasta que están viendo un presupuesto de reemplazo.
|
||||
|
||||
Esto es lo que le pasa a tu cubierta cada temporada que saltas el mantenimiento:
|
||||
|
||||
ÍNDICE UV 10-11 de marzo a octubre. Es uno de los más altos de los Estados Unidos continentales. Blanquea la lignina — el aglutinante natural que mantiene las fibras de teca unidas — y oxida los aceites superficiales que le dan densidad a la madera. Una vez que la lignina se va, las fibras comienzan a separarse.
|
||||
|
||||
AIRE SALADO deposita cristales de sal en el grano abierto. Esos cristales absorben humedad y se expanden y contraen con el ciclo de calor diario, abriendo físicamente la madera desde adentro.
|
||||
|
||||
CICLOS TÉRMICOS en una cubierta oscura significan cambios de 18-22°C entre las 7 AM y las 2 PM. Eso es movimiento constante de la madera — y acelera todo lo anterior.
|
||||
|
||||
Las tablas de teca salen de fábrica a 16 mm de espesor. Cada lijado agresivo para corregir el abandono elimina material que no vuelve. Dos temporadas sin mantenimiento en Florida pueden convertir una cubierta recuperable en una conversación de reemplazo.
|
||||
|
||||
Un ciclo de mantenimiento al año detiene los tres canales de daño. La diferencia entre prevención y reemplazo no admite discusión.
|
||||
|
||||
Escríbenos para un presupuesto — Stuart to Jacksonville.
|
||||
|
||||
#PrisaYachts #NavegacionFlorida #TecaMarina #DañoUV #CubertaDeTeca #MantenimientoNautico #ProteccionTeca #YachtFlorida #DetallingMarino #CuidadoYacht
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
## TIP 5 — GELCOAT DETAILING: LIGHT OXIDATION VS. SEVERE — WHEN TO POLISH, WHEN TO WAX
|
||||
|
||||
### Technical Explanation
|
||||
|
||||
Gelcoat is a polyester resin surface — not paint — and it degrades through oxidation, not peeling. Florida's UV load, heat, and salt environment accelerate this process more aggressively than any other environment in the continental US. Understanding the difference between light oxidation and severe oxidation determines whether you reach for a polishing compound, a machine polisher, or a conversation about professional refinishing.
|
||||
|
||||
Light oxidation presents as a slight haze or loss of depth in the gel coat's gloss. The color looks flat rather than vibrant. Run a white rag across the surface — if you get a faint chalky residue, you are in the light oxidation category. At this stage, a fine-cut polish on a dual-action polisher or even by hand will restore the surface without removing significant gelcoat. After polishing, a quality marine wax or paint sealant applied in thin coats provides 3 to 6 months of UV protection in Florida conditions. This is the maintenance cycle — polish when needed, protect always.
|
||||
|
||||
Severe oxidation is visually distinct. The surface chalks heavily when touched, the color has shifted toward white or gray regardless of the original pigment, and you may see micro-cracking or a matte texture that does not respond to light polishing. At this stage, a cutting compound with a wool or foam cutting pad on a rotary or forced-rotation polisher is the entry point. This removes more material from the surface to get below the oxidized layer. The risk here is cutting through the gelcoat entirely — gelcoat is typically 0.5 to 0.8mm thick — which is why severe oxidation requires experienced hands. After a heavy cut, a finishing polish removes the cut marks, then wax or sealant seals the restored surface. In the most severe cases, if the gel coat has checked or crazing cracks across the surface, professional barrier coat application or refinishing may be the only path to a durable result.
|
||||
|
||||
---
|
||||
|
||||
### Caption EN (Instagram)
|
||||
|
||||
Not all oxidation is the same — and treating them the same way is how you either under-correct or burn through your gelcoat.
|
||||
|
||||
Here's how to read what you're dealing with:
|
||||
|
||||
LIGHT OXIDATION: Surface looks hazy, colors seem flat instead of vibrant. Drag a white rag across it — faint chalk residue. At this stage, a fine-cut polish on a DA polisher (or by hand) restores the gloss without removing significant gelcoat. Follow with a quality marine wax or sealant. In Florida, that protection lasts 3 to 6 months.
|
||||
|
||||
SEVERE OXIDATION: Heavy chalking when touched, original color has shifted toward white or gray, surface feels matte and doesn't respond to light polishing. Now you're in cutting compound territory — wool or foam cutting pad, rotary or forced-rotation polisher. This removes more material. The risk: gelcoat is only 0.5 to 0.8mm thick. Heavy cutting requires experienced hands or you break through.
|
||||
|
||||
The rule we follow: start with the least aggressive correction that solves the problem. You can always cut more. You can't put gelcoat back.
|
||||
|
||||
After any cut: finishing polish to remove cut marks, then wax or sealant to protect what you've restored.
|
||||
|
||||
DM us for a quote — Stuart to Jacksonville.
|
||||
|
||||
#PrisaYachts #GelcoatRestoration #BoatDetailing #MarineDetailing #FloridaBoating #GelcoatPolishing #BoatWax #YachtDetailing #OxidationRemoval #BoatCare
|
||||
|
||||
---
|
||||
|
||||
### Caption ES (Instagram)
|
||||
|
||||
No toda oxidación es igual — y tratarlas igual es como se corrige de menos o se quema la capa de gelcoat.
|
||||
|
||||
Así se lee lo que tienes frente a ti:
|
||||
|
||||
OXIDACIÓN LEVE: La superficie se ve opaca, los colores parecen apagados en vez de vibrantes. Pasa un trapo blanco — residuo de tiza leve. En este punto, una pulida fina con pulidora DA (o a mano) restaura el brillo sin remover gelcoat significativo. Termina con cera marina o sellador. En Florida, esa protección dura 3 a 6 meses.
|
||||
|
||||
OXIDACIÓN SEVERA: Tizamiento fuerte al tacto, el color original se ha ido hacia el blanco o gris, la superficie se siente mate y no responde al pulido leve. Ahora estás en territorio de compuesto de corte — pad de lana o espuma de corte, pulidora rotativa o de rotación forzada. Esto remueve más material. El riesgo: el gelcoat tiene solo 0.5 a 0.8mm de espesor. El corte agresivo requiere manos con experiencia o lo atraviesas.
|
||||
|
||||
La regla que seguimos: empezar siempre con la corrección menos agresiva que resuelve el problema. Siempre puedes cortar más. No puedes devolver el gelcoat.
|
||||
|
||||
Después de cualquier corte: pulido de acabado para eliminar las marcas, luego cera o sellador para proteger lo que restauraste.
|
||||
|
||||
Escríbenos para un presupuesto — Stuart to Jacksonville.
|
||||
|
||||
#PrisaYachts #RestauracionGelcoat #DetallingNautico #DetallingMarino #NavegacionFlorida #PulidoGelcoat #CeraParaBarcos #DetallingYacht #EliminacionOxidacion #CuidadoBarco
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
## BEFORE/AFTER CAPTIONS — 3 SPECIAL CAPTIONS FOR REAL TEAK PHOTOS
|
||||
|
||||
---
|
||||
|
||||
### CAPTION 1 — DRAMATIC RESTORATION (Very Deteriorated → Perfect)
|
||||
|
||||
**Caption EN**
|
||||
|
||||
This is what two seasons of Florida sun and zero maintenance does to teak. And this is what the same deck looks like after a full recovery.
|
||||
|
||||
No replacement. No new planks. The same wood — cleaned, sanded, oiled, and sealed by hand.
|
||||
|
||||
What you're seeing on the left: gray, fiber-lifting, salt-embedded teak that most people would write off. What you're seeing on the right: the same deck, same wood, after a full two-part clean, progressive sanding, penetrating marine oil, and UV-blocking sealer.
|
||||
|
||||
The honey-brown color was there the entire time. We just had to take the time to find it.
|
||||
|
||||
This is why we evaluate before we quote. A lot of teak that looks dead in Florida isn't. It's just asking for the right process.
|
||||
|
||||
DM us for a quote — Stuart to Jacksonville.
|
||||
|
||||
#PrisaYachts #TeakBeforeAfter #TeakRestoration #MarineTeak #FloridaBoating #BoatDetailingFlorida #TeakDeck #BeforeAndAfter #YachtCare #MarineDetailing
|
||||
|
||||
---
|
||||
|
||||
**Caption ES**
|
||||
|
||||
Esto es lo que dos temporadas de sol de Florida y cero mantenimiento le hacen a la teca. Y esto es lo que luce el mismo deck después de una recuperación completa.
|
||||
|
||||
Sin reemplazo. Sin tablas nuevas. La misma madera — limpiada, lijada, aceitada y sellada a mano.
|
||||
|
||||
Lo que ves a la izquierda: teca gris, fibras levantadas, sal incrustada, que la mayoría descartaría. Lo que ves a la derecha: el mismo deck, la misma madera, después de una limpieza en dos partes, lijado progresivo, aceite marino penetrante y sellador con bloqueo UV.
|
||||
|
||||
El color miel estaba ahí todo el tiempo. Solo había que tomarse el tiempo de encontrarlo.
|
||||
|
||||
Por eso evaluamos antes de cotizar. Mucha teca que parece muerta en Florida no lo está. Solo necesita el proceso correcto.
|
||||
|
||||
Escríbenos para un presupuesto — Stuart to Jacksonville.
|
||||
|
||||
#PrisaYachts #TecaAntesYDespues #RestauracionTeca #TecaMarina #NavegacionFlorida #DetallingFlorida #CubertaTeca #AntesYDespues #CuidadoYacht #DetallingMarino
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
### CAPTION 2 — PREVENTIVE MAINTENANCE (Cared-For Teak → Even Better)
|
||||
|
||||
**Caption EN**
|
||||
|
||||
Preventive maintenance doesn't make the news. But this is what it actually looks like.
|
||||
|
||||
This deck wasn't neglected. The owner was already doing the right things — keeping it clean, protecting it seasonally. We came in for a maintenance detail: light clean, light sand to refresh the grain, one coat of oil, sealer applied on horizontal surfaces.
|
||||
|
||||
The difference between left and right isn't dramatic. It's deliberate.
|
||||
|
||||
This is what protects a deck from ever needing the dramatic before/after. This is what adds years to the life of a teak investment. This is what the deck looks like when the owner treats it like the asset it is.
|
||||
|
||||
One maintenance cycle per year in Florida. That's the whole strategy.
|
||||
|
||||
DM us for a quote — Stuart to Jacksonville.
|
||||
|
||||
#PrisaYachts #TeakMaintenance #PreventiveMaintenance #MarineTeak #BoatCare #FloridaYacht #TeakDeck #YachtMaintenance #MarineDetailing #BoatDetailing
|
||||
|
||||
---
|
||||
|
||||
**Caption ES**
|
||||
|
||||
El mantenimiento preventivo no sale en los titulares. Pero así es como se ve en la realidad.
|
||||
|
||||
Esta cubierta no estaba abandonada. El propietario ya estaba haciendo las cosas bien — manteniéndola limpia, protegiéndola cada temporada. Llegamos para un detailing de mantenimiento: limpieza leve, lijado suave para refrescar el grano, una capa de aceite, sellador en las superficies horizontales.
|
||||
|
||||
La diferencia entre la izquierda y la derecha no es dramática. Es intencional.
|
||||
|
||||
Esto es lo que protege una cubierta de necesitar el antes/después dramático. Esto es lo que le agrega años de vida a una inversión en teca. Así luce una cubierta cuando el propietario la trata como el activo que es.
|
||||
|
||||
Un ciclo de mantenimiento al año en Florida. Esa es toda la estrategia.
|
||||
|
||||
Escríbenos para un presupuesto — Stuart to Jacksonville.
|
||||
|
||||
#PrisaYachts #MantenimientoTeca #MantenimientoPreventivo #TecaMarina #CuidadoBarco #YachtFlorida #CubertaTeca #MantenimientoYacht #DetallingMarino #DetallingNautico
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
### CAPTION 3 — IN PROGRESS (Showing the Work)
|
||||
|
||||
**Caption EN**
|
||||
|
||||
Somewhere between gray and honey-brown, there's a lot of work.
|
||||
|
||||
This is what teak recovery looks like mid-process. Two-part cleaner done. First sand done. The color is already starting to come back — you can see the honey starting to show through in the sections we've finished, still gray in the ones we haven't reached yet.
|
||||
|
||||
This is the part that doesn't get skipped. No shortcuts between the before and after.
|
||||
|
||||
Every plank by hand. Every seam inspected. Sanding with the grain, not against it. Wiping off the oil before it skins. Letting each coat dry before the next one goes on.
|
||||
|
||||
The finished result is in the next post. But we wanted you to see this part too — because this is where the job actually gets done.
|
||||
|
||||
DM us for a quote — Stuart to Jacksonville.
|
||||
|
||||
#PrisaYachts #TeakRecovery #BehindTheScenes #MarineTeak #BoatDetailingFlorida #TeakRestoration #WorkInProgress #YachtCare #MarineDetailing #FloridaBoating
|
||||
|
||||
---
|
||||
|
||||
**Caption ES**
|
||||
|
||||
Entre el gris y el color miel, hay mucho trabajo.
|
||||
|
||||
Así luce una recuperación de teca a la mitad del proceso. Limpiador en dos partes listo. Primer lijado listo. El color ya está empezando a volver — puedes ver el miel asomando en las secciones que terminamos, todavía gris en las que no hemos llegado.
|
||||
|
||||
Esta es la parte que no se salta. No hay atajos entre el antes y el después.
|
||||
|
||||
Cada tabla a mano. Cada costura inspeccionada. Lijando a favor de la veta, nunca en contra. Limpiando el aceite antes de que forme película. Dejando que cada capa seque antes de aplicar la siguiente.
|
||||
|
||||
El resultado final está en el próximo post. Pero queríamos que vieras esta parte también — porque aquí es donde realmente se hace el trabajo.
|
||||
|
||||
Escríbenos para un presupuesto — Stuart to Jacksonville.
|
||||
|
||||
#PrisaYachts #RecuperacionTeca #DetrásDeEscena #TecaMarina #DetallingFlorida #RestauracionTeca #EnProceso #CuidadoYacht #DetallingMarino #NavegacionFlorida
|
||||
|
||||
---
|
||||
|
||||
*END OF CONTENT PACK — Prisa Yachts LLC — Teca Marina & Detailing*
|
||||
*5 Technical Tips + 3 Before/After Captions | Generated 2026-05-04*
|
||||
@@ -0,0 +1,4 @@
|
||||
flask==3.0.3
|
||||
flask-sqlalchemy==3.1.1
|
||||
requests==2.32.3
|
||||
beautifulsoup4==4.12.3
|
||||
@@ -0,0 +1,59 @@
|
||||
import requests, json, sys
|
||||
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124 Safari/537.36",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
}
|
||||
|
||||
# 1. Redfin city lookup
|
||||
print("=== Redfin City IDs ===")
|
||||
for city in ["Vero Beach, FL", "Jacksonville, FL", "Melbourne, FL", "Stuart, FL", "St. Augustine, FL"]:
|
||||
try:
|
||||
r = requests.get(
|
||||
f"https://www.redfin.com/stingray/do/location-autocomplete?location={requests.utils.quote(city)}&count=3&v=2",
|
||||
headers=headers, timeout=10
|
||||
)
|
||||
data = json.loads(r.text.replace("{}&&", ""))
|
||||
rows = data.get("payload", {}).get("sections", [{}])[0].get("rows", [])
|
||||
if rows:
|
||||
row = rows[0]
|
||||
print(f" {city}: id={row.get('id')}, type={row.get('type')}, url={row.get('url','')}")
|
||||
else:
|
||||
print(f" {city}: no result")
|
||||
except Exception as e:
|
||||
print(f" {city}: ERROR {e}")
|
||||
|
||||
# 2. Test Redfin search with a known city
|
||||
print("\n=== Redfin Search Sample (Vero Beach, FL) ===")
|
||||
try:
|
||||
# region_id for Vero Beach found via autocomplete
|
||||
r = requests.get(
|
||||
"https://www.redfin.com/stingray/api/gis?al=1&market=florida"
|
||||
"®ion_type=6&status=9&uipt=1,2,3,4&max_price=230000&num_homes=5&start=0&v=8"
|
||||
"&location=Vero+Beach%2C+FL",
|
||||
headers=headers, timeout=15
|
||||
)
|
||||
data = json.loads(r.text.replace("{}&&", ""))
|
||||
homes = data.get("payload", {}).get("homes", [])
|
||||
print(f" Found: {len(homes)} homes")
|
||||
for h in homes[:3]:
|
||||
print(f" - ${h.get('price',{}).get('value','?'):,} | {h.get('streetLine',{}).get('value','?')}, {h.get('city','?')}")
|
||||
except Exception as e:
|
||||
print(f" ERROR: {e}")
|
||||
|
||||
# 3. Test Redfin with city URL slug
|
||||
print("\n=== Redfin via city page ===")
|
||||
try:
|
||||
r = requests.get(
|
||||
"https://www.redfin.com/city/19073/FL/Vero-Beach/filter/max-price=230000,property-type=house+condo+townhouse",
|
||||
headers=headers, timeout=15
|
||||
)
|
||||
import re
|
||||
match = re.search(r'"payload":\s*\{.*?"homes":\s*(\[.*?\])\s*,\s*"(?:totalRowCount|url)"', r.text, re.DOTALL)
|
||||
if match:
|
||||
homes = json.loads(match.group(1))
|
||||
print(f" Found {len(homes)} homes via page")
|
||||
else:
|
||||
print(f" Status {r.status_code}, size {len(r.text)} - no match")
|
||||
except Exception as e:
|
||||
print(f" ERROR: {e}")
|
||||
@@ -0,0 +1,60 @@
|
||||
import requests, json, re
|
||||
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124 Safari/537.36",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Referer": "https://www.redfin.com/",
|
||||
}
|
||||
|
||||
# Test the correct Redfin GIS endpoint that was working before
|
||||
print("=== Redfin GIS with region_id ===")
|
||||
try:
|
||||
r = requests.get(
|
||||
"https://www.redfin.com/stingray/api/gis?al=1&market=florida"
|
||||
"®ion_id=14146®ion_type=6&status=9&uipt=1,2,3,4"
|
||||
"&max_price=230000&num_homes=5&start=0&v=8",
|
||||
headers=headers, timeout=15
|
||||
)
|
||||
print(f"Status: {r.status_code}")
|
||||
raw = r.text.replace("{}&&", "")
|
||||
data = json.loads(raw)
|
||||
homes = data.get("payload", {}).get("homes", [])
|
||||
print(f"Homes: {len(homes)}")
|
||||
for h in homes[:3]:
|
||||
print(f" ${h.get('price',{}).get('value',0):,} | {h.get('streetLine',{}).get('value','?')}, {h.get('city','?')} {h.get('zip','')}")
|
||||
except Exception as e:
|
||||
print(f"ERROR: {e}")
|
||||
|
||||
# Try finding region IDs for our cities
|
||||
print("\n=== Finding region IDs ===")
|
||||
cities = ["Vero Beach", "Melbourne", "Jacksonville", "Stuart", "Daytona Beach"]
|
||||
for city in cities:
|
||||
try:
|
||||
r = requests.get(
|
||||
f"https://www.redfin.com/stingray/do/location-autocomplete?location={requests.utils.quote(city + ' FL')}&count=3&v=2",
|
||||
headers=headers, timeout=10
|
||||
)
|
||||
print(f" {city}: status={r.status_code}, len={len(r.text)}")
|
||||
raw = r.text.replace("{}&&", "")
|
||||
if raw.strip():
|
||||
data = json.loads(raw)
|
||||
rows = data.get("payload", {}).get("sections", [{}])[0].get("rows", [])
|
||||
for row in rows[:2]:
|
||||
print(f" -> id={row.get('id')}, type={row.get('type')}, name={row.get('name')}")
|
||||
except Exception as e:
|
||||
print(f" {city}: ERROR {e}")
|
||||
|
||||
# Try Redfin search API directly
|
||||
print("\n=== Redfin /stingray/api/gis-search ===")
|
||||
try:
|
||||
r = requests.get(
|
||||
"https://www.redfin.com/stingray/api/gis-search?"
|
||||
"al=1&market=florida®ion_id=19073®ion_type=6"
|
||||
"&status=9&uipt=1,2,3,4&max_price=230000&num_homes=10&start=0&v=8",
|
||||
headers=headers, timeout=15
|
||||
)
|
||||
print(f"Status: {r.status_code}, size: {len(r.text)}")
|
||||
print(r.text[:400])
|
||||
except Exception as e:
|
||||
print(f"ERROR: {e}")
|
||||
@@ -0,0 +1,59 @@
|
||||
import requests, json, re
|
||||
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124 Safari/537.36",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Referer": "https://www.redfin.com/",
|
||||
}
|
||||
|
||||
# Redfin city region IDs for Florida coastal cities
|
||||
FL_CITY_IDS = {
|
||||
"Vero Beach": 19073,
|
||||
"Sebastian": 16461,
|
||||
"Stuart": 16992,
|
||||
"Jensen Beach": 9058,
|
||||
"Fort Pierce": 7528,
|
||||
"Port St. Lucie": 15348,
|
||||
"Melbourne": 12007,
|
||||
"Cocoa Beach": 5401,
|
||||
"Cocoa": 5400,
|
||||
"Titusville": 18427,
|
||||
"Daytona Beach": 5866,
|
||||
"Ormond Beach": 14428,
|
||||
"New Smyrna Beach": 13473,
|
||||
"Palm Coast": 14706,
|
||||
"St. Augustine": 16629,
|
||||
"Jacksonville": 9009,
|
||||
"Jacksonville Beach": 9004,
|
||||
"Atlantic Beach": 2075,
|
||||
"Neptune Beach": 13437,
|
||||
"Fernandina Beach": 7082,
|
||||
}
|
||||
|
||||
print("=== Testing Redfin with Florida region IDs ===\n")
|
||||
total_found = 0
|
||||
|
||||
for city, region_id in list(FL_CITY_IDS.items())[:5]:
|
||||
try:
|
||||
url = (f"https://www.redfin.com/stingray/api/gis?"
|
||||
f"al=1&market=florida®ion_id={region_id}®ion_type=6"
|
||||
f"&status=9&uipt=1,2,3,4&max_price=230000&num_homes=10&start=0&v=8")
|
||||
r = requests.get(url, headers=headers, timeout=15)
|
||||
data = json.loads(r.text.replace("{}&&", ""))
|
||||
homes = data.get("payload", {}).get("homes", [])
|
||||
print(f"{city} (id={region_id}): {len(homes)} homes found")
|
||||
for h in homes[:2]:
|
||||
price = h.get("price", {}).get("value", 0)
|
||||
addr = h.get("streetLine", {}).get("value", "?")
|
||||
hcity = h.get("city", "?")
|
||||
state = h.get("state", "?")
|
||||
beds = h.get("beds", "?")
|
||||
sqft = h.get("sqFt", {}).get("value", "?")
|
||||
status = h.get("mlsStatus", "?")
|
||||
print(f" ${price:,} | {addr}, {hcity}, {state} | {beds}bd | {sqft}sqft | {status}")
|
||||
total_found += len(homes)
|
||||
except Exception as e:
|
||||
print(f"{city}: ERROR {e}")
|
||||
|
||||
print(f"\nTotal found in 5 cities: {total_found}")
|
||||
@@ -0,0 +1,82 @@
|
||||
import re, time, random
|
||||
from playwright.sync_api import sync_playwright
|
||||
from playwright_stealth import Stealth
|
||||
|
||||
CHROME_PATH = r"C:\Program Files\Google\Chrome\Application\chrome.exe"
|
||||
|
||||
def human_delay(a=1.5, b=4.0):
|
||||
time.sleep(random.uniform(a, b))
|
||||
|
||||
def slow_scroll(page, steps=5):
|
||||
for _ in range(steps):
|
||||
page.mouse.wheel(0, random.randint(250, 600))
|
||||
time.sleep(random.uniform(0.4, 1.0))
|
||||
|
||||
print("=== Zillow con Chrome real + Stealth ===")
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(
|
||||
executable_path=CHROME_PATH,
|
||||
headless=False,
|
||||
args=[
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--start-maximized",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
]
|
||||
)
|
||||
ctx = browser.new_context(
|
||||
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||
locale="en-US",
|
||||
timezone_id="America/New_York",
|
||||
viewport={"width": 1366, "height": 768},
|
||||
)
|
||||
page = ctx.new_page()
|
||||
Stealth().apply_stealth_sync(page)
|
||||
|
||||
page.goto("https://www.zillow.com/homes/for_sale/vero-beach-fl/", wait_until="load", timeout=45000)
|
||||
human_delay(2, 4)
|
||||
slow_scroll(page, 5)
|
||||
human_delay(1, 2)
|
||||
|
||||
print("Title:", page.title()[:80])
|
||||
cards = page.query_selector_all("[data-test='property-card']")
|
||||
print(f"Cards: {len(cards)}")
|
||||
for card in cards[:5]:
|
||||
print(" ", card.inner_text()[:130].replace('\n', ' | '))
|
||||
|
||||
if not cards:
|
||||
content = page.content()
|
||||
prices = re.findall(r'"unformattedPrice":\s*(\d+)', content)
|
||||
zpids = re.findall(r'"zpid":\s*(\d+)', content)
|
||||
print("Prices:", prices[:5])
|
||||
print("ZPIDs:", zpids[:5])
|
||||
print("Blocked?", "Access to this page has been denied" in content)
|
||||
|
||||
browser.close()
|
||||
|
||||
print("\n=== Realtor.com con Chrome real ===")
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(
|
||||
executable_path=CHROME_PATH,
|
||||
headless=False,
|
||||
args=["--disable-blink-features=AutomationControlled", "--start-maximized"]
|
||||
)
|
||||
ctx = browser.new_context(
|
||||
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||
locale="en-US",
|
||||
timezone_id="America/New_York",
|
||||
)
|
||||
page = ctx.new_page()
|
||||
Stealth().apply_stealth_sync(page)
|
||||
|
||||
page.goto("https://www.realtor.com/realestateandhomes-search/Vero-Beach_FL/price-na-230000", wait_until="load", timeout=45000)
|
||||
human_delay(2, 4)
|
||||
slow_scroll(page, 5)
|
||||
|
||||
print("Title:", page.title()[:80])
|
||||
cards = page.query_selector_all("[data-testid='property-card-content']")
|
||||
print(f"Cards: {len(cards)}")
|
||||
for card in cards[:3]:
|
||||
print(" ", card.inner_text()[:130].replace('\n', ' | '))
|
||||
|
||||
browser.close()
|
||||
@@ -0,0 +1,64 @@
|
||||
import requests, re
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
|
||||
|
||||
# Craigslist Florida markets for our target zones
|
||||
MARKETS = {
|
||||
"Treasure Coast": "https://treasure.craigslist.org", # Stuart, Vero Beach, Fort Pierce
|
||||
"Space Coast": "https://spacecoast.craigslist.org", # Melbourne, Titusville, Cocoa
|
||||
"Daytona": "https://daytona.craigslist.org", # Daytona, Ormond, NSB
|
||||
"Jacksonville": "https://jacksonville.craigslist.org", # Jacksonville, St. Augustine
|
||||
"Flagler/Volusia": "https://volusia.craigslist.org", # Palm Coast, Flagler Beach
|
||||
}
|
||||
|
||||
print("=== Craigslist Florida RSS Test ===\n")
|
||||
total = 0
|
||||
for name, base in MARKETS.items():
|
||||
url = f"{base}/search/rfs?format=rss&max_price=230000&min_price=40000"
|
||||
try:
|
||||
r = requests.get(url, headers=headers, timeout=15)
|
||||
soup = BeautifulSoup(r.text, "xml") if "xml" in r.headers.get("content-type","") else BeautifulSoup(r.text, "html.parser")
|
||||
items = soup.find_all("item")
|
||||
print(f"{name}: {len(items)} listings")
|
||||
for item in items[:2]:
|
||||
title = item.find("title")
|
||||
price_el = item.find("price") or item.find("ask")
|
||||
link = item.find("link")
|
||||
title_text = title.get_text() if title else "?"
|
||||
price_text = price_el.get_text() if price_el else re.search(r'\$[\d,]+', title_text or "")
|
||||
if hasattr(price_text, 'group'):
|
||||
price_text = price_text.group()
|
||||
print(f" {title_text[:70]} | {price_text}")
|
||||
total += len(items)
|
||||
except Exception as e:
|
||||
print(f"{name}: ERROR {e}")
|
||||
|
||||
print(f"\nTotal listings available: {total}")
|
||||
|
||||
# Test Zillow with session/cookie
|
||||
print("\n=== Zillow with session ===")
|
||||
try:
|
||||
session = requests.Session()
|
||||
session.headers.update({
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124 Safari/537.36",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
|
||||
})
|
||||
# First get homepage to get cookies
|
||||
session.get("https://www.zillow.com", timeout=15)
|
||||
# Now search
|
||||
r = session.get(
|
||||
"https://www.zillow.com/homes/for_sale/Vero-Beach-FL/?searchQueryState=%7B%22filterState%22%3A%7B%22price%22%3A%7B%22max%22%3A230000%7D%7D%7D",
|
||||
timeout=20
|
||||
)
|
||||
print(f"Status: {r.status_code}, size: {len(r.text)}")
|
||||
has_data = "__NEXT_DATA__" in r.text
|
||||
has_listings = "listResults" in r.text or "zpid" in r.text
|
||||
print(f"Has __NEXT_DATA__: {has_data}, Has listing data: {has_listings}")
|
||||
if has_listings:
|
||||
prices = re.findall(r'"unformattedPrice":\s*(\d+)', r.text)
|
||||
fl_prices = [int(p) for p in prices if 40000 < int(p) <= 230000]
|
||||
print(f"Prices in range: {fl_prices[:5]}")
|
||||
except Exception as e:
|
||||
print(f"ERROR: {e}")
|
||||
@@ -0,0 +1,100 @@
|
||||
import re, json, time, random
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
def human_delay(min_s=1.5, max_s=4.0):
|
||||
time.sleep(random.uniform(min_s, max_s))
|
||||
|
||||
def slow_scroll(page, steps=5):
|
||||
for i in range(steps):
|
||||
page.mouse.wheel(0, random.randint(300, 600))
|
||||
time.sleep(random.uniform(0.4, 0.9))
|
||||
|
||||
def test_zillow_human():
|
||||
print("=== Zillow - comportamiento humano ===")
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(
|
||||
headless=False, # Ventana visible - menos detectable
|
||||
args=[
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--no-sandbox",
|
||||
"--start-maximized",
|
||||
]
|
||||
)
|
||||
ctx = browser.new_context(
|
||||
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||
locale="en-US",
|
||||
timezone_id="America/New_York",
|
||||
viewport={"width": 1366, "height": 768},
|
||||
)
|
||||
ctx.add_init_script("""
|
||||
Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
|
||||
window.chrome = {runtime: {}};
|
||||
Object.defineProperty(navigator, 'plugins', {get: () => [1,2,3,4,5]});
|
||||
""")
|
||||
page = ctx.new_page()
|
||||
|
||||
# Primero ir a Google como un humano
|
||||
print("Abriendo Google...")
|
||||
page.goto("https://www.google.com", wait_until="load", timeout=30000)
|
||||
human_delay(1, 2)
|
||||
|
||||
# Buscar Zillow en Google
|
||||
page.fill("textarea[name=q], input[name=q]", "zillow vero beach florida homes for sale under 230000")
|
||||
human_delay(0.5, 1)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_load_state("load", timeout=20000)
|
||||
human_delay(1, 2)
|
||||
|
||||
# Ir directo a Zillow
|
||||
print("Abriendo Zillow...")
|
||||
page.goto("https://www.zillow.com/homes/for_sale/vero-beach-fl/", wait_until="load", timeout=45000)
|
||||
human_delay(2, 4)
|
||||
|
||||
print("Title:", page.title()[:80])
|
||||
slow_scroll(page, 4)
|
||||
human_delay(1, 2)
|
||||
|
||||
content = page.content()
|
||||
cards = page.query_selector_all("[data-test='property-card']")
|
||||
print(f"Cards: {len(cards)}")
|
||||
for card in cards[:5]:
|
||||
print(" ", card.inner_text()[:120].replace('\n', ' | '))
|
||||
|
||||
if not cards:
|
||||
prices = re.findall(r'"unformattedPrice":\s*(\d+)', content)
|
||||
print("Prices en HTML:", prices[:5])
|
||||
|
||||
browser.close()
|
||||
|
||||
def test_realtor_human():
|
||||
print("\n=== Realtor.com - comportamiento humano ===")
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(
|
||||
headless=False,
|
||||
args=["--disable-blink-features=AutomationControlled", "--start-maximized"]
|
||||
)
|
||||
ctx = browser.new_context(
|
||||
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||
locale="en-US",
|
||||
timezone_id="America/New_York",
|
||||
)
|
||||
ctx.add_init_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined});")
|
||||
page = ctx.new_page()
|
||||
|
||||
page.goto("https://www.realtor.com", wait_until="load", timeout=30000)
|
||||
human_delay(1.5, 3)
|
||||
|
||||
page.goto("https://www.realtor.com/realestateandhomes-search/Vero-Beach_FL/price-na-230000", wait_until="load", timeout=45000)
|
||||
human_delay(2, 4)
|
||||
slow_scroll(page, 5)
|
||||
|
||||
print("Title:", page.title()[:80])
|
||||
cards = page.query_selector_all("[data-testid='property-card-content']")
|
||||
print(f"Cards: {len(cards)}")
|
||||
for card in cards[:3]:
|
||||
print(" ", card.inner_text()[:120].replace('\n', ' | '))
|
||||
|
||||
browser.close()
|
||||
|
||||
test_zillow_human()
|
||||
test_realtor_human()
|
||||
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
Abre el browser visible y espera a que el usuario resuelva cualquier CAPTCHA.
|
||||
El script continua solo cuando detecta listings en la página.
|
||||
"""
|
||||
import re, time, random
|
||||
from playwright.sync_api import sync_playwright
|
||||
from playwright_stealth import Stealth
|
||||
|
||||
CHROME_PATH = r"C:\Program Files\Google\Chrome\Application\chrome.exe"
|
||||
|
||||
def slow_scroll(page, steps=4):
|
||||
for _ in range(steps):
|
||||
page.mouse.wheel(0, random.randint(300, 600))
|
||||
time.sleep(random.uniform(0.5, 1.2))
|
||||
|
||||
def wait_for_listings(page, selectors, timeout=90):
|
||||
"""Espera hasta que aparezcan listings o se agote el tiempo."""
|
||||
print(f"Esperando listings (max {timeout}s)... resuelve cualquier captcha si aparece")
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
for sel in selectors:
|
||||
cards = page.query_selector_all(sel)
|
||||
if cards:
|
||||
return cards
|
||||
time.sleep(2)
|
||||
return []
|
||||
|
||||
print("=== Zillow - espera manual ===")
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(
|
||||
executable_path=CHROME_PATH,
|
||||
headless=False,
|
||||
args=["--disable-blink-features=AutomationControlled", "--start-maximized"]
|
||||
)
|
||||
ctx = browser.new_context(
|
||||
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||
locale="en-US",
|
||||
timezone_id="America/New_York",
|
||||
viewport={"width": 1366, "height": 768},
|
||||
)
|
||||
page = ctx.new_page()
|
||||
Stealth().apply_stealth_sync(page)
|
||||
|
||||
print("Navegando a Zillow...")
|
||||
page.goto("https://www.zillow.com/homes/for_sale/vero-beach-fl/", wait_until="load", timeout=45000)
|
||||
|
||||
# Esperar hasta 90 segundos para que el usuario resuelva captcha
|
||||
cards = wait_for_listings(page, ["[data-test='property-card']"], timeout=90)
|
||||
|
||||
print(f"\nListings encontrados: {len(cards)}")
|
||||
for card in cards[:5]:
|
||||
print(" ", card.inner_text()[:130].replace('\n', ' | '))
|
||||
|
||||
if not cards:
|
||||
content = page.content()
|
||||
prices = re.findall(r'"unformattedPrice":\s*(\d+)', content)
|
||||
print("Prices in JSON:", prices[:5])
|
||||
blocked = "Access to this page has been denied" in content or "cf-browser-verification" in content
|
||||
print("Bloqueado:", blocked)
|
||||
print("Título:", page.title())
|
||||
|
||||
input("\nPresiona Enter para cerrar el browser...")
|
||||
browser.close()
|
||||
@@ -0,0 +1,93 @@
|
||||
import re, json, time
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
def test_zillow():
|
||||
print("=== Zillow con Playwright ===")
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(
|
||||
headless=True,
|
||||
args=["--disable-blink-features=AutomationControlled"]
|
||||
)
|
||||
ctx = browser.new_context(
|
||||
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||
locale="en-US",
|
||||
viewport={"width": 1280, "height": 800},
|
||||
)
|
||||
ctx.add_init_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})")
|
||||
page = ctx.new_page()
|
||||
try:
|
||||
page.goto(
|
||||
"https://www.zillow.com/homes/for_sale/vero-beach-fl/",
|
||||
wait_until="load", timeout=45000
|
||||
)
|
||||
time.sleep(3)
|
||||
print("Title:", page.title()[:80])
|
||||
content = page.content()
|
||||
print("Page size:", len(content))
|
||||
cards = page.query_selector_all("[data-test='property-card']")
|
||||
print(f"Property cards: {len(cards)}")
|
||||
for card in cards[:3]:
|
||||
txt = card.inner_text()
|
||||
print(" ", txt[:150].replace('\n', ' | '))
|
||||
if not cards:
|
||||
prices = re.findall(r'"unformattedPrice":\s*(\d+)', content)
|
||||
print("Prices in HTML:", prices[:5])
|
||||
except Exception as e:
|
||||
print(f"ERROR: {e}")
|
||||
browser.close()
|
||||
|
||||
def test_realtor():
|
||||
print("\n=== Realtor.com con Playwright ===")
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(
|
||||
headless=True,
|
||||
args=["--disable-blink-features=AutomationControlled"]
|
||||
)
|
||||
ctx = browser.new_context(
|
||||
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||
locale="en-US",
|
||||
)
|
||||
ctx.add_init_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})")
|
||||
page = ctx.new_page()
|
||||
try:
|
||||
page.goto(
|
||||
"https://www.realtor.com/realestateandhomes-search/Vero-Beach_FL/price-na-230000",
|
||||
wait_until="load", timeout=45000
|
||||
)
|
||||
time.sleep(3)
|
||||
print("Title:", page.title()[:80])
|
||||
cards = page.query_selector_all("[data-testid='property-card-content']")
|
||||
print(f"Cards (testid): {len(cards)}")
|
||||
if not cards:
|
||||
cards = page.query_selector_all(".jsx-1403264941, [class*='PropertyCard']")
|
||||
print(f"Cards (class): {len(cards)}")
|
||||
for card in cards[:3]:
|
||||
txt = card.inner_text()
|
||||
print(" ", txt[:150].replace('\n', ' | '))
|
||||
except Exception as e:
|
||||
print(f"ERROR: {e}")
|
||||
browser.close()
|
||||
|
||||
def test_new_construction():
|
||||
print("\n=== NewHomeSource (casas nuevas) ===")
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page()
|
||||
try:
|
||||
page.goto(
|
||||
"https://www.newhomesource.com/homes-for-sale/fl/vero-beach?priceMax=230000",
|
||||
wait_until="load", timeout=30000
|
||||
)
|
||||
time.sleep(2)
|
||||
content = page.content()
|
||||
print("Title:", page.title()[:80])
|
||||
print("Size:", len(content))
|
||||
prices = re.findall(r'\$[\d,]+', content)
|
||||
print("Prices:", prices[:8])
|
||||
except Exception as e:
|
||||
print(f"ERROR: {e}")
|
||||
browser.close()
|
||||
|
||||
test_zillow()
|
||||
test_realtor()
|
||||
test_new_construction()
|
||||
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Usa el perfil real de Chrome (con sesión de Google) para scrapear Zillow y Realtor.com.
|
||||
Primero copia el perfil a un directorio temporal para no corromper el original.
|
||||
"""
|
||||
import re, time, random, shutil, os, json
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
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"
|
||||
|
||||
def human_delay(a=1.5, b=3.5):
|
||||
time.sleep(random.uniform(a, b))
|
||||
|
||||
def slow_scroll(page, steps=4):
|
||||
for _ in range(steps):
|
||||
page.mouse.wheel(0, random.randint(300, 600))
|
||||
time.sleep(random.uniform(0.4, 0.9))
|
||||
|
||||
def wait_for_content(page, selector, timeout=60):
|
||||
print(f" Esperando '{selector}' (max {timeout}s)...")
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
try:
|
||||
items = page.query_selector_all(selector)
|
||||
if items:
|
||||
return items
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(2)
|
||||
return []
|
||||
|
||||
# Copiar perfil si no existe el temporal
|
||||
if not os.path.exists(TEMP_PROFILE):
|
||||
print("Copiando perfil de Chrome (solo Default)...")
|
||||
os.makedirs(TEMP_PROFILE, exist_ok=True)
|
||||
src_default = os.path.join(CHROME_PROFILE, "Default")
|
||||
dst_default = os.path.join(TEMP_PROFILE, "Default")
|
||||
# Solo copiar archivos de sesión, no caché (para ir más rápido)
|
||||
for item in ["Cookies", "Login Data", "Web Data", "Preferences"]:
|
||||
src = os.path.join(src_default, item)
|
||||
if os.path.exists(src):
|
||||
os.makedirs(dst_default, exist_ok=True)
|
||||
shutil.copy2(src, dst_default)
|
||||
print("Perfil copiado.")
|
||||
else:
|
||||
print("Usando perfil temporal existente.")
|
||||
|
||||
print("\n=== Zillow con sesión de Google ===")
|
||||
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()
|
||||
|
||||
# Ir a Zillow directamente
|
||||
print("Navegando a Zillow...")
|
||||
try:
|
||||
page.goto(
|
||||
"https://www.zillow.com/homes/for_sale/vero-beach-fl/?searchQueryState=%7B%22filterState%22%3A%7B%22price%22%3A%7B%22max%22%3A230000%7D%7D%7D",
|
||||
wait_until="load", timeout=45000
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Timeout en load (continuando): {e}")
|
||||
|
||||
human_delay(2, 3)
|
||||
|
||||
# Si hay captcha Cloudflare, esperar que el usuario lo resuelva
|
||||
print("Título actual:", page.title()[:60])
|
||||
if "denied" in page.title().lower() or "verification" in page.title().lower():
|
||||
print(">> Cloudflare challenge detectado. Resuélvelo en el browser (90s)...")
|
||||
time.sleep(90)
|
||||
|
||||
slow_scroll(page, 5)
|
||||
human_delay(1, 2)
|
||||
|
||||
# Buscar listings
|
||||
cards = wait_for_content(page, "[data-test='property-card']", timeout=30)
|
||||
print(f"\nListings Zillow: {len(cards)}")
|
||||
results = []
|
||||
for card in cards[:10]:
|
||||
txt = card.inner_text()
|
||||
price_m = re.search(r'\$[\d,]+', txt)
|
||||
addr_lines = txt.strip().split('\n')
|
||||
price = price_m.group() if price_m else "?"
|
||||
addr = addr_lines[0] if addr_lines else "?"
|
||||
print(f" {price} | {addr[:60]}")
|
||||
results.append({"price": price, "address": addr})
|
||||
|
||||
if results:
|
||||
with open("zillow_results.json", "w") as f:
|
||||
json.dump(results, f, indent=2)
|
||||
print(f"\nGuardado en zillow_results.json ({len(results)} propiedades)")
|
||||
else:
|
||||
content = page.content()
|
||||
prices = re.findall(r'"unformattedPrice":\s*(\d+)', content)
|
||||
print("Precios en HTML:", prices[:5])
|
||||
|
||||
input("\n[Enter para ir a Realtor.com]")
|
||||
|
||||
print("\n=== Realtor.com con sesión de Google ===")
|
||||
page.goto(
|
||||
"https://www.realtor.com/realestateandhomes-search/Vero-Beach_FL/price-na-230000",
|
||||
wait_until="load", timeout=45000
|
||||
)
|
||||
human_delay(2, 3)
|
||||
slow_scroll(page, 5)
|
||||
|
||||
cards2 = wait_for_content(page, "[data-testid='property-card-content']", timeout=30)
|
||||
print(f"\nListings Realtor: {len(cards2)}")
|
||||
results2 = []
|
||||
for card in cards2[:10]:
|
||||
txt = card.inner_text()
|
||||
price_m = re.search(r'\$[\d,]+', txt)
|
||||
lines = txt.strip().split('\n')
|
||||
price = price_m.group() if price_m else "?"
|
||||
addr = lines[0] if lines else "?"
|
||||
print(f" {price} | {addr[:60]}")
|
||||
results2.append({"price": price, "address": addr})
|
||||
|
||||
if results2:
|
||||
with open("realtor_results.json", "w") as f:
|
||||
json.dump(results2, f, indent=2)
|
||||
print(f"Guardado en realtor_results.json ({len(results2)} propiedades)")
|
||||
|
||||
input("\n[Enter para cerrar el browser]")
|
||||
ctx.close()
|
||||
@@ -0,0 +1,70 @@
|
||||
import re, time, random
|
||||
from playwright.sync_api import sync_playwright
|
||||
from playwright_stealth import Stealth
|
||||
|
||||
def human_delay(a=1.5, b=3.5):
|
||||
time.sleep(random.uniform(a, b))
|
||||
|
||||
def slow_scroll(page, steps=4):
|
||||
for _ in range(steps):
|
||||
page.mouse.wheel(0, random.randint(300, 700))
|
||||
time.sleep(random.uniform(0.5, 1.0))
|
||||
|
||||
print("=== Test Stealth Playwright - Zillow ===")
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(
|
||||
headless=False,
|
||||
args=["--disable-blink-features=AutomationControlled", "--start-maximized"]
|
||||
)
|
||||
ctx = browser.new_context(
|
||||
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||
locale="en-US",
|
||||
timezone_id="America/New_York",
|
||||
viewport={"width": 1366, "height": 768},
|
||||
)
|
||||
page = ctx.new_page()
|
||||
Stealth().apply_stealth_sync(page)
|
||||
|
||||
page.goto("https://www.zillow.com/homes/for_sale/vero-beach-fl/", wait_until="load", timeout=45000)
|
||||
human_delay(2, 4)
|
||||
slow_scroll(page, 5)
|
||||
human_delay(1, 2)
|
||||
|
||||
print("Title:", page.title()[:80])
|
||||
cards = page.query_selector_all("[data-test='property-card']")
|
||||
print(f"Property cards: {len(cards)}")
|
||||
for card in cards[:5]:
|
||||
print(" ", card.inner_text()[:120].replace('\n', ' | '))
|
||||
|
||||
if not cards:
|
||||
content = page.content()
|
||||
prices = re.findall(r'"unformattedPrice":\s*(\d+)', content)
|
||||
print("Prices in HTML:", prices[:5])
|
||||
|
||||
browser.close()
|
||||
|
||||
print("\n=== Test Stealth - Realtor.com ===")
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(
|
||||
headless=False,
|
||||
args=["--disable-blink-features=AutomationControlled", "--start-maximized"]
|
||||
)
|
||||
ctx = browser.new_context(
|
||||
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||
locale="en-US",
|
||||
timezone_id="America/New_York",
|
||||
)
|
||||
page = ctx.new_page()
|
||||
Stealth().apply_stealth_sync(page)
|
||||
|
||||
page.goto("https://www.realtor.com/realestateandhomes-search/Vero-Beach_FL/price-na-230000", wait_until="load", timeout=45000)
|
||||
human_delay(2, 4)
|
||||
slow_scroll(page, 5)
|
||||
|
||||
print("Title:", page.title()[:80])
|
||||
cards = page.query_selector_all("[data-testid='property-card-content']")
|
||||
print(f"Cards: {len(cards)}")
|
||||
for card in cards[:3]:
|
||||
print(" ", card.inner_text()[:120].replace('\n', ' | '))
|
||||
|
||||
browser.close()
|
||||
@@ -0,0 +1,87 @@
|
||||
import requests, json, re
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
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",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"Connection": "keep-alive",
|
||||
}
|
||||
|
||||
# 1. Test Brevard County Property Appraiser (government API - no anti-bot)
|
||||
print("=== Brevard County Property API ===")
|
||||
try:
|
||||
r = requests.get(
|
||||
"https://www.bcpao.us/api/search?activeOnly=true&saleAmountMax=230000"
|
||||
"&saleAmountMin=50000&town=MELBOURNE&count=10&page=1",
|
||||
headers=headers, timeout=15
|
||||
)
|
||||
print(f"Status: {r.status_code}, size: {len(r.text)}")
|
||||
print(r.text[:500])
|
||||
except Exception as e:
|
||||
print(f"ERROR: {e}")
|
||||
|
||||
# 2. Test Indian River County (Vero Beach) Property Appraiser
|
||||
print("\n=== Indian River County Property API ===")
|
||||
try:
|
||||
r = requests.get(
|
||||
"https://www.ircpa.net/propertysearch.aspx",
|
||||
headers=headers, timeout=15
|
||||
)
|
||||
print(f"Status: {r.status_code}, size: {len(r.text)}")
|
||||
except Exception as e:
|
||||
print(f"ERROR: {e}")
|
||||
|
||||
# 3. Test Point2Homes (no JS required usually)
|
||||
print("\n=== Point2Homes ===")
|
||||
try:
|
||||
r = requests.get(
|
||||
"https://www.point2homes.com/US/Real-Estate-Listings/FL/Indian-River-County.html"
|
||||
"?PriceMin=50000&PriceMax=230000",
|
||||
headers=headers, timeout=15
|
||||
)
|
||||
print(f"Status: {r.status_code}, size: {len(r.text)}")
|
||||
soup = BeautifulSoup(r.text, "html.parser")
|
||||
listings = soup.select(".item-listing, .listing, [class*='listing-card']")
|
||||
print(f"Listings found: {len(listings)}")
|
||||
for l in listings[:2]:
|
||||
print(f" {l.get_text()[:100].strip()}")
|
||||
except Exception as e:
|
||||
print(f"ERROR: {e}")
|
||||
|
||||
# 4. Test Homes.com
|
||||
print("\n=== Homes.com ===")
|
||||
try:
|
||||
r = requests.get(
|
||||
"https://www.homes.com/homes-for-sale/fl/vero-beach/?min_price=50000&max_price=230000",
|
||||
headers=headers, timeout=15
|
||||
)
|
||||
print(f"Status: {r.status_code}, size: {len(r.text)}")
|
||||
soup = BeautifulSoup(r.text, "html.parser")
|
||||
# Try to find embedded JSON data
|
||||
scripts = soup.find_all("script", type="application/json")
|
||||
print(f" JSON scripts found: {len(scripts)}")
|
||||
scripts2 = [s for s in soup.find_all("script") if s.string and "listPrice" in (s.string or "")]
|
||||
print(f" Scripts with listPrice: {len(scripts2)}")
|
||||
except Exception as e:
|
||||
print(f"ERROR: {e}")
|
||||
|
||||
# 5. Test HUD proper endpoint
|
||||
print("\n=== HUD Homes (correct endpoint) ===")
|
||||
try:
|
||||
# Try different HUD endpoints
|
||||
urls = [
|
||||
"https://www.hudhomestore.gov/HudHomes/SearchProperties.aspx?state=FL&county=Indian+River&maxprice=230000",
|
||||
"https://www.hudhomestore.gov/HudHomes/SearchProperties.aspx?state=FL&county=Brevard&maxprice=230000",
|
||||
]
|
||||
for url in urls:
|
||||
r = requests.get(url, headers=headers, timeout=15)
|
||||
soup = BeautifulSoup(r.text, "html.parser")
|
||||
listings = soup.select("tr.datarow, .propRow, [class*='prop-row']")
|
||||
prices = re.findall(r'\$[\d,]+', r.text)
|
||||
print(f" {url.split('county=')[1].split('&')[0]}: status={r.status_code}, listings={len(listings)}, prices found={len(prices)}")
|
||||
if prices:
|
||||
print(f" First prices: {prices[:5]}")
|
||||
except Exception as e:
|
||||
print(f"ERROR: {e}")
|
||||
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
Scraper Zillow: usa el search box como humano + extrae JSON de la página.
|
||||
"""
|
||||
import re, time, random, json
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
CHROME_PATH = r"C:\Program Files\Google\Chrome\Application\chrome.exe"
|
||||
TEMP_PROFILE = r"C:\Temp\chrome_casa_hunter"
|
||||
|
||||
def hd(a=1.0, b=2.5):
|
||||
time.sleep(random.uniform(a, b))
|
||||
|
||||
def scroll(page, steps=4):
|
||||
for _ in range(steps):
|
||||
page.mouse.wheel(0, random.randint(250, 550))
|
||||
time.sleep(random.uniform(0.4, 0.9))
|
||||
|
||||
def parse_listings(html, min_p=40000, max_p=230000):
|
||||
results = []
|
||||
m = re.search(r'<script[^>]*id="__NEXT_DATA__"[^>]*>(.*?)</script>', html, re.DOTALL)
|
||||
if m:
|
||||
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 = p.get("addressCity", "")
|
||||
state = p.get("addressState", "")
|
||||
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,
|
||||
"state": state,
|
||||
"zip": p.get("addressZipcode",""),
|
||||
"status": p.get("statusType",""),
|
||||
"url": "https://www.zillow.com" + p.get("detailUrl",""),
|
||||
"img": p.get("imgSrc",""),
|
||||
"type": p.get("hdpData",{}).get("homeInfo",{}).get("homeType",""),
|
||||
})
|
||||
except Exception as e:
|
||||
print(f" Parse error: {e}")
|
||||
return results
|
||||
|
||||
def search_city(page, city_query, max_price=230000):
|
||||
"""Busca una ciudad en Zillow usando el search box."""
|
||||
print(f"\n--- Buscando: {city_query} ---")
|
||||
try:
|
||||
# Ir a zillow.com
|
||||
page.goto("https://www.zillow.com", wait_until="load", timeout=30000)
|
||||
hd(1.5, 2.5)
|
||||
|
||||
# Encontrar el search box y escribir la ciudad
|
||||
search_box = page.query_selector("input[id*='search'], input[placeholder*='address'], input[placeholder*='city']")
|
||||
if not search_box:
|
||||
# Probar selectores alternativos
|
||||
search_box = page.query_selector("#search-box-input, [data-testid='search-input'], input[name='searchQueryState']")
|
||||
|
||||
if search_box:
|
||||
search_box.click()
|
||||
hd(0.3, 0.6)
|
||||
page.keyboard.down("Control")
|
||||
page.keyboard.press("a")
|
||||
page.keyboard.up("Control")
|
||||
hd(0.2, 0.4)
|
||||
page.keyboard.press("Delete")
|
||||
hd(0.3, 0.5)
|
||||
# Escribir como humano, caracter por caracter
|
||||
for char in city_query:
|
||||
page.keyboard.type(char)
|
||||
time.sleep(random.uniform(0.07, 0.18))
|
||||
hd(1.0, 1.8)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_load_state("load", timeout=30000)
|
||||
hd(2, 3)
|
||||
scroll(page, 4)
|
||||
hd(1, 2)
|
||||
else:
|
||||
# Si no encuentra search box, usar URL directamente
|
||||
city_slug = city_query.lower().replace(" ", "-").replace(",", "")
|
||||
url = f"https://www.zillow.com/homes/for_sale/{city_slug}/?searchQueryState=%7B%22filterState%22%3A%7B%22price%22%3A%7B%22max%22%3A{max_price}%2C%22min%22%3A40000%7D%7D%7D"
|
||||
page.goto(url, wait_until="load", timeout=45000)
|
||||
hd(2, 3)
|
||||
scroll(page, 4)
|
||||
|
||||
title = page.title()
|
||||
html = page.content()
|
||||
print(f" Título: {title[:60]}")
|
||||
|
||||
listings = parse_listings(html)
|
||||
print(f" Encontrados: {len(listings)} en rango $40K-$230K")
|
||||
for l in listings[:3]:
|
||||
print(f" ${l['price']:,} | {l.get('beds','?')}bd | {l['address'][:50]}, {l['city']}")
|
||||
return listings
|
||||
|
||||
except Exception as e:
|
||||
print(f" ERROR: {e}")
|
||||
return []
|
||||
|
||||
# Ciudades objetivo
|
||||
CITIES = [
|
||||
"Vero Beach, FL",
|
||||
"Melbourne, FL",
|
||||
"Jacksonville, FL",
|
||||
"Stuart, FL",
|
||||
"Daytona Beach, FL",
|
||||
"St. Augustine, FL",
|
||||
"Palm Coast, FL",
|
||||
"New Smyrna Beach, FL",
|
||||
]
|
||||
|
||||
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",
|
||||
"--no-default-browser-check",
|
||||
],
|
||||
viewport={"width": 1366, "height": 768},
|
||||
)
|
||||
page = ctx.new_page()
|
||||
|
||||
for city in CITIES:
|
||||
listings = search_city(page, city)
|
||||
all_results.extend(listings)
|
||||
hd(2, 4) # pausa entre ciudades
|
||||
|
||||
ctx.close()
|
||||
|
||||
# Deduplicar por dirección
|
||||
seen = set()
|
||||
unique = []
|
||||
for r in all_results:
|
||||
key = r["address"].lower().strip()
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(r)
|
||||
|
||||
print(f"\n=== TOTAL: {len(unique)} listings únicos en $40K-$230K ===")
|
||||
with open("zillow_final.json", "w", encoding="utf-8") as f:
|
||||
json.dump(unique, f, indent=2, ensure_ascii=False)
|
||||
print("Guardado en zillow_final.json")
|
||||
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
Scraper de Zillow usando el perfil real de Chrome.
|
||||
Extrae datos completos del JSON embebido en la página.
|
||||
"""
|
||||
import re, time, random, json, os
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
CHROME_PATH = r"C:\Program Files\Google\Chrome\Application\chrome.exe"
|
||||
TEMP_PROFILE = r"C:\Temp\chrome_casa_hunter"
|
||||
|
||||
def human_delay(a=1.5, b=3.5):
|
||||
time.sleep(random.uniform(a, b))
|
||||
|
||||
def slow_scroll(page, steps=4):
|
||||
for _ in range(steps):
|
||||
page.mouse.wheel(0, random.randint(300, 600))
|
||||
time.sleep(random.uniform(0.5, 1.0))
|
||||
|
||||
def parse_zillow_listings(html, max_price=230000, min_price=40000):
|
||||
"""Extrae listings del JSON embebido en Zillow."""
|
||||
results = []
|
||||
|
||||
# Buscar el JSON principal de Zillow (__NEXT_DATA__ o searchResults)
|
||||
match = re.search(r'<script[^>]*id="__NEXT_DATA__"[^>]*>(.*?)</script>', html, re.DOTALL)
|
||||
if match:
|
||||
try:
|
||||
data = json.loads(match.group(1))
|
||||
# Navegar en la estructura JSON de Zillow
|
||||
props = data.get("props", {})
|
||||
page_props = props.get("pageProps", {})
|
||||
search = page_props.get("searchPageState", {})
|
||||
cat1 = search.get("cat1", {})
|
||||
search_results = cat1.get("searchResults", {})
|
||||
list_results = search_results.get("listResults", [])
|
||||
|
||||
for prop in list_results:
|
||||
price = prop.get("unformattedPrice", 0)
|
||||
if not (min_price <= price <= max_price):
|
||||
continue
|
||||
results.append({
|
||||
"source": "zillow",
|
||||
"address": prop.get("address", "?"),
|
||||
"price": price,
|
||||
"beds": prop.get("beds", 0),
|
||||
"baths": prop.get("baths", 0),
|
||||
"sqft": prop.get("area", 0),
|
||||
"city": prop.get("addressCity", ""),
|
||||
"state": prop.get("addressState", ""),
|
||||
"zip": prop.get("addressZipcode", ""),
|
||||
"status": prop.get("statusType", ""),
|
||||
"url": "https://www.zillow.com" + prop.get("detailUrl", ""),
|
||||
"img": prop.get("imgSrc", ""),
|
||||
"property_type": prop.get("hdpData", {}).get("homeInfo", {}).get("homeType", ""),
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Error parsing __NEXT_DATA__: {e}")
|
||||
|
||||
if not results:
|
||||
# Fallback: buscar unformattedPrice directamente
|
||||
raw_prices = re.findall(r'"unformattedPrice":\s*(\d+)', html)
|
||||
raw_addrs = re.findall(r'"address":\s*"([^"]+)"', html)
|
||||
for i, (p, a) in enumerate(zip(raw_prices, raw_addrs)):
|
||||
price = int(p)
|
||||
if min_price <= price <= max_price:
|
||||
results.append({"source": "zillow", "price": price, "address": a})
|
||||
|
||||
return results
|
||||
|
||||
cities_fl = [
|
||||
("Vero Beach FL", "https://www.zillow.com/homes/for_sale/vero-beach-fl/?searchQueryState=%7B%22filterState%22%3A%7B%22price%22%3A%7B%22max%22%3A230000%2C%22min%22%3A40000%7D%7D%7D"),
|
||||
("Jacksonville FL", "https://www.zillow.com/homes/for_sale/jacksonville-fl/?searchQueryState=%7B%22filterState%22%3A%7B%22price%22%3A%7B%22max%22%3A230000%2C%22min%22%3A40000%7D%7D%7D"),
|
||||
("Melbourne FL", "https://www.zillow.com/homes/for_sale/melbourne-fl/?searchQueryState=%7B%22filterState%22%3A%7B%22price%22%3A%7B%22max%22%3A230000%2C%22min%22%3A40000%7D%7D%7D"),
|
||||
("Stuart FL", "https://www.zillow.com/homes/for_sale/stuart-fl/?searchQueryState=%7B%22filterState%22%3A%7B%22price%22%3A%7B%22max%22%3A230000%2C%22min%22%3A40000%7D%7D%7D"),
|
||||
]
|
||||
|
||||
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",
|
||||
"--no-default-browser-check",
|
||||
],
|
||||
viewport={"width": 1366, "height": 768},
|
||||
)
|
||||
page = ctx.new_page()
|
||||
|
||||
for city, url in cities_fl:
|
||||
print(f"\n--- {city} ---")
|
||||
try:
|
||||
page.goto(url, wait_until="load", timeout=45000)
|
||||
human_delay(2, 3)
|
||||
slow_scroll(page, 4)
|
||||
human_delay(1, 2)
|
||||
|
||||
html = page.content()
|
||||
title = page.title()
|
||||
print(f"Título: {title[:60]}")
|
||||
|
||||
listings = parse_zillow_listings(html)
|
||||
print(f"Listings encontrados: {len(listings)}")
|
||||
for l in listings[:5]:
|
||||
print(f" ${l['price']:,} | {l.get('beds','?')}bd | {l.get('address','?')[:50]}, {l.get('city','?')}, {l.get('state','?')}")
|
||||
all_results.extend(listings)
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR en {city}: {e}")
|
||||
|
||||
ctx.close()
|
||||
|
||||
print(f"\n=== TOTAL: {len(all_results)} listings en rango $40K-$230K ===")
|
||||
with open("zillow_all.json", "w", encoding="utf-8") as f:
|
||||
json.dump(all_results, f, indent=2, ensure_ascii=False)
|
||||
print("Guardado en zillow_all.json")
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user