commit 1ce867f3a801b9fa4fa25f9bbc482adad13c0e37 Author: yml2213 Date: Sun Jun 21 21:56:56 2026 +0800 inti diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..df989ca --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.venv +*/__pycache__ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..427f0a2 --- /dev/null +++ b/README.md @@ -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 diff --git a/__pycache__/main.cpython-312.pyc b/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000..1764664 Binary files /dev/null and b/__pycache__/main.cpython-312.pyc differ diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..e22cbe7 --- /dev/null +++ b/config.yaml @@ -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" diff --git a/data/accounts.json b/data/accounts.json new file mode 100644 index 0000000..61d34d5 --- /dev/null +++ b/data/accounts.json @@ -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 + } + ] +} diff --git a/douyu/__init__.py b/douyu/__init__.py new file mode 100644 index 0000000..c21814a --- /dev/null +++ b/douyu/__init__.py @@ -0,0 +1,7 @@ +"""斗鱼登录模块""" + +from .login import DouyuLogin +from .email_verifier import EmailVerifier +from .config import Config + +__all__ = ["DouyuLogin", "EmailVerifier", "Config"] diff --git a/douyu/config.py b/douyu/config.py new file mode 100644 index 0000000..6096ce7 --- /dev/null +++ b/douyu/config.py @@ -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'}) diff --git a/douyu/crypto.py b/douyu/crypto.py new file mode 100644 index 0000000..597a3af --- /dev/null +++ b/douyu/crypto.py @@ -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 diff --git a/douyu/email_verifier.py b/douyu/email_verifier.py new file mode 100644 index 0000000..da84277 --- /dev/null +++ b/douyu/email_verifier.py @@ -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}) diff --git a/douyu/login.py b/douyu/login.py new file mode 100644 index 0000000..e4f5f5e --- /dev/null +++ b/douyu/login.py @@ -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}") diff --git a/geetest/__init__.py b/geetest/__init__.py new file mode 100644 index 0000000..2822de0 --- /dev/null +++ b/geetest/__init__.py @@ -0,0 +1,5 @@ +"""Geetest slider captcha solver package.""" + +from .solver import run_solver + +__all__ = ["run_solver"] diff --git a/geetest/crypto.py b/geetest/crypto.py new file mode 100644 index 0000000..3b459fe --- /dev/null +++ b/geetest/crypto.py @@ -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) \ No newline at end of file diff --git a/geetest/imaging.py b/geetest/imaging.py new file mode 100644 index 0000000..11c0da8 --- /dev/null +++ b/geetest/imaging.py @@ -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')) diff --git a/geetest/network.py b/geetest/network.py new file mode 100644 index 0000000..ca20b6e --- /dev/null +++ b/geetest/network.py @@ -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 响应") diff --git a/geetest/performance.py b/geetest/performance.py new file mode 100644 index 0000000..561be00 --- /dev/null +++ b/geetest/performance.py @@ -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: 滑动轨迹: [[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] \ No newline at end of file diff --git a/geetest/solver.py b/geetest/solver.py new file mode 100644 index 0000000..d809d6e --- /dev/null +++ b/geetest/solver.py @@ -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) diff --git a/geetest/trajectory.py b/geetest/trajectory.py new file mode 100644 index 0000000..77468e0 --- /dev/null +++ b/geetest/trajectory.py @@ -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 \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..4d0da1b --- /dev/null +++ b/main.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c2a7cf5 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..98c411d --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/test_login.py b/test_login.py new file mode 100644 index 0000000..12c2840 --- /dev/null +++ b/test_login.py @@ -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() diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..54a6ff8 --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1,6 @@ +"""工具模块""" + +from .logger import setup_logger +from .helpers import load_accounts, save_cookie + +__all__ = ["setup_logger", "load_accounts", "save_cookie"] diff --git a/utils/helpers.py b/utils/helpers.py new file mode 100644 index 0000000..6a6bca9 --- /dev/null +++ b/utils/helpers.py @@ -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 diff --git a/utils/logger.py b/utils/logger.py new file mode 100644 index 0000000..e4a402a --- /dev/null +++ b/utils/logger.py @@ -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="{time:YYYY-MM-DD HH:mm:ss} | " + "{level: <8} | " + "{name}:{function}:{line} | " + "{message}", + 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", + ) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..73f7350 --- /dev/null +++ b/uv.lock @@ -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" }, +]