1191 lines
44 KiB
Python
1191 lines
44 KiB
Python
"""主窗口模块"""
|
||
|
||
import json
|
||
import re
|
||
import threading
|
||
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
|
||
|
||
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',
|
||
'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.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))
|
||
|
||
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('<<ComboboxSelected>>', 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=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 _get_proxy_config(self) -> ProxyConfig:
|
||
"""获取当前代理配置"""
|
||
enabled = self.proxy_enabled_var.get()
|
||
proxy_type = self.proxy_type_var.get()
|
||
|
||
if proxy_type == 'api':
|
||
return ProxyConfig(
|
||
enabled=enabled,
|
||
api_url=self.proxy_api_var.get().strip(),
|
||
http='',
|
||
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=http,
|
||
https=https,
|
||
)
|
||
|
||
@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():
|
||
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,
|
||
]
|
||
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):
|
||
"""开始批量登录"""
|
||
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.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()
|
||
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.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()
|