From eb66284172ed48baff4265a4477c2349bc514650 Mon Sep 17 00:00:00 2001 From: EiSiMo Date: Tue, 21 Apr 2026 14:46:12 +0200 Subject: [PATCH] enrichment: Haiku flat details + image gallery on expand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apply service - POST /internal/fetch-listing: headless Playwright fetch of a listing URL, returns {html, image_urls[], final_url}. Uses the same browser fingerprint/profile as the apply run so bot guards don't kick in web service - New enrichment pipeline (web/enrichment.py): /internal/flats → upsert → kick() enrichment in a background thread 1. POST /internal/fetch-listing on apply 2. llm.extract_flat_details(html, url) — Haiku tool-use call returns structured JSON (address, rooms, rent, description, pros/cons, etc.) 3. Download each image directly to /data/flats//NN. 4. Persist enrichment_json + image_count + enrichment_status on the flat - llm.py: minimal Anthropic /v1/messages wrapper, no SDK - DB migration v5 adds enrichment_json/_status/_updated_at + image_count - Admin "Altbestand anreichern" button (POST /actions/enrich-all) queues backfill for all pending/failed rows; runs in a detached task - GET /partials/wohnung/ renders _wohnung_detail.html - GET /flat-images// serves the downloaded image UI - Chevron on each list row toggles an inline detail pane (HTMX fetch on first open, hx-preserve keeps it open across the 3–30 s polls) - CSS .flat-gallery normalises image tiles to a 4/3 aspect with object-fit: cover so different source sizes align cleanly - "analysiert…" / "?" chips on the list reflect enrichment_status Config - ANTHROPIC_API_KEY + ANTHROPIC_MODEL wired into docker-compose's web service (default model: claude-haiku-4-5-20251001) Co-Authored-By: Claude Opus 4.7 (1M context) --- apply/main.py | 95 +++++++++++++++- docker-compose.yml | 2 + web/app.py | 66 ++++++++++++ web/db.py | 32 ++++++ web/enrichment.py | 168 +++++++++++++++++++++++++++++ web/llm.py | 119 ++++++++++++++++++++ web/settings.py | 4 + web/static/app.js | 34 ++++++ web/templates/_wohnung_detail.html | 82 ++++++++++++++ web/templates/_wohnungen_body.html | 107 ++++++++++-------- web/templates/base.html | 23 ++++ 11 files changed, 688 insertions(+), 44 deletions(-) create mode 100644 web/enrichment.py create mode 100644 web/llm.py create mode 100644 web/templates/_wohnung_detail.html diff --git a/apply/main.py b/apply/main.py index 59dbadb..ea1ec1f 100644 --- a/apply/main.py +++ b/apply/main.py @@ -1,8 +1,9 @@ import logging from contextlib import asynccontextmanager -from urllib.parse import urlparse +from urllib.parse import urljoin, urlparse from fastapi import Depends, FastAPI, Header, HTTPException, status +from playwright.async_api import ViewportSize, async_playwright from pydantic import BaseModel, Field from rich.console import Console from rich.logging import RichHandler @@ -13,7 +14,7 @@ from classes.application_result import ApplicationResult from classes.profile import Profile from language import _ from providers._provider import ApplyContext -from settings import INTERNAL_API_KEY +from settings import BROWSER_HEIGHT, BROWSER_LOCALE, BROWSER_WIDTH, HEADLESS, INTERNAL_API_KEY def setup_logging(): @@ -125,3 +126,93 @@ async def apply(req: ApplyRequest): application_id=req.application_id, forensics=recorder.to_json(), ) + + +class FetchListingRequest(BaseModel): + url: str + + +class FetchListingResponse(BaseModel): + final_url: str + html: str + image_urls: list[str] + + +MAX_FETCH_HTML_BYTES = 400_000 +MAX_FETCH_IMAGES = 30 + + +@app.post( + "/internal/fetch-listing", + response_model=FetchListingResponse, + dependencies=[Depends(require_api_key)], +) +async def fetch_listing(req: FetchListingRequest): + """Headless Playwright fetch of a flat listing — returns page HTML + + absolute image URLs. Used by the web service's LLM enrichment pipeline + so we look like a real browser and don't get bounced by bot guards.""" + url = req.url.strip() + if not url: + raise HTTPException(400, "url required") + logger.info("fetch-listing url=%s", url) + + async with async_playwright() as p: + browser = await p.chromium.launch( + headless=HEADLESS, + args=["--disable-blink-features=AutomationControlled"], + ) + try: + context = await browser.new_context( + viewport=ViewportSize({"width": BROWSER_WIDTH, "height": BROWSER_HEIGHT}), + locale=BROWSER_LOCALE, + ) + page = await context.new_page() + await page.goto(url, timeout=30_000) + try: + await page.wait_for_load_state("networkidle", timeout=10_000) + except Exception: + pass + final_url = page.url + html = await page.content() + # Collect image candidates: + + srcset first URL. + raw_imgs: list[str] = await page.evaluate( + """() => { + const out = []; + document.querySelectorAll('img').forEach((img) => { + if (img.src) out.push(img.src); + const ds = img.getAttribute('data-src'); + if (ds) out.push(ds); + const ss = img.getAttribute('srcset'); + if (ss) { + const first = ss.split(',')[0].trim().split(' ')[0]; + if (first) out.push(first); + } + }); + return out; + }""" + ) + finally: + await browser.close() + + # Absolutize, dedupe, drop tiny icons/data-uris. + seen: set[str] = set() + image_urls: list[str] = [] + for u in raw_imgs: + if not u or u.startswith("data:"): + continue + absu = urljoin(final_url, u) + if absu in seen: + continue + seen.add(absu) + lower = absu.lower() + if any(x in lower for x in ("logo", "favicon", "sprite", "icon", ".svg")): + continue + image_urls.append(absu) + if len(image_urls) >= MAX_FETCH_IMAGES: + break + + return FetchListingResponse( + final_url=final_url, + html=html[:MAX_FETCH_HTML_BYTES], + image_urls=image_urls, + ) diff --git a/docker-compose.yml b/docker-compose.yml index 10fc595..15b42a3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,8 @@ services: - SMTP_PASSWORD=${SMTP_PASSWORD:-} - SMTP_FROM=${SMTP_FROM:-lazyflat@localhost} - SMTP_STARTTLS=${SMTP_STARTTLS:-true} + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} + - ANTHROPIC_MODEL=${ANTHROPIC_MODEL:-claude-haiku-4-5-20251001} volumes: - lazyflat_data:/data expose: diff --git a/web/app.py b/web/app.py index dc292de..97f0160 100644 --- a/web/app.py +++ b/web/app.py @@ -15,6 +15,7 @@ import hmac import io import json import logging +import mimetypes import sqlite3 import zipfile from contextlib import asynccontextmanager @@ -33,6 +34,7 @@ except Exception: BERLIN_TZ = timezone.utc import db +import enrichment import notifications import retention from apply_client import ApplyClient, _row_to_profile @@ -119,6 +121,7 @@ def _iso_utc(s: str | None) -> str: templates.env.filters["de_dt"] = _de_dt templates.env.filters["iso_utc"] = _iso_utc +templates.env.filters["flat_slug"] = lambda s: enrichment.flat_slug(str(s or "")) @app.middleware("http") @@ -473,6 +476,53 @@ def partial_wohnungen(request: Request, user=Depends(require_user)): return templates.TemplateResponse("_wohnungen_body.html", ctx) +@app.get("/partials/wohnung/{flat_id:path}", response_class=HTMLResponse) +def partial_wohnung_detail(request: Request, flat_id: str, user=Depends(require_user)): + flat = db.get_flat(flat_id) + if not flat: + raise HTTPException(404) + enrichment_data = None + if flat["enrichment_json"]: + try: + enrichment_data = json.loads(flat["enrichment_json"]) + except Exception: + enrichment_data = None + slug = enrichment.flat_slug(flat_id) + image_urls = [ + f"/flat-images/{slug}/{i}" + for i in range(1, int(flat["image_count"] or 0) + 1) + ] + ctx = { + "request": request, + "flat": flat, + "enrichment": enrichment_data, + "enrichment_status": flat["enrichment_status"], + "image_urls": image_urls, + } + return templates.TemplateResponse("_wohnung_detail.html", ctx) + + +@app.get("/flat-images/{slug}/{index}") +def flat_image(slug: str, index: int): + """Serve a downloaded flat image by slug + 1-based index. + + `slug` is derived from enrichment.flat_slug(flat_id) and is filesystem-safe + (hex), so it can be composed into a path without sanitisation concerns.""" + if not slug.isalnum() or not 1 <= index <= 99: + raise HTTPException(404) + d = enrichment.IMAGES_DIR / slug + if not d.exists(): + raise HTTPException(404) + # Files are named NN.; try the usual extensions. + prefix = f"{index:02d}." + for f in d.iterdir(): + if f.name.startswith(prefix): + media = mimetypes.guess_type(f.name)[0] or "image/jpeg" + return Response(content=f.read_bytes(), media_type=media, + headers={"Cache-Control": "public, max-age=3600"}) + raise HTTPException(404) + + @app.post("/actions/filters") async def action_save_filters( request: Request, @@ -974,6 +1024,19 @@ async def action_users_disable( return RedirectResponse("/einstellungen/benutzer", status_code=303) +@app.post("/actions/enrich-all") +async def action_enrich_all( + request: Request, + csrf: str = Form(...), + admin=Depends(require_admin), +): + require_csrf(admin["id"], csrf) + queued = enrichment.kick_backfill() + db.log_audit(admin["username"], "enrichment.backfill", + f"queued={queued}", user_id=admin["id"], ip=client_ip(request)) + return _wohnungen_partial_or_redirect(request, admin) + + @app.post("/actions/users/delete") async def action_users_delete( request: Request, @@ -1010,6 +1073,9 @@ async def internal_submit_flat( if not is_new: return {"status": "duplicate"} + # Kick LLM enrichment + image download for this fresh flat. + enrichment.kick(str(payload["id"])) + for u in db.list_users(): if u["disabled"]: continue diff --git a/web/db.py b/web/db.py index b7b1459..065f8f6 100644 --- a/web/db.py +++ b/web/db.py @@ -195,6 +195,13 @@ MIGRATIONS: list[str] = [ ); CREATE INDEX IF NOT EXISTS idx_rejections_user ON flat_rejections(user_id); """, + # 0005: LLM enrichment — extracted details + downloaded image count per flat + """ + ALTER TABLE flats ADD COLUMN enrichment_json TEXT; + ALTER TABLE flats ADD COLUMN enrichment_status TEXT NOT NULL DEFAULT 'pending'; + ALTER TABLE flats ADD COLUMN enrichment_updated_at TEXT; + ALTER TABLE flats ADD COLUMN image_count INTEGER NOT NULL DEFAULT 0; + """, ] @@ -447,6 +454,31 @@ def get_flat(flat_id: str) -> Optional[sqlite3.Row]: return _conn.execute("SELECT * FROM flats WHERE id = ?", (flat_id,)).fetchone() +def set_flat_enrichment(flat_id: str, status: str, + enrichment: Optional[dict] = None, + image_count: int = 0) -> None: + with _lock: + _conn.execute( + """UPDATE flats SET enrichment_status = ?, + enrichment_json = ?, + enrichment_updated_at = ?, + image_count = ? + WHERE id = ?""", + (status, + json.dumps(enrichment) if enrichment is not None else None, + now_iso(), image_count, flat_id), + ) + + +def flats_needing_enrichment(limit: int = 100) -> list[sqlite3.Row]: + return list(_conn.execute( + """SELECT id, link FROM flats + WHERE enrichment_status IN ('pending', 'failed') + ORDER BY discovered_at DESC LIMIT ?""", + (limit,), + ).fetchall()) + + # --------------------------------------------------------------------------- # Applications # --------------------------------------------------------------------------- diff --git a/web/enrichment.py b/web/enrichment.py new file mode 100644 index 0000000..50e8f33 --- /dev/null +++ b/web/enrichment.py @@ -0,0 +1,168 @@ +"""Flat-enrichment pipeline. + +For each new flat we: +1. Ask the apply service to fetch the listing via Playwright (bypasses bot guards) +2. Feed the HTML to Haiku via `llm.extract_flat_details` → structured dict +3. Download each image URL directly into /data/flats//NN. +4. Persist result on the flat row (enrichment_json + image_count + status) + +Kicked as a detached asyncio task from /internal/flats so scraping stays fast. +A small queue cap + per-call lock would be next steps if we ever need them. +""" +from __future__ import annotations + +import asyncio +import hashlib +import logging +import mimetypes +import os +from pathlib import Path +from typing import Optional +from urllib.parse import urlparse + +import requests + +import db +import llm +from settings import DATA_DIR, INTERNAL_API_KEY + +logger = logging.getLogger("web.enrichment") + +APPLY_FETCH_URL = os.environ.get("APPLY_URL", "http://apply:8000") + "/internal/fetch-listing" +IMAGES_DIR = DATA_DIR / "flats" +IMAGES_DIR.mkdir(parents=True, exist_ok=True) + +MAX_IMAGES = 12 +MAX_IMAGE_BYTES = 3_000_000 # 3 MB per image +IMAGE_TIMEOUT = 15 + + +def flat_slug(flat_id: str) -> str: + """Filesystem-safe short identifier for a flat (IDs are URLs).""" + return hashlib.sha1(flat_id.encode("utf-8")).hexdigest()[:16] + + +def flat_image_dir(flat_id: str) -> Path: + d = IMAGES_DIR / flat_slug(flat_id) + d.mkdir(parents=True, exist_ok=True) + return d + + +def _fetch_listing(url: str) -> Optional[dict]: + try: + r = requests.post( + APPLY_FETCH_URL, + headers={"X-Internal-Api-Key": INTERNAL_API_KEY}, + json={"url": url}, + timeout=90, + ) + except requests.RequestException as e: + logger.warning("fetch-listing request failed for %s: %s", url, e) + return None + if r.status_code >= 400: + logger.warning("fetch-listing %s: %s", r.status_code, r.text[:300]) + return None + return r.json() + + +def _ext_from_response(resp: requests.Response, url: str) -> str: + ct = resp.headers.get("content-type", "").split(";")[0].strip().lower() + if ct: + ext = mimetypes.guess_extension(ct) or "" + if ext: + return ext.replace(".jpe", ".jpg") + path = urlparse(url).path + _, ext = os.path.splitext(path) + return ext.lower() or ".jpg" + + +def _download_images(flat_id: str, urls: list[str], referer: str) -> int: + d = flat_image_dir(flat_id) + # Clear any previous attempts so re-enrichment doesn't pile up dupes. + for old in d.iterdir(): + try: old.unlink() + except OSError: pass + + saved = 0 + for raw_url in urls[:MAX_IMAGES]: + try: + r = requests.get( + raw_url, + headers={"Referer": referer, + "User-Agent": "Mozilla/5.0 (lazyflat enricher)"}, + timeout=IMAGE_TIMEOUT, + stream=True, + ) + if r.status_code >= 400: + continue + ct = r.headers.get("content-type", "").split(";")[0].strip().lower() + if not ct.startswith("image/"): + continue + ext = _ext_from_response(r, raw_url) + path = d / f"{saved + 1:02d}{ext}" + total = 0 + with open(path, "wb") as f: + for chunk in r.iter_content(chunk_size=65_536): + if not chunk: + continue + total += len(chunk) + if total > MAX_IMAGE_BYTES: + break + f.write(chunk) + if total == 0: + path.unlink(missing_ok=True) + continue + saved += 1 + except requests.RequestException as e: + logger.info("image download failed %s: %s", raw_url, e) + continue + return saved + + +def enrich_flat_sync(flat_id: str) -> None: + """Run the full enrichment pipeline for one flat. Blocking.""" + flat = db.get_flat(flat_id) + if not flat: + return + url = flat["link"] + logger.info("enrich start flat=%s url=%s", flat_id, url) + listing = _fetch_listing(url) + if not listing: + db.set_flat_enrichment(flat_id, "failed") + return + + details = llm.extract_flat_details(listing.get("html") or "", + listing.get("final_url") or url) + if details is None: + db.set_flat_enrichment(flat_id, "failed") + return + + image_urls = listing.get("image_urls") or [] + image_count = _download_images(flat_id, image_urls, referer=url) + + db.set_flat_enrichment(flat_id, "ok", enrichment=details, image_count=image_count) + logger.info("enrich done flat=%s images=%d", flat_id, image_count) + + +def kick(flat_id: str) -> None: + """Fire-and-forget enrichment in a background thread.""" + asyncio.create_task(asyncio.to_thread(enrich_flat_sync, flat_id)) + + +async def _backfill_runner() -> None: + rows = db.flats_needing_enrichment(limit=200) + logger.info("enrich backfill: %d flats queued", len(rows)) + for row in rows: + try: + await asyncio.to_thread(enrich_flat_sync, row["id"]) + except Exception: + logger.exception("backfill step failed flat=%s", row["id"]) + + +def kick_backfill() -> int: + """Queue enrichment for every flat still pending/failed. Returns how many + flats are queued; the actual work happens in a detached task so the admin + UI doesn't block for minutes.""" + pending = db.flats_needing_enrichment(limit=200) + asyncio.create_task(_backfill_runner()) + return len(pending) diff --git a/web/llm.py b/web/llm.py new file mode 100644 index 0000000..491f0d3 --- /dev/null +++ b/web/llm.py @@ -0,0 +1,119 @@ +"""Minimal Anthropic Messages API wrapper for flat enrichment. + +Uses tool-use forced output so Haiku returns structured JSON instead of free +text we'd have to regex. No SDK — plain `requests` is enough here. +""" +from __future__ import annotations + +import logging +from typing import Any, Optional + +import requests + +from settings import ANTHROPIC_API_KEY, ANTHROPIC_MODEL + +logger = logging.getLogger("web.llm") + +API_URL = "https://api.anthropic.com/v1/messages" +API_VERSION = "2023-06-01" + +TOOL_NAME = "record_flat_details" +TOOL_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "address": {"type": ["string", "null"], + "description": "Full street address incl. postcode+city if present"}, + "rooms": {"type": ["number", "null"], "description": "Number of rooms (decimal ok)"}, + "size_sqm": {"type": ["number", "null"], "description": "Size in m²"}, + "rent_cold": {"type": ["number", "null"], "description": "Kaltmiete in €"}, + "rent_total": {"type": ["number", "null"], "description": "Warm/Gesamtmiete in €"}, + "utilities": {"type": ["number", "null"], "description": "Nebenkosten in €"}, + "deposit": {"type": ["number", "null"], "description": "Kaution in €"}, + "available_from": {"type": ["string", "null"], "description": "Bezugsfrei ab (text)"}, + "floor": {"type": ["string", "null"], "description": "Etage (text, z.B. '3. OG')"}, + "heating": {"type": ["string", "null"]}, + "energy_certificate": {"type": ["string", "null"]}, + "energy_value": {"type": ["string", "null"]}, + "year_built": {"type": ["string", "null"]}, + "wbs_required": {"type": ["boolean", "null"]}, + "wbs_type": {"type": ["string", "null"], "description": "WBS-Typ, z.B. '160' oder null"}, + "description": { + "type": ["string", "null"], + "description": "Kurze 2–3-Satz-Beschreibung der Wohnung auf Deutsch. Fakten, keine Werbesprache.", + }, + "features": { + "type": "array", "items": {"type": "string"}, + "description": "Ausstattungsmerkmale (z.B. 'Balkon', 'Einbauküche', 'Parkett')", + }, + "pros": { + "type": "array", "items": {"type": "string"}, + "description": "2–4 konkrete Vorteile aus Bewerbersicht (keine Werbung)", + }, + "cons": { + "type": "array", "items": {"type": "string"}, + "description": "2–4 mögliche Nachteile / Punkte zum Beachten", + }, + }, + "required": [], + "additionalProperties": False, +} + +SYSTEM_PROMPT = ( + "Du extrahierst strukturierte Wohnungsdaten aus deutschem HTML-Quelltext von " + "Berliner Wohnungsbaugesellschaften (howoge, gewobag, degewo, gesobau, wbm, " + "stadt-und-land). Antworte AUSSCHLIESSLICH über den bereitgestellten Tool-Call. " + "Fehlende Werte → null. Keine Erfindungen — wenn etwas nicht klar aus dem HTML " + "hervorgeht, lass das Feld null. Zahlen bitte als Zahlen (nicht als String), " + "Beschreibung/Pros/Cons auf Deutsch." +) + + +def extract_flat_details(html: str, url: str, + max_html_chars: int = 60_000, + timeout: int = 60) -> Optional[dict]: + """Call Haiku; return the structured dict or None on failure.""" + if not ANTHROPIC_API_KEY: + logger.info("skipping enrichment: ANTHROPIC_API_KEY not set") + return None + + user_content = ( + f"URL: {url}\n\n" + f"HTML-Quellcode (ggf. gekürzt):\n---\n{html[:max_html_chars]}\n---" + ) + body = { + "model": ANTHROPIC_MODEL, + "max_tokens": 1500, + "system": SYSTEM_PROMPT, + "tools": [{ + "name": TOOL_NAME, + "description": "Persist the extracted flat details.", + "input_schema": TOOL_SCHEMA, + }], + "tool_choice": {"type": "tool", "name": TOOL_NAME}, + "messages": [{"role": "user", "content": user_content}], + } + try: + r = requests.post( + API_URL, + headers={ + "x-api-key": ANTHROPIC_API_KEY, + "anthropic-version": API_VERSION, + "content-type": "application/json", + }, + json=body, + timeout=timeout, + ) + except requests.RequestException as e: + logger.warning("anthropic request failed: %s", e) + return None + + if r.status_code >= 400: + logger.warning("anthropic %s: %s", r.status_code, r.text[:300]) + return None + + data = r.json() + for block in data.get("content", []): + if block.get("type") == "tool_use" and block.get("name") == TOOL_NAME: + return block.get("input") or {} + logger.warning("anthropic returned no tool_use block: %s", data) + return None diff --git a/web/settings.py b/web/settings.py index a33d45d..2eaef8b 100644 --- a/web/settings.py +++ b/web/settings.py @@ -63,3 +63,7 @@ SMTP_STARTTLS: bool = getenv("SMTP_STARTTLS", "true").lower() in ("true", "1", " # --- App URL (used to build links in notifications) --------------------------- PUBLIC_URL: str = getenv("PUBLIC_URL", "https://flat.lab.moritz.run") + +# --- LLM enrichment (Anthropic Haiku) ----------------------------------------- +ANTHROPIC_API_KEY: str = getenv("ANTHROPIC_API_KEY", "") +ANTHROPIC_MODEL: str = getenv("ANTHROPIC_MODEL", "claude-haiku-4-5-20251001") diff --git a/web/static/app.js b/web/static/app.js index 037745c..2c7662d 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -47,3 +47,37 @@ document.addEventListener("DOMContentLoaded", tick); document.body && document.body.addEventListener("htmx:afterSwap", tick); setInterval(updateCountdowns, 1000); setInterval(updateRelativeTimes, 5000); + +// Flat detail expand — lazily fetches /partials/wohnung/ into the sibling +// .flat-detail container on first open, toggles visibility on subsequent clicks. +// Event delegation survives HTMX swaps without re-binding on each poll. +document.addEventListener("click", (ev) => { + const btn = ev.target.closest(".flat-expand-btn"); + if (!btn) return; + const row = btn.closest(".flat-row"); + if (!row) return; + const pane = row.querySelector(".flat-detail"); + if (!pane) return; + + if (btn.classList.contains("open")) { + pane.style.display = "none"; + btn.classList.remove("open"); + return; + } + btn.classList.add("open"); + pane.style.display = "block"; + if (pane.dataset.loaded) return; + + pane.innerHTML = '
lädt…
'; + const flatId = btn.dataset.flatId || ""; + fetch("/partials/wohnung/" + encodeURIComponent(flatId), + { headers: { "HX-Request": "true" } }) + .then((r) => r.text()) + .then((html) => { + pane.innerHTML = html; + pane.dataset.loaded = "1"; + }) + .catch(() => { + pane.innerHTML = '
Detail konnte nicht geladen werden.
'; + }); +}); diff --git a/web/templates/_wohnung_detail.html b/web/templates/_wohnung_detail.html new file mode 100644 index 0000000..b8d9152 --- /dev/null +++ b/web/templates/_wohnung_detail.html @@ -0,0 +1,82 @@ +{# Expanded detail for a single flat, loaded into #flat-detail- via HTMX. #} +{% if enrichment_status == 'pending' %} +
Analyse läuft – kommt in wenigen Augenblicken zurück…
+{% elif enrichment_status == 'failed' %} +
+ Detail-Analyse konnte nicht abgerufen werden. + Zur Original-Anzeige → +
+{% else %} +
+ {% if image_urls %} + + {% endif %} + + {% if enrichment and enrichment.description %} +

