# from DrissionPage import ChromiumPage # # 假设上面的代码保存在 human_mouse.py # from human_mouse import HumanMouse # # 初始化 DrissionPage # page = ChromiumPage() # page.get('https://example.com') # # 初始化我们移植的鼠标控制器,开启 debug 模式可在页面上看到鼠标轨迹点 # mouse = HumanMouse(page, debug=True) # # 1. 拟人化移动到一个坐标 # mouse.move(500, 300, humanize=True) # # 2. 拟人化点击 # mouse.click(600, 400, humanize=True) # # 3. 如果需要结合 DrissionPage 元素一起使用: # ele = page.ele('@href=https://www.iana.org/domains/example') # # 获取元素在屏幕中的中点坐标 # x, y = ele.rect.midpoint # mouse.click(x, y, humanize=True) import logging import math import random import time from dataclasses import dataclass from enum import Enum from typing import Optional, Tuple from DrissionPage import ChromiumPage from utils.math_utils import ( bezier_2d, fitts_duration, minimum_jerk, random_control_points, ) logger = logging.getLogger(__name__) class MouseButton(Enum): LEFT = "left" RIGHT = "right" MIDDLE = "middle" class MouseEventType(Enum): MOUSE_MOVED = "mouseMoved" MOUSE_PRESSED = "mousePressed" MOUSE_RELEASED = "mouseReleased" @dataclass(frozen=True) class MouseTimingConfig: """模拟人类鼠标移动物理特性的配置""" fitts_a: float = 0.070 fitts_b: float = 0.150 frame_interval: float = 0.012 frame_interval_variance: float = 0.004 curvature_min: float = 0.10 curvature_max: float = 0.30 curvature_asymmetry: float = 0.6 short_distance_threshold: float = 50.0 tremor_amplitude: float = 1.0 overshoot_probability: float = 0.70 overshoot_distance_min: float = 0.03 overshoot_distance_max: float = 0.12 overshoot_speed_threshold: float = 200.0 pre_click_pause_min: float = 0.05 pre_click_pause_max: float = 0.20 click_hold_min: float = 0.05 click_hold_max: float = 0.15 double_click_interval_min: float = 0.05 double_click_interval_max: float = 0.10 drag_start_pause_min: float = 0.08 drag_start_pause_max: float = 0.20 drag_end_pause_min: float = 0.05 drag_end_pause_max: float = 0.15 micro_pause_probability: float = 0.03 micro_pause_min: float = 0.015 micro_pause_max: float = 0.04 min_duration: float = 0.08 max_duration: float = 2.5 class HumanMouse: """ DrissionPage 的高仿真人类鼠标控制器。 提供移动、点击、双击和拖拽方法,采用贝塞尔曲线、Fitts 定律、生理性微颤和过冲校正。 """ _DEBUG_INIT_JS = """ if (!document.getElementById('__dp_mouse_debug')) { const canvas = document.createElement('canvas'); canvas.id = '__dp_mouse_debug'; canvas.style.cssText = 'position:fixed;top:0;left:0;width:100vw;height:100vh;' + 'pointer-events:none;z-index:2147483647;'; canvas.width = window.innerWidth; canvas.height = window.innerHeight; document.body.appendChild(canvas); window.__dp_debug_ctx = canvas.getContext('2d'); } """ _DEBUG_DOT_JS = """ if (window.__dp_debug_ctx) { const ctx = window.__dp_debug_ctx; ctx.beginPath(); ctx.arc(%s, %s, %s, 0, 2 * Math.PI); ctx.fillStyle = '%s'; ctx.fill(); } """ def __init__( self, page: ChromiumPage, timing: Optional[MouseTimingConfig] = None, debug: bool = False, ): """ 初始化鼠标控制器 :param page: DrissionPage 的 ChromiumPage 或 ChromiumTab 实例 :param timing: 轨迹与时间配置 :param debug: 是否在页面上绘制调试红蓝点 """ self._page = page self._timing = timing or MouseTimingConfig() self._position: Tuple[float, float] = (0.0, 0.0) self._debug = debug self._debug_initialized = False @property def timing(self) -> MouseTimingConfig: return self._timing @timing.setter def timing(self, config: MouseTimingConfig) -> None: self._timing = config @property def debug(self) -> bool: return self._debug @debug.setter def debug(self, value: bool) -> None: self._debug = value self._debug_initialized = False def move(self, x: float, y: float, *, humanize: bool = False) -> None: """移动鼠标""" if humanize: self._move_humanized(x, y) return self._dispatch_move(x, y) def human_click_ele(self, element): """ 拟人化点击元素 (修复版) :param element: DrissionPage 的元素对象 """ # 1. 强制滚动,把元素尽量移到屏幕正中间,防止被顶部导航栏或底部悬浮窗遮盖 element.scroll.to_see(center=True) time.sleep(0.5) # 等待滚动动画完成,防止移动时坐标还在变化 # 2. 【核心修复】获取相对于当前屏幕视口的坐标,而不是页面绝对坐标 # 注意:这里使用的是 viewport_midpoint mid_x, mid_y = element.rect.viewport_midpoint # 3. 如果元素太靠近边缘,可能会获取失败,做一个基础保护 if mid_x is None or mid_y is None: logger.warning("Failed to get viewport midpoint, falling back to absolute midpoint.") mid_x, mid_y = element.rect.midpoint self.click(mid_x, mid_y, humanize=True) def click( self, x: float, y: float, *, button: MouseButton = MouseButton.LEFT, click_count: int = 1, humanize: bool = False, ) -> None: """点击鼠标""" if humanize: self._click_humanized(x, y, button, click_count) return self._dispatch_move(x, y) self._dispatch_button(MouseEventType.MOUSE_PRESSED, button, click_count) self._dispatch_button(MouseEventType.MOUSE_RELEASED, button, click_count) def double_click( self, x: float, y: float, *, button: MouseButton = MouseButton.LEFT, humanize: bool = False, ) -> None: """双击鼠标""" self.click(x, y, button=button, click_count=2, humanize=humanize) def down(self, button: MouseButton = MouseButton.LEFT) -> None: """按下鼠标按键""" self._dispatch_button(MouseEventType.MOUSE_PRESSED, button) def up(self, button: MouseButton = MouseButton.LEFT) -> None: """释放鼠标按键""" self._dispatch_button(MouseEventType.MOUSE_RELEASED, button) def drag( self, start_x: float, start_y: float, end_x: float, end_y: float, *, humanize: bool = False, ) -> None: """拖拽鼠标""" if humanize: self._drag_humanized(start_x, start_y, end_x, end_y) return self._dispatch_move(start_x, start_y) self._dispatch_button(MouseEventType.MOUSE_PRESSED, MouseButton.LEFT) self._dispatch_move(end_x, end_y) self._dispatch_button(MouseEventType.MOUSE_RELEASED, MouseButton.LEFT) def _move_humanized(self, target_x: float, target_y: float, custom_duration: Optional[float] = None) -> None: """拟人化移动核心逻辑""" start = self._position target = (target_x, target_y) distance = math.hypot(target_x - start[0], target_y - start[1]) if distance < 1.0: self._dispatch_move(target_x, target_y) return config = self._timing if custom_duration is not None: duration = custom_duration else: duration = fitts_duration(distance, 20.0, config.fitts_a, config.fitts_b) duration = max(config.min_duration, min(duration, config.max_duration)) should_overshoot = ( distance > config.overshoot_speed_threshold and random.random() < config.overshoot_probability ) if should_overshoot: self._move_with_overshoot(start, target, duration) else: cp1, cp2 = self._get_control_points(start, target) self._perform_movement_loop(start, target, duration, cp1, cp2) self._dispatch_move(target_x, target_y) def _move_with_overshoot( self, start: Tuple[float, float], target: Tuple[float, float], duration: float, ) -> None: """带过冲现象的移动""" config = self._timing overshoot_fraction = random.uniform( config.overshoot_distance_min, config.overshoot_distance_max ) dx = target[0] - start[0] dy = target[1] - start[1] overshoot = (target[0] + dx * overshoot_fraction, target[1] + dy * overshoot_fraction) cp1, cp2 = self._get_control_points(start, overshoot) self._perform_movement_loop(start, overshoot, duration * 0.85, cp1, cp2) cp1, cp2 = self._get_control_points(overshoot, target) self._perform_movement_loop(overshoot, target, duration * 0.15, cp1, cp2) def _perform_movement_loop( self, start: Tuple[float, float], end: Tuple[float, float], duration: float, cp1: Tuple[float, float], cp2: Tuple[float, float], ) -> None: """按帧渲染并执行移动循环""" config = self._timing start_time = time.perf_counter() prev = (start[0], start[1], start_time) while True: now = time.perf_counter() elapsed = now - start_time if elapsed >= duration: break t = minimum_jerk(elapsed / duration) x, y = bezier_2d(t, start, cp1, cp2, end) sigma = self._compute_tremor_sigma(x, y, now, prev, config) x += random.gauss(0, sigma) y += random.gauss(0, sigma) self._dispatch_move(x, y) prev = (x, y, now) frame_delay = config.frame_interval + random.uniform( -config.frame_interval_variance, config.frame_interval_variance ) time.sleep(max(0.001, frame_delay)) if random.random() < config.micro_pause_probability: pause = random.uniform(config.micro_pause_min, config.micro_pause_max) time.sleep(pause) start_time += pause # 补偿停顿时间 @staticmethod def _compute_tremor_sigma( x: float, y: float, now: float, prev: Tuple[float, float, float], config: MouseTimingConfig, ) -> float: """动态计算手抖幅度""" dt = now - prev[2] if dt > 0: velocity = math.hypot(x - prev[0], y - prev[1]) / dt speed_factor = max(0.2, 1.0 - velocity / 500.0) else: speed_factor = 1.0 return config.tremor_amplitude * speed_factor def _click_humanized( self, x: float, y: float, button: MouseButton, click_count: int, ) -> None: """拟人化点击""" config = self._timing self._move_humanized(x, y) pre_pause = random.uniform(config.pre_click_pause_min, config.pre_click_pause_max) time.sleep(pre_pause) for i in range(click_count): current_count = i + 1 self._dispatch_button(MouseEventType.MOUSE_PRESSED, button, current_count) hold = random.uniform(config.click_hold_min, config.click_hold_max) time.sleep(hold) self._dispatch_button(MouseEventType.MOUSE_RELEASED, button, current_count) if current_count < click_count: interval = random.uniform( config.double_click_interval_min, config.double_click_interval_max, ) time.sleep(interval) def _drag_humanized( self, start_x: float, start_y: float, end_x: float, end_y: float, ) -> None: """拟人化拖拽""" config = self._timing self._move_humanized(start_x, start_y) self._dispatch_button(MouseEventType.MOUSE_PRESSED, MouseButton.LEFT) drag_start_pause = random.uniform(config.drag_start_pause_min, config.drag_start_pause_max) time.sleep(drag_start_pause) start = self._position distance = math.hypot(end_x - start[0], end_y - start[1]) duration = fitts_duration(distance, 20.0, config.fitts_a, config.fitts_b) duration = max(config.min_duration, min(duration, config.max_duration)) cp1, cp2 = self._get_control_points(start, (end_x, end_y)) self._perform_movement_loop(start, (end_x, end_y), duration, cp1, cp2) self._dispatch_move(end_x, end_y) drag_end_pause = random.uniform(config.drag_end_pause_min, config.drag_end_pause_max) time.sleep(drag_end_pause) self._dispatch_button(MouseEventType.MOUSE_RELEASED, MouseButton.LEFT) def _get_control_points( self, start: Tuple[float, float], end: Tuple[float, float], ) -> Tuple[Tuple[float, float], Tuple[float, float]]: config = self._timing return random_control_points( start, end, config.curvature_min, config.curvature_max, config.curvature_asymmetry, config.short_distance_threshold, ) def _dispatch_move(self, x: float, y: float) -> None: """发送 CDP 鼠标移动指令""" self._page.run_cdp( "Input.dispatchMouseEvent", type=MouseEventType.MOUSE_MOVED.value, x=int(round(x)), y=int(round(y)) ) self._position = (x, y) if self._debug: self._debug_draw_dot(x, y, radius=2, color='rgba(0,150,255,0.6)') def _dispatch_button( self, event_type: MouseEventType, button: MouseButton, click_count: int = 1, ) -> None: """发送 CDP 鼠标按键指令""" self._page.run_cdp( "Input.dispatchMouseEvent", type=event_type.value, button=button.value, x=int(round(self._position[0])), y=int(round(self._position[1])), clickCount=click_count ) if self._debug and event_type == MouseEventType.MOUSE_PRESSED: self._debug_draw_dot( self._position[0], self._position[1], radius=6, color='rgba(255,50,50,0.9)' ) def _debug_draw_dot(self, x: float, y: float, radius: int, color: str) -> None: """绘制轨迹调试点""" if not self._debug_initialized: self._page.run_js(self._DEBUG_INIT_JS) self._debug_initialized = True script = self._DEBUG_DOT_JS % (int(round(x)), int(round(y)), radius, color) self._page.run_js(script)