From 63c1ffa04639532a98bb62c7ab20ea6d48dc4029 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 22 Jun 2026 13:39:03 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BD=BB=E5=BA=95=E4=BC=98=E5=8C=96=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E7=BB=93=E6=9E=84,=E4=BD=BF=E7=94=A8=20web?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 21 +- README.md | 166 +- __pycache__/main.cpython-312.pyc | Bin 5927 -> 0 bytes bg.jpg | Bin 6648 -> 0 bytes ck对比.md | 14 - config.yaml | 32 - core/__init__.py | 5 + {douyu => core/douyu}/__init__.py | 3 +- {douyu => core/douyu}/crypto.py | 0 {douyu => core/douyu}/email_verifier.py | 0 {douyu => core/douyu}/login.py | 12 +- {douyu => core/douyu}/proxy.py | 0 {douyu => core/douyu}/whitelist.py | 0 core/geetest/__init__.py | 5 + core/geetest/common/__init__.py | 1 + {geetest => core/geetest/common}/crypto.py | 0 {geetest => core/geetest/common}/imaging.py | 0 {geetest => core/geetest/common}/network.py | 0 .../geetest/common}/performance.py | 0 .../geetest/common}/trajectory.py | 0 .../geetest/v3_slide}/__init__.py | 2 +- {geetest => core/geetest/v3_slide}/solver.py | 10 +- core/models.py | 27 + data/web.db | Bin 36864 -> 36864 bytes douyu/config.py | 85 - fullbg.jpg | Bin 6349 -> 0 bytes gui/__init__.py | 13 - gui/account_importer.py | 185 -- gui/app.py | 1507 ----------------- gui/login_worker.py | 203 --- gui/state.py | 118 -- gui/widgets.py | 212 --- main.py | 165 -- pyproject.toml | 10 +- requirements.txt | 2 - slice.jpg | Bin 4030 -> 0 bytes test_email_fetch.py | 202 --- test_login.py | 98 -- utils/__init__.py | 3 +- utils/helpers.py | 95 -- .../__pycache__/accounts.cpython-312.pyc | Bin 7871 -> 7876 bytes .../routers/__pycache__/proxy.cpython-312.pyc | Bin 6779 -> 6784 bytes web/backend/routers/accounts.py | 2 +- web/backend/routers/proxy.py | 2 +- .../__pycache__/login_service.cpython-312.pyc | Bin 6945 -> 6949 bytes web/backend/services/login_service.py | 4 +- 46 files changed, 127 insertions(+), 3077 deletions(-) delete mode 100644 __pycache__/main.cpython-312.pyc delete mode 100644 bg.jpg delete mode 100644 ck对比.md delete mode 100644 config.yaml create mode 100644 core/__init__.py rename {douyu => core/douyu}/__init__.py (59%) rename {douyu => core/douyu}/crypto.py (100%) rename {douyu => core/douyu}/email_verifier.py (100%) rename {douyu => core/douyu}/login.py (98%) rename {douyu => core/douyu}/proxy.py (100%) rename {douyu => core/douyu}/whitelist.py (100%) create mode 100644 core/geetest/__init__.py create mode 100644 core/geetest/common/__init__.py rename {geetest => core/geetest/common}/crypto.py (100%) rename {geetest => core/geetest/common}/imaging.py (100%) rename {geetest => core/geetest/common}/network.py (100%) rename {geetest => core/geetest/common}/performance.py (100%) rename {geetest => core/geetest/common}/trajectory.py (100%) rename {geetest => core/geetest/v3_slide}/__init__.py (56%) rename {geetest => core/geetest/v3_slide}/solver.py (92%) create mode 100644 core/models.py delete mode 100644 douyu/config.py delete mode 100644 fullbg.jpg delete mode 100644 gui/__init__.py delete mode 100644 gui/account_importer.py delete mode 100644 gui/app.py delete mode 100644 gui/login_worker.py delete mode 100644 gui/state.py delete mode 100644 gui/widgets.py delete mode 100644 main.py delete mode 100644 slice.jpg delete mode 100644 test_email_fetch.py delete mode 100644 test_login.py delete mode 100644 utils/helpers.py 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 bcaa6e9b3c8093597ac23e69a6a0bbda402747b8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5927 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 D$v5Sv diff --git a/bg.jpg b/bg.jpg deleted file mode 100644 index c7108facc1c6374d60c92fc7e67d034add16f950..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6648 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 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 ecf8064d22fa4a83c7a613935a2b1a1e35a5124a..a8a724eda0613e9e82218fb599ece969599e1d1d 100644 GIT binary patch delta 368 zcmZozz|^pSX@WE(|3n#QM*fWnOZeFs`QsS)<2DN_gz!&}lb1Agb}vjycQ=Xf@i44% zjEpF%@JtUZ_Akitt0+sa(l?6CcQi;IG1GY;I*@pl4xZXlQ0&$TB(KR}~~=Y^i5zY-(g-V$VFe z-(OL|@X3xox&T#Uxcs{UBu<0g7mJlEPVc`D* S^wE2McA!Db%nY213KRecJ74ht delta 1164 zcmZ`%O-vI(7~P+uP~BZfR3umy6-<Re{8qLdO^;H z1hesGA|ZM)Mq>zxH%+_~V>}V{qy(>Cym)YywuoA;Z@%}vH}8A*mbrV&+)^Xk@ZeaK zZFqI{iR9mE!ge(K5gd0pw$b_=zud4E$#UBVrBEu;^x}@ zt(OlGKqLrA5TRzEg@GEzhN5Z)!Whl(^%Pn-%J~_2p-T3Qr88DG;iR)=XBj&--F5xa zkUcjBM!@Jmcif)9*6ps?Y@skTo$Rsfk^cORLXYKKGYjKAk?b8UZWou>>D1PnrLCo> z+upQ_=AaG$(kj{ZUR2^v$_-jU8?~L>xP5utN+q+=k?w)vbjq95z1bo{ z5Y~XABS1nSc+fp#wt5iO6|52isNlf%6bOY8QV75*M8IE$pspBzXix<;9wgH#=uN2x z3gPN%h7Hso79xDvs{7fjzX&iry@E*ysM`7c)l5zOX<3LQW|MX*F*B3Sr<`yim$TNB?yj4sj(UD6 zfRJhsL3+$^;szsbNN4Ho*2N=N*#nH+7z_kCR*38b<*rS^bNn8Qkq#4_$7+{@9P@{T zGBj?i5pN4}-%cH3k^6C~`9#S8!AQrnf{CKsE+V9Ion@ci81zfO7*6`(RrLog>ZT<6 Rc(#W3@eIq+6~2{B^KbI_IsE_t 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 962777be49b4740dcff65c1c2793df2e34b162b1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6349 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%0D30IcD zSmrc+LV2p~l+$YilEJt##WTmzqT^Y*8jtfmz4S!;c+@yzl{>i5SN*+nQq;I&!|w)9 zPiV|=Kc&biqWe;y`B@#^$q@sSh3l8Qw**LKeW)^KbD6r(VB3{gPWr6nCRgLZ?{(sE24SE^d>k~ zCb$l4Q2pqEDd7Z;(|P8_{t$gd1;NC)391)2l2&aBlC50*h+irwO-KeHqYu!hZ$_D! zq^ZUXRWrvM zxovuQGlO>aY}nu&digMXcmw)ns18nhcK)pG(HvVOPvzFyC|kM7+BU**@96$B;@ugF zekLL;Kooyi;R&5(o1?f-K6hwvoiM6+2c!)Qc`_Mc?--V*L?LKxklONK#X^UQY z-6@0eCCPW}92oYLV{)d&IiXZ_@Ie|(p+?(au}r}6v=Z&$D!wC3T3s$9cMM-QaoB7r z`joj&?Kvl}y&D|xjW8j8`C6qIO+Kj(qy{Cu?64ITUp6y0NbZiJw=rNOo6?No9p@IfujPe6 zSshO=(PwGi!-ve1U;(AO|A4T zC}E2)j%?X^JVqW6E4Jlm@#(seRbzqn;XTI@RJ=}>>i5)H!oDP|8+}_73yi8_0T%!5 z0OLy{NGuQs-*c#BugI_wCQN;RD+{O4&nB7Q-0XTge}Ku~9%#SuW!QW_BpR2cY_Y&p z!K{q|@@imI78@%Z)nORpi$uHFXrFOPPB)yb#{${o%rrHq6$Nrd z)K@vPwohGa8V$C0qGaZw9PbAb5vNL+;gugcnn;w}#4Pwi(8^X*q?O(+QpHsP(AnUE zJyq+E$=RE%Oj#f!Zyk0vhw^#M+ao_J5ZNSG`{_QJiys=7c zW|i^350j&af315{3!tsw-$hf%@0HTH>gpeq^!MorAxJ2e@ULt9R5UBtLEgkQ8uBr> z3gO!d-#QA^fM{40OqV{@KK;j3+44{@KeWw}MS?Cy4kH5Z9Zk@Tki=0t zqkF6A^B>J)ic6jR+q7opI~O=mv&!2(d!7b1dR0e6^cTFP%cV0!-Se(1)>^P`F&P#y|(UA3m4yG=il9g-hj5S#Oz z82z0Eeq7(&0vxt|gjD}?zv$;>|GYtdrdvNb=f7V7-}WN(y8b;d{J!Ew9eTvhGE#ny zzVG%?3HYdncffw>OQPAlsbVn>px>I0(5(7}HU8P#Td4vFBr$@pReVbn*D1WRs6_OG zVE#GPzYieTf(dVcvq;BLe8aS%1R$KoHv 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 843ba4944e496a0397d71581da509c2723ffaccf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4030 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 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 228fd13f54908a1d57c99b9776aeffd183a96a57..02e77bc765c4a8ce43d07e7f46d98856c2f498a3 100644 GIT binary patch delta 41 vcmdmQd&HLaG%qg~0}!|#v&ib$$QvTaCYhXHl&ZHmNzjy$(S7p_A$1-A`e_S% delta 36 qcmX?NyWf`gG%qg~0}#j^u*hoO$QvTaD6%&xO@H+~i delta 36 qcmZoL{cXZ~nwOW00SF=vSY)l+$jdFt$iG=mRG5*`W3!Q%9}57q2?)mk 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 886196ad6399d14ad4f8ae57410876b2da7c99fd..65207c987ee314c420af8f64b613ceaf56298c70 100644 GIT binary patch delta 89 zcmZ2zw$zOGG%qg~0}wbKv&bsg$m=a7%axp8l&Y7KUs_pO#0HeP#SIq7%}+_qDV|&? jH9