From 66b03a7d89494d3a3305942b738b3ac3fa1dcbd3 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 22 Jun 2026 10:10:51 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=20gui=20=E4=B8=8D=E6=80=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +- README.md | 35 +- gui/account_importer.py | 185 +++++++++ gui/app.py | 856 +++++++++++++++++++++++++++++++++------- gui/login_worker.py | 41 +- gui/state.py | 115 ++++++ main.py | 2 +- pyproject.toml | 2 +- 8 files changed, 1087 insertions(+), 153 deletions(-) create mode 100644 gui/account_importer.py create mode 100644 gui/state.py diff --git a/.gitignore b/.gitignore index df989ca..c83e6b9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .venv -*/__pycache__ \ No newline at end of file +*/__pycache__ +/__pycache__ +data/gui_state.json diff --git a/README.md b/README.md index 427f0a2..f34536d 100644 --- a/README.md +++ b/README.md @@ -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 ``` ## 登录流程 diff --git a/gui/account_importer.py b/gui/account_importer.py new file mode 100644 index 0000000..c834e9c --- /dev/null +++ b/gui/account_importer.py @@ -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, + ) diff --git a/gui/app.py b/gui/app.py index f9eb4cf..1dd6677 100644 --- a/gui/app.py +++ b/gui/app.py @@ -1,6 +1,7 @@ """主窗口模块""" import json +import re import threading import tkinter as tk from tkinter import ttk, messagebox, filedialog @@ -9,15 +10,26 @@ from pathlib import Path from queue import Queue, Empty from typing import List +import requests from loguru import logger from douyu.config import Account, ProxyConfig 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', @@ -32,28 +44,44 @@ LOG_LEVEL_TAGS = { class DouyuLoginApp: """斗鱼批量登录GUI应用""" - def __init__(self, config_path: str = 'config.yaml'): + 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.accounts: List[Account] = [] + 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.proxy_config = self._load_proxy_config() + 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('900x700') - self.root.minsize(800, 600) + self.root.geometry('1180x820') + self.root.minsize(1040, 820) + self._content_split_initialized = False # 设置图标(如果存在) try: @@ -63,47 +91,34 @@ class DouyuLoginApp: # 创建界面 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_config(self) -> ProxyConfig: - """从配置文件加载代理配置""" - try: - import yaml - with open(self.config_path, 'r', encoding='utf-8') as f: - config = yaml.safe_load(f) - proxy = config.get('proxy', {}) - return ProxyConfig( - enabled=proxy.get('enabled', False), - api_url=proxy.get('api_url', ''), - http=proxy.get('http', ''), - https=proxy.get('https', ''), - ) - except: - return ProxyConfig() + 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: - import yaml - with open(self.config_path, 'r', encoding='utf-8') as f: - config = yaml.safe_load(f) - geetest = config.get('geetest', {}) - return geetest.get('max_retries', 5) - except: + 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: - """从配置文件加载默认日志等级。""" - try: - import yaml - with open(self.config_path, 'r', encoding='utf-8') as f: - config = yaml.safe_load(f) - level = config.get('log', {}).get('level', 'INFO').upper() - return level if level in LOG_LEVELS else 'INFO' - except: - return 'INFO' + """加载默认日志等级。""" + 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日志输出等级。""" @@ -138,59 +153,88 @@ class DouyuLoginApp: # 主容器 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) - # 顶部:导入区域 - self._create_import_section(main_frame) + # 左侧:导入、配置和操作 + 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) - # 配置区域:代理和极验 - self._create_config_section(main_frame) + # 右侧:账号列表和日志 + workspace = ttk.Frame(main_frame) + workspace.grid(row=0, column=1, sticky='nsew') - # 中部:账号列表 - self._create_table_section(main_frame) + self._create_workspace_section(workspace) + self._reload_accounts_table() + self.root.after(120, self._set_initial_content_split) - # 底部:按钮和日志 - self._create_bottom_section(main_frame) + 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=10) - frame.pack(fill='x', pady=(0, 10)) + frame = ttk.LabelFrame(parent, text='账号数据导入', padding=8) + frame.pack(fill='x', pady=(0, 8)) # 文本输入框 - self.import_text = tk.Text(frame, height=5, font=('Consolas', 10)) + 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) - ttk.Button(btn_frame, text='解析导入', command=self._parse_import).pack(side='left') - ttk.Button(btn_frame, text='从文件导入', command=self._import_from_file).pack(side='left', padx=(5, 0)) - ttk.Button(btn_frame, text='清空', command=self._clear_import).pack(side='left', padx=(5, 0)) + 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(btn_frame, text='示例:用户9006787894|a778899|jtmjcm@bdhg.xyz|www123', - foreground='gray').pack(side='right') + ttk.Label( + frame, + text='支持 txt/csv/json/yaml\n格式:用户名|密码|邮箱|邮箱密码', + foreground='gray', + ).pack(anchor='w', pady=(2, 0)) def _create_config_section(self, parent): - """创建配置区域(代理和极验)""" - config_frame = ttk.Frame(parent) - config_frame.pack(fill='x', pady=(0, 10)) - - # 代理配置 - proxy_frame = ttk.LabelFrame(config_frame, text='代理配置', padding=10) - proxy_frame.pack(side='left', fill='both', expand=True, padx=(0, 5)) + """创建配置区域。""" + 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_frame, text='启用代理', variable=self.proxy_enabled_var, - command=self._on_proxy_toggle).pack(anchor='w') + 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='api' if self.proxy_config.api_url else 'static') + 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, @@ -202,103 +246,182 @@ class DouyuLoginApp: 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=50) + 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:').pack(side='left') + 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.pack(side='left', padx=(5, 10)) + self.proxy_http_entry.grid(row=0, column=1, sticky='ew', padx=(5, 0), pady=(0, 4)) - ttk.Label(static_frame, text='HTTPS:').pack(side='left') + 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.pack(side='left', padx=(5, 0)) + self.proxy_https_entry.grid(row=1, column=1, sticky='ew', padx=(5, 0)) - # 右侧配置:极验和日志 - side_frame = ttk.Frame(config_frame) - side_frame.pack(side='right', fill='y', 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)) - # 极验配置 - geetest_frame = ttk.LabelFrame(side_frame, text='极验配置', padding=10) - geetest_frame.pack(fill='x') + runtime_frame = ttk.LabelFrame(parent, text='运行设置', padding=8) + runtime_frame.pack(fill='x', pady=(0, 8)) + runtime_frame.columnconfigure(1, weight=1) - ttk.Label(geetest_frame, text='最大重试:').pack(anchor='w') + 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(geetest_frame, from_=1, to=20, width=5, + retries_spin = ttk.Spinbox(runtime_frame, from_=1, to=20, width=5, textvariable=self.geetest_retries_var) - retries_spin.pack(anchor='w', pady=(5, 0)) + retries_spin.grid(row=0, column=1, sticky='w', padx=(8, 0), pady=(0, 8)) - # 日志配置 - log_frame = ttk.LabelFrame(side_frame, text='日志配置', padding=10) - log_frame.pack(fill='x', pady=(8, 0)) - - ttk.Label(log_frame, text='等级:').pack(anchor='w') + 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( - log_frame, + runtime_frame, values=LOG_LEVELS, textvariable=self.log_level_var, state='readonly', width=10, ) - log_level_combo.pack(anchor='w', pady=(5, 0)) + 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() + 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=10) - frame.pack(fill='both', expand=True, pady=(0, 10)) + 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_bottom_section(self, parent): - """创建底部区域""" - # 按钮栏 - btn_frame = ttk.Frame(parent) - btn_frame.pack(fill='x', pady=(0, 10)) + 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(btn_frame, text='开始批量登录', command=self._start_login) - self.start_btn.pack(side='left') + 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(btn_frame, text='停止', command=self._stop_login, state='disabled') - self.stop_btn.pack(side='left', padx=(5, 0)) + 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), + ) - ttk.Button(btn_frame, text='导出结果', command=self._export_results).pack(side='left', padx=(5, 0)) - ttk.Button(btn_frame, text='清空列表', command=self._clear_table).pack(side='left', padx=(5, 0)) - - # 进度标签 self.progress_var = tk.StringVar(value='就绪') - ttk.Label(btn_frame, textvariable=self.progress_var).pack(side='right') + 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=10) - log_frame.pack(fill='both', expand=True) + log_frame = ttk.LabelFrame(parent, text='日志输出', padding=8) + parent.add(log_frame, weight=2) - self.log_panel = LogPanel(log_frame) + 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() - state = 'normal' if enabled else 'disabled' + if hasattr(self, 'proxy_test_btn'): + self.proxy_test_btn.configure(state='normal' if enabled else 'disabled') - self.proxy_api_entry.configure(state=state) - self.proxy_http_entry.configure(state=state) - self.proxy_https_entry.configure(state=state) + 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') @@ -308,6 +431,7 @@ class DouyuLoginApp: 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 _get_proxy_config(self) -> ProxyConfig: """获取当前代理配置""" @@ -322,14 +446,210 @@ class DouyuLoginApp: https='', ) 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=self.proxy_http_var.get().strip(), - https=self.proxy_https_var.get().strip(), + http=http, + https=https, ) - def _parse_import(self): + @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 _fetch_proxy_from_api(api_url: str) -> str: + """从代理API获取一个代理地址。""" + response = requests.get(api_url, timeout=10) + response.raise_for_status() + text = response.text.strip() + match = re.search(r'(\d+\.\d+\.\d+\.\d+):(\d+)', text) + if not match: + return '' + return f'http://{match.group(1)}:{match.group(2)}' + + @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, + } + last_error = '' + + 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: + last_error = f'{name} 验证失败: {exc}' + + return False, last_error 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 = '' + + 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: + proxy_url = self._fetch_proxy_from_api(proxy_config.api_url) + except Exception as exc: + last_message = f'代理API请求失败: {exc}' + self.log_queue.put(('warning', f'代理预检 {attempt}/{attempts}: {last_message}')) + continue + + if not proxy_url: + last_message = '代理API未返回有效的 ip:port' + 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(): @@ -341,42 +661,50 @@ class DouyuLoginApp: messagebox.showwarning('提示', '未解析到有效账号') return - # 添加到列表 - for acc in accounts: - self.accounts.append(acc) - self.table.add_account(len(self.accounts) - 1, acc.username, acc.email) - - self.log_panel.append_log('info', f'成功导入 {len(accounts)} 个账号') - self.progress_var.set(f'共 {len(self.accounts)} 个账号') + 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(self): + 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='选择账号文件', - filetypes=[('文本文件', '*.txt'), ('CSV文件', '*.csv'), ('所有文件', '*.*')] + initialdir=initial_dir, + filetypes=[ + ('账号文件', '*.txt *.csv *.json *.yaml *.yml'), + ('文本文件', '*.txt'), + ('CSV文件', '*.csv'), + ('JSON文件', '*.json'), + ('YAML文件', '*.yaml *.yml'), + ('所有文件', '*.*'), + ] ) if not filepath: return try: - with open(filepath, 'r', encoding='utf-8') as f: - text = f.read() - - accounts = parse_accounts_text(text) + accounts = load_accounts_from_file(filepath) if not accounts: messagebox.showwarning('提示', '文件中未解析到有效账号') return - for acc in accounts: - self.accounts.append(acc) - self.table.add_account(len(self.accounts) - 1, acc.username, acc.email) - - self.log_panel.append_log('info', f'从文件导入 {len(accounts)} 个账号') - self.progress_var.set(f'共 {len(self.accounts)} 个账号') + 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}') @@ -385,9 +713,47 @@ class DouyuLoginApp: """清空导入框""" 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: + if self.is_running or self.is_preparing_login: messagebox.showwarning('提示', '登录进行中,无法清空') return @@ -396,6 +762,119 @@ class DouyuLoginApp: 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, + ] + 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, + }, + '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): """开始批量登录""" @@ -403,24 +882,101 @@ class DouyuLoginApp: messagebox.showwarning('提示', '请先导入账号') return - if self.is_running: + if self.is_running or self.is_preparing_login: return # 获取当前GUI配置 proxy_config = self._get_proxy_config() - geetest_retries = self.geetest_retries_var.get() + geetest_retries = self._get_geetest_retries() log_level = self.log_level_var.get().upper() self._configure_logging(log_level) # 验证代理配置 - if proxy_config.enabled: - if proxy_config.api_url and not proxy_config.api_url.startswith('http'): - messagebox.showwarning('提示', '代理API地址格式不正确,需要以http://或https://开头') - return - if not proxy_config.api_url and not proxy_config.http and not proxy_config.https: - messagebox.showwarning('提示', '请填写代理地址') - return + proxy_error = self._validate_proxy_config(proxy_config) + if proxy_error: + messagebox.showwarning('提示', proxy_error) + return + self._save_state() + + 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 + 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() @@ -463,6 +1019,14 @@ class DouyuLoginApp: 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 @@ -487,6 +1051,22 @@ class DouyuLoginApp: 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: diff --git a/gui/login_worker.py b/gui/login_worker.py index 79ba3cb..9b7d60e 100644 --- a/gui/login_worker.py +++ b/gui/login_worker.py @@ -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( diff --git a/gui/state.py b/gui/state.py new file mode 100644 index 0000000..d0f8ab2 --- /dev/null +++ b/gui/state.py @@ -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(), + ) diff --git a/main.py b/main.py index 3d62263..4204df2 100644 --- a/main.py +++ b/main.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index c2a7cf5..13ebb54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,4 +22,4 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["douyu", "geetest", "utils"] +packages = ["douyu", "geetest", "utils", "gui"]