diff --git a/.python-version b/.python-version
new file mode 100644
index 0000000..24ee5b1
--- /dev/null
+++ b/.python-version
@@ -0,0 +1 @@
+3.13
diff --git a/hello.py b/hello.py
new file mode 100644
index 0000000..c7c7935
--- /dev/null
+++ b/hello.py
@@ -0,0 +1,6 @@
+def main():
+ print("Hello from uulm-coronang-autojoin!")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/main.py b/main.py
index c86e890..23b79c0 100644
--- a/main.py
+++ b/main.py
@@ -3,115 +3,391 @@ import requests
from bs4 import BeautifulSoup
import re
import argparse
+from datetime import datetime
+from concurrent.futures import ThreadPoolExecutor, as_completed
+
+# ---------------------------------------------------------------------------
+# Argument parsing
+# ---------------------------------------------------------------------------
-# Create an ArgumentParser object
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.")
-# Add arguments for cvid and jsessionid
-parser.add_argument("--cvid", type=int, required=True, help="The cvid value")
-parser.add_argument("--jsessionid", type=str, required=True, help="The JSESSIONID cookie value")
-
-# Parse the command-line arguments
args = parser.parse_args()
-# URL to scrape
-url = f"https://campusonline.uni-ulm.de/CoronaNG/user/userDetails.html?cvid={args.cvid}"
+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.")
-# Cookie to include in the request
-cookie = f"JSESSIONID={args.jsessionid}"
+# ---------------------------------------------------------------------------
+# Shared state
+# ---------------------------------------------------------------------------
-# Headers for the GET request
-headers = {
+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",
- "Referer": url,
"Connection": "keep-alive",
- "Cookie": cookie,
"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"
+ "Cache-Control": "no-cache",
}
-def make_post_request(url, headers, payload, max_retries=3):
- retries = 0
- while retries < max_retries:
- try:
- response = requests.post(url, headers=headers, data=payload)
- response.raise_for_status() # Raise an exception for 4xx or 5xx status codes
- return response
- except requests.exceptions.RequestException as e:
- print(f"Error occurred during POST request: {str(e)}")
- retries += 1
- if retries < max_retries:
- print(f"Retrying in 5 seconds... (Attempt {retries+1}/{max_retries})")
- time.sleep(5)
- else:
- print("Max retries reached. Exiting.")
- exit(1)
+current_jsessionid = args.jsessionid
+registered_cvids = set()
-while True:
+# 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: # 5–15 s
+ return 1.0
+ elif elapsed_sec < 45: # 15–45 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:
- print("Scraping...")
- # Send GET request to the URL with the specified headers
- response = requests.get(url, headers=headers)
- response.raise_for_status() # Raise an exception for 4xx or 5xx status codes
+ 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
- # Parse the HTML content using BeautifulSoup
+# ---------------------------------------------------------------------------
+# 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")
- # Find the
element with class "dbu"
- tr_elements = soup.find_all("tr", class_="dbu")
+ 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)}
- for tr_element in tr_elements:
- th_element = tr_element.find("th")
- if th_element and "Max. Teilnehmer" in th_element.text:
- # Extract the max and current participant numbers
- td_element = tr_element.find("td")
- participant_info = td_element.text.strip()
-
- # regex to find the numbers in a string like "10 (aktuell 10)"
- regex = r"(\d+) \(aktuell (\d+)\)"
- match = re.search(regex, participant_info)
- if match:
- max_participants = int(match.group(1))
- current_participants = int(match.group(2))
- print("Max participants:", max_participants, "; Current participants:", current_participants)
- else:
- print("Failed to parse participant info:", participant_info)
- continue
-
- # Check if there is a free spot
- if current_participants < max_participants:
- # Send POST request to participate
- post_url = "https://campusonline.uni-ulm.de/CoronaNG/user/userDetails.html"
- payload = f"id={args.cvid}&command=participate"
- post_headers = headers.copy()
- post_headers["Content-Type"] = "application/x-www-form-urlencoded"
- post_headers["Content-Length"] = str(len(payload))
- post_headers["Origin"] = "https://campusonline.uni-ulm.de"
-
- post_response = make_post_request(post_url, post_headers, payload)
-
- if post_response.status_code == 200:
- print("Successfully participated!")
- exit(0)
- else:
- print("Failed to participate. Status code:", post_response.status_code)
- exit(1)
- else:
- print("No free spots available.")
- break
- else:
- print("Participant information not found.")
+ 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:
- print(f"Error occurred during GET request: {str(e)}")
+ return {"cvid": cvid, "error": e}
- print(f'Current Time: {time.strftime("%H:%M:%S")}. Sleeping for 30 seconds...')
- time.sleep(30)
+# ---------------------------------------------------------------------------
+# 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)
diff --git a/pyproject.toml b/pyproject.toml
index 9e0cde3..00e898c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,16 +1,10 @@
-[tool.poetry]
-name = "coronang-autojoin"
+[project]
+name = "uulm-coronang-autojoin"
version = "0.1.0"
-description = ""
-authors = ["Yandrik "]
+description = "Add your description here"
readme = "README.md"
-
-[tool.poetry.dependencies]
-python = "^3.12"
-bs4 = "^0.0.2"
-requests = "^2.31.0"
-
-
-[build-system]
-requires = ["poetry-core"]
-build-backend = "poetry.core.masonry.api"
+requires-python = ">=3.13"
+dependencies = [
+ "bs4>=0.0.2",
+ "requests>=2.33.1",
+]
diff --git a/pyproject.toml.old b/pyproject.toml.old
new file mode 100644
index 0000000..9e0cde3
--- /dev/null
+++ b/pyproject.toml.old
@@ -0,0 +1,16 @@
+[tool.poetry]
+name = "coronang-autojoin"
+version = "0.1.0"
+description = ""
+authors = ["Yandrik "]
+readme = "README.md"
+
+[tool.poetry.dependencies]
+python = "^3.12"
+bs4 = "^0.0.2"
+requests = "^2.31.0"
+
+
+[build-system]
+requires = ["poetry-core"]
+build-backend = "poetry.core.masonry.api"
diff --git a/run-docker.sh b/run-docker.sh
new file mode 100755
index 0000000..cec708b
--- /dev/null
+++ b/run-docker.sh
@@ -0,0 +1,34 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+IMAGE="ghcr.io/astral-sh/uv:debian"
+CONTAINER_NAME="coronang-autojoin"
+WORKDIR="/app"
+DOCKER_FLAGS=()
+SCRIPT_ARGS=()
+
+while (($#)); do
+ case "$1" in
+ --rm)
+ DOCKER_FLAGS+=(--rm)
+ ;;
+ -it)
+ DOCKER_FLAGS+=(-it)
+ ;;
+ *)
+ SCRIPT_ARGS+=("$1")
+ ;;
+ esac
+ shift
+done
+
+docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
+
+docker run "${DOCKER_FLAGS[@]}" \
+ --name "$CONTAINER_NAME" \
+ -e UV_PROJECT_ENVIRONMENT=/tmp/uv-project-env \
+ -v "$PWD":"$WORKDIR" \
+ -w "$WORKDIR" \
+ "$IMAGE" \
+ uv run main.py "${SCRIPT_ARGS[@]}"
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..43a6f9b
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,159 @@
+version = 1
+requires-python = ">=3.13"
+
+[[package]]
+name = "beautifulsoup4"
+version = "4.14.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "soupsieve" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721 },
+]
+
+[[package]]
+name = "bs4"
+version = "0.0.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "beautifulsoup4" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c9/aa/4acaf814ff901145da37332e05bb510452ebed97bc9602695059dd46ef39/bs4-0.0.2.tar.gz", hash = "sha256:a48685c58f50fe127722417bae83fe6badf500d54b55f7e39ffe43b798653925", size = 698 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/51/bb/bf7aab772a159614954d84aa832c129624ba6c32faa559dfb200a534e50b/bs4-0.0.2-py2.py3-none-any.whl", hash = "sha256:abf8742c0805ef7f662dce4b51cca104cffe52b835238afc169142ab9b3fbccc", size = 1189 },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.2.25"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684 },
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.4.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627 },
+ { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008 },
+ { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303 },
+ { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282 },
+ { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595 },
+ { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986 },
+ { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711 },
+ { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036 },
+ { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998 },
+ { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056 },
+ { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537 },
+ { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176 },
+ { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723 },
+ { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085 },
+ { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819 },
+ { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915 },
+ { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234 },
+ { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042 },
+ { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706 },
+ { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727 },
+ { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882 },
+ { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860 },
+ { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564 },
+ { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276 },
+ { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238 },
+ { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189 },
+ { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352 },
+ { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024 },
+ { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869 },
+ { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541 },
+ { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634 },
+ { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384 },
+ { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133 },
+ { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257 },
+ { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851 },
+ { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393 },
+ { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251 },
+ { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609 },
+ { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014 },
+ { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979 },
+ { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238 },
+ { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110 },
+ { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824 },
+ { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103 },
+ { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194 },
+ { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827 },
+ { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168 },
+ { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018 },
+ { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958 },
+]
+
+[[package]]
+name = "idna"
+version = "3.11"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008 },
+]
+
+[[package]]
+name = "requests"
+version = "2.33.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947 },
+]
+
+[[package]]
+name = "soupsieve"
+version = "2.8.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016 },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 },
+]
+
+[[package]]
+name = "urllib3"
+version = "2.6.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584 },
+]
+
+[[package]]
+name = "uulm-coronang-autojoin"
+version = "0.1.0"
+source = { virtual = "." }
+dependencies = [
+ { name = "bs4" },
+ { name = "requests" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "bs4", specifier = ">=0.0.2" },
+ { name = "requests", specifier = ">=2.33.1" },
+]