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

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

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"&nbsp;?", " ", 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()