优化 gui 不急
This commit is contained in:
+3
-1
@@ -1,2 +1,4 @@
|
|||||||
.venv
|
.venv
|
||||||
*/__pycache__
|
*/__pycache__
|
||||||
|
/__pycache__
|
||||||
|
data/gui_state.json
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
- ✅ 批量账号登录
|
- ✅ 批量账号登录
|
||||||
- ✅ Cookie持久化存储
|
- ✅ Cookie持久化存储
|
||||||
- ✅ 代理支持
|
- ✅ 代理支持
|
||||||
|
- ✅ GUI导入账号、自动保存配置、代理测试
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@
|
|||||||
douyu_login_py/
|
douyu_login_py/
|
||||||
├── main.py # 主入口
|
├── main.py # 主入口
|
||||||
├── config.yaml # 配置文件
|
├── config.yaml # 配置文件
|
||||||
├── requirements.txt # 依赖
|
├── pyproject.toml # uv项目配置
|
||||||
├── douyu/ # 斗鱼登录模块
|
├── douyu/ # 斗鱼登录模块
|
||||||
│ ├── config.py # 配置管理
|
│ ├── config.py # 配置管理
|
||||||
│ ├── crypto.py # 加密工具
|
│ ├── crypto.py # 加密工具
|
||||||
@@ -39,12 +40,30 @@ douyu_login_py/
|
|||||||
## 安装依赖
|
## 安装依赖
|
||||||
|
|
||||||
```bash
|
```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
|
```yaml
|
||||||
accounts:
|
accounts:
|
||||||
@@ -62,22 +81,22 @@ accounts:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 登录第一个账号
|
# 登录第一个账号
|
||||||
python main.py
|
uv run python main.py
|
||||||
|
|
||||||
# 登录指定索引的账号
|
# 登录指定索引的账号
|
||||||
python main.py -i 0
|
uv run python main.py -i 0
|
||||||
```
|
```
|
||||||
|
|
||||||
### 批量登录
|
### 批量登录
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python main.py --batch
|
uv run python main.py --batch
|
||||||
```
|
```
|
||||||
|
|
||||||
### 详细日志
|
### 详细日志
|
||||||
|
|
||||||
```bash
|
```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,
|
||||||
|
)
|
||||||
+718
-138
File diff suppressed because it is too large
Load Diff
+37
-4
@@ -1,5 +1,6 @@
|
|||||||
"""登录工作线程模块"""
|
"""登录工作线程模块"""
|
||||||
|
|
||||||
|
import csv
|
||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -13,6 +14,13 @@ from douyu.email_verifier import get_email_config_for_account
|
|||||||
|
|
||||||
|
|
||||||
EMAIL_PATTERN = re.compile(r'^[^\s@|]+@[^\s@|]+\.[^\s@|]+$')
|
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:
|
def get_imap_server(email: str) -> str:
|
||||||
@@ -29,6 +37,27 @@ def _is_ascii(value: str) -> bool:
|
|||||||
return False
|
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]:
|
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('#'):
|
if not line or line.startswith('#'):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
parts = line.split('|')
|
parts = _split_account_line(line)
|
||||||
|
if line_num == 1 and _looks_like_header(parts):
|
||||||
|
continue
|
||||||
|
|
||||||
if len(parts) != 4:
|
if len(parts) != 4:
|
||||||
logger.warning(f"第{line_num}行格式错误,需要4个字段,实际{len(parts)}个: {line}")
|
logger.warning(f"第{line_num}行格式错误,需要4个字段,实际{len(parts)}个: {line}")
|
||||||
continue
|
continue
|
||||||
@@ -117,13 +149,14 @@ class LoginWorker(threading.Thread):
|
|||||||
proxy_api_url = None
|
proxy_api_url = None
|
||||||
|
|
||||||
if self.proxy_config and self.proxy_config.enabled:
|
if self.proxy_config and self.proxy_config.enabled:
|
||||||
if self.proxy_config.api_url:
|
if self.proxy_config.http or self.proxy_config.https:
|
||||||
proxy_api_url = self.proxy_config.api_url
|
# 优先使用GUI预检通过的代理,避免进入登录流程后再直接获取未验证代理。
|
||||||
elif self.proxy_config.http or self.proxy_config.https:
|
|
||||||
proxy_url = {
|
proxy_url = {
|
||||||
'http': self.proxy_config.http or self.proxy_config.https,
|
'http': self.proxy_config.http or self.proxy_config.https,
|
||||||
'https': self.proxy_config.https or self.proxy_config.http,
|
'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(
|
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
|
# 启动GUI
|
||||||
if args.gui:
|
if args.gui:
|
||||||
from gui import DouyuLoginApp
|
from gui import DouyuLoginApp
|
||||||
app = DouyuLoginApp(config_path=args.config)
|
app = DouyuLoginApp()
|
||||||
app.run()
|
app.run()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -22,4 +22,4 @@ requires = ["hatchling"]
|
|||||||
build-backend = "hatchling.build"
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["douyu", "geetest", "utils"]
|
packages = ["douyu", "geetest", "utils", "gui"]
|
||||||
|
|||||||
Reference in New Issue
Block a user