This commit is contained in:
yml2213
2026-06-21 21:56:56 +08:00
commit 1ce867f3a8
25 changed files with 3111 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
.venv
*/__pycache__
+126
View File
@@ -0,0 +1,126 @@
# 斗鱼自动登录工具 - Python版
全自动登录斗鱼账号,获取Cookie。
## 功能特性
- ✅ 自动过极验v3滑块验证
- ✅ 自动获取邮箱验证码(IMAP
- ✅ 批量账号登录
- ✅ Cookie持久化存储
- ✅ 代理支持
## 项目结构
```
douyu_login_py/
├── main.py # 主入口
├── config.yaml # 配置文件
├── requirements.txt # 依赖
├── douyu/ # 斗鱼登录模块
│ ├── config.py # 配置管理
│ ├── crypto.py # 加密工具
│ ├── email_verifier.py # 邮箱验证
│ └── login.py # 登录核心
├── geetest/ # 极验滑块破解(复用geetest-v3-silde-crack
│ ├── solver.py
│ ├── network.py
│ ├── crypto.py
│ ├── imaging.py
│ └── trajectory.py
├── utils/ # 工具模块
│ ├── logger.py
│ └── helpers.py
└── data/
├── accounts.json # 账号配置
└── cookies/ # Cookie存储
```
## 安装依赖
```bash
pip install -r requirements.txt
```
## 配置
编辑 `config.yaml`
```yaml
accounts:
- username: "your_username"
password: "your_password"
email: "your_email@bdhg.xyz"
email_password: "your_email_password"
email_imap_server: "mail.bdhg.xyz"
email_imap_port: 993
```
## 使用方法
### 单账号登录
```bash
# 登录第一个账号
python main.py
# 登录指定索引的账号
python main.py -i 0
```
### 批量登录
```bash
python main.py --batch
```
### 详细日志
```bash
python main.py -v
```
## 登录流程
```
1️⃣ 第一次登录 → 获取极验参数
2️⃣ 极验滑块验证 → 自动识别缺口位置
3️⃣ 第二次登录 → 获取邮箱验证code
4️⃣ 发送邮箱验证邮件
5️⃣ IMAP获取验证码
6️⃣ 提交验证码
7️⃣ 完成登录获取Cookie
```
## Cookie使用
登录成功后,Cookie会保存到 `data/cookies/` 目录。
可以使用以下方式读取Cookie
```python
from utils.helpers import load_cookie
cookie = load_cookie("your_username")
print(cookie)
```
## 极验滑块破解
本项目复用了 `geetest-v3-silde-crack` 的滑块破解方案,包含:
- RSA/AES加密
- 轨迹生成
- 图像识别(OpenCV
- 性能数据伪造
## 注意事项
1. 请确保邮箱支持IMAP协议
2. 邮箱需要开启IMAP访问权限
3. 部分邮箱需要使用应用专用密码
4. 建议使用代理避免IP限制
## 许可证
MIT License
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
# 斗鱼自动登录配置
# 账号配置
accounts:
- username: "9006787894"
password: "a778899"
email: "jtmjcm@bdhg.xyz"
email_password: "www123"
email_imap_server: "mail.bdhg.xyz"
email_imap_port: 993
# 代理配置(可选)
proxy:
enabled: false
http: ""
https: ""
# 日志配置
log:
level: "DEBUG"
file: "logs/douyu_login.log"
# Cookie存储
cookie_dir: "data/cookies"
+12
View File
@@ -0,0 +1,12 @@
{
"accounts": [
{
"username": "your_username",
"password": "your_password",
"email": "your_email@bdhg.xyz",
"email_password": "your_email_password",
"email_imap_server": "mail.bdhg.xyz",
"email_imap_port": 993
}
]
}
+7
View File
@@ -0,0 +1,7 @@
"""斗鱼登录模块"""
from .login import DouyuLogin
from .email_verifier import EmailVerifier
from .config import Config
__all__ = ["DouyuLogin", "EmailVerifier", "Config"]
+72
View File
@@ -0,0 +1,72 @@
"""配置管理模块"""
import yaml
from pathlib import Path
from dataclasses import dataclass
from typing import List, Optional
@dataclass
class Account:
"""账号配置"""
username: str
password: str
email: str
email_password: str
email_imap_server: str
email_imap_port: int = 993
@dataclass
class ProxyConfig:
"""代理配置"""
enabled: bool = False
http: str = ""
https: str = ""
class Config:
"""配置管理器"""
def __init__(self, config_path: str = "config.yaml"):
self.config_path = Path(config_path)
self._config = self._load_config()
def _load_config(self) -> dict:
"""加载配置文件"""
if not self.config_path.exists():
raise FileNotFoundError(f"配置文件不存在: {self.config_path}")
with open(self.config_path, 'r', encoding='utf-8') as f:
return yaml.safe_load(f)
def get_accounts(self) -> List[Account]:
"""获取账号列表"""
accounts = []
for acc in self._config.get('accounts', []):
accounts.append(Account(
username=acc['username'],
password=acc['password'],
email=acc['email'],
email_password=acc['email_password'],
email_imap_server=acc.get('email_imap_server', ''),
email_imap_port=acc.get('email_imap_port', 993),
))
return accounts
def get_proxy(self) -> ProxyConfig:
"""获取代理配置"""
proxy = self._config.get('proxy', {})
return ProxyConfig(
enabled=proxy.get('enabled', False),
http=proxy.get('http', ''),
https=proxy.get('https', ''),
)
def get_cookie_dir(self) -> str:
"""获取Cookie存储目录"""
return self._config.get('cookie_dir', 'data/cookies')
def get_log_config(self) -> dict:
"""获取日志配置"""
return self._config.get('log', {'level': 'INFO', 'file': 'logs/douyu_login.log'})
+63
View File
@@ -0,0 +1,63 @@
"""斗鱼专用加密模块 - 用户名密码加密"""
import base64
import hashlib
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5
from Crypto.Cipher import AES
# 斗鱼RSA公钥(从JS中提取)
DOUYU_RSA_PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHfGXRkF+RiLA71KAHOFBaWGBy
J7M6D3MDAsFHo2JMBDm2Kfj6V3GFMI7B2JQ3qGl0jCk6ILT1jQ+IFhLvLR3cXPaC
HT5xYa0hzJpMNO3bLSuJhzY5jQNqRMWfbcV4FLB2JBaFfWcY7RWQ2pCE6jjDnMHM
o2kz+dJoGnZM0b99VwIDAQAB
-----END PUBLIC KEY-----"""
# 斗鱼AES密钥(16位)
DOUYU_AES_KEY = "1234567890abcdef"
def md5(text: str) -> str:
"""MD5加密"""
return hashlib.md5(text.encode('utf-8')).hexdigest()
def rsa_encrypt(text: str, public_key: str = DOUYU_RSA_PUBLIC_KEY) -> str:
"""RSA加密"""
key = RSA.import_key(public_key)
cipher = PKCS1_v1_5.new(key)
encrypted = cipher.encrypt(text.encode('utf-8'))
return base64.b64encode(encrypted).decode('utf-8')
def aes_encrypt(text: str, key: str = DOUYU_AES_KEY) -> str:
"""AES加密"""
key_bytes = key.encode('utf-8')
text_bytes = text.encode('utf-8')
# 填充到16的倍数
padding_len = 16 - (len(text_bytes) % 16)
text_bytes += bytes([padding_len] * padding_len)
cipher = AES.new(key_bytes, AES.MODE_ECB)
encrypted = cipher.encrypt(text_bytes)
return base64.b64encode(encrypted).decode('utf-8')
def encrypt_username(username: str) -> str:
"""加密用户名(斗鱼使用RSA加密)"""
return rsa_encrypt(username)
def encrypt_password(password: str) -> str:
"""加密密码(斗鱼使用MD5"""
return md5(password)
def encrypt_nickname_or_phone(text: str) -> str:
"""加密昵称或手机号"""
# 斗鱼使用特殊的Base64编码
encoded = base64.b64encode(text.encode('utf-8')).decode('utf-8')
return encoded
+231
View File
@@ -0,0 +1,231 @@
"""邮箱验证模块 - IMAP获取验证码"""
import imaplib
import email
import re
import time
from datetime import datetime, timedelta
from email.header import decode_header
from typing import Optional
from loguru import logger
class EmailVerifier:
"""邮箱验证器"""
def __init__(
self,
imap_server: str,
imap_port: int,
username: str,
password: str,
timeout: float = 12,
):
self.imap_server = imap_server
self.imap_port = imap_port
self.username = username
self.password = password
self.timeout = timeout
self._connection: Optional[imaplib.IMAP4_SSL] = None
def connect(self) -> None:
"""连接IMAP服务器"""
try:
logger.info(f"连接IMAP服务器: {self.imap_server}:{self.imap_port}")
self._connection = imaplib.IMAP4_SSL(
self.imap_server,
self.imap_port,
timeout=self.timeout,
)
self._connection.login(self.username, self.password)
logger.info("IMAP连接成功")
except Exception as e:
logger.error(f"IMAP连接失败: {e}")
raise
def disconnect(self) -> None:
"""断开IMAP连接"""
if self._connection:
try:
self._connection.logout()
except:
pass
self._connection = None
def get_verification_code(self, max_wait: int = 60, interval: int = 2) -> str:
"""
获取斗鱼验证码
Args:
max_wait: 最大等待时间(秒)
interval: 轮询间隔(秒)
Returns:
6位验证码
"""
logger.info(f"等待斗鱼验证码邮件,最大等待 {max_wait} 秒...")
self.connect()
try:
start_time = time.time()
while time.time() - start_time < max_wait:
code = self._fetch_latest_code()
if code:
logger.success(f"获取到验证码: {code}")
return code
logger.debug("未找到验证码,等待中...")
time.sleep(interval)
raise TimeoutError("等待验证码超时")
finally:
self.disconnect()
def _fetch_latest_code(self) -> Optional[str]:
"""从IMAP获取最新验证码"""
try:
self._connection.select('INBOX')
# 搜索最近5分钟的邮件
since = (datetime.now() - timedelta(minutes=5)).strftime("%d-%b-%Y")
status, messages = self._connection.search(
None,
f'(OR (FROM "douyu") (FROM "斗鱼") (SUBJECT "验证码")) SINCE {since}'
)
if status != 'OK' or not messages[0]:
return None
# 获取最新的一封邮件
latest_id = messages[0].split()[-1]
status, msg_data = self._connection.fetch(latest_id, '(RFC822)')
if status != 'OK':
return None
msg = email.message_from_bytes(msg_data[0][1])
# 检查是否是斗鱼的邮件
subject = self._decode_subject(msg.get('Subject', ''))
if not self._is_douyu_email(subject, msg.get('From', '')):
return None
# 提取验证码
body = self._get_email_body(msg)
code = self._extract_verification_code(body)
if code:
logger.info(f"从邮件中提取到验证码: {code}")
return code
except Exception as e:
logger.error(f"获取邮件失败: {e}")
return None
def _is_douyu_email(self, subject: str, from_addr: str) -> bool:
"""判断是否是斗鱼的邮件"""
douyu_keywords = ['斗鱼', 'douyu', '验证码', '安全验证']
subject_lower = subject.lower()
from_lower = from_addr.lower()
return any(keyword in subject_lower or keyword in from_lower
for keyword in douyu_keywords)
def _decode_subject(self, subject: str) -> str:
"""解码邮件主题"""
if not subject:
return ""
decoded_parts = decode_header(subject)
result = []
for part, charset in decoded_parts:
if isinstance(part, bytes):
result.append(part.decode(charset or 'utf-8', errors='ignore'))
else:
result.append(part)
return ' '.join(result)
def _get_email_body(self, msg: email.message.Message) -> str:
"""获取邮件正文"""
body = ""
if msg.is_multipart():
for part in msg.walk():
content_type = part.get_content_type()
if content_type == 'text/plain' or content_type == 'text/html':
try:
payload = part.get_payload(decode=True)
charset = part.get_content_charset() or 'utf-8'
body += payload.decode(charset, errors='ignore')
except:
pass
else:
try:
payload = msg.get_payload(decode=True)
charset = msg.get_content_charset() or 'utf-8'
body = payload.decode(charset, errors='ignore')
except:
pass
return body
def _extract_verification_code(self, text: str) -> Optional[str]:
"""从文本中提取验证码"""
# 清理HTML标签
text = re.sub(r'<[^>]+>', ' ', text)
# 查找6位数字验证码
# 注意:中文冒号和英文冒号需要分别处理
patterns = [
r'验证码[:]\s*(\d{6})', # 验证码:123456 或 验证码:123456
r'验证码\s+(\d{6})', # 验证码 123456
r'(\d{6})\s*是您的验证码', # 123456是您的验证码
r'您的验证码[是为]\s*[:]\s*(\d{6})', # 您的验证码是:123456
r'您的验证码[是为]\s*(\d{6})', # 您的验证码是123456
r'verification code[:]\s*(\d{6})',
r'code[:]\s*(\d{6})',
r'(\d{6})', # 最后尝试匹配任何6位数字
]
for pattern in patterns:
match = re.search(pattern, text, re.IGNORECASE)
if match:
code = match.group(1)
# 验证码有效性检查(简单过滤明显不是验证码的数字)
if not self._is_likely_code(code):
continue
return code
return None
def _is_likely_code(self, code: str) -> bool:
"""判断是否是有效的验证码"""
# 过滤掉一些明显不是验证码的数字
invalid_patterns = [
r'^(\d)\1{5}$', # 全部相同:111111, 222222
r'^123456$', # 顺序数字
r'^654321$', # 逆序数字
]
for pattern in invalid_patterns:
if re.match(pattern, code):
return False
return True
def get_email_config_for_account(email_address: str) -> dict:
"""根据邮箱地址自动配置IMAP服务器"""
configs = {
'qq.com': {'server': 'imap.qq.com', 'port': 993},
'163.com': {'server': 'imap.163.com', 'port': 993},
'126.com': {'server': 'imap.126.com', 'port': 993},
'gmail.com': {'server': 'imap.gmail.com', 'port': 993},
'outlook.com': {'server': 'outlook.office365.com', 'port': 993},
'hotmail.com': {'server': 'outlook.office365.com', 'port': 993},
}
domain = email_address.split('@')[-1].lower()
return configs.get(domain, {'server': f'imap.{domain}', 'port': 993})
+458
View File
@@ -0,0 +1,458 @@
"""斗鱼登录核心模块"""
import re
import json
import time
import requests
from typing import Mapping, Optional, Tuple
from pathlib import Path
from urllib.parse import urlsplit, urlunsplit
from loguru import logger
from .config import Account
from .crypto import encrypt_password, encrypt_nickname_or_phone
from .email_verifier import EmailVerifier
# 导入geetest滑块模块
import sys
sys.path.insert(0, str(Path(__file__).parent.parent))
from geetest import run_solver
from geetest.solver import (
_generate_seed, get_w1, get_w2, get_w3,
)
from geetest.network import get_js_address, get_c_s, req_slide, get_picture, req_end
from geetest.imaging import download_picture
class LoginResult:
"""登录结果"""
def __init__(self, success: bool, cookie: str = "", message: str = "", code: str = ""):
self.success = success
self.cookie = cookie
self.message = message
self.code = code # 需要验证时的code
class DouyuLogin:
"""斗鱼登录器"""
# 斗鱼API地址
LOGIN_API = "https://passport.douyu.com/wgapi/member/passport/login"
SEND_EMAIL_API = "https://passport.douyu.com/wgapi/member/passport/remotelogin/sendemail"
VERIFY_API = "https://passport.douyu.com/wgapi/member/passport/remotelogin/verify"
LOGIN_CALLBACK_API = "https://www.douyu.com/api/passport/login"
WEBLOGIN_API = "https://msg.douyu.com/webLogin"
REQUEST_TIMEOUT = (3.05, 12)
def __init__(
self,
account: Account,
proxy: Optional[str | Mapping[str, str]] = None,
timeout: tuple[float, float] = REQUEST_TIMEOUT,
):
self.account = account
self.proxy = proxy
self.timeout = timeout
self.session = requests.Session()
self._setup_session()
def _setup_session(self) -> None:
"""配置Session"""
# 只使用配置文件里显式传入的代理,避免系统环境变量悄悄影响请求。
self.session.trust_env = False
self.session.headers.update({
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36',
'Accept': 'application/json, text/javascript, */*; q=0.01',
'Accept-Language': 'zh-CN,zh;q=0.9',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'Referer': 'https://www.douyu.com/',
'Origin': 'https://www.douyu.com',
'X-Requested-With': 'XMLHttpRequest',
})
if self.proxy:
if isinstance(self.proxy, str):
self.session.proxies = {
'http': self.proxy,
'https': self.proxy,
}
else:
self.session.proxies = {
scheme: url
for scheme, url in self.proxy.items()
if url
}
def _safe_url(self, url: str) -> str:
"""隐藏查询参数,避免日志泄露登录回调 code。"""
parsed = urlsplit(url)
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", ""))
def _request(self, method: str, url: str, **kwargs) -> requests.Response:
"""统一发送请求,附带分段超时和更明确的错误信息。"""
timeout = kwargs.pop('timeout', self.timeout)
safe_url = self._safe_url(url)
started = time.monotonic()
try:
response = self.session.request(method, url, timeout=timeout, **kwargs)
except requests.Timeout as exc:
elapsed = time.monotonic() - started
raise TimeoutError(
f"{method.upper()} {safe_url} 超时,耗时 {elapsed:.1f}s"
f"timeout={timeout}"
) from exc
except requests.RequestException as exc:
elapsed = time.monotonic() - started
raise ConnectionError(
f"{method.upper()} {safe_url} 请求失败,耗时 {elapsed:.1f}s: {exc}"
) from exc
elapsed = time.monotonic() - started
logger.debug(
f"{method.upper()} {safe_url} -> {response.status_code} "
f"({elapsed:.2f}s)"
)
return response
def _request_json(self, method: str, url: str, source: str, **kwargs) -> dict:
"""请求 JSON 接口,并在响应异常时输出可定位的信息。"""
response = self._request(method, url, **kwargs)
response.raise_for_status()
body = response.text.strip()
if not body:
raise ValueError(f"{source} 返回为空,无法解析 JSON")
try:
return response.json()
except json.JSONDecodeError as exc:
preview = body[:200].replace("\n", "\\n")
raise ValueError(f"{source} 返回的不是有效 JSON: {preview}") from exc
def login(self) -> LoginResult:
"""
完整登录流程
Returns:
LoginResult: 登录结果,包含cookie
"""
logger.info(f"开始登录账号: {self.account.username}")
try:
# 1️⃣ 第一次登录(获取极验参数)
logger.info("步骤1: 第一次登录,获取极验参数...")
gt, challenge, code_token, initial_cookies = self._first_login()
# 2️⃣ 极验滑块验证
logger.info("步骤2: 极验滑块验证...")
validate, seccode = self._solve_geetest(gt, challenge)
# 3️⃣ 第二次登录(带极验)
logger.info("步骤3: 第二次登录(带极验验证)...")
remote_code = self._second_login(gt, challenge, validate, seccode, code_token)
# 4️⃣ 发送邮箱验证
logger.info("步骤4: 发送邮箱验证...")
self._send_email_verify(remote_code)
# 5️⃣ IMAP获取验证码
logger.info("步骤5: 获取邮箱验证码...")
verify_code = self._get_email_code()
# 6️⃣ 提交验证码
logger.info("步骤6: 提交验证码...")
login_url = self._submit_verify_code(remote_code, verify_code)
# 7️⃣ 完成登录获取Cookie
logger.info("步骤7: 完成登录,获取Cookie...")
cookie = self._complete_login(login_url)
logger.success(f"登录成功! Cookie长度: {len(cookie)}")
return LoginResult(success=True, cookie=cookie, message="登录成功")
except Exception as e:
logger.error(f"登录失败: {e}")
return LoginResult(success=False, message=str(e))
def _first_login(self) -> Tuple[str, str, str, dict]:
"""
第一次登录,获取极验参数
Returns:
(gt, challenge, code_token, cookies)
"""
# 加密用户名和密码
encrypted_username = encrypt_nickname_or_phone(self.account.username)
encrypted_password = encrypt_password(self.account.password)
data = {
'type': '1',
'nicknameOrPhoneEncrypt': encrypted_username,
'password': encrypted_password,
'biz_type': '1',
'room_id': '0',
'redirect_url': 'https://www.douyu.com/',
}
payload = self._request_json(
'post',
self.LOGIN_API,
'第一次登录接口',
data=data,
)
logger.debug(f"第一次登录响应: {json.dumps(payload, ensure_ascii=False)[:200]}")
if payload.get('error') != 81:
error_msg = payload.get('msg', '未知错误')
raise ValueError(f"第一次登录失败: {error_msg}")
# 提取极验参数
geetest_data = payload.get('data', {}).get('geetest', {})
code_data = geetest_data.get('code_data', {})
code_token = geetest_data.get('code_token', '')
gt = code_data.get('gt', '')
challenge = code_data.get('challenge', '')
if not gt or not challenge:
raise ValueError("获取极验参数失败")
logger.info(f"获取极验参数成功: gt={gt[:10]}..., challenge={challenge[:10]}...")
return gt, challenge, code_token, self.session.cookies.get_dict()
def _solve_geetest(self, gt: str, challenge: str) -> Tuple[str, str]:
"""
解决极验滑块验证
Args:
gt: 极验gt参数
challenge: 极验challenge参数
Returns:
(validate, seccode)
"""
logger.info("开始极验滑块验证...")
# 使用geetest-v3-silde-crack的完整流程
str_16 = _generate_seed()
# 获取JS地址
get_js_address(gt)
# 获取第一个w值
w1 = get_w1(gt, challenge, str_16)
# 获取c和s
c, s = get_c_s(gt, challenge, w1)
# 获取第二个w值
w2 = get_w2(gt, challenge, c, s, str_16)
# 请求滑块
req_slide(gt, challenge, w2)
# 获取图片
bg, fullbg, c, s, slice, challenge_new = get_picture(gt, challenge)
# 识别缺口位置
hkjl = download_picture(bg, fullbg, slice)
# 获取第三个w值
w3 = get_w3(str_16, challenge_new, hkjl, c, s, gt)
# 最后验证
result = req_end(gt, challenge_new, w3)
# 从req_end的响应中提取validate
# req_end返回的是验证结果,成功时包含validate
if isinstance(result, dict):
validate = result.get('validate', '')
else:
# 如果返回的是字符串,可能是直接的validate值
validate = str(result)
if not validate:
raise ValueError("极验验证失败,未获取到validate")
seccode = f"{validate}|jordan"
logger.success(f"极验滑块验证成功! validate={validate[:20]}...")
return validate, seccode
def _second_login(self, gt: str, challenge: str, validate: str,
seccode: str, code_token: str) -> str:
"""
第二次登录(带极验验证)
Returns:
remote_code: 用于邮箱验证的code
"""
encrypted_username = encrypt_nickname_or_phone(self.account.username)
encrypted_password = encrypt_password(self.account.password)
# 参考HAR文件中的完整参数
data = {
'type': '1',
'nicknameOrPhoneEncrypt': encrypted_username,
'password': encrypted_password,
'room_id': '0',
'code_type': '1',
'code_token': code_token,
'gt_version': 'v3',
'geetest_challenge': challenge,
'geetest_validate': validate,
'geetest_seccode': seccode,
'code_data[geetest_challenge]': challenge,
'code_data[geetest_validate]': validate,
'code_data[geetest_seccode]': seccode,
'code_data[gt_version]': 'v3',
'code_data[code]': '',
'redirect_url': 'https://www.douyu.com/',
't': str(int(time.time() * 1000)),
'client_id': '1',
'did': '',
'lang': '',
'isMultiAccount': '0',
'biz_type': '1',
}
payload = self._request_json(
'post',
self.LOGIN_API,
'第二次登录接口',
data=data,
)
logger.debug(f"第二次登录响应: {json.dumps(payload, ensure_ascii=False)[:200]}")
if payload.get('error') != 130014:
error_msg = payload.get('msg', '未知错误')
raise ValueError(f"第二次登录失败: {error_msg}")
# 提取remote_code
remote_code = payload.get('data', {}).get('remoteLogin', {}).get('code', '')
if not remote_code:
raise ValueError("获取remote_code失败")
logger.info(f"获取remote_code成功: {remote_code[:20]}...")
return remote_code
def _send_email_verify(self, remote_code: str) -> None:
"""发送邮箱验证"""
data = {
'code': remote_code,
'client_id': '1',
}
payload = self._request_json(
'post',
self.SEND_EMAIL_API,
'发送验证邮件接口',
data=data,
)
if payload.get('error') != 0:
raise ValueError(f"发送验证邮件失败: {payload.get('msg')}")
logger.info("验证邮件已发送")
def _get_email_code(self) -> str:
"""获取邮箱验证码"""
verifier = EmailVerifier(
imap_server=self.account.email_imap_server,
imap_port=self.account.email_imap_port,
username=self.account.email,
password=self.account.email_password,
)
return verifier.get_verification_code(max_wait=60)
def _submit_verify_code(self, remote_code: str, verify_code: str) -> str:
"""
提交验证码
Returns:
login_url: 登录回调URL
"""
data = {
'verify_type': '2',
'captcha': verify_code,
'isMultiAccount': '0',
'code': remote_code,
'client_id': '1',
'redirect_url': '//www.douyu.com/api/passport/login',
}
payload = self._request_json(
'post',
self.VERIFY_API,
'提交验证码接口',
data=data,
)
if payload.get('error') != 0:
raise ValueError(f"提交验证码失败: {payload.get('msg')}")
login_url = payload.get('data', {}).get('url', '')
if not login_url:
raise ValueError("获取登录URL失败")
# 补全URL
if login_url.startswith('//'):
login_url = 'https:' + login_url
logger.info(f"获取登录URL成功: {login_url[:50]}...")
return login_url
def _complete_login(self, login_url: str) -> str:
"""
完成登录,获取Cookie
Returns:
cookie: 完整的Cookie字符串
"""
# 访问登录URL
response = self._request('get', login_url)
response.raise_for_status()
# 尝试访问webLogin获取用户信息
try:
code_match = re.search(r'code=([^&]+)', login_url)
if code_match:
code = code_match.group(1)
weblogin_url = f"{self.WEBLOGIN_API}?code={code}"
response2 = self._request('get', weblogin_url)
if response2.status_code == 200:
logger.info("WebLogin成功")
except Exception as e:
logger.warning(f"WebLogin请求失败(不影响登录): {e}")
# 收集所有Cookie
cookies = self.session.cookies.get_dict()
# 格式化Cookie字符串
cookie_str = '; '.join([f"{k}={v}" for k, v in cookies.items()])
return cookie_str
def save_cookie(self, cookie: str, filepath: str) -> None:
"""保存Cookie到文件"""
Path(filepath).parent.mkdir(parents=True, exist_ok=True)
with open(filepath, 'w', encoding='utf-8') as f:
json.dump({
'username': self.account.username,
'cookie': cookie,
'timestamp': int(time.time()),
}, f, ensure_ascii=False, indent=2)
logger.info(f"Cookie已保存到: {filepath}")
+5
View File
@@ -0,0 +1,5 @@
"""Geetest slider captcha solver package."""
from .solver import run_solver
__all__ = ["run_solver"]
+246
View File
@@ -0,0 +1,246 @@
import random
import hashlib
from typing import Any
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5, AES
# 随机产生4个字符组成的字符串
def four_random_chart() -> str:
return hex(int(65536 * (1 + random.random())))[2:][1:]
# PKCS#1 v1.5 填充 + RSA 加密
def parse_jsbn_bigint(n_obj:dict[Any, int]) -> int:
DB = 28
DV = 1 << DB
t = n_obj['t']
result = 0
for i in range(t):
result += n_obj[i] * (DV ** i)
return result
def encrypt_data(plaintext:str) -> str:
cipher = PKCS1_v1_5.new(public_key)
encrypted = cipher.encrypt(plaintext.encode('utf-8'))
# 转换为十六进制,确保偶数长度
hex_result = encrypted.hex()
if len(hex_result) % 2 == 1:
hex_result = '0' + hex_result
return hex_result
def RSA_jiami_r(str_16:str) -> str:
global public_key
# 你的数据
n_data = {
0: 134982529, 1: 254232810, 2: 164556709, 3: 234907349,
4: 134685994, 5: 35463984, 6: 258277946, 7: 12518857,
8: 44638621, 9: 93783641, 10: 212253739, 11: 62792472,
12: 186688352, 13: 109500232, 14: 182488077, 15: 261196188,
16: 26354094, 17: 103248217, 18: 106891695, 19: 165771045,
20: 41530993, 21: 263704736, 22: 111785174, 23: 12753611,
24: 232116673, 25: 155524985, 26: 218291229, 27: 122452343,
28: 248250238, 29: 118739550, 30: 251169095, 31: 129059733,
32: 149835464, 33: 5498868, 34: 71719731, 35: 154456417,
36: 49635,
't': 37, 's': 0
}
e = 65537
n = parse_jsbn_bigint(n_data)
# 构造公钥
public_key = RSA.construct((n, e))
encrypted = encrypt_data(str_16)
return encrypted
# AES加密
# 加密模式: AES-CBC
# 密钥长度: 128位
# IV: 固定为 "0000000000000000"
def parse_string_to_wordarray(text:str) -> list[int]:
"""将字符串转换为 WordArray 格式"""
length = len(text)
words = []
for i in range(length):
# 计算在 words 数组中的索引
word_index = i >> 2 # 相当于 i // 4
# 确保 words 数组足够长
while len(words) <= word_index:
words.append(0)
# 获取字符的 ASCII 码
char_code = ord(text[i]) & 0xFF
# 计算位移量
shift = 24 - (i % 4) * 8
# 将字符添加到对应的 word 中
words[word_index] |= char_code << shift
return words
def AES_O(plaintext:str, str_16:str) -> list[int]:
# 密钥
key_words = parse_string_to_wordarray(str_16)
key = b''.join(w.to_bytes(4, 'big') for w in key_words)
# IV
iv = b'0000' * 4 # "0000000000000000"
# 填充(PKCS7)
pad_len = 16 - len(plaintext) % 16
plaintext_padded = plaintext.encode() + bytes([pad_len] * pad_len)
# 加密
cipher = AES.new(key, AES.MODE_CBC, iv)
ciphertext = cipher.encrypt(plaintext_padded)
# 结果是字节数组
return list(ciphertext)
# 自定义base64编码
def geetest_base64_encode(data:list[int]) -> dict[str, Any]:
"""极验自定义Base64编码"""
# 配置
charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789()'
pad_char = '.'
# 位掩码 (这些是打乱的)
masks = [7274496, 9483264, 19220, 235]
mask_bits = 24 # 总位数
def extract_bits(value, mask):
"""根据掩码提取位"""
result = 0
# 从高位到低位遍历
for i in range(mask_bits - 1, -1, -1):
# 如果掩码的第i位是1
if (mask >> i) & 1:
# 提取value的第i位,添加到结果
result = (result << 1) | ((value >> i) & 1)
return result
encoded = ""
padding = ""
length = len(data)
# 每3字节一组
i = 0
while i < length:
if i + 2 < length:
# 完整3字节
block = (data[i] << 16) | (data[i + 1] << 8) | data[i + 2]
# 使用4个掩码提取
encoded += charset[extract_bits(block, masks[0])]
encoded += charset[extract_bits(block, masks[1])]
encoded += charset[extract_bits(block, masks[2])]
encoded += charset[extract_bits(block, masks[3])]
i += 3
else:
# 处理剩余
remainder = length - i
if remainder == 2:
block = (data[i] << 16) | (data[i + 1] << 8)
encoded += charset[extract_bits(block, masks[0])]
encoded += charset[extract_bits(block, masks[1])]
encoded += charset[extract_bits(block, masks[2])]
padding = pad_char
elif remainder == 1:
block = data[i] << 16
encoded += charset[extract_bits(block, masks[0])]
encoded += charset[extract_bits(block, masks[1])]
padding = pad_char + pad_char
break
return {
"res": encoded,
"end": padding
}
def encrypt_string(e:str, t:list[int], n:str) -> str:
"""
JS加密函数的Python实现
参数:
e: 原始字符串
t: 加密参数数组
n: 十六进制字符串
"""
if not t or not n:
return e
o = 0 # 偏移量
i = e # 结果字符串
s = t[0] # 12
a = t[2] # 98
_ = t[4] # 43
# 每次读取2个字符(十六进制)
while o < len(n):
r = n[o:o + 2] # 取2个字符
if len(r) < 2:
break
o += 2
# 解析十六进制
c = int(r, 16)
# 转换为字符
l = chr(c)
# 计算插入位置: (s * c^2 + a * c + _) % len(e)
u = (s * c * c + a * c + _) % len(e)
# 在位置u插入字符
i = i[:u] + l + i[u:]
return i
def simple_md5(message:str) -> str:
"""
简化版MD5实现,结构更清晰
"""
# 使用内置hashlib验证结果
def verify_result(msg):
return hashlib.md5(msg.encode()).hexdigest()
# 轮移位常量
shifts = [
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21
]
# K常数(与JavaScript版本中的常数对应)
K = [
0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee,
0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501,
0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be,
0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821,
0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa,
0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8,
0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed,
0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a,
0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c,
0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70,
0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05,
0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665,
0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039,
0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1,
0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1,
0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391
]
# 实际实现...
return verify_result(message)
+89
View File
@@ -0,0 +1,89 @@
import requests
import cv2
import numpy as np
from PIL import Image
REQUEST_TIMEOUT = (3.05, 12)
# 将 Image 转换为 Mat,通过 flag 可以控制颜色
def pilImgToCv2(img: Image.Image, flag=cv2.COLOR_RGB2BGR):
return cv2.cvtColor(np.asarray(img), flag)
# 识别图片缺口返回滑块距离
def shibie(img: Image.Image, slice: Image.Image):
# 通过 pilImgToCv2 将图片置灰
# 背景图和滑块图都需要做相同处理
grayImg = pilImgToCv2(img, cv2.COLOR_BGR2GRAY)
# showImg(grayImg) # 可以通过它来看处理后的图片效果
graySlice = pilImgToCv2(slice, cv2.COLOR_BGR2GRAY)
# 做边缘检测进一步降低干扰,阈值可以自行调整
grayImg = cv2.Canny(grayImg, 255, 255)
# showImg(grayImg) # 可以通过它来看处理后的图片效果
graySlice = cv2.Canny(graySlice, 255, 255)
# 通过模板匹配两张图片,找出缺口的位置
result = cv2.matchTemplate(grayImg, graySlice, cv2.TM_CCOEFF_NORMED)
maxLoc = cv2.minMaxLoc(result)[3]
# 匹配出来的滑动距离
distance = maxLoc[0]
# 下面的逻辑是在图片画出一个矩形框来标记匹配到的位置,可以直观的看到匹配结果,去掉也可以的
sliceHeight, sliceWidth = graySlice.shape[:2]
# 左上角
x, y = maxLoc
# 右下角
x2, y2 = x + sliceWidth, y + sliceHeight
resultBg = pilImgToCv2(img, cv2.COLOR_RGB2BGR)
cv2.rectangle(resultBg, (x, y), (x2, y2), (0, 0, 255), 2)
# showImg(resultBg) # 可以通过它来看处理后的图片效果
return distance
# 还原图片
def restore_geetest_image(input_path:str, output_path:str) -> None:
"""
还原极验打乱的验证码图像
"""
Ut = [
39, 38, 48, 49, 41, 40, 46, 47, 35, 34, 50, 51, 33, 32, 28, 29,
27, 26, 36, 37, 31, 30, 44, 45, 43, 42, 12, 13, 23, 22, 14, 15,
21, 20, 8, 9, 25, 24, 6, 7, 3, 2, 0, 1, 11, 10, 4, 5, 19, 18, 16, 17
]
# 打开混淆图像
img = Image.open(input_path)
new_img = Image.new("RGB", (260,160))
r = 160
for _ in range(len(Ut)):
a = r / 2
c = Ut[_] % 26 * 12 + 1
u = 80 if Ut[_] > 25 else 0
l = img.crop(box=(c, u, c + 10, u + 80))
new_img.paste(l, box=(_ % 26 * 10, 80 if _ > 25 else 0))
new_img.save(output_path)
print(f"图像已还原并保存到: {output_path}")
# 下载图片
def download_picture(bg:str, fullbg:str, slice:str) -> int:
for i in range(3):
if i == 0:
url = "https://static.geetest.com/"+bg
response = requests.get(url, timeout=REQUEST_TIMEOUT)
response.raise_for_status()
with open("bg.jpg", "wb") as f:
f.write(response.content)
restore_geetest_image("bg.jpg", "bg.jpg")
else:
if i == 1:
url = "https://static.geetest.com/" + fullbg
response = requests.get(url, timeout=REQUEST_TIMEOUT)
response.raise_for_status()
with open("fullbg.jpg", "wb") as f:
f.write(response.content)
restore_geetest_image("fullbg.jpg", "fullbg.jpg")
else:
url = "https://static.geetest.com/" + slice
response = requests.get(url, timeout=REQUEST_TIMEOUT)
response.raise_for_status()
with open("slice.jpg", "wb") as f:
f.write(response.content)
return shibie(Image.open('fullbg.jpg'), Image.open('slice.jpg'))
+250
View File
@@ -0,0 +1,250 @@
import time
import requests
import json
import re
from typing import Tuple
REQUEST_TIMEOUT = (3.05, 12)
def _parse_json_response(response: requests.Response, source: str) -> dict:
response.raise_for_status()
body = response.text.strip()
if not body:
raise ValueError(f"{source} 返回为空,无法解析 JSON")
try:
return response.json()
except json.JSONDecodeError as exc:
preview = body[:200].replace("\n", "\\n")
raise ValueError(f"{source} 返回的不是有效 JSON: {preview}") from exc
def get_challenge_gt_bak() -> Tuple[str, str]:
headers = {
'accept': 'application/json, text/javascript, */*; q=0.01',
'accept-language': 'zh-CN,zh;q=0.9',
'priority': 'u=1, i',
'referer': 'https://demos.geetest.com/slide-float.html',
'sec-ch-ua': '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Windows"',
'sec-fetch-dest': 'empty',
'sec-fetch-mode': 'cors',
'sec-fetch-site': 'same-origin',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
'x-requested-with': 'XMLHttpRequest',
}
params = {
't': str(int(round(time.time() * 1000))),
}
response = requests.get(
'https://demos.geetest.com/gt/register-slide',
params=params,
headers=headers,
timeout=10,
)
data = _parse_json_response(response, "极验 demo register-slide")
return data["gt"], data["challenge"]
def get_challenge_gt() -> Tuple[str, str]:
headers = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36',
'Accept': 'application/json, text/javascript, */*; q=0.01',
'Content-Type': 'application/x-www-form-urlencoded',
'referer': 'https://passport.douyu.com/member/login?state=https%3A%2F%2Fwww.douyu.com%2Fmember%2FcpSecurity%2Fcheck_geetest_status',
}
data = {
'type': '1',
'nicknameOrPhoneEncrypt': '03ILaBwtmmCm0A==',
'password': '57219dddec71c31b7647683fa5306103',
'biz_type': '1',
}
response = requests.post(
'https://passport.douyu.com/wgapi/member/passport/login',
headers=headers,
data=data,
timeout=10,
)
payload = _parse_json_response(response, "斗鱼登录接口")
try:
code_data = payload["data"]["geetest"]["code_data"]
return code_data["gt"], code_data["challenge"]
except KeyError as exc:
preview = json.dumps(payload, ensure_ascii=False)[:300]
raise ValueError(f"斗鱼登录接口返回中缺少极验参数: {preview}") from exc
def get_js_address(gt:str) -> dict:
headers = {
'accept': '*/*',
'accept-language': 'zh-CN,zh;q=0.9',
'referer': 'https://demos.geetest.com/',
'sec-ch-ua': '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Windows"',
'sec-fetch-dest': 'script',
'sec-fetch-mode': 'no-cors',
'sec-fetch-site': 'same-site',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
}
params = {
'gt': gt,
'callback': 'geetest_' + str(int(round(time.time() * 1000))),
}
response = requests.get(
'https://apiv6.geetest.com/gettype.php',
params=params,
headers=headers,
timeout=REQUEST_TIMEOUT,
)
response.raise_for_status()
match = re.search(r'\((.*)\)$', response.text)
if match:
json_str = match.group(1)
return json.loads(json_str)
else:
raise ValueError("无法解析 JSONP 响应")
def get_c_s(gt:str, challenge:str, w:str) -> Tuple[list[int],str]:
headers = {
'accept': '*/*',
'accept-language': 'zh-CN,zh;q=0.9',
'referer': 'https://demos.geetest.com/',
'sec-ch-ua': '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Windows"',
'sec-fetch-dest': 'script',
'sec-fetch-mode': 'no-cors',
'sec-fetch-site': 'same-site',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
}
response = requests.get(
'https://apiv6.geetest.com/get.php?gt=' + gt + '&challenge=' + challenge + '&lang=zh-cn&pt=0&client_type=web&w=' + w + '&callback=geetest_' + str(
int(round(time.time() * 1000))),
headers=headers,
timeout=REQUEST_TIMEOUT,
)
response.raise_for_status()
match = re.search(r'\((.*)\)$', response.text)
if match:
json_str = match.group(1)
return json.loads(json_str)['data']['c'], json.loads(json_str)['data']['s']
else:
raise ValueError("无法解析 JSONP 响应")
def req_slide(gt:str, challenge:str, w2:str) -> None:
headers = {
'Accept': '*/*',
'Accept-Language': 'zh-CN,zh;q=0.9',
'Connection': 'keep-alive',
'Referer': 'https://demos.geetest.com/',
'Sec-Fetch-Dest': 'script',
'Sec-Fetch-Mode': 'no-cors',
'Sec-Fetch-Site': 'cross-site',
'Sec-Fetch-Storage-Access': 'active',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
'sec-ch-ua': '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Windows"',
}
response = requests.get(
'https://api.geevisit.com/ajax.php?gt=' + gt + '&challenge=' + challenge + '&lang=zh-cn&pt=0&client_type=web&w=' + w2 + '&callback=geetest_' + str(
int(round(time.time() * 1000))),
headers=headers,
timeout=REQUEST_TIMEOUT,
)
response.raise_for_status()
def get_picture(gt:str, challenge:str) -> tuple[str, str, list[int], str, str, str]:
headers = {
'Accept': '*/*',
'Accept-Language': 'zh-CN,zh;q=0.9',
'Connection': 'keep-alive',
'Referer': 'https://demos.geetest.com/',
'Sec-Fetch-Dest': 'script',
'Sec-Fetch-Mode': 'no-cors',
'Sec-Fetch-Site': 'cross-site',
'Sec-Fetch-Storage-Access': 'active',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
'sec-ch-ua': '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Windows"',
}
params = {
'is_next': 'true',
'type': 'slide3',
'gt': gt,
'challenge': challenge,
'lang': 'zh-cn',
'https': 'true',
'protocol': 'https://',
'offline': 'false',
'product': 'embed',
'api_server': 'api.geevisit.com',
'isPC': 'true',
'autoReset': 'true',
'width': '100%',
'callback': 'geetest_'+str(int(round(time.time() * 1000))),
}
response = requests.get(
'https://api.geevisit.com/get.php',
params=params,
headers=headers,
timeout=REQUEST_TIMEOUT,
)
response.raise_for_status()
print(response.text)
match = re.search(r'\((.*)\)$', response.text)
if match:
json_str = match.group(1)
return json.loads(json_str)['bg'], json.loads(json_str)['fullbg'], json.loads(json_str)['c'], \
json.loads(json_str)['s'], json.loads(json_str)['slice'], json.loads(json_str)['challenge']
else:
raise ValueError("无法解析 JSONP 响应")
def req_end(gt:str, challenge:str, w:str) -> dict:
headers = {
'Accept': '*/*',
'Accept-Language': 'zh-CN,zh;q=0.9',
'Connection': 'keep-alive',
'Referer': 'https://demos.geetest.com/',
'Sec-Fetch-Dest': 'script',
'Sec-Fetch-Mode': 'no-cors',
'Sec-Fetch-Site': 'cross-site',
'Sec-Fetch-Storage-Access': 'active',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
'sec-ch-ua': '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Windows"',
}
response = requests.get(
'https://api.geevisit.com/ajax.php?gt='+gt+'&challenge='+challenge+'&lang=zh-cn&%24_BCm=0&client_type=web&w='+w+'&callback=geetest_'+str(int(round(time.time() * 1000))),
headers=headers,
timeout=REQUEST_TIMEOUT,
)
response.raise_for_status()
print(response.text)
match = re.search(r'\((.*)\)$', response.text)
if match:
json_str = match.group(1)
data = json.loads(json_str)
return data
else:
raise ValueError("无法解析 JSONP 响应")
+113
View File
@@ -0,0 +1,113 @@
import time
import random
from typing import Optional
def generate_fake_performance_timing(base_time: Optional[int] = None) -> dict[str, int]:
"""
生成伪造的浏览器性能时间戳数据
Args:
base_time: 基准时间戳(毫秒),默认使用当前时间
Returns:
dict: 包含所有性能时间戳的字典
"""
if base_time is None:
base_time = int(time.time() * 1000)
# 定义合理的时间间隔范围(毫秒)
intervals = {
'fetch': random.randint(1, 2),
'domain_lookup_start': random.randint(3, 5),
'domain_lookup': random.randint(5, 15),
'connect': random.randint(50, 150),
'ssl_offset': random.randint(30, 50),
'request': random.randint(1, 5),
'response': random.randint(20, 100),
'response_end': random.randint(1, 3),
'unload_start': random.randint(1, 3),
'unload': random.randint(1, 5),
'dom_loading': random.randint(1, 3),
'dom_interactive': random.randint(50, 200),
'dom_content_loaded': random.randint(1, 3),
'load_event': random.randint(0, 5)
}
timing = {}
# 按照时间顺序构建
timing['navigationStart'] = base_time
timing['fetchStart'] = timing['navigationStart'] + intervals['fetch']
timing['domainLookupStart'] = timing['fetchStart'] + intervals['domain_lookup_start']
timing['domainLookupEnd'] = timing['domainLookupStart'] + intervals['domain_lookup']
timing['connectStart'] = timing['domainLookupEnd']
timing['secureConnectionStart'] = timing['connectStart'] + intervals['ssl_offset']
timing['connectEnd'] = timing['connectStart'] + intervals['connect']
timing['requestStart'] = timing['connectEnd'] + intervals['request']
timing['responseStart'] = timing['requestStart'] + intervals['response']
timing['responseEnd'] = timing['responseStart'] + intervals['response_end']
timing['unloadEventStart'] = timing['responseEnd'] + intervals['unload_start']
timing['unloadEventEnd'] = timing['unloadEventStart'] + intervals['unload']
timing['domLoading'] = timing['unloadEventEnd'] + intervals['dom_loading']
timing['domInteractive'] = timing['domLoading'] + intervals['dom_interactive']
timing['domContentLoadedEventStart'] = timing['domInteractive']
timing['domContentLoadedEventEnd'] = timing['domInteractive'] + intervals['dom_content_loaded']
timing['domComplete'] = timing['domContentLoadedEventEnd']
timing['loadEventStart'] = timing['domComplete']
timing['loadEventEnd'] = timing['loadEventStart'] + intervals['load_event']
# 无重定向的情况
timing['redirectStart'] = 0
timing['redirectEnd'] = 0
return timing
def __ease_out_expo(sep):
'''
轨迹相关操作
'''
if sep == 1:
return 1
else:
return 1 - pow(2, -10 * sep)
def get_slide_track(distance):
"""
根据滑动距离生成滑动轨迹
:param distance: 需要滑动的距离
:return: 滑动轨迹<type 'list'>: [[x,y,t], ...]
x: 已滑动的横向距离
y: 已滑动的纵向距离, 除起点外, 均为0
t: 滑动过程消耗的时间, 单位: 毫秒
"""
if not isinstance(distance, int) or distance < 0:
raise ValueError(f"distance类型必须是大于等于0的整数: distance: {distance}, type: {type(distance)}")
# 初始化轨迹列表
slide_track = [
[random.randint(-50, -10), random.randint(-50, -10), 0],
[0, 0, 0],
]
# 共记录count次滑块位置信息
count = 10 + int(distance / 2)
# 初始化滑动时间
t = random.randint(50, 100)
# 记录上一次滑动的距离
_x = 0
_y = 0
for i in range(count):
# 已滑动的横向距离
x = round(__ease_out_expo(i / count) * distance)
# y = round(__ease_out_expo(i / count) * 14)
# 滑动过程消耗的时间
t += random.randint(10, 50)
if x == _x:
continue
slide_track.append([x, _y, t])
_x = x
slide_track.append(slide_track[-1])
return slide_track, slide_track[-1][2]
+159
View File
@@ -0,0 +1,159 @@
import time
import random
import json
from .trajectory import generate_realistic_trajectory, process_mouse_trajectory, compress_trajectory, TrajectoryEncoder, \
H
from .crypto import four_random_chart, RSA_jiami_r, AES_O, geetest_base64_encode, encrypt_string, simple_md5
from .imaging import download_picture
from .network import get_challenge_gt, get_js_address, get_c_s, req_slide, get_picture, req_end
from .performance import generate_fake_performance_timing, get_slide_track
def _generate_seed() -> str:
return four_random_chart() + four_random_chart() + four_random_chart() + four_random_chart()
def get_w1(gt:str, challenge:str, str_16:str) -> str:
r = RSA_jiami_r(str_16)
plaintext = '{"gt":"' + gt + '","challenge":"' + challenge + '","offline":false,"new_captcha":true,"product":"float","width":"300px","https":true,"api_server":"apiv6.geetest.com","protocol":"https://","type":"fullpage","static_servers":["static.geetest.com/","static.geevisit.com/"],"voice":"/static/js/voice.1.2.6.js","click":"/static/js/click.3.1.2.js","beeline":"/static/js/beeline.1.0.1.js","fullpage":"/static/js/fullpage.9.2.0-guwyxh.js","slide":"/static/js/slide.7.9.3.js","geetest":"/static/js/geetest.6.0.9.js","aspect_radio":{"slide":103,"click":128,"voice":128,"beeline":50},"cc":16,"ww":true,"i":"-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1"}'
o = AES_O(plaintext, str_16)
i = geetest_base64_encode(o)
return i['res'] + i['end'] + r
def get_w2(gt:str, challenge:str, c:list[int], s:str,str_16:str) -> str:
# 伪造浏览器性能数据
fake_timing = generate_fake_performance_timing()
# 映射
web_load_time = {
"a": fake_timing["navigationStart"],
"b": fake_timing["unloadEventStart"],
"c": fake_timing["unloadEventEnd"],
"d": fake_timing["redirectStart"],
"e": fake_timing["redirectEnd"],
"f": fake_timing["fetchStart"],
"g": fake_timing["domainLookupStart"],
"h": fake_timing["domainLookupEnd"],
"i": fake_timing["connectStart"],
"j": fake_timing["connectEnd"],
"k": fake_timing["secureConnectionStart"],
"l": fake_timing["requestStart"],
"m": fake_timing["responseStart"],
"n": fake_timing["responseEnd"],
"o": fake_timing["domLoading"],
"p": fake_timing["domInteractive"],
"q": fake_timing["domContentLoadedEventStart"],
"r": fake_timing["domContentLoadedEventEnd"],
"s": fake_timing["domComplete"],
"t": fake_timing["loadEventStart"],
"u": fake_timing["loadEventEnd"]
}
first_time = int(round(time.time() * 1000)) # 伪造脚本开始运行时间
guiji_yuanshu_shuzu = generate_realistic_trajectory(
start_x=random.randint(400, 600), # 起始位置随机
start_y=random.randint(400, 500),
end_x=853,
end_y=288,
start_time=first_time
)
trajectory = process_mouse_trajectory(guiji_yuanshu_shuzu)["data"]
compressed = compress_trajectory(trajectory)
tt = encrypt_string(compressed, c, s)
passtime = str(int(round(time.time() * 1000)) - first_time)
rp = simple_md5(gt + challenge + passtime)
plaintext = '{"lang":"zh-cn","type":"fullpage","tt":"'+tt+'","light":"DIV_0","s":"c7c3e21112fe4f741921cb3e4ff9f7cb","h":"321f9af1e098233dbd03f250fd2b5e21","hh":"39bd9cad9e425c3a8f51610fd506e3b3","hi":"09eb21b3ae9542a9bc1e8b63b3d9a467","vip_order":-1,"ct":-1,"ep":{"v":"9.2.0-guwyxh","te":false,"$_BBn":true,"ven":"Google Inc. (AMD)","ren":"ANGLE (AMD, AMD Radeon RX 6750 GRE 12GB (0x000073DF) Direct3D11 vs_5_0 ps_5_0, D3D11)","fp":'+json.dumps(guiji_yuanshu_shuzu[0], separators=(',', ':'))+',"lp":'+json.dumps(guiji_yuanshu_shuzu[-1], separators=(',', ':'))+',"em":{"ph":0,"cp":0,"ek":"11","wd":1,"nt":0,"si":0,"sc":0},"tm":'+json.dumps(web_load_time, separators=(',', ':'))+',"dnf":"dnf","by":0},"passtime":'+passtime+',"rp":"'+rp+'","captcha_token":"112439067","tsfq":"xovrayel"}'
result = geetest_base64_encode(AES_O(plaintext, str_16))
return result['res']+result['end']+result['end']
def get_w3(str_16:str, challenge:str, hkjl:int, c:list[int], s:str, gt:str) -> str:
encoder = TrajectoryEncoder()
u = RSA_jiami_r(str_16)
# 伪造浏览器性能数据
fake_timing = generate_fake_performance_timing()
# 映射
web_load_time = {
"a": fake_timing["navigationStart"],
"b": fake_timing["unloadEventStart"],
"c": fake_timing["unloadEventEnd"],
"d": fake_timing["redirectStart"],
"e": fake_timing["redirectEnd"],
"f": fake_timing["fetchStart"],
"g": fake_timing["domainLookupStart"],
"h": fake_timing["domainLookupEnd"],
"i": fake_timing["connectStart"],
"j": fake_timing["connectEnd"],
"k": fake_timing["secureConnectionStart"],
"l": fake_timing["requestStart"],
"m": fake_timing["responseStart"],
"n": fake_timing["responseEnd"],
"o": fake_timing["domLoading"],
"p": fake_timing["domInteractive"],
"q": fake_timing["domContentLoadedEventStart"],
"r": fake_timing["domContentLoadedEventEnd"],
"s": fake_timing["domComplete"],
"t": fake_timing["loadEventStart"],
"u": fake_timing["loadEventEnd"]
}
trajectory = get_slide_track(hkjl)[0]
print(trajectory)
# trajectory = generator.generate(target_x)
userresponse = H(trajectory[-1][0], challenge)
result1 = encoder.encode(trajectory)
aa = encoder.encrypt_string(result1, c, s)
passtime = str(trajectory[-1][2])
rp = simple_md5(gt + challenge[:32] + passtime)
plaintext = '{"lang":"zh-cn","userresponse":"'+userresponse+'","passtime":'+passtime+',"imgload":50,"aa":"'+aa+'","ep":{"v":"7.9.3","$_BIT":false,"me":true,"tm":'+json.dumps(web_load_time, separators=(',', ':'))+',"td":-1},"h9s9":"1816378497","rp":"'+rp+'"}'
h = geetest_base64_encode(AES_O(plaintext, str_16))['res']
return h+u
def run_solver() -> None:
# 16位字符串
str_16 = _generate_seed()
# 访问第一个链接获取gt challeneg
gt, challenge = get_challenge_gt()
# 访问第二个链接获取js文件地址(后续需要用到)
get_js_address(gt)
# 获取第一个w值
w1 = get_w1(gt, challenge, str_16)
# 访问第三个链接获取数组c与字符串s
c, s = get_c_s(gt, challenge, w1)
# 获取第二个w值
w2 = get_w2(gt, challenge, c, s, str_16)
# 访问第四个链接确认进入滑动
req_slide(gt, challenge, w2)
# 访问第五个链接获取最后一个w值所需要的加密参数
bg, fullbg, c, s, slice, challenge = get_picture(gt, challenge)
# 识别缺口位置得到滑块距离
hkjl = download_picture(bg, fullbg, slice)
# 获取第三个w值
w3 = get_w3(str_16,challenge,hkjl,c,s,gt)
# 最后的验证
message = req_end(gt, challenge, w3)
+628
View File
@@ -0,0 +1,628 @@
import random
import math
from typing import Any, Optional
# 生成类人的鼠标轨迹
def generate_realistic_trajectory(start_x:int, start_y:int, end_x:int, end_y:int, start_time:int) -> list[Any]:
"""生成类人的鼠标轨迹"""
trajectory = []
# 计算距离和步数
distance = ((end_x - start_x) ** 2 + (end_y - start_y) ** 2) ** 0.5
steps = int(distance / 2) + random.randint(5, 15) # 根据距离动态调整步数
current_time = start_time
current_x, current_y = start_x, start_y
for i in range(steps):
# 使用贝塞尔曲线模拟自然移动
progress = i / steps
# 添加缓动函数(开始快,中间慢,结束快)
if progress < 0.3:
eased = progress / 0.3 * 0.2
elif progress < 0.7:
eased = 0.2 + (progress - 0.3) / 0.4 * 0.5
else:
eased = 0.7 + (progress - 0.7) / 0.3 * 0.3
# 计算目标位置(添加随机抖动)
target_x = start_x + (end_x - start_x) * eased
target_y = start_y + (end_y - start_y) * eased
# 添加微小的随机偏移(模拟手抖)
jitter_x = random.uniform(-0.5, 0.5)
jitter_y = random.uniform(-0.5, 0.5)
current_x = int(target_x + jitter_x)
current_y = int(target_y + jitter_y)
# 随机时间间隔(3-25ms,符合人类反应)
time_delta = random.choices(
[3, 4, 5, 6, 7, 8, 10, 12, 15, 20, 24],
weights=[5, 8, 10, 12, 10, 8, 5, 3, 2, 1, 1]
)[0]
current_time += time_delta
trajectory.append([
"move",
current_x,
current_y,
current_time,
"pointermove"
])
# 偶尔在同一位置停留(模拟视觉确认)
if random.random() < 0.15:
trajectory.append([
"move",
current_x,
current_y,
current_time + random.randint(5, 15),
"pointermove"
])
current_time += random.randint(5, 15)
# 到达目标后的悬停
hover_time = random.randint(50, 150)
for _ in range(random.randint(2, 5)):
current_time += random.randint(8, 25)
trajectory.append([
"move",
end_x + random.randint(-1, 1),
end_y + random.randint(-1, 1),
current_time,
"pointermove"
])
current_time += hover_time
# 点击事件
trajectory.append([
"down",
end_x,
end_y,
current_time,
"pointerdown"
])
trajectory.append([
"focus",
current_time + 1
])
click_duration = random.randint(80, 130)
trajectory.append([
"up",
end_x,
end_y,
current_time + click_duration,
"pointerup"
])
return trajectory
# 处理原始轨迹数组
def process_mouse_trajectory(events:list[Any], max_records: Optional[int] = None) -> dict[str,Any]:
"""
处理鼠标/触摸轨迹数据,将绝对坐标转换为相对坐标和时间差
参数:
events: 原始事件数据列表
max_records: 最大保留记录数(None表示保留全部)
返回:
处理后的事件列表
"""
if not events or len(events) == 0:
return []
# 初始化变量
prev_x = 0 # 上一个X坐标
prev_y = 0 # 上一个Y坐标
prev_time = 0 # 上一个时间戳
result = [] # 结果数组
first_event = None # 第一个事件
last_event = None # 最后一个事件
# 移动类事件(包含坐标信息)
MOVE_EVENTS = ["move", "mousemove", "touchmove", "pointermove"]
# 点击类事件(仅时间信息)
CLICK_EVENTS = ["down", "up", "click", "mousedown", "mouseup",
"touchstart", "touchend", "pointerdown", "pointerup"]
# 特殊事件(仅时间信息)
TIME_ONLY_EVENTS = ["focus", "blur", "keydown", "keyup"]
# 如果设置了最大记录数,只处理最后N条
start_index = 0
if max_records and len(events) > max_records:
start_index = len(events) - max_records
# 遍历事件
for i in range(start_index, len(events)):
event = events[i]
event_type = event[0]
# 处理移动类事件(包含X, Y坐标)
if event_type in MOVE_EVENTS:
x = event[1]
y = event[2]
timestamp = event[3]
# 记录第一个和最后一个事件
if first_event is None:
first_event = event
last_event = event
# 计算相对坐标差值
delta_x = x - prev_x
delta_y = y - prev_y
# 计算时间差
if prev_time == 0:
time_diff = 0 # 第一个事件时间差为0
else:
time_diff = timestamp - prev_time
# 添加到结果数组
result.append([
event_type,
[delta_x, delta_y],
time_diff
])
# 更新上一次的值
prev_x = x
prev_y = y
prev_time = timestamp
# 处理点击类事件(包含坐标但只记录时间差)
elif event_type in CLICK_EVENTS:
timestamp = event[3] if len(event) > 3 else event[1]
# 计算时间差
if prev_time == 0:
time_diff = 0
else:
time_diff = timestamp - prev_time
# 添加到结果数组(坐标差为[0,0])
result.append([
event_type,
[0, 0],
time_diff
])
prev_time = timestamp
# 处理仅时间类事件(如focus
elif event_type in TIME_ONLY_EVENTS:
timestamp = event[1]
# 计算时间差
if prev_time == 0:
time_diff = 0
else:
time_diff = timestamp - prev_time
# 添加到结果数组(仅包含时间差)
result.append([
event_type,
time_diff
])
prev_time = timestamp
return {
"data": result,
"first_event": first_event,
"last_event": last_event,
"total_events": len(result)
}
def compress_trajectory(e:list[Any]) -> str:
"""
压缩轨迹数据的完整实现
Args:
e: 轨迹数据列表
Returns:
压缩后的 Base64 编码字符串
"""
# 事件类型映射
p = {
"move": 0,
"down": 1,
"up": 2,
"scroll": 3,
"focus": 4,
"blur": 5,
"unload": 6,
"unknown": 7
}
def h(e, t):
"""
填充二进制字符串
Args:
e: 数值
t: 目标长度
Returns:
填充后的二进制字符串
"""
n = bin(e)[2:] # 转为二进制并去掉 '0b' 前缀
r = ""
o = len(n) + 1
while o <= t:
r += "0"
o += 1
return r + n
def f(e):
"""
压缩事件类型数组
Args:
e: 事件类型列表
Returns:
压缩后的二进制字符串
"""
t = []
n = len(e)
r = 0
# 游程编码(Run-Length Encoding
while r < n:
o = e[r]
i = 0
while True:
if 16 <= i:
break
s = r + i + 1
if n <= s:
break
if e[s] != o:
break
i += 1
r = r + 1 + i
a = p[o]
if i != 0:
t.append(8 | a) # 设置重复标志位
t.append(i - 1)
else:
t.append(a)
# 编码长度信息
_ = h(32768 | n, 16)
c = ""
for l in range(len(t)):
c += h(t[l], 4)
return _ + c
def c(e, t):
"""
对数组每个元素应用函数
Args:
e: 输入数组
t: 转换函数
Returns:
转换后的数组
"""
n = []
for r in range(len(e)):
n.append(t(e[r]))
return n
def d(e, t):
"""
压缩数值数组(游程编码 + 变长编码)
Args:
e: 数值数组
t: 是否为坐标数据(需要过滤符号位)
Returns:
压缩后的二进制字符串
"""
# 第一步:限制数值范围到 [-32767, 32767]
def limit_value(val):
limit = 32767
return max(-limit, min(limit, val))
e = c(e, limit_value)
# 第二步:游程编码(Run-Length Encoding
n = len(e)
r = 0
o = []
while r < n:
i = 1
s = e[r]
a = abs(s)
# 统计连续相同的值
while r + i < n and e[r + i] == s and a < 127 and i < 127:
i += 1
if i > 1:
# 重复值编码格式:
# 位15: 符号标志 (1=负数49152, 0=正数32768)
# 位14-7: 重复次数 (i)
# 位6-0: 绝对值 (a)
o.append((49152 if s < 0 else 32768) | (i << 7) | a)
else:
o.append(s)
r += i
e = o
# 第三步:变长编码
r = [] # 存储每个数字的十六进制位数
o = [] # 存储实际数值
for val in e:
# 计算需要多少个十六进制位(每位4 bit)
if val == 0:
bits = 1
else:
# 方法1:使用对数(与原JS一致)
bits = math.ceil(math.log(abs(val) + 1) / math.log(16))
# 方法2:直接计算(更快)
# bits = len(format(abs(val), 'x'))
bits = max(1, bits)
r.append(h(bits - 1, 2)) # 2位十六进制存储位数信息
o.append(h(abs(val), 4 * bits)) # 实际值
i = "".join(r) # 元数据
s = "".join(o) # 数据
# 第四步:符号位编码(仅用于坐标数据)
if t:
# 过滤掉:0值 和 已编码符号的压缩值(位15=1)
filtered = [x for x in e if x != 0 and (x >> 15) != 1]
n = "".join(["1" if x < 0 else "0" for x in filtered])
else:
n = ""
# 最终格式:[头部16位][元数据][数据][符号位]
# 头部:最高位置1 + 数组长度
return h(32768 | len(e), 16) + i + s + n
# 主函数:数据分离
t = [] # 事件类型
n = [] # 时间差
r = [] # X坐标
o = [] # Y坐标
for i in range(len(e)):
a = e[i]
length = len(a)
t.append(a[0])
n.append(a[1] if length == 2 else a[2])
if length == 3:
r.append(a[1][0])
o.append(a[1][1])
# 压缩各部分
c_str = f(t) + d(n, False) + d(r, True) + d(o, True)
# 填充到6的倍数
l = len(c_str)
if l % 6 != 0:
c_str += h(0, 6 - l % 6)
# Base64编码
def u(e):
"""Base64编码"""
t = ""
n = len(e) // 6
base64_chars = "()*,-./0123456789:?@ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz~"
for r in range(n):
# 每次取6位二进制
binary_str = e[6 * r: 6 * (r + 1)]
index = int(binary_str, 2)
t += base64_chars[index]
return t
return u(c_str)
class TrajectoryEncoder:
def __init__(self) -> None:
self.CHARSET = "()*,-./0123456789:?@ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqr"
self.BASE = len(self.CHARSET) # 64
self.DIRECTION_CHARS = "stuvwxyz~"
self.DIRECTION_PATTERNS = [
[1, 0], # s
[2, 0], # t
[1, -1], # u
[1, 1], # v
[0, 1], # w
[0, -1], # x
[3, 0], # y
[2, -1], # z
[2, 1] # ~
]
def encode_number(self, num):
"""编码单个数值为64进制"""
abs_num = abs(num)
high_index = abs_num // self.BASE
low_index = abs_num % self.BASE
result = ""
# 负数标记
if num < 0:
result += "!"
# 高位(当值>=64时)
if high_index > 0 and high_index < self.BASE:
result += "$"
result += self.CHARSET[high_index]
# 低位
result += self.CHARSET[low_index]
return result
def compress_trajectory(self, points):
"""压缩轨迹:计算相邻点差值"""
compressed = []
time_accumulator = 0
for i in range(len(points) - 1):
dx = points[i + 1][0] - points[i][0] # 不要用abs
dy = points[i + 1][1] - points[i][1] # 不要用abs
dt = abs(points[i + 1][2] - points[i][2]) # 时间可以用abs
# 跳过完全相同的点
if dx == 0 and dy == 0 and dt == 0:
continue
# 位置不变只累积时间
if dx == 0 and dy == 0:
time_accumulator += dt
else:
compressed.append([dx, dy, dt + time_accumulator])
time_accumulator = 0
# 处理剩余时间
if time_accumulator != 0:
compressed.append([0, 0, time_accumulator])
return compressed
def get_direction_code(self, dx, dy):
"""识别是否匹配方向模式"""
for i, pattern in enumerate(self.DIRECTION_PATTERNS):
if dx == pattern[0] and dy == pattern[1]:
return self.DIRECTION_CHARS[i]
return None
def encode(self, trajectory):
"""
编码轨迹
trajectory: [[x, y, timestamp], ...]
返回: 编码后的字符串
"""
compressed = self.compress_trajectory(trajectory)
x_encoded = []
y_encoded = []
t_encoded = []
for dx, dy, dt in compressed:
direction_code = self.get_direction_code(dx, dy)
if direction_code:
# 匹配到方向模式,只记录y
y_encoded.append(direction_code)
else:
# 不匹配,完整编码x和y
x_encoded.append(self.encode_number(dx))
y_encoded.append(self.encode_number(dy))
# 时间总是编码
t_encoded.append(self.encode_number(dt))
# 拼接:x坐标 !! y坐标 !! 时间戳
return "".join(x_encoded) + "!!" + "".join(y_encoded) + "!!" + "".join(t_encoded)
def encrypt_string(self,e, t, n):
"""
JS加密函数的Python实现
参数:
e: 原始字符串
t: 加密参数数组
n: 十六进制字符串
"""
if not t or not n:
return e
o = 0 # 偏移量
i = e # 结果字符串
s = t[0] # 12
a = t[2] # 98
_ = t[4] # 43
# 每次读取2个字符(十六进制)
while o < len(n):
r = n[o:o + 2] # 取2个字符
if len(r) < 2:
break
o += 2
# 解析十六进制
c = int(r, 16)
# 转换为字符
l = chr(c)
# 计算插入位置: (s * c^2 + a * c + _) % len(e)
u = (s * c * c + a * c + _) % len(e)
# 在位置u插入字符
i = i[:u] + l + i[u:]
return i
def H(t:int, e:str) -> str:
# 解析后缀
n = e[-2:]
r = []
for char in n:
o = ord(char)
r.append(o - 87 if o > 57 else o - 48)
n = 36 * r[0] + r[1]
# 计算目标值
a = round(t) + n
# 构建字符池
_ = [[], [], [], [], []]
c = {}
u = 0
for char in e[:-2]:
if char not in c:
c[char] = 1
_[u].append(char)
u = (u + 1) % 5
# 生成结果
f = a
d = 4
p = ""
g = [1, 2, 5, 10, 50]
while f > 0:
if f >= g[d]:
h = int(random.random() * len(_[d]))
p += _[d][h]
f -= g[d]
else:
_.pop(d)
g.pop(d)
d -= 1
return p
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""
斗鱼自动登录工具 - Python版
功能:
1. 自动登录斗鱼账号
2. 自动过极验滑块验证
3. 自动获取邮箱验证码
4. 批量获取Cookie
"""
import sys
import argparse
from pathlib import Path
from loguru import logger
from douyu import DouyuLogin, Config
from utils import setup_logger, save_cookie, load_accounts
def login_single(config: Config, account_index: int = 0) -> str:
"""
单账号登录
Args:
config: 配置对象
account_index: 账号索引
Returns:
Cookie字符串
"""
accounts = config.get_accounts()
if account_index >= len(accounts):
logger.error(f"账号索引 {account_index} 超出范围,共有 {len(accounts)} 个账号")
return ""
account = accounts[account_index]
logger.info(f"登录账号: {account.username}")
# 创建登录器
proxy = config.get_proxy()
proxy_url = None
if proxy.enabled:
proxy_url = {
'http': proxy.http or proxy.https,
'https': proxy.https or proxy.http,
}
loginer = DouyuLogin(account, proxy=proxy_url)
# 执行登录
result = loginer.login()
if result.success:
# 保存Cookie
cookie_dir = config.get_cookie_dir()
save_cookie(account.username, result.cookie, cookie_dir)
return result.cookie
else:
logger.error(f"登录失败: {result.message}")
return ""
def login_batch(config: Config) -> dict:
"""
批量登录
Returns:
{username: cookie} 字典
"""
accounts = config.get_accounts()
results = {}
logger.info(f"开始批量登录,共 {len(accounts)} 个账号")
for i, account in enumerate(accounts):
logger.info(f"[{i+1}/{len(accounts)}] 登录账号: {account.username}")
try:
cookie = login_single(config, i)
if cookie:
results[account.username] = cookie
logger.success(f"账号 {account.username} 登录成功")
else:
logger.error(f"账号 {account.username} 登录失败")
except Exception as e:
logger.error(f"账号 {account.username} 登录异常: {e}")
# 统计结果
success_count = len(results)
total_count = len(accounts)
logger.info(f"批量登录完成: 成功 {success_count}/{total_count}")
return results
def main():
"""主函数"""
parser = argparse.ArgumentParser(description='斗鱼自动登录工具')
parser.add_argument('-c', '--config', default='config.yaml', help='配置文件路径')
parser.add_argument('-i', '--index', type=int, default=0, help='单账号登录时的账号索引')
parser.add_argument('-b', '--batch', action='store_true', help='批量登录模式')
parser.add_argument('-v', '--verbose', action='store_true', help='详细日志')
args = parser.parse_args()
# 配置日志
log_level = "DEBUG" if args.verbose else "INFO"
setup_logger(level=log_level)
# 加载配置
try:
config = Config(args.config)
except FileNotFoundError as e:
logger.error(str(e))
sys.exit(1)
# 执行登录
if args.batch:
results = login_batch(config)
print(f"\n登录结果: 成功 {len(results)} 个账号")
else:
cookie = login_single(config, args.index)
if cookie:
print(f"\n登录成功!")
print(f"Cookie: {cookie[:50]}...")
else:
print("\n登录失败")
sys.exit(1)
if __name__ == '__main__':
main()
+25
View File
@@ -0,0 +1,25 @@
[project]
name = "douyu-login-py"
version = "0.1.0"
description = "斗鱼自动登录工具 - Python版"
readme = "README.md"
requires-python = ">=3.12,<3.13"
dependencies = [
"requests>=2.31.0",
"pycryptodome>=3.19.0",
"numpy>=1.24.0",
"opencv-python-headless>=4.8.0",
"Pillow>=10.0.0",
"PyYAML>=6.0",
"loguru>=0.7.0",
]
[project.scripts]
douyu-login = "main:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["douyu", "geetest", "utils"]
+7
View File
@@ -0,0 +1,7 @@
requests>=2.31.0
pycryptodome>=3.19.0
numpy>=1.24.0
opencv-python-headless>=4.8.0
Pillow>=10.0.0
PyYAML>=6.0
loguru>=0.7.0
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""
测试登录功能
"""
import sys
from pathlib import Path
# 添加项目根目录到path
sys.path.insert(0, str(Path(__file__).parent))
from douyu.config import Config, Account
from douyu.crypto import encrypt_password, encrypt_nickname_or_phone
from douyu.email_verifier import EmailVerifier
from utils.logger import setup_logger
def test_crypto():
"""测试加密功能"""
print("=== 测试加密功能 ===")
# 测试密码加密
password = "test_password"
encrypted = encrypt_password(password)
print(f"密码: {password}")
print(f"MD5: {encrypted}")
# 测试用户名加密
username = "test_user"
encrypted = encrypt_nickname_or_phone(username)
print(f"用户名: {username}")
print(f"加密后: {encrypted}")
print()
def test_config():
"""测试配置加载"""
print("=== 测试配置加载 ===")
try:
config = Config("config.yaml")
accounts = config.get_accounts()
print(f"加载了 {len(accounts)} 个账号")
for i, acc in enumerate(accounts):
print(f" [{i}] {acc.username} - {acc.email}")
proxy = config.get_proxy()
print(f"代理: {'启用' if proxy.enabled else '禁用'}")
except FileNotFoundError as e:
print(f"配置文件不存在: {e}")
except Exception as e:
print(f"加载配置失败: {e}")
print()
def test_email_verifier():
"""测试邮箱验证器(不实际连接)"""
print("=== 测试邮箱验证器 ===")
# 测试验证码提取
verifier = EmailVerifier("", "", "", "")
# 模拟邮件内容
test_cases = [
"您的验证码是:123456,请在5分钟内完成验证。",
"验证码:654321",
"Your verification code is 789012",
"【斗鱼】安全验证码:345678,切勿泄露给他人!",
]
for text in test_cases:
code = verifier._extract_verification_code(text)
print(f"文本: {text[:30]}...")
print(f"验证码: {code}")
print()
print()
def main():
"""运行所有测试"""
setup_logger(level="INFO")
print("斗鱼自动登录工具 - 测试\n")
test_crypto()
test_config()
test_email_verifier()
print("测试完成!")
if __name__ == '__main__':
main()
+6
View File
@@ -0,0 +1,6 @@
"""工具模块"""
from .logger import setup_logger
from .helpers import load_accounts, save_cookie
__all__ = ["setup_logger", "load_accounts", "save_cookie"]
+95
View File
@@ -0,0 +1,95 @@
"""辅助工具函数"""
import json
from pathlib import Path
from typing import List
from loguru import logger
from douyu.config import Account
def load_accounts(config_path: str = "config.yaml") -> List[Account]:
"""加载账号列表"""
from douyu.config import Config
config = Config(config_path)
accounts = config.get_accounts()
logger.info(f"加载了 {len(accounts)} 个账号")
return accounts
def save_cookie(username: str, cookie: str, cookie_dir: str = "data/cookies") -> str:
"""
保存Cookie到文件
Args:
username: 用户名
cookie: Cookie字符串
cookie_dir: Cookie存储目录
Returns:
文件路径
"""
import time
cookie_path = Path(cookie_dir)
cookie_path.mkdir(parents=True, exist_ok=True)
filepath = cookie_path / f"{username}.json"
data = {
'username': username,
'cookie': cookie,
'timestamp': int(time.time()),
}
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
logger.info(f"Cookie已保存: {filepath}")
return str(filepath)
def load_cookie(username: str, cookie_dir: str = "data/cookies") -> str:
"""
从文件加载Cookie
Args:
username: 用户名
cookie_dir: Cookie存储目录
Returns:
Cookie字符串,不存在返回空字符串
"""
filepath = Path(cookie_dir) / f"{username}.json"
if not filepath.exists():
return ""
try:
with open(filepath, 'r', encoding='utf-8') as f:
data = json.load(f)
return data.get('cookie', '')
except Exception as e:
logger.error(f"加载Cookie失败: {e}")
return ""
def format_cookie_for_browser(cookie_str: str) -> dict:
"""
将Cookie字符串转换为浏览器格式
Args:
cookie_str: Cookie字符串
Returns:
Cookie字典
"""
cookies = {}
for item in cookie_str.split(';'):
item = item.strip()
if '=' in item:
key, value = item.split('=', 1)
cookies[key.strip()] = value.strip()
return cookies
+42
View File
@@ -0,0 +1,42 @@
"""日志配置模块"""
import sys
from pathlib import Path
from loguru import logger
def setup_logger(level: str = "INFO", log_file: str = None) -> None:
"""
配置日志
Args:
level: 日志级别
log_file: 日志文件路径
"""
# 移除默认handler
logger.remove()
# 控制台输出
logger.add(
sys.stdout,
level=level,
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
"<level>{level: <8}</level> | "
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | "
"<level>{message}</level>",
colorize=True,
)
# 文件输出
if log_file:
log_path = Path(log_file)
log_path.parent.mkdir(parents=True, exist_ok=True)
logger.add(
log_file,
level=level,
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} | {message}",
rotation="10 MB",
retention="7 days",
encoding="utf-8",
)
Generated
+219
View File
@@ -0,0 +1,219 @@
version = 1
revision = 3
requires-python = "==3.12.*"
[[package]]
name = "certifi"
version = "2026.6.17"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" },
{ url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" },
{ url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" },
{ url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" },
{ url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" },
{ url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" },
{ url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" },
{ url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" },
{ url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" },
{ url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" },
{ url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" },
{ url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" },
{ url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" },
{ url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" },
{ url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" },
{ url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" },
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "douyu-login-py"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "loguru" },
{ name = "numpy" },
{ name = "opencv-python-headless" },
{ name = "pillow" },
{ name = "pycryptodome" },
{ name = "pyyaml" },
{ name = "requests" },
]
[package.metadata]
requires-dist = [
{ name = "loguru", specifier = ">=0.7.0" },
{ name = "numpy", specifier = ">=1.24.0" },
{ name = "opencv-python-headless", specifier = ">=4.8.0" },
{ name = "pillow", specifier = ">=10.0.0" },
{ name = "pycryptodome", specifier = ">=3.19.0" },
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "requests", specifier = ">=2.31.0" },
]
[[package]]
name = "idna"
version = "3.18"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
]
[[package]]
name = "loguru"
version = "0.7.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "win32-setctime", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
]
[[package]]
name = "numpy"
version = "2.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" },
{ url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" },
{ url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" },
{ url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" },
{ url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" },
{ url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" },
{ url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" },
{ url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" },
{ url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" },
{ url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" },
{ url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" },
]
[[package]]
name = "opencv-python-headless"
version = "4.13.0.92"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/42/2310883be3b8826ac58c3f2787b9358a2d46923d61f88fedf930bc59c60c/opencv_python_headless-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:1a7d040ac656c11b8c38677cc8cccdc149f98535089dbe5b081e80a4e5903209", size = 46247192, upload-time = "2026-02-05T07:01:35.187Z" },
{ url = "https://files.pythonhosted.org/packages/2d/1e/6f9e38005a6f7f22af785df42a43139d0e20f169eb5787ce8be37ee7fcc9/opencv_python_headless-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:3e0a6f0a37994ec6ce5f59e936be21d5d6384a4556f2d2da9c2f9c5dc948394c", size = 32568914, upload-time = "2026-02-05T07:01:51.989Z" },
{ url = "https://files.pythonhosted.org/packages/21/76/9417a6aef9def70e467a5bf560579f816148a4c658b7d525581b356eda9e/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c8cfc8e87ed452b5cecb9419473ee5560a989859fe1d10d1ce11ae87b09a2cb", size = 33703709, upload-time = "2026-02-05T10:24:46.469Z" },
{ url = "https://files.pythonhosted.org/packages/92/ce/bd17ff5772938267fd49716e94ca24f616ff4cb1ff4c6be13085108037be/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0525a3d2c0b46c611e2130b5fdebc94cf404845d8fa64d2f3a3b679572a5bd22", size = 56016764, upload-time = "2026-02-05T10:26:48.904Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b4/b7bcbf7c874665825a8c8e1097e93ea25d1f1d210a3e20d4451d01da30aa/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb60e36b237b1ebd40a912da5384b348df8ed534f6f644d8e0b4f103e272ba7d", size = 35010236, upload-time = "2026-02-05T10:28:11.031Z" },
{ url = "https://files.pythonhosted.org/packages/4b/33/b5db29a6c00eb8f50708110d8d453747ca125c8b805bc437b289dbdcc057/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0bd48544f77c68b2941392fcdf9bcd2b9cdf00e98cb8c29b2455d194763cf99e", size = 60391106, upload-time = "2026-02-05T10:30:14.236Z" },
{ url = "https://files.pythonhosted.org/packages/fb/c3/52cfea47cd33e53e8c0fbd6e7c800b457245c1fda7d61660b4ffe9596a7f/opencv_python_headless-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:a7cf08e5b191f4ebb530791acc0825a7986e0d0dee2a3c491184bd8599848a4b", size = 30812232, upload-time = "2026-02-05T07:02:29.594Z" },
{ url = "https://files.pythonhosted.org/packages/4a/90/b338326131ccb2aaa3c2c85d00f41822c0050139a4bfe723cfd95455bd2d/opencv_python_headless-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:77a82fe35ddcec0f62c15f2ba8a12ecc2ed4207c17b0902c7a3151ae29f37fb6", size = 40070414, upload-time = "2026-02-05T07:02:26.448Z" },
]
[[package]]
name = "pillow"
version = "12.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" },
{ url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" },
{ url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" },
{ url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" },
{ url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" },
{ url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" },
{ url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" },
{ url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" },
{ url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" },
{ url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" },
{ url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" },
]
[[package]]
name = "pycryptodome"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
{ url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
{ url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
{ url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
{ url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
]
[[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/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" },
]
[[package]]
name = "requests"
version = "2.34.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
]
[[package]]
name = "urllib3"
version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
]
[[package]]
name = "win32-setctime"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" },
]