ibo/app.py
timu 650d7f3394 ibo: containerize + Gitea-CI + dev/prod-Deploy + Zitadel-SSO + noVNC-Live-View
- Dockerfile/compose: Flask-App + selenium/standalone-chrome (noVNC), Healthcheck
- app.py: /healthz + eingebettete noVNC-Live-Ansicht (NOVNC_URL, lokal:7900 / Cluster:/novnc/)
- Helm-Chart infra/k8s/base/ibo: App + Selenium-Sidecar, 2 Ports, Ingress
  (/ + /novnc via stripPrefix), SSO-Middlewares aus dem CD-Repo
- Gitea-CI: version -> test -> build-push -> helm-validate -> deploy-dev (Write-back)
- .gitignore/.dockerignore (Dev-Samples raus)
- infra/RUNBOOK-ibo.md: Zitadel/DNS/Deploy/Prod-Promote

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 01:03:32 +02:00

2125 lines
94 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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/<step>", 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/<iid>", 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/<iid>", 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/<step>/<path:email_or_url>", 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/<step>/<path:iid>", 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 = """<!doctype html>
<html lang="de"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Immo-Bot Batch</title>
<style>
:root{
color-scheme: light dark;
--accent:#3b82f6; --accent-d:#2563eb; --accent2:#22c55e; --warn:#f59e0b; --danger:#ef4444;
--card: color-mix(in srgb, canvastext 3.5%, canvas);
--input: color-mix(in srgb, canvastext 5%, canvas);
--border: color-mix(in srgb, canvastext 16%, transparent);
--btn: color-mix(in srgb, canvastext 8%, transparent);
--btn-hover: color-mix(in srgb, canvastext 15%, transparent);
--muted: color-mix(in srgb, canvastext 55%, transparent);
}
*{ box-sizing:border-box; }
body{ font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; max-width:1360px;
margin:0 auto; padding:22px 20px 60px; background:canvas; color:canvastext; line-height:1.45; }
h1{ font-size:1.35rem; margin:0 0 2px; letter-spacing:-.01em; }
a{ color:var(--accent); }
.sub{ color:var(--muted); font-size:.85rem; margin:0 0 20px; }
.hint{ color:var(--muted); font-size:.8rem; }
code{ background:var(--btn); padding:1px 5px; border-radius:5px; font-size:.85em; }
input,select,textarea{ width:100%; padding:9px 10px; border:1px solid var(--border);
border-radius:9px; background:var(--input); color:inherit; font:inherit; font-size:.85rem; }
textarea{ resize:vertical; }
input:focus,select:focus,textarea:focus{ outline:2px solid color-mix(in srgb,var(--accent) 40%,transparent);
outline-offset:0; border-color:var(--accent); }
input[type=checkbox]{ width:auto; accent-color:var(--accent); }
label.chk{ display:flex; align-items:center; gap:8px; font-size:.82rem; margin:9px 0 0; cursor:pointer; }
button{ font:inherit; display:inline-flex; align-items:center; justify-content:center; gap:6px;
padding:8px 13px; border:1px solid var(--border); border-radius:9px; background:var(--btn);
color:inherit; cursor:pointer; font-weight:600; font-size:.82rem; line-height:1;
transition:background .12s,border-color .12s,transform .04s; }
button:hover{ background:var(--btn-hover); }
button:active{ transform:translateY(1px); }
button.p1{ background:var(--accent); border-color:transparent; color:#fff; }
button.p1:hover{ background:var(--accent-d); }
button.p2{ background:var(--warn); border-color:transparent; color:#3a2900; }
button.p3{ background:var(--accent2); border-color:transparent; color:#053312; }
button.ghost{ background:transparent; }
button.btn-small{ padding:5px 9px; font-size:.72rem; border-radius:7px; }
button.watching{ background:var(--danger); border-color:transparent; color:#fff; }
button.del{ background:transparent; border:0; color:var(--danger); padding:5px 8px; font-size:.85rem; }
button.del:hover{ background:color-mix(in srgb,var(--danger) 15%,transparent); }
.actions{ display:flex; flex-wrap:wrap; gap:8px; margin-top:12px; }
.scale-row{ display:flex; align-items:center; gap:10px; margin-top:10px; flex-wrap:wrap; }
.scale-row label{ font-size:.8rem; font-weight:600; white-space:nowrap; }
.scale-row input{ width:74px; text-align:center; font-weight:700; }
.scale-row .hint{ flex:1 1 140px; }
.id-tag{ font-size:.62rem; color:var(--muted); font-family:ui-monospace,monospace;
background:var(--btn); padding:1px 5px; border-radius:5px; margin-left:6px; }
.cta{ width:100%; padding:13px; font-size:.92rem; margin-top:12px; }
.card{ border:1px solid var(--border); border-radius:16px; padding:18px 18px 20px; background:var(--card); }
.setup{ display:flex; flex-wrap:wrap; gap:10px 12px; align-items:center; margin-top:10px; }
.setup .grow{ flex:1 1 260px; }
.grid3{ display:grid; grid-template-columns:repeat(auto-fit,minmax(340px,1fr)); gap:18px; margin-top:22px; align-items:start; }
.card{ min-width:0; } /* erlaubt Ellipsis-Truncation in den Karten */
.step-head{ display:flex; align-items:center; gap:9px; margin:0 0 10px; font-size:1.02rem; font-weight:700; }
.step-head .dot{ width:11px; height:11px; border-radius:50%; flex:0 0 auto; }
.step-num{ font-size:.7rem; color:var(--muted); font-weight:700; }
.spinner{ display:inline-block; width:15px; height:15px; border:2px solid color-mix(in srgb,canvastext 22%,transparent);
border-top-color:var(--accent); border-radius:50%; animation:spin .7s linear infinite; vertical-align:-3px; }
@keyframes spin{ to{ transform:rotate(360deg); } }
.loading{ display:flex; align-items:center; gap:9px; justify-content:center; padding:16px; color:var(--muted); font-size:.85rem; }
details.cfg{ margin:0 0 14px; border:1px solid var(--border); border-radius:11px;
background:color-mix(in srgb,canvastext 2%,canvas); overflow:hidden; }
details.cfg > summary{ cursor:pointer; padding:10px 12px; font-weight:600; font-size:.85rem;
list-style:none; user-select:none; color:var(--muted); }
details.cfg > summary::-webkit-details-marker{ display:none; }
details.cfg > summary::before{ content:"⚙️ "; }
details.cfg[open] > summary{ border-bottom:1px solid var(--border); color:inherit; }
.cfg-body{ padding:12px; }
.cfg-body > label{ display:block; font-size:.74rem; font-weight:600; margin:9px 0 3px; color:var(--muted); }
.cfg-grid{ display:grid; grid-template-columns:1fr 1fr; gap:8px 10px; }
.cfg-grid label{ display:block; font-size:.74rem; font-weight:600; margin:0 0 3px; color:var(--muted); }
.cfg-sub{ margin-top:12px; padding-top:10px; border-top:1px dashed var(--border); }
.cfg-sub h4{ margin:0 0 6px; font-size:.8rem; }
.cfg-saved{ font-size:.75rem; color:var(--accent2); margin-left:8px; }
.list-head{ display:flex; align-items:center; justify-content:space-between; margin:16px 0 4px; }
.list-head h3{ margin:0; font-size:.85rem; }
.count-pill{ background:var(--btn); border-radius:20px; padding:2px 10px; font-size:.72rem; color:var(--muted); font-weight:600; }
table{ width:100%; border-collapse:collapse; margin-top:6px; font-size:.75rem; }
th{ text-align:left; padding:7px 8px; font-size:.64rem; text-transform:uppercase; letter-spacing:.04em;
color:var(--muted); border-bottom:1px solid var(--border); font-weight:700; }
td{ text-align:left; padding:8px; border-bottom:1px solid var(--border); vertical-align:top; }
.mini{ font-size:.72rem; color:var(--muted); white-space:pre-wrap; word-break:break-all; }
.kv{ font-family:ui-monospace,monospace; font-size:.7rem; word-break:break-all; }
.copy-btn{ display:inline-flex; align-items:center; gap:4px; margin-top:4px; padding:3px 8px;
border:1px solid color-mix(in srgb,var(--accent) 45%,transparent); border-radius:7px;
background:color-mix(in srgb,var(--accent) 12%,transparent); color:inherit; cursor:pointer;
font-size:.68rem; font-weight:600; white-space:nowrap; }
.copy-btn:hover{ background:color-mix(in srgb,var(--accent) 22%,transparent); }
.expand-btn{ background:none; border:0; padding:0 2px; cursor:pointer; font-size:.9rem; }
.logs-container{ margin-top:8px; background:color-mix(in srgb,canvastext 6%,canvas); border-radius:8px; padding:8px;
max-height:170px; overflow-y:auto; font-family:ui-monospace,monospace; font-size:.66rem; }
.logs-container div{ margin:2px 0; white-space:pre-wrap; word-break:break-all; }
.live{ font-size:.66rem; color:var(--muted); font-style:italic; margin-top:3px; word-break:break-all; }
.live-wrap{ margin-top:14px; border:1px solid var(--border); border-radius:12px; overflow:hidden;
background:color-mix(in srgb,canvastext 2%,canvas); }
.live-bar{ display:flex; align-items:center; justify-content:space-between; gap:10px;
padding:7px 11px; border-bottom:1px solid var(--border); }
.live-frame{ width:100%; height:min(72vh,760px); border:0; display:block; background:#111; }
button.live-on{ background:var(--danger); border-color:transparent; color:#fff; }
.okmark{ color:var(--accent2); font-weight:700; }
.waitmail{ display:inline-flex; align-items:center; gap:5px; margin-top:4px; padding:2px 8px; border-radius:20px;
background:color-mix(in srgb,var(--warn) 22%,transparent); color:inherit; font-size:.66rem; font-weight:600; }
/* URL-Karten (ersetzen die gequetschten Tabellen) */
.urows{ display:flex; flex-direction:column; gap:8px; margin-top:8px; }
.urow{ border:1px solid var(--border); border-radius:11px; padding:10px; background:color-mix(in srgb,canvastext 2.5%,canvas); min-width:0; }
.urow-top{ display:flex; align-items:center; gap:8px; min-width:0; }
.urow-top .expand-btn{ flex:0 0 auto; }
.urow-url{ flex:1 1 auto; min-width:0; font-size:.76rem; font-weight:600; color:inherit; text-decoration:none; }
.urow-badges{ flex:0 0 auto; display:flex; flex-wrap:wrap; gap:4px; justify-content:flex-end; }
.trunc{ display:block; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.urow-extra{ margin-top:6px; display:flex; flex-wrap:wrap; gap:6px; align-items:center; }
.urow-meta{ margin-top:8px; display:grid; gap:6px; }
.urow-field{ display:flex; align-items:center; gap:6px; min-width:0; font-size:.72rem; }
.urow-field .lbl{ flex:0 0 46px; color:var(--muted); font-weight:700; text-transform:uppercase; font-size:.58rem; }
.urow-field .val{ flex:1 1 auto; min-width:0; }
.urow-actions{ margin-top:9px; display:flex; flex-wrap:wrap; gap:6px; align-items:center; }
.urow-actions .spacer{ flex:1 1 auto; }
.empty-row{ padding:14px; text-align:center; color:var(--muted); font-size:.8rem;
border:1px dashed var(--border); border-radius:11px; }
.badge{ display:inline-block; padding:2px 9px; border-radius:20px; font-size:.7rem; font-weight:600; background:var(--btn); }
.offen{ background:var(--btn); color:var(--muted); }
.laeuft,.email_wird_erzeugt,.warte_mail,.bereit_zum_absenden{ background:color-mix(in srgb,var(--warn) 32%,transparent); }
.email_bereit,.ausgefuellt_offen{ background:color-mix(in srgb,var(--accent) 26%,transparent); }
.abgesendet,.fertig{ background:color-mix(in srgb,var(--accent2) 30%,transparent); }
.uebersprungen{ background:var(--btn); color:var(--muted); text-decoration:line-through; }
.fehler,.fehler_beim_absenden{ background:color-mix(in srgb,var(--danger) 30%,transparent); }
.pipes{ display:flex; flex-direction:column; gap:12px; }
.pipe{ border:1px solid var(--border); border-radius:14px; padding:14px; background:color-mix(in srgb,canvastext 2%,canvas); }
.pipe-url{ font-size:.8rem; font-weight:600; word-break:break-all; margin-bottom:10px; }
.pipe-row{ display:flex; gap:10px; align-items:stretch; overflow-x:auto; padding-bottom:4px; }
.stage{ flex:0 0 auto; min-width:172px; border:1px solid var(--border); border-radius:11px; padding:9px;
background:color-mix(in srgb,canvastext 3%,canvas); }
.stage-title{ font-size:.62rem; font-weight:700; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); margin:0 2px 8px; }
.stage-arrow{ display:flex; align-items:center; font-size:1.2rem; color:var(--muted); opacity:.5; }
.pjob{ display:flex; align-items:center; gap:7px; font-size:.72rem; padding:6px 8px; border-radius:8px; margin:4px 0; border:1px solid transparent; }
.pjob .ic{ width:16px; text-align:center; }
.pjob.pending{ background:var(--btn); color:var(--muted); }
.pjob.running{ background:color-mix(in srgb,var(--warn) 30%,transparent); border-color:color-mix(in srgb,var(--warn) 55%,transparent); animation:pulse 1.1s ease-in-out infinite; }
.pjob.success{ background:color-mix(in srgb,var(--accent2) 24%,transparent); border-color:color-mix(in srgb,var(--accent2) 50%,transparent); }
.pjob.failed{ background:color-mix(in srgb,var(--danger) 24%,transparent); border-color:color-mix(in srgb,var(--danger) 55%,transparent); }
.pjob.skipped{ background:var(--btn); color:var(--muted); text-decoration:line-through; }
@keyframes pulse{ 0%,100%{opacity:1} 50%{opacity:.5} }
.mail-item{ border:1px solid var(--border); border-radius:11px; padding:12px; margin-bottom:12px; background:var(--card); font-size:.8rem; }
.mail-item .hdr{ word-break:break-all; margin-bottom:2px; }
.mail-frame{ width:100%; height:280px; border:1px solid var(--border); border-radius:9px; margin-top:10px; background:#fff; }
.mail-empty{ padding:16px; text-align:center; font-size:.88rem; color:var(--muted); border:1px dashed var(--border); border-radius:11px; }
.mail-link{ margin-top:8px; padding:9px; border-radius:9px; word-break:break-all; font-size:.78rem; }
.mail-link.ok{ background:color-mix(in srgb,var(--accent2) 20%,transparent); }
.mail-link.no{ background:color-mix(in srgb,var(--danger) 18%,transparent); }
.cred{ border:1px solid color-mix(in srgb,var(--accent) 40%,transparent); border-radius:11px; padding:10px; margin-bottom:8px;
background:color-mix(in srgb,var(--accent) 8%,transparent); font-size:.78rem; }
.cred .cred-url{ font-weight:600; word-break:break-all; margin-bottom:6px; }
.cred b{ display:inline-block; min-width:56px; }
.modal{ display:none; position:fixed; inset:0; background:rgba(0,0,0,.55); z-index:999; align-items:center; justify-content:center; padding:16px; }
.modal.show{ display:flex; }
.modal-content{ background:canvas; color:canvastext; padding:22px; border-radius:16px; max-width:620px; width:100%;
max-height:82vh; overflow-y:auto; box-shadow:0 12px 48px rgba(0,0,0,.4); border:1px solid var(--border); }
.modal-content h3{ margin-top:0; }
.modal-row{ margin:12px 0; padding:10px; background:var(--card); border-radius:9px; }
.modal-row label{ display:block; font-weight:600; font-size:.85rem; margin-bottom:4px; }
.modal-row .value{ font-family:ui-monospace,monospace; word-break:break-all; }
.modal-buttons{ margin-top:16px; display:flex; gap:10px; }
.modal-buttons button{ flex:1; }
.modebar{ display:flex; align-items:center; gap:12px; flex-wrap:wrap;
border:1px solid var(--border); border-radius:14px; padding:12px 16px; margin:0 0 16px; }
.modebar.test{ background:color-mix(in srgb,var(--accent) 10%,canvas); border-color:color-mix(in srgb,var(--accent) 45%,transparent); }
.modebar.prod{ background:color-mix(in srgb,var(--danger) 11%,canvas); border-color:color-mix(in srgb,var(--danger) 48%,transparent); }
.modebar .mode-pill{ font-weight:800; font-size:.82rem; padding:5px 11px; border-radius:999px; letter-spacing:.02em; }
.modebar.test .mode-pill{ background:var(--accent); color:#fff; }
.modebar.prod .mode-pill{ background:var(--danger); color:#fff; }
.modebar .mode-desc{ font-size:.82rem; color:var(--muted); flex:1 1 220px; }
.modebar .mode-switch{ margin-left:auto; }
</style></head><body>
<h1>🏠 Immo-Bot</h1>
<p class="sub">3-Schritte-Prozess &middot; Feldwerte je Step unter <b>⚙️ Config</b> &middot; E-Mail-Modus: <b id="mode">?</b></p>
<div id="modebar" class="modebar test">
<span class="mode-pill" id="mode_pill">…</span>
<span class="mode-desc" id="mode_desc"></span>
<button class="mode-switch btn-small" id="mode_switch" onclick="switchMode()">Modus wechseln</button>
</div>
<div class="card">
<div class="step-head"><span class="dot" style="background:var(--muted)"></span><span class="step-num">SETUP</span> Cookies einmalig speichern (DSGVO)</div>
<p class="hint">Browser öffnet &rarr; Cookie-/DSGVO-Zustimmung anklicken &rarr; speichern. Danach laufen alle Batches ohne Banner.</p>
<div class="setup">
<div class="grow"><input id="consent_url" placeholder="https://www.immowelt.de/"></div>
<button class="ghost" onclick="consentOpen()">🌐 Browser öffnen</button>
<button class="p3" onclick="consentSave()">💾 Speichern &amp; schliessen</button>
<button class="ghost btn-small" id="live_toggle" onclick="toggleLive()">👁️ Browser live ansehen</button>
<span class="hint" id="consent_status"></span>
</div>
<div id="live_wrap" class="live-wrap" style="display:none;">
<div class="live-bar">
<span class="hint">Live-Ansicht des Automations-Browsers (läuft im Container, per noVNC gestreamt)</span>
<a id="live_pop" class="hint" href="#" target="_blank" rel="noopener">↗ in neuem Tab</a>
</div>
<iframe id="live_frame" class="live-frame" title="Automations-Browser (noVNC)"></iframe>
</div>
</div>
<div class="card" style="margin-top:18px;">
<div class="step-head"><span class="dot" style="background:var(--accent)"></span>🧩 Pipeline-Dashboard
<span style="margin-left:auto; display:inline-flex; gap:8px;">
<button id="stop_batch_btn" onclick="stopBatch()" class="btn-small watching" style="display:none;">⏹ Stop / Zuruecksetzen</button>
<button id="close_browsers_btn" onclick="closeBrowsers()" class="btn-small watching" style="display:none;">🧹 Offene Browser schliessen</button>
</span>
</div>
<p class="hint">Fortschritt pro URL über alle Steps und Sub-Steps &ndash; live aktualisiert.</p>
<div id="pipelines" class="pipes"><div class="mail-empty">Noch keine URLs in Step 1.</div></div>
</div>
<div class="grid3">
<!-- ===== STEP 1 ===== -->
<div class="card">
<div class="step-head"><span class="dot" style="background:var(--accent)"></span><span class="step-num">STEP 1</span> Kontaktformular</div>
<details class="cfg">
<summary>So wird das Kontaktformular ausgefüllt</summary>
<div class="cfg-body">
<label>Anrede</label>
<select id="c_salutation"><option value="mr">Herr</option><option value="mrs">Frau</option></select>
<div class="cfg-grid">
<div><label>Vorname</label><input id="c_firstName"></div>
<div><label>Nachname</label><input id="c_lastName"></div>
</div>
<label>Telefon</label><input id="c_phone">
<label>E-Mail</label>
<input value="wird pro URL automatisch erzeugt (mail.tm)" disabled style="opacity:.55;">
<label>Nachricht</label><textarea id="c_message" rows="3"></textarea>
<div class="actions"><button class="p1 btn-small" onclick="saveCfg('1')">💾 Speichern</button>
<span class="cfg-saved" id="saved_1"></span></div>
</div>
</details>
<textarea id="urls_s1" rows="3" placeholder="immowelt-URLs (je Zeile eine)"></textarea>
<div class="scale-row">
<label for="scale_s1">🔁 Skalierung pro URL</label>
<input id="scale_s1" type="number" min="1" max="50" value="1" title="Wie viele parallele Durchlaeufe (Browser + Mails) pro URL">
<span class="hint" id="scale_hint">= 1 Browser + 1 Mail pro URL</span>
</div>
<div class="actions">
<button class="p1" onclick="addUrls('1')">➕ URLs</button>
<button class="ghost" onclick="prepareStep1()">🔑 Email+Token</button>
<button class="ghost" onclick="batch('1')" title="Bei aktivem 'vollautomatisch weiter' (Haken in Step 2) laeuft pro Item direkt Step 2 + 3 weiter.">▶️ Step 1 starten</button>
</div>
<button class="p3 cta" onclick="runAll()">🚀 Komplett-Durchlauf 1 → 2 → 3</button>
<p class="hint" style="margin:8px 0 0;">Füllt aus und <b>wartet auf deine Freigabe</b> (📤/⏭ pro Zeile).
Direkt nach jedem Absenden läuft <b>vollautomatisch</b> weiter: Step 2 (auf die eigene Mail warten) → Link → Step 3.</p>
<span class="hint" id="running_s1"></span>
<div id="step1_output" style="display:none; margin-top:12px;"></div>
<div class="list-head"><h3>Liste</h3><span class="count-pill"><span id="count_s1">0</span> URLs</span></div>
<label class="chk"><input type="checkbox" id="auto_submit_s1"> Auto-Absenden (alle)</label>
<div id="tbl_s1" class="urows"></div>
</div>
<!-- ===== STEP 2 ===== -->
<div class="card">
<div class="step-head"><span class="dot" style="background:var(--warn)"></span><span class="step-num">STEP 2</span> Mail &amp; Link</div>
<details class="cfg">
<summary>Mail-Einstellungen</summary>
<div class="cfg-body">
<label>Modus</label>
<select id="c_mode"><option value="mailtm">mailtm (Wegwerf)</option><option value="imap">imap (eigenes Postfach)</option></select>
<label>Absender-Filter (sender_filter)</label><input id="c_sender_filter">
<label>IMAP Host</label><input id="c_imap_host">
<div class="cfg-grid">
<div><label>IMAP User</label><input id="c_imap_user"></div>
<div><label>IMAP Passwort</label><input id="c_imap_password" type="password"></div>
</div>
<div class="actions"><button class="p1 btn-small" onclick="saveCfg('2')">💾 Speichern</button>
<span class="cfg-saved" id="saved_2"></span></div>
</div>
</details>
<label class="cfg-body" style="padding:0; display:block; font-size:.74rem; font-weight:600; margin:0 0 3px; color:var(--muted);">E-Mail</label>
<input id="test_email" type="text" placeholder="eg5gyqjqp5@web-library.net" style="margin-bottom:8px;">
<label style="display:block; font-size:.74rem; font-weight:600; margin:0 0 3px; color:var(--muted);">Token</label>
<input id="test_token" type="text" placeholder="abc123..." style="font-family:ui-monospace,monospace; font-size:.72rem;">
<div id="s2_auto_note" class="hint" style="display:none; color:var(--accent2); margin:8px 0 0;"></div>
<div class="actions">
<button class="ghost" onclick="testExtractLink()">🔍 Link extrahieren</button>
<button class="ghost" onclick="showMail()">📨 Mail anzeigen</button>
<button class="p1" id="watch_btn" onclick="toggleMailWatch()">🔄 Auf Mail warten (live)</button>
</div>
<label class="chk"><input type="checkbox" id="auto_step3" checked> Nach dem Absenden automatisch weiter: parallel auf ALLE Mails warten &rarr; Link &rarr; Step 3</label>
<div id="test_result" style="margin-top:12px; padding:10px; background:var(--card); border:1px solid var(--border); border-radius:9px; display:none;">
<h4 style="margin:0 0 8px 0; font-size:.85rem;">Ergebnis</h4>
<div id="test_result_content" style="font-family:ui-monospace,monospace; font-size:.75rem; word-break:break-all;"></div>
<input id="test_link_confirm" type="text" placeholder="Link hier kopieren" readonly style="margin-top:8px; font-size:.75rem;">
</div>
<div id="mail_show_result" style="display:none; margin-top:12px;"></div>
</div>
<!-- ===== STEP 3 ===== -->
<div class="card">
<div class="step-head"><span class="dot" style="background:var(--accent2)"></span><span class="step-num">STEP 3</span> Bewerbungsformular</div>
<details class="cfg">
<summary>So wird das Bewerbungsformular ausgefüllt</summary>
<div class="cfg-body">
<div class="cfg-grid">
<div><label>Reservierungsnr.</label><input id="a_reserveNumber"></div>
<div><label>Gültig bis</label><input id="a_validUntil"></div>
</div>
<div class="cfg-grid">
<div><label>Anzahl Zimmer</label><input id="a_numberRooms"></div>
<div></div>
</div>
<label class="chk"><input type="checkbox" id="a_secondTenant"> Zweiter Mieter</label>
<label class="chk"><input type="checkbox" id="a_consent"> Zustimmung/Consent</label>
<div class="cfg-sub"><h4>Verhalten nach dem Ausfüllen</h4>
<label class="chk"><input type="checkbox" id="a_auto_submit"> Bewerbung automatisch absenden (Standard: aus)</label>
<label class="chk"><input type="checkbox" id="a_keep_open"> Browser offen lassen &amp; nicht beenden (Standard: an)</label>
</div>
<div class="cfg-sub"><h4>Mieter 1</h4>
<div class="cfg-grid">
<div><label>Vorname</label><input id="t1_firstName"></div>
<div><label>Nachname</label><input id="t1_lastName"></div>
<div><label>Geburtsdatum</label><input id="t1_dateOfBirth"></div>
<div><label>Strasse</label><input id="t1_street"></div>
<div><label>Hausnr.</label><input id="t1_housenumber"></div>
<div><label>PLZ</label><input id="t1_zipcode"></div>
<div><label>Stadt</label><input id="t1_city"></div>
<div><label>Tel. privat</label><input id="t1_phonePrivate"></div>
<div><label>Tel. geschaeftl.</label><input id="t1_phoneBusiness"></div>
</div>
</div>
<div class="cfg-sub"><h4>Mieter 2</h4>
<div class="cfg-grid">
<div><label>Vorname</label><input id="t2_firstName"></div>
<div><label>Nachname</label><input id="t2_lastName"></div>
<div><label>Geburtsdatum</label><input id="t2_dateOfBirth"></div>
<div><label>Strasse</label><input id="t2_street"></div>
<div><label>Hausnr.</label><input id="t2_housenumber"></div>
<div><label>PLZ</label><input id="t2_zipcode"></div>
<div><label>Stadt</label><input id="t2_city"></div>
<div><label>Tel. privat</label><input id="t2_phonePrivate"></div>
<div><label>Tel. geschaeftl.</label><input id="t2_phoneBusiness"></div>
</div>
</div>
<div class="actions"><button class="p1 btn-small" onclick="saveCfg('3')">💾 Speichern</button>
<span class="cfg-saved" id="saved_3"></span></div>
</div>
</details>
<p class="hint">Entweder den <b>Formular-Link direkt</b> (…/property/application/form/…) ODER
<b>dieselbe URL wie Step 1</b> (Token wird übernommen, Link aus der Mail geholt).</p>
<textarea id="urls_s3" rows="3" placeholder="Formular-Link ODER dieselbe URL wie Step 1 — je Zeile eine"></textarea>
<div class="actions">
<button class="p1" onclick="addUrls('3')">➕ URLs</button>
<button class="ghost" onclick="batch('3')">▶️ Batch starten</button>
</div>
<label class="chk"><input type="checkbox" id="auto_batch_s3" checked> Batch automatisch starten, sobald eine URL reinkommt</label>
<span class="hint" id="running_s3"></span>
<div class="list-head"><h3>Liste</h3><span class="count-pill"><span id="count_s3">0</span> URLs</span></div>
<label class="chk"><input type="checkbox" id="auto_submit_s3"> Auto-Absenden (alle)</label>
<div id="tbl_s3" class="urows"></div>
</div>
</div>
<div id="mailModal" class="modal">
<div class="modal-content">
<h3>📧 Mail &amp; Link Extraktion</h3>
<div id="mailContent"></div>
<div class="modal-row">
<label>Link (extrahiert oder manuell):</label>
<input id="linkInput" type="text" placeholder="Link hier eingeben oder bestaetigen">
</div>
<div class="modal-buttons">
<button onclick="closeMailModal()">Abbrechen</button>
<button class="p1" onclick="saveLinkFromModal()">✅ OK &amp; Speichern</button>
</div>
</div>
</div>
<script>
let currentMailUrl = '';
let currentMailStep = '';
let CFG = {}; // aktuelle config.json (fuer die Config-Panels)
function scaleValue(){
const el = document.getElementById('scale_s1');
let n = parseInt(el ? el.value : '1', 10);
if(!Number.isFinite(n) || n < 1) n = 1;
if(n > 50) n = 50;
return n;
}
function updateScaleHint(){
const n = scaleValue();
const h = document.getElementById('scale_hint');
if(h) h.textContent = n === 1
? '= 1 Browser + 1 Mail pro URL'
: '= ' + n + ' Browser + ' + n + ' Mails parallel pro URL';
}
function addUrls(step){
const elem = step === '1' ? document.getElementById('urls_s1')
: document.getElementById('urls_s3');
const val = elem.value.trim();
if(!val){ alert('Bitte mindestens eine URL eingeben.'); return; }
// Skalierung gilt nur fuer Step 1 (Step 3 bekommt fertige Links -> immer 1).
const count = step === '1' ? scaleValue() : 1;
fetch('/urls/add',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({step: step, url: val, count: count})})
.then(r=>r.json())
.then(d=>{
elem.value='';
// Liste SOFORT aus der Antwort rendern (nicht auf den Poll warten).
if(d && (d.items_s1 || d.items_s3)) renderLists(d);
})
.catch(e=>alert('Fehler beim Hinzufuegen: '+e));
}
function removeUrl(step, id){
fetch('/urls/remove',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({step: step, id:id})})
.then(r=>r.json())
.then(d=>{ if(d && (d.items_s1 || d.items_s3)) renderLists(d); });
}
function prepareStep1(){
fetch('/step1/prepare',{method:'POST'})
.then(r=>r.json())
.then(d=>{
if(!d.ok){ alert('Fehler: '+(d.error||'unbekannt')); return; }
if(d.items_s1){ renderLists({items_s1:d.items_s1}); renderStep1Output(d.items_s1); }
})
.catch(e=>alert('Fehler: '+e));
}
function renderStep1Output(items){
const box = document.getElementById('step1_output');
if(!box) return;
if(!items || !items.length){ box.style.display='none'; return; }
box.style.display = 'block';
box.innerHTML = '<h4 style="margin:0 0 6px;font-size:.85rem;">🔑 Email &amp; Token pro URL</h4>' +
items.map(it => {
const emailShown = it.email || '⏳ wird erzeugt ...';
const tokenShown = it.token ? (it.token.slice(0,28)+'…') : '⏳ wird erzeugt ...';
return `<div class="cred">
<div class="cred-url">${esc(it.url)}</div>
<div><b>E-Mail:</b> <span class="kv">${esc(emailShown)}</span>${it.email?` <button class="copy-btn" data-copy="${attr(it.email)}" onclick="copyEl(this)">📋 kopieren</button>`:''}</div>
<div><b>Token:</b> <span class="kv">${esc(tokenShown)}</span>${it.token?` <button class="copy-btn" data-copy="${attr(it.token)}" onclick="copyEl(this)">📋 kopieren</button>`:''}</div>
</div>`;
}).join('');
}
function viewMail(step, url){
currentMailStep = step;
currentMailUrl = url;
fetch('/mail/extract/'+step+'/'+encodeURIComponent(url))
.then(r=>r.json())
.then(d=>{
const content = document.getElementById('mailContent');
if(!d.ok){
content.innerHTML = '<div style="color:red;">❌ '+esc(d.error)+'</div>';
linkInput.value = '';
} else {
let html = '';
if(d.msgs && d.msgs.length){
for(let m of d.msgs){
html += '<div class="modal-row">';
html += '<label>Von:</label><div class="value">'+esc(m.from)+'</div>';
html += '<label>Betreff:</label><div class="value">'+esc(m.subject)+'</div>';
if(m.link){
html += '<label style="color:green;">✅ Link gefunden:</label>';
html += '<div class="value" style="background:#e8f5e9; padding:8px; border-radius:4px;">'+esc(m.link)+'</div>';
} else {
html += '<label style="color:red;">❌ Kein Link gefunden</label>';
}
html += '</div>';
}
linkInput.value = (d.msgs.find(m=>m.link) || {}).link || '';
} else {
content.innerHTML = '<div style="color:orange;">⚠️ Keine Mails gefunden</div>';
}
content.innerHTML = html;
}
document.getElementById('mailModal').classList.add('show');
});
}
function closeMailModal(){
document.getElementById('mailModal').classList.remove('show');
currentMailUrl = '';
currentMailStep = '';
}
function saveLinkFromModal(){
const link = linkInput.value.trim();
if(!link){
alert('Bitte Link eingeben');
return;
}
fetch('/mail/set-link/'+currentMailStep+'/'+encodeURIComponent(currentMailUrl),
{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({link:link})})
.then(r=>r.json())
.then(d=>{
if(d.ok){
alert('Link gespeichert ✅');
closeMailModal();
refresh();
} else {
alert('Fehler: '+d.error);
}
});
}
function batch(step){
// Step 1: wenn "vollautomatisch weiter" aktiv ist, laeuft pro Item direkt
// Step 2 + 3 durch (kein separater Trigger noetig).
const body = {};
if(step === '1') body.auto = !!document.getElementById('auto_step3')?.checked;
fetch('/batch/'+step,{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify(body)}).then(r=>r.json()).then(d=>{
if(!d.ok) alert(d.error);
});
}
function runAll(){
if(!confirm('Kompletter Durchlauf 1→2→3 fuer ALLE URLs in Step 1.\\n\\nDie Kontaktformulare werden ausgefuellt und warten dann auf DEINE Freigabe: pro Zeile 📤 Absenden oder ⏭ Ueberspringen. Erst nach dem Absenden wird auf die Mail gewartet und das Bewerbungsformular ausgefuellt.\\n\\nStarten?')) return;
fetch('/batch/all',{method:'POST'}).then(r=>r.json()).then(d=>{
if(!d.ok) alert(d.error);
});
}
// ---- Live-Ansicht (noVNC) -------------------------------------------------
// Der Platzhalter unten wird serverseitig aus der Env NOVNC_URL ersetzt (lokal
// http://localhost:7900, im Cluster /novnc/). autoconnect + password=secret =
// Default des selenium/standalone-chrome-Images; resize=scale passt an den Rahmen.
var NOVNC_URL = "__NOVNC_URL__";
// Pfad-Anteil von NOVNC_URL ohne fuehrende/abschliessende Slashes ('' bei blanker
// Origin wie http://localhost:7900, 'novnc/' bei /novnc/). noVNC baut den
// Websocket als ws://<page-host>/<path> -> im Cluster MUSS der /novnc-Prefix mit
// rein, damit die stripPrefix-Route greift; lokal bleibt er leer.
function novncPathPrefix(u){
var p;
try { p = /^https?:\\/\\//i.test(u) ? new URL(u).pathname : u; }
catch(e){ p = u; }
p = (p || '').replace(/^\\/+|\\/+$/g, '');
return p ? p + '/' : '';
}
function liveSrc(){
var base = NOVNC_URL.replace(/\\/+$/, '') + '/vnc.html';
return base + '?autoconnect=1&resize=scale&password=secret&path=' +
encodeURIComponent(novncPathPrefix(NOVNC_URL) + 'websockify');
}
function showLive(){
var f = document.getElementById('live_frame');
if(!f.src) f.src = liveSrc();
document.getElementById('live_pop').href = liveSrc();
document.getElementById('live_wrap').style.display = '';
var b = document.getElementById('live_toggle');
b.classList.add('live-on'); b.textContent = '🔴 Live-Ansicht (schliessen)';
}
function hideLive(){
document.getElementById('live_wrap').style.display = 'none';
var b = document.getElementById('live_toggle');
b.classList.remove('live-on'); b.textContent = '👁️ Browser live ansehen';
}
function toggleLive(){
var open = document.getElementById('live_wrap').style.display !== 'none';
if(open) hideLive(); else showLive();
}
function consentOpen(){
fetch('/consent/open',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({url: consent_url.value})}).then(r=>r.json()).then(d=>{
if(!d.ok){ alert(d.error); return; }
showLive(); // Browser ist offen -> Live-Ansicht direkt einblenden
});
}
function consentSave(){
fetch('/consent/save',{method:'POST'}).then(r=>r.json()).then(d=>{
if(!d.ok) alert(d.error);
else alert('Zustimmung gespeichert'+(d.origin?(' fuer '+d.origin):'')+'.');
});
}
function consentPoll(){
fetch('/consent/state').then(r=>r.json()).then(d=>{
const last = (d.lines||[]).slice(-1)[0] || '';
consent_status.textContent = ' ['+d.status+'] '+last;
});
}
function badge(s){ return '<span class="badge '+s+'">'+s+'</span>'; }
function esc(x){ return (x||'').replace(/</g,'&lt;'); }
function testExtractLink(){
const email = test_email.value.trim();
const token = test_token.value.trim();
if(!email || !token){
alert('Email + Token erforderlich');
return;
}
const rd = document.getElementById('test_result');
const rc = document.getElementById('test_result_content');
rd.style.display = 'block';
rc.innerHTML = '<div class="loading"><span class="spinner"></span> Extrahiere Link …</div>';
document.getElementById('test_link_confirm').value = '';
fetch('/mail/extract/2/'+encodeURIComponent(email)+'?token='+encodeURIComponent(token))
.then(r=>r.json())
.then(d=>{
const resultDiv = document.getElementById('test_result');
const resultContent = document.getElementById('test_result_content');
const linkConfirm = document.getElementById('test_link_confirm');
resultDiv.style.display = 'block';
if(!d.ok){
resultContent.innerHTML = '❌ '+esc(d.error);
linkConfirm.value = '';
} else if(!d.msgs || !d.msgs.length){
resultContent.innerHTML = '⚠️ Keine Mails gefunden';
linkConfirm.value = '';
} else {
let html = '';
let foundLink = '';
for(let m of d.msgs){
html += 'Von: '+esc(m.from)+'\\n';
html += 'Betreff: '+esc(m.subject)+'\\n';
if(m.link){
html += '✅ LINK: '+esc(m.link)+'\\n';
if(!foundLink) foundLink = m.link;
} else {
html += '❌ Kein Link\\n';
}
html += '---\\n';
}
resultContent.textContent = html;
linkConfirm.value = foundLink || '';
}
})
.catch(e=>alert('Fehler: '+e));
}
function renderMails(msgs){
return msgs.map(m => {
const link = m.link
? `<div class="mail-link ok">✅ Formular-Link: ${esc(m.link)}<br><button class="copy-btn" data-copy="${attr(m.link)}" onclick="copyEl(this)">📋 Link kopieren</button></div>`
: `<div class="mail-link no">❌ Kein Formular-Link gefunden</div>`;
return `<div class="mail-item">
<div class="hdr"><b>Von:</b> ${esc(m.from)}</div>
<div class="hdr"><b>Betreff:</b> ${esc(m.subject)}</div>
${link}
<iframe class="mail-frame" sandbox srcdoc="${attr(m.html||'(kein Inhalt)')}"></iframe>
</div>`;
}).join('');
}
// Einmalige Abfrage des Postfachs.
function showMail(){
const token = document.getElementById('test_token').value.trim();
const box = document.getElementById('mail_show_result');
box.style.display = 'block';
if(!token){ box.innerHTML = '<div class="mail-empty">❌ Bitte Token eingeben.</div>'; return; }
box.innerHTML = '<div class="loading"><span class="spinner"></span> Lade Postfach …</div>';
fetch('/mail/show?token='+encodeURIComponent(token))
.then(r=>r.json())
.then(d=>{
if(!d.ok){ box.innerHTML = '<div class="mail-empty">❌ '+esc(d.error)+'</div>'; return; }
if(!d.msgs || !d.msgs.length){
box.innerHTML = '<div class="mail-empty">📭 <b>Keine Mail</b> im Postfach.</div>';
return;
}
box.innerHTML = renderMails(d.msgs);
})
.catch(e => { box.innerHTML = '<div class="mail-empty">❌ '+esc(''+e)+'</div>'; });
}
// SKALIERBARER Auto-Weg: sobald abgesendete Step-1-Items da sind, den
// server-seitigen Step-2-Batch (/batch/2) EINMAL ausloesen. Der Server wartet
// dann fuer JEDES Item parallel auf sein eigenes Postfach und faehrt bis Step 3.
let lastAutoToken = ''; // (nur noch fuer die manuelle Feld-Uebernahme genutzt)
let s2AutoArmed = true;
function maybeAutoBatchStep2(items){
const cb = document.getElementById('auto_step3');
if(!cb || !cb.checked) return; // Auto-Weiterlauf deaktiviert
// Items, die abgesendet sind, aber Step 2 noch NICHT gestartet haben.
const pending = (items||[]).some(it => it.s1 === 'abgesendet'
&& (!it.s3 || it.s3 === 'offen'));
if(pending && s2AutoArmed){
s2AutoArmed = false; // nur einmal ausloesen
const note = document.getElementById('s2_auto_note');
if(note){ note.style.display='block';
note.textContent = '✅ Step 1 abgesendet — warte jetzt parallel auf ALLE Postfaecher (Step 2 → 3).'; }
fetch('/batch/2',{method:'POST'}).then(r=>r.json()).then(x=>{ if(!x.ok) s2AutoArmed = true; })
.catch(()=>{ s2AutoArmed = true; });
} else if(!pending){
s2AutoArmed = true; // wieder scharf fuer neue abgesendete Items
}
}
let activeStep2Id = '';
// Live-Suche: pollt das Postfach alle 5s, bis eine Mail da ist.
let mailPollTimer = null, mailPollCount = 0;
function toggleMailWatch(){
if(mailPollTimer){ stopMailWatch(); return; }
const token = document.getElementById('test_token').value.trim();
if(!token){ alert('Token erforderlich'); return; }
mailPollCount = 0;
const btn = document.getElementById('watch_btn');
btn.textContent = '⏹ Live-Suche stoppen';
btn.classList.add('watching');
document.getElementById('mail_show_result').style.display = 'block';
pollMailOnce();
mailPollTimer = setInterval(pollMailOnce, 5000);
}
function stopMailWatch(){
if(mailPollTimer){ clearInterval(mailPollTimer); mailPollTimer = null; }
const btn = document.getElementById('watch_btn');
if(btn){ btn.textContent = '🔄 Auf Mail warten (live)'; btn.classList.remove('watching'); }
}
function pollMailOnce(){
mailPollCount++;
const token = document.getElementById('test_token').value.trim();
const box = document.getElementById('mail_show_result');
fetch('/mail/show?token='+encodeURIComponent(token))
.then(r=>r.json())
.then(d=>{
if(!d.ok){ box.innerHTML = '<div class="mail-empty">❌ '+esc(d.error)+'</div>'; stopMailWatch(); return; }
if(!d.msgs || !d.msgs.length){
box.innerHTML = '<div class="loading"><span class="spinner"></span> Suche im Postfach … noch keine Mail (Versuch '+mailPollCount+', alle 5s)</div>';
if(activeStep2Id) clientJobs[activeStep2Id] = Object.assign({}, clientJobs[activeStep2Id], {s2_wait:'running'});
return;
}
box.innerHTML = renderMails(d.msgs);
stopMailWatch(); // Mail gefunden -> aufhoeren
const found = d.msgs.find(m => m.link);
if(activeStep2Id){
clientJobs[activeStep2Id] = Object.assign({}, clientJobs[activeStep2Id],
{s2_wait:'success', s2_link: found ? 'success' : 'failed'});
}
if(found) autoStartStep3(found.link); // -> Step 3 automatisch starten
})
.catch(e => { box.innerHTML = '<div class="mail-empty">❌ '+esc(''+e)+'</div>'; });
}
// Sobald Step 2 den Formular-Link hat: Link nur an Step 3 UEBERGEBEN.
// Den Batch startet Step 3 selbst (maybeAutoBatchS3), sobald eine offene URL da ist.
let step3Started = {};
let linkOfUrl = {}; // Step-1-Item-ID -> Formular-Link (fuer Pipeline-Merge)
function autoStartStep3(link){
if(!link || step3Started[link]) return;
if(!document.getElementById('auto_step3')?.checked) return;
step3Started[link] = true;
if(activeStep2Id) linkOfUrl[activeStep2Id] = link; // Zuordnung fuer die Pipeline
const note = document.getElementById('s2_auto_note');
if(note){ note.style.display='block'; note.textContent = '✅ Link gefunden — an Step 3 uebergeben.'; }
fetch('/urls/add',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({step:'3', url:link})})
.then(r=>r.json())
.then(d=>{ if(d.items_s3) renderLists({items_s3:d.items_s3}); })
.catch(e => { if(note) note.textContent = '❌ Step 3 Fehler: '+e; });
}
// Step 3 startet den Batch SELBST, sobald eine offene URL vorliegt und nichts laeuft.
let s3AutoArmed = true;
function maybeAutoBatchS3(d){
const cb = document.getElementById('auto_batch_s3');
if(!cb || !cb.checked) return;
if(d.running){ s3AutoArmed = true; return; } // etwas laeuft -> warten
const hasOffen = (d.items_s3 || []).some(i => i.s3 === 'offen');
if(hasOffen && s3AutoArmed){
s3AutoArmed = false; // nur einmal ausloesen
fetch('/batch/3',{method:'POST'}).then(r=>r.json()).then(x=>{ if(!x.ok) s3AutoArmed = true; });
} else if(!hasOffen){
s3AutoArmed = true; // wieder scharf fuer die naechste URL
}
}
function submitForm(id, autoSubmit=false){
let checked = autoSubmit;
if(!autoSubmit){
const cb = [...document.querySelectorAll('.submit-check')].find(c=>c.getAttribute('data-id')===id);
checked = cb ? cb.checked : false;
}
if(!checked && !autoSubmit){
alert('Bitte Haken setzen vor dem Absenden');
return;
}
if(!autoSubmit && !confirm('Formular wirklich absenden?')){
return;
}
fetch('/submit/'+encodeURIComponent(id), {method:'POST'})
.then(r=>r.json())
.then(d=>{
if(d.ok){
if(!autoSubmit) alert('✅ Abgesendet!');
refresh();
} else {
if(!autoSubmit) alert('❌ '+d.error);
}
});
}
function toggleLogs(btn, id){
const elem = document.getElementById(id);
if(elem) elem.style.display = elem.style.display === 'none' ? 'block' : 'none';
}
function attr(s){ return (s||'').replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
// ---- Pipeline-Dashboard -------------------------------------------------
const PIPELINE = [
{stage:'Step 1 · Kontakt', jobs:[
{key:'s1_mail', label:'Email + Token'},
{key:'s1_fill', label:'Formular ausfüllen'},
{key:'s1_submit', label:'Absenden'},
]},
{stage:'Step 2 · Mail', jobs:[
{key:'s2_wait', label:'Auf Mail warten'},
{key:'s2_link', label:'Link extrahieren'},
]},
{stage:'Step 3 · Bewerbung', jobs:[
{key:'s3_open', label:'Formular öffnen'},
{key:'s3_fill', label:'Bewerbung ausfüllen'},
{key:'s3_submit', label:'Absenden'},
]},
];
const JOB_ICON = {pending:'○', running:'⏳', success:'✅', failed:'✖', skipped:'⏭'};
// Client-seitiger Overlay fuer den manuellen Weg (Step 2 Live-Suche setzt hier Jobs).
let clientJobs = {};
function jobStatus(it, key){
const cj = clientJobs[it.id] || {};
return cj[key] || (it.jobs && it.jobs[key]) || 'pending';
}
function mergeStep3Jobs(d){
// Im manuellen/Auto-Weg laeuft Step 3 auf einem eigenen items_s3-Item (URL=Formular-Link).
// Dessen s2_link/s3_* Jobs hier auf die zugehoerige Step-1-URL uebertragen, damit die
// Pipeline (aus items_s1) den Fortschritt zeigt.
const s3 = d.items_s3 || [];
(d.items_s1 || []).forEach(it => {
const link = linkOfUrl[it.id];
const s3it = s3.find(x => x.url === it.url || (link && x.url === link));
if(s3it && s3it.jobs){
const j = clientJobs[it.id] = clientJobs[it.id] || {};
['s2_link','s3_open','s3_fill','s3_submit'].forEach(k => { if(s3it.jobs[k]) j[k] = s3it.jobs[k]; });
}
});
}
function renderPipelines(items){
const box = document.getElementById('pipelines');
if(!box) return;
if(!items || !items.length){ box.innerHTML = '<div class="hint">Noch keine URLs in Step 1.</div>'; return; }
box.innerHTML = items.map(it => {
const stages = PIPELINE.map((st, si) => {
const jobs = st.jobs.map(j => {
const s = jobStatus(it, j.key);
return `<div class="pjob ${s}"><span class="ic">${JOB_ICON[s]||'○'}</span>${esc(j.label)}</div>`;
}).join('');
const arrow = si < PIPELINE.length-1 ? '<div class="stage-arrow">→</div>' : '';
return `<div class="stage"><div class="stage-title">${esc(st.stage)}</div>${jobs}</div>${arrow}`;
}).join('');
return `<div class="pipe"><div class="pipe-url">🔗 ${esc(it.url)}<span class="id-tag">${attr(it.id)}</span></div><div class="pipe-row">${stages}</div></div>`;
}).join('');
}
function shortTok(t){ return t ? (t.length > 22 ? t.slice(0,22)+'…' : t) : ''; }
function renderLists(d){
// Step 1
if(d.items_s1){
const autoSubmitS1 = document.getElementById('auto_submit_s1')?.checked;
document.getElementById('count_s1').textContent = d.items_s1.length;
const rows_s1 = d.items_s1.map(it => {
const canSubmit = it.s1 === 'bereit_zum_absenden';
const id = attr(it.id);
const logsId = 'logs_1_'+it.id;
const u = attr(it.url);
const active = (it.s1==='laeuft' || it.s3==='warte_mail' || it.s3==='laeuft');
const lastLine = (it.lines && it.lines.length) ? it.lines[it.lines.length-1] : '';
if(canSubmit && autoSubmitS1){ setTimeout(() => submitForm(it.id, true), 100); }
return `<div class="urow">
<div class="urow-top">
<button class="expand-btn" onclick="toggleLogs(this,'${logsId}')" title="Log ein/aus">📄</button>
<a class="urow-url trunc" href="${u}" target="_blank" title="${u}">${esc(it.url)}</a><span class="id-tag">${id}</span>
<span class="urow-badges">${badge(it.s1)}${(it.s3 && it.s3!=='offen')?badge(it.s3):''}</span>
</div>
${it.s3==='warte_mail'?`<div class="urow-extra"><span class="waitmail">⏳ Warte auf Antwort-Mail …</span></div>`:''}
${it.link?`<div class="urow-extra"><a href="${attr(it.link)}" target="_blank" style="font-size:.72rem;">🔗 Formular öffnen</a></div>`:''}
${active&&lastLine?`<div class="live">${esc(lastLine)}</div>`:''}
<div class="urow-meta">
<div class="urow-field"><span class="lbl">Email</span><span class="val trunc kv">${it.email?'<span class="okmark">✅</span> ':''}${esc(it.email||'–')}</span>${it.email?`<button class="copy-btn" data-copy="${attr(it.email)}" onclick="copyEl(this)">📋</button>`:''}</div>
<div class="urow-field"><span class="lbl">Token</span><span class="val trunc kv">${it.token?'<span class="okmark">✅</span> ':''}${esc(it.token?shortTok(it.token):'–')}</span>${it.token?`<button class="copy-btn" data-copy="${attr(it.token)}" onclick="copyEl(this)">📋</button>`:''}</div>
</div>
<div class="urow-actions">
${canSubmit?`<label class="chk" style="margin:0;"><input type="checkbox" class="submit-check" data-id="${id}" ${autoSubmitS1?'checked':''}> </label><button class="btn-small p1" data-id="${id}" onclick="submitEl(this)">📤 Absenden</button><button class="btn-small ghost" data-id="${id}" onclick="skipEl(this)" title="Nicht absenden">⏭</button>`:''}
${it.token?`<button class="btn-small ghost" data-email="${attr(it.email)}" data-token="${attr(it.token)}" onclick="toStep2El(this)" title="Email+Token in Step 2 uebernehmen">→ Step 2</button>`:''}
<span class="spacer"></span>
<button class="btn-small del" data-id="${id}" onclick="removeEl(this,'1')" title="Entfernen">🗑</button>
</div>
<div style="display:none;" id="${logsId}"><div class="logs-container">${(it.lines||[]).map(l=>`<div>${esc(l)}</div>`).join('')}</div></div>
</div>`;
}).join('');
document.getElementById('tbl_s1').innerHTML = rows_s1 || '<div class="empty-row">Noch keine URLs.</div>';
const ob = document.getElementById('step1_output');
if(ob && ob.style.display !== 'none') renderStep1Output(d.items_s1);
}
// Step 3
if(d.items_s3){
document.getElementById('count_s3').textContent = d.items_s3.length;
const rows_s3 = d.items_s3.map(it => {
const id = attr(it.id);
const logsId = 'logs_3_'+it.id;
const u = attr(it.url);
return `<div class="urow">
<div class="urow-top">
<button class="expand-btn" onclick="toggleLogs(this,'${logsId}')" title="Log ein/aus">📄</button>
<a class="urow-url trunc" href="${u}" target="_blank" title="${u}">${esc(it.url)}</a><span class="id-tag">${id}</span>
<span class="urow-badges">${badge(it.s3)}</span>
</div>
${it.link?`<div class="urow-field" style="margin-top:6px;"><span class="lbl">Link</span><span class="val trunc kv">${esc(it.link)}</span></div>`:''}
<div class="urow-actions"><span class="spacer"></span><button class="btn-small del" data-id="${id}" onclick="removeEl(this,'3')" title="Entfernen">🗑</button></div>
<div style="display:none;" id="${logsId}"><div class="logs-container">${(it.lines||[]).map(l=>`<div>${esc(l)}</div>`).join('')}</div></div>
</div>`;
}).join('');
document.getElementById('tbl_s3').innerHTML = rows_s3 || '<div class="empty-row">Noch keine URLs.</div>';
}
}
// Handler, die den Wert aus data-Attributen lesen (kein Quote-Escaping noetig).
function copyEl(btn){
const orig = btn.textContent;
copyText(btn.getAttribute('data-copy'));
btn.textContent = '✅ kopiert';
setTimeout(()=>{ btn.textContent = orig; }, 1200);
}
function removeEl(btn, step){ removeUrl(step, btn.getAttribute('data-id')); }
function submitEl(btn){ submitForm(btn.getAttribute('data-id')); }
function skipEl(btn){
fetch('/skip/'+encodeURIComponent(btn.getAttribute('data-id')),{method:'POST'})
.then(r=>r.json()).then(d=>{ if(!d.ok) alert(d.error); });
}
function toStep2El(btn){
document.getElementById('test_email').value = btn.getAttribute('data-email')||'';
document.getElementById('test_token').value = btn.getAttribute('data-token')||'';
document.getElementById('test_token').scrollIntoView({block:'center'});
}
function refresh(){
fetch('/state').then(r=>r.json()).then(d=>{
document.getElementById('mode').textContent = d.mode;
if(d.app_mode) renderMode(d.app_mode, !!d.mode_locked);
document.getElementById('running_s1').textContent = d.running ? ' (Batch laeuft...)' : '';
document.getElementById('running_s3').textContent = d.running ? ' (Batch laeuft...)' : '';
renderLists(d);
mergeStep3Jobs(d); // Step-3-Jobs aus items_s3 in die Pipeline mergen
renderPipelines(d.items_s1); // CI-Dashboard
maybeAutoBatchS3(d); // Step 3 startet sich selbst, sobald eine URL da ist
if(!d.running) maybeAutoBatchStep2(d.items_s1); // skalierbarer Step-2-Batch, wenn kein Batch laeuft
// Stop/Reset anzeigen, solange ein Batch laeuft.
const sbtn = document.getElementById('stop_batch_btn');
if(sbtn) sbtn.style.display = d.running ? 'inline-block' : 'none';
// Button "Offene Browser schliessen" anzeigen, solange welche offen sind.
const cbtn = document.getElementById('close_browsers_btn');
if(cbtn){
const n = d.kept_browsers || 0;
cbtn.style.display = n > 0 ? 'inline-block' : 'none';
cbtn.textContent = '🧹 Offene Browser schliessen (' + n + ')';
}
});
}
function closeBrowsers(){
fetch('/browsers/close',{method:'POST'}).then(r=>r.json()).then(d=>{
if(d.ok) refresh();
});
}
function stopBatch(){
if(!confirm('Laufenden Batch stoppen und zuruecksetzen?\\n\\nLaufende Vorgaenge werden abgebrochen. Danach kannst du direkt neu starten.')) return;
// Auto-Trigger sofort entschaerfen, damit nichts direkt wieder anspringt.
s2AutoArmed = false; s3AutoArmed = false;
fetch('/batch/stop',{method:'POST'}).then(r=>r.json()).then(d=>{ refresh(); });
}
function jsq(s){ return (s||'').replace(/\\\\/g,'\\\\\\\\').replace(/'/g,"\\\\'"); }
function copyText(t){ navigator.clipboard.writeText(t).catch(()=>{}); }
// ---- Config-Panels (pro Step) --------------------------------------------
function setV(id, v){ const e=document.getElementById(id); if(e) e.value = (v==null?'':v); }
function getV(id){ const e=document.getElementById(id); return e?e.value:''; }
function setC(id, v){ const e=document.getElementById(id); if(e) e.checked = !!v; }
function getC(id){ const e=document.getElementById(id); return e?e.checked:false; }
function loadCfg(){
fetch('/config').then(r=>r.json()).then(cfg=>{
CFG = cfg || {};
const c = CFG.contact||{}, e = CFG.email||{}, a = CFG.application||{};
const t1 = a.tenant1||{}, t2 = a.tenant2||{};
setV('c_salutation', c.salutation); setV('c_firstName', c.firstName);
setV('c_lastName', c.lastName); setV('c_phone', c.phone); setV('c_message', c.message);
setV('c_mode', e.mode); setV('c_sender_filter', e.sender_filter);
setV('c_imap_host', e.imap_host); setV('c_imap_user', e.imap_user);
setV('c_imap_password', e.imap_password);
setV('a_reserveNumber', a.reserveNumber); setV('a_validUntil', a.validUntil);
setV('a_numberRooms', a.numberRooms);
setC('a_secondTenant', a.secondTenant); setC('a_consent', a.consent);
const r = CFG.run||{};
setC('a_auto_submit', r.auto_submit); setC('a_keep_open', r.keep_browser_open !== false);
['firstName','lastName','dateOfBirth','street','housenumber','zipcode','city','phonePrivate','phoneBusiness']
.forEach(k => { setV('t1_'+k, t1[k]); setV('t2_'+k, t2[k]); });
});
}
function saveCfg(step){
// Immer die komplette (zuletzt geladene) CFG mit den Panel-Werten zurueckschreiben.
CFG.contact = CFG.contact||{}; CFG.email = CFG.email||{};
CFG.application = CFG.application||{};
CFG.application.tenant1 = CFG.application.tenant1||{};
CFG.application.tenant2 = CFG.application.tenant2||{};
CFG.contact.salutation = getV('c_salutation'); CFG.contact.firstName = getV('c_firstName');
CFG.contact.lastName = getV('c_lastName'); CFG.contact.phone = getV('c_phone');
CFG.contact.message = getV('c_message');
CFG.email.mode = getV('c_mode'); CFG.email.sender_filter = getV('c_sender_filter');
CFG.email.imap_host = getV('c_imap_host'); CFG.email.imap_user = getV('c_imap_user');
CFG.email.imap_password = getV('c_imap_password');
CFG.application.reserveNumber = getV('a_reserveNumber');
CFG.application.validUntil = getV('a_validUntil');
CFG.application.numberRooms = getV('a_numberRooms');
CFG.application.secondTenant = getC('a_secondTenant');
CFG.application.consent = getC('a_consent');
CFG.run = CFG.run||{};
CFG.run.auto_submit = getC('a_auto_submit');
CFG.run.keep_browser_open = getC('a_keep_open');
delete CFG.application.auto_submit; delete CFG.application.keep_browser_open;
['firstName','lastName','dateOfBirth','street','housenumber','zipcode','city','phonePrivate','phoneBusiness']
.forEach(k => { CFG.application.tenant1[k] = getV('t1_'+k); CFG.application.tenant2[k] = getV('t2_'+k); });
fetch('/config',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify(CFG)})
.then(r=>r.json())
.then(d=>{
const s = document.getElementById('saved_'+step);
if(s){ s.textContent = d.ok ? '✅ gespeichert' : '❌ Fehler'; setTimeout(()=>{s.textContent='';}, 2500); }
refresh();
})
.catch(e=>alert('Speichern fehlgeschlagen: '+e));
}
// ---- Modus (mock | prod) -------------------------------------------------
let APP_MODE = null, MODE_LOCKED = false;
function renderMode(mode, locked){
if(mode === APP_MODE && locked === MODE_LOCKED) return; // nichts geaendert
const changed = APP_MODE !== null && mode !== APP_MODE;
APP_MODE = mode; MODE_LOCKED = locked;
const bar = document.getElementById('modebar');
const pill = document.getElementById('mode_pill');
const desc = document.getElementById('mode_desc');
const btn = document.getElementById('mode_switch');
if(!bar) return;
bar.classList.remove('test','prod'); bar.classList.add(mode);
pill.textContent = mode === 'test' ? '🧪 TEST-MODUS' : '🚀 PROD-MODUS';
desc.innerHTML = mode === 'test'
? 'Nur die <b>Formulardaten</b> sind Testdaten. E-Mail, Token & Ablauf sind <b>echt</b> (wie prod).'
: 'Deine <b>echten Bewerbungsdaten</b> in den Formularen. E-Mail, Token & Ablauf wie immer echt.';
btn.textContent = mode === 'test' ? '→ Zu PROD wechseln' : '→ Zu TEST wechseln';
btn.disabled = locked;
btn.title = locked ? 'Modus ist per Umgebungsvariable IBO_MODE festgelegt.' : '';
btn.style.opacity = locked ? '.5' : '1';
if(changed) loadCfg(); // Formulardaten des neuen Modus in die Panels laden
}
function switchMode(){
if(MODE_LOCKED){ alert('Modus ist per Umgebungsvariable IBO_MODE festgelegt.'); return; }
const target = APP_MODE === 'test' ? 'prod' : 'test';
if(target === 'prod' && !confirm('In den PROD-Modus wechseln?\\n\\nDann werden deine ECHTEN Bewerbungsdaten in die Formulare eingetragen (statt Testdaten).')) return;
fetch('/mode',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({mode:target})})
.then(r=>r.json())
.then(d=>{ if(!d.ok){ alert(d.error||'Wechsel fehlgeschlagen'); return; }
renderMode(d.app_mode, MODE_LOCKED); refresh(); });
}
loadCfg();
setInterval(refresh, 1500); refresh();
setInterval(consentPoll, 1500); consentPoll();
(function(){ const s = document.getElementById('scale_s1');
if(s){ s.addEventListener('input', updateScaleHint); } updateScaleHint(); })();
</script>
</body></html>"""
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=False)