""" 并发测试:批量创建浏览器环境并执行广告点击+聊天操作 """ from loguru import logger from adspower_client import AdsPowerClient from config import Config from db_manager import SiteManager, ClickManager, InteractionManager import sys import time import random from datetime import datetime from pathlib import Path from concurrent.futures import ThreadPoolExecutor, as_completed from typing import List, Dict import threading # 添加线程锁支持 # 配置日志 logger.remove() logger.add( sys.stdout, format="{time:HH:mm:ss} | {level: <8} | {message}", level="INFO" ) class ConcurrentTester: """并发测试管理器""" # 类级别的浏览器启动锁,确保启动操作串行化 _browser_start_lock = threading.Lock() def __init__(self, test_url: str, max_workers: int = 3): """ 初始化并发测试器 Args: test_url: 测试的目标URL max_workers: 最大并发数 """ self.test_url = test_url self.max_workers = max_workers self.client = AdsPowerClient() self.created_profiles = [] # 记录创建的环境ID self.created_proxies = [] # 记录创建的代理ID # 创建测试目录 self.test_base_dir = Path("./test_concurrent") self.test_base_dir.mkdir(exist_ok=True) # 初始化数据库 self.site_mgr = SiteManager() self.site_id = self._init_site() def _init_site(self) -> int: """初始化或获取站点""" site = self.site_mgr.get_site_by_url(self.test_url) if not site: site_id = self.site_mgr.add_site( site_url=self.test_url, site_name="并发测试站点", site_dimension="医疗健康" ) logger.info(f"✅ 创建测试站点: site_id={site_id}") else: site_id = site['id'] logger.info(f"✅ 使用已存在站点: site_id={site_id}") return site_id def create_browser_profile(self, index: int) -> Dict: """ 创建浏览器环境 Args: index: 环境编号 Returns: 环境信息字典 """ try: # 获取分组ID group_id = self.client.get_group_by_env() time.sleep(0.5) # API 调用间隔 # 获取大麦IP代理 logger.info(f"[环境 {index}] 获取代理IP...") proxy_info = self.client.get_damai_proxy() time.sleep(0.5) # API 调用间隔 if not proxy_info: logger.warning(f"[环境 {index}] 获取代理失败,将使用随机代理") proxy_config = {} proxy_id = None else: logger.info(f"[环境 {index}] 代理IP: {proxy_info['host']}:{proxy_info['port']}") # 创建代理并记录ID proxy_data = { "type": "http", "host": proxy_info["host"], "port": proxy_info["port"], "user": self.client.DAMAI_USER, "password": self.client.DAMAI_PASSWORD, "remark": f"并发测试代理_{index}" } proxy_id = self.client.create_proxy(proxy_data) time.sleep(0.5) # API 调用间隔 if proxy_id: self.created_proxies.append(proxy_id) logger.info(f"[环境 {index}] 创建代理: {proxy_id}") proxy_config = {"proxyid": proxy_id} else: logger.warning(f"[环境 {index}] 创建代理失败") proxy_config = {} proxy_id = None profile_data = { "name": f"并发测试_{index}_{datetime.now().strftime('%H%M%S')}", "group_id": str(group_id) if group_id else "0", "platform": "health.baidu.com", "tabs": [self.test_url], "repeat_config": [], "ignore_cookie_error": "1", "country": "cn", "city": "beijing", "remark": f"并发测试环境 #{index}", "fingerprint_config": { "automatic_timezone": "1", "flash": "block", "scan_port_type": "1", "location": "ask", "location_switch": "1", "canvas": "0", "webgl": "0", "audio": "0", "webrtc": "local", "do_not_track": "true", "hardware_concurrency": "default", "device_memory": "default", "gpu": "2", "mac_address_config": { "model": "1", "address": "" }, "browser_kernel_config": { "version": "latest", "type": "chrome" }, "random_ua": { "ua_system_version": ["Windows"] } } } # 如果有代理配置,添加到profile_data if proxy_config: profile_data.update(proxy_config) response = self.client._make_request( 'POST', '/api/v2/browser-profile/create', json=profile_data ) if response and response.get('code') == 0: profile_id = response.get('data', {}).get('profile_id') logger.info(f"✅ 创建环境 #{index}: {profile_id}") self.created_profiles.append(profile_id) return { 'index': index, 'profile_id': profile_id, 'name': profile_data['name'], 'proxy': proxy_info if proxy_info else None } else: logger.error(f"❌ 创建环境 #{index} 失败: {response}") return None except Exception as e: logger.error(f"❌ 创建环境 #{index} 异常: {str(e)}") return None def run_single_task(self, profile_info: Dict) -> Dict: """ 执行单个浏览器任务 Args: profile_info: 环境信息 Returns: 执行结果 """ index = profile_info['index'] profile_id = profile_info['profile_id'] result = { 'index': index, 'profile_id': profile_id, 'success': False, 'click_id': None, 'interaction_id': None, 'error': None } # 创建任务文件夹 task_folder = self.test_base_dir / f"task_{index}_{datetime.now().strftime('%H%M%S')}" task_folder.mkdir(exist_ok=True) # 每个线程创建自己的 AdsPowerClient 实例 client = AdsPowerClient() try: logger.info(f"[任务 {index}] 启动浏览器: {profile_id}") # 使用类锁确保浏览器启动串行化,避免 API 频率限制 with self._browser_start_lock: logger.debug(f"[任务 {index}] 获取启动锁...") # 启动浏览器 browser_info = client.start_browser(user_id=profile_id) if not browser_info: result['error'] = "启动浏览器失败" return result # 启动后等待,避免下一个启动请求过快 time.sleep(1.5) logger.debug(f"[任务 {index}] 释放启动锁") time.sleep(1) # 额外等待浏览器完全启动 # 连接浏览器 browser = client.connect_browser(browser_info) if not browser: result['error'] = "CDP连接失败" return result # 获取页面 context = browser.contexts[0] pages = context.pages # 清理多余页面 for p in pages: if 'start.adspower.net' in p.url: pages.remove(p) if pages: page = pages[0] else: page = context.new_page() logger.info(f"[任务 {index}] 访问页面: {self.test_url}") page.goto(self.test_url, wait_until='domcontentloaded', timeout=60000) time.sleep(3) # 等待页面完全加载 try: page.wait_for_load_state('networkidle', timeout=10000) except Exception: logger.warning(f"[任务 {index}] 网络空闲超时,继续执行") time.sleep(2) # 截图 page.screenshot(path=str(task_folder / "01_loaded.png")) # 查找并点击广告 ad_selector = 'span.ec-tuiguang.ecfc-tuiguang.xz81bbe' ad_elements = page.locator(ad_selector) ad_count = ad_elements.count() logger.info(f"[任务 {index}] 找到 {ad_count} 个广告") if ad_count > 0: # 点击第一个广告 first_ad = ad_elements.first first_ad.scroll_into_view_if_needed() time.sleep(1) # 点击,忽略超时错误 try: first_ad.click(timeout=60000) logger.info(f"[任务 {index}] ✅ 已点击广告") except Exception as click_err: logger.warning(f"[任务 {index}] 点击超时,但可能已跳转") # 记录点击 click_mgr = ClickManager() click_id = click_mgr.record_click( site_id=self.site_id, site_url=self.test_url, user_ip=None, device_type='pc' ) result['click_id'] = click_id # 等待跳转 time.sleep(3) page.wait_for_load_state('domcontentloaded') page.screenshot(path=str(task_folder / "02_after_click.png")) # 发送消息 messages = [ "我想要预约一个医生,有什么推荐吗?", "我现在本人不在当地,医生什么时候有空,是随时能去吗?", "咱们医院是周六日是否上班,随时去吗?", "想找医生看看,有没有推荐的医生", "最近很不舒服,也说不出来全部的症状,能不能直接对话医生?" ] message = random.choice(messages) # 滚动到底部 page.evaluate("window.scrollTo(0, document.body.scrollHeight)") time.sleep(1) # 查找输入框 input_selectors = [ "textarea[contenteditable='true']", "textarea", "textarea[placeholder]", "input[type='text']" ] input_found = False for selector in input_selectors: try: count = page.locator(selector).count() if count > 0: for i in range(count): input_elem = page.locator(selector).nth(i) if input_elem.is_visible(timeout=1000): input_elem.scroll_into_view_if_needed() time.sleep(0.5) input_elem.click() time.sleep(0.5) input_elem.fill(message) logger.info(f"[任务 {index}] ✅ 已输入消息") input_found = True break if input_found: break except: continue # 兜底方案 if not input_found: logger.warning(f"[任务 {index}] 未找到输入框,尝试兜底方案...") try: # 检查 viewport_size 是否为 None if page.viewport_size is None: logger.warning(f"[任务 {index}] viewport_size 为 None,设置默认视口") # 设置默认视口大小 page.set_viewport_size({"width": 1280, "height": 720}) time.sleep(0.5) viewport_height = page.viewport_size['height'] click_x = page.viewport_size['width'] // 2 click_y = viewport_height - 10 logger.debug(f"[任务 {index}] 点击位置: ({click_x}, {click_y})") page.mouse.click(click_x, click_y) time.sleep(1) page.keyboard.type(message, delay=50) logger.info(f"[任务 {index}] ✅ 已输入消息(兜底)") input_found = True except Exception as fallback_err: logger.error(f"[任务 {index}] 兜底方案失败: {str(fallback_err)}") # 发送消息 if input_found: try: page.keyboard.press('Enter') logger.info(f"[任务 {index}] ✅ 已发送消息") time.sleep(2) # 记录互动 interaction_mgr = InteractionManager() interaction_id = interaction_mgr.record_interaction( site_id=self.site_id, click_id=click_id, interaction_type='message', reply_content=message, is_successful=True, response_received=False, response_content=None ) result['interaction_id'] = interaction_id page.screenshot(path=str(task_folder / "03_sent.png")) result['success'] = True except Exception as e: logger.warning(f"[任务 {index}] 发送失败: {str(e)}") # 关闭浏览器前,截图聊天页面最终状态 try: logger.info(f"[任务 {index}] 截图聊天页面...") # 等待可能的回复消息加载 time.sleep(2) # 滚动到页面顶部,确保看到完整对话 page.evaluate("window.scrollTo(0, 0)") time.sleep(0.5) # 截图整个页面 page.screenshot(path=str(task_folder / "04_final_chat.png"), full_page=True) logger.info(f"[任务 {index}] ✅ 聊天页面截图已保存") except Exception as screenshot_err: logger.warning(f"[任务 {index}] 截图失败: {str(screenshot_err)}") # 优雅关闭 Playwright 连接,避免 CancelledError try: if browser: logger.debug(f"[任务 {index}] 关闭 Playwright 浏览器连接...") browser.close() time.sleep(0.5) except Exception as close_err: logger.debug(f"[任务 {index}] 关闭浏览器连接异常: {str(close_err)}") # 根据配置决定是否关闭浏览器进程 if Config.AUTO_CLOSE_BROWSER: try: client.stop_browser(user_id=profile_id) logger.info(f"[任务 {index}] 浏览器已关闭") except Exception as stop_err: logger.warning(f"[任务 {index}] 关闭浏览器失败: {str(stop_err)}") except Exception as e: logger.error(f"[任务 {index}] 执行异常: {str(e)}") result['error'] = str(e) import traceback traceback.print_exc() return result def delete_profiles(self, profile_ids: List[str]): """ 批量删除环境 Args: profile_ids: 环境ID列表 """ if not profile_ids: return try: response = self.client._make_request( 'POST', '/api/v2/browser-profile/delete', json={'profile_id': profile_ids} ) if response and response.get('code') == 0: logger.info(f"✅ 已删除 {len(profile_ids)} 个环境") else: logger.error(f"❌ 删除环境失败: {response}") except Exception as e: logger.error(f"❌ 删除环境异常: {str(e)}") def delete_proxies(self, proxy_ids: List[str]): """ 批量删除代理 Args: proxy_ids: 代理ID列表 """ if not proxy_ids: return try: response = self.client._make_request( 'POST', '/api/v2/proxy-list/delete', json={'proxy_id': proxy_ids} ) if response and response.get('code') == 0: logger.info(f"✅ 已删除 {len(proxy_ids)} 个代理") else: logger.error(f"❌ 删除代理失败: {response}") except Exception as e: logger.error(f"❌ 删除代理异帰常: {str(e)}") def run_concurrent_test(self, num_tasks: int): """ 运行并发测试 Args: num_tasks: 并发任务数量 """ logger.info("=" * 60) logger.info(f"开始并发测试: {num_tasks} 个任务, 最大并发数: {self.max_workers}") logger.info("=" * 60) # 第一步:批量创建环境 logger.info("\n步骤 1: 创建浏览器环境") logger.info("-" * 60) profiles = [] for i in range(num_tasks): profile_info = self.create_browser_profile(i + 1) if profile_info: profiles.append(profile_info) proxy_info = profile_info.get('proxy') if proxy_info: logger.info(f"环境 #{i+1} 使用代理: {proxy_info['host']}:{proxy_info['port']}") # 增加环境创建间隔,避免触发 API 频率限制 time.sleep(3) # 每个环境创建后等待 3 秒 logger.info(f"✅ 成功创建 {len(profiles)} 个环境\n") if not profiles: logger.error("没有成功创建任何环境,退出测试") return # 第二步:并发执行任务 logger.info("步骤 2: 并发执行任务") logger.info("-" * 60) results = [] with ThreadPoolExecutor(max_workers=self.max_workers) as executor: futures = { executor.submit(self.run_single_task, profile): profile for profile in profiles } for future in as_completed(futures): profile = futures[future] try: result = future.result() results.append(result) status = "✅ 成功" if result['success'] else "❌ 失败" logger.info(f"[任务 {result['index']}] {status}") except Exception as e: logger.error(f"[任务 {profile['index']}] 执行异常: {str(e)}") # 第三步:统计结果 logger.info("\n步骤 3: 测试结果统计") logger.info("=" * 60) success_count = sum(1 for r in results if r['success']) failed_count = len(results) - success_count logger.info(f"总任务数: {len(results)}") logger.info(f"成功数: {success_count}") logger.info(f"失败数: {failed_count}") logger.info(f"成功率: {success_count/len(results)*100:.1f}%") # 第四步:清理环境(可选) if Config.AUTO_CLOSE_BROWSER: logger.info("\n步骤 4: 清理测试环境") logger.info("-" * 60) # 删除环境 if self.created_profiles: self.delete_profiles(self.created_profiles) # 删除代理 if self.created_proxies: self.delete_proxies(self.created_proxies) logger.info("\n" + "=" * 60) logger.info("并发测试完成!") logger.info("=" * 60) if __name__ == "__main__": logger.info("并发测试工具") logger.info(f"当前环境: {Config.ENV}") logger.info(f"AdsPower API: {Config.ADSPOWER_API_URL}") logger.info("") # ==================== 配置区 ==================== TEST_URL = "https://health.baidu.com/m/detail/ar_2366617956693492811" NUM_TASKS = 3 # 并发任务数 MAX_WORKERS = 3 # 最大并发执行数(建议不超过3) # ===================================================== tester = ConcurrentTester( test_url=TEST_URL, max_workers=MAX_WORKERS ) tester.run_concurrent_test(num_tasks=NUM_TASKS)