389 lines
15 KiB
Python
389 lines
15 KiB
Python
import os
|
|
import json
|
|
import logging
|
|
import time
|
|
import threading
|
|
from concurrent.futures import ThreadPoolExecutor, Future
|
|
from typing import Dict, List, Optional, Any, Tuple
|
|
from datetime import datetime
|
|
from playwright.sync_api import sync_playwright, Browser, Page
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class BrowserManager:
|
|
"""管理浏览器实例和配置文件."""
|
|
|
|
def __init__(self, settings=None):
|
|
"""
|
|
初始化浏览器管理器.
|
|
|
|
Args:
|
|
settings: 应用程序设置
|
|
"""
|
|
self.settings = settings or {}
|
|
self.profiles_dir = self.settings.get(
|
|
"profiles_dir",
|
|
os.path.join(os.path.expanduser("~"), "douyin_imbot", "profiles"),
|
|
)
|
|
self.browsers: Dict[str, Browser] = {}
|
|
self.pages: Dict[str, Page] = {}
|
|
self.profile_details: Dict[str, Dict] = {}
|
|
self.playwright = None
|
|
self.executor = settings.get("browser_executor") if settings else None
|
|
self.running_tasks: Dict[str, Future] = {}
|
|
self.browser_locks: Dict[str, threading.Lock] = {}
|
|
self.load_profiles()
|
|
|
|
# 如果没有提供线程池,创建一个
|
|
if not self.executor:
|
|
max_workers = self.settings.get("browser_count", 3)
|
|
self.executor = ThreadPoolExecutor(max_workers=max_workers)
|
|
logger.info(f"已创建浏览器线程池,最大线程数: {max_workers}")
|
|
|
|
def load_profiles(self):
|
|
"""加载配置文件."""
|
|
try:
|
|
if not os.path.exists(self.profiles_dir):
|
|
os.makedirs(self.profiles_dir, exist_ok=True)
|
|
logger.info(f"创建配置文件目录: {self.profiles_dir}")
|
|
|
|
profiles_index_path = os.path.join(self.profiles_dir, "profiles.json")
|
|
if os.path.exists(profiles_index_path):
|
|
with open(profiles_index_path, "r", encoding="utf-8") as f:
|
|
self.profile_details = json.load(f)
|
|
logger.info(f"已加载 {len(self.profile_details)} 个配置文件")
|
|
else:
|
|
self.profile_details = {}
|
|
with open(profiles_index_path, "w", encoding="utf-8") as f:
|
|
json.dump({}, f)
|
|
logger.info("创建了空的配置文件索引")
|
|
except Exception as e:
|
|
logger.error(f"加载配置文件失败: {str(e)}")
|
|
self.profile_details = {}
|
|
|
|
def get_profiles(self):
|
|
"""获取所有配置文件."""
|
|
return list(self.profile_details.keys())
|
|
|
|
def get_profile_details(self, profile_name):
|
|
"""获取配置文件详情."""
|
|
return self.profile_details.get(profile_name, {})
|
|
|
|
def create_profile(self, profile_name, description=""):
|
|
"""创建新的配置文件."""
|
|
try:
|
|
if profile_name in self.profile_details:
|
|
logger.warning(f"配置文件 '{profile_name}' 已存在")
|
|
return False
|
|
|
|
# 创建配置目录
|
|
profile_dir = os.path.join(self.profiles_dir, profile_name)
|
|
os.makedirs(profile_dir, exist_ok=True)
|
|
|
|
# 记录配置信息
|
|
self.profile_details[profile_name] = {
|
|
"user_data_dir": profile_dir,
|
|
"created": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
"last_used": "",
|
|
"description": description,
|
|
}
|
|
|
|
# 保存配置索引
|
|
self._save_profiles_index()
|
|
logger.info(f"创建了配置文件 '{profile_name}'")
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"创建配置文件失败: {str(e)}")
|
|
return False
|
|
|
|
def delete_profile(self, profile_name):
|
|
"""删除配置文件."""
|
|
try:
|
|
if profile_name not in self.profile_details:
|
|
logger.warning(f"配置文件 '{profile_name}' 不存在")
|
|
return False
|
|
|
|
# 如果浏览器正在运行,先关闭
|
|
self._close_browser(profile_name)
|
|
|
|
# 从列表中移除
|
|
del self.profile_details[profile_name]
|
|
|
|
# 保存配置索引
|
|
self._save_profiles_index()
|
|
logger.info(f"删除了配置文件 '{profile_name}'")
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"删除配置文件失败: {str(e)}")
|
|
return False
|
|
|
|
def _save_profiles_index(self):
|
|
"""保存配置文件索引."""
|
|
try:
|
|
profiles_index_path = os.path.join(self.profiles_dir, "profiles.json")
|
|
with open(profiles_index_path, "w", encoding="utf-8") as f:
|
|
json.dump(self.profile_details, f, ensure_ascii=False, indent=4)
|
|
logger.debug("配置文件索引已更新")
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"保存配置文件索引失败: {str(e)}")
|
|
return False
|
|
|
|
def _close_browser(self, profile_name):
|
|
"""关闭特定配置的浏览器."""
|
|
try:
|
|
# 获取此配置的锁
|
|
if profile_name in self.browser_locks:
|
|
with self.browser_locks[profile_name]:
|
|
if profile_name in self.browsers:
|
|
if profile_name in self.pages:
|
|
# 先关闭页面
|
|
try:
|
|
self.pages[profile_name].close()
|
|
except Exception as e:
|
|
logger.error(f"关闭页面失败: {str(e)}")
|
|
del self.pages[profile_name]
|
|
|
|
# 关闭浏览器
|
|
try:
|
|
self.browsers[profile_name].close()
|
|
except Exception as e:
|
|
logger.error(f"关闭浏览器失败: {str(e)}")
|
|
del self.browsers[profile_name]
|
|
logger.info(f"已关闭配置 '{profile_name}' 的浏览器")
|
|
|
|
# 删除锁
|
|
del self.browser_locks[profile_name]
|
|
elif profile_name in self.browsers:
|
|
# 没有锁但有浏览器实例,直接关闭
|
|
if profile_name in self.pages:
|
|
try:
|
|
self.pages[profile_name].close()
|
|
except Exception:
|
|
pass
|
|
del self.pages[profile_name]
|
|
|
|
try:
|
|
self.browsers[profile_name].close()
|
|
except Exception:
|
|
pass
|
|
del self.browsers[profile_name]
|
|
logger.info(f"已关闭配置 '{profile_name}' 的浏览器(无锁)")
|
|
|
|
# 取消正在运行的任务
|
|
if profile_name in self.running_tasks:
|
|
self.running_tasks[profile_name].cancel()
|
|
del self.running_tasks[profile_name]
|
|
logger.info(f"已取消配置 '{profile_name}' 的运行任务")
|
|
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"关闭浏览器 '{profile_name}' 时出错: {str(e)}")
|
|
return False
|
|
|
|
def launch_browser(self, profile_name, headless=False, on_complete=None):
|
|
"""
|
|
启动特定配置的浏览器.
|
|
|
|
Args:
|
|
profile_name: 配置文件名称
|
|
headless: 是否以无头模式启动
|
|
on_complete: 浏览器启动完成后的回调函数
|
|
|
|
Returns:
|
|
Future对象表示异步任务
|
|
"""
|
|
if profile_name not in self.profile_details:
|
|
logger.error(f"配置文件 '{profile_name}' 不存在")
|
|
return None
|
|
|
|
# 先检查该配置文件是否已有浏览器实例在运行
|
|
if profile_name in self.browsers:
|
|
logger.warning(f"配置 '{profile_name}' 已有浏览器实例在运行")
|
|
if on_complete:
|
|
on_complete(
|
|
self.browsers[profile_name], self.pages.get(profile_name), False
|
|
)
|
|
return None
|
|
|
|
# 为这个配置创建一个锁
|
|
if profile_name not in self.browser_locks:
|
|
self.browser_locks[profile_name] = threading.Lock()
|
|
|
|
# 提交任务到线程池
|
|
task = self.executor.submit(
|
|
self._launch_browser_task, profile_name, headless, on_complete
|
|
)
|
|
self.running_tasks[profile_name] = task
|
|
logger.info(f"已提交浏览器启动任务: {profile_name}")
|
|
return task
|
|
|
|
def _launch_browser_task(self, profile_name, headless, on_complete):
|
|
"""在线程中执行的浏览器启动任务."""
|
|
logger.info(f"开始在线程中启动浏览器: {profile_name}")
|
|
success = False
|
|
browser = None
|
|
page = None
|
|
|
|
try:
|
|
# 获取锁,确保同一配置文件只启动一个浏览器实例
|
|
with self.browser_locks[profile_name]:
|
|
# 再次检查浏览器是否已启动(双重检查锁定模式)
|
|
if profile_name in self.browsers:
|
|
logger.warning(
|
|
f"配置 '{profile_name}' 在获取锁后已有浏览器实例运行"
|
|
)
|
|
browser = self.browsers[profile_name]
|
|
page = self.pages.get(profile_name)
|
|
success = True
|
|
else:
|
|
# 获取配置信息
|
|
profile_info = self.profile_details[profile_name]
|
|
user_data_dir = profile_info.get("user_data_dir")
|
|
|
|
if not user_data_dir or not os.path.exists(
|
|
os.path.dirname(user_data_dir)
|
|
):
|
|
logger.error(
|
|
f"配置 '{profile_name}' 的用户数据目录无效: {user_data_dir}"
|
|
)
|
|
return False
|
|
|
|
# 启动浏览器
|
|
try:
|
|
self.playwright = sync_playwright().start()
|
|
logger.info(f"正在启动配置 '{profile_name}' 的浏览器...")
|
|
browser = self.playwright.chromium.launch_persistent_context(
|
|
user_data_dir=user_data_dir,
|
|
headless=headless,
|
|
args=[
|
|
"--disable-blink-features=AutomationControlled",
|
|
"--no-sandbox",
|
|
"--disable-setuid-sandbox",
|
|
],
|
|
)
|
|
|
|
# 等待浏览器完全启动
|
|
time.sleep(1)
|
|
|
|
# 创建或获取页面
|
|
if len(browser.pages) > 0:
|
|
page = browser.pages[0]
|
|
else:
|
|
page = browser.new_page()
|
|
|
|
# 存储浏览器和页面实例
|
|
self.browsers[profile_name] = browser
|
|
self.pages[profile_name] = page
|
|
|
|
# 更新最后使用时间
|
|
self.profile_details[profile_name][
|
|
"last_used"
|
|
] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
self._save_profiles_index()
|
|
|
|
logger.info(f"配置 '{profile_name}' 的浏览器启动成功")
|
|
success = True
|
|
except Exception as e:
|
|
logger.error(f"启动浏览器失败: {str(e)}")
|
|
if self.playwright:
|
|
try:
|
|
self.playwright.stop()
|
|
except Exception:
|
|
pass
|
|
raise e
|
|
except Exception as e:
|
|
logger.error(f"在线程中启动浏览器时出错: {str(e)}")
|
|
finally:
|
|
# 如果设置了回调,通知浏览器启动结果
|
|
if on_complete:
|
|
on_complete(browser, page, success)
|
|
# 删除运行任务记录
|
|
if profile_name in self.running_tasks:
|
|
del self.running_tasks[profile_name]
|
|
return success, browser, page
|
|
|
|
def navigate_to_url(self, profile_name, url):
|
|
"""
|
|
导航到指定URL.
|
|
|
|
Args:
|
|
profile_name: 配置文件名称
|
|
url: 要导航到的URL
|
|
|
|
Returns:
|
|
成功返回True,失败返回False
|
|
"""
|
|
try:
|
|
if profile_name not in self.browsers or profile_name not in self.pages:
|
|
logger.error(f"配置 '{profile_name}' 的浏览器未启动")
|
|
return False
|
|
|
|
page = self.pages[profile_name]
|
|
logger.info(f"浏览器 '{profile_name}' 导航到: {url}")
|
|
page.goto(url, wait_until="networkidle", timeout=60000)
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"导航到URL时出错: {str(e)}")
|
|
return False
|
|
|
|
def launch_multiple_browsers(
|
|
self, profile_names, headless=False, on_each_complete=None, on_all_complete=None
|
|
):
|
|
"""
|
|
并行启动多个浏览器.
|
|
|
|
Args:
|
|
profile_names: 配置文件名称列表
|
|
headless: 是否以无头模式启动
|
|
on_each_complete: 每个浏览器启动完成后的回调函数
|
|
on_all_complete: 所有浏览器启动完成后的回调函数
|
|
|
|
Returns:
|
|
包含所有Future对象的字典
|
|
"""
|
|
tasks = {}
|
|
results = {}
|
|
|
|
# 定义单个浏览器完成时的回调
|
|
def on_browser_complete(browser, page, success, profile):
|
|
results[profile] = (browser, page, success)
|
|
if on_each_complete:
|
|
on_each_complete(browser, page, success, profile)
|
|
|
|
# 检查是否所有浏览器都已启动
|
|
if len(results) == len(profile_names) and on_all_complete:
|
|
on_all_complete(results)
|
|
|
|
# 提交所有浏览器启动任务
|
|
for profile in profile_names:
|
|
# 为每个配置文件创建特定的回调
|
|
callback = lambda b, p, s, name=profile: on_browser_complete(b, p, s, name)
|
|
task = self.launch_browser(profile, headless, callback)
|
|
if task:
|
|
tasks[profile] = task
|
|
|
|
logger.info(f"已提交 {len(tasks)} 个浏览器启动任务")
|
|
return tasks
|
|
|
|
def close_all_browsers(self):
|
|
"""关闭所有浏览器实例."""
|
|
for profile_name in list(self.browsers.keys()):
|
|
self._close_browser(profile_name)
|
|
|
|
# 确保playwright实例被关闭
|
|
if self.playwright:
|
|
try:
|
|
self.playwright.stop()
|
|
self.playwright = None
|
|
except Exception as e:
|
|
logger.error(f"关闭Playwright实例失败: {str(e)}")
|
|
|
|
logger.info("已关闭所有浏览器实例")
|
|
return True
|
|
|
|
def __del__(self):
|
|
"""析构函数,确保资源被释放."""
|
|
self.close_all_browsers()
|