per-step screenshot + html snapshots, matches-only list, full German UI, CSV export

* apply: Recorder.step_snap(page, name) captures both a JPEG screenshot and
  the page HTML for every major moment; every provider now calls step_snap at
  each logical step so failure reports contain the exact DOM and rendered
  state at every stage of the flow
* ZIP report: each snapshot becomes snapshots/NN_<label>.jpg +
  snapshots/NN_<label>.html for AI-assisted debugging
* web: Wohnungsliste zeigt nur noch Flats, die die eigenen Filter treffen;
  Match-Chip entfernt (Liste ist jetzt implizit matchend)
* UI komplett auf Deutsch: Protokoll statt Logs, Administrator statt admin,
  Trockenmodus statt dry-run, Automatik pausiert statt circuit open,
  Alarm statt Alert, Abmelden statt Logout
* Wohnungen-Header: Zeile 1 Info (Alarm + Filter), Zeile 2 Schalter mit
  echten Radio-Paaren (An/Aus) für Automatisch bewerben und Trockenmodus;
  hx-confirm auf den kritischen Radios; per-form CSS für sichtbaren Check-State
* Protokoll: von/bis-Datumsfilter (Berliner Zeit) + CSV-Download
  (/logs/export.csv) mit UTC + lokaler Zeit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Moritz 2026-04-21 11:40:12 +02:00
parent 04b591fa9e
commit 7444f90d6a
16 changed files with 360 additions and 202 deletions

View file

@ -31,6 +31,7 @@ MAX_CONSOLE_ENTRIES = 200
MAX_NETWORK_ENTRIES = 150 MAX_NETWORK_ENTRIES = 150
MAX_BODY_SNIPPET = 2000 MAX_BODY_SNIPPET = 2000
MAX_HTML_DUMP = 200_000 # 200 KB MAX_HTML_DUMP = 200_000 # 200 KB
MAX_SCREENSHOTS = 40
SCREENSHOT_JPEG_QUALITY = 60 SCREENSHOT_JPEG_QUALITY = 60
@ -117,21 +118,33 @@ class Recorder:
page.on("request", on_request) page.on("request", on_request)
page.on("response", lambda r: asyncio.create_task(on_response(r))) page.on("response", lambda r: asyncio.create_task(on_response(r)))
# --- screenshots -------------------------------------------------------- # --- screenshots + html dump -------------------------------------------
async def snap(self, page, label: str) -> None: async def snap(self, page, label: str) -> None:
"""Capture screenshot + full page HTML for this moment."""
if len(self.screenshots) >= MAX_SCREENSHOTS:
return
ts = round(time.time() - self.started_at, 3)
entry = {"ts": ts, "label": label, "url": page.url,
"b64_jpeg": "", "size": 0, "html": "", "html_size": 0}
try: try:
data = await page.screenshot(type="jpeg", quality=SCREENSHOT_JPEG_QUALITY, img = await page.screenshot(type="jpeg", quality=SCREENSHOT_JPEG_QUALITY,
full_page=False, timeout=5000) full_page=False, timeout=5000)
b64 = base64.b64encode(data).decode("ascii") entry["b64_jpeg"] = base64.b64encode(img).decode("ascii")
self.screenshots.append({ entry["size"] = len(img)
"ts": round(time.time() - self.started_at, 3),
"label": label,
"url": page.url,
"b64_jpeg": b64,
"size": len(data),
})
except Exception as e: except Exception as e:
logger.warning("snap failed (%s): %s", label, e) logger.warning("snap screenshot failed (%s): %s", label, e)
try:
html = await page.content()
entry["html"] = html[:MAX_HTML_DUMP]
entry["html_size"] = len(html)
except Exception as e:
logger.warning("snap html failed (%s): %s", label, e)
self.screenshots.append(entry)
async def step_snap(self, page, name: str, detail: str = "", status: str = "ok") -> None:
"""Log a step AND capture a screenshot + HTML for it."""
self.step(name, status, detail)
await self.snap(page, name)
async def finalize(self, page) -> None: async def finalize(self, page) -> None:
try: try:

View file

@ -17,29 +17,34 @@ class Degewo(Provider):
r = ctx.recorder r = ctx.recorder
p = ctx.profile p = ctx.profile
async with open_page(url, recorder=r) as page: async with open_page(url, recorder=r) as page:
r.step("cookies.check") await r.step_snap(page, "cookies.check")
cookie_accept_btn = page.locator("#cookie-consent-submit-all") cookie_accept_btn = page.locator("#cookie-consent-submit-all")
if await cookie_accept_btn.is_visible(): if await cookie_accept_btn.is_visible():
await cookie_accept_btn.click() await cookie_accept_btn.click()
r.step("cookies.accepted") await r.step_snap(page, "cookies.accepted")
r.step("page.404_check") await r.step_snap(page, "page.404_check")
if page.url == "https://www.degewo.de/immosuche/404": if page.url == "https://www.degewo.de/immosuche/404":
await r.step_snap(page, "abort.not_found", status="warn")
return ApplicationResult(False, message=_("ad_offline")) return ApplicationResult(False, message=_("ad_offline"))
r.step("ad.deactivated_check") await r.step_snap(page, "ad.deactivated_check")
if await page.locator("span", has_text="Inserat deaktiviert").is_visible(): if await page.locator("span", has_text="Inserat deaktiviert").is_visible():
await r.step_snap(page, "abort.deactivated", status="warn")
return ApplicationResult(False, message=_("ad_deactivated")) return ApplicationResult(False, message=_("ad_deactivated"))
r.step("page.moved_check") await r.step_snap(page, "page.moved_check")
if await page.locator("h1", has_text="Diese Seite ist umgezogen!").is_visible(): if await page.locator("h1", has_text="Diese Seite ist umgezogen!").is_visible():
await r.step_snap(page, "abort.moved", status="warn")
return ApplicationResult(False, message=_("ad_offline")) return ApplicationResult(False, message=_("ad_offline"))
r.step("form.open"); await page.get_by_role("link", name="Kontakt").click() await r.step_snap(page, "form.open")
r.step("iframe.locate") await page.get_by_role("link", name="Kontakt").click()
await r.step_snap(page, "iframe.locate")
form_frame = page.frame_locator("iframe[src*='wohnungshelden']") form_frame = page.frame_locator("iframe[src*='wohnungshelden']")
r.step("form.fill_personal") await r.step_snap(page, "form.fill_personal")
await form_frame.locator("#salutation").fill(p.salutation) await form_frame.locator("#salutation").fill(p.salutation)
await form_frame.get_by_role("option", name=p.salutation, exact=True).click() await form_frame.get_by_role("option", name=p.salutation, exact=True).click()
await form_frame.locator("#firstName").fill(p.firstname) await form_frame.locator("#firstName").fill(p.firstname)
@ -49,11 +54,11 @@ class Degewo(Provider):
await form_frame.locator("input[title='Anzahl einziehende Personen']").fill(str(p.person_count)) await form_frame.locator("input[title='Anzahl einziehende Personen']").fill(str(p.person_count))
await page.wait_for_timeout(1000) await page.wait_for_timeout(1000)
r.step("form.wbs_section") await r.step_snap(page, "form.wbs_section")
wbs_question = form_frame.locator("input[id*='wbs_available'][id$='Ja']") wbs_question = form_frame.locator("input[id*='wbs_available'][id$='Ja']")
if await wbs_question.is_visible(): if await wbs_question.is_visible():
if not p.is_possessing_wbs: if not p.is_possessing_wbs:
r.step("wbs_required_abort", "warn") await r.step_snap(page, "wbs_required_abort", status="warn")
return ApplicationResult(False, _("wbs_required")) return ApplicationResult(False, _("wbs_required"))
await wbs_question.click() await wbs_question.click()
await form_frame.locator("input[title='WBS gültig bis']").fill(p.wbs_valid_till_dt().strftime("%d.%m.%Y")) await form_frame.locator("input[title='WBS gültig bis']").fill(p.wbs_valid_till_dt().strftime("%d.%m.%Y"))
@ -66,24 +71,26 @@ class Degewo(Provider):
await form_frame.locator("ng-select[id*='fuer_wen_ist_wohnungsanfrage']").click() await form_frame.locator("ng-select[id*='fuer_wen_ist_wohnungsanfrage']").click()
await page.wait_for_timeout(1000) await page.wait_for_timeout(1000)
await form_frame.get_by_role("option", name="Für mich selbst").click() await form_frame.get_by_role("option", name="Für mich selbst").click()
await r.step_snap(page, "form.filled")
if not ctx.submit_forms: if not ctx.submit_forms:
r.step("submit.dry_run") await r.step_snap(page, "submit.dry_run")
return ApplicationResult(success=True, message=_("application_success_dry")) return ApplicationResult(success=True, message=_("application_success_dry"))
r.step("submit.click"); await form_frame.locator("button[data-cy*='btn-submit']").click() await r.step_snap(page, "submit.click")
await form_frame.locator("button[data-cy*='btn-submit']").click()
await page.wait_for_timeout(3000) await page.wait_for_timeout(3000)
r.step("success.check") await r.step_snap(page, "success.check")
if await page.locator("h4", has_text="Vielen Dank für das Übermitteln Ihrer Informationen. Sie können dieses Fenster jetzt schließen.").is_visible(): if await page.locator("h4", has_text="Vielen Dank für das Übermitteln Ihrer Informationen. Sie können dieses Fenster jetzt schließen.").is_visible():
return ApplicationResult(success=True) return ApplicationResult(success=True)
elif await self._missing_fields_warning(page): elif await self._missing_fields_warning(page):
r.step("missing_fields_detected", "warn") await r.step_snap(page, "missing_fields_detected", status="warn")
return ApplicationResult(success=False, message=_("missing_fields")) return ApplicationResult(success=False, message=_("missing_fields"))
elif await self._already_applied_warning(page): elif await self._already_applied_warning(page):
r.step("already_applied_detected", "warn") await r.step_snap(page, "already_applied_detected", status="warn")
return ApplicationResult(success=False, message=_("already_applied")) return ApplicationResult(success=False, message=_("already_applied"))
r.step("success.not_found", "warn") await r.step_snap(page, "success.not_found", status="warn")
return ApplicationResult(success=False, message=_("submit_conformation_msg_not_found")) return ApplicationResult(success=False, message=_("submit_conformation_msg_not_found"))
async def _already_applied_warning(self, page) -> bool: async def _already_applied_warning(self, page) -> bool:

