#!/usr/bin/env python3 """Small eZICOM5 login helper. Credentials are read from environment variables, command line arguments, or interactive prompts. They are never written to disk by this script. """ 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 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" ) BOOKING_TYPES = { "kommen": {"art": "1000", "status": "0", "tp": "0", "doppel": "0"}, "pause": {"art": "0", "status": "1751", "tp": "0", "doppel": "0"}, "gehen": {"art": "2000", "status": "0", "tp": "0", "doppel": "0"}, } MAX_CORRECTION_AGE_DAYS = 14 CORRECTION_BOOKINGS = (("10:00", "1000"), ("14:00", "2000")) def clean_html_text(value: str) -> str: value = re.sub(r"<[^>]+>", " ", value) value = html.unescape(value).replace("\xa0", " ") return " ".join(value.split()) def load_dotenv(path: Path = Path(".env")) -> None: 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: 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]: 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: str, pattern: str) -> str | None: match = re.search(pattern, html) if not match: return None return match.group(1) def follow_next_page( opener: urllib.request.OpenerDirector, base_url: str, html: str, ) -> tuple[int, str, str] | None: cust_info_url = find_single_quoted_js_value( html, r"url:\s*'([^']+/isCustInfoPnl)'", ) build_info_url = find_single_quoted_js_value( html, r"\$\.get\('([^']+/buildCustInfoPnl)'", ) frame_url = find_single_quoted_js_value( html, 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: str, url: str) -> str | None: sid = find_single_quoted_js_value( html, 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 def mask_secret(value: str) -> str: if len(value) <= 8: return "***" return value[:4] + "..." + value[-4:] def build_booking_payload(sid: str, booking_type: str) -> dict[str, str]: now = dt.datetime.now() payload = { "page": "buch", "sid": sid, "kstbuch": "", "datum": now.strftime("%d.%m.%Y"), "zeit": now.strftime("%H:%M:%S"), } payload.update(BOOKING_TYPES[booking_type]) return payload def submit_booking( opener: urllib.request.OpenerDirector, base_url: str, payload: dict[str, str], ) -> tuple[int, str, str]: return request( opener, base_url.rstrip("/") + "/user", method="POST", data=payload, headers={ "Origin": base_url.rstrip("/"), "Referer": base_url.rstrip("/") + f"/user/{payload['sid']}/rahmen.html", }, ) def fetch_fehlerliste( opener: urllib.request.OpenerDirector, base_url: str, sid: str, ) -> tuple[int, str, str]: 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)) FehlerlisteEntry = tuple[dt.date, str, str] 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") def parse_fehlerliste(html_text: str) -> list[FehlerlisteEntry]: 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]]: 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: 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]: if not entries: print("Fehlerliste: keine fehlerhaften Tage gefunden.") return [] print(f"Fehlerliste: {len(entries)} fehlerhafte Tage") recent, too_old = split_fehlerliste_entries(entries) if recent: print("Buchbar (juenger als 15 Tage):") for number, entry in enumerate(recent, start=1): print(f" {number}) {format_fehlerliste_entry(entry)}") else: print("Buchbar (juenger als 15 Tage): keine Eintraege") if too_old: print("--- Aelter als 14 Tage, keine Aenderung mehr moeglich ---") for entry in too_old: print(f" {format_fehlerliste_entry(entry)}") return recent def ask_for_correction_entry(entries: list[FehlerlisteEntry]) -> FehlerlisteEntry | None: if not entries: return None answer = input("Nummer fuer automatische Buchung eingeben, sonst Enter: ").strip() if not answer: return None if not answer.isdecimal(): print("Keine gueltige Nummer eingegeben.", file=sys.stderr) return None index = int(answer) - 1 if index < 0 or index >= len(entries): print("Nummer ist nicht in der Liste.", file=sys.stderr) return None return entries[index] def submit_correction_booking( opener: urllib.request.OpenerDirector, base_url: str, sid: str, date_value: dt.date, time_value: str, art_value: str, ) -> tuple[int, str, str]: params = { "lnr": "0", "typ": "mem", "xplnr": "0", "date": format_ezicom_date(date_value), "time": time_value, "art": art_value, "awn": "", "comm": "Automatisch nachgetragen", } url = ( base_url.rstrip("/") + f"/call/{urllib.parse.quote(sid)}/mem/js/corrbuchdo?" + urllib.parse.urlencode(params) ) print(f" Debug request URL: {url}") status, final_url, response_text = request(opener, url) print(f" Debug response: HTTP {status} ({final_url})") print(f" Debug response body: {response_text.strip() or ''}") return status, final_url, response_text def init_correction_form( opener: urllib.request.OpenerDirector, base_url: str, sid: str, ) -> tuple[int, str, str]: params = { "lnr": "0", "bantr": "0", "xplnr": "0", "typ": "mem", } url = ( base_url.rstrip("/") + f"/call/{urllib.parse.quote(sid)}/mem/js/corrbuch?" + urllib.parse.urlencode(params) ) print(f"Debug corrbuch request URL: {url}") status, final_url, response_text = request(opener, url) print(f"Debug corrbuch response: HTTP {status} ({final_url})") preview = clean_html_text(response_text)[:500] print(f"Debug corrbuch response body preview: {preview or ''}") return status, final_url, response_text def fetch_new_correction_page( opener: urllib.request.OpenerDirector, base_url: str, sid: str, ) -> tuple[int, str, str]: params = { "page": "corrnew", "sid": sid, "bt": "1", } url = base_url.rstrip("/") + "/user?" + urllib.parse.urlencode(params) print(f"Debug corrnew request URL: {url}") status, final_url, response_text = request(opener, url) print(f"Debug corrnew response: HTTP {status} ({final_url})") preview = clean_html_text(response_text)[:500] print(f"Debug corrnew response body preview: {preview or ''}") return status, final_url, response_text def submit_corrections_for_day( opener: urllib.request.OpenerDirector, base_url: str, sid: str, entry: FehlerlisteEntry, ) -> bool: date_value, _, _ = entry print(f"Buchungen fuer {format_ezicom_date(date_value)} werden gesendet:") init_status, init_url, _ = init_correction_form(opener, base_url, sid) if init_status >= 400: print( f"Konnte Buchungsformular nicht initialisieren: HTTP {init_status} ({init_url})", file=sys.stderr, ) return False corrnew_status, corrnew_url, _ = fetch_new_correction_page(opener, base_url, sid) if corrnew_status >= 400: print( f"Neue Buchung konnte nicht geoeffnet werden: HTTP {corrnew_status} ({corrnew_url})", file=sys.stderr, ) return False for time_value, art_value in CORRECTION_BOOKINGS: status, url, _ = submit_correction_booking( opener, base_url, sid, date_value, time_value, art_value, ) if status >= 400: print(f" {time_value}: fehlgeschlagen HTTP {status} ({url})", file=sys.stderr) return False print(f" {time_value}: gesendet") return True def login( base_url: str, username: str, password: str, *, insecure: bool, save_response: Path | None, save_next_response: Path | None, booking_type: str | None, execute_booking: bool, save_booking_response: Path | None, ) -> int: base_url = base_url.rstrip("/") opener = make_opener(insecure) try: initial_status, _, _ = request(opener, base_url + "/") except urllib.error.URLError as error: print(f"Could not open initial page: {error}", file=sys.stderr) if "CERTIFICATE_VERIFY_FAILED" in str(error): print("Hint: retry with --insecure or install the issuing CA.", file=sys.stderr) return 2 if initial_status >= 400: print(f"Initial page returned HTTP {initial_status}", file=sys.stderr) return 2 try: status, final_url, html = 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"Could not submit login form: {error}", file=sys.stderr) if "CERTIFICATE_VERIFY_FAILED" in str(error): print("Hint: retry with --insecure or install the issuing CA.", file=sys.stderr) return 2 if save_response: save_response.write_text(html, encoding="utf-8") lower_html = html.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 failed: HTTP {status} ({final_url})", file=sys.stderr) return 1 print(f"Login looks successful: HTTP {status} ({final_url})") if save_response: print(f"Saved response HTML to {save_response}") next_response = follow_next_page(opener, base_url, html) if not next_response: print("No follow-up page URL found in login response.", file=sys.stderr) return 2 next_status, next_url, next_html = next_response if next_status >= 400: print(f"Follow-up page failed: HTTP {next_status} ({next_url})", file=sys.stderr) return 2 print(f"Follow-up page loaded: HTTP {next_status} ({next_url})") if save_next_response: save_next_response.write_text(next_html, encoding="utf-8") print(f"Saved follow-up HTML to {save_next_response}") sid = extract_sid(next_html, next_url) if not sid: print("No session id found in follow-up page.", file=sys.stderr) return 2 fehlerliste_status, fehlerliste_url, fehlerliste_html = fetch_fehlerliste( opener, base_url, sid, ) if fehlerliste_status >= 400: print( f"Fehlerliste failed: HTTP {fehlerliste_status} ({fehlerliste_url})", file=sys.stderr, ) return 2 recent_entries = print_fehlerliste(parse_fehlerliste(fehlerliste_html)) correction_entry = ask_for_correction_entry(recent_entries) if correction_entry and not submit_corrections_for_day( opener, base_url, sid, correction_entry, ): return 2 if booking_type: payload = build_booking_payload(sid, booking_type) printable_payload = dict(payload) printable_payload["sid"] = mask_secret(printable_payload["sid"]) if not execute_booking: print(f"Dry run: would submit {booking_type!r} booking with:") for key, value in printable_payload.items(): print(f" {key}={value}") print("Add --execute-booking to actually send this booking.") return 0 booking_status, booking_url, booking_html = submit_booking( opener, base_url, payload, ) if save_booking_response: save_booking_response.write_text(booking_html, encoding="utf-8") print(f"Saved booking response HTML to {save_booking_response}") if booking_status >= 400: print( f"Booking failed: HTTP {booking_status} ({booking_url})", file=sys.stderr, ) return 2 print(f"Booking submitted: {booking_type} HTTP {booking_status} ({booking_url})") return 0 def parse_args() -> argparse.Namespace: load_dotenv() parser = argparse.ArgumentParser( description="Log in to the SUFW eZICOM5 web UI." ) 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( "--insecure", action="store_true", default=True, help="Disable TLS certificate verification. This is the default for this eZICOM server.", ) parser.add_argument( "--verify-tls", action="store_false", dest="insecure", help="Enable TLS certificate verification.", ) parser.add_argument( "--save-response", type=Path, help="Optional path for saving the post-login HTML response for inspection.", ) parser.add_argument( "--save-next-response", type=Path, help="Optional path for saving the HTML after following the Weiter step.", ) parser.add_argument( "--book", choices=sorted(BOOKING_TYPES), help="Prepare a time booking. Defaults to dry-run unless --execute-booking is set.", ) parser.add_argument( "--execute-booking", action="store_true", help="Actually submit the booking selected with --book.", ) parser.add_argument( "--save-booking-response", type=Path, help="Optional path for saving the HTML response after a submitted booking.", ) return parser.parse_args() def main() -> int: args = parse_args() username = args.username or input("eZICOM username: ") password = args.password or getpass.getpass("eZICOM password: ") return login( args.base_url, username, password, insecure=args.insecure, save_response=args.save_response, save_next_response=args.save_next_response, booking_type=args.book, execute_booking=args.execute_booking, save_booking_response=args.save_booking_response, ) if __name__ == "__main__": raise SystemExit(main())