feat: implement images in listings comparison
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.routers import comparisons, listings, rankings
|
||||
from app.routers import comparisons, images, listings, rankings
|
||||
|
||||
app = FastAPI(
|
||||
title="House ELO Ranking",
|
||||
@@ -20,6 +20,7 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
app.include_router(comparisons.router, prefix="/api", tags=["comparisons"])
|
||||
app.include_router(images.router, prefix="/api", tags=["images"])
|
||||
app.include_router(listings.router, prefix="/api", tags=["listings"])
|
||||
app.include_router(rankings.router, prefix="/api", tags=["rankings"])
|
||||
|
||||
|
||||
98
backend/app/routers/images.py
Normal file
98
backend/app/routers/images.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Image proxy – scrape Funda listing pages for photo URLs."""
|
||||
|
||||
import re
|
||||
import time
|
||||
import urllib.request
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Simple in-memory cache: global_id → (timestamp, image_urls)
|
||||
_cache: dict[str, tuple[float, list[str]]] = {}
|
||||
_CACHE_TTL = 3600 # 1 hour
|
||||
|
||||
|
||||
def _scrape_images(url: str) -> list[str]:
|
||||
"""Fetch a Funda listing page and extract image URLs."""
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
),
|
||||
"Accept": "text/html,application/xhtml+xml",
|
||||
},
|
||||
)
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
html = resp.read().decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
images: list[str] = []
|
||||
seen_bases: set[str] = set()
|
||||
|
||||
# Pattern 1: valentina_media images (main property photos)
|
||||
for match in re.finditer(
|
||||
r"https://cloud\.funda\.nl/valentina_media/(\d+/\d+/\d+)(?:\.jpg|_\d+x\d+\.jpg)",
|
||||
html,
|
||||
):
|
||||
base = match.group(1)
|
||||
if base not in seen_bases:
|
||||
seen_bases.add(base)
|
||||
images.append(
|
||||
f"https://cloud.funda.nl/valentina_media/{base}.jpg?options=width=720"
|
||||
)
|
||||
|
||||
# Pattern 2: listing-management images (newer uploads)
|
||||
for match in re.finditer(
|
||||
r"https://cloud\.funda\.nl/listing-management/([0-9a-f-]{36})",
|
||||
html,
|
||||
):
|
||||
uuid = match.group(1)
|
||||
if uuid not in seen_bases:
|
||||
seen_bases.add(uuid)
|
||||
images.append(
|
||||
f"https://cloud.funda.nl/listing-management/{uuid}?options=width=720"
|
||||
)
|
||||
|
||||
return images
|
||||
|
||||
|
||||
@router.get("/listings/{global_id}/images")
|
||||
def get_listing_images(
|
||||
global_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, list[str]]:
|
||||
"""Return image URLs for a listing, scraped from its Funda page."""
|
||||
# Check cache
|
||||
now = time.time()
|
||||
if global_id in _cache:
|
||||
ts, cached = _cache[global_id]
|
||||
if now - ts < _CACHE_TTL:
|
||||
return {"images": cached}
|
||||
|
||||
# Look up listing URL
|
||||
row = db.execute(
|
||||
text(
|
||||
f"SELECT url FROM {settings.LISTINGS_SCHEMA}.{settings.LISTINGS_TABLE} "
|
||||
f"WHERE global_id = :gid"
|
||||
),
|
||||
{"gid": global_id},
|
||||
).first()
|
||||
|
||||
if not row or not row.url:
|
||||
raise HTTPException(status_code=404, detail="Listing not found")
|
||||
|
||||
images = _scrape_images(row.url)
|
||||
_cache[global_id] = (now, images)
|
||||
|
||||
return {"images": images}
|
||||
@@ -4,14 +4,13 @@ services:
|
||||
container_name: elo-backend
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
expose:
|
||||
- "8000"
|
||||
network_mode: host
|
||||
|
||||
elo-frontend:
|
||||
build: ./frontend
|
||||
container_name: elo-frontend
|
||||
restart: unless-stopped
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-8888}:80"
|
||||
depends_on:
|
||||
- elo-backend
|
||||
|
||||
@@ -5,7 +5,7 @@ server {
|
||||
|
||||
# API requests → backend
|
||||
location /api/ {
|
||||
proxy_pass http://elo-backend:8000/api/;
|
||||
proxy_pass http://host.docker.internal:8000/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
@@ -113,4 +113,7 @@ export const api = {
|
||||
getStats: () => request<Stats>("/stats"),
|
||||
|
||||
getListings: () => request<Listing[]>("/listings"),
|
||||
|
||||
getListingImages: (globalId: string) =>
|
||||
request<{ images: string[] }>(`/listings/${globalId}/images`),
|
||||
};
|
||||
|
||||
110
frontend/src/components/ImageCarousel.tsx
Normal file
110
frontend/src/components/ImageCarousel.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
|
||||
interface Props {
|
||||
globalId: string;
|
||||
}
|
||||
|
||||
export default function ImageCarousel({ globalId }: Props) {
|
||||
const [images, setImages] = useState<string[]>([]);
|
||||
const [index, setIndex] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setIndex(0);
|
||||
api
|
||||
.getListingImages(globalId)
|
||||
.then((data) => setImages(data.images))
|
||||
.catch(() => setImages([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [globalId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="w-full aspect-[4/3] bg-slate-100 rounded-lg flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (images.length === 0) {
|
||||
return (
|
||||
<div className="w-full aspect-[4/3] bg-slate-100 rounded-lg flex items-center justify-center text-slate-400 text-sm">
|
||||
No images available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const prev = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIndex((i) => (i - 1 + images.length) % images.length);
|
||||
};
|
||||
|
||||
const next = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIndex((i) => (i + 1) % images.length);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full aspect-[4/3] bg-slate-100 rounded-lg overflow-hidden group">
|
||||
<img
|
||||
src={images[index]}
|
||||
alt={`Photo ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
{/* Left arrow */}
|
||||
{images.length > 1 && (
|
||||
<button
|
||||
onClick={prev}
|
||||
className="absolute left-1.5 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white rounded-full w-8 h-8 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
aria-label="Previous image"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Right arrow */}
|
||||
{images.length > 1 && (
|
||||
<button
|
||||
onClick={next}
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white rounded-full w-8 h-8 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
aria-label="Next image"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Counter */}
|
||||
{images.length > 1 && (
|
||||
<span className="absolute bottom-2 right-2 bg-black/60 text-white text-xs px-2 py-0.5 rounded-full">
|
||||
{index + 1} / {images.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Listing } from "../api";
|
||||
import ImageCarousel from "./ImageCarousel";
|
||||
|
||||
interface Props {
|
||||
listing: Listing;
|
||||
@@ -37,6 +38,11 @@ export default function ListingCard({
|
||||
: "border-slate-200 hover:border-indigo-400 hover:shadow-md"
|
||||
} ${disabled ? "opacity-70 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
>
|
||||
{/* Image carousel */}
|
||||
<div className="mb-4">
|
||||
<ImageCarousel globalId={listing.global_id} />
|
||||
</div>
|
||||
|
||||
{/* Title & link */}
|
||||
<div className="mb-3">
|
||||
<h3 className="font-bold text-lg text-slate-800 leading-tight">
|
||||
|
||||
Reference in New Issue
Block a user