Per user request, the LLM is no longer asked to extract rooms/size/rent/WBS — those come from the inberlinwohnen.de scraper which is reliable. Haiku is now used for one narrow job: pick which <img> URLs from the listing page are actual flat photos (vs. logos, badges, ads, employee portraits). On any LLM failure the unfiltered candidate list passes through. Image dedup runs in two tiers: 1. SHA256 of bytes — drops different URLs that point to byte-identical files 2. Perceptual hash (Pillow + imagehash, Hamming distance ≤ 5) — drops the "same image at a different resolution" duplicates from srcset / CDN variants that were filling galleries with 2–4× copies UI: - Wohnungsliste falls back to scraper-only display (rooms/size/rent/wbs) - Detail panel only shows images + "Zur Original-Anzeige →"; description / features / pros & cons / kv table are gone - Per-row "erneut versuchen" link + the "analysiert…/?" status chips were tied to LLM extraction and are removed; the header "Bilder nachladen (N)" button still surfaces pending/failed batches for admins Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
226 lines
11 KiB
HTML
226 lines
11 KiB
HTML
{# Renders the full Wohnungen body; used both by the full page and by HTMX poll. #}
|
|
<div id="wohnungen-body"
|
|
class="space-y-5"
|
|
hx-get="/partials/wohnungen"
|
|
hx-trigger="every {{ poll_interval }}s"
|
|
hx-swap="outerHTML">
|
|
|
|
<!-- Reihe 1: Info-Kacheln Alarm + Filter -->
|
|
<section class="grid grid-cols-2 gap-3">
|
|
<a class="card px-4 py-2.5 flex flex-col gap-0.5 hover:bg-[#f6fafd]" href="/einstellungen/benachrichtigungen">
|
|
<div class="text-[11px] uppercase tracking-wide text-slate-500">Alarm</div>
|
|
<div class="flex items-center gap-2">
|
|
<span class="chip chip-{{ alert_chip }}">{{ alert_label }}</span>
|
|
</div>
|
|
</a>
|
|
<a class="card px-4 py-2.5 flex flex-col gap-0.5 hover:bg-[#f6fafd]" href="/einstellungen/filter">
|
|
<div class="text-[11px] uppercase tracking-wide text-slate-500">Filter</div>
|
|
<div class="text-sm text-slate-700 truncate">{{ filter_summary }}</div>
|
|
</a>
|
|
</section>
|
|
|
|
<!-- Reihe 2: Schalter Automatisch bewerben + Final absenden -->
|
|
<section class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<form class="card p-4 flex items-center justify-between gap-3">
|
|
<input type="hidden" name="csrf" value="{{ csrf }}">
|
|
<div class="text-sm font-medium">Automatisch bewerben</div>
|
|
<label class="switch warn">
|
|
<input type="checkbox" name="value" value="on"
|
|
hx-post="/actions/auto-apply"
|
|
hx-trigger="change"
|
|
hx-include="closest form"
|
|
hx-target="#wohnungen-body"
|
|
hx-swap="outerHTML"
|
|
{% if not auto_apply_enabled %}hx-confirm="Automatisches Bewerben einschalten? Bei jedem passenden Flat wird automatisch beworben."{% endif %}
|
|
{% if auto_apply_enabled %}checked{% endif %}>
|
|
<span class="switch-visual"></span>
|
|
</label>
|
|
</form>
|
|
|
|
<form class="card p-4 flex items-center justify-between gap-3">
|
|
<input type="hidden" name="csrf" value="{{ csrf }}">
|
|
<div class="text-sm font-medium">Final absenden</div>
|
|
<label class="switch warn">
|
|
<input type="checkbox" name="value" value="on"
|
|
hx-post="/actions/submit-forms"
|
|
hx-trigger="change"
|
|
hx-include="closest form"
|
|
hx-target="#wohnungen-body"
|
|
hx-swap="outerHTML"
|
|
{% if not submit_forms %}hx-confirm="Final absenden einschalten? Formulare werden dann WIRKLICH abgeschickt!"{% endif %}
|
|
{% if submit_forms %}checked{% endif %}>
|
|
<span class="switch-visual"></span>
|
|
</label>
|
|
</form>
|
|
</section>
|
|
|
|
{% if not apply_allowed %}
|
|
<div class="card p-3 text-sm">
|
|
<span class="chip chip-bad">Bewerbungs-Dienst nicht erreichbar</span>
|
|
<span class="ml-2 text-slate-600">{{ apply_block_reason }}</span>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if circuit_open %}
|
|
<div class="card p-3 text-sm flex items-center justify-between">
|
|
<div>
|
|
<span class="chip chip-bad">Automatik pausiert</span>
|
|
<span class="ml-2 text-slate-600">{{ apply_failures }} Fehler in Folge</span>
|
|
</div>
|
|
<form method="post" action="/actions/reset-circuit"
|
|
hx-post="/actions/reset-circuit" hx-target="#wohnungen-body" hx-swap="outerHTML">
|
|
<input type="hidden" name="csrf" value="{{ csrf }}">
|
|
<button class="btn btn-ghost text-xs" type="submit">Zurücksetzen</button>
|
|
</form>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Header + View-Toggle (Liste/Karte) -->
|
|
<section class="flex items-center justify-between gap-4 flex-wrap">
|
|
<h2 class="font-semibold">Passende Wohnungen auf inberlinwohnen.de</h2>
|
|
<div class="flex items-center gap-3 text-xs text-slate-500">
|
|
<span>{{ flats|length }} gefunden</span>
|
|
{% if next_scrape_utc %}
|
|
<span class="sep">·</span>
|
|
<span>nächste Aktualisierung <span class="countdown" data-countdown-utc="{{ next_scrape_utc }}">…</span></span>
|
|
{% endif %}
|
|
{% if is_admin and (enrichment_counts.pending or enrichment_counts.failed) %}
|
|
<span class="sep">·</span>
|
|
<form method="post" action="/actions/enrich-all"
|
|
hx-post="/actions/enrich-all" hx-target="#wohnungen-body" hx-swap="outerHTML">
|
|
<input type="hidden" name="csrf" value="{{ csrf }}">
|
|
<button class="btn btn-ghost text-xs" type="submit"
|
|
hx-confirm="Bilder für ausstehende Wohnungen nachladen? Kann einige Minuten dauern.">
|
|
Bilder nachladen ({{ enrichment_counts.pending + enrichment_counts.failed }})
|
|
</button>
|
|
</form>
|
|
{% endif %}
|
|
<div class="view-toggle ml-2">
|
|
<label>
|
|
<input type="radio" name="view_mode" id="v_list" value="list" checked>
|
|
Liste
|
|
</label>
|
|
<label>
|
|
<input type="radio" name="view_mode" id="v_map" value="map">
|
|
Karte
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Karte (Leaflet-Container bleibt über HTMX-Swaps hinweg erhalten) -->
|
|
<section class="view-map">
|
|
<div class="card p-3">
|
|
<div id="flats-map" hx-preserve="true"></div>
|
|
</div>
|
|
</section>
|
|
<script id="flats-map-data" type="application/json" data-csrf="{{ csrf }}">{{ map_points | tojson }}</script>
|
|
|
|
<!-- Liste -->
|
|
<section class="view-list card">
|
|
<div>
|
|
{% for item in flats %}
|
|
{% set f = item.row %}
|
|
<div class="flat-row">
|
|
<div class="px-4 py-3 flex flex-col md:flex-row md:items-center gap-3">
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
<a class="font-medium truncate" href="{{ f.link }}" target="_blank" rel="noopener noreferrer">
|
|
{{ f.address or f.link }}
|
|
</a>
|
|
{% if item.last and item.last.finished_at is none %}
|
|
<span class="chip chip-warn">läuft…</span>
|
|
{% elif item.last and item.last.success == 1 %}<span class="chip chip-ok">beworben</span>
|
|
{% elif item.last and item.last.success == 0 %}<span class="chip chip-bad">fehlgeschlagen</span>
|
|
{% endif %}
|
|
</div>
|
|
<div class="text-xs text-slate-500 mt-0.5">
|
|
{% set parts = [] %}
|
|
{% if f.rooms %}{% set _ = parts.append('%g Z'|format(f.rooms)) %}{% endif %}
|
|
{% if f.size %}{% set _ = parts.append('%.0f m²'|format(f.size)) %}{% endif %}
|
|
{% if f.total_rent %}{% set _ = parts.append('%.0f €'|format(f.total_rent)) %}{% endif %}
|
|
{% if f.wbs == 'erforderlich' %}{% set _ = parts.append('WBS: erforderlich') %}
|
|
{% elif f.wbs == 'nicht erforderlich' %}{% set _ = parts.append('ohne WBS') %}
|
|
{% endif %}
|
|
{{ parts|join(' · ') }}{% if parts %} · {% endif %}<span data-rel-utc="{{ f.discovered_at|iso_utc }}" title="{{ f.discovered_at|de_dt }}">…</span>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-2 items-center">
|
|
{% if apply_allowed and not (item.last and item.last.success == 1) %}
|
|
{% set is_running = item.last and item.last.finished_at is none %}
|
|
<form method="post" action="/actions/apply"
|
|
hx-post="/actions/apply" hx-target="#wohnungen-body" hx-swap="outerHTML">
|
|
<input type="hidden" name="csrf" value="{{ csrf }}">
|
|
<input type="hidden" name="flat_id" value="{{ f.id }}">
|
|
<button class="btn btn-primary text-sm" type="submit"
|
|
{% if is_running %}disabled{% endif %}
|
|
hx-confirm="Bewerbung für {{ (f.address or f.link)|e }} starten?">
|
|
{% if is_running %}läuft…{% else %}Bewerben{% endif %}
|
|
</button>
|
|
</form>
|
|
{% endif %}
|
|
<form method="post" action="/actions/reject"
|
|
hx-post="/actions/reject" hx-target="#wohnungen-body" hx-swap="outerHTML">
|
|
<input type="hidden" name="csrf" value="{{ csrf }}">
|
|
<input type="hidden" name="flat_id" value="{{ f.id }}">
|
|
<button class="btn btn-ghost text-sm" type="submit"
|
|
hx-confirm="Ablehnen und aus der Liste entfernen?">
|
|
Ablehnen
|
|
</button>
|
|
</form>
|
|
<button type="button" class="flat-expand-btn" aria-label="Details"
|
|
data-flat-id="{{ f.id }}">
|
|
<svg width="14" height="14" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="5 8 10 13 15 8"/></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="flat-detail" id="flat-detail-{{ f.id|flat_slug }}" hx-preserve="true"></div>
|
|
</div>
|
|
{% else %}
|
|
<div class="px-4 py-8 text-center text-slate-500">
|
|
{% if not has_filters %}
|
|
Bitte zuerst Filter einstellen, damit passende Wohnungen angezeigt werden.
|
|
{% else %}
|
|
Aktuell keine Wohnung, die alle Filter erfüllt.
|
|
{% endif %}
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</section>
|
|
|
|
|
|
{% if rejected_flats %}
|
|
<section class="card">
|
|
<details class="group">
|
|
<summary class="px-4 py-3 text-sm font-medium flex items-center justify-between cursor-pointer">
|
|
<span>Abgelehnte Wohnungen</span>
|
|
<span class="text-xs text-slate-500">{{ rejected_flats|length }}</span>
|
|
</summary>
|
|
<div class="divide-y divide-soft border-t border-soft">
|
|
{% for f in rejected_flats %}
|
|
<div class="px-4 py-3 flex flex-col md:flex-row md:items-center gap-3">
|
|
<div class="flex-1 min-w-0">
|
|
<a class="font-medium truncate" href="{{ f.link }}" target="_blank" rel="noopener noreferrer">
|
|
{{ f.address or f.link }}
|
|
</a>
|
|
<div class="text-xs text-slate-500 mt-0.5">
|
|
{% 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 %}
|
|
· abgelehnt <span data-rel-utc="{{ f.rejected_at|iso_utc }}" title="{{ f.rejected_at|de_dt }}">…</span>
|
|
</div>
|
|
</div>
|
|
<form method="post" action="/actions/unreject"
|
|
hx-post="/actions/unreject" hx-target="#wohnungen-body" hx-swap="outerHTML">
|
|
<input type="hidden" name="csrf" value="{{ csrf }}">
|
|
<input type="hidden" name="flat_id" value="{{ f.id }}">
|
|
<button class="btn btn-ghost text-sm" type="submit">Wiederherstellen</button>
|
|
</form>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</details>
|
|
</section>
|
|
{% endif %}
|
|
|
|
</div>
|