{{ enrichment.description }}

+ {% endif %} + + {% if enrichment %} +
+ {% macro kv(label, value) %} + {% if value is not none and value != '' %} +
+ {{ label }} + {{ value }} +
+ {% endif %} + {% endmacro %} + {{ kv('Adresse', enrichment.address) }} + {{ kv('Zimmer', enrichment.rooms) }} + {{ kv('Größe', enrichment.size_sqm ~ ' m²' if enrichment.size_sqm else none) }} + {{ kv('Kaltmiete', enrichment.rent_cold ~ ' €' if enrichment.rent_cold else none) }} + {{ kv('Nebenkosten', enrichment.utilities ~ ' €' if enrichment.utilities else none) }} + {{ kv('Gesamtmiete', enrichment.rent_total ~ ' €' if enrichment.rent_total else none) }} + {{ kv('Kaution', enrichment.deposit ~ ' €' if enrichment.deposit else none) }} + {{ kv('Bezugsfrei ab', enrichment.available_from) }} + {{ kv('Etage', enrichment.floor) }} + {{ kv('Heizung', enrichment.heating) }} + {{ kv('Energieausweis', enrichment.energy_certificate) }} + {{ kv('Energiewert', enrichment.energy_value) }} + {{ kv('Baujahr', enrichment.year_built) }} + {{ kv('WBS', 'erforderlich' if enrichment.wbs_required else ('nicht erforderlich' if enrichment.wbs_required == false else none)) }} + {{ kv('WBS-Typ', enrichment.wbs_type) }} +
+ {% endif %} + + {% if enrichment and enrichment.features %} +
+ {% for f in enrichment.features %}{{ f }}{% endfor %} +
+ {% endif %} + +
+ {% if enrichment and enrichment.pros %} +
+
Pro
+
    + {% for p in enrichment.pros %}
  • + {{ p }}
  • {% endfor %} +
