feat: implement images in listings comparison

This commit is contained in:
Stijnvandenbroek
2026-03-06 12:53:06 +00:00
parent e1a67da3ce
commit da5a455c36
7 changed files with 223 additions and 6 deletions

View File

@@ -3,7 +3,7 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.routers import comparisons, listings, rankings from app.routers import comparisons, images, listings, rankings
app = FastAPI( app = FastAPI(
title="House ELO Ranking", title="House ELO Ranking",
@@ -20,6 +20,7 @@ app.add_middleware(
) )
app.include_router(comparisons.router, prefix="/api", tags=["comparisons"]) 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(listings.router, prefix="/api", tags=["listings"])
app.include_router(rankings.router, prefix="/api", tags=["rankings"]) app.include_router(rankings.router, prefix="/api", tags=["rankings"])

View 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}

View File

@@ -4,14 +4,13 @@ services:
container_name: elo-backend container_name: elo-backend
restart: unless-stopped restart: unless-stopped
env_file: .env env_file: .env
expose: network_mode: host
- "8000"
elo-frontend: elo-frontend:
build: ./frontend build: ./frontend
container_name: elo-frontend container_name: elo-frontend
restart: unless-stopped restart: unless-stopped
extra_hosts:
- "host.docker.internal:host-gateway"
ports: ports:
- "${FRONTEND_PORT:-8888}:80" - "${FRONTEND_PORT:-8888}:80"
depends_on:
- elo-backend

View File

@@ -5,7 +5,7 @@ server {
# API requests → backend # API requests → backend
location /api/ { 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 Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
} }

View File

@@ -113,4 +113,7 @@ export const api = {
getStats: () => request<Stats>("/stats"), getStats: () => request<Stats>("/stats"),
getListings: () => request<Listing[]>("/listings"), getListings: () => request<Listing[]>("/listings"),
getListingImages: (globalId: string) =>
request<{ images: string[] }>(`/listings/${globalId}/images`),
}; };

View 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>
);
}

View File

@@ -1,4 +1,5 @@
import type { Listing } from "../api"; import type { Listing } from "../api";
import ImageCarousel from "./ImageCarousel";
interface Props { interface Props {
listing: Listing; listing: Listing;
@@ -37,6 +38,11 @@ export default function ListingCard({
: "border-slate-200 hover:border-indigo-400 hover:shadow-md" : "border-slate-200 hover:border-indigo-400 hover:shadow-md"
} ${disabled ? "opacity-70 cursor-not-allowed" : "cursor-pointer"}`} } ${disabled ? "opacity-70 cursor-not-allowed" : "cursor-pointer"}`}
> >
{/* Image carousel */}
<div className="mb-4">
<ImageCarousel globalId={listing.global_id} />
</div>
{/* Title & link */} {/* Title & link */}
<div className="mb-3"> <div className="mb-3">
<h3 className="font-bold text-lg text-slate-800 leading-tight"> <h3 className="font-bold text-lg text-slate-800 leading-tight">