From da5a455c36a469903f044da5d9fd7836ff967ac8 Mon Sep 17 00:00:00 2001 From: Stijnvandenbroek Date: Fri, 6 Mar 2026 12:53:06 +0000 Subject: [PATCH] feat: implement images in listings comparison --- backend/app/main.py | 3 +- backend/app/routers/images.py | 98 +++++++++++++++++++ docker-compose.yaml | 7 +- frontend/nginx.conf | 2 +- frontend/src/api.ts | 3 + frontend/src/components/ImageCarousel.tsx | 110 ++++++++++++++++++++++ frontend/src/components/ListingCard.tsx | 6 ++ 7 files changed, 223 insertions(+), 6 deletions(-) create mode 100644 backend/app/routers/images.py create mode 100644 frontend/src/components/ImageCarousel.tsx diff --git a/backend/app/main.py b/backend/app/main.py index 9398de3..1bc1e34 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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"]) diff --git a/backend/app/routers/images.py b/backend/app/routers/images.py new file mode 100644 index 0000000..af4791a --- /dev/null +++ b/backend/app/routers/images.py @@ -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} diff --git a/docker-compose.yaml b/docker-compose.yaml index 832c0c1..0e91ee3 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -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 diff --git a/frontend/nginx.conf b/frontend/nginx.conf index d987fb1..019f21e 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -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; } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 2811ba8..767bcbf 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -113,4 +113,7 @@ export const api = { getStats: () => request("/stats"), getListings: () => request("/listings"), + + getListingImages: (globalId: string) => + request<{ images: string[] }>(`/listings/${globalId}/images`), }; diff --git a/frontend/src/components/ImageCarousel.tsx b/frontend/src/components/ImageCarousel.tsx new file mode 100644 index 0000000..df72264 --- /dev/null +++ b/frontend/src/components/ImageCarousel.tsx @@ -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([]); + 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 ( +
+
+
+ ); + } + + if (images.length === 0) { + return ( +
+ No images available +
+ ); + } + + 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 ( +
+ {`Photo + + {/* Left arrow */} + {images.length > 1 && ( + + )} + + {/* Right arrow */} + {images.length > 1 && ( + + )} + + {/* Counter */} + {images.length > 1 && ( + + {index + 1} / {images.length} + + )} +
+ ); +} diff --git a/frontend/src/components/ListingCard.tsx b/frontend/src/components/ListingCard.tsx index 9619c2c..89eeaac 100644 --- a/frontend/src/components/ListingCard.tsx +++ b/frontend/src/components/ListingCard.tsx @@ -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 */} +
+ +
+ {/* Title & link */}