Files
uulm-coronang-autojoin/main.py
2026-04-13 18:23:46 +02:00

394 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import time
import requests
from bs4 import BeautifulSoup
import re
import argparse
from datetime import datetime
from concurrent.futures import ThreadPoolExecutor, as_completed
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
parser = argparse.ArgumentParser(description="CoronaNG Autojoin Script")
parser.add_argument("--cvid", type=int, required=True, nargs='+', help="One or more cvid values")
parser.add_argument("--jsessionid", type=str, help="The JSESSIONID cookie value (re-login not supported)")
parser.add_argument("--user", type=str, help="Username for auto-login")
parser.add_argument("--pass", dest="password", type=str, help="Password for auto-login")
parser.add_argument("--interval", type=float, default=30.0,
help="Long refresh time in seconds after the active window has been running for a while (default: 30)")
parser.add_argument("--presend", action="store_true",
help="Start hammering registration requests 5s before the window opens (tiny interval). "
"Without this flag, wakes up 2s early to avoid missing the window.")
args = parser.parse_args()
if not args.jsessionid and not (args.user and args.password):
parser.error("Either --jsessionid or both --user and --pass must be provided.")
if args.jsessionid:
print("Warning: --jsessionid provided; automatic re-login on session expiry is not supported.")
# ---------------------------------------------------------------------------
# Shared state
# ---------------------------------------------------------------------------
base_headers = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "en-GB,en;q=0.5",
"Accept-Encoding": "gzip, deflate, br",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "same-origin",
"Sec-Fetch-User": "?1",
"Pragma": "no-cache",
"Cache-Control": "no-cache",
}
current_jsessionid = args.jsessionid
registered_cvids = set()
# Seconds before the window opens to wake up
PRE_START_WINDOW_PRESEND = 5 # --presend: wake at T-5, hammer T-3 to T at 1/s
PRE_START_WINDOW_NORMAL = 2 # default: wake at T-2, sleep precisely to T
# ---------------------------------------------------------------------------
# Auth helpers
# ---------------------------------------------------------------------------
def login(username, password):
login_url = "https://campusonline.uni-ulm.de/CoronaNG/index.html"
login_headers = {
**base_headers,
"Content-Type": "application/x-www-form-urlencoded",
"Origin": "https://campusonline.uni-ulm.de",
"Referer": login_url,
}
response = requests.post(
login_url,
headers=login_headers,
data={"uid": username, "password": password},
allow_redirects=False,
)
jsessionid = response.cookies.get("JSESSIONID")
if not jsessionid:
match = re.search(r"JSESSIONID=([A-Fa-f0-9]+)", response.headers.get("Set-Cookie", ""))
if match:
jsessionid = match.group(1)
return jsessionid
def is_logged_out(response):
return 'name="uid"' in response.text or 'name="password"' in response.text
def ensure_logged_in():
global current_jsessionid
if args.user and args.password:
print("Session expired — logging in again...")
jsessionid = login(args.user, args.password)
if jsessionid:
current_jsessionid = jsessionid
print("Re-login successful. Waiting 1s...")
time.sleep(1)
else:
print("Re-login failed: JSESSIONID not found in response.")
else:
print("Session appears expired and no credentials provided for re-login.")
# ---------------------------------------------------------------------------
# Parsing helpers
# ---------------------------------------------------------------------------
_DATE_RE = re.compile(r'(\d{2}\.\d{2}\.\d{4})\s+(\d{2}:\d{2})')
def _parse_date(td):
m = _DATE_RE.search(td.get_text())
if m:
return datetime.strptime(f"{m.group(1)} {m.group(2)}", "%d.%m.%Y %H:%M")
return None
def parse_anmeldezeit(soup):
"""Return (start_dt, end_dt) from the Anmeldezeiten table, or (None, None)."""
caption = soup.find("caption", string=re.compile(r"Anmeldezeiten"))
if not caption:
return None, None
table = caption.find_parent("table")
row = table.find("tr", class_="bgo")
if not row:
return None, None
tds = row.find_all("td")
if len(tds) < 2:
return None, None
return _parse_date(tds[0]), _parse_date(tds[1])
def get_participant_info(soup):
"""Return (current, max) participants, or (None, None)."""
for tr in soup.find_all("tr", class_="dbu"):
th = tr.find("th")
if th and "Max. Teilnehmer" in th.text:
td = tr.find("td")
match = re.search(r"(\d+) \(aktuell (\d+)\)", td.text.strip())
if match:
return int(match.group(2)), int(match.group(1)) # current, max
return None, None
def get_course_name(soup):
"""Return the course name from the Vorlesungsverzeichnis table, or None."""
caption = soup.find("caption", string=re.compile(r"Veranstaltung aus dem Vorlesungsverzeichnis"))
if not caption:
return None
table = caption.find_parent("table")
for tr in table.find_all("tr"):
th = tr.find("th")
if th and th.get_text(strip=True) == "Name":
td = tr.find("td")
if td:
return td.get_text(strip=True)
return None
def is_registered(soup):
"""Return True if already registered (person.gif appears twice — once for observer, once for participant)."""
return len(soup.find_all("img", src=lambda s: s and "person.gif" in s)) >= 2
# ---------------------------------------------------------------------------
# Registration
# ---------------------------------------------------------------------------
def registration_interval(elapsed_sec):
"""Sleep interval (seconds) based on elapsed time since the window opened."""
if elapsed_sec < 5:
return 0.5
elif elapsed_sec < 15: # 515 s
return 1.0
elif elapsed_sec < 45: # 1545 s
return 3.0
elif elapsed_sec < 105: # 45 s 1 m 45 s
return 5.0
else:
return args.interval
def try_register(cvid):
"""POST to register directly (no preceding GET). Returns True if confirmed via person.gif."""
post_url = "https://campusonline.uni-ulm.de/CoronaNG/user/userDetails.html"
referer = f"https://campusonline.uni-ulm.de/CoronaNG/user/userDetails.html?id={cvid}"
payload = f"id={cvid}&command=participate"
post_headers = {
**base_headers,
"Cookie": f"JSESSIONID={current_jsessionid}",
"Content-Type": "application/x-www-form-urlencoded",
"Content-Length": str(len(payload)),
"Origin": "https://campusonline.uni-ulm.de",
"Referer": referer,
}
try:
response = requests.post(post_url, headers=post_headers, data=payload)
if response.status_code != 200:
return False
soup = BeautifulSoup(response.content, "html.parser")
return is_registered(soup)
except requests.exceptions.RequestException as e:
print(f" POST error: {e}")
return False
# ---------------------------------------------------------------------------
# Initial login
# ---------------------------------------------------------------------------
if args.user and args.password:
print("Logging in...")
current_jsessionid = login(args.user, args.password)
if not current_jsessionid:
print("Initial login failed.")
exit(1)
print("Login successful. Waiting 1s...")
time.sleep(1)
# ---------------------------------------------------------------------------
# Parallel fetch
# ---------------------------------------------------------------------------
def fetch_course(cvid, jsessionid):
"""Fetch a single course page. Returns a result dict."""
url = f"https://campusonline.uni-ulm.de/CoronaNG/user/userDetails.html?id={cvid}"
headers = {**base_headers, "Cookie": f"JSESSIONID={jsessionid}", "Referer": url}
try:
response = requests.get(url, headers=headers)
if response.status_code == 403:
return {"cvid": cvid, "logged_out": True, "headers": headers}
response.raise_for_status()
fetched_at = datetime.now()
if is_logged_out(response):
return {"cvid": cvid, "logged_out": True, "headers": headers}
soup = BeautifulSoup(response.content, "html.parser")
error_span = soup.find("span", class_="Error")
if error_span:
print(f" cvid={cvid}: Server error — {error_span.get_text(strip=True)}")
return {"cvid": cvid, "error": error_span.get_text(strip=True)}
list_table = soup.find("div", class_="listTable")
if not list_table:
print(f" cvid={cvid}: WARNING — listTable not found, HTML parsing may have failed.")
print(soup.prettify())
start_dt, end_dt = parse_anmeldezeit(soup)
current_p, max_p = get_participant_info(soup)
registered = is_registered(soup)
name = get_course_name(soup)
return {
"cvid": cvid,
"logged_out": False,
"headers": headers,
"fetched_at": fetched_at,
"start_dt": start_dt,
"end_dt": end_dt,
"current_p": current_p,
"max_p": max_p,
"registered": registered,
"name": name,
}
except requests.exceptions.RequestException as e:
return {"cvid": cvid, "error": e}
# ---------------------------------------------------------------------------
# Main loop
# ---------------------------------------------------------------------------
while True:
iteration_start = datetime.now()
min_sleep = 300.0
logged_out_detected = False
to_register = [] # cvids with open spots
remaining_cvids = []
if len(registered_cvids) == len(args.cvid):
print("All courses are registered. Exiting.")
exit(0)
for cvid in args.cvid:
if cvid in registered_cvids:
print(f" cvid={cvid}: Already registered!")
else:
remaining_cvids.append(cvid)
# Fetch all course pages in parallel
with ThreadPoolExecutor(max_workers=len(remaining_cvids)) as executor:
futures = {executor.submit(fetch_course, cvid, current_jsessionid): cvid
for cvid in remaining_cvids}
results = [future.result() for future in as_completed(futures)]
for result in results:
cvid = result["cvid"]
if "error" in result:
print(f" cvid={cvid}: GET error: {result['error']}")
continue
if result["logged_out"]:
logged_out_detected = True
continue
if result["registered"]:
print(f" cvid={cvid}: Already registered!")
registered_cvids.add(cvid)
continue
now = result["fetched_at"]
start_dt = result["start_dt"]
end_dt = result["end_dt"]
current_p = result["current_p"]
max_p = result["max_p"]
headers = result["headers"]
name = result.get("name") or "?"
print(f" cvid={cvid}: {name}")
# ---- No Anmeldezeiten found --------------------------------------------
if start_dt is None:
print(f" cvid={cvid}: No Anmeldezeit found — polling every 5 min.")
continue
# ---- Registration window already closed --------------------------------
if now > end_dt:
print(f" cvid={cvid}: Anmeldezeit expired ({end_dt:%d.%m.%Y %H:%M}). Skipping.")
continue
secs_to_start = (start_dt - now).total_seconds()
pre_window = PRE_START_WINDOW_PRESEND if args.presend else PRE_START_WINDOW_NORMAL
# ---- Far from start: sleep until pre-window ----------------------------
if secs_to_start > pre_window:
sleep_needed = min(secs_to_start - pre_window, 300.0)
print(f" cvid={cvid}: Opens {start_dt:%d.%m.%Y %H:%M} "
f"(in {secs_to_start:.0f}s). Next check in {sleep_needed:.0f}s.")
min_sleep = min(min_sleep, sleep_needed)
continue
# ---- Pre-start window --------------------------------------------------
if secs_to_start > 0:
if args.presend and secs_to_start > 3.0:
# T-5 to T-3: sleep precisely until T-3, then start hammering
interval = secs_to_start - 3.0
print(f" cvid={cvid}: Pre-start ({secs_to_start:.1f}s to go) — sleeping {interval:.2f}s to T-3.")
elif args.presend:
# T-3 to T: hammer at 1 req/s
interval = 1.0
print(f" cvid={cvid}: Pre-start ({secs_to_start:.1f}s to go) — hammering at 1 req/s.")
else:
# T-2 to T: sleep precisely until T
interval = secs_to_start
print(f" cvid={cvid}: Pre-start ({secs_to_start:.2f}s to go) — sleeping to T.")
# ---- Active window: T to end_dt ----------------------------------------
else:
elapsed = -secs_to_start
interval = registration_interval(elapsed)
print(f" cvid={cvid}: Active (+{elapsed:.1f}s) — interval {interval}s.")
min_sleep = min(min_sleep, interval)
if current_p is not None and max_p is not None:
print(f" Participants: {current_p}/{max_p}")
if current_p < max_p:
to_register.append(cvid)
else:
print(f" No spots available.")
else:
print(f" Could not read participant info.")
# Attempt registration in parallel
if to_register:
with ThreadPoolExecutor(max_workers=len(to_register)) as executor:
futures = {executor.submit(try_register, cvid): cvid for cvid in to_register}
for future in as_completed(futures):
cvid = futures[future]
print(f" cvid={cvid}: Spot available! Registering...")
if future.result():
print(f"Successfully registered for cvid={cvid}!")
registered_cvids.add(cvid)
if len(registered_cvids) == len(args.cvid):
print("All courses are registered. Exiting.")
exit(0)
else:
print(f" cvid={cvid}: Registration attempt failed.")
if len(registered_cvids) == len(args.cvid):
print("All courses are registered. Exiting.")
exit(0)
if logged_out_detected:
ensure_logged_in()
min_sleep = 1.0
# Sleep until the computed wake-up time, accounting for time already spent this iteration
wake_at = iteration_start.timestamp() + min_sleep
sleep_sec = max(wake_at - time.time(), 0.05)
if sleep_sec >= 1.0:
print(f'[{time.strftime("%H:%M:%S")}] Sleeping {sleep_sec:.1f}s...')
time.sleep(sleep_sec)