#!/usr/bin/env python3 """ app.py - Flask-Web-App fuer den Immo-Bot mit URL-Liste + Parallel-Batch. Bedienung im Browser: http://localhost:5000 (bzw. http://VPS-IP:5000) Idee: - Du pflegst eine LISTE von immowelt-URLs (URL-Builder in der App). - "Batch Schritt 1": fuer jede URL parallel EIN eigener Browser -> Kontakt- formular ausfuellen (+ absenden). Pro URL wird eine Wegwerf-Adresse (mail.tm) erzeugt, damit die Antwort-Mails nicht kollidieren. - "Batch Schritt 3": fuer jede URL parallel EIN eigener Browser -> auf die Mail warten, Formular-Link holen und das Bewerbungsformular ausfuellen. Alle Feldwerte kommen aus config.json (siehe config.py). Selenium: - Lokal: je URL ein Chrome-Fenster. - Server: SELENIUM_REMOTE_URL=http://selenium:4444 setzen. Fuer echte Parallelitaet muss der Selenium-Container mehrere Sessions erlauben (SE_NODE_MAX_SESSIONS, siehe docker-compose.yml). Start: pip install flask selenium python app.py """ import os import time import threading from concurrent.futures import ThreadPoolExecutor from flask import Flask, request, jsonify, Response import config import core app = Flask(__name__) # --------------------------------------------------------------------------- # Zustand: URL-Liste + pro-URL-Item # --------------------------------------------------------------------------- class Item: """Ein Vorgang fuer genau eine URL (eigener Browser, eigene Mail). Wichtig: Ein Item hat eine eigene, stabile ID. Dieselbe URL kann mehrfach vorkommen (Skalierung: 1 URL -> N parallele Durchlaeufe = N Browser + N Mails). Deshalb wird ueberall die ID als Schluessel genutzt, NICHT die URL.""" def __init__(self, url, iid): self.id = iid # eindeutige Item-ID (Schluessel in State + UI) self.url = url self.email = None # Wegwerf-/IMAP-Adresse self.token = None # mail.tm Token self.link = None # Formular-Link aus der Mail self.s1 = "offen" # Status Schritt 1 self.s3 = "offen" # Status Schritt 3 (inkl. Mail holen) self.submit_decision = "pending" # pending | go | skip (Freigabe zum Absenden) self.canceled = False # True -> laufender Worker soll abbrechen (Loeschen/Stop) # Pipeline-Jobs (GitLab-CI-Style). Status je Job: # pending | running | success | failed | skipped self.jobs = {} self.lines = [] def log(self, msg): self.lines.append(msg) def job(self, key, status): """Setzt den Status eines Pipeline-Jobs (fuer das Dashboard).""" self.jobs[key] = status def as_dict(self): return {"id": self.id, "url": self.url, "email": self.email, "token": self.token, "link": self.link, "s1": self.s1, "s3": self.s3, "jobs": dict(self.jobs), "lines": self.lines[-8:]} class State: def __init__(self): self.lock = threading.Lock() self.items_s1 = {} # id -> Item für Step 1 self.order_s1 = [] # Reihenfolge der IDs self.items_s3 = {} # id -> Item für Step 3 self.order_s3 = [] self.running = False self.epoch = 0 # zaehlt jeden Batch-Start; entkoppelt Stop von Zombie-Threads self._seq = 0 # Zaehler fuer eindeutige Item-IDs def _new_id(self): self._seq += 1 return f"i{self._seq}" def begin_run(self): """Startet einen Batch, wenn keiner laeuft. Gibt ein Epoch-Token zurueck (fuer end_run), oder None wenn schon ein Batch laeuft.""" with self.lock: if self.running: return None self.running = True self.epoch += 1 return self.epoch def end_run(self, token): """Beendet den Batch NUR, wenn seither kein Stop/Neustart passiert ist. Verhindert, dass ein spaet endender Zombie-Thread einen neuen Batch abwuergt.""" with self.lock: if self.epoch == token: self.running = False def stop_all(self): """Harter Stop: alle Items zum Abbruch markieren und running sofort loeschen. Epoch wird erhoeht, damit noch laufende Worker den Flag nicht mehr anfassen.""" with self.lock: self.epoch += 1 self.running = False n = 0 for it in list(self.items_s1.values()) + list(self.items_s3.values()): it.canceled = True if it.submit_decision == "pending": it.submit_decision = "skip" # blockierte Freigabe-Warten freigeben n += 1 return n def add(self, step, url, count=1): """Legt `count` Items fuer die URL an (Skalierung: 1 URL -> N Durchlaeufe). Gibt die Liste der neu erzeugten IDs zurueck.""" url = url.strip() if not url: return [] count = max(1, int(count)) new_ids = [] for _ in range(count): iid = self._new_id() item = Item(url, iid) if step == "1": self.items_s1[iid] = item self.order_s1.append(iid) elif step == "3": self.items_s3[iid] = item self.order_s3.append(iid) else: continue new_ids.append(iid) return new_ids def remove(self, step, iid): iid = iid.strip() items = self.items_s1 if step == "1" else self.items_s3 if step == "3" else None order = self.order_s1 if step == "1" else self.order_s3 if step == "3" else None if items is None or iid not in items: return # Ein evtl. noch laufender Worker fuer dieses Item soll abbrechen, sonst # blockiert er (Freigabe-Warten/Mail-Warten) und haelt state.running fest. it = items[iid] it.canceled = True if it.submit_decision == "pending": it.submit_decision = "skip" del items[iid] order.remove(iid) def list(self, step): if step == "1": return [self.items_s1[i].as_dict() for i in self.order_s1] elif step == "3": return [self.items_s3[i].as_dict() for i in self.order_s3] return [] def get_items_for_step(self, step): if step == "1": return self.items_s1, self.order_s1 elif step == "3": return self.items_s3, self.order_s3 return {}, [] def find_s1_by_url(self, url, need_token=False): """Erstes Step-1-Item mit passender URL (optional: mit Token). Fallback fuer den separaten Step-3-Batch, der Token/Email aus Step 1 uebernimmt.""" url = (url or "").strip() for iid in self.order_s1: it = self.items_s1[iid] if it.url == url and (not need_token or it.token): return it return None state = State() # Browser, die nach Step 3 bewusst OFFEN bleiben (nicht automatisch schliessen). # Referenz hier halten, damit Chrome nicht durch GC/Thread-Ende zugeht. KEPT_DRIVERS = [] KEPT_LOCK = threading.Lock() def _keep_open(driver): with KEPT_LOCK: KEPT_DRIVERS.append(driver) def _close_kept_drivers(): with KEPT_LOCK: drivers = list(KEPT_DRIVERS) KEPT_DRIVERS.clear() n = 0 for d in drivers: try: d.quit() n += 1 except Exception: pass return n # --------------------------------------------------------------------------- # Einmaliges Cookie-/DSGVO-Setup (ein sichtbarer Browser zum Zustimmen) # --------------------------------------------------------------------------- class Consent: """Haelt den einen sichtbaren Browser, in dem der User die Zustimmung einmalig anklickt. save_consent() schreibt Cookies + localStorage nach consent.json; alle spaeteren Batch-Browser spielen das ein.""" def __init__(self): self.lock = threading.Lock() self.driver = None self.status = "idle" # idle | offen | gespeichert | fehler self.lines = [] def log(self, msg): self.lines.append(str(msg)) consent = Consent() # --------------------------------------------------------------------------- # Batch-Ausfuehrung (parallel, pro URL eigener Browser) # --------------------------------------------------------------------------- def _run_parallel(step, fn): """fn(item) fuer alle Items parallel ausfuehren.""" token = state.begin_run() if token is None: return False def worker(): headless = bool(config.CFG.get("run", {}).get("headless", False)) items, order = state.get_items_for_step(step) ids = list(order) try: with ThreadPoolExecutor(max_workers=_batch_workers(len(ids))) as ex: futures = [ex.submit(_safe, fn, items[i], headless) for i in ids] for f in futures: f.result() finally: state.end_run(token) threading.Thread(target=worker, daemon=True).start() return True # Sicherheits-Obergrenze fuer gleichzeitig offene Browser (schuetzt RAM/CPU). BATCH_SAFETY_CAP = 20 def _batch_workers(n_items): """Wie viele Items gleichzeitig laufen. Damit Skalierung (1 URL -> N Mails) wirklich N Browser parallel startet, laufen standardmaessig ALLE Items parallel - begrenzt durch parallel_workers (falls hoeher gesetzt) bzw. BATCH_SAFETY_CAP.""" workers = int(config.CFG.get("run", {}).get("parallel_workers", 5)) cap = max(workers, BATCH_SAFETY_CAP) return max(1, min(max(1, n_items), cap)) def _run_parallel_managed(step, fn): """Wie _run_parallel, aber fn(item) verwaltet seine Browser SELBST (z.B. Komplett-Durchlauf: Browser nach Step 1 schliessen, spaeter neuen oeffnen).""" token = state.begin_run() if token is None: return False def worker(): items, order = state.get_items_for_step(step) ids = list(order) try: with ThreadPoolExecutor(max_workers=_batch_workers(len(ids))) as ex: futures = [ex.submit(_safe_managed, fn, items[i]) for i in ids] for f in futures: f.result() finally: state.end_run(token) threading.Thread(target=worker, daemon=True).start() return True def _run_managed_items(item_list, fn): """fn(item) parallel fuer eine konkrete Item-Liste ausfuehren (managed). fn verwaltet seine Browser selbst. Nutzt denselben running-Flag/Cap wie die anderen Batches.""" if not item_list: return False, "Keine passenden Items (erst Step 1 absenden)." token = state.begin_run() if token is None: return False, "Es laeuft bereits ein Batch." def worker(): try: with ThreadPoolExecutor(max_workers=_batch_workers(len(item_list))) as ex: futures = [ex.submit(_safe_managed, fn, it) for it in item_list] for f in futures: f.result() finally: state.end_run(token) threading.Thread(target=worker, daemon=True).start() return True, None def _safe_managed(fn, item): try: fn(item) except Exception as e: item.log(f"!! Fehler: {e}") def _safe(fn, item, headless): driver = None try: # Consent (DSGVO/Cookies) kommt pro Browser aus consent.json und wird # in _open_with_cookies() eingespielt - parallel-tauglich. driver = core.make_driver(headless=headless) fn(item, driver) except Exception as e: item.log(f"!! Fehler: {e}") finally: if driver: try: driver.quit() except Exception: pass def _open_with_cookies(driver, url, log=None): """Seite oeffnen und gespeicherte DSGVO-/Cookie-Zustimmung einspielen (Cookies + localStorage aus consent.json, via cookies_setup.py angelegt).""" driver.get(url) time.sleep(1.5) try: if core.restore_consent(driver, log): # macht intern refresh() time.sleep(1.0) except Exception: pass def _await_submit_decision(item, timeout=900, poll=0.5): """Blockiert, bis der User in der UI 'Absenden' (go) oder 'Ueberspringen' (skip) waehlt. Der Browser bleibt so lange offen (Formular bleibt gefuellt). Bei Abbruch (Item geloescht/Stop) sofort 'skip'. Gibt 'go' | 'skip' | 'timeout' zurueck.""" deadline = time.time() + timeout while time.time() < deadline: if item.canceled: return "skip" if item.submit_decision in ("go", "skip"): return item.submit_decision time.sleep(poll) return "timeout" def _fill_and_submit(item, driver): """Step 1: Email+Token sichern, Kontaktformular ausfuellen, dann auf die Freigabe zum Absenden warten und (bei 'go') absenden. Gibt True zurueck, wenn abgesendet wurde.""" item.submit_decision = "pending" item.s1 = "laeuft" mode = config.CFG["email"]["mode"] item.job("s1_mail", "running") if mode == "mailtm": # Wegwerf-Adresse ggf. schon vorab erzeugt (Button "Email+Token erzeugen"). if not item.token: item.email, item.token = core.disposable_address(item.log) else: item.email = config.CFG["email"].get("imap_user", "") item.job("s1_mail", "success") contact = core.contact_data(item.email) item.log(f"[1] Oeffne {item.url}") item.job("s1_fill", "running") _open_with_cookies(driver, item.url, item.log) if not core.fill_contact_form(driver, item.log, contact): item.job("s1_fill", "failed") item.s1 = "fehler" return False item.job("s1_fill", "success") # Formular ist ausgefuellt -> auf Freigabe warten (Auswahl in der UI). item.s1 = "bereit_zum_absenden" item.job("s1_submit", "running") item.log("✋ Ausgefuellt. Warte auf Freigabe: 'Absenden' oder 'Ueberspringen' in der UI.") decision = _await_submit_decision(item) if decision == "skip": item.s1 = "uebersprungen" item.job("s1_submit", "skipped") item.log("⏭ Uebersprungen - NICHT abgesendet.") return False if decision == "timeout": item.s1 = "fehler" item.job("s1_submit", "failed") item.log("!! Timeout - keine Freigabe zum Absenden erhalten.") return False if not core.submit_contact_form(driver, item.log): item.s1 = "fehler_beim_absenden" item.job("s1_submit", "failed") return False item.s1 = "abgesendet" item.job("s1_submit", "success") item.log("[1] ✅ Kontaktformular abgesendet.") return True def do_step1(item, driver): """Nur Step 1: Kontaktformular ausfuellen und (nach Freigabe) absenden.""" _fill_and_submit(item, driver) def _step3_link(item): """Ermittelt den Bewerbungsformular-Link fuer ein Step-3-Item. Reihenfolge: 1) item.link schon gesetzt (z.B. per Step 2 uebernommen) -> nutzen. 2) Die eingegebene URL IST bereits ein Bewerbungsformular-Link -> direkt nutzen. 3) Token/E-Mail aus dem Step-1-Item derselben URL uebernehmen. 4) Sonst: aus der Mail holen (Wegwerf-Postfach oder IMAP). Gibt den Link oder None zurueck. """ mode = config.CFG["email"]["mode"] sender = config.CFG["email"].get("sender_filter", "") if item.link: return item.link if "/property/application/form/" in item.url.lower(): item.log("URL ist bereits der Bewerbungsformular-Link -> direkt oeffnen.") return item.url if not item.token: with state.lock: s1 = state.find_s1_by_url(item.url, need_token=True) if s1 and s1.token: item.token, item.email = s1.token, s1.email item.log("Wegwerf-Adresse aus Schritt 1 uebernommen.") if mode == "mailtm": if not item.token: item.log("!! Kein Link und keine Wegwerf-Adresse. Entweder den " "Bewerbungsformular-Link direkt eingeben, ODER erst Schritt 1 " "fuer DIESELBE URL ausfuehren.") return None return core.watch_disposable(item.token, item.log, sender_hint=sender) return core.watch_inbox_imap( config.CFG["email"]["imap_host"], config.CFG["email"]["imap_user"], config.CFG["email"]["imap_password"], item.log, sender_hint=sender) def _run_step3_form(item, driver): """Bewerbungsformular oeffnen + ausfuellen. Je nach App-Config (run): - auto_submit=false (Default): NICHT absenden. - keep_browser_open=true (Default): Browser OFFEN lassen (nicht schliessen). Gibt True zurueck, wenn der Browser offen gehalten wird -> Aufrufer darf ihn dann NICHT schliessen.""" run_cfg = config.CFG.get("run", {}) auto_submit = bool(run_cfg.get("auto_submit", False)) keep_open = bool(run_cfg.get("keep_browser_open", True)) try: item.job("s3_open", "running") item.log(f"[3] Oeffne Bewerbungsformular: {item.link}") _open_with_cookies(driver, item.link, item.log) item.job("s3_open", "success") item.job("s3_fill", "running") core.fill_application_form(driver, item.log) item.job("s3_fill", "success") except Exception as e: item.job("s3_fill", "failed") item.s3 = "fehler" item.log(f"[3] !! Fehler beim Ausfuellen: {e}") return False if auto_submit: item.job("s3_submit", "running") item.log("[3] auto_submit=true -> Bewerbung wird abgesendet.") if core.submit_contact_form(driver, item.log): item.job("s3_submit", "success") item.s3 = "fertig" else: item.job("s3_submit", "failed") item.s3 = "fehler" else: item.job("s3_submit", "skipped") item.s3 = "ausgefuellt_offen" item.log("[3] ✅ Ausgefuellt - NICHT abgesendet (auto_submit=false).") if keep_open: _keep_open(driver) item.log("[3] 🖐 Browser bleibt OFFEN. Schliessen ueber '🧹 Offene Browser schliessen'.") return True return False def do_step3(item): """Nur Step 3: Link ermitteln, Bewerbungsformular ausfuellen (eigener Browser).""" headless = bool(config.CFG.get("run", {}).get("headless", False)) item.s3 = "laeuft" item.job("s2_link", "running") item.link = _step3_link(item) if not item.link: item.job("s2_link", "failed") item.s3 = "fehler" return item.job("s2_link", "success") driver = core.make_driver(headless=headless) if not _run_step3_form(item, driver): try: driver.quit() except Exception: pass def _step2_and_step3(item): """Schritt 2 (auf Antwort-Mail warten + Formular-Link holen) und Schritt 3 (Bewerbungsformular ausfuellen) fuer EIN Item - ohne Step 1. Voraussetzung: item.token/email stammen aus Step 1. Jeder Aufruf laeuft in seinem EIGENEN Thread und wartet auf sein EIGENES Postfach -> voll skalierbar (N Items = N parallele Mail-Wartevorgaenge).""" mode = config.CFG["email"]["mode"] sender = config.CFG["email"].get("sender_filter", "") headless = bool(config.CFG.get("run", {}).get("headless", False)) cancel = lambda: item.canceled # ---------- Schritt 2: OHNE Browser, wartet auf die eigene Antwort-Mail ---------- item.s3 = "warte_mail" item.job("s2_wait", "running") item.log(f"[2] Warte auf Antwort-Mail fuer {item.email} / {(item.token or '')[:12]}… und extrahiere Formular-Link ...") if mode == "mailtm": if not item.token: item.log("[2] !! Kein Token aus Schritt 1 vorhanden.") item.job("s2_wait", "failed") item.s3 = "fehler" return item.link = core.watch_disposable(item.token, item.log, sender_hint=sender, should_cancel=cancel) else: item.link = core.watch_inbox_imap( config.CFG["email"]["imap_host"], config.CFG["email"]["imap_user"], config.CFG["email"]["imap_password"], item.log, sender_hint=sender, should_cancel=cancel) if item.canceled: item.s3 = "abgebrochen" item.job("s2_wait", "skipped") item.log("[2] ⏹ Abgebrochen.") return if not item.link: item.log("[2] !! Kein Formular-Link gefunden (Timeout?).") item.job("s2_wait", "failed") item.job("s2_link", "failed") item.s3 = "fehler" return item.job("s2_wait", "success") item.job("s2_link", "success") item.log(f"[2] Formular-Link: {item.link}") # ---------- Schritt 3: neuer Browser ---------- if item.canceled: item.s3 = "abgebrochen" item.log("[3] ⏹ Abgebrochen (vor dem Oeffnen).") return item.s3 = "laeuft" driver2 = core.make_driver(headless=headless) if not _run_step3_form(item, driver2): try: driver2.quit() except Exception: pass def do_step2(item): """Nur Schritt 2 -> 3 fuer ein bereits abgesendetes Step-1-Item. Wird vom skalierbaren Step-2-Batch pro Item parallel aufgerufen.""" if not item.token and config.CFG["email"]["mode"] == "mailtm": item.log("[2] Kein Token - Step 1 fuer dieses Item nicht abgesendet? Uebersprungen.") return _step2_and_step3(item) def do_all(item): """KOMPLETT-DURCHLAUF fuer eine URL. Verwaltet die Browser selbst: 1) Browser A: Kontaktformular ausfuellen + (nach Freigabe) absenden -> Browser A ZU. 2) OHNE Browser: Email+Token aus Schritt 1 werden AUTOMATISCH uebernommen, damit wird auf die Antwort-Mail gewartet und der Formular-Link geholt. 3) Browser B: Bewerbungsformular-Link oeffnen und ausfuellen -> Browser B ZU. """ headless = bool(config.CFG.get("run", {}).get("headless", False)) item.s3 = "offen" # ---------- Schritt 1: eigener Browser, danach schliessen ---------- driver = core.make_driver(headless=headless) try: submitted = _fill_and_submit(item, driver) finally: try: driver.quit() except Exception: pass item.log("[1] Browser geschlossen.") if not submitted: return # uebersprungen / Fehler -> keine Mail zu erwarten # ---------- Schritt 2 + 3: eigenes Postfach abwarten, Formular ausfuellen ---------- item.log("[2] Email+Token aus Schritt 1 uebernommen.") _step2_and_step3(item) # --------------------------------------------------------------------------- # Routen # --------------------------------------------------------------------------- @app.route("/healthz") def healthz(): """Leichter Health-Check fuer Container/K8s-Probes (kein State-Aufbau).""" return jsonify(ok=True) # Basis-URL des noVNC-Live-Views. Lokal (docker-compose): http://localhost:7900. # Im Cluster hinter dem Ingress: /novnc/ (stripPrefix-Middleware routet zum # selenium-Sidecar auf 7900). Ueber die Env-Var pro Umgebung setzbar. NOVNC_URL = os.environ.get("NOVNC_URL", "/novnc/") @app.route("/") def index(): # Token-Replace statt .format(): das PAGE-CSS ist voller { } -> .format() bricht. return Response(PAGE.replace("__NOVNC_URL__", NOVNC_URL), mimetype="text/html") @app.route("/urls/add", methods=["POST"]) def urls_add(): data = request.get_json(force=True) step = data.get("step", "1") # Skalierung: jede URL wird `count`-mal angelegt (N Browser + N Mails). try: count = max(1, int(data.get("count", 1))) except (TypeError, ValueError): count = 1 count = min(count, BATCH_SAFETY_CAP * 5) # harte Obergrenze pro Zeile with state.lock: for line in str(data.get("url", "")).splitlines(): if line.strip(): state.add(step, line, count=count) return jsonify(ok=True, items_s1=state.list("1"), items_s3=state.list("3")) @app.route("/urls/remove", methods=["POST"]) def urls_remove(): data = request.get_json(force=True) step = data.get("step", "1") with state.lock: state.remove(step, str(data.get("id", "")).strip()) return jsonify(ok=True, items_s1=state.list("1"), items_s3=state.list("3")) @app.route("/step1/prepare", methods=["POST"]) def step1_prepare(): """Erzeugt pro Step-1-URL SOFORT eine Wegwerf-Adresse (mail.tm) + Token und zeigt beides in der Liste an - ohne einen Browser zu starten. Laeuft im Hintergrund; die UI pollt /state und sieht Email+Token, sobald sie fertig sind.""" with state.lock: targets = [state.items_s1[u] for u in state.order_s1 if not state.items_s1[u].token] if config.CFG["email"]["mode"] != "mailtm": # Ohne Wegwerf-Modus gibt es keinen Token; einfach die IMAP-Adresse zeigen. for it in targets: it.email = config.CFG["email"].get("imap_user", "") it.log("IMAP-Modus: feste Adresse, kein Token.") return jsonify(ok=True, items_s1=state.list("1")) if not targets: return jsonify(ok=True, note="Alle URLs haben bereits Email+Token.", items_s1=state.list("1")) def gen(item): try: item.s1 = "email_wird_erzeugt" item.email, item.token = core.disposable_address(item.log) if item.s1 == "email_wird_erzeugt": item.s1 = "email_bereit" except Exception as e: item.log(f"!! Email-Erzeugung fehlgeschlagen: {e}") def worker(): with ThreadPoolExecutor(max_workers=5) as ex: for f in [ex.submit(gen, it) for it in targets]: f.result() threading.Thread(target=worker, daemon=True).start() return jsonify(ok=True, count=len(targets), items_s1=state.list("1")) @app.route("/batch/", methods=["POST"]) def batch(step): if step == "1": # auto=true -> pro Item DIREKT nach erfolgreichem Absenden weiter mit # Step 2 (auf eigene Mail warten) + Step 3. Laeuft alles in EINEM Batch, # jeder Vorgang unabhaengig -> kein Warten auf die anderen Items. auto = bool((request.get_json(silent=True) or {}).get("auto", False)) if auto: ok = _run_parallel_managed("1", do_all) else: ok = _run_parallel("1", do_step1) elif step == "2": # Skalierbarer Step 2: fuer JEDES abgesendete Step-1-Item parallel auf # das EIGENE Postfach warten, Link holen und Step 3 ausfuellen. with state.lock: targets = [state.items_s1[i] for i in state.order_s1 if state.items_s1[i].s1 == "abgesendet" and state.items_s1[i].s3 not in ("warte_mail", "laeuft", "fertig", "ausgefuellt_offen")] ok, err = _run_managed_items(targets, do_step2) if not ok: return jsonify(ok=False, error=err or "Batch laeuft bereits.") return jsonify(ok=True, count=len(targets)) elif step == "3": # do_step3 verwaltet seinen Browser selbst (offen lassen moeglich). ok = _run_parallel_managed("3", do_step3) elif step == "all": # Kompletter Durchlauf 1->2->3 fuer jede URL der Step-1-Liste. # do_all verwaltet seine Browser selbst (Browser nach Step 1 schliessen). ok = _run_parallel_managed("1", do_all) else: return jsonify(ok=False, error="Unbekannter Schritt.") if not ok: return jsonify(ok=False, error="Es laeuft bereits ein Batch.") return jsonify(ok=True) @app.route("/batch/stop", methods=["POST"]) def batch_stop(): """Harter Stop/Reset: laufende Vorgaenge abbrechen und den 'laeuft'-Status sofort loeschen. Danach kann direkt ein neuer Batch gestartet werden. Noch blockierte Worker (Freigabe-/Mail-Warten) bekommen ihr Abbruch-Signal und beenden sich in wenigen Sekunden von selbst.""" n = state.stop_all() return jsonify(ok=True, canceled=n) @app.route("/browsers/close", methods=["POST"]) def browsers_close(): """Schliesst alle Browser, die nach Step 3 bewusst offen gehalten wurden.""" n = _close_kept_drivers() return jsonify(ok=True, closed=n, open=len(KEPT_DRIVERS)) @app.route("/state") def get_state(): return jsonify(running=state.running, items_s1=state.list("1"), items_s3=state.list("3"), mode=config.CFG["email"]["mode"], kept_browsers=len(KEPT_DRIVERS), app_mode=config.MODE, mode_locked=config.mode_locked_by_env()) @app.route("/mode", methods=["GET", "POST"]) def mode_route(): """Aktiven App-Modus lesen/umschalten (test|prod). Umschalten nur, wenn kein Batch laeuft und der Modus nicht per ENV festgelegt ist.""" if request.method == "POST": if state.running: return jsonify(ok=False, error="Es laeuft ein Batch - erst abwarten.") if config.mode_locked_by_env(): return jsonify(ok=False, error="Modus ist per Umgebungsvariable IBO_MODE festgelegt.") want = (request.get_json(force=True) or {}).get("mode", "") new = config.set_mode(want) return jsonify(ok=True, app_mode=new) return jsonify(app_mode=config.MODE, locked=config.mode_locked_by_env()) @app.route("/config", methods=["GET", "POST"]) def config_route(): if request.method == "POST": new = request.get_json(force=True) config.save(new) config.reload() return jsonify(ok=True) return jsonify(config.CFG) # ---- Einmaliges Cookie-/DSGVO-Setup --------------------------------------- @app.route("/consent/open", methods=["POST"]) def consent_open(): """Oeffnet EINEN sichtbaren Browser auf der angegebenen Seite, damit der User die Zustimmung einmalig anklickt.""" data = request.get_json(force=True) or {} first_url = state.items_s1[state.order_s1[0]].url if state.order_s1 else "" url = (data.get("url") or "").strip() or first_url or "https://www.immowelt.de/" with consent.lock: if consent.driver is not None: # alten Browser schliessen try: consent.driver.quit() except Exception: pass consent.driver = None consent.lines = [] try: # headless=False -> sichtbar. (Remote/VPS: ueber noVNC Port 7900 ansehen.) consent.driver = core.make_driver(headless=False) except Exception as e: consent.status = "fehler" consent.log(f"!! Browser-Start fehlgeschlagen: {e}") return jsonify(ok=False, error=f"Browser-Start fehlgeschlagen: {e}") consent.status = "offen" def go(): try: consent.log(f"Oeffne {url}") consent.driver.get(url) consent.log("Im Browser die Cookie-/DSGVO-Zustimmung anklicken, " "dann hier 'Zustimmung speichern & schliessen'.") except Exception as e: consent.log(f"!! {e}") threading.Thread(target=go, daemon=True).start() return jsonify(ok=True) @app.route("/consent/save", methods=["POST"]) def consent_save(): """Speichert Cookies + localStorage der aktuell offenen Seite und schliesst den Browser.""" with consent.lock: if consent.driver is None: return jsonify(ok=False, error="Kein Consent-Browser offen.") try: origin = core.save_consent(consent.driver, consent.log) except Exception as e: consent.log(f"!! Speichern fehlgeschlagen: {e}") return jsonify(ok=False, error=str(e)) try: consent.driver.quit() except Exception: pass consent.driver = None consent.status = "gespeichert" return jsonify(ok=True, origin=origin) @app.route("/consent/state") def consent_state(): return jsonify(open=consent.driver is not None, status=consent.status, lines=consent.lines[-8:]) # ---- Mail-Extraktion zum Testen / Bestaetigen ---------------------------------- @app.route("/submit/", methods=["POST"]) def submit_form(iid): """Gibt das Absenden frei. Das eigentliche Absenden macht der laufende Worker, der den bereits ausgefuellten Browser noch offen haelt (Formular bleibt gefuellt).""" iid = iid.strip() with state.lock: item = state.items_s1.get(iid) if not item: return jsonify(ok=False, error="Item nicht gefunden") if item.s1 != "bereit_zum_absenden": return jsonify(ok=False, error=f"Formular ist in Status '{item.s1}', nicht bereit zum Absenden") item.submit_decision = "go" item.log("👍 Freigabe zum Absenden erhalten.") return jsonify(ok=True) @app.route("/skip/", methods=["POST"]) def skip_form(iid): """Ueberspringt das Absenden fuer dieses Item (Worker faehrt NICHT fort).""" iid = iid.strip() with state.lock: item = state.items_s1.get(iid) if not item: return jsonify(ok=False, error="Item nicht gefunden") if item.s1 != "bereit_zum_absenden": return jsonify(ok=False, error=f"Status '{item.s1}', nicht im Freigabe-Zustand") item.submit_decision = "skip" item.log("⏭ Zum Ueberspringen markiert.") return jsonify(ok=True) @app.route("/mail/extract//", methods=["GET"]) def mail_extract(step, email_or_url): """Holt Mail und extrahiert den Link. - Step 1/3: email_or_url ist die URL im Item - Step 2: email_or_url ist direkt die Email-Adresse (manueller Test) Token wird vom Query-Parameter genommen (bei Step 2). """ email_or_url = email_or_url.strip() token = request.args.get("token", "").strip() # Fallback: aus Item holen (Step 1/3) - email_or_url ist dann die Item-ID if not token: with state.lock: if step == "1": item = state.items_s1.get(email_or_url) elif step == "3": item = state.items_s3.get(email_or_url) else: item = None if not item: return jsonify(ok=False, error="URL/Item nicht gefunden") if not item.token: return jsonify(ok=False, error="Keine Wegwerf-Adresse fuer diese URL") token = item.token if not token: return jsonify(ok=False, error="Token erforderlich") try: import mailtm msgs = mailtm.list_messages(token) if not msgs: return jsonify(ok=False, error="Keine Mails im Postfach", msgs=[]) results = [] for m in msgs: mid = m.get("id") sender = m.get("from", {}) if isinstance(sender, dict): sender = sender.get("address", "?") subject = m.get("subject", "(kein Betreff)") html = mailtm.get_message_html(token, mid) link = core.extract_form_link(html) results.append({ "id": mid, "from": sender, "subject": subject, "link": link, "has_link": bool(link) }) return jsonify(ok=True, msgs=results) except Exception as e: return jsonify(ok=False, error=str(e)) @app.route("/mail/show", methods=["GET"]) def mail_show(): """Zeigt ALLE Mails eines Wegwerf-Postfachs inkl. Inhalt (HTML/Text) an. Token kommt per Query-Parameter (?token=...). Ist kein Postfach/keine Mail vorhanden, wird eine leere Liste zurueckgegeben -> UI zeigt "Keine Mail".""" token = request.args.get("token", "").strip() if not token: return jsonify(ok=False, error="Token erforderlich") try: import mailtm msgs = mailtm.list_messages(token) if not msgs: return jsonify(ok=True, msgs=[]) results = [] for m in msgs: mid = m.get("id") sender = m.get("from", {}) if isinstance(sender, dict): sender = sender.get("address", "?") subject = m.get("subject", "(kein Betreff)") html = mailtm.get_message_html(token, mid) link = core.extract_form_link(html or "") results.append({ "id": mid, "from": sender, "subject": subject, "html": html or "", "link": link, "has_link": bool(link), }) return jsonify(ok=True, msgs=results) except Exception as e: return jsonify(ok=False, error=str(e)) @app.route("/mail/set-link//", methods=["POST"]) def mail_set_link(step, iid): """Speichert den manuell eingegebenen/bestaetigen Link (iid = Item-ID).""" iid = iid.strip() data = request.get_json(force=True) or {} link = (data.get("link") or "").strip() if not link: return jsonify(ok=False, error="Link erforderlich") with state.lock: if step == "1": item = state.items_s1.get(iid) else: item = state.items_s3.get(iid) if not item: return jsonify(ok=False, error="Item nicht gefunden") item.link = link item.log(f"Link manuell gespeichert: {link}") return jsonify(ok=True, link=link) # --------------------------------------------------------------------------- # HTML (inline) # --------------------------------------------------------------------------- PAGE = """ Immo-Bot Batch

