Rewrite as Windows Wi-Fi monitor service

This commit is contained in:
2026-04-24 10:44:28 -04:00
parent 955e636d5f
commit 18cc9b6bab
15 changed files with 2190 additions and 539 deletions

28
.gitignore vendored Normal file
View File

@@ -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

275
README.md
View File

@@ -1,60 +1,263 @@
# OSU Public Wi-Fi Auto Login
# OSU Public Wi-Fi Login
自动登录 OSUOhio 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
<service>
<id>osu-wifi-login</id>
<name>OSU Wi-Fi Login Service</name>
<description>Keeps WiFi@OSU or eduroam connected and logs into the captive portal when needed.</description>
<executable>%BASE%\osu-wifi-login.exe</executable>
<arguments>--config "%BASE%\config.yaml"</arguments>
<workingdirectory>%BASE%</workingdirectory>
<log mode="roll-by-size">
<sizeThreshold>1048576</sizeThreshold>
<keepFiles>8</keepFiles>
</log>
<onfailure action="restart" delay="10 sec" />
<stoptimeout>30 sec</stoptimeout>
</service>
```
以管理员 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
```
### 服务账户建议
默认不写 `<serviceaccount>`WinSW 通常使用 `LocalSystem`。这个程序需要能访问 WLAN profile、启动 Edge 自动化并执行 `netsh`,更推荐使用你自己的 Windows 用户运行服务。
如果要指定服务账户,在 XML 中加入:
```xml
<serviceaccount>
<domain>.</domain>
<user>YOUR_WINDOWS_ACCOUNT</user>
<password>YOUR_PASSWORD</password>
<allowservicelogon>true</allowservicelogon>
</serviceaccount>
```
如果你保持 `allow_unsafe_mac_changes: false`,通常不需要管理员权限来修改 MAC但重启网卡和某些 `netsh` 操作仍可能需要提升权限。若服务日志提示权限不足,再改用管理员账户运行服务。
## 当前注意事项
- `WiFi@OSU` 的 Captive Portal 表单字段仍然基于当前已知页面结构;如果学校改版,可能需要微调 YAML 或代码
- 现在默认不依赖 `msedgedriver`;复杂页面会自动回退到 Playwright + 已安装 Edge

55
config.example.yaml Normal file
View File

@@ -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

247
main.py
View File

@@ -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()
raise SystemExit(main())

View File

@@ -0,0 +1 @@
"""OSU Wi-Fi background monitor for Windows."""

47
osu_wifi_login/cli.py Normal file
View File

@@ -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

389
osu_wifi_login/config.py Normal file
View File

@@ -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

537
osu_wifi_login/portal.py Normal file
View File

@@ -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])

251
osu_wifi_login/service.py Normal file
View File

@@ -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

412
osu_wifi_login/windows.py Normal file
View File

@@ -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 "<none>"
target = interface_name or "<default>"
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 == "<None>":
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"""<?xml version="1.0"?>
<WLANProfile xmlns="http://www.microsoft.com/networking/WLAN/profile/v1">
<name>{escaped_name}</name>
<SSIDConfig>
<SSID>
<name>{escaped_ssid}</name>
</SSID>
<nonBroadcast>false</nonBroadcast>
</SSIDConfig>
<connectionType>ESS</connectionType>
<connectionMode>{connection_mode}</connectionMode>
<MSM>
<security>
<authEncryption>
<authentication>open</authentication>
<encryption>none</encryption>
<useOneX>false</useOneX>
</authEncryption>
</security>
</MSM>
</WLANProfile>
"""
def _xml_escape(value: str) -> str:
return (
value.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("'", "&apos;")
)
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))

View File

@@ -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]

View File

@@ -1,3 +1,3 @@
@echo off
cd /d "C:\Users\yuanz\src\osu-wifi-login"
uv run main.py
cd /d "C:\Users\yuanzhe\src\OSU-Public-Wi-Fi-Login"
.\.venv\Scripts\python.exe main.py --config config.yaml

440
uv.lock generated
View File

@@ -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" },
]

17
winsw/README.md Normal file
View File

@@ -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。

17
winsw/osu-wifi-login.xml Normal file
View File

@@ -0,0 +1,17 @@
<service>
<id>osu-wifi-login</id>
<name>OSU Wi-Fi Login Service</name>
<description>Keeps WiFi@OSU or eduroam connected on Windows and logs into the captive portal when needed.</description>
<executable>%BASE%\osu-wifi-login.exe</executable>
<arguments>--config "%BASE%\config.yaml"</arguments>
<workingdirectory>%BASE%</workingdirectory>
<log mode="roll-by-size">
<sizeThreshold>1048576</sizeThreshold>
<keepFiles>8</keepFiles>
</log>
<onfailure action="restart" delay="10 sec" />
<stoptimeout>30 sec</stoptimeout>
</service>