View file

@ -21,37 +21,42 @@ class Gesobau(Provider):
return ApplicationResult(False, _("missing_fields")) return ApplicationResult(False, _("missing_fields"))
async with open_page(url, recorder=r) as page: async with open_page(url, recorder=r) as page:
r.step("extract.immomio_link") await r.step_snap(page, "extract.immomio_link")
immomio_link = await page.get_by_role("link", name="Jetzt bewerben").get_attribute("href") immomio_link = await page.get_by_role("link", name="Jetzt bewerben").get_attribute("href")
r.step("auth.goto"); await page.goto("https://tenant.immomio.com/de/auth/login") await r.step_snap(page, "auth.goto")
await page.goto("https://tenant.immomio.com/de/auth/login")
await page.wait_for_timeout(1000) await page.wait_for_timeout(1000)
r.step("auth.login") await r.step_snap(page, "auth.login")
await page.locator('input[name="email"]').fill(p.immomio_email) await page.locator('input[name="email"]').fill(p.immomio_email)
await page.get_by_role("button", name="Anmelden").click() await page.get_by_role("button", name="Anmelden").click()
await page.wait_for_timeout(1000) await page.wait_for_timeout(1000)
await page.locator("#password").fill(p.immomio_password) await page.locator("#password").fill(p.immomio_password)
await page.locator("#kc-login").click() await page.locator("#kc-login").click()
await page.wait_for_timeout(1000) await page.wait_for_timeout(1000)
await r.step_snap(page, "auth.done")
r.step("back.to_immomio", detail=immomio_link or "") await r.step_snap(page, "back.to_immomio", detail=immomio_link or "")
await page.goto(immomio_link) await page.goto(immomio_link)
await page.wait_for_timeout(1000) await page.wait_for_timeout(1000)
r.step("cookies.check") await r.step_snap(page, "cookies.check")
cookie_accept_btn = page.get_by_role("button", name="Alle erlauben") cookie_accept_btn = page.get_by_role("button", name="Alle erlauben")
if await cookie_accept_btn.is_visible(): if await cookie_accept_btn.is_visible():
await cookie_accept_btn.click() await cookie_accept_btn.click()
await r.step_snap(page, "cookies.accepted")
r.step("apply.click"); await page.get_by_role("button", name="Jetzt bewerben").click() await r.step_snap(page, "apply.click")
await page.get_by_role("button", name="Jetzt bewerben").click()
await page.wait_for_timeout(3000) await page.wait_for_timeout(3000)
await r.step_snap(page, "apply.clicked")
r.step("already_applied.check") await r.step_snap(page, "already_applied.check")
if page.url == "https://tenant.immomio.com/de/properties/applications": if page.url == "https://tenant.immomio.com/de/properties/applications":
return ApplicationResult(False, message=_("already_applied")) return ApplicationResult(False, message=_("already_applied"))
r.step("answer_questions.click") await r.step_snap(page, "answer_questions.click")
answer_questions_btn = page.get_by_role("button", name="Fragen beantworten") answer_questions_btn = page.get_by_role("button", name="Fragen beantworten")
if await answer_questions_btn.is_visible(): if await answer_questions_btn.is_visible():
await answer_questions_btn.click() await answer_questions_btn.click()
@ -61,9 +66,9 @@ class Gesobau(Provider):
await answer_questions_btn.click() await answer_questions_btn.click()
await page.wait_for_timeout(2000) await page.wait_for_timeout(2000)
r.step("success.check") await r.step_snap(page, "success.check")
if not await answer_questions_btn.is_visible(): if not await answer_questions_btn.is_visible():
return ApplicationResult(True) return ApplicationResult(True)
r.step("success.not_found", "warn") await r.step_snap(page, "success.not_found", status="warn")
return ApplicationResult(False, _("submit_conformation_msg_not_found")) return ApplicationResult(False, _("submit_conformation_msg_not_found"))

View file

