- 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>
2125 lines
94 KiB
Python
2125 lines
94 KiB
Python
#!/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 · Feldwerte je Step unter <b>⚙️ Config</b> · 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 → Cookie-/DSGVO-Zustimmung anklicken → 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 & 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 – 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 & 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 → Link → 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 & 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 & 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 & 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 & 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,'<'); }
|
||
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,'&').replace(/"/g,'"').replace(/</g,'<'); }
|
||
// ---- 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)
|