Rewrite as Windows Wi-Fi monitor service
This commit is contained in:
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal 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
275
README.md
@@ -1,60 +1,263 @@
|
||||
# OSU Public Wi-Fi Auto Login
|
||||
# OSU Public Wi-Fi Login
|
||||
|
||||
自动登录 OSU(Ohio State University)公共 Wi-Fi 的脚本。通过 Selenium 驱动 Edge 浏览器,自动完成 Captive Portal 的条款确认和登录操作。
|
||||
适用于 Windows 11 的 OSU Wi-Fi 后台守护程序。它会持续监控无线网络状态,并按配置自动修复连接。
|
||||
|
||||
## 工作原理
|
||||
这个重写版本选择了 Python,而不是 PowerShell 或 Rust,原因是:
|
||||
|
||||
1. 检测当前网络是否已连接(通过 HTTP 204 / connecttest 验证)
|
||||
2. 若未连接,使用 headless Edge 浏览器访问 Captive Portal 触发 URL(自动尝试多个备选地址)
|
||||
3. 自动勾选 "I accept the terms of use" 并点击 "Log In"
|
||||
4. 登录后轮询验证网络是否真正连通,失败则自动重试(最多 3 次)
|
||||
- `WiFi@OSU` Captive Portal 可以直接用 HTTP 表单提交流程处理,不依赖浏览器驱动
|
||||
- 如果 portal 页面结构比较复杂,程序会自动回退到 Playwright 驱动已安装的 Microsoft Edge
|
||||
- Windows 下调用 `netsh`、修改注册表、接 WinSW 都很直接
|
||||
- 后续想打包成单文件 `exe` 也比较方便
|
||||
|
||||
## 功能
|
||||
|
||||
- 后台常驻运行,而不是一次性触发
|
||||
- 可在 `WiFi@OSU` 和 `eduroam` 之间切换
|
||||
- 支持 YAML 配置网络类型和检查/重试时间
|
||||
- `WiFi@OSU` 模式下:
|
||||
- 启动时如果 Wi-Fi 还没连上,会主动尝试连接目标网络
|
||||
- 可选启用随机 MAC
|
||||
- 可按周期刷新 MAC 并重连
|
||||
- 自动打开 Captive Portal 并完成条款确认/登录
|
||||
- `eduroam` 模式下:
|
||||
- 启动时如果 Wi-Fi 还没连上,会主动尝试连接目标网络
|
||||
- 使用系统已保存的凭据
|
||||
- 可恢复为硬件 MAC
|
||||
- 仅做断线检测和自动重连
|
||||
- 支持 WinSW 包装为 Windows 服务
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
main.py CLI 入口
|
||||
osu_wifi_login/config.py YAML 配置加载
|
||||
osu_wifi_login/windows.py netsh / 注册表 / 连通性检测
|
||||
osu_wifi_login/portal.py HTTP Portal 登录
|
||||
osu_wifi_login/service.py 常驻监控与恢复逻辑
|
||||
config.example.yaml 配置示例
|
||||
winsw/ WinSW 服务模板
|
||||
```
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Python >= 3.14
|
||||
- Microsoft Edge 浏览器
|
||||
- [uv](https://docs.astral.sh/uv/) 包管理器
|
||||
- Windows 11 x86-64
|
||||
- Python 3.11+
|
||||
- Microsoft Edge
|
||||
- [uv](https://docs.astral.sh/uv/)
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
```powershell
|
||||
uv sync
|
||||
```
|
||||
|
||||
## 使用
|
||||
仓库已经附带默认的 `config.yaml`,直接编辑即可;如果想恢复模板,也可以参考 `config.example.yaml`。
|
||||
如果 `WiFi@OSU` portal 页面较复杂,运行时会使用 Playwright 调用本机已安装的 Edge,所以第一次改完依赖后需要先完成一次 `uv sync`。
|
||||
|
||||
```bash
|
||||
uv run main.py
|
||||
## 配置
|
||||
|
||||
默认配置文件是根目录下的 `config.yaml`。
|
||||
|
||||
最常用的项如下:
|
||||
|
||||
```yaml
|
||||
selected_network: wifi_osu
|
||||
|
||||
wifi_interface_name: Wi-Fi
|
||||
allow_unsafe_mac_changes: false
|
||||
randomize_mac_on_start: false
|
||||
|
||||
networks:
|
||||
wifi_osu:
|
||||
ssid: WiFi@OSU
|
||||
profile_name: WiFi@OSU
|
||||
requires_portal_login: true
|
||||
randomize_mac: false
|
||||
restore_hardware_mac: false
|
||||
auto_create_open_profile: true
|
||||
mac_refresh_hours: 6
|
||||
|
||||
eduroam:
|
||||
ssid: eduroam
|
||||
profile_name: eduroam
|
||||
requires_portal_login: false
|
||||
randomize_mac: false
|
||||
restore_hardware_mac: true
|
||||
auto_create_open_profile: false
|
||||
|
||||
connectivity_checks:
|
||||
- url: http://example.com/
|
||||
expected_status: 200
|
||||
expected_text: Example Domain
|
||||
require_final_url_match: true
|
||||
allow_redirects: false
|
||||
```
|
||||
|
||||
### 可选参数
|
||||
说明:
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `--random-mac` | 登录前随机化 Wi-Fi 网卡的 MAC 地址(需要**管理员权限**) |
|
||||
- `selected_network` 设为 `wifi_osu` 或 `eduroam`
|
||||
- `wifi_interface_name` 一般是 `Wi-Fi`,如果你的系统接口名不同,需要改成实际值
|
||||
- `profile_name` 是 Windows 已保存的 WLAN 配置名,通常与 SSID 相同
|
||||
- `auto_create_open_profile` 适合 `WiFi@OSU` 这类开放网络;如果本机还没有保存过 profile,程序会先自动创建一个基础 profile 再连接
|
||||
- `connectivity_checks` 默认使用像 `example.com` 这样的普通页面,不再使用 Windows/Apple/Firefox 的系统探针地址,避免被 portal 白名单误判成“已联网”
|
||||
- `allow_unsafe_mac_changes` 默认关闭;关闭时不会写注册表改 `NetworkAddress`
|
||||
- `randomize_mac` 和 `restore_hardware_mac` 都只有在 `allow_unsafe_mac_changes: true` 时才会生效
|
||||
- 某些无线网卡驱动在修改 MAC 时会直接蓝屏,建议默认保持关闭
|
||||
- `connect_retry_cooldown_seconds` 控制“Wi-Fi 完全没连上时”主动重连的频率
|
||||
- `disconnect_grace_seconds` 表示断线多久后开始强制恢复
|
||||
- `connection_timeout_seconds` 会用于登录后等待网络放行,默认给 captive portal 留 60 秒
|
||||
- `mac_refresh_hours` 只对 `WiFi@OSU` 生效
|
||||
|
||||
示例:
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
# 普通登录
|
||||
uv run main.py
|
||||
前台调试运行:
|
||||
|
||||
# 随机 MAC 地址后登录
|
||||
uv run main.py --random-mac
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe main.py --config config.yaml
|
||||
```
|
||||
|
||||
> **注意**:`--random-mac` 会通过修改注册表并重启网卡来更改 MAC 地址,需要以管理员身份运行。
|
||||
只跑一次检查:
|
||||
|
||||
## Windows 开机自启
|
||||
|
||||
1. 按 `Win + R`,输入 `shell:startup` 打开启动文件夹
|
||||
2. 将 `run.bat` 的快捷方式放入该文件夹
|
||||
3. 修改 `run.bat` 中的路径为你的实际项目路径
|
||||
|
||||
## 打包为可执行文件
|
||||
|
||||
```bash
|
||||
uv run pyinstaller --onefile main.py
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe main.py --config config.yaml --once
|
||||
```
|
||||
|
||||
生成的 `main.exe` 位于 `dist/` 目录下。
|
||||
## 服务逻辑
|
||||
|
||||
程序每轮会做这些事:
|
||||
|
||||
1. 检查当前是否连在目标 SSID 上
|
||||
2. 如果 Wi-Fi 完全没连上,先直接尝试连接目标网络
|
||||
3. 如果是 `WiFi@OSU`,刚连上后会主动探测 captive portal;检测到登录页就立刻登录
|
||||
4. 检查是否真的能上网
|
||||
5. 如果断线或异常持续超过阈值,则重启 Wi-Fi 网卡并重新连接
|
||||
6. 如果你显式开启了高风险 MAC 改写,才会在启动时和刷新周期到达时改 MAC 并重连
|
||||
|
||||
## 管理员权限
|
||||
|
||||
以下操作需要管理员权限:
|
||||
|
||||
- 修改 `NetworkAddress` 注册表项
|
||||
- 为 `WiFi@OSU` 随机化 MAC
|
||||
- 清除随机 MAC,恢复硬件 MAC
|
||||
- 重启无线网卡
|
||||
|
||||
其中 MAC 相关操作在部分驱动上可能导致蓝屏;现在默认关闭,只有在你明确打开 `allow_unsafe_mac_changes: true` 后才会执行。
|
||||
|
||||
## 编译发布
|
||||
|
||||
推荐使用 PyInstaller 的 `onedir` 模式发布。这个模式会生成一个完整目录,里面包含 `exe`、Python 运行时、第三方依赖和 Playwright 驱动文件;目标机器仍需要安装 Microsoft Edge,但不需要安装 Python、uv 或项目依赖。
|
||||
|
||||
```powershell
|
||||
uv sync
|
||||
uv run pyinstaller --noconfirm --clean --onedir --name osu-wifi-login main.py
|
||||
```
|
||||
|
||||
生成结果位于:
|
||||
|
||||
```text
|
||||
dist\osu-wifi-login\osu-wifi-login.exe
|
||||
```
|
||||
|
||||
把 `config.yaml` 放到同一个发布目录中:
|
||||
|
||||
```powershell
|
||||
Copy-Item config.yaml dist\osu-wifi-login\config.yaml
|
||||
```
|
||||
|
||||
发布目录最终至少应包含:
|
||||
|
||||
```text
|
||||
dist\osu-wifi-login\
|
||||
osu-wifi-login.exe
|
||||
config.yaml
|
||||
_internal\
|
||||
```
|
||||
|
||||
前台验证:
|
||||
|
||||
```powershell
|
||||
.\dist\osu-wifi-login\osu-wifi-login.exe --config .\dist\osu-wifi-login\config.yaml --once
|
||||
```
|
||||
|
||||
也可以生成真正的单文件 `exe`:
|
||||
|
||||
```powershell
|
||||
uv run pyinstaller --noconfirm --clean --onefile --name osu-wifi-login main.py
|
||||
```
|
||||
|
||||
不过不建议优先使用 `onefile`。Playwright 会在运行时解包和查找驱动文件,`onedir` 更稳定;`onefile` 虽然只有一个 `exe`,但启动更慢,也更容易遇到服务账户临时目录权限问题。无论使用哪种模式,Microsoft Edge 仍需要由系统提供,因为程序使用的是已安装 Edge 渠道。
|
||||
|
||||
## WinSW 服务
|
||||
|
||||
仓库中已经提供:
|
||||
|
||||
- [winsw/osu-wifi-login.xml](/C:/Users/yuanzhe/src/OSU-Public-Wi-Fi-Login/winsw/osu-wifi-login.xml)
|
||||
- [winsw/README.md](/C:/Users/yuanzhe/src/OSU-Public-Wi-Fi-Login/winsw/README.md)
|
||||
|
||||
### 使用编译后的 exe
|
||||
|
||||
1. 下载 WinSW x64 可执行文件
|
||||
2. 将 WinSW 重命名为 `osu-wifi-login-service.exe`
|
||||
3. 将 `osu-wifi-login-service.exe` 放入 `dist\osu-wifi-login\`
|
||||
4. 在同一目录创建 `osu-wifi-login-service.xml`
|
||||
|
||||
`dist\osu-wifi-login\osu-wifi-login-service.xml` 示例:
|
||||
|
||||
```xml
|
||||
<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
55
config.example.yaml
Normal 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
247
main.py
@@ -1,248 +1,5 @@
|
||||
import argparse
|
||||
import random
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import requests
|
||||
import winreg
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.edge.options import Options
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
|
||||
# Captive Portal 触发 URL 列表,按优先级依次尝试
|
||||
CAPTIVE_PORTAL_URLS = [
|
||||
"http://captive.apple.com/",
|
||||
"http://www.msftconnecttest.com/redirect",
|
||||
"http://detectportal.firefox.com/",
|
||||
]
|
||||
|
||||
MAX_LOGIN_RETRIES = 3
|
||||
|
||||
|
||||
def is_connected():
|
||||
"""通过 HTTP 204 检测网络是否连通。"""
|
||||
test_urls = [
|
||||
("http://clients3.google.com/generate_204", 204),
|
||||
("http://www.msftconnecttest.com/connecttest.txt", 200),
|
||||
]
|
||||
for url, expected_status in test_urls:
|
||||
try:
|
||||
r = requests.get(url, timeout=3)
|
||||
if r.status_code == expected_status:
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def get_wifi_adapter_name():
|
||||
"""获取当前活跃的 Wi-Fi 网卡名称。"""
|
||||
result = subprocess.run(
|
||||
["netsh", "wlan", "show", "interfaces"],
|
||||
capture_output=True, text=True, encoding="gbk",
|
||||
)
|
||||
for line in result.stdout.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("名称") or line.startswith("Name"):
|
||||
return line.split(":", 1)[1].strip()
|
||||
return None
|
||||
|
||||
|
||||
def generate_random_mac():
|
||||
"""生成一个随机的本地管理单播 MAC 地址。"""
|
||||
mac = [random.randint(0x00, 0xFF) for _ in range(6)]
|
||||
mac[0] = (mac[0] & 0xFC) | 0x02 # 本地管理、单播
|
||||
return mac
|
||||
|
||||
|
||||
def find_adapter_registry_key(adapter_name):
|
||||
"""在注册表中找到匹配 adapter_name 的网卡子键路径。"""
|
||||
base_path = r"SYSTEM\CurrentControlSet\Control\Class\{4d36e972-e325-11ce-bfc1-08002be10318}"
|
||||
try:
|
||||
base_key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, base_path)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
index = 0
|
||||
while True:
|
||||
try:
|
||||
subkey_name = winreg.EnumKey(base_key, index)
|
||||
index += 1
|
||||
except OSError:
|
||||
break
|
||||
try:
|
||||
subkey = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, f"{base_path}\\{subkey_name}")
|
||||
driver_desc, _ = winreg.QueryValueEx(subkey, "DriverDesc")
|
||||
if adapter_name.lower() in driver_desc.lower():
|
||||
winreg.CloseKey(subkey)
|
||||
winreg.CloseKey(base_key)
|
||||
return f"{base_path}\\{subkey_name}"
|
||||
winreg.CloseKey(subkey)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
winreg.CloseKey(base_key)
|
||||
return None
|
||||
|
||||
|
||||
def randomize_mac():
|
||||
"""随机化当前 Wi-Fi 网卡的 MAC 地址(需要管理员权限)。"""
|
||||
adapter_name = get_wifi_adapter_name()
|
||||
if not adapter_name:
|
||||
print("错误:未找到活跃的 Wi-Fi 适配器。")
|
||||
return False
|
||||
|
||||
print(f"找到 Wi-Fi 适配器: {adapter_name}")
|
||||
|
||||
reg_path = find_adapter_registry_key(adapter_name)
|
||||
if not reg_path:
|
||||
print(f"错误:未在注册表中找到适配器 '{adapter_name}' 的条目。")
|
||||
return False
|
||||
|
||||
mac = generate_random_mac()
|
||||
mac_str = "".join(f"{b:02X}" for b in mac)
|
||||
mac_display = ":".join(f"{b:02X}" for b in mac)
|
||||
|
||||
try:
|
||||
key = winreg.OpenKey(
|
||||
winreg.HKEY_LOCAL_MACHINE, reg_path, 0, winreg.KEY_SET_VALUE,
|
||||
)
|
||||
winreg.SetValueEx(key, "NetworkAddress", 0, winreg.REG_SZ, mac_str)
|
||||
winreg.CloseKey(key)
|
||||
except PermissionError:
|
||||
print("错误:需要管理员权限来修改 MAC 地址。请以管理员身份运行。")
|
||||
return False
|
||||
|
||||
print(f"正在将 MAC 地址更改为: {mac_display}")
|
||||
|
||||
# 获取网络适配器的接口名称(用于 netsh)
|
||||
interface_name = get_netsh_interface_name()
|
||||
if not interface_name:
|
||||
interface_name = adapter_name
|
||||
|
||||
print("正在重启网卡以应用新 MAC 地址...")
|
||||
subprocess.run(
|
||||
["netsh", "interface", "set", "interface", interface_name, "disable"],
|
||||
capture_output=True,
|
||||
)
|
||||
time.sleep(2)
|
||||
subprocess.run(
|
||||
["netsh", "interface", "set", "interface", interface_name, "enable"],
|
||||
capture_output=True,
|
||||
)
|
||||
time.sleep(3)
|
||||
print(f"MAC 地址已更改为: {mac_display}")
|
||||
return True
|
||||
|
||||
|
||||
def get_netsh_interface_name():
|
||||
"""获取 Wi-Fi 网络接口的名称(用于 netsh interface 命令)。"""
|
||||
result = subprocess.run(
|
||||
["netsh", "interface", "show", "interface"],
|
||||
capture_output=True, text=True, encoding="gbk",
|
||||
)
|
||||
for line in result.stdout.splitlines():
|
||||
if "Wi-Fi" in line or "WLAN" in line or "Wireless" in line:
|
||||
parts = line.split()
|
||||
if len(parts) >= 4:
|
||||
return " ".join(parts[3:])
|
||||
return None
|
||||
|
||||
|
||||
def auto_login_wifi():
|
||||
"""自动登录 OSU 公共 Wi-Fi,支持重试。"""
|
||||
edge_options = Options()
|
||||
edge_options.add_argument("--start-maximized")
|
||||
edge_options.add_argument("--ignore-certificate-errors")
|
||||
edge_options.add_argument("--headless")
|
||||
|
||||
for attempt in range(1, MAX_LOGIN_RETRIES + 1):
|
||||
driver = None
|
||||
try:
|
||||
driver = webdriver.Edge(options=edge_options)
|
||||
print(f"第 {attempt} 次尝试登录...")
|
||||
|
||||
# 依次尝试不同的 Captive Portal 触发 URL
|
||||
portal_reached = False
|
||||
for url in CAPTIVE_PORTAL_URLS:
|
||||
print(f" 访问触发页面: {url}")
|
||||
driver.get(url)
|
||||
try:
|
||||
wait = WebDriverWait(driver, 10)
|
||||
wait.until(EC.element_to_be_clickable((By.NAME, "visitor_accept_terms")))
|
||||
portal_reached = True
|
||||
break
|
||||
except Exception:
|
||||
print(f" 未能通过 {url} 跳转到登录页,尝试下一个...")
|
||||
continue
|
||||
|
||||
if not portal_reached:
|
||||
print(f" 第 {attempt} 次尝试未能到达登录页面。")
|
||||
continue
|
||||
|
||||
# 勾选同意协议
|
||||
checkbox = driver.find_element(By.NAME, "visitor_accept_terms")
|
||||
if not checkbox.is_selected():
|
||||
checkbox.click()
|
||||
print(" 已勾选同意协议。")
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
# 点击登录按钮
|
||||
login_button = driver.find_element(
|
||||
By.XPATH, "//input[@type='submit' and @value='Log In']",
|
||||
)
|
||||
login_button.click()
|
||||
print(" 已点击登录按钮。")
|
||||
|
||||
# 轮询验证是否真正连上网络
|
||||
for i in range(10):
|
||||
time.sleep(2)
|
||||
if is_connected():
|
||||
print("登录成功!网络已连通。")
|
||||
return
|
||||
print(f" 第 {attempt} 次尝试:点击登录后网络仍未连通。")
|
||||
|
||||
except Exception as e:
|
||||
print(f" 第 {attempt} 次尝试出错: {e}")
|
||||
finally:
|
||||
if driver:
|
||||
driver.quit()
|
||||
|
||||
if attempt < MAX_LOGIN_RETRIES:
|
||||
print(" 等待 3 秒后重试...")
|
||||
time.sleep(3)
|
||||
|
||||
print("多次尝试后仍未能成功登录。")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="OSU 公共 Wi-Fi 自动登录工具")
|
||||
parser.add_argument(
|
||||
"--random-mac",
|
||||
action="store_true",
|
||||
help="登录前随机化 Wi-Fi 网卡的 MAC 地址(需要管理员权限)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if is_connected():
|
||||
print("网络已连接,无需登录。")
|
||||
return
|
||||
|
||||
if args.random_mac:
|
||||
print("正在随机化 MAC 地址...")
|
||||
if not randomize_mac():
|
||||
print("MAC 地址随机化失败,继续尝试登录...")
|
||||
else:
|
||||
# MAC 更改后需要等待 Wi-Fi 重新连接
|
||||
print("等待 Wi-Fi 重新连接...")
|
||||
time.sleep(5)
|
||||
|
||||
auto_login_wifi()
|
||||
from osu_wifi_login.cli import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
raise SystemExit(main())
|
||||
|
||||
1
osu_wifi_login/__init__.py
Normal file
1
osu_wifi_login/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""OSU Wi-Fi background monitor for Windows."""
|
||||
47
osu_wifi_login/cli.py
Normal file
47
osu_wifi_login/cli.py
Normal 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
389
osu_wifi_login/config.py
Normal 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
537
osu_wifi_login/portal.py
Normal 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
251
osu_wifi_login/service.py
Normal 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
412
osu_wifi_login/windows.py
Normal 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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
.replace("'", "'")
|
||||
)
|
||||
|
||||
|
||||
def _normalize_url(url: str) -> str:
|
||||
return url.strip().rstrip("/").casefold()
|
||||
|
||||
|
||||
def _require_adapter_name() -> str:
|
||||
status = get_wifi_status()
|
||||
if not status.interface_name:
|
||||
raise WifiCommandError("Could not find a Wi-Fi adapter from 'netsh wlan show interfaces'")
|
||||
return status.interface_name
|
||||
|
||||
|
||||
def _decode_output(raw: bytes) -> str:
|
||||
for encoding in _candidate_encodings():
|
||||
try:
|
||||
return raw.decode(encoding)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return raw.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _candidate_encodings() -> list[str]:
|
||||
preferred = locale.getpreferredencoding(False)
|
||||
return [preferred, "utf-8", "gbk", "cp936", "big5"]
|
||||
|
||||
|
||||
def _parse_key_value_block(block: str) -> dict[str, str]:
|
||||
mapping: dict[str, str] = {}
|
||||
for raw_line in block.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or ":" not in line:
|
||||
continue
|
||||
key, value = line.split(":", 1)
|
||||
normalized = _normalize_key(key.strip())
|
||||
if normalized:
|
||||
mapping[normalized] = value.strip()
|
||||
return mapping
|
||||
|
||||
|
||||
def _normalize_key(key: str) -> str | None:
|
||||
lookup = {
|
||||
"name": "name",
|
||||
"名称": "name",
|
||||
"description": "description",
|
||||
"描述": "description",
|
||||
"state": "state",
|
||||
"状态": "state",
|
||||
"ssid": "ssid",
|
||||
}
|
||||
normalized = key.strip().casefold()
|
||||
if normalized == "bssid":
|
||||
return None
|
||||
return lookup.get(key.strip(), lookup.get(normalized))
|
||||
@@ -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]
|
||||
|
||||
6
run.bat
6
run.bat
@@ -1,3 +1,3 @@
|
||||
@echo off
|
||||
cd /d "C:\Users\yuanz\src\osu-wifi-login"
|
||||
uv run main.py
|
||||
@echo off
|
||||
cd /d "C:\Users\yuanzhe\src\OSU-Public-Wi-Fi-Login"
|
||||
.\.venv\Scripts\python.exe main.py --config config.yaml
|
||||
|
||||
440
uv.lock
generated
440
uv.lock
generated
@@ -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
17
winsw/README.md
Normal 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
17
winsw/osu-wifi-login.xml
Normal 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>
|
||||
Reference in New Issue
Block a user