@ -17,31 +17,36 @@ class Gewobag(Provider):
r = ctx.recorder r = ctx.recorder
p = ctx.profile p = ctx.profile
async with open_page(url, recorder=r) as page: async with open_page(url, recorder=r) as page:
r.step("cookies.check") await r.step_snap(page, "cookies.check")
btn = page.get_by_text("Alle Cookies akzeptieren") btn = page.get_by_text("Alle Cookies akzeptieren")
if await btn.is_visible(): if await btn.is_visible():
await btn.click() await btn.click()
r.step("cookies.accepted") await r.step_snap(page, "cookies.accepted")
r.step("ad.exists_check") await r.step_snap(page, "ad.exists_check")
if await page.get_by_text("Mietangebot nicht gefunden").first.is_visible(): if await page.get_by_text("Mietangebot nicht gefunden").first.is_visible():
await r.step_snap(page, "abort.not_found", status="warn")
return ApplicationResult(False, message=_("not_found")) return ApplicationResult(False, message=_("not_found"))
r.step("ad.still_open_check") await r.step_snap(page, "ad.still_open_check")
if await page.locator('#immo-mediation-notice').is_visible(): if await page.locator('#immo-mediation-notice').is_visible():
await r.step_snap(page, "abort.deactivated", status="warn")
return ApplicationResult(False, message=_("ad_deactivated")) return ApplicationResult(False, message=_("ad_deactivated"))
r.step("form.open"); await page.get_by_role("button", name="Anfrage senden").first.click() await r.step_snap(page, "form.open")
await page.get_by_role("button", name="Anfrage senden").first.click()
r.step("form.senior_check") await r.step_snap(page, "form.senior_check")
if await self._is_senior_flat(page): if await self._is_senior_flat(page):
await r.step_snap(page, "abort.senior_flat", status="warn")
return ApplicationResult(False, _("senior_flat")) return ApplicationResult(False, _("senior_flat"))
r.step("form.special_wbs_check") await r.step_snap(page, "form.special_wbs_check")
if await self._is_special_needs_wbs(page): if await self._is_special_needs_wbs(page):
await r.step_snap(page, "abort.special_wbs", status="warn")
return ApplicationResult(False, _("special_need_wbs_flat")) return ApplicationResult(False, _("special_need_wbs_flat"))
r.step("iframe.locate") await r.step_snap(page, "iframe.locate")
form_iframe = page.frame_locator("#contact-iframe") form_iframe = page.frame_locator("#contact-iframe")
async def fill_field(locator, filling): async def fill_field(locator, filling):
@ -73,7 +78,7 @@ class Gewobag(Provider):
await sec.locator("input[type='file']").set_input_files(files) await sec.locator("input[type='file']").set_input_files(files)
await page.wait_for_timeout(2000) await page.wait_for_timeout(2000)
r.step("form.fill_personal") await r.step_snap(page, "form.fill_personal")
await select_field("#salutation-dropdown", p.salutation) await select_field("#salutation-dropdown", p.salutation)
await fill_field("#firstName", p.firstname) await fill_field("#firstName", p.firstname)
await fill_field("#lastName", p.lastname) await fill_field("#lastName", p.lastname)
@ -87,7 +92,7 @@ class Gewobag(Provider):
await fill_field("input[id*='anzahl_kinder']", str(p.children_count)) await fill_field("input[id*='anzahl_kinder']", str(p.children_count))
await fill_field("input[id*='gesamtzahl_der_einziehenden_personen']", str(p.person_count)) await fill_field("input[id*='gesamtzahl_der_einziehenden_personen']", str(p.person_count))
r.step("form.wbs_fill") await r.step_snap(page, "form.wbs_fill")
await check_checkbox("[data-cy*='wbs_available'][data-cy*='-Ja']") await check_checkbox("[data-cy*='wbs_available'][data-cy*='-Ja']")
await fill_field("input[id*='wbs_valid_until']", p.wbs_valid_till_dt().strftime("%d.%m.%Y")) await fill_field("input[id*='wbs_valid_until']", p.wbs_valid_till_dt().strftime("%d.%m.%Y"))
await select_field("input[id*='wbs_max_number_rooms']", f"{p.wbs_rooms} Räume") await select_field("input[id*='wbs_max_number_rooms']", f"{p.wbs_rooms} Räume")
@ -96,21 +101,23 @@ class Gewobag(Provider):
await fill_field("input[id*='telephone_number']", p.telephone) await fill_field("input[id*='telephone_number']", p.telephone)
await check_checkbox("input[id*='datenschutzhinweis']") await check_checkbox("input[id*='datenschutzhinweis']")
await upload_files("el-application-form-document-upload", files=None) await upload_files("el-application-form-document-upload", files=None)
await r.step_snap(page, "form.filled")
if not ctx.submit_forms: if not ctx.submit_forms:
r.step("submit.dry_run") await r.step_snap(page, "submit.dry_run")
return ApplicationResult(True, _("application_success_dry")) return ApplicationResult(True, _("application_success_dry"))
r.step("submit.click"); await form_iframe.get_by_role("button", name="Anfrage versenden").click() await r.step_snap(page, "submit.click")
await form_iframe.get_by_role("button", name="Anfrage versenden").click()
await page.wait_for_timeout(5000) await page.wait_for_timeout(5000)
r.step("success.check", detail=page.url) await r.step_snap(page, "success.check", detail=page.url)
if page.url.startswith("https://www.gewobag.de/daten-uebermittelt/"): if page.url.startswith("https://www.gewobag.de/daten-uebermittelt/"):
return ApplicationResult(True) return ApplicationResult(True)
elif await self._is_missing_fields_warning(page): elif await self._is_missing_fields_warning(page):
r.step("missing_fields_detected", "warn") await r.step_snap(page, "missing_fields_detected", status="warn")
return ApplicationResult(False, _("missing_fields")) return ApplicationResult(False, _("missing_fields"))
r.step("success.not_found", "warn") await r.step_snap(page, "success.not_found", status="warn")
return ApplicationResult(False, _("submit_conformation_msg_not_found")) return ApplicationResult(False, _("submit_conformation_msg_not_found"))
async def _is_senior_flat(self, page): async def _is_senior_flat(self, page):

View file

@ -17,39 +17,48 @@ class Howoge(Provider):
r = ctx.recorder r = ctx.recorder
p = ctx.profile p = ctx.profile
async with open_page(url, recorder=r) as page: async with open_page(url, recorder=r) as page:
r.step("cookies.check") await r.step_snap(page, "cookies.check")
cookie_accept_btn = page.get_by_role("button", name="Alles akzeptieren") cookie_accept_btn = page.get_by_role("button", name="Alles akzeptieren")
if await cookie_accept_btn.is_visible(): if await cookie_accept_btn.is_visible():
await cookie_accept_btn.click() await cookie_accept_btn.click()
r.step("cookies.accepted") await r.step_snap(page, "cookies.accepted")
else: else:
r.step("cookies.absent") r.step("cookies.absent")
r.step("page.404_check", detail=page.url) r.step("page.404_check", detail=page.url)
if page.url == "https://www.howoge.de/404": if page.url == "https://www.howoge.de/404":
await r.step_snap(page, "abort.not_found", status="warn")
return ApplicationResult(False, message=_("not_found")) return ApplicationResult(False, message=_("not_found"))
r.step("form.open") await r.step_snap(page, "form.open")
await page.get_by_role("link", name="Besichtigung anfragen").click() await page.get_by_role("link", name="Besichtigung anfragen").click()
r.step("form.wbs_hint"); await page.get_by_text("Ja, ich habe die Hinweise zum WBS zur Kenntnis genommen.").click() await r.step_snap(page, "form.wbs_hint")
await page.get_by_text("Ja, ich habe die Hinweise zum WBS zur Kenntnis genommen.").click()
await page.get_by_role("button", name="Weiter").click() await page.get_by_role("button", name="Weiter").click()
r.step("form.income_hint"); await page.get_by_text("Ja, ich habe den Hinweis zum Haushaltsnettoeinkommen zur Kenntnis genommen.").click()
await r.step_snap(page, "form.income_hint")
await page.get_by_text("Ja, ich habe den Hinweis zum Haushaltsnettoeinkommen zur Kenntnis genommen.").click()
await page.get_by_role("button", name="Weiter").click() await page.get_by_role("button", name="Weiter").click()
r.step("form.bonitaet_hint"); await page.get_by_text("Ja, ich habe den Hinweis zur Bonitätsauskunft zur Kenntnis genommen.").click()
await r.step_snap(page, "form.bonitaet_hint")
await page.get_by_text("Ja, ich habe den Hinweis zur Bonitätsauskunft zur Kenntnis genommen.").click()
await page.get_by_role("button", name="Weiter").click() await page.get_by_role("button", name="Weiter").click()
r.step("form.name"); await page.locator("#immo-form-firstname").fill(p.firstname)
await r.step_snap(page, "form.fill")
await page.locator("#immo-form-firstname").fill(p.firstname)
await page.locator("#immo-form-lastname").fill(p.lastname) await page.locator("#immo-form-lastname").fill(p.lastname)
await page.locator("#immo-form-email").fill(p.email) await page.locator("#immo-form-email").fill(p.email)
if not ctx.submit_forms: if not ctx.submit_forms:
r.step("submit.dry_run") await r.step_snap(page, "submit.dry_run")
return ApplicationResult(True, _("application_success_dry")) return ApplicationResult(True, _("application_success_dry"))
r.step("submit.click"); await page.get_by_role("button", name="Anfrage senden").click() await r.step_snap(page, "submit.click")
await page.get_by_role("button", name="Anfrage senden").click()
r.step("success.check") await r.step_snap(page, "success.check")
if await page.get_by_role("heading", name="Vielen Dank.").is_visible(): if await page.get_by_role("heading", name="Vielen Dank.").is_visible():
return ApplicationResult(True) return ApplicationResult(True)
r.step("success.not_found", "warn") await r.step_snap(page, "success.not_found", status="warn")
return ApplicationResult(False, _("submit_conformation_msg_not_found")) return ApplicationResult(False, _("submit_conformation_msg_not_found"))

