- 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>
412 lines
15 KiB
Python
412 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Immo-Bot - simple UI, die den ganzen Ablauf automatisiert:
|
|
|
|
1. Kontaktformular auf immowelt (URL) mit Mock-Daten ausfuellen
|
|
und - falls gewuenscht - automatisch absenden.
|
|
2. Posteingang (IMAP) beobachten, die Bestaetigungs-Mail finden
|
|
und den "Formular ausfuellen"-Link extrahieren.
|
|
3. Diesen Link oeffnen und das Bewerbungsformular (Angular) ausfuellen.
|
|
|
|
Es werden die bestehenden Scripte wiederverwendet:
|
|
- autofill_kontakt.py -> Kontaktformular (React)
|
|
- autofill.py -> Bewerbungsformular (Angular)
|
|
|
|
Setup:
|
|
pip install selenium
|
|
(Chrome + chromedriver; Selenium 4 verwaltet den Driver selbst.)
|
|
|
|
Start:
|
|
python immo_bot.py
|
|
|
|
Hinweis E-Mail:
|
|
Fuer den Postfach-Zugriff wird IMAP mit App-Passwort genutzt.
|
|
- Gmail: Host imap.gmail.com (App-Passwort in Google-Konto erzeugen)
|
|
- Outlook: Host outlook.office365.com (persoenliche Outlook-Konten
|
|
erlauben teils nur OAuth statt Passwort - dann Gmail nehmen oder
|
|
die Mail an ein Gmail weiterleiten.)
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import time
|
|
import email
|
|
import imaplib
|
|
import threading
|
|
import tkinter as tk
|
|
from tkinter import ttk, scrolledtext
|
|
|
|
from selenium import webdriver
|
|
from selenium.webdriver.common.by import By
|
|
from selenium.webdriver.support.ui import WebDriverWait
|
|
|
|
# Bestehende Scripte wiederverwenden
|
|
import autofill # Angular-Bewerbungsformular
|
|
import autofill_kontakt as ak # React-Kontaktformular
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Schritt 1: Kontaktformular ausfuellen (+ optional absenden)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def fill_contact_form(driver, log, applicant_email=None):
|
|
"""Fuellt das immowelt-Kontaktformular. Nutzt Funktionen aus autofill_kontakt."""
|
|
if applicant_email:
|
|
ak.MOCK["email"] = applicant_email # Mail = das Postfach, das wir beobachten
|
|
|
|
ak.click_cookie_banner(driver)
|
|
|
|
if not ak.visible(driver, 'input[name="email"]'):
|
|
log("E-Mail-Feld nicht sichtbar -> versuche Kontakt-Button ...")
|
|
ak.open_contact_form(driver)
|
|
|
|
log("Warte auf Kontaktformular ...")
|
|
for _ in range(25):
|
|
if ak.visible(driver, 'input[name="email"]'):
|
|
break
|
|
time.sleep(1.0)
|
|
|
|
if not ak.visible(driver, 'input[name="email"]'):
|
|
log("!! Kein Kontaktformular gefunden.")
|
|
return False
|
|
|
|
log("Fuelle Kontaktformular ...")
|
|
ak.choose_salutation(driver, ak.MOCK["salutation"])
|
|
ak.fill_by_name(driver, "firstName", ak.MOCK["firstName"])
|
|
ak.fill_by_name(driver, "lastName", ak.MOCK["lastName"])
|
|
ak.fill_by_name(driver, "email", ak.MOCK["email"])
|
|
ak.fill_phone(driver, ak.MOCK["phone"])
|
|
ak.fill_by_name(driver, "message", ak.MOCK["message"])
|
|
log(f" -> E-Mail im Formular: {ak.MOCK['email']}")
|
|
return True
|
|
|
|
|
|
def submit_contact_form(driver, log):
|
|
"""Klickt den Absenden-Button des Kontaktformulars."""
|
|
labels = ["Nachricht senden", "Anfrage senden", "Kontaktanfrage senden",
|
|
"Anfrage absenden", "Absenden", "Senden", "Abschicken"]
|
|
for lbl in labels:
|
|
try:
|
|
btns = driver.find_elements(
|
|
By.XPATH, f"//button[contains(normalize-space(.), '{lbl}')]")
|
|
for btn in btns:
|
|
if btn.is_displayed() and btn.is_enabled():
|
|
driver.execute_script(
|
|
"arguments[0].scrollIntoView({block:'center'});", btn)
|
|
btn.click()
|
|
log(f"Kontaktformular abgesendet (Button: '{lbl}').")
|
|
return True
|
|
except Exception:
|
|
pass
|
|
log("!! Kein Absenden-Button gefunden - bitte manuell pruefen.")
|
|
return False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Schritt 2: E-Mail beobachten und Formular-Link extrahieren
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _html_from_message(msg):
|
|
"""Holt den text/html-Teil einer E-Mail als String."""
|
|
parts = msg.walk() if msg.is_multipart() else [msg]
|
|
for part in parts:
|
|
if part.get_content_type() == "text/html":
|
|
charset = part.get_content_charset() or "utf-8"
|
|
try:
|
|
return part.get_payload(decode=True).decode(charset, errors="replace")
|
|
except Exception:
|
|
return part.get_payload(decode=True).decode("utf-8", errors="replace")
|
|
return ""
|
|
|
|
|
|
def extract_form_link(html):
|
|
"""Sucht in der Mail den Link hinter 'Formular ausfuellen'."""
|
|
anchors = re.findall(r'<a\b[^>]*href="([^"]+)"[^>]*>(.*?)</a>', html, re.I | re.S)
|
|
exclude = ("withdraw", "widerruf", "abmeld", "unsub", "datenschutz", "impressum")
|
|
candidates = []
|
|
for href, text in anchors:
|
|
clean = re.sub(r"<[^>]+>", "", text)
|
|
clean = re.sub(r" ?", " ", clean).strip().lower()
|
|
if "formular" in clean and not any(x in href.lower() for x in exclude):
|
|
candidates.append(href)
|
|
# bevorzugt den echten Formular-Link
|
|
for c in candidates:
|
|
if "application/form" in c.lower():
|
|
return c
|
|
if candidates:
|
|
return candidates[0]
|
|
# Fallback: irgendein application/form-Link im HTML
|
|
m = re.search(r'https://[^"\s]*application/form[^"\s]*', html)
|
|
return m.group(0) if m else None
|
|
|
|
|
|
def watch_inbox_for_link(host, user, password, log,
|
|
sender_hint="", timeout=300, poll=15):
|
|
"""Pollt den Posteingang, bis eine passende Mail mit Formular-Link kommt."""
|
|
log(f"Verbinde mit {host} als {user} ...")
|
|
try:
|
|
M = imaplib.IMAP4_SSL(host)
|
|
M.login(user, password)
|
|
except Exception as e:
|
|
log(f"!! IMAP-Login fehlgeschlagen: {e}")
|
|
return None
|
|
|
|
deadline = time.time() + timeout
|
|
seen_ids = set()
|
|
try:
|
|
while time.time() < deadline:
|
|
M.select("INBOX")
|
|
# ungelesene Mails zuerst; sonst die neuesten
|
|
typ, data = M.search(None, "UNSEEN")
|
|
ids = data[0].split() if typ == "OK" and data[0] else []
|
|
if not ids:
|
|
typ, data = M.search(None, "ALL")
|
|
ids = (data[0].split() if typ == "OK" and data[0] else [])[-10:]
|
|
|
|
for mid in reversed(ids):
|
|
if mid in seen_ids:
|
|
continue
|
|
seen_ids.add(mid)
|
|
typ, msg_data = M.fetch(mid, "(RFC822)")
|
|
if typ != "OK":
|
|
continue
|
|
msg = email.message_from_bytes(msg_data[0][1])
|
|
frm = str(msg.get("From", ""))
|
|
if sender_hint and sender_hint.lower() not in frm.lower():
|
|
continue
|
|
html = _html_from_message(msg)
|
|
link = extract_form_link(html)
|
|
if link:
|
|
log(f"Formular-Link gefunden in Mail von: {frm}")
|
|
return link
|
|
|
|
remaining = int(deadline - time.time())
|
|
log(f"Noch keine passende Mail. Warte {poll}s (Rest {remaining}s) ...")
|
|
time.sleep(poll)
|
|
finally:
|
|
try:
|
|
M.logout()
|
|
except Exception:
|
|
pass
|
|
|
|
log("!! Timeout - keine Mail mit Formular-Link gefunden.")
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Schritt 3: Bewerbungsformular (Angular) ausfuellen
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def fill_application_form(driver, log):
|
|
"""Fuellt das Angular-Formular ueber die Funktionen aus autofill.py."""
|
|
wait = WebDriverWait(driver, 10)
|
|
time.sleep(1.5) # Angular-Bootstrap
|
|
|
|
log("Aktiviere zweiten Mieter ...")
|
|
autofill.set_checkbox(driver, "formly_5_checkbox_secondTenant_5", True)
|
|
time.sleep(0.5)
|
|
|
|
log("Dropdowns ...")
|
|
for fid, opt in autofill.SELECTS.items():
|
|
autofill.select_option(driver, wait, fid, opt)
|
|
|
|
log("Textfelder ...")
|
|
for fid, val in autofill.TEXT_FIELDS.items():
|
|
autofill.fill_text(driver, wait, fid, val)
|
|
|
|
log("Zustimmungs-Checkboxen ...")
|
|
for cid in autofill.CHECKBOXES:
|
|
if cid == "formly_5_checkbox_secondTenant_5":
|
|
continue
|
|
autofill.set_checkbox(driver, cid, True)
|
|
|
|
log("Bewerbungsformular ausgefuellt (nicht abgesendet).")
|
|
return True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# UI
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class ImmoBotUI:
|
|
def __init__(self, root):
|
|
self.root = root
|
|
self.driver = None
|
|
self.busy = False
|
|
self.form_link = None
|
|
|
|
root.title("Immo-Bot")
|
|
root.geometry("720x640")
|
|
|
|
pad = {"padx": 8, "pady": 4}
|
|
frm = ttk.Frame(root, padding=12)
|
|
frm.pack(fill="both", expand=True)
|
|
|
|
# --- Eingaben ---
|
|
ttk.Label(frm, text="immowelt Expose-/Kontakt-URL:").grid(
|
|
row=0, column=0, sticky="w", **pad)
|
|
self.url_var = tk.StringVar()
|
|
ttk.Entry(frm, textvariable=self.url_var, width=70).grid(
|
|
row=1, column=0, columnspan=3, sticky="we", **pad)
|
|
|
|
ttk.Label(frm, text="Deine E-Mail (Formular + Postfach):").grid(
|
|
row=2, column=0, sticky="w", **pad)
|
|
self.email_var = tk.StringVar(value="abutepeli@outlook.de")
|
|
ttk.Entry(frm, textvariable=self.email_var, width=40).grid(
|
|
row=3, column=0, sticky="we", **pad)
|
|
|
|
ttk.Label(frm, text="IMAP-Host:").grid(row=2, column=1, sticky="w", **pad)
|
|
self.host_var = tk.StringVar(value="outlook.office365.com")
|
|
ttk.Entry(frm, textvariable=self.host_var, width=28).grid(
|
|
row=3, column=1, sticky="we", **pad)
|
|
|
|
ttk.Label(frm, text="E-Mail App-Passwort:").grid(
|
|
row=4, column=0, sticky="w", **pad)
|
|
self.pw_var = tk.StringVar()
|
|
ttk.Entry(frm, textvariable=self.pw_var, width=40, show="*").grid(
|
|
row=5, column=0, sticky="we", **pad)
|
|
|
|
ttk.Label(frm, text="Absender-Filter (optional):").grid(
|
|
row=4, column=1, sticky="w", **pad)
|
|
self.sender_var = tk.StringVar(value="muenchner-wohnen")
|
|
ttk.Entry(frm, textvariable=self.sender_var, width=28).grid(
|
|
row=5, column=1, sticky="we", **pad)
|
|
|
|
self.submit_var = tk.BooleanVar(value=False)
|
|
ttk.Checkbutton(
|
|
frm, text="Kontaktformular automatisch ABSENDEN",
|
|
variable=self.submit_var).grid(row=6, column=0, columnspan=2,
|
|
sticky="w", **pad)
|
|
|
|
# --- Buttons ---
|
|
btns = ttk.Frame(frm)
|
|
btns.grid(row=7, column=0, columnspan=3, sticky="we", pady=8)
|
|
self.b_all = ttk.Button(btns, text="ALLES automatisch (1 -> 2 -> 3)",
|
|
command=self.run_all)
|
|
self.b_all.pack(side="left", padx=4)
|
|
self.b1 = ttk.Button(btns, text="1. Kontakt ausfuellen",
|
|
command=self.run_step1)
|
|
self.b1.pack(side="left", padx=4)
|
|
self.b2 = ttk.Button(btns, text="2. Mail -> Link",
|
|
command=self.run_step2)
|
|
self.b2.pack(side="left", padx=4)
|
|
self.b3 = ttk.Button(btns, text="3. Bewerbung ausfuellen",
|
|
command=self.run_step3)
|
|
self.b3.pack(side="left", padx=4)
|
|
|
|
# --- Log ---
|
|
self.log_box = scrolledtext.ScrolledText(frm, height=18, wrap="word")
|
|
self.log_box.grid(row=8, column=0, columnspan=3, sticky="nsew", **pad)
|
|
frm.rowconfigure(8, weight=1)
|
|
frm.columnconfigure(0, weight=1)
|
|
|
|
self.log("Bereit. URL eingeben und einen Schritt starten.")
|
|
|
|
# ---- Helfer ----
|
|
def log(self, msg):
|
|
self.root.after(0, self._append, str(msg))
|
|
|
|
def _append(self, msg):
|
|
self.log_box.insert("end", msg + "\n")
|
|
self.log_box.see("end")
|
|
|
|
def _run_bg(self, target):
|
|
if self.busy:
|
|
self.log("... laeuft bereits, bitte warten.")
|
|
return
|
|
self.busy = True
|
|
|
|
def wrap():
|
|
try:
|
|
target()
|
|
except Exception as e:
|
|
self.log(f"!! Fehler: {e}")
|
|
finally:
|
|
self.busy = False
|
|
|
|
threading.Thread(target=wrap, daemon=True).start()
|
|
|
|
def ensure_driver(self):
|
|
if self.driver is None:
|
|
remote = os.environ.get("SELENIUM_REMOTE_URL")
|
|
opts = webdriver.ChromeOptions()
|
|
if remote:
|
|
# z.B. SELENIUM_REMOTE_URL=http://VPS-IP:4444
|
|
# Rendering live sichtbar ueber noVNC: http://VPS-IP:7900 (PW: secret)
|
|
self.log(f"Verbinde mit Remote-Selenium: {remote}")
|
|
self.driver = webdriver.Remote(command_executor=remote, options=opts)
|
|
else:
|
|
self.log("Starte lokales Chrome ...")
|
|
self.driver = webdriver.Chrome(options=opts)
|
|
return self.driver
|
|
|
|
# ---- Aktionen ----
|
|
def run_step1(self):
|
|
self._run_bg(self._step1)
|
|
|
|
def _step1(self):
|
|
url = self.url_var.get().strip()
|
|
if not url:
|
|
self.log("!! Bitte URL eingeben.")
|
|
return
|
|
d = self.ensure_driver()
|
|
self.log(f"Oeffne {url}")
|
|
d.get(url)
|
|
time.sleep(2.0)
|
|
if fill_contact_form(d, self.log, self.email_var.get().strip()):
|
|
if self.submit_var.get():
|
|
submit_contact_form(d, self.log)
|
|
else:
|
|
self.log("Nicht abgesendet (Haken 'automatisch ABSENDEN' nicht gesetzt).")
|
|
|
|
def run_step2(self):
|
|
self._run_bg(self._step2)
|
|
|
|
def _step2(self):
|
|
link = watch_inbox_for_link(
|
|
self.host_var.get().strip(),
|
|
self.email_var.get().strip(),
|
|
self.pw_var.get(),
|
|
self.log,
|
|
sender_hint=self.sender_var.get().strip(),
|
|
)
|
|
if link:
|
|
self.form_link = link
|
|
self.log(f"LINK: {link}")
|
|
|
|
def run_step3(self):
|
|
self._run_bg(self._step3)
|
|
|
|
def _step3(self):
|
|
if not self.form_link:
|
|
self.log("!! Kein Formular-Link vorhanden (erst Schritt 2 ausfuehren).")
|
|
return
|
|
d = self.ensure_driver()
|
|
self.log(f"Oeffne Bewerbungsformular: {self.form_link}")
|
|
d.get(self.form_link)
|
|
fill_application_form(d, self.log)
|
|
|
|
def run_all(self):
|
|
self._run_bg(self._all)
|
|
|
|
def _all(self):
|
|
self._step1()
|
|
if not self.submit_var.get():
|
|
self.log("Hinweis: Ohne Absenden kommt keine E-Mail. "
|
|
"Setze den Haken 'automatisch ABSENDEN' fuer den Vollablauf.")
|
|
return
|
|
self._step2()
|
|
if self.form_link:
|
|
self._step3()
|
|
self.log("== Ablauf beendet ==")
|
|
|
|
|
|
def main():
|
|
root = tk.Tk()
|
|
ImmoBotUI(root)
|
|
root.mainloop()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|