优化 gui 不急
This commit is contained in:
@@ -1,2 +1,4 @@
|
||||
.venv
|
||||
*/__pycache__
|
||||
/__pycache__
|
||||
data/gui_state.json
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
## 登录流程
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
+716
-136
File diff suppressed because it is too large
Load Diff
+37
-4
@@ -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
@@ -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(),
|
||||
)
|
||||
@@ -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
@@ -22,4 +22,4 @@ requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["douyu", "geetest", "utils"]
|
||||
packages = ["douyu", "geetest", "utils", "gui"]
|
||||
|
||||
Reference in New Issue
Block a user