View file

@ -17,19 +17,18 @@ class Stadtundland(Provider):
r = ctx.recorder r = ctx.recorder
p = ctx.profile p = ctx.profile
async with open_page(url, recorder=r) as page: async with open_page(url, recorder=r) as page:
r.step("cookies.check") await r.step_snap(page, "cookies.check")
cookie_accept_btn = page.get_by_text("Alle akzeptieren") cookie_accept_btn = page.get_by_text("Alle akzeptieren")
if await cookie_accept_btn.is_visible(): if await cookie_accept_btn.is_visible():
await cookie_accept_btn.click() await cookie_accept_btn.click()
r.step("cookies.accepted") await r.step_snap(page, "cookies.accepted")
else:
r.step("cookies.absent")
r.step("page.offline_check") await r.step_snap(page, "page.offline_check")
if await page.get_by_role("heading", name="Hier ist etwas schief gelaufen").is_visible(): if await page.get_by_role("heading", name="Hier ist etwas schief gelaufen").is_visible():
await r.step_snap(page, "abort.offline", status="warn")
return ApplicationResult(False, message=_("ad_offline")) return ApplicationResult(False, message=_("ad_offline"))
r.step("form.fill") await r.step_snap(page, "form.fill")
await page.locator("#name").fill(p.firstname) await page.locator("#name").fill(p.firstname)
await page.locator("#surname").fill(p.lastname) await page.locator("#surname").fill(p.lastname)
await page.locator("#street").fill(p.street) await page.locator("#street").fill(p.street)
@ -40,17 +39,20 @@ class Stadtundland(Provider):
await page.locator("#email").fill(p.email) await page.locator("#email").fill(p.email)
await page.locator("#privacy").check() await page.locator("#privacy").check()
await page.locator("#provision").check() await page.locator("#provision").check()
await r.step_snap(page, "form.filled")
if not ctx.submit_forms: if not ctx.submit_forms:
r.step("submit.dry_run") await r.step_snap(page, "submit.dry_run")
return ApplicationResult(True, _("application_success_dry")) return ApplicationResult(True, _("application_success_dry"))
r.step("submit.review"); await page.get_by_role("button", name="Eingaben prüfen").click() await r.step_snap(page, "submit.review")
r.step("submit.send"); await page.get_by_role("button", name="Absenden").click() await page.get_by_role("button", name="Eingaben prüfen").click()
await r.step_snap(page, "submit.send")
await page.get_by_role("button", name="Absenden").click()
await page.wait_for_timeout(2000) await page.wait_for_timeout(2000)
r.step("success.check") await r.step_snap(page, "success.check")
if await page.locator("p").filter(has_text="Vielen Dank!").is_visible(): if await page.locator("p").filter(has_text="Vielen Dank!").is_visible():
return ApplicationResult(True) return ApplicationResult(True)
r.step("success.not_found", "warn") await r.step_snap(page, "success.not_found", status="warn")
return ApplicationResult(success=False, message=_("submit_conformation_msg_not_found")) return ApplicationResult(success=False, message=_("submit_conformation_msg_not_found"))

View file

@ -17,17 +17,16 @@ class Wbm(Provider):
r = ctx.recorder r = ctx.recorder
p = ctx.profile p = ctx.profile
async with open_page(url, recorder=r) as page: async with open_page(url, recorder=r) as page:
r.step("page.404_check") await r.step_snap(page, "page.404_check")
if await page.get_by_role("heading", name="Page Not Found").is_visible(): if await page.get_by_role("heading", name="Page Not Found").is_visible():
await r.step_snap(page, "abort.not_found", status="warn")
return ApplicationResult(False, message=_("not_found")) return ApplicationResult(False, message=_("not_found"))
r.step("cookies.check") await r.step_snap(page, "cookies.check")
cookie_accept_btn = page.get_by_text("Alle zulassen") cookie_accept_btn = page.get_by_text("Alle zulassen")
if await cookie_accept_btn.is_visible(): if await cookie_accept_btn.is_visible():
await cookie_accept_btn.click() await cookie_accept_btn.click()
r.step("cookies.accepted") await r.step_snap(page, "cookies.accepted")
else:
r.step("cookies.absent")
r.step("chatbot.remove") r.step("chatbot.remove")
try: try:
@ -35,13 +34,15 @@ class Wbm(Provider):
except Exception as e: except Exception as e:
r.step("chatbot.remove_failed", "warn", str(e)) r.step("chatbot.remove_failed", "warn", str(e))
r.step("ad.offline_check") await r.step_snap(page, "ad.offline_check")
if page.url == "https://www.wbm.de/wohnungen-berlin/angebote/": if page.url == "https://www.wbm.de/wohnungen-berlin/angebote/":
await r.step_snap(page, "abort.offline", status="warn")
return ApplicationResult(False, message=_("ad_offline")) return ApplicationResult(False, message=_("ad_offline"))
r.step("form.open"); await page.locator('.openimmo-detail__contact-box-button').click() await r.step_snap(page, "form.open")
await page.locator('.openimmo-detail__contact-box-button').click()
r.step("form.wbs_section") await r.step_snap(page, "form.wbs_section")
if p.is_possessing_wbs: if p.is_possessing_wbs:
await page.locator('label[for="powermail_field_wbsvorhanden_1"]').click() await page.locator('label[for="powermail_field_wbsvorhanden_1"]').click()
await page.locator("input[name*='[wbsgueltigbis]']").fill(p.wbs_valid_till_dt().strftime("%Y-%m-%d")) await page.locator("input[name*='[wbsgueltigbis]']").fill(p.wbs_valid_till_dt().strftime("%Y-%m-%d"))
@ -52,7 +53,7 @@ class Wbm(Provider):
else: else:
await page.locator('label[for="powermail_field_wbsvorhanden_2"]').click() await page.locator('label[for="powermail_field_wbsvorhanden_2"]').click()
r.step("form.personal") await r.step_snap(page, "form.personal")
await page.locator("#powermail_field_anrede").select_option(p.salutation) await page.locator("#powermail_field_anrede").select_option(p.salutation)
await page.locator("#powermail_field_name").fill(p.lastname) await page.locator("#powermail_field_name").fill(p.lastname)
await page.locator("#powermail_field_vorname").fill(p.firstname) await page.locator("#powermail_field_vorname").fill(p.firstname)
@ -62,20 +63,22 @@ class Wbm(Provider):
await page.locator("#powermail_field_e_mail").fill(p.email) await page.locator("#powermail_field_e_mail").fill(p.email)
await page.locator("#powermail_field_telefon").fill(p.telephone) await page.locator("#powermail_field_telefon").fill(p.telephone)
await page.locator("#powermail_field_datenschutzhinweis_1").check(force=True) await page.locator("#powermail_field_datenschutzhinweis_1").check(force=True)
await r.step_snap(page, "form.filled")
if not ctx.submit_forms: if not ctx.submit_forms:
r.step("submit.dry_run") await r.step_snap(page, "submit.dry_run")
return ApplicationResult(success=True, message=_("application_success_dry")) return ApplicationResult(success=True, message=_("application_success_dry"))
r.step("submit.click"); await page.get_by_role("button", name="Anfrage absenden").click() await r.step_snap(page, "submit.click")
await page.get_by_role("button", name="Anfrage absenden").click()
r.step("success.check") await r.step_snap(page, "success.check")
if await page.get_by_text("Wir haben Ihre Anfrage für das Wohnungsangebot erhalten.").is_visible(): if await page.get_by_text("Wir haben Ihre Anfrage für das Wohnungsangebot erhalten.").is_visible():
return ApplicationResult(True) return ApplicationResult(True)
elif await self._missing_fields_warning(page): elif await self._missing_fields_warning(page):
r.step("missing_fields_detected", "warn") await r.step_snap(page, "missing_fields_detected", status="warn")
return ApplicationResult(False, _("missing_fields")) return ApplicationResult(False, _("missing_fields"))
r.step("success.not_found", "warn") await r.step_snap(page, "success.not_found", status="warn")
return ApplicationResult(success=False, message=_("submit_conformation_msg_not_found")) return ApplicationResult(success=False, message=_("submit_conformation_msg_not_found"))
async def _missing_fields_warning(self, page) -> bool: async def _missing_fields_warning(self, page) -> bool:

