From 18cc9b6bab5bbd951c295df53c3c8ae3f7552d1c Mon Sep 17 00:00:00 2001 From: Zhe Yuan Date: Fri, 24 Apr 2026 10:44:28 -0400 Subject: [PATCH] Rewrite as Windows Wi-Fi monitor service --- .gitignore | 28 ++ README.md | 275 ++++++++++++++++--- config.example.yaml | 55 ++++ main.py | 247 +---------------- osu_wifi_login/__init__.py | 1 + osu_wifi_login/cli.py | 47 ++++ osu_wifi_login/config.py | 389 +++++++++++++++++++++++++++ osu_wifi_login/portal.py | 537 +++++++++++++++++++++++++++++++++++++ osu_wifi_login/service.py | 251 +++++++++++++++++ osu_wifi_login/windows.py | 412 ++++++++++++++++++++++++++++ pyproject.toml | 7 +- run.bat | 6 +- uv.lock | 440 +++++++++++++----------------- winsw/README.md | 17 ++ winsw/osu-wifi-login.xml | 17 ++ 15 files changed, 2190 insertions(+), 539 deletions(-) create mode 100644 .gitignore create mode 100644 config.example.yaml create mode 100644 osu_wifi_login/__init__.py create mode 100644 osu_wifi_login/cli.py create mode 100644 osu_wifi_login/config.py create mode 100644 osu_wifi_login/portal.py create mode 100644 osu_wifi_login/service.py create mode 100644 osu_wifi_login/windows.py create mode 100644 winsw/README.md create mode 100644 winsw/osu-wifi-login.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d1e6bd --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.Python +.venv/ +venv/ +env/ + +# uv +.uv-cache/ + +# Local configuration +config.yaml + +# PyInstaller +build/ +dist/ +*.spec + +# Logs and service output +*.log +logs/ + +# Windows +Thumbs.db +Desktop.ini diff --git a/README.md b/README.md index 6f4fb4a..e37d959 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,263 @@ -# OSU Public Wi-Fi Auto Login +# OSU Public Wi-Fi Login -自动登录 OSU(Ohio State University)公共 Wi-Fi 的脚本。通过 Selenium 驱动 Edge 浏览器,自动完成 Captive Portal 的条款确认和登录操作。 +适用于 Windows 11 的 OSU Wi-Fi 后台守护程序。它会持续监控无线网络状态,并按配置自动修复连接。 -## 工作原理 +这个重写版本选择了 Python,而不是 PowerShell 或 Rust,原因是: -1. 检测当前网络是否已连接(通过 HTTP 204 / connecttest 验证) -2. 若未连接,使用 headless Edge 浏览器访问 Captive Portal 触发 URL(自动尝试多个备选地址) -3. 自动勾选 "I accept the terms of use" 并点击 "Log In" -4. 登录后轮询验证网络是否真正连通,失败则自动重试(最多 3 次) +- `WiFi@OSU` Captive Portal 可以直接用 HTTP 表单提交流程处理,不依赖浏览器驱动 +- 如果 portal 页面结构比较复杂,程序会自动回退到 Playwright 驱动已安装的 Microsoft Edge +- Windows 下调用 `netsh`、修改注册表、接 WinSW 都很直接 +- 后续想打包成单文件 `exe` 也比较方便 + +## 功能 + +- 后台常驻运行,而不是一次性触发 +- 可在 `WiFi@OSU` 和 `eduroam` 之间切换 +- 支持 YAML 配置网络类型和检查/重试时间 +- `WiFi@OSU` 模式下: + - 启动时如果 Wi-Fi 还没连上,会主动尝试连接目标网络 + - 可选启用随机 MAC + - 可按周期刷新 MAC 并重连 + - 自动打开 Captive Portal 并完成条款确认/登录 +- `eduroam` 模式下: + - 启动时如果 Wi-Fi 还没连上,会主动尝试连接目标网络 + - 使用系统已保存的凭据 + - 可恢复为硬件 MAC + - 仅做断线检测和自动重连 +- 支持 WinSW 包装为 Windows 服务 + +## 项目结构 + +```text +main.py CLI 入口 +osu_wifi_login/config.py YAML 配置加载 +osu_wifi_login/windows.py netsh / 注册表 / 连通性检测 +osu_wifi_login/portal.py HTTP Portal 登录 +osu_wifi_login/service.py 常驻监控与恢复逻辑 +config.example.yaml 配置示例 +winsw/ WinSW 服务模板 +``` ## 环境要求 -- Python >= 3.14 -- Microsoft Edge 浏览器 -- [uv](https://docs.astral.sh/uv/) 包管理器 +- Windows 11 x86-64 +- Python 3.11+ +- Microsoft Edge +- [uv](https://docs.astral.sh/uv/) ## 安装 -```bash +```powershell uv sync ``` -## 使用 +仓库已经附带默认的 `config.yaml`,直接编辑即可;如果想恢复模板,也可以参考 `config.example.yaml`。 +如果 `WiFi@OSU` portal 页面较复杂,运行时会使用 Playwright 调用本机已安装的 Edge,所以第一次改完依赖后需要先完成一次 `uv sync`。 -```bash -uv run main.py +## 配置 + +默认配置文件是根目录下的 `config.yaml`。 + +最常用的项如下: + +```yaml +selected_network: wifi_osu + +wifi_interface_name: Wi-Fi +allow_unsafe_mac_changes: false +randomize_mac_on_start: false + +networks: + wifi_osu: + ssid: WiFi@OSU + profile_name: WiFi@OSU + requires_portal_login: true + randomize_mac: false + restore_hardware_mac: false + auto_create_open_profile: true + mac_refresh_hours: 6 + + eduroam: + ssid: eduroam + profile_name: eduroam + requires_portal_login: false + randomize_mac: false + restore_hardware_mac: true + auto_create_open_profile: false + +connectivity_checks: + - url: http://example.com/ + expected_status: 200 + expected_text: Example Domain + require_final_url_match: true + allow_redirects: false ``` -### 可选参数 +说明: -| 参数 | 说明 | -|------|------| -| `--random-mac` | 登录前随机化 Wi-Fi 网卡的 MAC 地址(需要**管理员权限**) | +- `selected_network` 设为 `wifi_osu` 或 `eduroam` +- `wifi_interface_name` 一般是 `Wi-Fi`,如果你的系统接口名不同,需要改成实际值 +- `profile_name` 是 Windows 已保存的 WLAN 配置名,通常与 SSID 相同 +- `auto_create_open_profile` 适合 `WiFi@OSU` 这类开放网络;如果本机还没有保存过 profile,程序会先自动创建一个基础 profile 再连接 +- `connectivity_checks` 默认使用像 `example.com` 这样的普通页面,不再使用 Windows/Apple/Firefox 的系统探针地址,避免被 portal 白名单误判成“已联网” +- `allow_unsafe_mac_changes` 默认关闭;关闭时不会写注册表改 `NetworkAddress` +- `randomize_mac` 和 `restore_hardware_mac` 都只有在 `allow_unsafe_mac_changes: true` 时才会生效 +- 某些无线网卡驱动在修改 MAC 时会直接蓝屏,建议默认保持关闭 +- `connect_retry_cooldown_seconds` 控制“Wi-Fi 完全没连上时”主动重连的频率 +- `disconnect_grace_seconds` 表示断线多久后开始强制恢复 +- `connection_timeout_seconds` 会用于登录后等待网络放行,默认给 captive portal 留 60 秒 +- `mac_refresh_hours` 只对 `WiFi@OSU` 生效 -示例: +## 运行 -```bash -# 普通登录 -uv run main.py +前台调试运行: -# 随机 MAC 地址后登录 -uv run main.py --random-mac +```powershell +.\.venv\Scripts\python.exe main.py --config config.yaml ``` -> **注意**:`--random-mac` 会通过修改注册表并重启网卡来更改 MAC 地址,需要以管理员身份运行。 +只跑一次检查: -## Windows 开机自启 - -1. 按 `Win + R`,输入 `shell:startup` 打开启动文件夹 -2. 将 `run.bat` 的快捷方式放入该文件夹 -3. 修改 `run.bat` 中的路径为你的实际项目路径 - -## 打包为可执行文件 - -```bash -uv run pyinstaller --onefile main.py +```powershell +.\.venv\Scripts\python.exe main.py --config config.yaml --once ``` -生成的 `main.exe` 位于 `dist/` 目录下。 +## 服务逻辑 + +程序每轮会做这些事: + +1. 检查当前是否连在目标 SSID 上 +2. 如果 Wi-Fi 完全没连上,先直接尝试连接目标网络 +3. 如果是 `WiFi@OSU`,刚连上后会主动探测 captive portal;检测到登录页就立刻登录 +4. 检查是否真的能上网 +5. 如果断线或异常持续超过阈值,则重启 Wi-Fi 网卡并重新连接 +6. 如果你显式开启了高风险 MAC 改写,才会在启动时和刷新周期到达时改 MAC 并重连 + +## 管理员权限 + +以下操作需要管理员权限: + +- 修改 `NetworkAddress` 注册表项 +- 为 `WiFi@OSU` 随机化 MAC +- 清除随机 MAC,恢复硬件 MAC +- 重启无线网卡 + +其中 MAC 相关操作在部分驱动上可能导致蓝屏;现在默认关闭,只有在你明确打开 `allow_unsafe_mac_changes: true` 后才会执行。 + +## 编译发布 + +推荐使用 PyInstaller 的 `onedir` 模式发布。这个模式会生成一个完整目录,里面包含 `exe`、Python 运行时、第三方依赖和 Playwright 驱动文件;目标机器仍需要安装 Microsoft Edge,但不需要安装 Python、uv 或项目依赖。 + +```powershell +uv sync +uv run pyinstaller --noconfirm --clean --onedir --name osu-wifi-login main.py +``` + +生成结果位于: + +```text +dist\osu-wifi-login\osu-wifi-login.exe +``` + +把 `config.yaml` 放到同一个发布目录中: + +```powershell +Copy-Item config.yaml dist\osu-wifi-login\config.yaml +``` + +发布目录最终至少应包含: + +```text +dist\osu-wifi-login\ + osu-wifi-login.exe + config.yaml + _internal\ +``` + +前台验证: + +```powershell +.\dist\osu-wifi-login\osu-wifi-login.exe --config .\dist\osu-wifi-login\config.yaml --once +``` + +也可以生成真正的单文件 `exe`: + +```powershell +uv run pyinstaller --noconfirm --clean --onefile --name osu-wifi-login main.py +``` + +不过不建议优先使用 `onefile`。Playwright 会在运行时解包和查找驱动文件,`onedir` 更稳定;`onefile` 虽然只有一个 `exe`,但启动更慢,也更容易遇到服务账户临时目录权限问题。无论使用哪种模式,Microsoft Edge 仍需要由系统提供,因为程序使用的是已安装 Edge 渠道。 + +## WinSW 服务 + +仓库中已经提供: + +- [winsw/osu-wifi-login.xml](/C:/Users/yuanzhe/src/OSU-Public-Wi-Fi-Login/winsw/osu-wifi-login.xml) +- [winsw/README.md](/C:/Users/yuanzhe/src/OSU-Public-Wi-Fi-Login/winsw/README.md) + +### 使用编译后的 exe + +1. 下载 WinSW x64 可执行文件 +2. 将 WinSW 重命名为 `osu-wifi-login-service.exe` +3. 将 `osu-wifi-login-service.exe` 放入 `dist\osu-wifi-login\` +4. 在同一目录创建 `osu-wifi-login-service.xml` + +`dist\osu-wifi-login\osu-wifi-login-service.xml` 示例: + +```xml + + osu-wifi-login + OSU Wi-Fi Login Service + Keeps WiFi@OSU or eduroam connected and logs into the captive portal when needed. + + %BASE%\osu-wifi-login.exe + --config "%BASE%\config.yaml" + %BASE% + + + 1048576 + 8 + + + + 30 sec + +``` + +以管理员 PowerShell 安装并启动: + +```powershell +Set-Location .\dist\osu-wifi-login +.\osu-wifi-login-service.exe install +.\osu-wifi-login-service.exe start +``` + +查看状态、停止、卸载: + +```powershell +.\osu-wifi-login-service.exe status +.\osu-wifi-login-service.exe stop +.\osu-wifi-login-service.exe uninstall +``` + +### 服务账户建议 + +默认不写 `` 时,WinSW 通常使用 `LocalSystem`。这个程序需要能访问 WLAN profile、启动 Edge 自动化并执行 `netsh`,更推荐使用你自己的 Windows 用户运行服务。 + +如果要指定服务账户,在 XML 中加入: + +```xml + + . + YOUR_WINDOWS_ACCOUNT + YOUR_PASSWORD + true + +``` + +如果你保持 `allow_unsafe_mac_changes: false`,通常不需要管理员权限来修改 MAC;但重启网卡和某些 `netsh` 操作仍可能需要提升权限。若服务日志提示权限不足,再改用管理员账户运行服务。 + +## 当前注意事项 + +- `WiFi@OSU` 的 Captive Portal 表单字段仍然基于当前已知页面结构;如果学校改版,可能需要微调 YAML 或代码 +- 现在默认不依赖 `msedgedriver`;复杂页面会自动回退到 Playwright + 已安装 Edge diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..2085ae3 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,55 @@ +selected_network: wifi_osu + +wifi_interface_name: Wi-Fi +allow_unsafe_mac_changes: false +randomize_mac_on_start: false + +networks: + wifi_osu: + ssid: WiFi@OSU + profile_name: WiFi@OSU + requires_portal_login: true + randomize_mac: false + restore_hardware_mac: false + auto_create_open_profile: true + mac_refresh_hours: 6 + + eduroam: + ssid: eduroam + profile_name: eduroam + requires_portal_login: false + randomize_mac: false + restore_hardware_mac: true + auto_create_open_profile: false + +connectivity_checks: + - url: http://example.com/ + expected_status: 200 + expected_text: Example Domain + require_final_url_match: true + allow_redirects: false + +monitor: + check_interval_seconds: 15 + connect_retry_cooldown_seconds: 20 + disconnect_grace_seconds: 45 + adapter_reset_cooldown_seconds: 120 + reconnect_wait_seconds: 12 + connection_timeout_seconds: 60 + +selenium: + headless: true + page_load_timeout_seconds: 20 + element_timeout_seconds: 12 + max_login_retries: 3 + +portal: + trigger_urls: + - http://captive.apple.com/ + - http://www.msftconnecttest.com/redirect + - http://detectportal.firefox.com/ + accept_terms_name: visitor_accept_terms + login_button_xpath: //input[@type='submit' and @value='Log In'] + +logging: + level: INFO diff --git a/main.py b/main.py index 5852ae0..3dd9480 100644 --- a/main.py +++ b/main.py @@ -1,248 +1,5 @@ -import argparse -import random -import subprocess -import sys -import time - -import requests -import winreg -from selenium import webdriver -from selenium.webdriver.common.by import By -from selenium.webdriver.edge.options import Options -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import WebDriverWait - -# Captive Portal 触发 URL 列表,按优先级依次尝试 -CAPTIVE_PORTAL_URLS = [ - "http://captive.apple.com/", - "http://www.msftconnecttest.com/redirect", - "http://detectportal.firefox.com/", -] - -MAX_LOGIN_RETRIES = 3 - - -def is_connected(): - """通过 HTTP 204 检测网络是否连通。""" - test_urls = [ - ("http://clients3.google.com/generate_204", 204), - ("http://www.msftconnecttest.com/connecttest.txt", 200), - ] - for url, expected_status in test_urls: - try: - r = requests.get(url, timeout=3) - if r.status_code == expected_status: - return True - except Exception: - continue - return False - - -def get_wifi_adapter_name(): - """获取当前活跃的 Wi-Fi 网卡名称。""" - result = subprocess.run( - ["netsh", "wlan", "show", "interfaces"], - capture_output=True, text=True, encoding="gbk", - ) - for line in result.stdout.splitlines(): - line = line.strip() - if line.startswith("名称") or line.startswith("Name"): - return line.split(":", 1)[1].strip() - return None - - -def generate_random_mac(): - """生成一个随机的本地管理单播 MAC 地址。""" - mac = [random.randint(0x00, 0xFF) for _ in range(6)] - mac[0] = (mac[0] & 0xFC) | 0x02 # 本地管理、单播 - return mac - - -def find_adapter_registry_key(adapter_name): - """在注册表中找到匹配 adapter_name 的网卡子键路径。""" - base_path = r"SYSTEM\CurrentControlSet\Control\Class\{4d36e972-e325-11ce-bfc1-08002be10318}" - try: - base_key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, base_path) - except OSError: - return None - - index = 0 - while True: - try: - subkey_name = winreg.EnumKey(base_key, index) - index += 1 - except OSError: - break - try: - subkey = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, f"{base_path}\\{subkey_name}") - driver_desc, _ = winreg.QueryValueEx(subkey, "DriverDesc") - if adapter_name.lower() in driver_desc.lower(): - winreg.CloseKey(subkey) - winreg.CloseKey(base_key) - return f"{base_path}\\{subkey_name}" - winreg.CloseKey(subkey) - except OSError: - continue - - winreg.CloseKey(base_key) - return None - - -def randomize_mac(): - """随机化当前 Wi-Fi 网卡的 MAC 地址(需要管理员权限)。""" - adapter_name = get_wifi_adapter_name() - if not adapter_name: - print("错误:未找到活跃的 Wi-Fi 适配器。") - return False - - print(f"找到 Wi-Fi 适配器: {adapter_name}") - - reg_path = find_adapter_registry_key(adapter_name) - if not reg_path: - print(f"错误:未在注册表中找到适配器 '{adapter_name}' 的条目。") - return False - - mac = generate_random_mac() - mac_str = "".join(f"{b:02X}" for b in mac) - mac_display = ":".join(f"{b:02X}" for b in mac) - - try: - key = winreg.OpenKey( - winreg.HKEY_LOCAL_MACHINE, reg_path, 0, winreg.KEY_SET_VALUE, - ) - winreg.SetValueEx(key, "NetworkAddress", 0, winreg.REG_SZ, mac_str) - winreg.CloseKey(key) - except PermissionError: - print("错误:需要管理员权限来修改 MAC 地址。请以管理员身份运行。") - return False - - print(f"正在将 MAC 地址更改为: {mac_display}") - - # 获取网络适配器的接口名称(用于 netsh) - interface_name = get_netsh_interface_name() - if not interface_name: - interface_name = adapter_name - - print("正在重启网卡以应用新 MAC 地址...") - subprocess.run( - ["netsh", "interface", "set", "interface", interface_name, "disable"], - capture_output=True, - ) - time.sleep(2) - subprocess.run( - ["netsh", "interface", "set", "interface", interface_name, "enable"], - capture_output=True, - ) - time.sleep(3) - print(f"MAC 地址已更改为: {mac_display}") - return True - - -def get_netsh_interface_name(): - """获取 Wi-Fi 网络接口的名称(用于 netsh interface 命令)。""" - result = subprocess.run( - ["netsh", "interface", "show", "interface"], - capture_output=True, text=True, encoding="gbk", - ) - for line in result.stdout.splitlines(): - if "Wi-Fi" in line or "WLAN" in line or "Wireless" in line: - parts = line.split() - if len(parts) >= 4: - return " ".join(parts[3:]) - return None - - -def auto_login_wifi(): - """自动登录 OSU 公共 Wi-Fi,支持重试。""" - edge_options = Options() - edge_options.add_argument("--start-maximized") - edge_options.add_argument("--ignore-certificate-errors") - edge_options.add_argument("--headless") - - for attempt in range(1, MAX_LOGIN_RETRIES + 1): - driver = None - try: - driver = webdriver.Edge(options=edge_options) - print(f"第 {attempt} 次尝试登录...") - - # 依次尝试不同的 Captive Portal 触发 URL - portal_reached = False - for url in CAPTIVE_PORTAL_URLS: - print(f" 访问触发页面: {url}") - driver.get(url) - try: - wait = WebDriverWait(driver, 10) - wait.until(EC.element_to_be_clickable((By.NAME, "visitor_accept_terms"))) - portal_reached = True - break - except Exception: - print(f" 未能通过 {url} 跳转到登录页,尝试下一个...") - continue - - if not portal_reached: - print(f" 第 {attempt} 次尝试未能到达登录页面。") - continue - - # 勾选同意协议 - checkbox = driver.find_element(By.NAME, "visitor_accept_terms") - if not checkbox.is_selected(): - checkbox.click() - print(" 已勾选同意协议。") - - time.sleep(1) - - # 点击登录按钮 - login_button = driver.find_element( - By.XPATH, "//input[@type='submit' and @value='Log In']", - ) - login_button.click() - print(" 已点击登录按钮。") - - # 轮询验证是否真正连上网络 - for i in range(10): - time.sleep(2) - if is_connected(): - print("登录成功!网络已连通。") - return - print(f" 第 {attempt} 次尝试:点击登录后网络仍未连通。") - - except Exception as e: - print(f" 第 {attempt} 次尝试出错: {e}") - finally: - if driver: - driver.quit() - - if attempt < MAX_LOGIN_RETRIES: - print(" 等待 3 秒后重试...") - time.sleep(3) - - print("多次尝试后仍未能成功登录。") - - -def main(): - parser = argparse.ArgumentParser(description="OSU 公共 Wi-Fi 自动登录工具") - parser.add_argument( - "--random-mac", - action="store_true", - help="登录前随机化 Wi-Fi 网卡的 MAC 地址(需要管理员权限)", - ) - args = parser.parse_args() - - if is_connected(): - print("网络已连接,无需登录。") - return - - if args.random_mac: - print("正在随机化 MAC 地址...") - if not randomize_mac(): - print("MAC 地址随机化失败,继续尝试登录...") - else: - # MAC 更改后需要等待 Wi-Fi 重新连接 - print("等待 Wi-Fi 重新连接...") - time.sleep(5) - - auto_login_wifi() +from osu_wifi_login.cli import main if __name__ == "__main__": - main() \ No newline at end of file + raise SystemExit(main()) diff --git a/osu_wifi_login/__init__.py b/osu_wifi_login/__init__.py new file mode 100644 index 0000000..33767bd --- /dev/null +++ b/osu_wifi_login/__init__.py @@ -0,0 +1 @@ +"""OSU Wi-Fi background monitor for Windows.""" diff --git a/osu_wifi_login/cli.py b/osu_wifi_login/cli.py new file mode 100644 index 0000000..90de6b0 --- /dev/null +++ b/osu_wifi_login/cli.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import argparse +import logging +import sys +from pathlib import Path + +from .config import ConfigError, load_config +from .service import WifiBackgroundService +from .windows import WifiCommandError + + +def main() -> int: + parser = argparse.ArgumentParser(description="OSU Wi-Fi background monitor for Windows") + parser.add_argument( + "--config", + default="config.yaml", + help="Path to the YAML configuration file (default: config.yaml)", + ) + parser.add_argument( + "--once", + action="store_true", + help="Run one monitor iteration and exit", + ) + args = parser.parse_args() + + try: + config = load_config(Path(args.config)) + except ConfigError as exc: + print(f"Configuration error: {exc}", file=sys.stderr) + return 2 + + logging.basicConfig( + level=getattr(logging, config.logging.level, logging.INFO), + format="%(asctime)s [%(levelname)s] %(message)s", + ) + logger = logging.getLogger("osu_wifi_login") + + try: + service = WifiBackgroundService(config, logger) + return service.run_forever(run_once=args.once) + except WifiCommandError as exc: + logger.error("%s", exc) + return 1 + except KeyboardInterrupt: + logger.info("Stopping OSU Wi-Fi monitor") + return 0 diff --git a/osu_wifi_login/config.py b/osu_wifi_login/config.py new file mode 100644 index 0000000..bbf6686 --- /dev/null +++ b/osu_wifi_login/config.py @@ -0,0 +1,389 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import yaml + + +class ConfigError(ValueError): + """Raised when the YAML configuration is invalid.""" + + +@dataclass(slots=True) +class ConnectivityCheck: + url: str + expected_status: int + expected_text: str | None = None + require_final_url_match: bool = True + allow_redirects: bool = False + + +@dataclass(slots=True) +class NetworkProfile: + key: str + ssid: str + profile_name: str + requires_portal_login: bool + randomize_mac: bool + restore_hardware_mac: bool = False + auto_create_open_profile: bool = False + mac_refresh_hours: float | None = None + + +@dataclass(slots=True) +class MonitorConfig: + check_interval_seconds: int = 15 + connect_retry_cooldown_seconds: int = 20 + disconnect_grace_seconds: int = 45 + adapter_reset_cooldown_seconds: int = 120 + reconnect_wait_seconds: int = 12 + connection_timeout_seconds: int = 25 + + +@dataclass(slots=True) +class SeleniumConfig: + headless: bool = True + page_load_timeout_seconds: int = 20 + element_timeout_seconds: int = 12 + max_login_retries: int = 3 + + +@dataclass(slots=True) +class PortalConfig: + trigger_urls: list[str] = field( + default_factory=lambda: [ + "http://captive.apple.com/", + "http://www.msftconnecttest.com/redirect", + "http://detectportal.firefox.com/", + ], + ) + accept_terms_name: str = "visitor_accept_terms" + login_button_xpath: str = "//input[@type='submit' and @value='Log In']" + + +@dataclass(slots=True) +class LoggingConfig: + level: str = "INFO" + + +@dataclass(slots=True) +class AppConfig: + selected_network: str + networks: dict[str, NetworkProfile] + connectivity_checks: list[ConnectivityCheck] + monitor: MonitorConfig = field(default_factory=MonitorConfig) + selenium: SeleniumConfig = field(default_factory=SeleniumConfig) + portal: PortalConfig = field(default_factory=PortalConfig) + logging: LoggingConfig = field(default_factory=LoggingConfig) + wifi_interface_name: str | None = None + allow_unsafe_mac_changes: bool = False + randomize_mac_on_start: bool = True + + @property + def active_network(self) -> NetworkProfile: + try: + return self.networks[self.selected_network] + except KeyError as exc: + raise ConfigError( + f"selected_network '{self.selected_network}' is not defined in networks" + ) from exc + + +def load_config(path: str | Path) -> AppConfig: + config_path = Path(path) + if not config_path.exists(): + raise ConfigError(f"Config file not found: {config_path}") + + raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + if not isinstance(raw, dict): + raise ConfigError("The top-level YAML object must be a mapping") + + selected_network = _require_str(raw, "selected_network") + networks_raw = raw.get("networks") + if not isinstance(networks_raw, dict) or not networks_raw: + raise ConfigError("networks must be a non-empty mapping") + + networks: dict[str, NetworkProfile] = {} + for key, value in networks_raw.items(): + if not isinstance(value, dict): + raise ConfigError(f"network '{key}' must be a mapping") + networks[key] = NetworkProfile( + key=key, + ssid=_require_str(value, "ssid", context=f"networks.{key}"), + profile_name=str(value.get("profile_name") or value.get("ssid") or "").strip(), + requires_portal_login=_require_bool( + value, + "requires_portal_login", + context=f"networks.{key}", + ), + randomize_mac=_require_bool(value, "randomize_mac", context=f"networks.{key}"), + restore_hardware_mac=_require_bool( + value, + "restore_hardware_mac", + default=False, + context=f"networks.{key}", + ), + auto_create_open_profile=_require_bool( + value, + "auto_create_open_profile", + default=False, + context=f"networks.{key}", + ), + mac_refresh_hours=_optional_float( + value, + "mac_refresh_hours", + context=f"networks.{key}", + ), + ) + if not networks[key].profile_name: + raise ConfigError(f"networks.{key}.profile_name cannot be empty") + if networks[key].randomize_mac and networks[key].mac_refresh_hours is not None: + if networks[key].mac_refresh_hours <= 0: + raise ConfigError(f"networks.{key}.mac_refresh_hours must be greater than 0") + + if selected_network not in networks: + raise ConfigError(f"selected_network '{selected_network}' is not defined in networks") + + checks_raw = raw.get("connectivity_checks") + if not isinstance(checks_raw, list) or not checks_raw: + raise ConfigError("connectivity_checks must be a non-empty list") + connectivity_checks = [ + ConnectivityCheck( + url=_require_str(item, "url", context="connectivity_checks[]"), + expected_status=_require_int(item, "expected_status", context="connectivity_checks[]"), + expected_text=_optional_str(item, "expected_text"), + require_final_url_match=_require_bool( + item, + "require_final_url_match", + default=True, + context="connectivity_checks[]", + ), + allow_redirects=_require_bool( + item, + "allow_redirects", + default=False, + context="connectivity_checks[]", + ), + ) + for item in checks_raw + ] + + monitor_raw = raw.get("monitor", {}) + if not isinstance(monitor_raw, dict): + raise ConfigError("monitor must be a mapping") + monitor = MonitorConfig( + check_interval_seconds=_require_int( + monitor_raw, + "check_interval_seconds", + default=15, + context="monitor", + ), + connect_retry_cooldown_seconds=_require_int( + monitor_raw, + "connect_retry_cooldown_seconds", + default=20, + context="monitor", + ), + disconnect_grace_seconds=_require_int( + monitor_raw, + "disconnect_grace_seconds", + default=45, + context="monitor", + ), + adapter_reset_cooldown_seconds=_require_int( + monitor_raw, + "adapter_reset_cooldown_seconds", + default=120, + context="monitor", + ), + reconnect_wait_seconds=_require_int( + monitor_raw, + "reconnect_wait_seconds", + default=12, + context="monitor", + ), + connection_timeout_seconds=_require_int( + monitor_raw, + "connection_timeout_seconds", + default=25, + context="monitor", + ), + ) + for field_name, value in ( + ("check_interval_seconds", monitor.check_interval_seconds), + ("connect_retry_cooldown_seconds", monitor.connect_retry_cooldown_seconds), + ("disconnect_grace_seconds", monitor.disconnect_grace_seconds), + ("adapter_reset_cooldown_seconds", monitor.adapter_reset_cooldown_seconds), + ("reconnect_wait_seconds", monitor.reconnect_wait_seconds), + ("connection_timeout_seconds", monitor.connection_timeout_seconds), + ): + if value <= 0: + raise ConfigError(f"monitor.{field_name} must be greater than 0") + + selenium_raw = raw.get("selenium", {}) + if not isinstance(selenium_raw, dict): + raise ConfigError("selenium must be a mapping") + selenium = SeleniumConfig( + headless=_require_bool(selenium_raw, "headless", default=True, context="selenium"), + page_load_timeout_seconds=_require_int( + selenium_raw, + "page_load_timeout_seconds", + default=20, + context="selenium", + ), + element_timeout_seconds=_require_int( + selenium_raw, + "element_timeout_seconds", + default=12, + context="selenium", + ), + max_login_retries=_require_int( + selenium_raw, + "max_login_retries", + default=3, + context="selenium", + ), + ) + + portal_raw = raw.get("portal", {}) + if not isinstance(portal_raw, dict): + raise ConfigError("portal must be a mapping") + portal = PortalConfig( + trigger_urls=_require_string_list( + portal_raw, + "trigger_urls", + default=[ + "http://captive.apple.com/", + "http://www.msftconnecttest.com/redirect", + "http://detectportal.firefox.com/", + ], + context="portal", + ), + accept_terms_name=_require_str( + portal_raw, + "accept_terms_name", + default="visitor_accept_terms", + context="portal", + ), + login_button_xpath=_require_str( + portal_raw, + "login_button_xpath", + default="//input[@type='submit' and @value='Log In']", + context="portal", + ), + ) + + logging_raw = raw.get("logging", {}) + if not isinstance(logging_raw, dict): + raise ConfigError("logging must be a mapping") + logging_config = LoggingConfig( + level=_require_str(logging_raw, "level", default="INFO", context="logging").upper(), + ) + + return AppConfig( + selected_network=selected_network, + networks=networks, + connectivity_checks=connectivity_checks, + monitor=monitor, + selenium=selenium, + portal=portal, + logging=logging_config, + wifi_interface_name=_optional_str(raw, "wifi_interface_name"), + allow_unsafe_mac_changes=_require_bool( + raw, + "allow_unsafe_mac_changes", + default=False, + ), + randomize_mac_on_start=_require_bool( + raw, + "randomize_mac_on_start", + default=True, + ), + ) + + +def _require_str( + data: dict[str, Any], + key: str, + default: str | None = None, + *, + context: str = "config", +) -> str: + value = data.get(key, default) + if not isinstance(value, str) or not value.strip(): + raise ConfigError(f"{context}.{key} must be a non-empty string") + return value.strip() + + +def _optional_str(data: dict[str, Any], key: str) -> str | None: + value = data.get(key) + if value is None: + return None + if not isinstance(value, str): + raise ConfigError(f"config.{key} must be a string when set") + stripped = value.strip() + return stripped or None + + +def _require_bool( + data: dict[str, Any], + key: str, + default: bool | None = None, + *, + context: str = "config", +) -> bool: + value = data.get(key, default) + if not isinstance(value, bool): + raise ConfigError(f"{context}.{key} must be a boolean") + return value + + +def _require_int( + data: dict[str, Any], + key: str, + default: int | None = None, + *, + context: str = "config", +) -> int: + value = data.get(key, default) + if isinstance(value, bool) or not isinstance(value, int): + raise ConfigError(f"{context}.{key} must be an integer") + return value + + +def _optional_float( + data: dict[str, Any], + key: str, + *, + context: str = "config", +) -> float | None: + value = data.get(key) + if value is None: + return None + if isinstance(value, bool): + raise ConfigError(f"{context}.{key} must be a number when set") + if isinstance(value, int): + return float(value) + if not isinstance(value, float): + raise ConfigError(f"{context}.{key} must be a number when set") + return value + + +def _require_string_list( + data: dict[str, Any], + key: str, + default: list[str] | None = None, + *, + context: str = "config", +) -> list[str]: + value = data.get(key, default) + if not isinstance(value, list) or not value: + raise ConfigError(f"{context}.{key} must be a non-empty list") + normalized: list[str] = [] + for item in value: + if not isinstance(item, str) or not item.strip(): + raise ConfigError(f"{context}.{key} must contain non-empty strings") + normalized.append(item.strip()) + return normalized diff --git a/osu_wifi_login/portal.py b/osu_wifi_login/portal.py new file mode 100644 index 0000000..bd7bd61 --- /dev/null +++ b/osu_wifi_login/portal.py @@ -0,0 +1,537 @@ +from __future__ import annotations + +import logging +import re +import time +from dataclasses import dataclass, field +from html.parser import HTMLParser +from urllib.parse import urljoin + +import requests +import urllib3 +try: + from playwright.sync_api import Error as PlaywrightError + from playwright.sync_api import TimeoutError as PlaywrightTimeoutError + from playwright.sync_api import sync_playwright +except ImportError: # pragma: no cover - depends on local environment + PlaywrightError = RuntimeError + PlaywrightTimeoutError = TimeoutError + sync_playwright = None + +from .config import PortalConfig, SeleniumConfig + + +class PortalLoginError(RuntimeError): + """Raised when the captive portal login fails.""" + + +@dataclass(slots=True) +class ParsedPortalForm: + action: str + method: str + inputs: list[dict[str, str | None]] = field(default_factory=list) + textareas: dict[str, str] = field(default_factory=dict) + + +class _PortalFormParser(HTMLParser): + def __init__(self, accept_terms_name: str) -> None: + super().__init__(convert_charrefs=True) + self.accept_terms_name = accept_terms_name + self.portal_form: ParsedPortalForm | None = None + self._form_stack: list[ParsedPortalForm] = [] + self._document_inputs: list[dict[str, str | None]] = [] + self._document_textareas: dict[str, str] = {} + self._textarea_name: str | None = None + self._textarea_buffer: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + attrs_dict = {key: value for key, value in attrs} + tag_lower = tag.casefold() + + if tag_lower == "form": + method = (attrs_dict.get("method") or "post").casefold() + action = attrs_dict.get("action") or "" + self._form_stack.append(ParsedPortalForm(action=action, method=method)) + + if tag_lower == "input": + self._document_inputs.append(attrs_dict) + if self._form_stack: + self._form_stack[-1].inputs.append(attrs_dict) + return + + if tag_lower == "textarea": + self._textarea_name = attrs_dict.get("name") + self._textarea_buffer = [] + return + + def handle_data(self, data: str) -> None: + if self._textarea_name is not None: + self._textarea_buffer.append(data) + + def handle_endtag(self, tag: str) -> None: + tag_lower = tag.casefold() + + if tag_lower == "textarea" and self._textarea_name: + text = "".join(self._textarea_buffer) + self._document_textareas[self._textarea_name] = text + if self._form_stack: + self._form_stack[-1].textareas[self._textarea_name] = text + self._textarea_name = None + self._textarea_buffer = [] + return + + if tag_lower == "form" and self._form_stack: + completed_form = self._form_stack.pop() + if self.portal_form is None and self._is_portal_form(completed_form): + self.portal_form = completed_form + + def _is_portal_form(self, form: ParsedPortalForm) -> bool: + for input_attrs in form.inputs: + if (input_attrs.get("name") or "").strip() == self.accept_terms_name: + return True + return False + + def build_fallback_form(self) -> ParsedPortalForm | None: + for input_attrs in self._document_inputs: + if (input_attrs.get("name") or "").strip() == self.accept_terms_name: + return ParsedPortalForm( + action="", + method="post", + inputs=list(self._document_inputs), + textareas=dict(self._document_textareas), + ) + return None + + +class CaptivePortalLogin: + def __init__( + self, + portal: PortalConfig, + selenium_config: SeleniumConfig, + logger: logging.Logger, + ) -> None: + self.portal = portal + self.selenium_config = selenium_config + self.logger = logger + + def login(self) -> None: + if not self.login_if_present(): + raise PortalLoginError("Could not reach the OSU captive portal") + + def login_if_present(self) -> bool: + browser_result = self._login_with_browser() + if browser_result: + return True + + self.logger.info("Playwright-based portal automation did not detect login controls; trying HTTP fallback") + last_error: Exception | None = None + saw_successful_response = False + + with requests.Session() as session: + session.verify = False + session.headers.update( + { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/135.0.0.0 Safari/537.36 Edg/135.0.0.0" + ), + }, + ) + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + for attempt in range(1, self.selenium_config.max_login_retries + 1): + self.logger.info( + "Portal login attempt %s/%s", + attempt, + self.selenium_config.max_login_retries, + ) + + for url in self.portal.trigger_urls: + self.logger.info("Opening captive portal trigger URL: %s", url) + try: + response = session.get( + url, + timeout=self.selenium_config.page_load_timeout_seconds, + allow_redirects=True, + ) + except requests.RequestException as exc: + last_error = exc + continue + + saw_successful_response = True + if response.url != url: + self.logger.info("Portal trigger resolved to %s", response.url) + form = self._extract_portal_form(response.text) + if form is None: + continue + + self.logger.info("Captive portal form detected at %s", response.url) + try: + self._submit_form(session, response.url, form) + time.sleep(2) + return True + except PortalLoginError as exc: + last_error = exc + self.logger.warning( + "HTTP captive portal submission failed, falling back to browser automation: %s", + exc, + ) + break + + if attempt < self.selenium_config.max_login_retries: + time.sleep(2) + + if saw_successful_response: + self.logger.info("Captive portal page was not detected") + return False + + raise PortalLoginError(str(last_error or "Unknown captive portal request error")) + + def _extract_portal_form(self, html: str) -> ParsedPortalForm | None: + parser = _PortalFormParser(self.portal.accept_terms_name) + parser.feed(html) + parser.close() + return parser.portal_form or parser.build_fallback_form() + + def _submit_form( + self, + session: requests.Session, + page_url: str, + form: ParsedPortalForm, + ) -> None: + payload = self._build_payload(form) + action_url = urljoin(page_url, form.action or page_url) + method = form.method or "post" + + self.logger.info("Submitting captive portal form to %s", action_url) + try: + if method == "get": + response = session.get( + action_url, + params=payload, + timeout=self.selenium_config.page_load_timeout_seconds, + allow_redirects=True, + ) + else: + response = session.post( + action_url, + data=payload, + timeout=self.selenium_config.page_load_timeout_seconds, + allow_redirects=True, + ) + except requests.RequestException as exc: + raise PortalLoginError(f"Submitting captive portal form failed: {exc}") from exc + + if response.status_code >= 400: + raise PortalLoginError( + f"Captive portal submission returned HTTP {response.status_code}", + ) + + def _build_payload(self, form: ParsedPortalForm) -> dict[str, str]: + payload: dict[str, str] = {} + submit_added = False + + for input_attrs in form.inputs: + name = (input_attrs.get("name") or "").strip() + if not name: + continue + + input_type = (input_attrs.get("type") or "text").casefold() + value = input_attrs.get("value") or "" + + if input_type in {"checkbox", "radio"}: + if name == self.portal.accept_terms_name: + payload[name] = value or "on" + elif "checked" in input_attrs: + payload[name] = value or "on" + continue + + if input_type in {"submit", "button", "image"}: + lowered = value.casefold() + if not submit_added and ("log in" in lowered or "login" in lowered or not lowered): + payload[name] = value + submit_added = True + continue + + if input_type == "file": + continue + + payload[name] = value + + for name, text in form.textareas.items(): + payload.setdefault(name, text) + + if self.portal.accept_terms_name not in payload: + raise PortalLoginError( + f"Captive portal form did not contain '{self.portal.accept_terms_name}' payload data", + ) + + return payload + + def _login_with_browser(self) -> bool: + if sync_playwright is None: + raise PortalLoginError( + "Playwright is not installed. Run 'uv sync' before using browser-based portal automation.", + ) + try: + with sync_playwright() as playwright: + browser = playwright.chromium.launch( + channel="msedge", + headless=self.selenium_config.headless, + args=["--ignore-certificate-errors"], + ) + context = browser.new_context(ignore_https_errors=True) + page = context.new_page() + + try: + for url in self.portal.trigger_urls: + self.logger.info("Opening captive portal trigger URL in browser: %s", url) + try: + page.goto( + url, + wait_until="domcontentloaded", + timeout=self.selenium_config.page_load_timeout_seconds * 1000, + ) + except KeyboardInterrupt: + raise + except PlaywrightTimeoutError: + self.logger.warning("Timed out opening portal trigger URL in browser: %s", url) + continue + page.wait_for_timeout(1500) + if page.url != url: + self.logger.info("Browser portal trigger resolved to %s", page.url) + if self._submit_portal_in_browser(page): + return True + finally: + browser.close() + except KeyboardInterrupt: + raise + except PlaywrightError as exc: + raise PortalLoginError( + f"Playwright browser automation failed: {exc}", + ) from exc + + self.logger.info("Playwright-based portal page was not detected") + return False + + def _submit_portal_in_browser(self, page) -> bool: + for frame in page.frames: + checkbox = self._find_accept_control(frame) + if checkbox is None: + continue + + frame_url = frame.url or page.url + self.logger.info("Captive portal controls detected in browser frame: %s", frame_url) + self._activate_accept_control(checkbox) + + login_button = self._find_login_button(frame) + if login_button is None: + raise PortalLoginError("Found the Agree control, but could not find the login button") + + self.logger.info("Clicking captive portal login button in browser") + submitted_with_js = self._submit_osu_guest_form(frame) + if not submitted_with_js: + login_button.click(timeout=self.selenium_config.element_timeout_seconds * 1000, force=True) + + try: + page.wait_for_load_state("networkidle", timeout=10000) + except PlaywrightTimeoutError: + pass + page.wait_for_timeout(5000) + self.logger.info("Submitted portal form in browser; current URL is %s", page.url) + self._log_page_status(page) + self._log_visible_portal_errors(page) + return True + + return False + + def _find_accept_control(self, scope): + candidate_selectors = [ + f'[name="{self.portal.accept_terms_name}"]', + f'input[name="{self.portal.accept_terms_name}"]', + 'input[type="checkbox"]', + 'text=/agree/i', + 'text=/accept/i', + ] + + for selector in candidate_selectors: + locator = scope.locator(selector).first + if locator.count() > 0: + return locator + return None + + def _activate_accept_control(self, locator) -> None: + input_type = (locator.get_attribute("type") or "").casefold() + if input_type in {"checkbox", "radio"}: + try: + locator.check(timeout=self.selenium_config.element_timeout_seconds * 1000, force=True) + locator.evaluate( + """(el) => { + el.checked = true; + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + }""", + ) + return + except PlaywrightError: + pass + + try: + locator.click(timeout=self.selenium_config.element_timeout_seconds * 1000, force=True) + except PlaywrightError as exc: + try: + locator.evaluate( + """(el) => { + if ('checked' in el) { + el.checked = true; + } + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + if (typeof el.click === 'function') { + el.click(); + } + }""", + ) + except PlaywrightError as inner_exc: + raise PortalLoginError( + f"Failed to activate the Agree control: {inner_exc}", + ) from exc + + def _find_login_button(self, scope): + role_locator = scope.get_by_role("button", name=re.compile(r"log\s*in", re.I)).first + if role_locator.count() > 0: + return role_locator + + selector_candidates = [ + f"xpath={self.portal.login_button_xpath}", + 'input[type="submit"]', + 'input[type="button"]', + 'button', + 'text=/log\\s*in/i', + ] + + for selector in selector_candidates: + locator = scope.locator(selector) + count = locator.count() + for index in range(count): + candidate = locator.nth(index) + label = " ".join( + filter( + None, + [ + candidate.get_attribute("value") or "", + candidate.get_attribute("aria-label") or "", + candidate.text_content() or "", + ], + ), + ).strip() + if selector == f"xpath={self.portal.login_button_xpath}" or re.search(r"log\s*in", label, re.I): + return candidate + + return None + + def _submit_osu_guest_form(self, scope) -> bool: + try: + result = scope.evaluate( + """() => { + const checkbox = document.querySelector('[name="visitor_accept_terms"]'); + if (!checkbox) { + return { submitted: false, reason: 'checkbox not found' }; + } + + checkbox.checked = true; + checkbox.value = checkbox.value || '1'; + checkbox.dispatchEvent(new Event('input', { bubbles: true })); + checkbox.dispatchEvent(new Event('change', { bubbles: true })); + if (!checkbox.checked) { + checkbox.checked = true; + } + + const form = checkbox.closest('form') || document.querySelector('form[name$="_weblogin"], form[id$="_weblogin"]'); + if (!form) { + return { submitted: false, reason: 'form not found' }; + } + + const submitButton = form.querySelector('input[type="submit"], button[type="submit"], button'); + if (submitButton) { + submitButton.disabled = false; + } + + const state = { + checked: checkbox.checked, + value: checkbox.value, + formName: form.name || form.id || '', + submitId: submitButton ? submitButton.id : '', + }; + + if (typeof window.Nwa_SubmitForm === 'function') { + const submitId = submitButton ? submitButton.id : ''; + checkbox.checked = true; + checkbox.dispatchEvent(new Event('change', { bubbles: true })); + window.Nwa_SubmitForm(form.name || form.id, submitId); + return { submitted: true, via: 'Nwa_SubmitForm', state }; + } + + if (typeof form.requestSubmit === 'function') { + form.requestSubmit(submitButton || undefined); + return { submitted: true, via: 'requestSubmit', state }; + } + + form.submit(); + return { submitted: true, via: 'form.submit', state }; + }""", + ) + except PlaywrightError as exc: + self.logger.warning("Direct portal form submission failed: %s", exc) + return False + + if result and result.get("submitted"): + self.logger.info( + "Submitted captive portal form via %s with state %s", + result.get("via"), + result.get("state"), + ) + return True + + self.logger.info("Direct portal form submission was not available: %s", result) + return False + + def _log_visible_portal_errors(self, page) -> None: + try: + errors: list[str] = [] + for selector in [".nwaError", ".nwaErrorBorder", "[role='alert']"]: + locator = page.locator(selector) + count = locator.count() + for index in range(count): + text = (locator.nth(index).inner_text(timeout=1000) or "").strip() + if text: + errors.append(text) + if errors: + self.logger.warning("Portal page reported: %s", " | ".join(errors[:3])) + except PlaywrightError: + return + + def _log_page_status(self, page) -> None: + try: + body_text = " ".join((page.locator("body").inner_text(timeout=1500) or "").split()) + except PlaywrightError: + return + + if not body_text: + return + + interesting_patterns = [ + r"success", + r"authenticated", + r"logged\s+in", + r"access\s+granted", + r"error", + r"failed", + r"denied", + r"must\s+accept", + r"terms\s+and\s+conditions", + r"network\s+access\s+login", + ] + if any(re.search(pattern, body_text, re.I) for pattern in interesting_patterns): + self.logger.info("Portal page status text: %s", body_text[:500]) diff --git a/osu_wifi_login/service.py b/osu_wifi_login/service.py new file mode 100644 index 0000000..accb749 --- /dev/null +++ b/osu_wifi_login/service.py @@ -0,0 +1,251 @@ +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass + +from .config import AppConfig +from .portal import CaptivePortalLogin, PortalLoginError +from .windows import ( + WifiCommandError, + connect_wifi, + disconnect_wifi, + ensure_hardware_mac, + get_wifi_status, + randomize_mac, + restart_adapter, + test_connectivity, + wait_for_ssid, +) + + +@dataclass(slots=True) +class ServiceState: + disconnected_since_monotonic: float | None = None + last_connect_attempt_monotonic: float | None = None + last_recovery_monotonic: float | None = None + last_mac_refresh_monotonic: float | None = None + startup_policy_pending: bool = True + + +class WifiBackgroundService: + def __init__(self, config: AppConfig, logger: logging.Logger) -> None: + self.config = config + self.logger = logger + self.state = ServiceState( + startup_policy_pending=(config.randomize_mac_on_start and config.allow_unsafe_mac_changes), + ) + self.portal_login = CaptivePortalLogin(config.portal, config.selenium, logger) + + def run_forever(self, run_once: bool = False) -> int: + if self.config.active_network.randomize_mac and not self.config.allow_unsafe_mac_changes: + self.logger.warning( + "MAC randomization is configured for '%s' but disabled because " + "allow_unsafe_mac_changes=false", + self.config.active_network.ssid, + ) + self.logger.info( + "Starting OSU Wi-Fi monitor for network '%s' (%s)", + self.config.active_network.key, + self.config.active_network.ssid, + ) + while True: + try: + self._run_iteration() + except WifiCommandError as exc: + if run_once: + self.logger.error("%s", exc) + return 1 + self.logger.error("Monitor iteration failed: %s", exc) + except Exception: + if run_once: + self.logger.exception("Monitor iteration failed unexpectedly") + return 1 + self.logger.exception("Monitor iteration failed unexpectedly") + if run_once: + return 0 + time.sleep(self.config.monitor.check_interval_seconds) + + def _run_iteration(self) -> None: + network = self.config.active_network + status = get_wifi_status(self.config.wifi_interface_name) + now = time.monotonic() + on_target_network = status.is_connected and status.ssid == network.ssid + internet_ok = on_target_network and test_connectivity(self.config.connectivity_checks) + + if self._mac_randomization_enabled() and self.state.startup_policy_pending: + self.logger.info("Applying startup MAC policy for %s", network.ssid) + self._recover_connection(reason="startup MAC policy") + self.state.startup_policy_pending = False + self.state.disconnected_since_monotonic = None + return + + if self._mac_randomization_enabled() and self._mac_refresh_due(): + self.logger.info("MAC refresh interval reached for %s", network.ssid) + self._recover_connection(reason="scheduled MAC refresh", refresh_mac=True) + self.state.disconnected_since_monotonic = None + return + + if not on_target_network and self._soft_connect_due(now): + if self._attempt_soft_connect(status): + self.state.disconnected_since_monotonic = None + return + + if on_target_network and internet_ok: + self.state.disconnected_since_monotonic = None + return + + if on_target_network and network.requires_portal_login and not internet_ok: + self.logger.info("Connected to %s without internet; attempting portal login", network.ssid) + self._login_if_needed() + self.state.disconnected_since_monotonic = None + return + + now = time.monotonic() + if self.state.disconnected_since_monotonic is None: + self.state.disconnected_since_monotonic = now + self.logger.warning( + "Wi-Fi is unhealthy. interface=%s state=%s ssid=%s", + status.interface_name, + status.state, + status.ssid, + ) + return + + unhealthy_seconds = now - self.state.disconnected_since_monotonic + if unhealthy_seconds < self.config.monitor.disconnect_grace_seconds: + return + + if self.state.last_recovery_monotonic is not None: + cooldown = now - self.state.last_recovery_monotonic + if cooldown < self.config.monitor.adapter_reset_cooldown_seconds: + return + + self._recover_connection(reason=f"connection unhealthy for {int(unhealthy_seconds)} seconds") + self.state.disconnected_since_monotonic = None + + def _recover_connection(self, reason: str, refresh_mac: bool | None = None) -> None: + network = self.config.active_network + refresh_mac = self._mac_randomization_enabled() if refresh_mac is None else refresh_mac + self.logger.warning("Starting recovery: %s", reason) + + status = get_wifi_status(self.config.wifi_interface_name) + interface_name = status.interface_name or self.config.wifi_interface_name + if not interface_name: + raise WifiCommandError("Could not determine the Wi-Fi interface name") + + adapter_locator = status.description or interface_name + + if network.restore_hardware_mac and self.config.allow_unsafe_mac_changes: + try: + restored = ensure_hardware_mac(adapter_locator) + if restored: + self.logger.info("Restored hardware MAC before reconnecting to %s", network.ssid) + except WifiCommandError as exc: + self.logger.warning("Could not restore hardware MAC: %s", exc) + + if refresh_mac: + mac_address = randomize_mac(adapter_locator) + self.state.last_mac_refresh_monotonic = time.monotonic() + self.logger.info("Randomized MAC to %s", mac_address) + + disconnect_wifi(interface_name) + restart_adapter(interface_name) + time.sleep(self.config.monitor.reconnect_wait_seconds) + connect_wifi( + network.profile_name, + interface_name, + ssid=network.ssid, + auto_create_open_profile=network.auto_create_open_profile, + ) + status = wait_for_ssid( + network.ssid, + self.config.monitor.connection_timeout_seconds, + interface_name, + ) + self.state.last_recovery_monotonic = time.monotonic() + + if not status.is_connected or status.ssid != network.ssid: + raise WifiCommandError(f"Failed to reconnect to {network.ssid}") + + self.logger.info("Reconnected to %s on interface %s", status.ssid, interface_name) + + if network.requires_portal_login: + self._login_if_needed() + + def _attempt_soft_connect(self, status) -> bool: + network = self.config.active_network + interface_name = status.interface_name or self.config.wifi_interface_name + if not interface_name: + raise WifiCommandError( + "Could not determine the Wi-Fi interface name. Set wifi_interface_name in config.yaml.", + ) + + self.logger.info( + "Attempting direct Wi-Fi connection to %s on interface %s", + network.ssid, + interface_name, + ) + connect_wifi( + network.profile_name, + interface_name, + ssid=network.ssid, + auto_create_open_profile=network.auto_create_open_profile, + ) + self.state.last_connect_attempt_monotonic = time.monotonic() + + new_status = wait_for_ssid( + network.ssid, + self.config.monitor.connection_timeout_seconds, + interface_name, + ) + if not new_status.is_connected or new_status.ssid != network.ssid: + self.logger.warning("Direct Wi-Fi connection to %s did not succeed", network.ssid) + return False + + self.logger.info("Connected to %s without adapter reset", network.ssid) + if network.requires_portal_login: + self._login_if_needed() + return True + + def _login_if_needed(self) -> None: + try: + if not self.portal_login.login_if_present(): + if test_connectivity(self.config.connectivity_checks): + self.logger.info("Captive portal was not detected and connectivity checks passed") + return + raise WifiCommandError( + "Connected to the Wi-Fi network, but internet is unavailable and the captive portal " + "login page was not detected", + ) + except PortalLoginError as exc: + raise WifiCommandError(f"Captive portal login failed: {exc}") from exc + + deadline = time.monotonic() + self.config.monitor.connection_timeout_seconds + while time.monotonic() < deadline: + if test_connectivity(self.config.connectivity_checks): + self.logger.info("Captive portal login succeeded") + return + self.logger.info("Waiting for internet access after captive portal submission") + time.sleep(2) + raise WifiCommandError("Captive portal login completed but internet is still unavailable") + + def _mac_refresh_due(self) -> bool: + network = self.config.active_network + if not self._mac_randomization_enabled() or not network.mac_refresh_hours: + return False + if self.state.last_mac_refresh_monotonic is None: + return False + refresh_seconds = network.mac_refresh_hours * 3600 + return (time.monotonic() - self.state.last_mac_refresh_monotonic) >= refresh_seconds + + def _mac_randomization_enabled(self) -> bool: + network = self.config.active_network + return self.config.allow_unsafe_mac_changes and network.randomize_mac + + def _soft_connect_due(self, now: float) -> bool: + if self.state.last_connect_attempt_monotonic is None: + return True + return ( + now - self.state.last_connect_attempt_monotonic + ) >= self.config.monitor.connect_retry_cooldown_seconds diff --git a/osu_wifi_login/windows.py b/osu_wifi_login/windows.py new file mode 100644 index 0000000..6912fe5 --- /dev/null +++ b/osu_wifi_login/windows.py @@ -0,0 +1,412 @@ +from __future__ import annotations + +import locale +import random +import re +import subprocess +import tempfile +import time +import winreg +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +import requests + +from .config import ConnectivityCheck + + +class WifiCommandError(RuntimeError): + """Raised when a Windows networking command fails.""" + + +@dataclass(slots=True) +class WifiStatus: + interface_name: str | None + description: str | None + state: str | None + ssid: str | None + + @property + def is_connected(self) -> bool: + state = (self.state or "").lower() + return "connected" in state or "已连接" in state or "已連線" in state + + +def test_connectivity(checks: Iterable[ConnectivityCheck], timeout_seconds: int = 3) -> bool: + for check in checks: + try: + response = requests.get( + check.url, + timeout=timeout_seconds, + allow_redirects=check.allow_redirects, + ) + except requests.RequestException: + continue + if response.status_code != check.expected_status: + continue + if check.require_final_url_match and _normalize_url(response.url) != _normalize_url(check.url): + continue + if check.expected_text is not None and check.expected_text not in response.text: + continue + if check.expected_status == 204 and response.content: + continue + return True + return False + + +def get_wifi_status(preferred_interface_name: str | None = None) -> WifiStatus: + output = run_command(["netsh", "wlan", "show", "interfaces"]) + blocks = [block.strip() for block in re.split(r"(?:\r?\n){2,}", output) if block.strip()] + parsed_blocks = [_parse_key_value_block(block) for block in blocks] + + candidate = None + if preferred_interface_name: + preferred = preferred_interface_name.casefold() + for block in parsed_blocks: + if (block.get("name") or "").casefold() == preferred: + candidate = block + break + + if candidate is None: + for block in parsed_blocks: + state = (block.get("state") or "").lower() + if "connected" in state or "disconnected" in state or "已" in state: + candidate = block + break + + if candidate is None: + return WifiStatus(interface_name=None, description=None, state=None, ssid=None) + + return WifiStatus( + interface_name=candidate.get("name"), + description=candidate.get("description"), + state=candidate.get("state"), + ssid=candidate.get("ssid"), + ) + + +def restart_adapter(interface_name: str) -> None: + run_command(["netsh", "interface", "set", "interface", interface_name, "disable"]) + time.sleep(2) + run_command(["netsh", "interface", "set", "interface", interface_name, "enable"]) + + +def disconnect_wifi(interface_name: str | None = None) -> None: + command = ["netsh", "wlan", "disconnect"] + if interface_name: + command.append(f"interface={interface_name}") + run_command(command) + + +def connect_wifi( + profile_name: str, + interface_name: str | None = None, + *, + ssid: str | None = None, + auto_create_open_profile: bool = False, +) -> None: + command = ["netsh", "wlan", "connect", f"name={profile_name}"] + if ssid: + command.append(f"ssid={ssid}") + if interface_name: + command.append(f"interface={interface_name}") + try: + run_command(command) + except WifiCommandError as exc: + if auto_create_open_profile and _is_missing_profile_error(str(exc), profile_name): + create_open_wifi_profile( + profile_name=profile_name, + ssid=ssid or profile_name, + interface_name=interface_name, + ) + run_command(command) + return + if _is_missing_profile_error(str(exc), profile_name): + available_profiles = list_wifi_profiles(interface_name) + profiles_display = ", ".join(available_profiles) if available_profiles else "" + target = interface_name or "" + raise WifiCommandError( + f'Windows WLAN profile "{profile_name}" is missing on interface {target}. ' + f"Available profiles: {profiles_display}", + ) from exc + raise + + +def list_wifi_profiles(interface_name: str | None = None) -> list[str]: + command = ["netsh", "wlan", "show", "profiles"] + if interface_name: + command.append(f"interface={interface_name}") + output = run_command(command) + + profiles: list[str] = [] + for raw_line in output.splitlines(): + line = raw_line.strip() + if not line or ":" not in line: + continue + key, value = line.split(":", 1) + key_lower = key.casefold() + if "profile" not in key_lower and "配置文件" not in key_lower and "設定檔" not in key_lower: + continue + name = value.strip() + if not name or name == "": + continue + profiles.append(name) + return profiles + + +def create_open_wifi_profile( + profile_name: str, + ssid: str, + interface_name: str | None = None, + *, + connection_mode: str = "auto", +) -> None: + profile_xml = _build_open_profile_xml(profile_name, ssid, connection_mode=connection_mode) + temp_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + suffix=".xml", + delete=False, + encoding="utf-8", + ) as handle: + handle.write(profile_xml) + temp_path = Path(handle.name) + + command = ["netsh", "wlan", "add", "profile", f"filename={temp_path}", "user=current"] + if interface_name: + command.append(f"interface={interface_name}") + run_command(command) + except WifiCommandError as exc: + raise WifiCommandError( + f'Failed to create an open Wi-Fi profile for SSID "{ssid}": {exc}', + ) from exc + finally: + if temp_path is not None: + temp_path.unlink(missing_ok=True) + + +def wait_for_ssid( + target_ssid: str, + timeout_seconds: int, + preferred_interface_name: str | None = None, +) -> WifiStatus: + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + status = get_wifi_status(preferred_interface_name) + if status.is_connected and status.ssid == target_ssid: + return status + time.sleep(1) + return get_wifi_status(preferred_interface_name) + + +def ensure_hardware_mac(adapter_locator: str | None = None) -> bool: + adapter_name = adapter_locator or _require_adapter_name() + reg_path = find_adapter_registry_key(adapter_name) + if not reg_path: + raise WifiCommandError(f"Could not find registry entry for adapter '{adapter_name}'") + + try: + key = winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, + reg_path, + 0, + winreg.KEY_SET_VALUE, + ) + try: + winreg.DeleteValue(key, "NetworkAddress") + except FileNotFoundError: + return False + finally: + winreg.CloseKey(key) + except PermissionError as exc: + raise WifiCommandError("Administrator privileges are required to restore hardware MAC") from exc + return True + + +def randomize_mac(adapter_locator: str | None = None) -> str: + adapter_name = adapter_locator or _require_adapter_name() + reg_path = find_adapter_registry_key(adapter_name) + if not reg_path: + raise WifiCommandError(f"Could not find registry entry for adapter '{adapter_name}'") + + mac_bytes = [random.randint(0x00, 0xFF) for _ in range(6)] + mac_bytes[0] = (mac_bytes[0] & 0xFC) | 0x02 + mac_compact = "".join(f"{part:02X}" for part in mac_bytes) + + try: + key = winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, + reg_path, + 0, + winreg.KEY_SET_VALUE, + ) + winreg.SetValueEx(key, "NetworkAddress", 0, winreg.REG_SZ, mac_compact) + winreg.CloseKey(key) + except PermissionError as exc: + raise WifiCommandError("Administrator privileges are required to randomize the MAC address") from exc + + return ":".join(f"{part:02X}" for part in mac_bytes) + + +def find_adapter_registry_key(adapter_name: str) -> str | None: + base_path = r"SYSTEM\CurrentControlSet\Control\Class\{4d36e972-e325-11ce-bfc1-08002be10318}" + try: + base_key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, base_path) + except OSError: + return None + + try: + index = 0 + adapter_name_folded = adapter_name.casefold() + while True: + try: + subkey_name = winreg.EnumKey(base_key, index) + except OSError: + return None + index += 1 + + full_path = f"{base_path}\\{subkey_name}" + try: + subkey = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, full_path) + except OSError: + continue + + try: + for field_name in ("DriverDesc", "NetCfgInstanceId", "ComponentId"): + try: + value, _ = winreg.QueryValueEx(subkey, field_name) + except OSError: + continue + if adapter_name_folded in str(value).casefold(): + return full_path + + try: + connection_key = winreg.OpenKey(subkey, "Connection") + except OSError: + connection_key = None + if connection_key is not None: + try: + connection_name, _ = winreg.QueryValueEx(connection_key, "Name") + if adapter_name_folded in str(connection_name).casefold(): + return full_path + except OSError: + pass + finally: + winreg.CloseKey(connection_key) + finally: + winreg.CloseKey(subkey) + finally: + winreg.CloseKey(base_key) + + +def run_command(command: list[str]) -> str: + result = subprocess.run(command, capture_output=True, text=False, check=False) + stdout = _decode_output(result.stdout) + stderr = _decode_output(result.stderr) + if result.returncode != 0: + message = stderr or stdout or f"Command failed: {' '.join(command)}" + raise WifiCommandError(message.strip()) + return stdout + + +def _is_missing_profile_error(message: str, profile_name: str) -> bool: + folded = message.casefold() + profile_folded = profile_name.casefold() + return ( + "there is no profile" in folded + or "is not found on the system" in folded + or f'profile "{profile_folded}"' in folded and "not found" in folded + ) + + +def _build_open_profile_xml(profile_name: str, ssid: str, *, connection_mode: str) -> str: + escaped_name = _xml_escape(profile_name) + escaped_ssid = _xml_escape(ssid) + return f""" + + {escaped_name} + + + {escaped_ssid} + + false + + ESS + {connection_mode} + + + + open + none + false + + + + +""" + + +def _xml_escape(value: str) -> str: + return ( + value.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + .replace("'", "'") + ) + + +def _normalize_url(url: str) -> str: + return url.strip().rstrip("/").casefold() + + +def _require_adapter_name() -> str: + status = get_wifi_status() + if not status.interface_name: + raise WifiCommandError("Could not find a Wi-Fi adapter from 'netsh wlan show interfaces'") + return status.interface_name + + +def _decode_output(raw: bytes) -> str: + for encoding in _candidate_encodings(): + try: + return raw.decode(encoding) + except UnicodeDecodeError: + continue + return raw.decode("utf-8", errors="replace") + + +def _candidate_encodings() -> list[str]: + preferred = locale.getpreferredencoding(False) + return [preferred, "utf-8", "gbk", "cp936", "big5"] + + +def _parse_key_value_block(block: str) -> dict[str, str]: + mapping: dict[str, str] = {} + for raw_line in block.splitlines(): + line = raw_line.strip() + if not line or ":" not in line: + continue + key, value = line.split(":", 1) + normalized = _normalize_key(key.strip()) + if normalized: + mapping[normalized] = value.strip() + return mapping + + +def _normalize_key(key: str) -> str | None: + lookup = { + "name": "name", + "名称": "name", + "description": "description", + "描述": "description", + "state": "state", + "状态": "state", + "ssid": "ssid", + } + normalized = key.strip().casefold() + if normalized == "bssid": + return None + return lookup.get(key.strip(), lookup.get(normalized)) diff --git a/pyproject.toml b/pyproject.toml index 41e6d1f..ad40bcd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,10 +3,11 @@ name = "osu-wifi-login" version = "0.1.0" description = "Add your description here" readme = "README.md" -requires-python = ">=3.14" +requires-python = ">=3.11" dependencies = [ - "selenium>=4.40.0", - "webdriver-manager>=4.0.2", + "playwright>=1.52.0", + "pyyaml>=6.0.3", + "requests>=2.32.5", ] [dependency-groups] diff --git a/run.bat b/run.bat index fa64d10..23a4e95 100644 --- a/run.bat +++ b/run.bat @@ -1,3 +1,3 @@ -@echo off -cd /d "C:\Users\yuanz\src\osu-wifi-login" -uv run main.py \ No newline at end of file +@echo off +cd /d "C:\Users\yuanzhe\src\OSU-Public-Wi-Fi-Login" +.\.venv\Scripts\python.exe main.py --config config.yaml diff --git a/uv.lock b/uv.lock index c826311..c5bd2d6 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.14" +requires-python = ">=3.11" [[package]] name = "altgraph" @@ -11,24 +11,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" }, ] -[[package]] -name = "async-generator" -version = "1.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/b6/6fa6b3b598a03cba5e80f829e0dadbb49d7645f523d209b2fb7ea0bbb02a/async_generator-1.10.tar.gz", hash = "sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144", size = 29870, upload-time = "2018-08-01T03:36:21.69Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/71/52/39d20e03abd0ac9159c162ec24b93fbcaa111e8400308f2465432495ca2b/async_generator-1.10-py3-none-any.whl", hash = "sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b", size = 18857, upload-time = "2018-08-01T03:36:20.029Z" }, -] - -[[package]] -name = "attrs" -version = "25.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, -] - [[package]] name = "certifi" version = "2026.1.4" @@ -38,29 +20,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, ] -[[package]] -name = "cffi" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, -] - [[package]] name = "charset-normalizer" version = "3.4.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, @@ -81,12 +94,60 @@ wheels = [ ] [[package]] -name = "h11" -version = "0.16.0" +name = "greenlet" +version = "3.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +sdist = { url = "https://files.pythonhosted.org/packages/86/94/a5935717b307d7c71fe877b52b884c6af707d2d2090db118a03fbd799369/greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff", size = 195913, upload-time = "2026-04-08T17:08:00.863Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c6/dba32cab7e3a625b011aa5647486e2d28423a48845a2998c126dd69c85e1/greenlet-3.4.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:805bebb4945094acbab757d34d6e1098be6de8966009ab9ca54f06ff492def58", size = 285504, upload-time = "2026-04-08T15:52:14.071Z" }, + { url = "https://files.pythonhosted.org/packages/54/f4/7cb5c2b1feb9a1f50e038be79980dfa969aa91979e5e3a18fdbcfad2c517/greenlet-3.4.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:439fc2f12b9b512d9dfa681c5afe5f6b3232c708d13e6f02c845e0d9f4c2d8c6", size = 605476, upload-time = "2026-04-08T16:24:37.064Z" }, + { url = "https://files.pythonhosted.org/packages/d6/af/b66ab0b2f9a4c5a867c136bf66d9599f34f21a1bcca26a2884a29c450bd9/greenlet-3.4.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a70ed1cb0295bee1df57b63bf7f46b4e56a5c93709eea769c1fec1bb23a95875", size = 618336, upload-time = "2026-04-08T16:30:56.59Z" }, + { url = "https://files.pythonhosted.org/packages/6d/31/56c43d2b5de476f77d36ceeec436328533bff960a4cba9a07616e93063ab/greenlet-3.4.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c5696c42e6bb5cfb7c6ff4453789081c66b9b91f061e5e9367fa15792644e76", size = 625045, upload-time = "2026-04-08T16:40:37.111Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5c/8c5633ece6ba611d64bf2770219a98dd439921d6424e4e8cf16b0ac74ea5/greenlet-3.4.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c660bce1940a1acae5f51f0a064f1bc785d07ea16efcb4bc708090afc4d69e83", size = 613515, upload-time = "2026-04-08T15:56:32.478Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/704d4e2c90acb8bdf7ae593f5cbc95f58e82de95cc540fb75631c1054533/greenlet-3.4.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:89995ce5ddcd2896d89615116dd39b9703bfa0c07b583b85b89bf1b5d6eddf81", size = 419745, upload-time = "2026-04-08T16:43:04.022Z" }, + { url = "https://files.pythonhosted.org/packages/a9/df/950d15bca0d90a0e7395eb777903060504cdb509b7b705631e8fb69ff415/greenlet-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee407d4d1ca9dc632265aee1c8732c4a2d60adff848057cdebfe5fe94eb2c8a2", size = 1574623, upload-time = "2026-04-08T16:26:18.596Z" }, + { url = "https://files.pythonhosted.org/packages/1a/e7/0839afab829fcb7333c9ff6d80c040949510055d2d4d63251f0d1c7c804e/greenlet-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:956215d5e355fffa7c021d168728321fd4d31fd730ac609b1653b450f6a4bc71", size = 1639579, upload-time = "2026-04-08T15:57:29.231Z" }, + { url = "https://files.pythonhosted.org/packages/d9/2b/b4482401e9bcaf9f5c97f67ead38db89c19520ff6d0d6699979c6efcc200/greenlet-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:5cb614ace7c27571270354e9c9f696554d073f8aa9319079dcba466bbdead711", size = 238233, upload-time = "2026-04-08T17:02:54.286Z" }, + { url = "https://files.pythonhosted.org/packages/0c/4d/d8123a4e0bcd583d5cfc8ddae0bbe29c67aab96711be331a7cc935a35966/greenlet-3.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:04403ac74fe295a361f650818de93be11b5038a78f49ccfb64d3b1be8fbf1267", size = 235045, upload-time = "2026-04-08T17:04:05.072Z" }, + { url = "https://files.pythonhosted.org/packages/65/8b/3669ad3b3f247a791b2b4aceb3aa5a31f5f6817bf547e4e1ff712338145a/greenlet-3.4.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1a54a921561dd9518d31d2d3db4d7f80e589083063ab4d3e2e950756ef809e1a", size = 286902, upload-time = "2026-04-08T15:52:12.138Z" }, + { url = "https://files.pythonhosted.org/packages/38/3e/3c0e19b82900873e2d8469b590a6c4b3dfd2b316d0591f1c26b38a4879a5/greenlet-3.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16dec271460a9a2b154e3b1c2fa1050ce6280878430320e85e08c166772e3f97", size = 606099, upload-time = "2026-04-08T16:24:38.408Z" }, + { url = "https://files.pythonhosted.org/packages/b5/33/99fef65e7754fc76a4ed14794074c38c9ed3394a5bd129d7f61b705f3168/greenlet-3.4.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90036ce224ed6fe75508c1907a77e4540176dcf0744473627785dd519c6f9996", size = 618837, upload-time = "2026-04-08T16:30:58.298Z" }, + { url = "https://files.pythonhosted.org/packages/44/57/eae2cac10421feae6c0987e3dc106c6d86262b1cb379e171b017aba893a6/greenlet-3.4.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6f0def07ec9a71d72315cf26c061aceee53b306c36ed38c35caba952ea1b319d", size = 624901, upload-time = "2026-04-08T16:40:38.981Z" }, + { url = "https://files.pythonhosted.org/packages/36/f7/229f3aed6948faa20e0616a0b8568da22e365ede6a54d7d369058b128afd/greenlet-3.4.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1c4f6b453006efb8310affb2d132832e9bbb4fc01ce6df6b70d810d38f1f6dc", size = 615062, upload-time = "2026-04-08T15:56:33.766Z" }, + { url = "https://files.pythonhosted.org/packages/6a/8a/0e73c9b94f31d1cc257fe79a0eff621674141cdae7d6d00f40de378a1e42/greenlet-3.4.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:0e1254cf0cbaa17b04320c3a78575f29f3c161ef38f59c977108f19ffddaf077", size = 423927, upload-time = "2026-04-08T16:43:05.293Z" }, + { url = "https://files.pythonhosted.org/packages/08/97/d988180011aa40135c46cd0d0cf01dd97f7162bae14139b4a3ef54889ba5/greenlet-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b2d9a138ffa0e306d0e2b72976d2fb10b97e690d40ab36a472acaab0838e2de", size = 1573511, upload-time = "2026-04-08T16:26:20.058Z" }, + { url = "https://files.pythonhosted.org/packages/d4/0f/a5a26fe152fb3d12e6a474181f6e9848283504d0afd095f353d85726374b/greenlet-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8424683caf46eb0eb6f626cb95e008e8cc30d0cb675bdfa48200925c79b38a08", size = 1640396, upload-time = "2026-04-08T15:57:30.88Z" }, + { url = "https://files.pythonhosted.org/packages/42/cf/bb2c32d9a100e36ee9f6e38fad6b1e082b8184010cb06259b49e1266ca01/greenlet-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0a53fb071531d003b075c444014ff8f8b1a9898d36bb88abd9ac7b3524648a2", size = 238892, upload-time = "2026-04-08T17:03:10.094Z" }, + { url = "https://files.pythonhosted.org/packages/b7/47/6c41314bac56e71436ce551c7fbe3cc830ed857e6aa9708dbb9c65142eb6/greenlet-3.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:f38b81880ba28f232f1f675893a39cf7b6db25b31cc0a09bb50787ecf957e85e", size = 235599, upload-time = "2026-04-08T15:52:54.3Z" }, + { url = "https://files.pythonhosted.org/packages/7a/75/7e9cd1126a1e1f0cd67b0eda02e5221b28488d352684704a78ed505bd719/greenlet-3.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43748988b097f9c6f09364f260741aa73c80747f63389824435c7a50bfdfd5c1", size = 285856, upload-time = "2026-04-08T15:52:45.82Z" }, + { url = "https://files.pythonhosted.org/packages/9d/c4/3e2df392e5cb199527c4d9dbcaa75c14edcc394b45040f0189f649631e3c/greenlet-3.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5566e4e2cd7a880e8c27618e3eab20f3494452d12fd5129edef7b2f7aa9a36d1", size = 610208, upload-time = "2026-04-08T16:24:39.674Z" }, + { url = "https://files.pythonhosted.org/packages/da/af/750cdfda1d1bd30a6c28080245be8d0346e669a98fdbae7f4102aa95fff3/greenlet-3.4.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1054c5a3c78e2ab599d452f23f7adafef55062a783a8e241d24f3b633ba6ff82", size = 621269, upload-time = "2026-04-08T16:30:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/e0/93/c8c508d68ba93232784bbc1b5474d92371f2897dfc6bc281b419f2e0d492/greenlet-3.4.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98eedd1803353daf1cd9ef23eef23eda5a4d22f99b1f998d273a8b78b70dd47f", size = 628455, upload-time = "2026-04-08T16:40:40.698Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/0cbc693622cd54ebe25207efbb3a0eb07c2639cb8594f6e3aaaa0bb077a8/greenlet-3.4.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f82cb6cddc27dd81c96b1506f4aa7def15070c3b2a67d4e46fd19016aacce6cf", size = 617549, upload-time = "2026-04-08T15:56:34.893Z" }, + { url = "https://files.pythonhosted.org/packages/7f/46/cfaaa0ade435a60550fd83d07dfd5c41f873a01da17ede5c4cade0b9bab8/greenlet-3.4.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:b7857e2202aae67bc5725e0c1f6403c20a8ff46094ece015e7d474f5f7020b55", size = 426238, upload-time = "2026-04-08T16:43:06.865Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c0/8966767de01343c1ff47e8b855dc78e7d1a8ed2b7b9c83576a57e289f81d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:227a46251ecba4ff46ae742bc5ce95c91d5aceb4b02f885487aff269c127a729", size = 1575310, upload-time = "2026-04-08T16:26:21.671Z" }, + { url = "https://files.pythonhosted.org/packages/b8/38/bcdc71ba05e9a5fda87f63ffc2abcd1f15693b659346df994a48c968003d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5b99e87be7eba788dd5b75ba1cde5639edffdec5f91fe0d734a249535ec3408c", size = 1640435, upload-time = "2026-04-08T15:57:32.572Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c2/19b664b7173b9e4ef5f77e8cef9f14c20ec7fce7920dc1ccd7afd955d093/greenlet-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:849f8bc17acd6295fcb5de8e46d55cc0e52381c56eaf50a2afd258e97bc65940", size = 238760, upload-time = "2026-04-08T17:04:03.878Z" }, + { url = "https://files.pythonhosted.org/packages/9b/96/795619651d39c7fbd809a522f881aa6f0ead504cc8201c3a5b789dfaef99/greenlet-3.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9390ad88b652b1903814eaabd629ca184db15e0eeb6fe8a390bbf8b9106ae15a", size = 235498, upload-time = "2026-04-08T17:05:00.584Z" }, + { url = "https://files.pythonhosted.org/packages/78/02/bde66806e8f169cf90b14d02c500c44cdbe02c8e224c9c67bafd1b8cadd1/greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e", size = 286291, upload-time = "2026-04-08T17:09:34.307Z" }, + { url = "https://files.pythonhosted.org/packages/05/1f/39da1c336a87d47c58352fb8a78541ce63d63ae57c5b9dae1fe02801bbc2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d", size = 656749, upload-time = "2026-04-08T16:24:41.721Z" }, + { url = "https://files.pythonhosted.org/packages/d3/6c/90ee29a4ee27af7aa2e2ec408799eeb69ee3fcc5abcecac6ddd07a5cd0f2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615", size = 669084, upload-time = "2026-04-08T16:31:01.372Z" }, + { url = "https://files.pythonhosted.org/packages/d2/4a/74078d3936712cff6d3c91a930016f476ce4198d84e224fe6d81d3e02880/greenlet-3.4.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:06c2d3b89e0c62ba50bd7adf491b14f39da9e7e701647cb7b9ff4c99bee04b19", size = 673405, upload-time = "2026-04-08T16:40:42.527Z" }, + { url = "https://files.pythonhosted.org/packages/07/49/d4cad6e5381a50947bb973d2f6cf6592621451b09368b8c20d9b8af49c5b/greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf", size = 665621, upload-time = "2026-04-08T15:56:35.995Z" }, + { url = "https://files.pythonhosted.org/packages/79/3e/df8a83ab894751bc31e1106fdfaa80ca9753222f106b04de93faaa55feb7/greenlet-3.4.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:070b8bac2ff3b4d9e0ff36a0d19e42103331d9737e8504747cd1e659f76297bd", size = 471670, upload-time = "2026-04-08T16:43:08.512Z" }, + { url = "https://files.pythonhosted.org/packages/37/31/d1edd54f424761b5d47718822f506b435b6aab2f3f93b465441143ea5119/greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf", size = 1622259, upload-time = "2026-04-08T16:26:23.201Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c6/6d3f9cdcb21c4e12a79cb332579f1c6aa1af78eb68059c5a957c7812d95e/greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda", size = 1686916, upload-time = "2026-04-08T15:57:34.282Z" }, + { url = "https://files.pythonhosted.org/packages/63/45/c1ca4a1ad975de4727e52d3ffe641ae23e1d7a8ffaa8ff7a0477e1827b92/greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d", size = 239821, upload-time = "2026-04-08T17:03:48.423Z" }, + { url = "https://files.pythonhosted.org/packages/71/c4/6f621023364d7e85a4769c014c8982f98053246d142420e0328980933ceb/greenlet-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802", size = 236932, upload-time = "2026-04-08T17:04:33.551Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8f/18d72b629783f5e8d045a76f5325c1e938e659a9e4da79c7dcd10169a48d/greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece", size = 294681, upload-time = "2026-04-08T15:52:35.778Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ad/5fa86ec46769c4153820d58a04062285b3b9e10ba3d461ee257b68dcbf53/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8", size = 658899, upload-time = "2026-04-08T16:24:43.32Z" }, + { url = "https://files.pythonhosted.org/packages/43/f0/4e8174ca0e87ae748c409f055a1ba161038c43cc0a5a6f1433a26ac2e5bf/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2", size = 665284, upload-time = "2026-04-08T16:31:02.833Z" }, + { url = "https://files.pythonhosted.org/packages/ef/92/466b0d9afd44b8af623139a3599d651c7564fa4152f25f117e1ee5949ffb/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4cd56a9eb7a6444edbc19062f7b6fbc8f287c663b946e3171d899693b1c19fa", size = 665872, upload-time = "2026-04-08T16:40:43.912Z" }, + { url = "https://files.pythonhosted.org/packages/19/da/991cf7cd33662e2df92a1274b7eb4d61769294d38a1bba8a45f31364845e/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed", size = 661861, upload-time = "2026-04-08T15:56:37.269Z" }, + { url = "https://files.pythonhosted.org/packages/0d/14/3395a7ef3e260de0325152ddfe19dffb3e49fe10873b94654352b53ad48e/greenlet-3.4.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1f85f204c4d54134ae850d401fa435c89cd667d5ce9dc567571776b45941af72", size = 489237, upload-time = "2026-04-08T16:43:09.993Z" }, + { url = "https://files.pythonhosted.org/packages/36/c5/6c2c708e14db3d9caea4b459d8464f58c32047451142fe2cfd90e7458f41/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f", size = 1622182, upload-time = "2026-04-08T16:26:24.777Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4c/50c5fed19378e11a29fabab1f6be39ea95358f4a0a07e115a51ca93385d8/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a", size = 1685050, upload-time = "2026-04-08T15:57:36.453Z" }, + { url = "https://files.pythonhosted.org/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705", size = 245554, upload-time = "2026-04-08T17:03:50.044Z" }, ] [[package]] @@ -98,18 +159,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] -[[package]] -name = "importlib-metadata" -version = "8.7.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "zipp" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, -] - [[package]] name = "macholib" version = "1.16.4" @@ -122,22 +171,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" }, ] -[[package]] -name = "mypy-extensions" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, -] - [[package]] name = "osu-wifi-login" version = "0.1.0" source = { virtual = "." } dependencies = [ - { name = "selenium" }, - { name = "webdriver-manager" }, + { name = "playwright" }, + { name = "pyyaml" }, + { name = "requests" }, ] [package.dev-dependencies] @@ -147,25 +188,14 @@ dev = [ [package.metadata] requires-dist = [ - { name = "selenium", specifier = ">=4.40.0" }, - { name = "webdriver-manager", specifier = ">=4.0.2" }, + { name = "playwright", specifier = ">=1.52.0" }, + { name = "pyyaml", specifier = ">=6.0.3" }, + { name = "requests", specifier = ">=2.32.5" }, ] [package.metadata.requires-dev] dev = [{ name = "pyinstaller", specifier = ">=6.18.0" }] -[[package]] -name = "outcome" -version = "1.3.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692, upload-time = "2023-10-26T04:26:02.532Z" }, -] - [[package]] name = "packaging" version = "26.0" @@ -185,12 +215,34 @@ wheels = [ ] [[package]] -name = "pycparser" -version = "3.0" +name = "playwright" +version = "1.58.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c9/9c6061d5703267f1baae6a4647bfd1862e386fbfdb97d889f6f6ae9e3f64/playwright-1.58.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:96e3204aac292ee639edbfdef6298b4be2ea0a55a16b7068df91adac077cc606", size = 42251098, upload-time = "2026-01-30T15:09:24.028Z" }, + { url = "https://files.pythonhosted.org/packages/e0/40/59d34a756e02f8c670f0fee987d46f7ee53d05447d43cd114ca015cb168c/playwright-1.58.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:70c763694739d28df71ed578b9c8202bb83e8fe8fb9268c04dd13afe36301f71", size = 41039625, upload-time = "2026-01-30T15:09:27.558Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ee/3ce6209c9c74a650aac9028c621f357a34ea5cd4d950700f8e2c4b7fe2c4/playwright-1.58.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:185e0132578733d02802dfddfbbc35f42be23a45ff49ccae5081f25952238117", size = 42251098, upload-time = "2026-01-30T15:09:30.461Z" }, + { url = "https://files.pythonhosted.org/packages/f1/af/009958cbf23fac551a940d34e3206e6c7eed2b8c940d0c3afd1feb0b0589/playwright-1.58.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c95568ba1eda83812598c1dc9be60b4406dffd60b149bc1536180ad108723d6b", size = 46235268, upload-time = "2026-01-30T15:09:33.787Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a6/0e66ad04b6d3440dae73efb39540c5685c5fc95b17c8b29340b62abbd952/playwright-1.58.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f9999948f1ab541d98812de25e3a8c410776aa516d948807140aff797b4bffa", size = 45964214, upload-time = "2026-01-30T15:09:36.751Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/236e60ab9f6d62ed0fd32150d61f1f494cefbf02304c0061e78ed80c1c32/playwright-1.58.0-py3-none-win32.whl", hash = "sha256:1e03be090e75a0fabbdaeab65ce17c308c425d879fa48bb1d7986f96bfad0b99", size = 36815998, upload-time = "2026-01-30T15:09:39.627Z" }, + { url = "https://files.pythonhosted.org/packages/41/f8/5ec599c5e59d2f2f336a05b4f318e733077cd5044f24adb6f86900c3e6a7/playwright-1.58.0-py3-none-win_amd64.whl", hash = "sha256:a2bf639d0ce33b3ba38de777e08697b0d8f3dc07ab6802e4ac53fb65e3907af8", size = 36816005, upload-time = "2026-01-30T15:09:42.449Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c4/cc0229fea55c87d6c9c67fe44a21e2cd28d1d558a5478ed4d617e9fb0c93/playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b", size = 33085919, upload-time = "2026-01-30T15:09:45.71Z" }, +] + +[[package]] +name = "pyee" +version = "13.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, ] [[package]] @@ -234,24 +286,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d5/b1/9da6ec3e88696018ee7bb9dc4a7310c2cfaebf32923a19598cd342767c10/pyinstaller_hooks_contrib-2026.0-py3-none-any.whl", hash = "sha256:0590db8edeba3e6c30c8474937021f5cd39c0602b4d10f74a064c73911efaca5", size = 452318, upload-time = "2026-01-20T00:15:21.88Z" }, ] -[[package]] -name = "pysocks" -version = "1.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" }, -] - -[[package]] -name = "python-dotenv" -version = "1.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, -] - [[package]] name = "pywin32-ctypes" version = "0.2.3" @@ -261,6 +295,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -276,26 +365,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] -[[package]] -name = "selenium" -version = "4.40.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "trio" }, - { name = "trio-typing" }, - { name = "trio-websocket" }, - { name = "types-certifi" }, - { name = "types-urllib3" }, - { name = "typing-extensions" }, - { name = "urllib3", extra = ["socks"] }, - { name = "websocket-client" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/ef/a5727fa7b33d20d296322adf851b76072d8d3513e1b151969d3228437faf/selenium-4.40.0.tar.gz", hash = "sha256:a88f5905d88ad0b84991c2386ea39e2bbde6d6c334be38df5842318ba98eaa8c", size = 930444, upload-time = "2026-01-18T23:12:31.565Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/74/eb9d6540aca1911106fa0877b8e9ef24171bc18857937a6b0ffe0586c623/selenium-4.40.0-py3-none-any.whl", hash = "sha256:c8823fc02e2c771d9ad9a0cf899cee7de1a57a6697e3d0b91f67566129f2b729", size = 9608184, upload-time = "2026-01-18T23:12:29.435Z" }, -] - [[package]] name = "setuptools" version = "80.10.2" @@ -305,90 +374,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/b8/f1f62a5e3c0ad2ff1d189590bfa4c46b4f3b6e49cef6f26c6ee4e575394d/setuptools-80.10.2-py3-none-any.whl", hash = "sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173", size = 1064234, upload-time = "2026-01-25T22:38:15.216Z" }, ] -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, -] - -[[package]] -name = "sortedcontainers" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, -] - -[[package]] -name = "trio" -version = "0.32.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" }, - { name = "idna" }, - { name = "outcome" }, - { name = "sniffio" }, - { name = "sortedcontainers" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d8/ce/0041ddd9160aac0031bcf5ab786c7640d795c797e67c438e15cfedf815c8/trio-0.32.0.tar.gz", hash = "sha256:150f29ec923bcd51231e1d4c71c7006e65247d68759dd1c19af4ea815a25806b", size = 605323, upload-time = "2025-10-31T07:18:17.466Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/bf/945d527ff706233636c73880b22c7c953f3faeb9d6c7e2e85bfbfd0134a0/trio-0.32.0-py3-none-any.whl", hash = "sha256:4ab65984ef8370b79a76659ec87aa3a30c5c7c83ff250b4de88c29a8ab6123c5", size = 512030, upload-time = "2025-10-31T07:18:15.885Z" }, -] - -[[package]] -name = "trio-typing" -version = "0.10.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "async-generator" }, - { name = "importlib-metadata" }, - { name = "mypy-extensions" }, - { name = "packaging" }, - { name = "trio" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b5/74/a87aafa40ec3a37089148b859892cbe2eef08d132c816d58a60459be5337/trio-typing-0.10.0.tar.gz", hash = "sha256:065ee684296d52a8ab0e2374666301aec36ee5747ac0e7a61f230250f8907ac3", size = 38747, upload-time = "2023-12-01T02:54:55.508Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/89/ff/9bd795273eb14fac7f6a59d16cc8c4d0948a619a1193d375437c7f50f3eb/trio_typing-0.10.0-py3-none-any.whl", hash = "sha256:6d0e7ec9d837a2fe03591031a172533fbf4a1a95baf369edebfc51d5a49f0264", size = 42224, upload-time = "2023-12-01T02:54:54.1Z" }, -] - -[[package]] -name = "trio-websocket" -version = "0.12.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "outcome" }, - { name = "trio" }, - { name = "wsproto" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d1/3c/8b4358e81f2f2cfe71b66a267f023a91db20a817b9425dd964873796980a/trio_websocket-0.12.2.tar.gz", hash = "sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae", size = 33549, upload-time = "2025-02-25T05:16:58.947Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/19/eb640a397bba49ba49ef9dbe2e7e5c04202ba045b6ce2ec36e9cadc51e04/trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6", size = 21221, upload-time = "2025-02-25T05:16:57.545Z" }, -] - -[[package]] -name = "types-certifi" -version = "2021.10.8.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/68/943c3aeaf14624712a0357c4a67814dba5cea36d194f5c764dad7959a00c/types-certifi-2021.10.8.3.tar.gz", hash = "sha256:72cf7798d165bc0b76e1c10dd1ea3097c7063c42c21d664523b928e88b554a4f", size = 2095, upload-time = "2022-06-09T15:19:05.244Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/63/2463d89481e811f007b0e1cd0a91e52e141b47f9de724d20db7b861dcfec/types_certifi-2021.10.8.3-py3-none-any.whl", hash = "sha256:b2d1e325e69f71f7c78e5943d410e650b4707bb0ef32e4ddf3da37f54176e88a", size = 2136, upload-time = "2022-06-09T15:19:03.127Z" }, -] - -[[package]] -name = "types-urllib3" -version = "1.26.25.14" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/73/de/b9d7a68ad39092368fb21dd6194b362b98a1daeea5dcfef5e1adb5031c7e/types-urllib3-1.26.25.14.tar.gz", hash = "sha256:229b7f577c951b8c1b92c1bc2b2fdb0b49847bd2af6d1cc2a2e3dd340f3bda8f", size = 11239, upload-time = "2023-07-20T15:19:31.307Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/11/7b/3fc711b2efea5e85a7a0bbfe269ea944aa767bbba5ec52f9ee45d362ccf3/types_urllib3-1.26.25.14-py3-none-any.whl", hash = "sha256:9683bbb7fb72e32bfe9d2be6e04875fbe1b3eeec3cbb4ea231435aa7fd6b4f0e", size = 15377, upload-time = "2023-07-20T15:19:30.379Z" }, -] - [[package]] name = "typing-extensions" version = "4.15.0" @@ -406,52 +391,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6 wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] - -[package.optional-dependencies] -socks = [ - { name = "pysocks" }, -] - -[[package]] -name = "webdriver-manager" -version = "4.0.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, - { name = "python-dotenv" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/24/4f/6e44478908c5133f680378d687f14ecaa99feed2c535344fcf68d8d21500/webdriver_manager-4.0.2.tar.gz", hash = "sha256:efedf428f92fd6d5c924a0d054e6d1322dd77aab790e834ee767af392b35590f", size = 25940, upload-time = "2024-07-25T08:13:49.331Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/b5/3bd0b038d80950ec13e6a2c8d03ed8354867dc60064b172f2f4ffac8afbe/webdriver_manager-4.0.2-py2.py3-none-any.whl", hash = "sha256:75908d92ecc45ff2b9953614459c633db8f9aa1ff30181cefe8696e312908129", size = 27778, upload-time = "2024-07-25T08:13:47.917Z" }, -] - -[[package]] -name = "websocket-client" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, -] - -[[package]] -name = "wsproto" -version = "1.3.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, -] - -[[package]] -name = "zipp" -version = "3.23.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, -] diff --git a/winsw/README.md b/winsw/README.md new file mode 100644 index 0000000..2601e31 --- /dev/null +++ b/winsw/README.md @@ -0,0 +1,17 @@ +# WinSW + +推荐先用 PyInstaller 生成 `dist\osu-wifi-login\osu-wifi-login.exe`,再把 WinSW 放进同一个发布目录。 + +1. 下载 WinSW x64 可执行文件,并重命名为 `osu-wifi-login-service.exe` +2. 将它放到 `dist\osu-wifi-login\` +3. 将 `config.yaml` 放到 `dist\osu-wifi-login\` +4. 在 `dist\osu-wifi-login\` 创建与 exe 同名的 `osu-wifi-login-service.xml` +5. 以管理员 PowerShell 运行: + +```powershell +Set-Location .\dist\osu-wifi-login +.\osu-wifi-login-service.exe install +.\osu-wifi-login-service.exe start +``` + +如果你选择 `WiFi@OSU` 并启用随机 MAC,服务账户需要有管理员权限,否则无法写注册表并重启网卡。默认配置关闭随机 MAC。 diff --git a/winsw/osu-wifi-login.xml b/winsw/osu-wifi-login.xml new file mode 100644 index 0000000..3d3d2a5 --- /dev/null +++ b/winsw/osu-wifi-login.xml @@ -0,0 +1,17 @@ + + osu-wifi-login + OSU Wi-Fi Login Service + Keeps WiFi@OSU or eduroam connected on Windows and logs into the captive portal when needed. + + %BASE%\osu-wifi-login.exe + --config "%BASE%\config.yaml" + %BASE% + + + 1048576 + 8 + + + + 30 sec +