#!/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) 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']*href="([^"]+)"[^>]*>(.*?)', 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