View file

@ -383,15 +383,13 @@ def _wohnungen_context(user) -> dict:
flats_view = [] flats_view = []
for f in flats: for f in flats:
last = db.last_application_for_flat(uid, f["id"]) if not flat_matches_filter({
flats_view.append({
"row": f,
"matched": flat_matches_filter({
"rooms": f["rooms"], "total_rent": f["total_rent"], "size": f["size"], "rooms": f["rooms"], "total_rent": f["total_rent"], "size": f["size"],
"wbs": f["wbs"], "connectivity": {"morning_time": f["connectivity_morning_time"]}, "wbs": f["wbs"], "connectivity": {"morning_time": f["connectivity_morning_time"]},
}, filters), }, filters):
"last": last, continue
}) last = db.last_application_for_flat(uid, f["id"])
flats_view.append({"row": f, "last": last})
allowed, reason = _manual_apply_allowed() allowed, reason = _manual_apply_allowed()
alert_label, alert_chip = _alert_status(filters_row, notif_row) alert_label, alert_chip = _alert_status(filters_row, notif_row)
@ -574,7 +572,8 @@ def bewerbung_zip(request: Request, app_id: int):
f" console.json Browser console entries\n" f" console.json Browser console entries\n"
f" errors.json Browser pageerror events\n" f" errors.json Browser pageerror events\n"
f" network.json Network requests + partial responses\n" f" network.json Network requests + partial responses\n"
f" screenshots/*.jpg Screenshots at key moments\n" f" snapshots/NN_*.jpg Screenshot at each step (NN = order)\n"
f" snapshots/NN_*.html Page HTML at each step\n"
) )
zf.writestr("application.json", json.dumps(app_meta, indent=2, default=str)) zf.writestr("application.json", json.dumps(app_meta, indent=2, default=str))
zf.writestr("flat.json", json.dumps(dict(flat) if flat else {}, indent=2, default=str)) zf.writestr("flat.json", json.dumps(dict(flat) if flat else {}, indent=2, default=str))
@ -593,14 +592,17 @@ def bewerbung_zip(request: Request, app_id: int):
zf.writestr("network.json", json.dumps(forensics.get("network", []), indent=2)) zf.writestr("network.json", json.dumps(forensics.get("network", []), indent=2))
for idx, s in enumerate(forensics.get("screenshots", []), start=1): for idx, s in enumerate(forensics.get("screenshots", []), start=1):
label = (s.get("label") or f"step{idx}").replace("/", "_").replace(" ", "_")
b64 = s.get("b64_jpeg", "") b64 = s.get("b64_jpeg", "")
if b64: if b64:
try: try:
data = base64.b64decode(b64) data = base64.b64decode(b64)
label = (s.get("label") or f"step{idx}").replace("/", "_").replace(" ", "_") zf.writestr(f"snapshots/{idx:02d}_{label}.jpg", data)
zf.writestr(f"screenshots/{idx:02d}_{label}.jpg", data)
except Exception: except Exception:
pass pass
html = s.get("html") or ""
if html:
zf.writestr(f"snapshots/{idx:02d}_{label}.html", html)
buf.seek(0) buf.seek(0)
filename = f"lazyflat-report-{a['id']}.zip" filename = f"lazyflat-report-{a['id']}.zip"
@ -614,47 +616,108 @@ def bewerbung_zip(request: Request, app_id: int):
# Tab: Logs # Tab: Logs
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _parse_date_range(from_str: str | None, to_str: str | None) -> tuple[str | None, str | None]:
"""Parse 'YYYY-MM-DD' local-Berlin date inputs into UTC ISO bounds.
Bounds are inclusive start-of-day and start-of-next-day."""
def _to_utc_iso(s: str, end_of_day: bool) -> str | None:
try:
d = datetime.strptime(s, "%Y-%m-%d").replace(tzinfo=BERLIN_TZ)
except ValueError:
return None
if end_of_day:
d = d + timedelta(days=1)
return d.astimezone(timezone.utc).isoformat(timespec="seconds")
start = _to_utc_iso(from_str, False) if from_str else None
end = _to_utc_iso(to_str, True) if to_str else None
return start, end
def _collect_events(start_iso: str | None, end_iso: str | None) -> list[dict]:
users = {row["id"]: row["username"] for row in db.list_users()}
events: list[dict] = []
for a in db.recent_audit(None, limit=5000):
if start_iso and a["timestamp"] < start_iso: continue
if end_iso and a["timestamp"] >= end_iso: continue
events.append({
"kind": "audit", "ts": a["timestamp"], "source": "web",
"actor": a["actor"], "action": a["action"],
"details": a["details"] or "",
"user": users.get(a["user_id"], ""),
"ip": a["ip"] or "",
})
for e in db.recent_errors(None, limit=5000):
if start_iso and e["timestamp"] < start_iso: continue
if end_iso and e["timestamp"] >= end_iso: continue
events.append({
"kind": "error", "ts": e["timestamp"], "source": e["source"],
"actor": e["source"], "action": e["kind"],
"details": e["summary"] or "",
"user": users.get(e["user_id"], "") if e["user_id"] else "",
"ip": "",
})
events.sort(key=lambda x: x["ts"], reverse=True)
return events
@app.get("/logs", response_class=HTMLResponse) @app.get("/logs", response_class=HTMLResponse)
def tab_logs(request: Request): def tab_logs(request: Request, **kwargs):
u = current_user(request) u = current_user(request)
if not u: if not u:
return RedirectResponse("/login", status_code=303) return RedirectResponse("/login", status_code=303)
if not u["is_admin"]: if not u["is_admin"]:
raise HTTPException(403, "admin only") raise HTTPException(403, "admin only")
# Merge audit + errors across the whole system for a unified view. q = request.query_params
users = {row["id"]: row["username"] for row in db.list_users()} from_str = q.get("from") or ""
events: list[dict] = [] to_str = q.get("to") or ""
for a in db.recent_audit(None, limit=500): start_iso, end_iso = _parse_date_range(from_str or None, to_str or None)
events.append({ events = _collect_events(start_iso, end_iso)[:500]
"kind": "audit",
"ts": a["timestamp"],
"source": "web",
"actor": a["actor"],
"action": a["action"],
"details": a["details"] or "",
"user": users.get(a["user_id"], ""),
"ip": a["ip"] or "",
})
for e in db.recent_errors(None, limit=500):
events.append({
"kind": "error",
"ts": e["timestamp"],
"source": e["source"],
"actor": e["source"],
"action": e["kind"],
"details": e["summary"] or "",
"user": users.get(e["user_id"], "") if e["user_id"] else "",
"ip": "",
})
events.sort(key=lambda x: x["ts"], reverse=True)
events = events[:300]
ctx = base_context(request, u, "logs") ctx = base_context(request, u, "logs")
ctx["events"] = events ctx.update({"events": events, "from_str": from_str, "to_str": to_str})
return templates.TemplateResponse("logs.html", ctx) return templates.TemplateResponse("logs.html", ctx)
@app.get("/logs/export.csv")
def tab_logs_export(request: Request):
u = current_user(request)
if not u:
raise HTTPException(401)
if not u["is_admin"]:
raise HTTPException(403)
import csv as _csv
q = request.query_params
start_iso, end_iso = _parse_date_range(q.get("from") or None, q.get("to") or None)
events = _collect_events(start_iso, end_iso)
buf = io.StringIO()
w = _csv.writer(buf, delimiter=",", quoting=_csv.QUOTE_MINIMAL)
w.writerow(["timestamp_utc", "timestamp_berlin", "kind", "source", "actor", "action", "user", "details", "ip"])
for e in events:
w.writerow([
e["ts"],
_de_dt(e["ts"]),
e["kind"],
e["source"],
e["actor"],
e["action"],
e["user"],
e["details"],
e["ip"],
])
body = buf.getvalue().encode("utf-8")
filename = "lazyflat-protokoll"
if q.get("from"): filename += f"-{q['from']}"
if q.get("to"): filename += f"-bis-{q['to']}"
filename += ".csv"
return Response(
content=body, media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Tab: Einstellungen (sub-tabs) # Tab: Einstellungen (sub-tabs)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -11,9 +11,9 @@
<h1 class="text-xl font-semibold">lazyflat</h1> <h1 class="text-xl font-semibold">lazyflat</h1>
</div> </div>
<div class="flex items-center gap-4 text-sm"> <div class="flex items-center gap-4 text-sm">
<span class="text-slate-500">{{ user.username }}{% if is_admin %} · <span class="chip chip-info">admin</span>{% endif %}</span> <span class="text-slate-500">{{ user.username }}{% if is_admin %} · <span class="chip chip-info">Administrator</span>{% endif %}</span>
<form method="post" action="/logout"> <form method="post" action="/logout">
<button class="btn btn-ghost text-sm" type="submit">Logout</button> <button class="btn btn-ghost text-sm" type="submit">Abmelden</button>
</form> </form>
</div> </div>
</div> </div>
@ -21,7 +21,7 @@
<a class="tab {% if active_tab=='wohnungen' %}active{% endif %}" href="/">Wohnungen</a> <a class="tab {% if active_tab=='wohnungen' %}active{% endif %}" href="/">Wohnungen</a>
<a class="tab {% if active_tab=='bewerbungen' %}active{% endif %}" href="/bewerbungen">Bewerbungen</a> <a class="tab {% if active_tab=='bewerbungen' %}active{% endif %}" href="/bewerbungen">Bewerbungen</a>
{% if is_admin %} {% if is_admin %}
<a class="tab {% if active_tab=='logs' %}active{% endif %}" href="/logs">Logs</a> <a class="tab {% if active_tab=='logs' %}active{% endif %}" href="/logs">Protokoll</a>
{% endif %} {% endif %}
<a class="tab {% if active_tab=='einstellungen' %}active{% endif %}" href="/einstellungen">Einstellungen</a> <a class="tab {% if active_tab=='einstellungen' %}active{% endif %}" href="/einstellungen">Einstellungen</a>
</nav> </nav>

View file

@ -102,18 +102,10 @@
</form> </form>
<hr class="my-6 border-soft"> <hr class="my-6 border-soft">
<h3 class="font-semibold mb-2">Formulare wirklich absenden?</h3> <h3 class="font-semibold mb-2">Trockenmodus</h3>
<p class="text-sm text-slate-600 mb-3"> <p class="text-sm text-slate-600 mb-3">
<span class="chip chip-warn">experimentell</span> <span class="chip chip-warn">experimentell</span>
Im Dry-Run-Modus füllt apply das Formular aus und stoppt vor „Senden". Nur einschalten, wenn du jeden Anbieter einmal im Dry-Run verifiziert hast. Im Trockenmodus wird das Formular ausgefüllt, aber nicht abgesendet. Erst deaktivieren,
wenn du jeden Anbieter einmal im Trockenmodus erfolgreich getestet hast — den Schalter
findest du auch oben auf der Wohnungen-Seite.
</p> </p>
<form method="post" action="/actions/submit-forms" class="inline-flex gap-3 items-center">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="value" value="on">
<button class="btn btn-ghost text-sm" type="submit">Echt senden einschalten</button>
</form>
<form method="post" action="/actions/submit-forms" class="inline-flex gap-3 items-center ml-2">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="value" value="off">
<button class="btn btn-ghost text-sm" type="submit">Dry-Run</button>
</form>

View file

@ -18,7 +18,7 @@
</div> </div>
<label class="inline-flex items-center gap-2"> <label class="inline-flex items-center gap-2">
<input type="checkbox" name="is_admin"> <input type="checkbox" name="is_admin">
<span>Admin-Rechte</span> <span>Administrator-Rechte</span>
</label> </label>
<button class="btn btn-primary" type="submit">Anlegen</button> <button class="btn btn-primary" type="submit">Anlegen</button>
</form> </form>
@ -30,7 +30,7 @@
{% for u in users %} {% for u in users %}
<div class="px-3 py-2 flex items-center gap-2 text-sm"> <div class="px-3 py-2 flex items-center gap-2 text-sm">
<span class="flex-1">{{ u.username }}</span> <span class="flex-1">{{ u.username }}</span>
{% if u.is_admin %}<span class="chip chip-info">admin</span>{% endif %} {% if u.is_admin %}<span class="chip chip-info">Administrator</span>{% endif %}
{% if u.disabled %}<span class="chip chip-bad">deaktiviert</span> {% if u.disabled %}<span class="chip chip-bad">deaktiviert</span>
{% else %}<span class="chip chip-ok">aktiv</span>{% endif %} {% else %}<span class="chip chip-ok">aktiv</span>{% endif %}
{% if u.id != user.id %} {% if u.id != user.id %}

View file

@ -5,62 +5,73 @@
hx-trigger="every {{ poll_interval }}s" hx-trigger="every {{ poll_interval }}s"
hx-swap="outerHTML"> hx-swap="outerHTML">
<!-- Slim status strip: Alert · Filter · Auto-Bewerben · Trockenmodus --> <!-- Reihe 1: Info-Kacheln Alarm + Filter -->
<section class="grid grid-cols-2 md:grid-cols-4 gap-3"> <section class="grid grid-cols-2 gap-3">
<!-- Alert -->
<a class="card px-4 py-2.5 flex flex-col gap-0.5 hover:bg-[#f6fafd]" href="/einstellungen/filter"> <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">Alert</div> <div class="text-[11px] uppercase tracking-wide text-slate-500">Alarm</div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="chip chip-{{ alert_chip }}">{{ alert_label }}</span> <span class="chip chip-{{ alert_chip }}">{{ alert_label }}</span>
</div> </div>
</a> </a>
<!-- Filter summary -->
<a class="card px-4 py-2.5 flex flex-col gap-0.5 hover:bg-[#f6fafd]" href="/einstellungen/filter"> <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-[11px] uppercase tracking-wide text-slate-500">Filter</div>
<div class="text-sm text-slate-700 truncate">{{ filter_summary }}</div> <div class="text-sm text-slate-700 truncate">{{ filter_summary }}</div>
</a> </a>
</section>
<!-- Auto-Bewerben toggle --> <!-- Reihe 2: Schalter Automatisch bewerben + Trockenmodus (Radio-Gruppen) -->
<form class="card px-4 py-2.5 flex items-center justify-between gap-2" <section class="grid grid-cols-1 md:grid-cols-2 gap-3">
method="post" action="/actions/auto-apply" <!-- Automatisch bewerben -->
hx-post="/actions/auto-apply" hx-target="#wohnungen-body" hx-swap="outerHTML"> <form class="card p-4"
hx-post="/actions/auto-apply"
hx-trigger="change"
hx-target="#wohnungen-body"
hx-swap="outerHTML">
<input type="hidden" name="csrf" value="{{ csrf }}"> <input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="value" value="{% if auto_apply_enabled %}off{% else %}on{% endif %}"> <div class="text-[11px] uppercase tracking-wide text-slate-500 mb-2">Automatisch bewerben</div>
<div class="flex flex-col gap-0.5"> <div class="flex gap-4">
<div class="text-[11px] uppercase tracking-wide text-slate-500">Auto-Bewerben</div> <label class="radio-opt">
<div>{% if auto_apply_enabled %}<span class="chip chip-warn">aktiv</span> <input type="radio" name="value" value="off"
{% else %}<span class="chip chip-info">aus</span>{% endif %}</div> {% if not auto_apply_enabled %}checked{% endif %}>
<span>Aus</span>
</label>
<label class="radio-opt"
{% if not auto_apply_enabled %}data-hx-confirm="Automatisches Bewerben einschalten? Bei jedem passenden Flat wird automatisch beworben."{% endif %}>
<input type="radio" name="value" value="on"
hx-confirm="Automatisches Bewerben einschalten? Bei jedem passenden Flat wird automatisch beworben."
{% if auto_apply_enabled %}checked{% endif %}>
<span>An</span>
</label>
</div> </div>
<button class="btn {% if auto_apply_enabled %}btn-ghost{% else %}btn-hot{% endif %} text-xs"
onclick="return confirm('{% if auto_apply_enabled %}Auto-Bewerben deaktivieren?{% else %}Auto-Bewerben aktivieren? Bei jedem passenden Flat wird automatisch beworben.{% endif %}');"
type="submit">
{% if auto_apply_enabled %}AUS{% else %}AN{% endif %}
</button>
</form> </form>
<!-- Trockenmodus toggle --> <!-- Trockenmodus -->
<form class="card px-4 py-2.5 flex items-center justify-between gap-2" <form class="card p-4"
method="post" action="/actions/submit-forms" hx-post="/actions/submit-forms"
hx-post="/actions/submit-forms" hx-target="#wohnungen-body" hx-swap="outerHTML"> hx-trigger="change"
hx-target="#wohnungen-body"
hx-swap="outerHTML">
<input type="hidden" name="csrf" value="{{ csrf }}"> <input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="value" value="{% if submit_forms %}off{% else %}on{% endif %}"> <div class="text-[11px] uppercase tracking-wide text-slate-500 mb-2">Trockenmodus</div>
<div class="flex flex-col gap-0.5"> <div class="flex gap-4">
<div class="text-[11px] uppercase tracking-wide text-slate-500">Trockenmodus</div> <label class="radio-opt">
<div>{% if submit_forms %}<span class="chip chip-warn">aus (echt!)</span> <input type="radio" name="value" value="on"
{% else %}<span class="chip chip-ok">an</span>{% endif %}</div> {% if not submit_forms %}checked{% endif %}>
<span>An <span class="text-xs text-slate-500">(Formular ausfüllen, nicht absenden)</span></span>
</label>
<label class="radio-opt">
<input type="radio" name="value" value="off"
hx-confirm="Trockenmodus ausschalten? Formulare werden dann WIRKLICH abgesendet!"
{% if submit_forms %}checked{% endif %}>
<span>Aus <span class="text-xs text-[#b8404e]">(echt senden)</span></span>
</label>
</div> </div>
<button class="btn btn-ghost text-xs"
onclick="return confirm('{% if submit_forms %}Trockenmodus wieder einschalten?{% else %}Trockenmodus ausschalten? Formulare werden dann WIRKLICH abgesendet!{% endif %}');"
type="submit">
{% if submit_forms %}AN{% else %}AUS{% endif %}
</button>
</form> </form>
</section> </section>
{% if not apply_allowed %} {% if not apply_allowed %}
<div class="card p-3 text-sm"> <div class="card p-3 text-sm">
<span class="chip chip-bad">apply blockiert</span> <span class="chip chip-bad">Bewerbungs-Dienst nicht erreichbar</span>
<span class="ml-2 text-slate-600">{{ apply_block_reason }}</span> <span class="ml-2 text-slate-600">{{ apply_block_reason }}</span>
</div> </div>
{% endif %} {% endif %}
@ -68,8 +79,8 @@
{% if circuit_open %} {% if circuit_open %}
<div class="card p-3 text-sm flex items-center justify-between"> <div class="card p-3 text-sm flex items-center justify-between">
<div> <div>
<span class="chip chip-bad">circuit open</span> <span class="chip chip-bad">Automatik pausiert</span>
<span class="ml-2 text-slate-600">{{ apply_failures }} Fehler in Serie — Auto-Bewerben pausiert</span> <span class="ml-2 text-slate-600">{{ apply_failures }} Fehler in Folge</span>
</div> </div>
<form method="post" action="/actions/reset-circuit" <form method="post" action="/actions/reset-circuit"
hx-post="/actions/reset-circuit" hx-target="#wohnungen-body" hx-swap="outerHTML"> hx-post="/actions/reset-circuit" hx-target="#wohnungen-body" hx-swap="outerHTML">
@ -79,12 +90,12 @@
</div> </div>
{% endif %} {% endif %}
<!-- Liste aller Wohnungen --> <!-- Liste passender Wohnungen -->
<section class="card"> <section class="card">
<div class="flex items-center justify-between px-4 py-3 border-b border-soft gap-4 flex-wrap"> <div class="flex items-center justify-between px-4 py-3 border-b border-soft gap-4 flex-wrap">
<h2 class="font-semibold">Neueste Wohnungen auf inberlinwohnen.de</h2> <h2 class="font-semibold">Passende Wohnungen auf inberlinwohnen.de</h2>
<div class="text-xs text-slate-500 flex gap-3 items-center"> <div class="text-xs text-slate-500 flex gap-3 items-center">
<span>{{ flats|length }} gesehen</span> <span>{{ flats|length }} gefunden</span>
{% if next_scrape_utc %} {% if next_scrape_utc %}
<span>· nächste Aktualisierung <span data-countdown-utc="{{ next_scrape_utc }}"></span></span> <span>· nächste Aktualisierung <span data-countdown-utc="{{ next_scrape_utc }}"></span></span>
{% endif %} {% endif %}
@ -93,17 +104,16 @@
<div class="divide-y divide-soft"> <div class="divide-y divide-soft">
{% for item in flats %} {% for item in flats %}
{% set f = item.row %} {% set f = item.row %}
<div class="px-4 py-3 flex flex-col md:flex-row md:items-center gap-3 {% if item.matched %}bg-[#f2f8ff]{% endif %}"> <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-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-center gap-2 flex-wrap">
<a class="font-medium truncate" href="{{ f.link }}" target="_blank" rel="noopener noreferrer"> <a class="font-medium truncate" href="{{ f.link }}" target="_blank" rel="noopener noreferrer">
{{ f.address or f.link }} {{ f.address or f.link }}
</a> </a>
{% if item.matched %}<span class="chip chip-ok">match</span>{% endif %}
{% if item.last and item.last.finished_at is none %} {% if item.last and item.last.finished_at is none %}
<span class="chip chip-warn">läuft…</span> <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 == 1 %}<span class="chip chip-ok">beworben</span>
{% elif item.last and item.last.success == 0 %}<span class="chip chip-bad">apply fehlgeschlagen</span> {% elif item.last and item.last.success == 0 %}<span class="chip chip-bad">fehlgeschlagen</span>
{% endif %} {% endif %}
</div> </div>
<div class="text-xs text-slate-500 mt-0.5"> <div class="text-xs text-slate-500 mt-0.5">
@ -128,7 +138,7 @@
<input type="hidden" name="flat_id" value="{{ f.id }}"> <input type="hidden" name="flat_id" value="{{ f.id }}">
<button class="btn btn-primary text-sm" type="submit" <button class="btn btn-primary text-sm" type="submit"
{% if is_running %}disabled{% endif %} {% if is_running %}disabled{% endif %}
onclick="return confirm('Bewerbung für {{ (f.address or f.link)|e }} ausführen?');"> hx-confirm="Bewerbung für {{ (f.address or f.link)|e }} starten?">
{% if is_running %}läuft…{% else %}Bewerben{% endif %} {% if is_running %}läuft…{% else %}Bewerben{% endif %}
</button> </button>
</form> </form>
@ -136,7 +146,13 @@
</div> </div>
</div> </div>
{% else %} {% else %}
<div class="px-4 py-8 text-center text-slate-500">Noch keine Wohnungen entdeckt.</div> <div class="px-4 py-8 text-center text-slate-500">
{% if alert_label == 'nicht eingerichtet' %}
Bitte zuerst Filter einstellen, damit passende Wohnungen angezeigt werden.
{% else %}
Aktuell keine Wohnung, die alle Filter erfüllt.
{% endif %}
</div>
{% endfor %} {% endfor %}
</div> </div>
</section> </section>

