Files
live-hub-py/gui/app.py
T
2026-06-22 11:29:22 +08:00

1457 lines
56 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""主窗口模块"""
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, Optional
import requests
from loguru import logger
from douyu.config import Account, ProxyConfig
from douyu.whitelist import WhitelistManager, get_exit_ip_via_proxy
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.whitelist_test_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))
# 白名单配置
whitelist_frame = ttk.LabelFrame(parent, text='白名单管理', padding=8)
whitelist_frame.pack(fill='x', pady=(0, 8))
whitelist_top_frame = ttk.Frame(whitelist_frame)
whitelist_top_frame.pack(fill='x')
self.whitelist_enabled_var = tk.BooleanVar(value=self.proxy_config.whitelist_enabled)
ttk.Checkbutton(whitelist_top_frame, text='启用白名单自动管理',
variable=self.whitelist_enabled_var,
command=self._on_whitelist_toggle).pack(side='left')
self.whitelist_test_btn = ttk.Button(whitelist_top_frame, text='测试连接',
command=self._test_whitelist_connection)
self.whitelist_test_btn.pack(side='right')
# UID
uid_frame = ttk.Frame(whitelist_frame)
uid_frame.pack(fill='x', pady=(5, 0))
ttk.Label(uid_frame, text='UID:').pack(side='left')
self.whitelist_uid_var = tk.StringVar(value=self.proxy_config.whitelist_uid)
self.whitelist_uid_entry = ttk.Entry(uid_frame, textvariable=self.whitelist_uid_var, width=20)
self.whitelist_uid_entry.pack(side='left', fill='x', expand=True, padx=(5, 0))
# UKEY
ukey_frame = ttk.Frame(whitelist_frame)
ukey_frame.pack(fill='x', pady=(5, 0))
ttk.Label(ukey_frame, text='UKEY:').pack(side='left')
self.whitelist_ukey_var = tk.StringVar(value=self.proxy_config.whitelist_ukey)
self.whitelist_ukey_entry = ttk.Entry(ukey_frame, textvariable=self.whitelist_ukey_var, width=28)
self.whitelist_ukey_entry.pack(side='left', fill='x', expand=True, padx=(5, 0))
self.whitelist_status_var = tk.StringVar(value='未配置')
ttk.Label(whitelist_frame, textvariable=self.whitelist_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()
self._on_whitelist_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 _on_whitelist_toggle(self):
"""白名单启用/禁用切换"""
enabled = self.whitelist_enabled_var.get()
if hasattr(self, 'whitelist_uid_entry'):
state = 'normal' if enabled else 'disabled'
self.whitelist_uid_entry.configure(state=state)
self.whitelist_ukey_entry.configure(state=state)
self.whitelist_test_btn.configure(state=state)
if not enabled:
self.whitelist_status_var.set('未启用')
else:
self.whitelist_status_var.set('已启用,请填写UID和UKEY')
self._schedule_save_state()
def _test_whitelist_connection(self):
"""测试白名单API连接并将出口IP同步到白名单"""
uid = self.whitelist_uid_var.get().strip()
ukey = self.whitelist_ukey_var.get().strip()
if not uid or not ukey:
messagebox.showwarning('提示', '请填写UID和UKEY')
return
self.whitelist_test_btn.configure(state='disabled')
self.whitelist_status_var.set('测试中...')
self.log_panel.append_log('info', '测试白名单API连接...')
proxy_config = self._get_proxy_config()
def run_test():
try:
manager = WhitelistManager(uid, ukey)
success, api_msg = manager.test_connection()
self.log_queue.put(('info' if success else 'error', f'白名单API: {api_msg}'))
if not success:
self.whitelist_test_queue.put({'status': '连接失败'})
return
self.log_queue.put(('info', '正在获取出口IP...'))
exit_ip = self._get_exit_ip_for_test(proxy_config)
if not exit_ip:
self.log_queue.put(('warning', '无法获取出口IP'))
self.whitelist_test_queue.put({'status': '无法获取出口IP'})
return
self.log_queue.put(('info', f'出口IP: {exit_ip}'))
records = manager.get_whitelist_json()
in_list = any(r.get('IP') == exit_ip for r in records)
self.log_queue.put(('info', f'白名单共 {len(records)} 条记录'))
if in_list:
record = next((r for r in records if r.get('IP') == exit_ip), {})
memo = record.get('MEMO', '')
if memo == manager.memo:
self.log_queue.put(('success', f'出口IP {exit_ip} 已在白名单中 (备注正确)'))
self.whitelist_test_queue.put({'status': f'{exit_ip} 已在白名单中'})
else:
self.log_queue.put(('warning', f'出口IP {exit_ip} 已存在但备注不匹配 (当前: "{memo}"),正在更新...'))
sync_ok, sync_msg = manager.sync_ip(exit_ip)
if sync_ok:
self.log_queue.put(('success', f'白名单备注已更新: {sync_msg}'))
self.whitelist_test_queue.put({'status': f'{exit_ip} 备注已更新'})
else:
self.log_queue.put(('error', f'白名单更新失败: {sync_msg}'))
self.whitelist_test_queue.put({'status': '更新失败'})
else:
self.log_queue.put(('info', f'正在将 {exit_ip} 添加到白名单...'))
sync_ok, sync_msg = manager.sync_ip(exit_ip)
if sync_ok:
self.log_queue.put(('success', f'白名单同步: {sync_msg}'))
self.whitelist_test_queue.put({'status': f'{exit_ip} 已同步'})
else:
self.log_queue.put(('error', f'白名单同步失败: {sync_msg}'))
self.whitelist_test_queue.put({'status': '同步失败'})
except Exception as e:
self.log_queue.put(('error', f'白名单测试失败: {e}'))
self.whitelist_test_queue.put({'status': '测试失败'})
thread = threading.Thread(target=run_test, daemon=True)
thread.start()
def _on_whitelist_test_complete(self, result: dict):
"""处理白名单测试结果。"""
status = str(result.get('status') or '完成')
enabled = self.whitelist_enabled_var.get()
self.whitelist_status_var.set(status)
self.whitelist_test_btn.configure(state='normal' if enabled else 'disabled')
@staticmethod
def _get_exit_ip_for_test(proxy_config: ProxyConfig) -> Optional[str]:
"""获取出口IP用于白名单测试"""
proxy_url = None
if proxy_config.enabled:
if proxy_config.api_url:
try:
resp = requests.get(proxy_config.api_url, timeout=10)
match = re.search(r'(\d+\.\d+\.\d+\.\d+):(\d+)', resp.text)
if match:
proxy_url = f"http://{match.group(1)}:{match.group(2)}"
except Exception:
pass
if not proxy_url:
proxy_url = proxy_config.http or proxy_config.https
if proxy_url:
return get_exit_ip_via_proxy(proxy_url)
# 没有代理时,用本机网络获取出口IP(多个探测源fallback)
detect_urls = [
'https://qifu-api.baidubce.com/ip/local/geo/v1/district',
'https://myip.ipip.net',
'https://4.ipw.cn',
]
for url in detect_urls:
try:
resp = requests.get(url, timeout=6, headers={'User-Agent': 'Mozilla/5.0'})
text = resp.text.strip()
match = re.search(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})', text)
if match:
return match.group(1)
except Exception:
continue
return None
def _sync_whitelist(self, proxy_config: ProxyConfig) -> bool:
"""
同步白名单IP
获取当前代理出口IP并同步到白名单
"""
if not proxy_config.whitelist_enabled:
return True
uid = proxy_config.whitelist_uid
ukey = proxy_config.whitelist_ukey
if not uid or not ukey:
self.log_queue.put(('warning', '白名单未配置UID/UKEY,跳过同步'))
return True
# 获取代理地址
proxy_url = None
if proxy_config.http or proxy_config.https:
proxy_url = proxy_config.http or proxy_config.https
elif proxy_config.api_url:
# 从API获取代理
try:
self.log_queue.put(('info', '从代理API获取IP用于白名单同步...'))
response = requests.get(proxy_config.api_url, timeout=10)
response.raise_for_status()
text = response.text.strip()
match = re.search(r'(\d+\.\d+\.\d+\.\d+):(\d+)', text)
if match:
proxy_url = f"http://{match.group(1)}:{match.group(2)}"
except Exception as e:
self.log_queue.put(('error', f'获取代理失败: {e}'))
return False
if not proxy_url:
self.log_queue.put(('warning', '无法获取代理地址,跳过白名单同步'))
return True
# 获取出口IP
self.log_queue.put(('info', f'通过代理获取出口IP...'))
exit_ip = get_exit_ip_via_proxy(proxy_url)
if not exit_ip:
self.log_queue.put(('error', '无法获取出口IP,白名单同步失败'))
return False
self.log_queue.put(('info', f'当前出口IP: {exit_ip}'))
# 同步白名单
manager = WhitelistManager(uid, ukey)
success, message = manager.sync_ip(exit_ip)
if success:
self.log_queue.put(('success', f'白名单同步: {message}'))
else:
self.log_queue.put(('error', f'白名单同步: {message}'))
return success
def _get_proxy_config(self) -> ProxyConfig:
"""获取当前代理配置"""
enabled = self.proxy_enabled_var.get()
proxy_type = self.proxy_type_var.get()
# 白名单配置
whitelist_enabled = self.whitelist_enabled_var.get() if hasattr(self, 'whitelist_enabled_var') else False
whitelist_uid = self.whitelist_uid_var.get().strip() if hasattr(self, 'whitelist_uid_var') else ''
whitelist_ukey = self.whitelist_ukey_var.get().strip() if hasattr(self, 'whitelist_ukey_var') else ''
if proxy_type == 'api':
return ProxyConfig(
enabled=enabled,
api_url=self.proxy_api_var.get().strip(),
http='',
https='',
whitelist_enabled=whitelist_enabled,
whitelist_uid=whitelist_uid,
whitelist_ukey=whitelist_ukey,
)
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,
whitelist_enabled=whitelist_enabled,
whitelist_uid=whitelist_uid,
whitelist_ukey=whitelist_ukey,
)
@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,
self.whitelist_enabled_var,
self.whitelist_uid_var,
self.whitelist_ukey_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,
'whitelist_enabled': proxy_config.whitelist_enabled,
'whitelist_uid': proxy_config.whitelist_uid,
'whitelist_ukey': proxy_config.whitelist_ukey,
},
'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.whitelist_enabled and not proxy_config.api_url:
if not self._sync_whitelist(proxy_config):
messagebox.showwarning('提示', '白名单同步失败,请检查配置')
return
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
# API代理模式:同步白名单(使用已验证的代理地址,避免API返回不同代理导致的不一致)
if proxy_config.whitelist_enabled and proxy_config.api_url and result.get('success'):
synced_config = result.get('proxy_config')
if synced_config:
self._sync_whitelist(synced_config)
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.whitelist_test_queue.get_nowait()
self._on_whitelist_test_complete(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()