+
+ {% endif %} + {% if enrichment and enrichment.cons %} +
+
Contra
+
    + {% for c in enrichment.cons %}
  • − {{ c }}
  • {% endfor %} +
+
+ {% endif %} +
+ + +
+{% endif %} diff --git a/web/templates/_wohnungen_body.html b/web/templates/_wohnungen_body.html index f733845..6c26aaa 100644 --- a/web/templates/_wohnungen_body.html +++ b/web/templates/_wohnungen_body.html @@ -106,53 +106,63 @@
-
+
{% for item in flats %} {% set f = item.row %} -
-
-
- - {{ f.address or f.link }} - - {% if item.last and item.last.finished_at is none %} - läuft… - {% elif item.last and item.last.success == 1 %}beworben - {% elif item.last and item.last.success == 0 %}fehlgeschlagen +
+
+
+
+ + {{ f.address or f.link }} + + {% if item.last and item.last.finished_at is none %} + läuft… + {% elif item.last and item.last.success == 1 %}beworben + {% elif item.last and item.last.success == 0 %}fehlgeschlagen + {% endif %} + {% if f.enrichment_status == 'pending' %}analysiert… + {% elif f.enrichment_status == 'failed' %}? + {% endif %} +
+
+ {% if f.rooms %}{{ "%.1f"|format(f.rooms) }} Z{% endif %} + {% if f.size %} · {{ "%.0f"|format(f.size) }} m²{% endif %} + {% if f.total_rent %} · {{ "%.0f"|format(f.total_rent) }} €{% endif %} + {% if f.wbs %} · WBS: {{ f.wbs }}{% endif %} + · +
+
+
+ {% if apply_allowed and not (item.last and item.last.success == 1) %} + {% set is_running = item.last and item.last.finished_at is none %} +
+ + + +
{% endif %} -
-
- {% if f.rooms %}{{ "%.1f"|format(f.rooms) }} Z{% endif %} - {% if f.size %} · {{ "%.0f"|format(f.size) }} m²{% endif %} - {% if f.total_rent %} · {{ "%.0f"|format(f.total_rent) }} €{% endif %} - {% if f.wbs %} · WBS: {{ f.wbs }}{% endif %} - · +
+ + + +
+
-
- {% if apply_allowed and not (item.last and item.last.success == 1) %} - {% set is_running = item.last and item.last.finished_at is none %} -
- - - -
- {% endif %} -
- - - -
-
+
{% else %}
@@ -166,6 +176,19 @@
+{% if is_admin %} +
+
+ + +
+
+{% endif %} + {% if rejected_flats %}
diff --git a/web/templates/base.html b/web/templates/base.html index 0e56f05..963821d 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -83,6 +83,29 @@ body:has(#v_map:checked) .view-map { display: block; } #flats-map { height: 520px; border-radius: 10px; } + /* Flat detail expand */ + .flat-row { border-top: 1px solid var(--border); } + .flat-row:first-child { border-top: 0; } + .flat-expand-btn { width: 1.75rem; height: 1.75rem; border-radius: 999px; + display: inline-flex; align-items: center; justify-content: center; + border: 1px solid var(--border); background: var(--surface); + color: var(--muted); cursor: pointer; transition: transform .2s, background .15s; } + .flat-expand-btn:hover { background: var(--ghost); color: var(--text); } + .flat-expand-btn.open { transform: rotate(180deg); } + .flat-detail { background: #fafcfe; border-top: 1px solid var(--border); } + .flat-detail:empty { display: none; } + + /* Normalised image gallery — every tile has the same aspect ratio */ + .flat-gallery { display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 8px; } + .flat-gallery-tile { aspect-ratio: 4 / 3; overflow: hidden; + border-radius: 8px; border: 1px solid var(--border); + background: #f0f5fa; display: block; } + .flat-gallery-tile img { width: 100%; height: 100%; object-fit: cover; + display: block; transition: transform .3s; } + .flat-gallery-tile:hover img { transform: scale(1.04); } + /* Leaflet popup — match site visual */ .leaflet-popup-content-wrapper { border-radius: 12px; box-shadow: 0 6px 20px rgba(16,37,63,.15); } .leaflet-popup-content { margin: 12px 14px; min-width: 220px; color: var(--text); }