From abd614604f4625122dd4cd1b0e8ba0067ea8c5e8 Mon Sep 17 00:00:00 2001 From: EiSiMo Date: Thu, 23 Apr 2026 09:56:04 +0200 Subject: [PATCH] feat(apply): treat "Inserat offline" as its own outcome Offline listings are a different class of result from real failures: they mean "this ad is gone", not "the apply pipeline is broken". - DB migration 0010 adds flats.offline_at; recent_flats() filters those out globally so they drop off every user's Wohnungen list - _is_offline_result() matches the four known offline/deactivated phrases (DE + EN translations) - On an offline result: mark the flat, reset the failure counter instead of incrementing, and skip the apply_fail notification - Bewerbungen history renders a yellow "offline" chip in place of the red "fehlgeschlagen" one Co-Authored-By: Claude Opus 4.7 (1M context) --- web/common.py | 31 ++++++++++++++++++++++++++++--- web/db.py | 18 +++++++++++++++++- web/templates/bewerbungen.html | 9 ++++++++- 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/web/common.py b/web/common.py index f67f968..e07498e 100644 --- a/web/common.py +++ b/web/common.py @@ -202,6 +202,23 @@ def _has_running_application(user_id: int) -> bool: return db.has_running_application(user_id) +# Apply returns success=False with one of these phrases when the listing has +# been taken down or deactivated. We treat that as "the flat is gone" rather +# than "the apply pipeline is broken" — no circuit-breaker hit, no fail +# notification, and the flat is hidden from every user's list. +_OFFLINE_MESSAGE_MARKERS = ( + "inserat offline", "inserat deaktiviert", # de + "ad offline", "ad deactivated", # en +) + + +def _is_offline_result(message: str) -> bool: + if not message: + return False + m = message.lower() + return any(marker in m for marker in _OFFLINE_MESSAGE_MARKERS) + + def _finish_apply_background(app_id: int, user_id: int, flat_id: str, url: str, profile: dict, submit_forms: bool) -> None: """Called on a worker thread AFTER the application row already exists. @@ -218,8 +235,14 @@ def _finish_apply_background(app_id: int, user_id: int, flat_id: str, url: str, db.finish_application(app_id, success=success, message=message, provider=provider, forensics=forensics) + offline = not success and _is_offline_result(message) + if offline: + db.mark_flat_offline(flat_id) + prefs = db.get_preferences(user_id) - if success: + if success or offline: + # Offline isn't a pipeline failure — reset the counter so a streak of + # stale listings doesn't trip the circuit breaker. db.update_preferences(user_id, {"apply_recent_failures": 0}) else: failures = int(prefs["apply_recent_failures"] or 0) + 1 @@ -241,10 +264,12 @@ def _finish_apply_background(app_id: int, user_id: int, flat_id: str, url: str, "total_rent": flat["total_rent"] if flat else None} if success: notifications.on_apply_ok(user_id, flat_dict, message) - else: + elif not offline: notifications.on_apply_fail(user_id, flat_dict, message) - db.log_audit("system", "apply_finished", f"app={app_id} success={success}", user_id=user_id) + outcome = "success" if success else ("offline" if offline else "failure") + db.log_audit("system", "apply_finished", + f"app={app_id} outcome={outcome}", user_id=user_id) def _kick_apply(user_id: int, flat_id: str, url: str, triggered_by: str) -> None: diff --git a/web/db.py b/web/db.py index 3e01c95..0d0360e 100644 --- a/web/db.py +++ b/web/db.py @@ -265,6 +265,11 @@ MIGRATIONS: list[str] = [ CREATE INDEX IF NOT EXISTS idx_applications_user_flat_started ON applications(user_id, flat_id, started_at DESC); """, + # 0010: flats go offline — mark globally so they drop out of every user's list + """ + ALTER TABLE flats ADD COLUMN offline_at TEXT; + CREATE INDEX IF NOT EXISTS idx_flats_offline ON flats(offline_at); + """, ] @@ -551,10 +556,21 @@ def upsert_flat(payload: dict) -> bool: def recent_flats(limit: int = 50) -> list[sqlite3.Row]: return list(_get_conn().execute( - "SELECT * FROM flats ORDER BY discovered_at DESC LIMIT ?", (limit,) + "SELECT * FROM flats WHERE offline_at IS NULL " + "ORDER BY discovered_at DESC LIMIT ?", (limit,) ).fetchall()) +def mark_flat_offline(flat_id: str) -> None: + """Flag a flat as no longer reachable. Idempotent — the first offline + timestamp wins so we can tell how long it's been gone.""" + with _lock: + _get_conn().execute( + "UPDATE flats SET offline_at = ? WHERE id = ? AND offline_at IS NULL", + (now_iso(), flat_id), + ) + + def get_flat(flat_id: str) -> Optional[sqlite3.Row]: return _get_conn().execute("SELECT * FROM flats WHERE id = ?", (flat_id,)).fetchone() diff --git a/web/templates/bewerbungen.html b/web/templates/bewerbungen.html index 4050324..5a82779 100644 --- a/web/templates/bewerbungen.html +++ b/web/templates/bewerbungen.html @@ -11,7 +11,14 @@
{% if a.success == 1 %}ok - {% elif a.success == 0 %}fehlgeschlagen + {% elif a.success == 0 %} + {% set msg_lower = (a.message or '')|lower %} + {% if 'inserat offline' in msg_lower or 'inserat deaktiviert' in msg_lower + or 'ad offline' in msg_lower or 'ad deactivated' in msg_lower %} + offline + {% else %} + fehlgeschlagen + {% endif %} {% else %}läuft{% endif %} {% if a.triggered_by == 'auto' %}automatisch{% else %}manuell{% endif %} {% if a.provider %}{{ a.provider }}{% endif %}