🏠 Immo-Bot

3-Schritte-Prozess · Feldwerte je Step unter ⚙️ Config · E-Mail-Modus: ?

…
SETUP Cookies einmalig speichern (DSGVO)

Browser öffnet → Cookie-/DSGVO-Zustimmung anklicken → speichern. Danach laufen alle Batches ohne Banner.

🧩 Pipeline-Dashboard

Fortschritt pro URL über alle Steps und Sub-Steps – live aktualisiert.

Noch keine URLs in Step 1.
STEP 1 Kontaktformular
So wird das Kontaktformular ausgefüllt
= 1 Browser + 1 Mail pro URL

Füllt aus und wartet auf deine Freigabe (📤/⏭ pro Zeile). Direkt nach jedem Absenden läuft vollautomatisch weiter: Step 2 (auf die eigene Mail warten) → Link → Step 3.

Liste

0 URLs
STEP 2 Mail & Link
Mail-Einstellungen
STEP 3 Bewerbungsformular
So wird das Bewerbungsformular ausgefüllt

Verhalten nach dem Ausfüllen

Mieter 1

Mieter 2

Entweder den Formular-Link direkt (…/property/application/form/…) ODER dieselbe URL wie Step 1 (Token wird übernommen, Link aus der Mail geholt).

Liste

0 URLs
""" if __name__ == "__main__": app.run(host="0.0.0.0", port=5000, debug=False)