diff --git a/.gitignore b/.gitignore index c83e6b9..11bd8e8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,17 @@ -.venv -*/__pycache__ -/__pycache__ -data/gui_state.json +.venv/ +__pycache__/ +*.pyc + +# 运行时数据 +data/ +logs/ +*.db + +# geetest 临时图片 +bg.jpg +fullbg.jpg +slice.jpg + +# 前端 +web/frontend/node_modules/ +web/frontend/dist/ diff --git a/README.md b/README.md index f34536d..defeee8 100644 --- a/README.md +++ b/README.md @@ -1,144 +1,84 @@ -# 斗鱼自动登录工具 - Python版 +# 斗鱼批量登录 Web 后台 -全自动登录斗鱼账号,获取Cookie。 +全自动登录斗鱼账号、获取 Cookie 的 Web 管理平台。 ## 功能特性 -- ✅ 自动过极验v3滑块验证 -- ✅ 自动获取邮箱验证码(IMAP) -- ✅ 批量账号登录 -- ✅ Cookie持久化存储 -- ✅ 代理支持 -- ✅ GUI导入账号、自动保存配置、代理测试 +- 极验 v3 滑块自动识别 +- 邮箱验证码自动获取(IMAP) +- 批量登录 + 实时日志推送(WebSocket) +- 代理 & 白名单自动管理 +- 角色权限控制(超级管理员 / 运营 / 客服) ## 项目结构 ``` douyu_login_py/ -├── main.py # 主入口 -├── config.yaml # 配置文件 -├── pyproject.toml # uv项目配置 -├── 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存储 +├── core/ # 核心业务逻辑 +│ ├── models.py # Account, ProxyConfig 数据类 +│ ├── douyu/ # 斗鱼登录模块 +│ │ ├── login.py # 登录流程 +│ │ ├── crypto.py # 密码加密 +│ │ ├── email_verifier.py # 邮箱验证码 +│ │ ├── proxy.py # 代理管理 +│ │ └── whitelist.py # 白名单管理 +│ └── geetest/ # 极验验证码求解 +│ ├── common/ # 公共工具(网络/加密/图像/轨迹) +│ └── v3_slide/ # v3 滑块求解器 +├── web/ # Web 后台 +│ ├── backend/ # FastAPI 后端 +│ │ ├── main.py # 入口 +│ │ ├── routers/ # API 路由 +│ │ └── services/ # 业务服务 +│ └── frontend/ # React + Ant Design 前端 +├── utils/ # 通用工具 +│ └── logger.py +├── data/ # 运行时数据(gitignore) +├── start_web.sh # 一键启动 +└── pyproject.toml ``` -## 安装依赖 +## 快速开始 ```bash -uv sync +# 一键启动(自动检查依赖) +./start_web.sh ``` -## GUI使用 - -GUI不再依赖 `config.yaml` 启动,账号数据在界面里导入,代理、极验重试、日志等级和账号列表会自动保存到 `data/gui_state.json`。 +或手动启动: ```bash -uv run python main.py --gui +# 后端 +uv pip install -e . +.venv/bin/python -m uvicorn web.backend.main:app --reload --port 8000 + +# 前端 +cd web/frontend && npm install && npm run dev ``` -支持导入格式: +访问 `http://localhost:5173`,默认账号 `admin / admin123`。 -```text -用户名|密码|邮箱|邮箱密码 -``` +## 角色权限 -也可以导入 `txt`、`csv`、`json`、`yaml` 文件。旧版 `config.yaml` 可直接在 GUI 中作为 YAML 文件导入,用来迁移账号数据。 - -代理区域支持 API 获取和静态代理,填写后可以点击“测试代理”验证出口连通性。开始批量登录时也会先进行代理预检,只有拿到可用出口后才会继续登录流程。 - -## 命令行配置 - -命令行模式仍使用 `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 -# 登录第一个账号 -uv run python main.py - -# 登录指定索引的账号 -uv run python main.py -i 0 -``` - -### 批量登录 - -```bash -uv run python main.py --batch -``` - -### 详细日志 - -```bash -uv run python main.py -v -``` +| 角色 | 权限 | +|---|---| +| 超级管理员 | 全部功能 + 用户管理 + 权限分配 | +| 运营 | 账号管理 + 批量登录 + 代理配置 + Cookie 导出 | +| 客服 | 仅查看分配给自己的账号(只显示用户名) | ## 登录流程 ``` -1️⃣ 第一次登录 → 获取极验参数 -2️⃣ 极验滑块验证 → 自动识别缺口位置 -3️⃣ 第二次登录 → 获取邮箱验证code -4️⃣ 发送邮箱验证邮件 -5️⃣ IMAP获取验证码 -6️⃣ 提交验证码 -7️⃣ 完成登录获取Cookie +1. 获取极验参数 → 滑块验证 → 自动识别缺口 +2. 获取邮箱验证 code → 发送验证邮件 +3. IMAP 获取验证码 → 提交验证码 → 获取 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限制 +- 后端:FastAPI + SQLAlchemy + SQLite + JWT +- 前端:React + Ant Design + Vite +- 核心:Python + OpenCV + pycryptodome ## 许可证 diff --git a/__pycache__/main.cpython-312.pyc b/__pycache__/main.cpython-312.pyc deleted file mode 100644 index bcaa6e9..0000000 Binary files a/__pycache__/main.cpython-312.pyc and /dev/null differ diff --git a/bg.jpg b/bg.jpg deleted file mode 100644 index c7108fa..0000000 Binary files a/bg.jpg and /dev/null differ diff --git a/ck对比.md b/ck对比.md deleted file mode 100644 index 38e4ab4..0000000 --- a/ck对比.md +++ /dev/null @@ -1,14 +0,0 @@ -软软马卡龙(1) - -cvl_csrf_token=62083f188904477e9bc1c5a70537145c; acf_ccn=8b025ffcbf403d5e0d880ad6327bb3cf; PHPSESSID=86k3denvoc1j16rl1fsv5l2mc2; acf_auth=5a8eUzBoDc8uyLasCP7LXaO9a2vUK%2FDaNFwYi0MVdUFMpUcMBfsoZfifJZKCQC8WgJuKq7WB78VZrRlRXnZUSt87TZM0tLNdnplqtuL87c7idAqo%2Fal57ls; acf_jwt_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJtZDUifQ.eyJ1aWQiOjk0MjQxNjQ1MSwiY3QiOjAsInN1YiI6InN0IiwiYXVkIjpbImR5Iiwibm9uZSJdLCJiaXoiOjEsImx0a2lkIjo2OTE2MTY4NSwic3RrIjoiNGZkNDY5ZDE2OWY3NzM0YiIsImV4cCI6MTc4MjMyMjc2OSwiaWF0IjoxNzgxNzE3OTcwLCJrZXkiOiJkeS1qd3QtbWQ1In0.NDc0YzMyNjQ0OGIzNTkxYzkxNGQ1NDk0Y2U0NTk1NTU; acf_dmjwt_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJtZDUifQ.eyJ1aWQiOjk0MjQxNjQ1MSwiY3QiOjAsInN1YiI6InN0IiwiYXVkIjpbImRtIiwibm9uZSJdLCJiaXoiOjEsImx0a2lkIjo2OTE2MTY4NSwic3RrIjoiNGZkNDY5ZDE2OWY3NzM0YiIsImV4cCI6MTc4MjMyMjc2OSwiaWF0IjoxNzgxNzE3OTcwLCJrZXkiOiJkeS1qd3QtbWQ1In0.NWM1ZDgwZjE1N2ZhNTE3NDUxNmQ4ZjljMzI5ODQ0ZjM; dy_auth=29bfEdmmHp50M9Zr2gxQ3%2F2AJD2DAdCK6I%2FoX92U5n13i64R%2B%2FywJZf%2FqG4%2BCJvbUQmFaUSu3dFbJj4%2Be1gU1DpW6ligJYaJLnNuVbmKMeZCtayBmGtns6A; wan_auth37wan=cf514c616a0fzsvhClbvRbju0sSZb4aDqHQQabpd66aLoVky4bvRRXYDfYMX3iSUNiDuh%2BHt%2B20QXW0f5JLZ6lBtOW8LIIwb31iX8OiZurGB0aztSKA; acf_uid=942416451; acf_username=942416451; acf_nickname=%E7%94%A8%E6%88%B74512197651; acf_own_room=0; acf_groupid=1; acf_phonestatus=1; acf_avatar=https%3A%2F%2Fapic.douyucdn.cn%2Fupload%2Favatar%2Fdefault%2F18_; acf_ct=0; acf_ltkid=69161685; acf_biz=1; acf_stk=4fd469d169f7734b; acf_devid=9869cce23d84da0fd8b1cd20162b3def--用户4512197651-- - - -============ - -新写的 - -dy_accounts_main=; dy_auth=e105BJzhxVWQ0tJKUXiiqN8ky11wgfj7ClW%2F8qo%2B1MPv8%2BDGHBq8VnuaJ1MWnm4EZqgVfLkM6PETWtGpLAHvadcMxCn9HWvTfT8%2BSKZR2dmsvReubJmeqBo; wan_auth37wan=4dc825c71d00LrMvodf%2B7ZrhnyMcsREgwQlgd7Ec06k5%2Bm%2FM0tdv0AJJ8gpoNoinSoPHS3vbJO8iK1vqiXfF2hTOLQqJOW3Wzs19DWLwpJpQgV8J%2BF0; LTP0=eyJhbGciOiJtZDUiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOlsicGFzc3BvcnQiXSwiY3QiOjAsImN0aW1lIjoxNzgyMDU0MDA0LCJleHAiOjE3OTc4MjIwMTYsImtleSI6ImR5LWp3dC1tZDUiLCJsdGsiOiJhZDMxOTliZGRhYmM3ZWRmIiwibHRraWQiOjgwNTYxMzgyLCJzdWIiOiJsdCIsInVpZCI6OTg2Mzg1Mjk2fQ.Y2M0YTQ4YjQwZGYwZWI4YWZiZTllNmVjODNiZmJkMGE; last_login_way=nickname; PHPSESSID=ccr09aa2letju5662gdh5aih62; acf_auth=1a34kGp6rHjazzRy4fVzKXX11qCjC9RuQASDGOEfkRbLjsI3A5PzSCXawo5hZvO4g47LF9ruapNiMpf%2BE3u4TplWPMkW4c4eI0pyG05a%2F9dKr%2BOGM0hAEXk; acf_jwt_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJtZDUifQ.eyJ1aWQiOjk4NjM4NTI5NiwiY3QiOjAsInN1YiI6InN0IiwiYXVkIjpbImR5Iiwibm9uZSJdLCJiaXoiOjEsImx0a2lkIjo4MDU2MTM4Miwic3RrIjoiNjFhODczZjUwZTg3M2Y5MCIsImV4cCI6MTc4MjY1ODgwNCwiaWF0IjoxNzgyMDU0MDA0LCJrZXkiOiJkeS1qd3QtbWQ1In0.OTFlMjIxOGU4MjZkMjcwMWM4NjQ1NTcwNjNmZWIzMDA; acf_dmjwt_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJtZDUifQ.eyJ1aWQiOjk4NjM4NTI5NiwiY3QiOjAsInN1YiI6InN0IiwiYXVkIjpbImRtIiwibm9uZSJdLCJiaXoiOjEsImx0a2lkIjo4MDU2MTM4Miwic3RrIjoiNjFhODczZjUwZTg3M2Y5MCIsImV4cCI6MTc4MjY1ODgwNCwiaWF0IjoxNzgyMDU0MDA0LCJrZXkiOiJkeS1qd3QtbWQ1In0.N2E0NmEyOWVlZDE1NTE4NDg0MTFlMmM5OGY1ZGQzZTk; acf_uid=986385296; acf_username=986385296; acf_nickname=%E7%94%A8%E6%88%B79006787894; acf_own_room=0; acf_groupid=1; acf_phonestatus=1; acf_avatar=https%3A%2F%2Fapic.douyucdn.cn%2Fupload%2Favatar%2Fdefault%2F25_; acf_ct=0; acf_ltkid=80561382; acf_biz=1; acf_stk=61a873f50e873f90; acf_devid=d53498d383f449f4c4ea28c4165aa2b4", - - - - diff --git a/config.yaml b/config.yaml deleted file mode 100644 index ab4d72d..0000000 --- a/config.yaml +++ /dev/null @@ -1,32 +0,0 @@ -# 斗鱼自动登录配置 - -# 账号配置 -accounts: - - username: "用户9006787894" - password: "a778899" - email: "jtmjcm@bdhg.xyz" - email_password: "www123" - email_imap_server: "mail.bdhg.xyz" - email_imap_port: 993 - -# 代理配置 -proxy: - enabled: true - # 代理API地址(自动获取代理IP) - api_url: "http://api.xiequ.cn/VAD/GetIp.aspx?act=get&uid=106015&vkey=97111DB5379E38E3BC2FF09A1B00A0C7&num=1&time=30&plat=1&re=0&type=0&so=1&ow=1&spl=1&addr=&db=1" - # 静态代理(如果不使用API获取) - http: "" - https: "" - -# 极验配置 -geetest: - # 最大重试次数(遇到点选验证码时重试) - max_retries: 10 - -# 日志配置 -log: - level: "DEBUG" - file: "logs/douyu_login.log" - -# Cookie存储 -cookie_dir: "data/cookies" diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..5810f44 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,5 @@ +"""核心业务逻辑""" + +from .models import Account, ProxyConfig + +__all__ = ["Account", "ProxyConfig"] diff --git a/douyu/__init__.py b/core/douyu/__init__.py similarity index 59% rename from douyu/__init__.py rename to core/douyu/__init__.py index 73ac7a9..9b6506a 100644 --- a/douyu/__init__.py +++ b/core/douyu/__init__.py @@ -2,8 +2,7 @@ from .login import DouyuLogin from .email_verifier import EmailVerifier -from .config import Config from .proxy import ProxyManager from .whitelist import WhitelistManager -__all__ = ["DouyuLogin", "EmailVerifier", "Config", "ProxyManager", "WhitelistManager"] +__all__ = ["DouyuLogin", "EmailVerifier", "ProxyManager", "WhitelistManager"] diff --git a/douyu/crypto.py b/core/douyu/crypto.py similarity index 100% rename from douyu/crypto.py rename to core/douyu/crypto.py diff --git a/douyu/email_verifier.py b/core/douyu/email_verifier.py similarity index 100% rename from douyu/email_verifier.py rename to core/douyu/email_verifier.py diff --git a/douyu/login.py b/core/douyu/login.py similarity index 98% rename from douyu/login.py rename to core/douyu/login.py index 20acc0c..68ed527 100644 --- a/douyu/login.py +++ b/core/douyu/login.py @@ -5,23 +5,19 @@ 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 core.models import Account from .crypto import encrypt_password, encrypt_nickname_or_phone from .email_verifier import EmailVerifier from .proxy import ProxyManager, get_proxy_manager -# 导入geetest滑块模块 -import sys -sys.path.insert(0, str(Path(__file__).parent.parent)) -from geetest import run_solver -from geetest.solver import ( +from core.geetest import run_solver +from core.geetest.v3_slide.solver import ( _generate_seed, get_w1, get_w2, ) -from geetest.network import ( +from core.geetest.common.network import ( get_js_address, get_c_s, req_fullpage_validate, diff --git a/douyu/proxy.py b/core/douyu/proxy.py similarity index 100% rename from douyu/proxy.py rename to core/douyu/proxy.py diff --git a/douyu/whitelist.py b/core/douyu/whitelist.py similarity index 100% rename from douyu/whitelist.py rename to core/douyu/whitelist.py diff --git a/core/geetest/__init__.py b/core/geetest/__init__.py new file mode 100644 index 0000000..27c3b02 --- /dev/null +++ b/core/geetest/__init__.py @@ -0,0 +1,5 @@ +"""Geetest 验证码求解器""" + +from .v3_slide.solver import run_solver + +__all__ = ["run_solver"] diff --git a/core/geetest/common/__init__.py b/core/geetest/common/__init__.py new file mode 100644 index 0000000..ac8dcca --- /dev/null +++ b/core/geetest/common/__init__.py @@ -0,0 +1 @@ +"""Geetest 公共工具模块""" diff --git a/geetest/crypto.py b/core/geetest/common/crypto.py similarity index 100% rename from geetest/crypto.py rename to core/geetest/common/crypto.py diff --git a/geetest/imaging.py b/core/geetest/common/imaging.py similarity index 100% rename from geetest/imaging.py rename to core/geetest/common/imaging.py diff --git a/geetest/network.py b/core/geetest/common/network.py similarity index 100% rename from geetest/network.py rename to core/geetest/common/network.py diff --git a/geetest/performance.py b/core/geetest/common/performance.py similarity index 100% rename from geetest/performance.py rename to core/geetest/common/performance.py diff --git a/geetest/trajectory.py b/core/geetest/common/trajectory.py similarity index 100% rename from geetest/trajectory.py rename to core/geetest/common/trajectory.py diff --git a/geetest/__init__.py b/core/geetest/v3_slide/__init__.py similarity index 56% rename from geetest/__init__.py rename to core/geetest/v3_slide/__init__.py index 2822de0..59f0b05 100644 --- a/geetest/__init__.py +++ b/core/geetest/v3_slide/__init__.py @@ -1,4 +1,4 @@ -"""Geetest slider captcha solver package.""" +"""Geetest v3 滑块验证码求解器""" from .solver import run_solver diff --git a/geetest/solver.py b/core/geetest/v3_slide/solver.py similarity index 92% rename from geetest/solver.py rename to core/geetest/v3_slide/solver.py index d809d6e..8461d90 100644 --- a/geetest/solver.py +++ b/core/geetest/v3_slide/solver.py @@ -1,12 +1,12 @@ import time import random import json -from .trajectory import generate_realistic_trajectory, process_mouse_trajectory, compress_trajectory, TrajectoryEncoder, \ +from core.geetest.common.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 +from core.geetest.common.crypto import four_random_chart, RSA_jiami_r, AES_O, geetest_base64_encode, encrypt_string, simple_md5 +from core.geetest.common.imaging import download_picture +from core.geetest.common.network import get_challenge_gt, get_js_address, get_c_s, req_slide, get_picture, req_end +from core.geetest.common.performance import generate_fake_performance_timing, get_slide_track def _generate_seed() -> str: diff --git a/core/models.py b/core/models.py new file mode 100644 index 0000000..df06565 --- /dev/null +++ b/core/models.py @@ -0,0 +1,27 @@ +"""核心数据模型""" + +from dataclasses import dataclass + + +@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 + api_url: str = "" + http: str = "" + https: str = "" + # 白名单配置 + whitelist_enabled: bool = False + whitelist_uid: str = "" + whitelist_ukey: str = "" diff --git a/data/web.db b/data/web.db index ecf8064..a8a724e 100644 Binary files a/data/web.db and b/data/web.db differ diff --git a/douyu/config.py b/douyu/config.py deleted file mode 100644 index 7048026..0000000 --- a/douyu/config.py +++ /dev/null @@ -1,85 +0,0 @@ -"""配置管理模块""" - -import yaml -from pathlib import Path -from dataclasses import dataclass -from typing import List, Optional - -from .email_verifier import get_email_config_for_account - - -@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 - api_url: str = "" - http: str = "" - https: str = "" - # 白名单配置 - whitelist_enabled: bool = False # 是否启用白名单自动管理 - whitelist_uid: str = "" # 协固用户ID - whitelist_ukey: 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', []): - email_config = get_email_config_for_account(acc['email']) - 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') or email_config['server'], - email_imap_port=acc.get('email_imap_port') or email_config['port'], - )) - return accounts - - def get_proxy(self) -> ProxyConfig: - """获取代理配置""" - proxy = self._config.get('proxy', {}) - return ProxyConfig( - enabled=proxy.get('enabled', False), - api_url=proxy.get('api_url', ''), - http=proxy.get('http', ''), - https=proxy.get('https', ''), - ) - - def get_geetest_config(self) -> dict: - """获取极验配置""" - return self._config.get('geetest', {'max_retries': 5}) - - 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/fullbg.jpg b/fullbg.jpg deleted file mode 100644 index 962777b..0000000 Binary files a/fullbg.jpg and /dev/null differ diff --git a/gui/__init__.py b/gui/__init__.py deleted file mode 100644 index 983586f..0000000 --- a/gui/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -"""GUI模块""" - -from .app import DouyuLoginApp -from .login_worker import parse_accounts_text, get_imap_server -from .widgets import AccountTable, LogPanel - -__all__ = [ - 'DouyuLoginApp', - 'parse_accounts_text', - 'get_imap_server', - 'AccountTable', - 'LogPanel', -] diff --git a/gui/account_importer.py b/gui/account_importer.py deleted file mode 100644 index c834e9c..0000000 --- a/gui/account_importer.py +++ /dev/null @@ -1,185 +0,0 @@ -"""账号导入工具。""" - -from __future__ import annotations - -import csv -import io -import json -from pathlib import Path -from typing import Any, Iterable, Mapping - -import yaml -from loguru import logger - -from douyu.config import Account -from douyu.email_verifier import get_email_config_for_account -from .login_worker import EMAIL_PATTERN, _is_ascii, parse_accounts_text - - -FIELD_ALIASES = { - "username": {"username", "user", "account", "账号", "用户名", "斗鱼账号"}, - "password": {"password", "pass", "pwd", "密码", "登录密码"}, - "email": {"email", "mail", "邮箱", "邮箱地址"}, - "email_password": { - "email_password", - "email_pass", - "email_pwd", - "mail_password", - "mail_pass", - "邮箱密码", - "邮箱授权码", - "授权码", - }, - "email_imap_server": {"email_imap_server", "imap_server", "imap", "imap服务器"}, - "email_imap_port": {"email_imap_port", "imap_port", "imap端口"}, -} - -NORMALIZED_ALIASES = { - "".join(alias.lower().replace("-", "_").split()): field - for field, aliases in FIELD_ALIASES.items() - for alias in aliases -} - - -def load_accounts_from_file(filepath: str | Path) -> list[Account]: - """从文件导入账号,支持txt/csv/json/yaml。""" - path = Path(filepath) - text = _read_text(path) - suffix = path.suffix.lower() - - if suffix == ".json": - return _load_structured_accounts(text, path.name, "json") - - if suffix in {".yaml", ".yml"}: - return _load_structured_accounts(text, path.name, "yaml") - - if suffix == ".csv": - accounts = _parse_csv_text(text, path.name) - return accounts or parse_accounts_text(text) - - return parse_accounts_text(text) - - -def _read_text(path: Path) -> str: - """按常见编码读取文本文件。""" - last_error: Exception | None = None - for encoding in ("utf-8-sig", "utf-8", "gb18030"): - try: - return path.read_text(encoding=encoding) - except UnicodeDecodeError as exc: - last_error = exc - - if last_error: - raise last_error - return path.read_text(encoding="utf-8") - - -def _load_structured_accounts(text: str, source: str, file_type: str) -> list[Account]: - """读取JSON/YAML中的账号列表。""" - try: - if file_type == "json": - data = json.loads(text) - else: - data = yaml.safe_load(text) - except Exception as exc: - logger.warning(f"{source} 结构化解析失败,尝试按文本格式导入: {exc}") - return parse_accounts_text(text) - - return accounts_from_payload(data, source) - - -def accounts_from_payload(data: Any, source: str = "导入数据") -> list[Account]: - """从结构化数据中提取账号列表。""" - if isinstance(data, Mapping): - rows = data.get("accounts") or data.get("data") or data.get("items") or [] - else: - rows = data - - if not isinstance(rows, list): - logger.warning(f"{source} 中未找到账号列表") - return [] - - return accounts_from_rows(rows, source) - - -def accounts_from_rows(rows: Iterable[Any], source: str = "导入数据") -> list[Account]: - """从字典行列表转换账号。""" - accounts: list[Account] = [] - for row_index, row in enumerate(rows, 1): - if not isinstance(row, Mapping): - logger.warning(f"{source} 第{row_index}行不是对象,已跳过") - continue - - account = _account_from_mapping(row, f"{source} 第{row_index}行") - if account: - accounts.append(account) - - return accounts - - -def _parse_csv_text(text: str, source: str) -> list[Account]: - """解析带表头的CSV文件。""" - reader = csv.reader(io.StringIO(text)) - try: - first_row = next(reader) - except StopIteration: - return [] - - if not _looks_like_header(first_row): - return [] - - dict_reader = csv.DictReader(io.StringIO(text)) - return accounts_from_rows(dict_reader, source) - - -def _looks_like_header(row: list[str]) -> bool: - """判断CSV首行是否像账号字段表头。""" - normalized = { - "".join(str(value).lower().replace("-", "_").split()) - for value in row - } - return len(normalized & set(NORMALIZED_ALIASES.keys())) >= 2 - - -def _account_from_mapping(row: Mapping[str, Any], source: str) -> Account | None: - """从字典字段构造账号。""" - normalized_row: dict[str, str] = {} - for key, value in row.items(): - normalized_key = "".join(str(key).lower().replace("-", "_").split()) - field = NORMALIZED_ALIASES.get(normalized_key) - if field: - normalized_row[field] = "" if value is None else str(value).strip() - - username = normalized_row.get("username", "") - password = normalized_row.get("password", "") - email = normalized_row.get("email", "") - email_password = normalized_row.get("email_password", "") - - if not all([username, password, email, email_password]): - logger.warning(f"{source} 存在空字段,已跳过") - return None - - if not EMAIL_PATTERN.match(email) or not _is_ascii(email): - logger.warning(f"{source} 邮箱格式不正确: {email}") - return None - - if not _is_ascii(email_password): - logger.warning(f"{source} 邮箱密码/授权码包含非ASCII字符,IMAP可能无法登录") - return None - - email_config = get_email_config_for_account(email) - imap_server = normalized_row.get("email_imap_server") or email_config["server"] - try: - imap_port = int(normalized_row.get("email_imap_port") or email_config["port"]) - except ValueError: - logger.warning(f"{source} IMAP端口不正确,已使用默认端口") - imap_port = email_config["port"] - - return Account( - username=username, - password=password, - email=email, - email_password=email_password, - email_imap_server=imap_server, - email_imap_port=imap_port, - ) diff --git a/gui/app.py b/gui/app.py deleted file mode 100644 index 1bddcee..0000000 --- a/gui/app.py +++ /dev/null @@ -1,1507 +0,0 @@ -"""主窗口模块""" - -import json -import re -import threading -import time -import tkinter as tk -from tkinter import ttk, messagebox, filedialog -from datetime import datetime -from pathlib import Path -from queue import Queue, Empty -from typing import List, Optional - -import requests -from loguru import logger - -from douyu.config import Account, ProxyConfig -from douyu.whitelist import WhitelistManager, get_exit_ip_via_proxy -from utils import setup_logger -from .account_importer import load_accounts_from_file -from .login_worker import LoginWorker, parse_accounts_text -from .state import DEFAULT_STATE_PATH, GuiStateStore, account_to_dict -from .widgets import AccountTable, LogPanel - - -LOG_LEVELS = ('DEBUG', 'INFO', 'WARNING', 'ERROR') -PROXY_API_TEST_ATTEMPTS = 5 -PROXY_STATIC_TEST_ATTEMPTS = 3 -PROXY_VERIFY_TIMEOUT = (4, 6) -PROXY_TEST_TARGETS = ( - ('百度IP定位', 'https://qifu-api.baidubce.com/ip/local/geo/v1/district'), - ('IPIP.NET', 'https://myip.ipip.net'), - ('IP.Wang', 'https://4.ipw.cn'), -) -LOG_LEVEL_TAGS = { - 'TRACE': 'debug', - 'DEBUG': 'debug', - 'INFO': 'info', - 'SUCCESS': 'success', - 'WARNING': 'warning', - 'ERROR': 'error', - 'CRITICAL': 'error', -} - - -class DouyuLoginApp: - """斗鱼批量登录GUI应用""" - - def __init__( - self, - config_path: str | None = None, - state_path: str | Path = DEFAULT_STATE_PATH, - ): - # config_path 为兼容旧调用保留,GUI启动不再依赖 config.yaml。 - self.config_path = config_path - self.state_store = GuiStateStore(state_path) - self.state = self.state_store.load() - self.accounts: List[Account] = self.state_store.accounts_from_state(self.state) - self.workers: List[LoginWorker] = [] - self.results: List[dict] = [] - self.is_running = False - self.is_preparing_login = False - self.proxy_test_running = False - self._state_save_after_id = None - self._state_ready = False - - # 队列用于线程间通信 - self.result_queue = Queue() - self.log_queue = Queue() - self.proxy_test_queue = Queue() - self.login_preflight_queue = Queue() - self.whitelist_test_queue = Queue() - - # 加载配置 - self.proxy_config = self.state_store.proxy_from_state(self.state) - self.proxy_type = self._load_proxy_type() - self.geetest_retries = self._load_geetest_retries() - self.log_level = self._load_log_level() - self.last_import_dir = str(self.state.get('last_import_dir') or Path.cwd()) - self._configure_logging(self.log_level) - - # 创建主窗口 - self.root = tk.Tk() - self.root.title('斗鱼批量登录工具') - self.root.geometry('1180x820') - self.root.minsize(1040, 820) - self._content_split_initialized = False - - # 设置图标(如果存在) - try: - self.root.iconbitmap('icon.ico') - except: - pass - - # 创建界面 - self._create_ui() - self._bind_state_traces() - self._state_ready = True - self._save_state() - - self.root.protocol('WM_DELETE_WINDOW', self._on_close) - - # 定时检查队列 - self._poll_queues() - - def _load_proxy_type(self) -> str: - """加载代理类型。""" - proxy_type = str(self.state.get('proxy_type') or '').strip() - if proxy_type in {'api', 'static'}: - return proxy_type - return 'api' if self.proxy_config.api_url else 'static' - - def _load_geetest_retries(self) -> int: - """加载极验重试次数。""" - try: - retries = int(self.state.get('geetest_retries', 5)) - return min(max(retries, 1), 20) - except (TypeError, ValueError): - return 5 - - def _load_log_level(self) -> str: - """加载默认日志等级。""" - level = str(self.state.get('log_level') or 'INFO').upper() - return level if level in LOG_LEVELS else 'INFO' - - def _configure_logging(self, level: str) -> None: - """配置终端和GUI日志输出等级。""" - self.log_level = level if level in LOG_LEVELS else 'INFO' - setup_logger(level=self.log_level) - logger.add( - self._enqueue_log_record, - level=self.log_level, - format='{message}', - colorize=False, - ) - - def _enqueue_log_record(self, message) -> None: - """把loguru日志转发到GUI日志队列。""" - record = message.record - level = record['level'].name - tag = LOG_LEVEL_TAGS.get(level, 'info') - text = ( - f"{record['name']}:{record['function']}:{record['line']} | " - f"{record['message']}" - ) - self.log_queue.put((tag, text)) - - def _on_log_level_change(self, _event=None) -> None: - """切换日志等级。""" - level = self.log_level_var.get().upper() - self._configure_logging(level) - self.log_panel.append_log('info', f'日志等级已切换为: {level}') - - def _create_ui(self): - """创建用户界面""" - # 主容器 - main_frame = ttk.Frame(self.root, padding=10) - main_frame.pack(fill='both', expand=True) - main_frame.columnconfigure(1, weight=1) - main_frame.rowconfigure(0, weight=1) - - # 左侧:导入、配置和操作 - sidebar = ttk.Frame(main_frame, width=360) - sidebar.grid(row=0, column=0, sticky='ns', padx=(0, 10)) - sidebar.grid_propagate(False) - sidebar.pack_propagate(False) - self._create_sidebar_section(sidebar) - - # 右侧:账号列表和日志 - workspace = ttk.Frame(main_frame) - workspace.grid(row=0, column=1, sticky='nsew') - - self._create_workspace_section(workspace) - self._reload_accounts_table() - self.root.after(120, self._set_initial_content_split) - - def _create_sidebar_section(self, parent): - """创建左侧操作栏。""" - self._create_import_section(parent) - self._create_config_section(parent) - self._create_action_bar(parent) - - def _create_import_section(self, parent): - """创建导入区域""" - frame = ttk.LabelFrame(parent, text='账号数据导入', padding=8) - frame.pack(fill='x', pady=(0, 8)) - - # 文本输入框 - self.import_text = tk.Text(frame, width=38, height=5, font=('Consolas', 10), wrap='none') - self.import_text.pack(fill='x') - - # 按钮栏 - btn_frame = ttk.Frame(frame) - btn_frame.pack(fill='x', pady=(5, 0)) - btn_frame.columnconfigure(0, weight=1) - btn_frame.columnconfigure(1, weight=1) - - import_buttons = [ - ('追加导入', self._parse_import_append), - ('替换导入', self._parse_import_replace), - ('从文件追加', self._import_from_file_append), - ('从文件替换', self._import_from_file_replace), - ('清空', self._clear_import), - ] - for index, (text, command) in enumerate(import_buttons): - ttk.Button(btn_frame, text=text, command=command).grid( - row=index // 2, - column=index % 2, - sticky='ew', - padx=(0, 4) if index % 2 == 0 else (4, 0), - pady=(0, 4), - ) - - # 提示标签 - ttk.Label( - frame, - text='支持 txt/csv/json/yaml\n格式:用户名|密码|邮箱|邮箱密码', - foreground='gray', - ).pack(anchor='w', pady=(2, 0)) - - def _create_config_section(self, parent): - """创建配置区域。""" - proxy_frame = ttk.LabelFrame(parent, text='代理配置', padding=8) - proxy_frame.pack(fill='x', pady=(0, 8)) - - # 启用代理 - proxy_top_frame = ttk.Frame(proxy_frame) - proxy_top_frame.pack(fill='x') - - self.proxy_enabled_var = tk.BooleanVar(value=self.proxy_config.enabled) - ttk.Checkbutton(proxy_top_frame, text='启用代理', variable=self.proxy_enabled_var, - command=self._on_proxy_toggle).pack(side='left') - self.proxy_test_btn = ttk.Button(proxy_top_frame, text='测试代理', command=self._test_proxy) - self.proxy_test_btn.pack(side='right') - - # 代理类型选择 - type_frame = ttk.Frame(proxy_frame) - type_frame.pack(fill='x', pady=(5, 0)) - - self.proxy_type_var = tk.StringVar(value=self.proxy_type) - ttk.Radiobutton(type_frame, text='API获取', variable=self.proxy_type_var, - value='api', command=self._on_proxy_type_change).pack(side='left') - ttk.Radiobutton(type_frame, text='静态代理', variable=self.proxy_type_var, - value='static', command=self._on_proxy_type_change).pack(side='left', padx=(10, 0)) - - # API地址 - api_frame = ttk.Frame(proxy_frame) - api_frame.pack(fill='x', pady=(5, 0)) - - ttk.Label(api_frame, text='API地址:').pack(side='left') - self.proxy_api_var = tk.StringVar(value=self.proxy_config.api_url) - self.proxy_api_entry = ttk.Entry(api_frame, textvariable=self.proxy_api_var, width=28) - self.proxy_api_entry.pack(side='left', fill='x', expand=True, padx=(5, 0)) - - # 静态代理 - static_frame = ttk.Frame(proxy_frame) - static_frame.pack(fill='x', pady=(5, 0)) - static_frame.columnconfigure(1, weight=1) - - ttk.Label(static_frame, text='HTTP:').grid(row=0, column=0, sticky='w') - self.proxy_http_var = tk.StringVar(value=self.proxy_config.http) - self.proxy_http_entry = ttk.Entry(static_frame, textvariable=self.proxy_http_var, width=20) - self.proxy_http_entry.grid(row=0, column=1, sticky='ew', padx=(5, 0), pady=(0, 4)) - - ttk.Label(static_frame, text='HTTPS:').grid(row=1, column=0, sticky='w') - self.proxy_https_var = tk.StringVar(value=self.proxy_config.https) - self.proxy_https_entry = ttk.Entry(static_frame, textvariable=self.proxy_https_var, width=20) - self.proxy_https_entry.grid(row=1, column=1, sticky='ew', padx=(5, 0)) - - self.proxy_test_status_var = tk.StringVar(value='未测试') - ttk.Label(proxy_frame, textvariable=self.proxy_test_status_var, - foreground='gray', wraplength=320).pack(anchor='w', pady=(6, 0)) - - # 白名单配置 - whitelist_frame = ttk.LabelFrame(parent, text='白名单管理', padding=8) - whitelist_frame.pack(fill='x', pady=(0, 8)) - - whitelist_top_frame = ttk.Frame(whitelist_frame) - whitelist_top_frame.pack(fill='x') - - self.whitelist_enabled_var = tk.BooleanVar(value=self.proxy_config.whitelist_enabled) - ttk.Checkbutton(whitelist_top_frame, text='启用白名单自动管理', - variable=self.whitelist_enabled_var, - command=self._on_whitelist_toggle).pack(side='left') - self.whitelist_test_btn = ttk.Button(whitelist_top_frame, text='测试连接', - command=self._test_whitelist_connection) - self.whitelist_test_btn.pack(side='right') - - # UID - uid_frame = ttk.Frame(whitelist_frame) - uid_frame.pack(fill='x', pady=(5, 0)) - - ttk.Label(uid_frame, text='UID:').pack(side='left') - self.whitelist_uid_var = tk.StringVar(value=self.proxy_config.whitelist_uid) - self.whitelist_uid_entry = ttk.Entry(uid_frame, textvariable=self.whitelist_uid_var, width=20) - self.whitelist_uid_entry.pack(side='left', fill='x', expand=True, padx=(5, 0)) - - # UKEY - ukey_frame = ttk.Frame(whitelist_frame) - ukey_frame.pack(fill='x', pady=(5, 0)) - - ttk.Label(ukey_frame, text='UKEY:').pack(side='left') - self.whitelist_ukey_var = tk.StringVar(value=self.proxy_config.whitelist_ukey) - self.whitelist_ukey_entry = ttk.Entry(ukey_frame, textvariable=self.whitelist_ukey_var, width=28) - self.whitelist_ukey_entry.pack(side='left', fill='x', expand=True, padx=(5, 0)) - - self.whitelist_status_var = tk.StringVar(value='未配置') - ttk.Label(whitelist_frame, textvariable=self.whitelist_status_var, - foreground='gray', wraplength=320).pack(anchor='w', pady=(6, 0)) - - runtime_frame = ttk.LabelFrame(parent, text='运行设置', padding=8) - runtime_frame.pack(fill='x', pady=(0, 8)) - runtime_frame.columnconfigure(1, weight=1) - - ttk.Label(runtime_frame, text='极验重试:').grid(row=0, column=0, sticky='w') - self.geetest_retries_var = tk.IntVar(value=self.geetest_retries) - retries_spin = ttk.Spinbox(runtime_frame, from_=1, to=20, width=5, - textvariable=self.geetest_retries_var) - retries_spin.grid(row=0, column=1, sticky='w', padx=(8, 0), pady=(0, 8)) - - ttk.Label(runtime_frame, text='日志等级:').grid(row=1, column=0, sticky='w') - self.log_level_var = tk.StringVar(value=self.log_level) - log_level_combo = ttk.Combobox( - runtime_frame, - values=LOG_LEVELS, - textvariable=self.log_level_var, - state='readonly', - width=10, - ) - log_level_combo.grid(row=1, column=1, sticky='w', padx=(8, 0)) - log_level_combo.bind('<>', self._on_log_level_change) - - # 初始状态 - self._on_proxy_toggle() - self._on_whitelist_toggle() - - def _create_workspace_section(self, parent): - """创建可拖拽的账号/日志工作区。""" - self.content_pane = ttk.Panedwindow(parent, orient=tk.VERTICAL) - self.content_pane.pack(fill='both', expand=True) - - account_pane = ttk.Frame(self.content_pane) - self.content_pane.add(account_pane, weight=3) - - self._create_table_section(account_pane) - self._create_log_section(self.content_pane) - - def _create_table_section(self, parent): - """创建表格区域""" - frame = ttk.LabelFrame(parent, text='账号列表', padding=8) - frame.pack(fill='both', expand=True, pady=(0, 8)) - - # 表格 - self.table = AccountTable(frame) - self.table.pack(fill='both', expand=True) - - def _create_action_bar(self, parent): - """创建操作按钮栏。""" - frame = ttk.LabelFrame(parent, text='操作', padding=8) - frame.pack(fill='x') - frame.columnconfigure(0, weight=1) - frame.columnconfigure(1, weight=1) - - self.start_btn = ttk.Button(frame, text='开始批量登录', command=self._start_login) - self.start_btn.grid(row=0, column=0, columnspan=2, sticky='ew', pady=(0, 6)) - - self.stop_btn = ttk.Button(frame, text='停止', command=self._stop_login, state='disabled') - self.stop_btn.grid(row=1, column=0, sticky='ew', padx=(0, 4), pady=(0, 6)) - ttk.Button(frame, text='保存数据', command=self._save_state).grid( - row=1, - column=1, - sticky='ew', - padx=(4, 0), - pady=(0, 6), - ) - ttk.Button(frame, text='导出结果', command=self._export_results).grid( - row=2, - column=0, - sticky='ew', - padx=(0, 4), - pady=(0, 6), - ) - ttk.Button(frame, text='删除选中', command=self._delete_selected_accounts).grid( - row=2, - column=1, - sticky='ew', - padx=(4, 0), - pady=(0, 6), - ) - ttk.Button(frame, text='清空列表', command=self._clear_table).grid( - row=3, - column=0, - columnspan=2, - sticky='ew', - pady=(0, 8), - ) - - self.progress_var = tk.StringVar(value='就绪') - ttk.Label(frame, textvariable=self.progress_var).grid( - row=4, - column=0, - columnspan=2, - sticky='w', - ) - self.save_status_var = tk.StringVar(value='') - ttk.Label(frame, textvariable=self.save_status_var, - foreground='gray').grid(row=5, column=0, columnspan=2, sticky='w') - - def _create_log_section(self, parent): - """创建日志区域。""" - # 日志区域 - log_frame = ttk.LabelFrame(parent, text='日志输出', padding=8) - parent.add(log_frame, weight=2) - - self.log_panel = LogPanel(log_frame, height=12) - self.log_panel.pack(fill='both', expand=True) - - def _set_initial_content_split(self): - """设置账号列表和日志的默认分割比例。""" - if self._content_split_initialized or not hasattr(self, 'content_pane'): - return - - height = self.content_pane.winfo_height() - if height < 300: - self.root.after(120, self._set_initial_content_split) - return - - min_table_height = 220 - min_log_height = 240 - if height <= min_table_height + min_log_height: - sash_position = int(height * 0.58) - else: - log_height = max(min_log_height, int(height * 0.40)) - sash_position = max(min_table_height, height - log_height) - - try: - self.content_pane.sashpos(0, sash_position) - self._content_split_initialized = True - except tk.TclError: - self.root.after(120, self._set_initial_content_split) - - def _on_proxy_toggle(self): - """代理启用/禁用切换""" - enabled = self.proxy_enabled_var.get() - if hasattr(self, 'proxy_test_btn'): - self.proxy_test_btn.configure(state='normal' if enabled else 'disabled') - - if enabled: - self._on_proxy_type_change() - else: - self.proxy_api_entry.configure(state='disabled') - self.proxy_http_entry.configure(state='disabled') - self.proxy_https_entry.configure(state='disabled') - - self._schedule_save_state() - - def _on_proxy_type_change(self): - """代理类型切换""" - proxy_type = self.proxy_type_var.get() - if not self.proxy_enabled_var.get(): - self.proxy_api_entry.configure(state='disabled') - self.proxy_http_entry.configure(state='disabled') - self.proxy_https_entry.configure(state='disabled') - self._schedule_save_state() - return - - if proxy_type == 'api': - self.proxy_api_entry.configure(state='normal') - self.proxy_http_entry.configure(state='disabled') - self.proxy_https_entry.configure(state='disabled') - else: - self.proxy_api_entry.configure(state='disabled') - self.proxy_http_entry.configure(state='normal') - self.proxy_https_entry.configure(state='normal') - self._schedule_save_state() - - def _on_whitelist_toggle(self): - """白名单启用/禁用切换""" - enabled = self.whitelist_enabled_var.get() - if hasattr(self, 'whitelist_uid_entry'): - state = 'normal' if enabled else 'disabled' - self.whitelist_uid_entry.configure(state=state) - self.whitelist_ukey_entry.configure(state=state) - self.whitelist_test_btn.configure(state=state) - - if not enabled: - self.whitelist_status_var.set('未启用') - else: - self.whitelist_status_var.set('已启用,请填写UID和UKEY') - - self._schedule_save_state() - - def _test_whitelist_connection(self): - """测试白名单API连接并将出口IP同步到白名单""" - uid = self.whitelist_uid_var.get().strip() - ukey = self.whitelist_ukey_var.get().strip() - - if not uid or not ukey: - messagebox.showwarning('提示', '请填写UID和UKEY') - return - - self.whitelist_test_btn.configure(state='disabled') - self.whitelist_status_var.set('测试中...') - self.log_panel.append_log('info', '测试白名单API连接...') - - proxy_config = self._get_proxy_config() - - def run_test(): - try: - manager = WhitelistManager(uid, ukey) - success, api_msg = manager.test_connection() - self.log_queue.put(('info' if success else 'error', f'白名单API: {api_msg}')) - - if not success: - self.whitelist_test_queue.put({'status': '连接失败'}) - return - - self.log_queue.put(('info', '正在获取出口IP...')) - exit_ip = self._get_exit_ip_for_test(proxy_config) - - if not exit_ip: - self.log_queue.put(('warning', '无法获取出口IP')) - self.whitelist_test_queue.put({'status': '无法获取出口IP'}) - return - - self.log_queue.put(('info', f'出口IP: {exit_ip}')) - - records = manager.get_whitelist_json() - in_list = any(r.get('IP') == exit_ip for r in records) - self.log_queue.put(('info', f'白名单共 {len(records)} 条记录')) - - if in_list: - record = next((r for r in records if r.get('IP') == exit_ip), {}) - memo = record.get('MEMO', '') - if memo == manager.memo: - self.log_queue.put(('success', f'出口IP {exit_ip} 已在白名单中 (备注正确)')) - self.whitelist_test_queue.put({'status': f'{exit_ip} 已在白名单中'}) - else: - self.log_queue.put(('warning', f'出口IP {exit_ip} 已存在但备注不匹配 (当前: "{memo}"),正在更新...')) - sync_ok, sync_msg = manager.sync_ip(exit_ip) - if sync_ok: - self.log_queue.put(('success', f'白名单备注已更新: {sync_msg}')) - self.whitelist_test_queue.put({'status': f'{exit_ip} 备注已更新'}) - else: - self.log_queue.put(('error', f'白名单更新失败: {sync_msg}')) - self.whitelist_test_queue.put({'status': '更新失败'}) - else: - self.log_queue.put(('info', f'正在将 {exit_ip} 添加到白名单...')) - sync_ok, sync_msg = manager.sync_ip(exit_ip) - if sync_ok: - self.log_queue.put(('success', f'白名单同步: {sync_msg}')) - self.whitelist_test_queue.put({'status': f'{exit_ip} 已同步'}) - else: - self.log_queue.put(('error', f'白名单同步失败: {sync_msg}')) - self.whitelist_test_queue.put({'status': '同步失败'}) - except Exception as e: - self.log_queue.put(('error', f'白名单测试失败: {e}')) - self.whitelist_test_queue.put({'status': '测试失败'}) - - thread = threading.Thread(target=run_test, daemon=True) - thread.start() - - def _on_whitelist_test_complete(self, result: dict): - """处理白名单测试结果。""" - status = str(result.get('status') or '完成') - enabled = self.whitelist_enabled_var.get() - self.whitelist_status_var.set(status) - self.whitelist_test_btn.configure(state='normal' if enabled else 'disabled') - - def _get_exit_ip_for_test(self, proxy_config: ProxyConfig) -> Optional[str]: - """获取出口IP用于白名单测试""" - proxy_url = None - if proxy_config.enabled: - if proxy_config.api_url: - try: - resp = requests.get(proxy_config.api_url, timeout=10) - resp.raise_for_status() - proxy_url = DouyuLoginApp._parse_proxy_url(resp.text.strip()) - except Exception: - pass - if not proxy_url: - proxy_url = proxy_config.http or proxy_config.https - - if proxy_url: - return get_exit_ip_via_proxy(proxy_url) - - # 没有代理时,用本机网络获取出口IP(多个探测源fallback) - detect_urls = [ - 'https://qifu-api.baidubce.com/ip/local/geo/v1/district', - 'https://myip.ipip.net', - 'https://4.ipw.cn', - ] - for url in detect_urls: - try: - resp = requests.get(url, timeout=6, headers={'User-Agent': 'Mozilla/5.0'}) - text = resp.text.strip() - match = re.search(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})', text) - if match: - return match.group(1) - except Exception: - continue - return None - - def _sync_whitelist(self, proxy_config: ProxyConfig) -> bool: - """ - 同步白名单IP - - 获取当前代理出口IP并同步到白名单 - """ - if not proxy_config.whitelist_enabled: - return True - - uid = proxy_config.whitelist_uid - ukey = proxy_config.whitelist_ukey - - if not uid or not ukey: - self.log_queue.put(('warning', '白名单未配置UID/UKEY,跳过同步')) - return True - - # 获取代理地址 - proxy_url = None - if proxy_config.http or proxy_config.https: - proxy_url = proxy_config.http or proxy_config.https - elif proxy_config.api_url: - # 从API获取代理 - try: - self.log_queue.put(('info', '从代理API获取IP用于白名单同步...')) - response = requests.get(proxy_config.api_url, timeout=10) - response.raise_for_status() - text = response.text.strip() - match = re.search(r'(\d+\.\d+\.\d+\.\d+):(\d+)', text) - if match: - proxy_url = f"http://{match.group(1)}:{match.group(2)}" - except Exception as e: - self.log_queue.put(('error', f'获取代理失败: {e}')) - return False - - if not proxy_url: - self.log_queue.put(('warning', '无法获取代理地址,跳过白名单同步')) - return True - - # 获取出口IP - self.log_queue.put(('info', f'通过代理获取出口IP...')) - exit_ip = get_exit_ip_via_proxy(proxy_url) - - if not exit_ip: - self.log_queue.put(('error', '无法获取出口IP,白名单同步失败')) - return False - - self.log_queue.put(('info', f'当前出口IP: {exit_ip}')) - - # 同步白名单 - manager = WhitelistManager(uid, ukey) - success, message = manager.sync_ip(exit_ip) - - if success: - self.log_queue.put(('success', f'白名单同步: {message}')) - else: - self.log_queue.put(('error', f'白名单同步: {message}')) - - return success - - def _get_proxy_config(self) -> ProxyConfig: - """获取当前代理配置""" - enabled = self.proxy_enabled_var.get() - proxy_type = self.proxy_type_var.get() - - # 白名单配置 - whitelist_enabled = self.whitelist_enabled_var.get() if hasattr(self, 'whitelist_enabled_var') else False - whitelist_uid = self.whitelist_uid_var.get().strip() if hasattr(self, 'whitelist_uid_var') else '' - whitelist_ukey = self.whitelist_ukey_var.get().strip() if hasattr(self, 'whitelist_ukey_var') else '' - - if proxy_type == 'api': - return ProxyConfig( - enabled=enabled, - api_url=self.proxy_api_var.get().strip(), - http='', - https='', - whitelist_enabled=whitelist_enabled, - whitelist_uid=whitelist_uid, - whitelist_ukey=whitelist_ukey, - ) - else: - http = self._normalize_proxy_url(self.proxy_http_var.get().strip()) - https = self._normalize_proxy_url(self.proxy_https_var.get().strip()) - if http != self.proxy_http_var.get().strip(): - self.proxy_http_var.set(http) - if https != self.proxy_https_var.get().strip(): - self.proxy_https_var.set(https) - return ProxyConfig( - enabled=enabled, - api_url='', - http=http, - https=https, - whitelist_enabled=whitelist_enabled, - whitelist_uid=whitelist_uid, - whitelist_ukey=whitelist_ukey, - ) - - @staticmethod - def _normalize_proxy_url(value: str) -> str: - """补全静态代理地址的协议。""" - if not value: - return '' - lowered = value.lower() - if lowered.startswith(('http://', 'https://')): - return value - return f'http://{value}' - - def _validate_proxy_config(self, proxy_config: ProxyConfig, require_enabled: bool = False) -> str: - """校验代理配置,返回错误消息。""" - if not proxy_config.enabled: - return '请先启用代理' if require_enabled else '' - - if proxy_config.api_url: - if not proxy_config.api_url.startswith(('http://', 'https://')): - return '代理API地址格式不正确,需要以 http:// 或 https:// 开头' - return '' - - proxy_url = proxy_config.http or proxy_config.https - if not proxy_url: - return '请填写代理地址' - if not proxy_url.startswith(('http://', 'https://')): - return '静态代理地址格式不正确,需要以 http:// 或 https:// 开头' - return '' - - def _test_proxy(self): - """测试当前代理配置。""" - if self.proxy_test_running: - return - - proxy_config = self._get_proxy_config() - error_message = self._validate_proxy_config(proxy_config, require_enabled=True) - if error_message: - messagebox.showwarning('提示', error_message) - return - - self._save_state() - self.proxy_test_running = True - self.proxy_test_btn.configure(state='disabled') - self.proxy_test_status_var.set('测试中...') - self.log_panel.append_log('info', '开始测试代理') - - thread = threading.Thread( - target=self._run_proxy_test, - args=(proxy_config,), - daemon=True, - ) - thread.start() - - def _run_proxy_test(self, proxy_config: ProxyConfig): - """后台执行代理测试。""" - result = self._resolve_working_proxy_config(proxy_config) - self.proxy_test_queue.put(result) - - @staticmethod - def _parse_proxy_url(text: str) -> str: - """从代理API响应中解析代理地址。""" - # ip:port 格式 - match = re.search(r'(\d+\.\d+\.\d+\.\d+):(\d+)', text) - if match: - return f'http://{match.group(1)}:{match.group(2)}' - - # JSON 格式: {"ip": "...", "port": ...} 等 - try: - data = json.loads(text) if text else {} - if isinstance(data, dict): - ip = data.get('ip') or data.get('host') or data.get('proxy') - port = data.get('port') - if ip and port: - return f'http://{ip}:{port}' - if ip: - match = re.search(r'(\d+\.\d+\.\d+\.\d+):?(\d+)?', str(ip)) - if match: - return f'http://{match.group(1)}:{match.group(2) or "80"}' - except Exception: - pass - return '' - - @staticmethod - def _extract_whitelist_ip_from_error(text: str) -> Optional[str]: - """从代理API白名单错误消息中提取需要添加的IP。""" - if '添加白名单' in text or '白名单' in text: - match = re.search(r'(\d+\.\d+\.\d+\.\d+)', text) - if match: - return match.group(1) - return None - - @staticmethod - def _extract_origin_ip(response: requests.Response) -> str: - """从IP查询服务响应中提取出口IP。""" - content_type = response.headers.get('content-type', '').lower() - if 'json' in content_type: - data = response.json() - if isinstance(data, dict): - for key in ('ip', 'origin'): - value = data.get(key) - if value: - return str(value).split(',')[0].strip() - - nested_data = data.get('data') - if isinstance(nested_data, dict) and nested_data.get('ip'): - return str(nested_data['ip']).strip() - - text = response.text.strip() - match = re.search(r'(\d{1,3}(?:\.\d{1,3}){3})', text) - if match: - return match.group(1) - - raise ValueError('无法解析出口IP') - - @staticmethod - def _verify_proxy_url(proxy_url: str, proxy_config: ProxyConfig) -> tuple[bool, str]: - """通过国内HTTPS目标验证代理出口。""" - proxies = { - 'http': proxy_config.http or proxy_url, - 'https': proxy_config.https or proxy_url, - } - errors = [] - - for name, url in PROXY_TEST_TARGETS: - try: - response = requests.get( - url, - proxies=proxies, - timeout=PROXY_VERIFY_TIMEOUT, - headers={'User-Agent': 'Mozilla/5.0'}, - ) - response.raise_for_status() - origin = DouyuLoginApp._extract_origin_ip(response) - return True, f'代理可用,出口IP: {origin}({name})' - except Exception as exc: - err_msg = str(exc) - if 'Tunnel connection failed' in err_msg or '503' in err_msg: - detail = '代理拒绝连接(白名单可能未生效)' - elif 'timed out' in err_msg.lower(): - detail = '连接超时' - else: - detail = type(exc).__name__ - errors.append(f'{name}: {detail}') - - return False, '; '.join(errors) or '代理验证失败' - - def _resolve_working_proxy_config(self, proxy_config: ProxyConfig) -> dict: - """获取并验证可用代理,成功后返回可用于登录的代理配置。""" - attempts = PROXY_API_TEST_ATTEMPTS if proxy_config.api_url else PROXY_STATIC_TEST_ATTEMPTS - last_message = '' - synced_whitelist = False - - for attempt in range(1, attempts + 1): - proxy_url = proxy_config.http or proxy_config.https - if proxy_config.api_url: - self.log_queue.put(('info', f'代理预检 {attempt}/{attempts}: 正在获取代理')) - try: - response = requests.get(proxy_config.api_url, timeout=10) - response.raise_for_status() - text = response.text.strip() - proxy_url = self._parse_proxy_url(text) - - # 代理API返回了白名单错误,提取需要添加的IP - if not proxy_url and not synced_whitelist and proxy_config.whitelist_enabled: - whitelist_ip = self._extract_whitelist_ip_from_error(text) - if whitelist_ip: - self.log_queue.put( - ('warning', f'代理需要白名单IP: {whitelist_ip},自动同步...')) - manager = WhitelistManager( - proxy_config.whitelist_uid, proxy_config.whitelist_ukey) - ok, msg = manager.sync_ip(whitelist_ip) - self.log_queue.put( - ('success' if ok else 'error', f'白名单同步: {msg}')) - if ok: - synced_whitelist = True - self.log_queue.put(('info', '白名单已更新,等待2秒后重试获取代理...')) - time.sleep(2) - continue - last_message = f'白名单同步失败: {msg}' - else: - last_message = f'代理API响应无法解析: {text[:60]}' - elif not proxy_url: - last_message = f'代理API响应无法解析: {text[:60]}' - except Exception as exc: - last_message = f'代理API请求失败: {exc}' - - if not proxy_url: - self.log_queue.put(('warning', f'代理预检 {attempt}/{attempts}: {last_message}')) - continue - else: - self.log_queue.put(('info', f'代理预检 {attempt}/{attempts}: 正在验证静态代理')) - - test_config = ProxyConfig( - enabled=True, - api_url=proxy_config.api_url, - http=proxy_url, - https=proxy_url, - ) - success, message = self._verify_proxy_url(proxy_url, test_config) - if success: - working_config = ProxyConfig( - enabled=True, - # 登录阶段只使用预检通过的代理,避免再次获取未验证代理。 - api_url='', - http=proxy_url, - https=proxy_url, - ) - return { - 'success': True, - 'message': message, - 'proxy': proxy_url, - 'proxy_config': working_config, - } - - last_message = message - self.log_queue.put(('warning', f'代理预检 {attempt}/{attempts}: {message}')) - - return { - 'success': False, - 'message': f'代理预检失败,已尝试 {attempts} 次: {last_message}', - } - - def _on_proxy_test_complete(self, result: dict): - """处理代理测试结果。""" - self.proxy_test_running = False - self.proxy_test_btn.configure(state='normal' if self.proxy_enabled_var.get() else 'disabled') - - success = bool(result.get('success')) - message = str(result.get('message') or '') - if success: - self.proxy_test_status_var.set(message) - self.log_panel.append_log('success', message) - else: - self.proxy_test_status_var.set('测试失败') - self.log_panel.append_log('error', message or '代理测试失败') - - def _parse_import_append(self): - """解析文本并追加导入。""" - self._parse_import(replace=False) - - def _parse_import_replace(self): - """解析文本并替换导入。""" - self._parse_import(replace=True) - - def _parse_import(self, replace: bool = False): - """解析导入的文本""" - text = self.import_text.get('1.0', 'end') - if not text.strip(): - messagebox.showwarning('提示', '请输入账号数据') - return - - accounts = parse_accounts_text(text) - if not accounts: - messagebox.showwarning('提示', '未解析到有效账号') - return - - added, skipped = self._add_accounts(accounts, replace=replace) - action = '替换导入' if replace else '追加导入' - self.log_panel.append_log('info', f'{action} {added} 个账号,跳过重复 {skipped} 个') - - # 清空输入框 - self.import_text.delete('1.0', 'end') - - def _import_from_file_append(self): - """从文件追加导入。""" - self._import_from_file(replace=False) - - def _import_from_file_replace(self): - """从文件替换导入。""" - self._import_from_file(replace=True) - - def _import_from_file(self, replace: bool = False): - """从文件导入""" - initial_dir = self.last_import_dir if Path(self.last_import_dir).exists() else str(Path.cwd()) - filepath = filedialog.askopenfilename( - title='选择账号文件', - initialdir=initial_dir, - filetypes=[ - ('账号文件', '*.txt *.csv *.json *.yaml *.yml'), - ('文本文件', '*.txt'), - ('CSV文件', '*.csv'), - ('JSON文件', '*.json'), - ('YAML文件', '*.yaml *.yml'), - ('所有文件', '*.*'), - ] - ) - - if not filepath: - return - - try: - accounts = load_accounts_from_file(filepath) - if not accounts: - messagebox.showwarning('提示', '文件中未解析到有效账号') - return - - self.last_import_dir = str(Path(filepath).parent) - added, skipped = self._add_accounts(accounts, replace=replace) - action = '从文件替换导入' if replace else '从文件追加导入' - self.log_panel.append_log('info', f'{action} {added} 个账号,跳过重复 {skipped} 个') - - except Exception as e: - messagebox.showerror('错误', f'读取文件失败: {e}') - - def _clear_import(self): - """清空导入框""" - self.import_text.delete('1.0', 'end') - - def _add_accounts(self, accounts: List[Account], replace: bool = False) -> tuple[int, int]: - """添加账号并刷新表格。""" - if replace: - self.accounts = list(accounts) - self.results.clear() - self._reload_accounts_table() - self._save_state() - return len(accounts), 0 - - existing_keys = { - (account.username, account.email) - for account in self.accounts - } - added = 0 - skipped = 0 - - for account in accounts: - key = (account.username, account.email) - if key in existing_keys: - skipped += 1 - continue - self.accounts.append(account) - existing_keys.add(key) - added += 1 - - self._reload_accounts_table() - self._save_state() - return added, skipped - - def _reload_accounts_table(self): - """按当前账号列表重建表格。""" - self.table.clear() - for index, account in enumerate(self.accounts): - self.table.add_account(index, account.username, account.email) - - if hasattr(self, 'progress_var'): - self.progress_var.set(f'共 {len(self.accounts)} 个账号' if self.accounts else '就绪') - - def _clear_table(self): - """清空表格""" - if self.is_running or self.is_preparing_login: - messagebox.showwarning('提示', '登录进行中,无法清空') - return - - self.accounts.clear() - self.results.clear() - self.table.clear() - self.progress_var.set('就绪') - self.log_panel.append_log('info', '已清空账号列表') - self._save_state() - - def _delete_selected_accounts(self): - """删除表格中选中的账号。""" - if self.is_running or self.is_preparing_login: - messagebox.showwarning('提示', '登录进行中,无法删除') - return - - selected = self.table.selection() - if not selected: - messagebox.showwarning('提示', '请先选择要删除的账号') - return - - selected_indices = sorted((int(item_id) for item_id in selected), reverse=True) - for index in selected_indices: - if 0 <= index < len(self.accounts): - del self.accounts[index] - - self.results.clear() - self._reload_accounts_table() - self._save_state() - self.log_panel.append_log('info', f'已删除 {len(selected_indices)} 个账号') - - def _bind_state_traces(self): - """绑定输入控件变更后的自动保存。""" - variables = [ - self.proxy_enabled_var, - self.proxy_type_var, - self.proxy_api_var, - self.proxy_http_var, - self.proxy_https_var, - self.geetest_retries_var, - self.log_level_var, - self.whitelist_enabled_var, - self.whitelist_uid_var, - self.whitelist_ukey_var, - ] - for variable in variables: - variable.trace_add('write', lambda *_: self._schedule_save_state()) - - def _schedule_save_state(self): - """延迟保存GUI状态,减少频繁写文件。""" - if not self._state_ready or not hasattr(self, 'root'): - return - - if self._state_save_after_id: - self.root.after_cancel(self._state_save_after_id) - - self._state_save_after_id = self.root.after(500, self._save_state) - - def _collect_state(self) -> dict: - """收集当前GUI状态。""" - proxy_config = self._get_proxy_config() - log_level = self.log_level_var.get().upper() if hasattr(self, 'log_level_var') else self.log_level - if log_level not in LOG_LEVELS: - log_level = 'INFO' - - return { - 'version': 1, - 'updated_at': datetime.now().isoformat(timespec='seconds'), - 'accounts': [account_to_dict(account) for account in self.accounts], - 'proxy': { - 'enabled': proxy_config.enabled, - 'api_url': proxy_config.api_url, - 'http': proxy_config.http, - 'https': proxy_config.https, - 'whitelist_enabled': proxy_config.whitelist_enabled, - 'whitelist_uid': proxy_config.whitelist_uid, - 'whitelist_ukey': proxy_config.whitelist_ukey, - }, - 'proxy_type': self.proxy_type_var.get() if hasattr(self, 'proxy_type_var') else self.proxy_type, - 'geetest_retries': self._get_geetest_retries(), - 'log_level': log_level, - 'last_import_dir': self.last_import_dir, - } - - def _get_geetest_retries(self) -> int: - """读取并规范化极验重试次数。""" - try: - current_value = int(self.geetest_retries_var.get()) - retries = current_value - needs_update = False - except (tk.TclError, TypeError, ValueError): - current_value = None - retries = 5 - needs_update = True - - retries = min(max(retries, 1), 20) - if current_value != retries: - needs_update = True - - if needs_update and hasattr(self, 'geetest_retries_var'): - self.geetest_retries_var.set(retries) - return retries - - def _save_state(self): - """保存当前GUI状态。""" - if self._state_save_after_id: - self._state_save_after_id = None - - try: - self.state_store.save(self._collect_state()) - if hasattr(self, 'save_status_var'): - self.save_status_var.set(f'已保存 {datetime.now().strftime("%H:%M:%S")}') - except Exception as exc: - logger.error(f"保存GUI状态失败: {exc}") - if hasattr(self, 'save_status_var'): - self.save_status_var.set('保存失败') - - def _on_close(self): - """关闭窗口前保存状态。""" - if self.is_running or self.is_preparing_login: - confirmed = messagebox.askyesno('确认关闭', '登录进行中,关闭窗口会停止当前任务。是否继续?') - if not confirmed: - return - self._stop_login() - - self._save_state() - self.root.destroy() - - def _start_login(self): - """开始批量登录""" - if not self.accounts: - messagebox.showwarning('提示', '请先导入账号') - return - - if self.is_running or self.is_preparing_login: - return - - # 获取当前GUI配置 - proxy_config = self._get_proxy_config() - geetest_retries = self._get_geetest_retries() - log_level = self.log_level_var.get().upper() - self._configure_logging(log_level) - - # 验证代理配置 - proxy_error = self._validate_proxy_config(proxy_config) - if proxy_error: - messagebox.showwarning('提示', proxy_error) - return - - self._save_state() - - # 同步白名单(手动代理模式,代理地址已知) - if proxy_config.whitelist_enabled and not proxy_config.api_url: - if not self._sync_whitelist(proxy_config): - messagebox.showwarning('提示', '白名单同步失败,请检查配置') - return - - if proxy_config.enabled: - self._prepare_login_proxy(proxy_config, geetest_retries, log_level) - return - - self._begin_login(proxy_config, geetest_retries, log_level) - - def _prepare_login_proxy(self, proxy_config: ProxyConfig, geetest_retries: int, log_level: str): - """登录前预检代理,拿到可用代理后再启动登录。""" - self.is_preparing_login = True - self.results.clear() - self.workers.clear() - self.start_btn.configure(state='disabled') - self.stop_btn.configure(state='normal') - - for i in range(len(self.accounts)): - self.table.update_status(i, 'pending') - - self.log_panel.append_log('info', f'开始代理预检,共 {len(self.accounts)} 个账号等待登录') - if proxy_config.api_url: - self.log_panel.append_log('info', f'代理API: {proxy_config.api_url[:50]}...') - else: - proxy_url = proxy_config.http or proxy_config.https - self.log_panel.append_log('info', f'静态代理: {proxy_url}') - - self.progress_var.set('代理预检中...') - thread = threading.Thread( - target=self._run_login_preflight, - args=(proxy_config, geetest_retries, log_level), - daemon=True, - ) - thread.start() - - def _run_login_preflight(self, proxy_config: ProxyConfig, geetest_retries: int, log_level: str): - """后台执行登录前代理预检。""" - result = self._resolve_working_proxy_config(proxy_config) - result['geetest_retries'] = geetest_retries - result['log_level'] = log_level - - # API代理模式:同步白名单(使用已验证的代理地址,避免API返回不同代理导致的不一致) - if proxy_config.whitelist_enabled and proxy_config.api_url and result.get('success'): - synced_config = result.get('proxy_config') - if synced_config: - self._sync_whitelist(synced_config) - - self.login_preflight_queue.put(result) - - def _on_login_preflight_complete(self, result: dict): - """代理预检完成后继续或停止登录流程。""" - if not self.is_preparing_login: - return - - self.is_preparing_login = False - success = bool(result.get('success')) - message = str(result.get('message') or '') - - if not success: - self.start_btn.configure(state='normal') - self.stop_btn.configure(state='disabled') - self.progress_var.set('代理预检失败') - self.log_panel.append_log('error', message or '代理预检失败,未启动登录') - messagebox.showwarning('代理不可用', message or '代理预检失败,未启动登录') - return - - working_proxy = result.get('proxy') or '' - self.proxy_test_status_var.set(message) - self.log_panel.append_log('success', f'{message},开始登录') - if working_proxy: - self.log_panel.append_log('info', f'本次登录使用已验证代理: {working_proxy}') - - working_proxy_config = result.get('proxy_config') - if not isinstance(working_proxy_config, ProxyConfig): - self.start_btn.configure(state='normal') - self.stop_btn.configure(state='disabled') - self.progress_var.set('代理预检失败') - self.log_panel.append_log('error', '代理预检结果异常,未启动登录') - return - - self._begin_login( - working_proxy_config, - int(result.get('geetest_retries', 5)), - str(result.get('log_level') or 'INFO'), - ) - - def _begin_login(self, proxy_config: ProxyConfig, geetest_retries: int, log_level: str): - """使用已确认的配置启动登录线程。""" - self.is_running = True - self.results.clear() - self.workers.clear() - self.start_btn.configure(state='disabled') - self.stop_btn.configure(state='normal') - - # 重置表格状态 - for i in range(len(self.accounts)): - self.table.update_status(i, 'pending') - - self.log_panel.append_log('info', f'开始批量登录,共 {len(self.accounts)} 个账号') - - # 显示代理配置 - if proxy_config.enabled: - if proxy_config.api_url: - self.log_panel.append_log('info', f'使用代理API: {proxy_config.api_url[:50]}...') - else: - proxy_url = proxy_config.http or proxy_config.https - self.log_panel.append_log('info', f'使用静态代理: {proxy_url}') - else: - self.log_panel.append_log('info', '未使用代理') - - self.log_panel.append_log('info', f'极验最大重试: {geetest_retries} 次') - self.log_panel.append_log('info', f'日志等级: {log_level}') - self.progress_var.set('登录中...') - - # 启动登录线程 - for i, account in enumerate(self.accounts): - worker = LoginWorker( - account=account, - index=i, - result_queue=self.result_queue, - log_queue=self.log_queue, - proxy_config=proxy_config, - max_geetest_retries=geetest_retries, - ) - self.workers.append(worker) - self.table.update_status(i, 'running') - worker.start() - - def _stop_login(self): - """停止登录""" - if self.is_preparing_login: - self.is_preparing_login = False - self.start_btn.configure(state='normal') - self.stop_btn.configure(state='disabled') - self.log_panel.append_log('warning', '已停止代理预检') - self.progress_var.set('已停止') - return - - if not self.is_running: - return - - self.is_running = False - - # 停止所有工作线程 - for worker in self.workers: - worker.stop() - - self.start_btn.configure(state='normal') - self.stop_btn.configure(state='disabled') - self.log_panel.append_log('warning', '已停止登录') - self.progress_var.set('已停止') - - def _poll_queues(self): - """定时检查队列""" - # 处理日志队列 - try: - while True: - level, message = self.log_queue.get_nowait() - self.log_panel.append_log(level, message) - except Empty: - pass - - # 处理代理测试结果 - try: - while True: - proxy_test_result = self.proxy_test_queue.get_nowait() - self._on_proxy_test_complete(proxy_test_result) - except Empty: - pass - - # 处理登录前代理预检结果 - try: - while True: - preflight_result = self.login_preflight_queue.get_nowait() - self._on_login_preflight_complete(preflight_result) - except Empty: - pass - - # 处理白名单测试结果 - try: - while True: - result = self.whitelist_test_queue.get_nowait() - self._on_whitelist_test_complete(result) - except Empty: - pass - - # 处理结果队列 - try: - while True: - result = self.result_queue.get_nowait() - self.results.append(result) - self.table.update_status( - result['index'], - result['status'], - result['cookie'] - ) - - # 更新进度 - completed = len(self.results) - total = len(self.accounts) - self.progress_var.set(f'完成 {completed}/{total}') - - # 检查是否全部完成 - if completed >= total: - self._on_login_complete() - except Empty: - pass - - # 继续轮询 - self.root.after(100, self._poll_queues) - - def _on_login_complete(self): - """登录完成回调""" - self.is_running = False - self.start_btn.configure(state='normal') - self.stop_btn.configure(state='disabled') - - # 统计结果 - success = sum(1 for r in self.results if r['status'] == 'success') - failed = len(self.results) - success - - self.log_panel.append_log('info', f'批量登录完成: 成功 {success}, 失败 {failed}') - self.progress_var.set(f'完成: 成功 {success}, 失败 {failed}') - - messagebox.showinfo('完成', f'批量登录完成\n成功: {success}\n失败: {failed}') - - def _export_results(self): - """导出结果""" - if not self.results: - messagebox.showwarning('提示', '没有可导出的结果') - return - - # 选择保存路径 - filepath = filedialog.asksaveasfilename( - title='导出结果', - defaultextension='.json', - filetypes=[ - ('JSON文件', '*.json'), - ('CSV文件', '*.csv'), - ('文本文件', '*.txt'), - ], - initialfile=f'douyu_cookies_{datetime.now().strftime("%Y%m%d_%H%M%S")}' - ) - - if not filepath: - return - - try: - if filepath.endswith('.json'): - self._export_json(filepath) - elif filepath.endswith('.csv'): - self._export_csv(filepath) - else: - self._export_txt(filepath) - - self.log_panel.append_log('success', f'结果已导出: {filepath}') - messagebox.showinfo('成功', f'结果已导出到:\n{filepath}') - - except Exception as e: - messagebox.showerror('错误', f'导出失败: {e}') - - def _export_json(self, filepath: str): - """导出为JSON""" - data = { - 'export_time': datetime.now().isoformat(), - 'total': len(self.results), - 'success': sum(1 for r in self.results if r['status'] == 'success'), - 'results': self.results, - } - - with open(filepath, 'w', encoding='utf-8') as f: - json.dump(data, f, ensure_ascii=False, indent=2) - - def _export_csv(self, filepath: str): - """导出为CSV""" - import csv - - with open(filepath, 'w', encoding='utf-8-sig', newline='') as f: - writer = csv.writer(f) - writer.writerow(['序号', '用户名', '邮箱', '状态', 'Cookie', '消息']) - - for r in self.results: - writer.writerow([ - r['index'] + 1, - r['username'], - r['email'], - r['status'], - r['cookie'], - r['message'], - ]) - - def _export_txt(self, filepath: str): - """导出为TXT""" - with open(filepath, 'w', encoding='utf-8') as f: - f.write(f'# 斗鱼批量登录结果\n') - f.write(f'# 导出时间: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}\n') - f.write(f'# 格式: 用户名|密码|邮箱|邮箱密码|Cookie\n\n') - - for r in self.results: - if r['status'] == 'success': - # 找到对应的账号信息 - acc = self.accounts[r['index']] - f.write(f'{acc.username}|{acc.password}|{acc.email}|{acc.email_password}|{r["cookie"]}\n') - - def run(self): - """运行应用""" - self.root.mainloop() diff --git a/gui/login_worker.py b/gui/login_worker.py deleted file mode 100644 index 9b7d60e..0000000 --- a/gui/login_worker.py +++ /dev/null @@ -1,203 +0,0 @@ -"""登录工作线程模块""" - -import csv -import re -import threading -import time -from queue import Queue -from typing import Optional -from loguru import logger - -from douyu import DouyuLogin -from douyu.config import Account, ProxyConfig -from douyu.email_verifier import get_email_config_for_account - - -EMAIL_PATTERN = re.compile(r'^[^\s@|]+@[^\s@|]+\.[^\s@|]+$') -HEADER_NAMES = { - 'username', 'user', 'account', '账号', '用户名', '斗鱼账号', - 'password', 'pass', 'pwd', '密码', '登录密码', - 'email', 'mail', '邮箱', '邮箱地址', - 'email_password', 'email_pass', 'email_pwd', 'mail_password', - 'mail_pass', '邮箱密码', '邮箱授权码', '授权码', -} - - -def get_imap_server(email: str) -> str: - """从邮箱地址推导IMAP服务器""" - return get_email_config_for_account(email)['server'] - - -def _is_ascii(value: str) -> bool: - """检查字符串是否为ASCII。""" - try: - value.encode('ascii') - return True - except UnicodeEncodeError: - return False - - -def _split_account_line(line: str) -> list[str]: - """拆分单行账号数据,支持竖线、Tab和CSV逗号。""" - if '|' in line: - return line.split('|') - if '\t' in line: - return line.split('\t') - if ',' in line: - return next(csv.reader([line])) - return line.split() - - -def _looks_like_header(parts: list[str]) -> bool: - """判断一行是否像表头。""" - normalized = { - part.strip().lower().replace('-', '_') - for part in parts - if part.strip() - } - return len(normalized & HEADER_NAMES) >= 2 - - -def parse_accounts_text(text: str) -> list[Account]: - """ - 解析账号文本 - - 格式:用户名|密码|邮箱|邮箱密码 - - Returns: - Account列表 - """ - accounts = [] - - for line_num, line in enumerate(text.strip().split('\n'), 1): - line = line.strip() - if not line or line.startswith('#'): - continue - - parts = _split_account_line(line) - if line_num == 1 and _looks_like_header(parts): - continue - - if len(parts) != 4: - logger.warning(f"第{line_num}行格式错误,需要4个字段,实际{len(parts)}个: {line}") - continue - - username, password, email, email_password = parts[0].strip(), parts[1].strip(), parts[2].strip(), parts[3].strip() - - if not all([username, password, email, email_password]): - logger.warning(f"第{line_num}行存在空字段: {line}") - continue - - if not EMAIL_PATTERN.match(email) or not _is_ascii(email): - logger.warning( - f"第{line_num}行邮箱格式不正确: {email}," - "请确认格式为:用户名|密码|邮箱|邮箱密码" - ) - continue - - if not _is_ascii(email_password): - logger.warning(f"第{line_num}行邮箱密码/授权码包含非ASCII字符,IMAP可能无法登录") - continue - - accounts.append(Account( - username=username, - password=password, - email=email, - email_password=email_password, - email_imap_server=get_imap_server(email), - email_imap_port=993, - )) - - return accounts - - -class LoginWorker(threading.Thread): - """登录工作线程""" - - def __init__( - self, - account: Account, - index: int, - result_queue: Queue, - log_queue: Queue, - proxy_config: Optional[ProxyConfig] = None, - max_geetest_retries: int = 5, - ): - super().__init__(daemon=True) - self.account = account - self.index = index - self.result_queue = result_queue - self.log_queue = log_queue - self.proxy_config = proxy_config - self.max_geetest_retries = max_geetest_retries - self._stop_event = threading.Event() - - def stop(self): - """停止线程""" - self._stop_event.set() - - def run(self): - """执行登录""" - username = self.account.username - - try: - # 发送日志 - self.log_queue.put(('info', f'[{self.index + 1}] 开始登录: {username}')) - - # 配置代理 - proxy_url = None - proxy_api_url = None - - if self.proxy_config and self.proxy_config.enabled: - if self.proxy_config.http or self.proxy_config.https: - # 优先使用GUI预检通过的代理,避免进入登录流程后再直接获取未验证代理。 - proxy_url = { - 'http': self.proxy_config.http or self.proxy_config.https, - 'https': self.proxy_config.https or self.proxy_config.http, - } - elif self.proxy_config.api_url: - proxy_api_url = self.proxy_config.api_url - - # 创建登录器 - loginer = DouyuLogin( - self.account, - proxy=proxy_url, - proxy_api_url=proxy_api_url, - max_geetest_retries=self.max_geetest_retries, - ) - - # 执行登录 - result = loginer.login() - - if result.success: - self.log_queue.put(('success', f'[{self.index + 1}] {username} 登录成功')) - self.result_queue.put({ - 'index': self.index, - 'username': username, - 'email': self.account.email, - 'status': 'success', - 'cookie': result.cookie, - 'message': '登录成功', - }) - else: - self.log_queue.put(('error', f'[{self.index + 1}] {username} 登录失败: {result.message}')) - self.result_queue.put({ - 'index': self.index, - 'username': username, - 'email': self.account.email, - 'status': 'failed', - 'cookie': '', - 'message': result.message, - }) - - except Exception as e: - error_msg = str(e) - self.log_queue.put(('error', f'[{self.index + 1}] {username} 登录异常: {error_msg}')) - self.result_queue.put({ - 'index': self.index, - 'username': username, - 'email': self.account.email, - 'status': 'error', - 'cookie': '', - 'message': error_msg, - }) diff --git a/gui/state.py b/gui/state.py deleted file mode 100644 index ad70256..0000000 --- a/gui/state.py +++ /dev/null @@ -1,118 +0,0 @@ -"""GUI本地状态存储。""" - -from __future__ import annotations - -import json -from pathlib import Path -from typing import Any - -from loguru import logger - -from douyu.config import Account, ProxyConfig -from douyu.email_verifier import get_email_config_for_account - - -DEFAULT_STATE_PATH = Path("data/gui_state.json") - - -def account_to_dict(account: Account) -> dict[str, Any]: - """把账号对象转换成可保存的字典。""" - return { - "username": account.username, - "password": account.password, - "email": account.email, - "email_password": account.email_password, - "email_imap_server": account.email_imap_server, - "email_imap_port": account.email_imap_port, - } - - -def account_from_dict(data: dict[str, Any]) -> Account | None: - """从本地状态恢复账号对象。""" - username = str(data.get("username", "")).strip() - password = str(data.get("password", "")).strip() - email = str(data.get("email", "")).strip() - email_password = str(data.get("email_password", "")).strip() - - if not all([username, password, email, email_password]): - return None - - email_config = get_email_config_for_account(email) - imap_server = str(data.get("email_imap_server") or email_config["server"]).strip() - try: - imap_port = int(data.get("email_imap_port") or email_config["port"]) - except (TypeError, ValueError): - imap_port = int(email_config["port"]) - - return Account( - username=username, - password=password, - email=email, - email_password=email_password, - email_imap_server=imap_server, - email_imap_port=imap_port, - ) - - -class GuiStateStore: - """负责保存和读取GUI状态。""" - - def __init__(self, path: str | Path = DEFAULT_STATE_PATH): - self.path = Path(path) - - def load(self) -> dict[str, Any]: - """读取本地状态文件。""" - if not self.path.exists(): - return {} - - try: - with self.path.open("r", encoding="utf-8") as f: - data = json.load(f) - return data if isinstance(data, dict) else {} - except Exception as exc: - logger.warning(f"读取GUI状态失败,将使用默认值: {exc}") - return {} - - def save(self, state: dict[str, Any]) -> None: - """原子写入本地状态文件。""" - self.path.parent.mkdir(parents=True, exist_ok=True) - tmp_path = self.path.with_suffix(f"{self.path.suffix}.tmp") - - with tmp_path.open("w", encoding="utf-8") as f: - json.dump(state, f, ensure_ascii=False, indent=2) - - tmp_path.replace(self.path) - - @staticmethod - def accounts_from_state(state: dict[str, Any]) -> list[Account]: - """从状态字典恢复账号列表。""" - accounts = [] - raw_accounts = state.get("accounts", []) - if not isinstance(raw_accounts, list): - return accounts - - for raw_account in raw_accounts: - if not isinstance(raw_account, dict): - continue - account = account_from_dict(raw_account) - if account: - accounts.append(account) - - return accounts - - @staticmethod - def proxy_from_state(state: dict[str, Any]) -> ProxyConfig: - """从状态字典恢复代理配置。""" - proxy = state.get("proxy", {}) - if not isinstance(proxy, dict): - return ProxyConfig() - - return ProxyConfig( - enabled=bool(proxy.get("enabled", False)), - api_url=str(proxy.get("api_url", "")).strip(), - http=str(proxy.get("http", "")).strip(), - https=str(proxy.get("https", "")).strip(), - whitelist_enabled=bool(proxy.get("whitelist_enabled", False)), - whitelist_uid=str(proxy.get("whitelist_uid", "")).strip(), - whitelist_ukey=str(proxy.get("whitelist_ukey", "")).strip(), - ) diff --git a/gui/widgets.py b/gui/widgets.py deleted file mode 100644 index 000649f..0000000 --- a/gui/widgets.py +++ /dev/null @@ -1,212 +0,0 @@ -"""自定义GUI组件""" - -import tkinter as tk -from tkinter import ttk -from datetime import datetime - - -class AccountTable(ttk.Treeview): - """账号列表表格""" - - COLUMNS = ('index', 'username', 'email', 'status', 'cookie') - - def __init__(self, parent, **kwargs): - super().__init__( - parent, - columns=self.COLUMNS, - show='headings', - selectmode='extended', - **kwargs - ) - - # 设置列标题 - self.heading('index', text='序号') - self.heading('username', text='用户名') - self.heading('email', text='邮箱') - self.heading('status', text='状态') - self.heading('cookie', text='Cookie') - - # 设置列宽 - self.column('index', width=50, minwidth=50, anchor='center') - self.column('username', width=150, minwidth=100) - self.column('email', width=200, minwidth=150) - self.column('status', width=80, minwidth=60, anchor='center') - self.column('cookie', width=200, minwidth=100) - - # 添加滚动条 - scrollbar = ttk.Scrollbar(parent, orient='vertical', command=self.yview) - self.configure(yscrollcommand=scrollbar.set) - scrollbar.pack(side='right', fill='y') - - # 状态标签样式 - self.tag_configure('pending', foreground='gray') - self.tag_configure('running', foreground='blue') - self.tag_configure('success', foreground='green') - self.tag_configure('failed', foreground='red') - self.tag_configure('error', foreground='red') - - def add_account(self, index: int, username: str, email: str): - """添加账号到表格""" - self.insert('', 'end', iid=str(index), values=( - index + 1, - username, - email, - '待登录', - '-' - ), tags=('pending',)) - - def update_status(self, index: int, status: str, cookie: str = ''): - """更新账号状态""" - status_map = { - 'pending': ('待登录', 'pending'), - 'running': ('登录中...', 'running'), - 'success': ('成功', 'success'), - 'failed': ('失败', 'failed'), - 'error': ('异常', 'error'), - } - - text, tag = status_map.get(status, (status, 'pending')) - - item_id = str(index) - if self.exists(item_id): - values = self.item(item_id, 'values') - cookie_display = cookie[:30] + '...' if len(cookie) > 30 else cookie - self.item(item_id, values=( - values[0], - values[1], - values[2], - text, - cookie_display if cookie else '-' - ), tags=(tag,)) - - def clear(self): - """清空表格""" - for item in self.get_children(): - self.delete(item) - - def get_all_items(self): - """获取所有项目""" - items = [] - for item_id in self.get_children(): - values = self.item(item_id, 'values') - items.append({ - 'index': values[0], - 'username': values[1], - 'email': values[2], - 'status': values[3], - 'cookie': values[4], - }) - return items - - -class LogPanel(tk.Text): - """日志输出面板""" - - def __init__(self, parent, **kwargs): - super().__init__( - parent, - wrap='word', - state='disabled', - font=('Consolas', 10), - **kwargs - ) - - # 日志级别颜色 - self.tag_configure('info', foreground='#333333') - self.tag_configure('success', foreground='#008000') - self.tag_configure('warning', foreground='#FF8C00') - self.tag_configure('error', foreground='#FF0000') - self.tag_configure('debug', foreground='#888888') - self.tag_configure('timestamp', foreground='#666666') - - # 添加滚动条 - scrollbar = ttk.Scrollbar(parent, orient='vertical', command=self.yview) - self.configure(yscrollcommand=scrollbar.set) - scrollbar.pack(side='right', fill='y') - - def append_log(self, level: str, message: str): - """添加日志""" - self.configure(state='normal') - - # 添加时间戳 - timestamp = datetime.now().strftime('%H:%M:%S') - self.insert('end', f'[{timestamp}] ', 'timestamp') - - # 添加日志内容 - self.insert('end', f'{message}\n', level) - - # 滚动到底部 - self.see('end') - - self.configure(state='disabled') - - def clear(self): - """清空日志""" - self.configure(state='normal') - self.delete('1.0', 'end') - self.configure(state='disabled') - - -class ImportDialog(tk.Toplevel): - """导入对话框""" - - def __init__(self, parent): - super().__init__(parent) - - self.title('从文件导入') - self.geometry('400x150') - self.resizable(False, False) - self.transient(parent) - self.grab_set() - - # 居中显示 - self.update_idletasks() - x = (self.winfo_screenwidth() - 400) // 2 - y = (self.winfo_screenheight() - 150) // 2 - self.geometry(f'+{x}+{y}') - - self.filepath = None - self.result = None - - # 文件路径 - frame = ttk.Frame(self, padding=20) - frame.pack(fill='both', expand=True) - - ttk.Label(frame, text='选择账号文件(每行一个,格式:用户名|密码|邮箱|邮箱密码)').pack(anchor='w') - - path_frame = ttk.Frame(frame) - path_frame.pack(fill='x', pady=(10, 0)) - - self.path_var = tk.StringVar() - ttk.Entry(path_frame, textvariable=self.path_var, state='readonly').pack(side='left', fill='x', expand=True) - ttk.Button(path_frame, text='浏览', command=self._browse).pack(side='left', padx=(5, 0)) - - # 按钮 - btn_frame = ttk.Frame(frame) - btn_frame.pack(fill='x', pady=(20, 0)) - - ttk.Button(btn_frame, text='确定', command=self._confirm).pack(side='right', padx=(5, 0)) - ttk.Button(btn_frame, text='取消', command=self._cancel).pack(side='right') - - def _browse(self): - """浏览文件""" - from tkinter import filedialog - filepath = filedialog.askopenfilename( - title='选择账号文件', - filetypes=[('文本文件', '*.txt'), ('所有文件', '*.*')] - ) - if filepath: - self.path_var.set(filepath) - self.filepath = filepath - - def _confirm(self): - """确认""" - if not self.filepath: - tk.messagebox.showwarning('提示', '请选择文件') - return - self.result = self.filepath - self.destroy() - - def _cancel(self): - """取消""" - self.destroy() diff --git a/main.py b/main.py deleted file mode 100644 index b2ace40..0000000 --- a/main.py +++ /dev/null @@ -1,165 +0,0 @@ -#!/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 - proxy_api_url = None - - if proxy.enabled: - if proxy.api_url: - # 使用代理API - proxy_api_url = proxy.api_url - elif proxy.http or proxy.https: - # 使用静态代理 - proxy_url = { - 'http': proxy.http or proxy.https, - 'https': proxy.https or proxy.http, - } - - # 获取极验配置 - geetest_config = config.get_geetest_config() - max_retries = geetest_config.get('max_retries', 5) - - loginer = DouyuLogin( - account, - proxy=proxy_url, - proxy_api_url=proxy_api_url, - max_geetest_retries=max_retries, - ) - - # 执行登录 - 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('-g', '--gui', action='store_true', help='启动GUI界面') - parser.add_argument('-w', '--web', action='store_true', help='启动Web后台') - 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) - - # 启动GUI - if args.gui: - from gui import DouyuLoginApp - app = DouyuLoginApp() - app.run() - return - - # 启动Web后台 - if args.web: - from web.backend.main import run - run() - return - - # 加载配置 - 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 index 129355d..0653414 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "douyu-login-py" -version = "0.1.0" -description = "斗鱼自动登录工具 - Python版" +version = "0.2.0" +description = "斗鱼批量登录 Web 后台" readme = "README.md" requires-python = ">=3.12,<3.13" dependencies = [ @@ -10,7 +10,6 @@ dependencies = [ "numpy>=1.24.0", "opencv-python-headless>=4.8.0", "Pillow>=10.0.0", - "PyYAML>=6.0", "loguru>=0.7.0", "fastapi>=0.110.0", "uvicorn[standard]>=0.27.0", @@ -21,12 +20,9 @@ dependencies = [ "python-multipart>=0.0.9", ] -[project.scripts] -douyu-login = "main:main" - [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["douyu", "geetest", "utils", "gui", "web"] +packages = ["core", "utils", "web"] diff --git a/requirements.txt b/requirements.txt index a4bdf8f..13fa0a9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,9 +3,7 @@ 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 -# Web 后端 fastapi>=0.110.0 uvicorn[standard]>=0.27.0 sqlalchemy>=2.0.0 diff --git a/slice.jpg b/slice.jpg deleted file mode 100644 index 843ba49..0000000 Binary files a/slice.jpg and /dev/null differ diff --git a/test_email_fetch.py b/test_email_fetch.py deleted file mode 100644 index 11781d6..0000000 --- a/test_email_fetch.py +++ /dev/null @@ -1,202 +0,0 @@ -#!/usr/bin/env python3 -"""邮箱IMAP读取测试脚本""" - -import argparse -import re -import sys -import time -from datetime import datetime, timedelta -from pathlib import Path - -# 添加项目根目录到path -sys.path.insert(0, str(Path(__file__).parent)) - -from douyu.config import Config -from douyu.email_verifier import EmailVerifier -from utils.logger import setup_logger - - -def mask_email(email_address: str) -> str: - """隐藏邮箱中间部分,避免控制台泄露完整账号。""" - if "@" not in email_address: - return email_address - - name, domain = email_address.split("@", 1) - if len(name) <= 2: - masked_name = name[0] + "*" - else: - masked_name = name[:2] + "*" * max(2, len(name) - 4) + name[-2:] - - return f"{masked_name}@{domain}" - - -def clean_preview(text: str, max_length: int = 120) -> str: - """清理正文预览,保持输出紧凑。""" - preview = " ".join(text.split()) - preview = re.sub(r"\b\d{6}\b", "******", preview) - if len(preview) <= max_length: - return preview - return preview[:max_length] + "..." - - -def format_code(code: str, show_code: bool) -> str: - """格式化验证码输出,默认隐藏大部分数字。""" - if not code: - return "未提取到" - if show_code: - return code - return f"****{code[-2:]}" - - -def format_message_time(verifier: EmailVerifier, msg) -> str: - """格式化邮件时间。""" - message_time = verifier._parse_message_time(msg) - if not message_time: - return "未知" - return message_time.strftime("%Y-%m-%d %H:%M:%S %z") - - -def load_account(config_path: str, index: int): - """从配置文件加载指定账号。""" - config = Config(config_path) - accounts = config.get_accounts() - if not accounts: - raise ValueError("config.yaml 中没有配置账号") - - if index < 0 or index >= len(accounts): - raise IndexError(f"账号索引超出范围: {index},当前共有 {len(accounts)} 个账号") - - return accounts[index] - - -def list_recent_messages( - verifier: EmailVerifier, - lookback_minutes: int, - limit: int, - show_code: bool, -) -> None: - """列出最近邮件,并测试斗鱼验证码解析。""" - verifier.connect() - - try: - status, _ = verifier._connection.select(verifier.mailbox, readonly=True) - if status != "OK": - raise RuntimeError(f"选择邮箱目录失败: {verifier.mailbox}") - - since_dt = datetime.now() - timedelta(minutes=lookback_minutes) - since = verifier._format_imap_date(since_dt) - status, messages = verifier._connection.search(None, "SINCE", since) - if status != "OK": - raise RuntimeError(f"搜索邮件失败: status={status}") - - message_ids = messages[0].split() if messages and messages[0] else [] - if not message_ids: - print(f"最近 {lookback_minutes} 分钟没有邮件") - return - - recent_ids = list(reversed(message_ids[-limit:])) - print(f"搜索范围: 最近 {lookback_minutes} 分钟") - print(f"匹配邮件数: {len(message_ids)},展示最新 {len(recent_ids)} 封\n") - - for index, message_id in enumerate(recent_ids, start=1): - msg = verifier._fetch_message(message_id) - if not msg: - print(f"[{index}] 读取失败: id={message_id.decode(errors='ignore')}") - continue - - subject = verifier._decode_subject(msg.get("Subject", "")) - from_addr = msg.get("From", "") - body = verifier._get_email_body(msg) - is_douyu = verifier._is_douyu_email(subject, from_addr, body) - code = verifier._extract_verification_code(body) if is_douyu else "" - - print(f"[{index}] 邮件ID: {message_id.decode(errors='ignore')}") - print(f" 时间: {format_message_time(verifier, msg)}") - print(f" 发件人: {from_addr}") - print(f" 主题: {subject}") - print(f" 斗鱼邮件: {'是' if is_douyu else '否'}") - print(f" 验证码: {format_code(code, show_code)}") - print(f" 正文预览: {clean_preview(body)}\n") - finally: - verifier.disconnect() - - -def wait_verification_code( - account, - max_wait: int, - interval: int, - new_only: bool, - show_code: bool, -) -> None: - """等待并提取斗鱼验证码,不负责发送验证码邮件。""" - verifier = EmailVerifier( - imap_server=account.email_imap_server, - imap_port=account.email_imap_port, - username=account.email, - password=account.email_password, - ) - - after_timestamp = time.time() if new_only else None - code = verifier.get_verification_code( - max_wait=max_wait, - interval=interval, - after_timestamp=after_timestamp, - ) - print(f"等待验证码结果: {format_code(code, show_code)}") - - -def parse_args() -> argparse.Namespace: - """解析命令行参数。""" - parser = argparse.ArgumentParser(description="测试邮箱IMAP是否能读取邮件和验证码") - parser.add_argument("-c", "--config", default="config.yaml", help="配置文件路径") - parser.add_argument("-i", "--index", type=int, default=0, help="账号索引") - parser.add_argument("--lookback-minutes", type=int, default=30, help="读取最近多少分钟邮件") - parser.add_argument("--limit", type=int, default=5, help="展示最近多少封邮件") - parser.add_argument("--wait-code", action="store_true", help="额外等待并提取斗鱼验证码") - parser.add_argument("--new-only", action="store_true", help="等待验证码时只接受脚本启动后的新邮件") - parser.add_argument("--show-code", action="store_true", help="输出完整验证码") - parser.add_argument("--max-wait", type=int, default=60, help="等待验证码最大秒数") - parser.add_argument("--interval", type=int, default=2, help="验证码轮询间隔秒数") - parser.add_argument("--log-level", default="INFO", help="日志级别") - return parser.parse_args() - - -def main() -> None: - """脚本入口。""" - args = parse_args() - setup_logger(level=args.log_level) - - account = load_account(args.config, args.index) - print("=== 邮箱IMAP读取测试 ===") - print(f"账号索引: {args.index}") - print(f"邮箱账号: {mask_email(account.email)}") - print(f"IMAP服务器: {account.email_imap_server}:{account.email_imap_port}\n") - - verifier = EmailVerifier( - imap_server=account.email_imap_server, - imap_port=account.email_imap_port, - username=account.email, - password=account.email_password, - lookback_minutes=args.lookback_minutes, - max_messages=args.limit, - ) - list_recent_messages( - verifier=verifier, - lookback_minutes=args.lookback_minutes, - limit=args.limit, - show_code=args.show_code, - ) - - if args.wait_code: - print("=== 等待斗鱼验证码 ===") - wait_verification_code( - account=account, - max_wait=args.max_wait, - interval=args.interval, - new_only=args.new_only, - show_code=args.show_code, - ) - - -if __name__ == "__main__": - main() diff --git a/test_login.py b/test_login.py deleted file mode 100644 index 12c2840..0000000 --- a/test_login.py +++ /dev/null @@ -1,98 +0,0 @@ -#!/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 index 54a6ff8..1e576b6 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -1,6 +1,5 @@ """工具模块""" from .logger import setup_logger -from .helpers import load_accounts, save_cookie -__all__ = ["setup_logger", "load_accounts", "save_cookie"] +__all__ = ["setup_logger"] diff --git a/utils/helpers.py b/utils/helpers.py deleted file mode 100644 index 6a6bca9..0000000 --- a/utils/helpers.py +++ /dev/null @@ -1,95 +0,0 @@ -"""辅助工具函数""" - -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/web/backend/routers/__pycache__/accounts.cpython-312.pyc b/web/backend/routers/__pycache__/accounts.cpython-312.pyc index 228fd13..02e77bc 100644 Binary files a/web/backend/routers/__pycache__/accounts.cpython-312.pyc and b/web/backend/routers/__pycache__/accounts.cpython-312.pyc differ diff --git a/web/backend/routers/__pycache__/proxy.cpython-312.pyc b/web/backend/routers/__pycache__/proxy.cpython-312.pyc index 02e27d1..78be19e 100644 Binary files a/web/backend/routers/__pycache__/proxy.cpython-312.pyc and b/web/backend/routers/__pycache__/proxy.cpython-312.pyc differ diff --git a/web/backend/routers/accounts.py b/web/backend/routers/accounts.py index e814b2f..9295eee 100644 --- a/web/backend/routers/accounts.py +++ b/web/backend/routers/accounts.py @@ -70,7 +70,7 @@ def import_accounts( current: User = Depends(require_permission("account:import")), ): """批量导入账号。格式:用户名|密码|邮箱|邮箱密码""" - from douyu.email_verifier import get_email_config_for_account + from core.douyu.email_verifier import get_email_config_for_account accounts = [] skipped = 0 diff --git a/web/backend/routers/proxy.py b/web/backend/routers/proxy.py index 0e5a7ee..807a3c7 100644 --- a/web/backend/routers/proxy.py +++ b/web/backend/routers/proxy.py @@ -97,7 +97,7 @@ def test_whitelist( current: User = Depends(require_permission("whitelist:test")), ): """测试白名单连接并自动同步出口IP。""" - from douyu.whitelist import WhitelistManager, get_exit_ip_via_proxy + from core.douyu.whitelist import WhitelistManager, get_exit_ip_via_proxy import requests as req_lib cfg = _get_or_create(db) diff --git a/web/backend/services/__pycache__/login_service.cpython-312.pyc b/web/backend/services/__pycache__/login_service.cpython-312.pyc index 886196a..65207c9 100644 Binary files a/web/backend/services/__pycache__/login_service.cpython-312.pyc and b/web/backend/services/__pycache__/login_service.cpython-312.pyc differ diff --git a/web/backend/services/login_service.py b/web/backend/services/login_service.py index 5cfba3a..516da12 100644 --- a/web/backend/services/login_service.py +++ b/web/backend/services/login_service.py @@ -9,8 +9,8 @@ from typing import Optional from sqlalchemy.orm import Session -from douyu import DouyuLogin -from douyu.config import Account, ProxyConfig as DouyuProxyConfig +from core.douyu import DouyuLogin +from core.models import Account, ProxyConfig as DouyuProxyConfig from ..models import Account as AccountModel, LoginTask, ProxyConfig as ProxyConfigModel from ..permissions import has_permission