View file

@ -47,6 +47,12 @@
.chip-warn { background: #fff4dd; color: #a36a1f; border: 1px solid #f5d48b; } .chip-warn { background: #fff4dd; color: #a36a1f; border: 1px solid #f5d48b; }
.chip-bad { background: #fde6e9; color: #b8404e; border: 1px solid #f5b5bf; } .chip-bad { background: #fde6e9; color: #b8404e; border: 1px solid #f5b5bf; }
.chip-info { background: #e3effc; color: #1f5f99; border: 1px solid #b6d4f0; } .chip-info { background: #e3effc; color: #1f5f99; border: 1px solid #b6d4f0; }
.radio-opt { display: inline-flex; align-items: center; gap: 0.45rem; cursor: pointer; padding: 0.35rem 0.65rem;
border: 1px solid var(--border); border-radius: 10px; background: var(--surface);
transition: border-color .15s, background .15s; user-select: none; }
.radio-opt:has(input:checked) { border-color: var(--primary); background: #ecf4fc; box-shadow: 0 0 0 1px var(--primary) inset; }
.radio-opt:hover { background: var(--ghost); }
.radio-opt input[type="radio"] { accent-color: var(--primary); }
.brand-dot { .brand-dot {
width: 2rem; height: 2rem; border-radius: 10px; width: 2rem; height: 2rem; border-radius: 10px;
background: linear-gradient(135deg, #66b7f2 0%, #2f8ae0 60%, #fbd76b 100%); background: linear-gradient(135deg, #66b7f2 0%, #2f8ae0 60%, #fbd76b 100%);

View file

@ -8,10 +8,10 @@
{% if application.success == 1 %}<span class="chip chip-ok">erfolgreich</span> {% if application.success == 1 %}<span class="chip chip-ok">erfolgreich</span>
{% elif application.success == 0 %}<span class="chip chip-bad">fehlgeschlagen</span> {% elif application.success == 0 %}<span class="chip chip-bad">fehlgeschlagen</span>
{% else %}<span class="chip chip-warn">läuft</span>{% endif %} {% else %}<span class="chip chip-warn">läuft</span>{% endif %}
<span class="chip chip-info">{{ application.triggered_by }}</span> <span class="chip chip-info">{% if application.triggered_by == 'auto' %}automatisch{% else %}manuell{% endif %}</span>
{% if application.provider %}<span class="chip chip-info">{{ application.provider }}</span>{% endif %} {% if application.provider %}<span class="chip chip-info">{{ application.provider }}</span>{% endif %}
{% if application.submit_forms_used %}<span class="chip chip-warn">echt gesendet</span> {% if application.submit_forms_used %}<span class="chip chip-warn">echt gesendet</span>
{% else %}<span class="chip chip-info">dry-run</span>{% endif %} {% else %}<span class="chip chip-info">Trockenmodus</span>{% endif %}
{% if application.success == 0 %} {% if application.success == 0 %}
<a class="btn btn-danger text-sm ml-auto" <a class="btn btn-danger text-sm ml-auto"
@ -53,12 +53,20 @@
{% if forensics.screenshots %} {% if forensics.screenshots %}
<details> <details>
<summary class="font-medium">Screenshots ({{ forensics.screenshots|length }})</summary> <summary class="font-medium">Momentaufnahmen ({{ forensics.screenshots|length }})</summary>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-3"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-3">
{% for s in forensics.screenshots %} {% for s in forensics.screenshots %}
<div class="border border-soft rounded-lg p-2"> <div class="border border-soft rounded-lg p-2 space-y-2">
<div class="text-xs text-slate-500 mb-1">{{ s.label }} @ {{ "%.2f"|format(s.ts) }}s — {{ s.url }}</div> <div class="text-xs text-slate-500">{{ s.label }} @ {{ "%.2f"|format(s.ts) }}s — {{ s.url }}</div>
{% if s.b64_jpeg %}
<img src="data:image/jpeg;base64,{{ s.b64_jpeg }}" class="w-full rounded" alt="{{ s.label }}"> <img src="data:image/jpeg;base64,{{ s.b64_jpeg }}" class="w-full rounded" alt="{{ s.label }}">
{% endif %}
{% if s.html %}
<details>
<summary class="text-xs text-slate-500">Quellcode anzeigen ({{ s.html_size }} B)</summary>
<pre class="mono whitespace-pre-wrap break-all mt-2 p-2 bg-[#f6fafd] rounded border border-soft max-h-72 overflow-auto">{{ s.html }}</pre>
</details>
{% endif %}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>

View file

@ -12,10 +12,10 @@
{% if a.success == 1 %}<span class="chip chip-ok">ok</span> {% if a.success == 1 %}<span class="chip chip-ok">ok</span>
{% elif a.success == 0 %}<span class="chip chip-bad">fehlgeschlagen</span> {% elif a.success == 0 %}<span class="chip chip-bad">fehlgeschlagen</span>
{% else %}<span class="chip chip-warn">läuft</span>{% endif %} {% else %}<span class="chip chip-warn">läuft</span>{% endif %}
<span class="chip chip-info">{{ a.triggered_by }}</span> <span class="chip chip-info">{% if a.triggered_by == 'auto' %}automatisch{% else %}manuell{% endif %}</span>
{% if a.provider %}<span class="chip chip-info">{{ a.provider }}</span>{% endif %} {% if a.provider %}<span class="chip chip-info">{{ a.provider }}</span>{% endif %}
{% if a.submit_forms_used %}<span class="chip chip-warn">echt gesendet</span> {% if a.submit_forms_used %}<span class="chip chip-warn">echt gesendet</span>
{% else %}<span class="chip chip-info">dry-run</span>{% endif %} {% else %}<span class="chip chip-info">Trockenmodus</span>{% endif %}
<span class="text-slate-500 text-xs ml-auto" <span class="text-slate-500 text-xs ml-auto"
title="{{ a.started_at|de_dt }}">{{ a.started_at|de_dt }}</span> title="{{ a.started_at|de_dt }}">{{ a.started_at|de_dt }}</span>
</div> </div>

View file

@ -1,9 +1,36 @@
{% extends "_layout.html" %} {% extends "_layout.html" %}
{% block content %} {% block content %}
<section class="card p-4">
<form method="get" action="/logs" class="flex flex-wrap items-end gap-3">
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">von</label>
<input class="input" type="date" name="from" value="{{ from_str }}">
</div>
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">bis</label>
<input class="input" type="date" name="to" value="{{ to_str }}">
</div>
<button class="btn btn-primary text-sm" type="submit">Anwenden</button>
<a class="btn btn-ghost text-sm" href="/logs">zurücksetzen</a>
<a class="btn btn-ghost text-sm"
href="/logs/export.csv?from={{ from_str }}&to={{ to_str }}">
CSV herunterladen
</a>
</form>
</section>
<section class="card"> <section class="card">
<div class="px-4 py-3 border-b border-soft flex items-center justify-between"> <div class="px-4 py-3 border-b border-soft flex items-center justify-between">
<h2 class="font-semibold">System-Log</h2> <h2 class="font-semibold">System-Protokoll</h2>
<span class="text-xs text-slate-500">{{ events|length }} Einträge (max 300, letzte 14 Tage)</span> <span class="text-xs text-slate-500">
{{ events|length }} Einträge
{% if from_str or to_str %}
· Zeitraum:
{{ from_str or "alles davor" }} — {{ to_str or "heute" }}
{% else %}
· ohne Filter (Anzeige bis 500; Aufbewahrung 14 Tage)
{% endif %}
</span>
</div> </div>
<div class="divide-y divide-soft"> <div class="divide-y divide-soft">
{% for e in events %} {% for e in events %}
@ -15,12 +42,12 @@
<span class="chip chip-info">{{ e.source }}</span> <span class="chip chip-info">{{ e.source }}</span>
{% endif %} {% endif %}
<span class="text-slate-700">{{ e.action }}</span> <span class="text-slate-700">{{ e.action }}</span>
{% if e.user %}<span class="text-slate-500">user={{ e.user }}</span>{% endif %} {% if e.user %}<span class="text-slate-500">Benutzer: {{ e.user }}</span>{% endif %}
{% if e.details %}<span class="text-slate-500">— {{ e.details }}</span>{% endif %} {% if e.details %}<span class="text-slate-500">— {{ e.details }}</span>{% endif %}
{% if e.ip %}<span class="text-slate-400">[{{ e.ip }}]</span>{% endif %} {% if e.ip %}<span class="text-slate-400">[{{ e.ip }}]</span>{% endif %}
</div> </div>
{% else %} {% else %}
<div class="px-4 py-8 text-center text-slate-500">Keine Einträge.</div> <div class="px-4 py-8 text-center text-slate-500">Keine Einträge im gewählten Zeitraum.</div>
{% endfor %} {% endfor %}
</div> </div>
</section> </section>