#!/usr/bin/env python3 """eZICOM5 Zeitbuchungs-Korrektur-Tool. Nimmt Korrekturbuchungen für fehlerhafte Tage aus der Fehlerliste vor. Verwendet die gleichen Login-Schritte wie ezicom_login.py. Credentials werden aus Umgebungsvariablen, Kommandozeilenargumenten oder interaktiven Eingaben gelesen und niemals auf die Festplatte geschrieben. """ from __future__ import annotations import argparse import datetime as dt import getpass import html import http.cookiejar import os import re import ssl import sys import urllib.error import urllib.parse import urllib.request from pathlib import Path # --------------------------------------------------------------------------- # Konstanten # --------------------------------------------------------------------------- DEFAULT_BASE_URL = "https://zm.sufw.de:31443" DEFAULT_USER_AGENT = ( "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:151.0) " "Gecko/20100101 Firefox/151.0" ) # Standard-Korrekturbuchungen: (Uhrzeit, Art) # 1000 = Kommen, 2000 = Gehen CORRECTION_BOOKINGS = (("10:00", "1000"), ("14:00", "2000")) # Maximale Anzahl Tage, für die Korrekturen möglich sind MAX_CORRECTION_AGE_DAYS = 14 # --------------------------------------------------------------------------- # Hilfsfunktionen # --------------------------------------------------------------------------- def clean_html_text(value: str) -> str: """Entfernt HTML-Tags und normalisiert Whitespace.""" value = re.sub(r"<[^>]+>", " ", value) value = html.unescape(value).replace("\xa0", " ") return " ".join(value.split()) def load_dotenv(path: Path = Path(".env")) -> None: """Lädt Umgebungsvariablen aus einer .env-Datei.""" if not path.exists(): return for line in path.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, value = line.split("=", 1) key = key.strip() value = value.strip().strip("'\"") if key and key not in os.environ: os.environ[key] = value def make_opener(insecure: bool) -> urllib.request.OpenerDirector: """Erstellt einen URL-Opener mit Cookie-Jar.""" cookie_jar = http.cookiejar.CookieJar() handlers: list[urllib.request.BaseHandler] = [ urllib.request.HTTPCookieProcessor(cookie_jar) ] if insecure: context = ssl._create_unverified_context() handlers.append(urllib.request.HTTPSHandler(context=context)) return urllib.request.build_opener(*handlers) def request( opener: urllib.request.OpenerDirector, url: str, *, method: str = "GET", data: dict[str, str] | None = None, headers: dict[str, str] | None = None, timeout: float = 20.0, ) -> tuple[int, str, str]: """Führt einen HTTP-Request aus und gibt (status, url, body) zurück.""" body = None request_headers = { "User-Agent": DEFAULT_USER_AGENT, "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", } if headers: request_headers.update(headers) if data is not None: body = urllib.parse.urlencode(data).encode("ascii") request_headers["Content-Type"] = "application/x-www-form-urlencoded" req = urllib.request.Request(url, data=body, headers=request_headers, method=method) try: with opener.open(req, timeout=timeout) as response: raw = response.read() charset = response.headers.get_content_charset() or "iso-8859-1" return response.status, response.geturl(), raw.decode(charset, "replace") except urllib.error.HTTPError as error: raw = error.read() charset = error.headers.get_content_charset() or "iso-8859-1" return error.code, error.geturl(), raw.decode(charset, "replace") def find_single_quoted_js_value(html_text: str, pattern: str) -> str | None: """Sucht einen in einfachen Anführungszeichen stehenden JS-Wert.""" match = re.search(pattern, html_text) if not match: return None return match.group(1) def mask_secret(value: str) -> str: """Maskiert einen sensiblen Wert für die Anzeige.""" if len(value) <= 8: return "***" return value[:4] + "..." + value[-4:] def _save_debug_response(debug_dir: Path, label: str, content: str) -> None: """Speichert eine Server-Antwort im Debug-Ordner mit Zeitstempel.""" timestamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S") filename = debug_dir / f"{timestamp}-{label}.html" filename.write_text(content, encoding="utf-8") print(f"[DEBUG] → {filename}") # --------------------------------------------------------------------------- # Datumshilfen # --------------------------------------------------------------------------- def parse_ezicom_date(value: str) -> dt.date: return dt.datetime.strptime(value, "%d.%m.%Y").date() def format_ezicom_date(value: dt.date) -> str: return value.strftime("%d.%m.%Y") # --------------------------------------------------------------------------- # Login (gleiche Schritte wie ezicom_login.py) # --------------------------------------------------------------------------- def login( base_url: str, username: str, password: str, *, insecure: bool, debug: bool = False, ) -> tuple[urllib.request.OpenerDirector, str] | None: """Führt den Login durch und gibt (opener, sid) zurück. Bei Fehlschlag wird None zurückgegeben. """ base_url = base_url.rstrip("/") opener = make_opener(insecure) # 1. Initiale Seite laden try: initial_status, _, _ = request(opener, base_url + "/") except urllib.error.URLError as error: print(f"Konnte Startseite nicht öffnen: {error}", file=sys.stderr) if "CERTIFICATE_VERIFY_FAILED" in str(error): print("Hinweis: versuche --insecure oder installiere das CA-Zertifikat.", file=sys.stderr) return None if initial_status >= 400: print(f"Startseite lieferte HTTP {initial_status}", file=sys.stderr) return None # 2. Login-Formular absenden try: status, final_url, html_text = request( opener, base_url + "/user", method="POST", data={"action": "login", "uid": username, "pw0": password}, headers={ "Origin": base_url, "Referer": base_url + "/", }, ) except urllib.error.URLError as error: print(f"Login-Request fehlgeschlagen: {error}", file=sys.stderr) if "CERTIFICATE_VERIFY_FAILED" in str(error): print("Hinweis: versuche --insecure oder installiere das CA-Zertifikat.", file=sys.stderr) return None lower_html = html_text.lower() failed = ( status in {401, 403} or "benutzername und/oder kennwort" in lower_html or "error 403" in lower_html or "403 - verboten" in lower_html ) if failed: print(f"Login fehlgeschlagen: HTTP {status} ({final_url})", file=sys.stderr) return None if debug: print(f"[DEBUG] Login erfolgreich: HTTP {status} ({final_url})") # 3. Weiter-Seite folgen (rahmen.html) next_result = _follow_next_page(opener, base_url, html_text) if next_result is None: print("Keine Folge-URL in Login-Antwort gefunden.", file=sys.stderr) return None next_status, next_url, next_html = next_result if next_status >= 400: print(f"Folgeseite fehlgeschlagen: HTTP {next_status} ({next_url})", file=sys.stderr) return None if debug: print(f"[DEBUG] Folgeseite geladen: HTTP {next_status} ({next_url})") # 4. Session-ID extrahieren sid = _extract_sid(next_html, next_url) if not sid: print("Keine Session-ID in der Folgeseite gefunden.", file=sys.stderr) return None if debug: print(f"[DEBUG] Session-ID: {mask_secret(sid)}") return opener, sid def _follow_next_page( opener: urllib.request.OpenerDirector, base_url: str, html_text: str, ) -> tuple[int, str, str] | None: """Folgt der 'Weiter'-Seite nach dem Login.""" cust_info_url = find_single_quoted_js_value( html_text, r"url:\s*'([^']+/isCustInfoPnl)'" ) build_info_url = find_single_quoted_js_value( html_text, r"\$\.get\('([^']+/buildCustInfoPnl)'" ) frame_url = find_single_quoted_js_value( html_text, r"location\.href='([^']+rahmen\.html)'" ) if not frame_url: return None if cust_info_url: status, _, response = request( opener, urllib.parse.urljoin(base_url + "/", cust_info_url) ) if status < 400 and response.strip() == "-1" and build_info_url: return request( opener, urllib.parse.urljoin(base_url + "/", build_info_url) ) return request(opener, urllib.parse.urljoin(base_url + "/", frame_url)) def _extract_sid(html_text: str, url: str) -> str | None: """Extrahiert die Session-ID aus HTML oder URL.""" sid = find_single_quoted_js_value( html_text, r']+name="sid"[^>]+value="([^"]+)"' ) if sid: return sid match = re.search(r"/user/([A-Fa-f0-9]+)/rahmen\.html", url) if match: return match.group(1) return None # --------------------------------------------------------------------------- # Fehlerliste # --------------------------------------------------------------------------- FehlerlisteEntry = tuple[dt.date, str, str] # (Datum, Wochentag, Grund) def fetch_fehlerliste( opener: urllib.request.OpenerDirector, base_url: str, sid: str, ) -> tuple[int, str, str]: """Ruft die Fehlerliste ab.""" params = { "page": "corrindex", "go": "dfhl", "sid": sid, "Btn": "9", "nc": str(int(dt.datetime.now().timestamp() * 1000)), } return request( opener, base_url.rstrip("/") + "/user?" + urllib.parse.urlencode(params) ) def parse_fehlerliste(html_text: str) -> list[FehlerlisteEntry]: """Parst die HTML-Fehlerliste und gibt Einträge zurück.""" dfhl_match = re.search( r']*>(.*?)', html_text, flags=re.IGNORECASE | re.DOTALL, ) if not dfhl_match: return [] rows = re.findall( r"]*>(.*?)", dfhl_match.group(1), flags=re.IGNORECASE | re.DOTALL, ) entries: list[FehlerlisteEntry] = [] for row in rows: cells = re.findall( r"]*>(.*?)", row, flags=re.IGNORECASE | re.DOTALL, ) if len(cells) < 4: continue date_text = clean_html_text(cells[2]) date_match = re.search(r"\b(\d{2}\.\d{2}\.\d{4})\b", date_text) if not date_match: continue weekday = date_text[: date_match.start()].strip() reason = re.sub(r"\[([^\]]+)\]", r"\1", clean_html_text(cells[3])) entries.append((parse_ezicom_date(date_match.group(1)), weekday, reason)) return entries def split_fehlerliste_entries( entries: list[FehlerlisteEntry], ) -> tuple[list[FehlerlisteEntry], list[FehlerlisteEntry]]: """Teilt Einträge in 'aktuell korrigierbar' und 'zu alt'.""" today = dt.date.today() recent: list[FehlerlisteEntry] = [] too_old: list[FehlerlisteEntry] = [] for entry in entries: age_days = (today - entry[0]).days if 0 <= age_days <= MAX_CORRECTION_AGE_DAYS: recent.append(entry) else: too_old.append(entry) return recent, too_old def format_fehlerliste_entry(entry: FehlerlisteEntry) -> str: """Formatiert einen Fehlerlisteneintrag für die Anzeige.""" date_value, weekday, reason = entry day = ( f"{weekday} {format_ezicom_date(date_value)}" if weekday else format_ezicom_date(date_value) ) return f"{day}: {reason}" def print_fehlerliste(entries: list[FehlerlisteEntry]) -> list[FehlerlisteEntry]: """Gibt die Fehlerliste aus und gibt die korrigierbaren Einträge zurück.""" if not entries: print("Fehlerliste: keine fehlerhaften Tage gefunden.") return [] print(f"\nFehlerliste: {len(entries)} fehlerhafte Tage") recent, too_old = split_fehlerliste_entries(entries) if recent: print(f"\n📋 Buchbar (jünger als {MAX_CORRECTION_AGE_DAYS + 1} Tage):") for number, entry in enumerate(recent, start=1): print(f" {number:>2}) {format_fehlerliste_entry(entry)}") else: print(f"\n📋 Buchbar (jünger als {MAX_CORRECTION_AGE_DAYS + 1} Tage): keine Einträge") if too_old: print(f"\n⚠️ Älter als {MAX_CORRECTION_AGE_DAYS} Tage – keine Änderung mehr möglich:") for entry in too_old: print(f" {format_fehlerliste_entry(entry)}") return recent # --------------------------------------------------------------------------- # Korrekturbuchungen # --------------------------------------------------------------------------- def fetch_corrnew_page( opener: urllib.request.OpenerDirector, base_url: str, sid: str, *, debug: bool = False, debug_dir: Path | None = None, ) -> bool: """Öffnet die 'Neue Buchung'-Seite (corrnew). Diese Seite wird über den Button 'Neue Buchung' auf der Fehlerliste aufgerufen. Sie bereitet das System auf eine neue Korrekturbuchung vor. URL: /user?page=corrnew&sid={sid}&bt=1 """ params = { "page": "corrnew", "sid": sid, "bt": "1", } url = base_url.rstrip("/") + "/user?" + urllib.parse.urlencode(params) if debug: print(f"[DEBUG] corrnew URL: {url}") status, final_url, response_text = request(opener, url) if debug: print(f"[DEBUG] corrnew response: HTTP {status} ({final_url})") preview = clean_html_text(response_text)[:500] print(f"[DEBUG] corrnew body preview: {preview or ''}") if debug_dir: _save_debug_response(debug_dir, "corrnew", response_text) if status >= 400: print( f"Neue Buchung konnte nicht geöffnet werden: HTTP {status}", file=sys.stderr, ) return False return True def submit_correction_booking( opener: urllib.request.OpenerDirector, base_url: str, sid: str, date_value: dt.date, time_value: str, art_value: str, *, comment: str = "Automatisch nachgetragen", debug: bool = False, debug_dir: Path | None = None, ) -> bool: """Sendet eine einzelne Korrekturbuchung – entspricht postbook(1) im Browser. Endpoint: /call/{sid}/mem/htm/corrnewdo Antwort: JSON {val: "ok"|"err", msg: "..."} """ params = { "datum": format_ezicom_date(date_value), "zeit": time_value, "art": art_value, "awn": "0", "msg": comment, } url = ( base_url.rstrip("/") + f"/call/{urllib.parse.quote(sid)}/mem/htm/corrnewdo?" + urllib.parse.urlencode(params) ) if debug: print(f"[DEBUG] corrnewdo URL: {url}") status, final_url, response_text = request(opener, url) if debug: print(f"[DEBUG] corrnewdo response: HTTP {status}") print(f"[DEBUG] corrnewdo body: {response_text.strip() or ''}") if debug_dir: label = f"corrnewdo-{time_value.replace(':', '')}" _save_debug_response(debug_dir, label, response_text) if status >= 400: return False # corrnewdo liefert JSON: {"val":"ok","msg":"..."} oder {"val":"err","msg":"..."} try: import json result = json.loads(response_text) if result.get("val") == "ok": return True else: print(f" ↳ Server meldet: {result.get('msg', 'Unbekannter Fehler')}", file=sys.stderr) return False except json.JSONDecodeError: # Fallback: HTML-Response wie bisher auswerten lower_resp = response_text.lower() if "fehler" in lower_resp or "error" in lower_resp: if debug: print(f"[DEBUG] Möglicher Fehler im Response: {response_text.strip()}") return False return True def submit_corrections_for_day( opener: urllib.request.OpenerDirector, base_url: str, sid: str, entry: FehlerlisteEntry, *, debug: bool = False, debug_dir: Path | None = None, ) -> bool: """Führt alle Korrekturbuchungen für einen Tag durch. Ablauf (entspricht dem Browser-Workflow): 1. corrnew – 'Neue Buchung'-Seite aufrufen (wie der Button) 2. corrnewdo – postbook(1) für jeden Eintrag (10:00 Kommen, 14:00 Gehen) """ date_value, _, reason = entry print(f"\n🔧 Korrektur für {format_ezicom_date(date_value)} ({reason}):") # Schritt 1: corrnew Seite aufrufen (entspricht dem Button "Neue Buchung") if not fetch_corrnew_page(opener, base_url, sid, debug=debug, debug_dir=debug_dir): return False # Schritt 2: Einzelne Buchungen senden (postbook(1) für jeden Eintrag) success = True for time_value, art_value in CORRECTION_BOOKINGS: art_name = "Kommen" if art_value == "1000" else "Gehen" if submit_correction_booking( opener, base_url, sid, date_value, time_value, art_value, debug=debug, debug_dir=debug_dir, ): print(f" ✅ {time_value} Uhr: {art_name} – gesendet") else: print(f" ❌ {time_value} Uhr: {art_name} – fehlgeschlagen", file=sys.stderr) success = False return success # --------------------------------------------------------------------------- # Interaktive Auswahl # --------------------------------------------------------------------------- def ask_for_correction_entries( entries: list[FehlerlisteEntry], ) -> list[FehlerlisteEntry]: """Fragt den Benutzer, welche Einträge korrigiert werden sollen. Unterstützt: - Einzelne Nummer (z.B. "3") - Mehrere Nummern durch Komma getrennt (z.B. "1,3,5") - Bereich mit Bindestrich (z.B. "1-4") - "a" oder "alle" für alle Einträge - Leere Eingabe zum Abbrechen """ if not entries: return [] print("\n📝 Auswahl:") print(" Nummer(n) eingeben (z.B. '1', '1,3,5', '1-4', 'a' für alle, Enter für Abbruch):") answer = input(" > ").strip() if not answer: return [] if answer.lower() in ("a", "alle", "all"): return list(entries) selected: list[FehlerlisteEntry] = [] parts = [p.strip() for p in answer.split(",")] for part in parts: if "-" in part: try: start_s, end_s = part.split("-", 1) start = int(start_s.strip()) end = int(end_s.strip()) for i in range(start, end + 1): idx = i - 1 if 0 <= idx < len(entries): if entries[idx] not in selected: selected.append(entries[idx]) except ValueError: print(f"Ungültiger Bereich: {part}", file=sys.stderr) else: try: idx = int(part) - 1 if 0 <= idx < len(entries): if entries[idx] not in selected: selected.append(entries[idx]) else: print(f"Nummer {part} ist nicht in der Liste.", file=sys.stderr) except ValueError: print(f"Ungültige Nummer: {part}", file=sys.stderr) return selected # --------------------------------------------------------------------------- # Hauptprogramm # --------------------------------------------------------------------------- def parse_args() -> argparse.Namespace: load_dotenv() parser = argparse.ArgumentParser( description="eZICOM5 Zeitbuchungs-Korrektur-Tool – trägt fehlende Buchungen nach.", ) parser.add_argument("--base-url", default=DEFAULT_BASE_URL) parser.add_argument("-u", "--username", default=os.getenv("EZICOM_USERNAME")) parser.add_argument("-p", "--password", default=os.getenv("EZICOM_PASSWORD")) parser.add_argument( "--debug", action="store_true", help="Debug-Ausgaben aktivieren (zeigt URLs und Responses).", ) return parser.parse_args() def main() -> int: args = parse_args() # Debug-Ordner vorbereiten debug_dir = Path("Research Data") if args.debug else None if debug_dir: debug_dir.mkdir(parents=True, exist_ok=True) # Credentials ermitteln username = args.username or input("eZICOM Benutzername: ") password = args.password or getpass.getpass("eZICOM Passwort: ") # Login print("🔐 Melde an...") result = login( args.base_url, username, password, insecure=True, debug=args.debug, ) if result is None: return 1 opener, sid = result print(f"✅ Login erfolgreich (Session: {mask_secret(sid)})") # Fehlerliste abrufen print("\n📋 Rufe Fehlerliste ab...") fl_status, fl_url, fl_html = fetch_fehlerliste(opener, args.base_url, sid) if fl_status >= 400: print(f"Fehlerliste konnte nicht abgerufen werden: HTTP {fl_status}", file=sys.stderr) return 2 if debug_dir: _save_debug_response(debug_dir, "fehlerliste", fl_html) entries = parse_fehlerliste(fl_html) recent = print_fehlerliste(entries) if not recent: print("\n✅ Keine korrigierbaren Einträge – alles in Ordnung!") return 0 # Interaktive Auswahl selected = ask_for_correction_entries(recent) if not selected: print("\n👋 Keine Einträge ausgewählt – beende.") return 0 # Korrekturbuchungen durchführen success_count = 0 fail_count = 0 for entry in selected: if submit_corrections_for_day( opener, args.base_url, sid, entry, debug=args.debug, debug_dir=debug_dir, ): success_count += 1 else: fail_count += 1 # Zusammenfassung print(f"\n{'─' * 40}") print(f"📊 Zusammenfassung:") print(f" Erfolgreich: {success_count} Tage") if fail_count: print(f" Fehlgeschlagen: {fail_count} Tage") print(f"{'─' * 40}") return 0 if fail_count == 0 else 2 if __name__ == "__main__": raise SystemExit(main())