优化 gui 不急

This commit is contained in:
yml2213
2026-06-22 10:10:51 +08:00
parent 220fcd4052
commit 66b03a7d89
8 changed files with 1087 additions and 153 deletions
+3 -1
View File
@@ -1,2 +1,4 @@
.venv
*/__pycache__
*/__pycache__
/__pycache__
data/gui_state.json
+27 -8
View File
@@ -9,6 +9,7 @@
- ✅ 批量账号登录
- ✅ Cookie持久化存储
- ✅ 代理支持
- ✅ GUI导入账号、自动保存配置、代理测试
## 项目结构
@@ -16,7 +17,7 @@
douyu_login_py/
├── main.py # 主入口
├── config.yaml # 配置文件
├── requirements.txt # 依赖
├── pyproject.toml # uv项目配置
├── douyu/ # 斗鱼登录模块
│ ├── config.py # 配置管理
│ ├── crypto.py # 加密工具
@@ -39,12 +40,30 @@ douyu_login_py/
## 安装依赖
```bash
pip install -r requirements.txt
uv sync
```
## 配置
## GUI使用
编辑 `config.yaml`
GUI不再依赖 `config.yaml` 启动,账号数据在界面里导入,代理、极验重试、日志等级和账号列表会自动保存到 `data/gui_state.json`
```bash
uv run python main.py --gui
```
支持导入格式:
```text
用户名|密码|邮箱|邮箱密码
```
也可以导入 `txt``csv``json``yaml` 文件。旧版 `config.yaml` 可直接在 GUI 中作为 YAML 文件导入,用来迁移账号数据。
代理区域支持 API 获取和静态代理,填写后可以点击“测试代理”验证出口连通性。开始批量登录时也会先进行代理预检,只有拿到可用出口后才会继续登录流程。
## 命令行配置
命令行模式仍使用 `config.yaml`
```yaml
accounts:
@@ -62,22 +81,22 @@ accounts:
```bash
# 登录第一个账号
python main.py
uv run python main.py
# 登录指定索引的账号
python main.py -i 0
uv run python main.py -i 0
```
### 批量登录
```bash
python main.py --batch
uv run python main.py --batch
```
### 详细日志
```bash
python main.py -v
uv run python main.py -v
```
## 登录流程
+185
View File
@@ -0,0 +1,185 @@
"""账号导入工具。"""
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,
)
+718 -138
View File
File diff suppressed because it is too large Load Diff
+37 -4
View File
@@ -1,5 +1,6 @@
"""登录工作线程模块"""
import csv
import re
import threading
import time
@@ -13,6 +14,13 @@ 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:
@@ -29,6 +37,27 @@ def _is_ascii(value: str) -> bool:
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]:
"""
解析账号文本
@@ -45,7 +74,10 @@ def parse_accounts_text(text: str) -> list[Account]:
if not line or line.startswith('#'):
continue
parts = line.split('|')
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
@@ -117,13 +149,14 @@ class LoginWorker(threading.Thread):
proxy_api_url = None
if self.proxy_config and self.proxy_config.enabled:
if self.proxy_config.api_url:
proxy_api_url = self.proxy_config.api_url
elif self.proxy_config.http or self.proxy_config.https:
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(
+115
View File
@@ -0,0 +1,115 @@
"""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(),
)
+1 -1
View File
@@ -129,7 +129,7 @@ def main():
# 启动GUI
if args.gui:
from gui import DouyuLoginApp
app = DouyuLoginApp(config_path=args.config)
app = DouyuLoginApp()
app.run()
return
+1 -1
View File
@@ -22,4 +22,4 @@ requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["douyu", "geetest", "utils"]
packages = ["douyu", "geetest", "utils", "gui"]