- 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>
388 lines
14 KiB
Python
388 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
core.py - UI-unabhaengige Logik fuer den Immo-Bot.
|
|
|
|
Wird sowohl von der Flask-App (app.py) als auch vom Desktop-Script
|
|
(immo_bot.py) genutzt. Enthaelt:
|
|
|
|
- make_driver() Chrome lokal ODER remote (VPS via Selenium-Container)
|
|
- fill_contact_form() immowelt-Kontaktformular (React) ausfuellen
|
|
- submit_contact_form() Kontaktformular absenden
|
|
- extract_form_link() "Formular ausfuellen"-Link aus einer Mail ziehen
|
|
- watch_inbox_imap() Postfach per IMAP beobachten
|
|
- disposable_address() Wegwerf-Adresse (mail.tm) anlegen
|
|
- watch_disposable() Wegwerf-Postfach beobachten
|
|
- fill_application_form() Bewerbungsformular (Angular) ausfuellen
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import json
|
|
import time
|
|
import email
|
|
import imaplib
|
|
|
|
from selenium import webdriver
|
|
from selenium.webdriver.common.by import By
|
|
from selenium.webdriver.support.ui import WebDriverWait
|
|
|
|
import autofill # Angular-Bewerbungsformular
|
|
import autofill_kontakt as ak # React-Kontaktformular
|
|
import mailtm # Wegwerf-E-Mail per API
|
|
import config # zentrale config.json
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cookie-/DSGVO-Zustimmung dauerhaft merken (Consent-Snapshot)
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# Ziel: die Cookie-/DSGVO-Zustimmung nur EINMAL anklicken (per Pre-App
|
|
# cookies_setup.py) und danach nie wieder.
|
|
#
|
|
# Wichtig fuer dieses Projekt: app.py startet die Browser PARALLEL (mehrere
|
|
# gleichzeitig). Ein persistenter Chrome-Profilordner (--user-data-dir) geht
|
|
# dabei NICHT, weil Chrome ein Profil auf genau eine laufende Instanz sperrt.
|
|
# Deshalb wird stattdessen ein Snapshot aus Cookies UND localStorage in
|
|
# consent.json gespeichert (moderne Consent-Banner legen die Zustimmung oft im
|
|
# localStorage ab, nicht nur in Cookies) und in jeden frischen Browser
|
|
# eingespielt. Das funktioniert lokal, parallel und remote (VPS-Container).
|
|
#
|
|
# Ablauf:
|
|
# 1) cookies_setup.py oeffnet die Seite(n), du klickst die Zustimmung an,
|
|
# save_consent() schreibt Cookies + localStorage pro Origin in consent.json.
|
|
# 2) Der Bot ruft nach driver.get(url) restore_consent(driver) auf -> Banner
|
|
# bleibt weg. click_cookie_banner() bleibt als Fallback bestehen.
|
|
|
|
from urllib.parse import urlparse
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
CONSENT_PATH = os.path.join(HERE, "consent.json")
|
|
|
|
|
|
def _origin(url):
|
|
p = urlparse(url or "")
|
|
return f"{p.scheme}://{p.netloc}" if p.netloc else ""
|
|
|
|
|
|
def _read_consent():
|
|
if os.path.exists(CONSENT_PATH):
|
|
try:
|
|
with open(CONSENT_PATH, encoding="utf-8") as f:
|
|
return json.load(f)
|
|
except Exception:
|
|
pass
|
|
return {}
|
|
|
|
|
|
def save_consent(driver, log=print):
|
|
"""Cookies + localStorage der AKTUELL geoeffneten Seite in consent.json ablegen.
|
|
|
|
Pro Origin (Schema+Host) ein Eintrag; mehrfach aufrufbar fuer mehrere Seiten.
|
|
Gibt den gespeicherten Origin zurueck.
|
|
"""
|
|
origin = _origin(driver.current_url)
|
|
if not origin:
|
|
log(" !! Keine gueltige Seite offen - nichts gespeichert.")
|
|
return None
|
|
try:
|
|
ls = driver.execute_script(
|
|
"var o={};for(var i=0;i<localStorage.length;i++)"
|
|
"{var k=localStorage.key(i);o[k]=localStorage.getItem(k);}return o;")
|
|
except Exception:
|
|
ls = {}
|
|
data = _read_consent()
|
|
data[origin] = {"cookies": driver.get_cookies(), "localStorage": ls or {}}
|
|
with open(CONSENT_PATH, "w", encoding="utf-8") as f:
|
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
log(f" Consent gespeichert fuer {origin}: "
|
|
f"{len(data[origin]['cookies'])} Cookies, {len(ls or {})} localStorage-Eintraege.")
|
|
return origin
|
|
|
|
|
|
def restore_consent(driver, log=None):
|
|
"""Gespeicherten Consent (Cookies + localStorage) fuer die aktuelle Seite einspielen.
|
|
|
|
NACH driver.get(<url>) aufrufen. Fuehrt am Ende ein refresh() aus, damit die
|
|
Seite die Zustimmung uebernimmt. Gibt True zurueck, wenn etwas eingespielt wurde.
|
|
"""
|
|
data = _read_consent()
|
|
if not data:
|
|
return False
|
|
|
|
origin = _origin(driver.current_url)
|
|
entry = data.get(origin)
|
|
if entry is None: # Fallback: nach Host matchen
|
|
host = urlparse(driver.current_url or "").netloc
|
|
entry = next((v for k, v in data.items()
|
|
if urlparse(k).netloc == host), None)
|
|
if not entry:
|
|
return False
|
|
|
|
for c in entry.get("cookies", []):
|
|
c = dict(c)
|
|
c.pop("sameSite", None) # manche Werte loesen add_cookie-Fehler aus
|
|
try:
|
|
driver.add_cookie(c)
|
|
except Exception:
|
|
pass
|
|
|
|
ls = entry.get("localStorage", {})
|
|
if ls:
|
|
try:
|
|
driver.execute_script(
|
|
"var o=arguments[0];for(var k in o)localStorage.setItem(k,o[k]);", ls)
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
driver.refresh()
|
|
except Exception:
|
|
pass
|
|
if log:
|
|
log(f" Consent wiederhergestellt fuer {origin} "
|
|
f"({len(entry.get('cookies', []))} Cookies, {len(ls)} localStorage).")
|
|
return True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WebDriver: lokal oder remote (VPS)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def make_driver(remote_url=None, headless=None):
|
|
"""
|
|
remote_url: z.B. "http://VPS-IP:4444" -> nutzt Selenium-Container auf VPS.
|
|
Falls None, wird die Env-Var SELENIUM_REMOTE_URL gelesen.
|
|
headless: True/False; Default: headless wenn remote oder Env HEADLESS=1.
|
|
"""
|
|
remote_url = remote_url or os.environ.get("SELENIUM_REMOTE_URL")
|
|
if headless is None:
|
|
headless = bool(remote_url) or os.environ.get("HEADLESS") == "1"
|
|
|
|
opts = webdriver.ChromeOptions()
|
|
if headless:
|
|
opts.add_argument("--headless=new")
|
|
opts.add_argument("--no-sandbox")
|
|
opts.add_argument("--disable-dev-shm-usage")
|
|
opts.add_argument("--window-size=1400,1000")
|
|
|
|
if remote_url:
|
|
return webdriver.Remote(command_executor=remote_url, options=opts)
|
|
return webdriver.Chrome(options=opts)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Schritt 1: Kontaktformular ausfuellen (+ optional absenden)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def contact_data(email_override=None):
|
|
"""Kontakt-Felder aus config.json holen (Kopie -> thread-sicher)."""
|
|
c = dict(config.CFG["contact"])
|
|
if email_override:
|
|
c["email"] = email_override
|
|
return c
|
|
|
|
|
|
def fill_contact_form(driver, log, contact):
|
|
"""
|
|
contact: dict mit salutation, firstName, lastName, email, phone, message.
|
|
Daten kommen als Parameter (kein globaler Zustand) -> parallel-tauglich.
|
|
"""
|
|
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, contact["salutation"])
|
|
ak.fill_by_name(driver, "firstName", contact["firstName"])
|
|
ak.fill_by_name(driver, "lastName", contact["lastName"])
|
|
ak.fill_by_name(driver, "email", contact["email"])
|
|
ak.fill_phone(driver, contact["phone"])
|
|
ak.fill_by_name(driver, "message", contact["message"])
|
|
log(f" -> E-Mail im Formular: {contact['email']}")
|
|
return True
|
|
|
|
|
|
def submit_contact_form(driver, log):
|
|
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: Link aus Mail extrahieren
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def extract_form_link(html):
|
|
"""Sucht in der Mail den Link hinter 'Formular ausfuellen'.
|
|
|
|
Validiert:
|
|
- Must contain "/property/application/form/" (URL-Struktur)
|
|
- Linktext sollte "formular" enthalten oder URL passt
|
|
- Exclude: withdraw, widerruf, abmeld, unsub, datenschutz, impressum
|
|
"""
|
|
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:
|
|
# Nur Links mit der richtigen URL-Struktur
|
|
if "/property/application/form/" not in href.lower():
|
|
continue
|
|
|
|
# Ausschlusswörter prüfen
|
|
if any(x in href.lower() for x in exclude):
|
|
continue
|
|
|
|
candidates.append(href)
|
|
|
|
if candidates:
|
|
return candidates[0]
|
|
|
|
# Fallback: regulärer Ausdruck als Backup
|
|
m = re.search(r'https://[^"\s]*?/property/application/form/[^"\s]*', html, re.I)
|
|
return m.group(0) if m else None
|
|
|
|
|
|
# ---- IMAP-Variante (echtes Postfach) --------------------------------------
|
|
|
|
def _html_from_message(msg):
|
|
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 watch_inbox_imap(host, user, password, log,
|
|
sender_hint="", timeout=300, poll=15, should_cancel=None):
|
|
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:
|
|
if should_cancel and should_cancel():
|
|
log("Warten abgebrochen.")
|
|
return None
|
|
M.select("INBOX")
|
|
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
|
|
link = extract_form_link(_html_from_message(msg))
|
|
if link:
|
|
log(f"Formular-Link in Mail von: {frm}")
|
|
return link
|
|
|
|
remaining = int(deadline - time.time())
|
|
log(f"Noch keine passende Mail. Warte {poll}s (Rest {remaining}s) ...")
|
|
slept = 0.0
|
|
while slept < poll:
|
|
if should_cancel and should_cancel():
|
|
log("Warten abgebrochen.")
|
|
return None
|
|
time.sleep(0.5)
|
|
slept += 0.5
|
|
finally:
|
|
try:
|
|
M.logout()
|
|
except Exception:
|
|
pass
|
|
log("!! Timeout - keine Mail mit Formular-Link gefunden.")
|
|
return None
|
|
|
|
|
|
# ---- Wegwerf-Variante (mail.tm, ohne Einrichtung) -------------------------
|
|
|
|
def disposable_address(log):
|
|
"""Legt eine Wegwerf-Adresse an. Gibt (address, token)."""
|
|
log("Erzeuge Wegwerf-Adresse (mail.tm) ...")
|
|
address, _pw, token = mailtm.create_account()
|
|
log(f"Wegwerf-Adresse: {address}")
|
|
return address, token
|
|
|
|
|
|
def watch_disposable(token, log, sender_hint="", timeout=300, poll=10, should_cancel=None):
|
|
return mailtm.wait_for_message(
|
|
token, extract_form_link, log,
|
|
sender_hint=sender_hint, timeout=timeout, poll=poll, should_cancel=should_cancel)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Schritt 3: Bewerbungsformular (Angular) ausfuellen
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def fill_application_form(driver, log):
|
|
wait = WebDriverWait(driver, 10)
|
|
time.sleep(1.5)
|
|
|
|
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
|