# 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.scroll import HumanScroll 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.28 curvature_asymmetry: float = 0.6 short_distance_threshold: float = 50.0 tremor_amplitude: float = 0.85 overshoot_probability: float = 0.22 overshoot_distance_min: float = 0.03 overshoot_distance_max: float = 0.10 overshoot_speed_threshold: float = 260.0 pre_click_pause_min: float = 0.04 pre_click_pause_max: float = 0.16 click_hold_min: float = 0.04 click_hold_max: float = 0.12 double_click_interval_min: float = 0.05 double_click_interval_max: float = 0.09 drag_start_pause_min: float = 0.06 drag_start_pause_max: float = 0.16 drag_end_pause_min: float = 0.04 drag_end_pause_max: float = 0.12 micro_pause_probability: float = 0.08 micro_pause_min: float = 0.010 micro_pause_max: float = 0.030 min_duration: float = 0.08 max_duration: float = 2.2 # ========================================== # 10套拟人化鼠标行为配置预设 (User Profiles) # ========================================== MOUSE_PROFILES = { # 1. 【电竞玩家 / 熟练用户】 # 特征:移动极快,轨迹趋近直线,因为速度太快偶尔会有轻微过冲,点击干脆利落。 "gamer": MouseTimingConfig( fitts_a=0.040, fitts_b=0.090, # 移动时间极短 curvature_min=0.05, curvature_max=0.15, # 轨迹很直 tremor_amplitude=0.40, # 手极其稳 overshoot_probability=0.25, overshoot_distance_min=0.02, overshoot_distance_max=0.06, # 容易滑过头但偏差小 pre_click_pause_min=0.02, pre_click_pause_max=0.06, # 几乎不犹豫直接点 click_hold_min=0.02, click_hold_max=0.06, # 点击非常轻快 micro_pause_probability=0.02, # 中途基本不犹豫 min_duration=0.05, max_duration=1.2 ), # 2. 【老年人 / 鼠标新手】 # 特征:移动缓慢,轨迹弧度大且手抖严重,经常在半路停顿,点击按压时间很长。 "elderly": MouseTimingConfig( fitts_a=0.150, fitts_b=0.250, # 移动非常耗时 curvature_min=0.15, curvature_max=0.40, # 巨大的弧线 tremor_amplitude=1.80, # 手部抖动明显 overshoot_probability=0.05, # 慢到基本不会过冲 pre_click_pause_min=0.15, pre_click_pause_max=0.35, # 到了目标要看半天才点 click_hold_min=0.10, click_hold_max=0.25, # 按下去很久才松开 micro_pause_probability=0.25, micro_pause_min=0.05, micro_pause_max=0.15, # 半路经常停下来找光标 min_duration=0.2, max_duration=3.5 ), # 3. 【触控板 / 笔记本用户】 # 特征:轨迹不对称,经常因为手指滑动到边缘而产生“微小停顿”(抬起手指重新滑动),无明显过冲。 "trackpad": MouseTimingConfig( fitts_a=0.080, fitts_b=0.180, curvature_asymmetry=0.8, # 触控板滑动极不对称 tremor_amplitude=0.50, overshoot_probability=0.05, # 触控板有天然加速度,一般不会过冲 micro_pause_probability=0.35, micro_pause_min=0.03, micro_pause_max=0.12, # 频繁微停顿(手指滑出边缘重置) drag_start_pause_max=0.25, drag_end_pause_max=0.20, # 触控板拖拽很困难,停顿长 double_click_interval_min=0.08, double_click_interval_max=0.15 ), # 4. 【急躁 / 喝了咖啡的用户】 # 特征:速度快但不精确,手抖严重,极度容易“过冲”并需要大幅度回调,点击间隔很短。 "caffeinated": MouseTimingConfig( fitts_a=0.050, fitts_b=0.110, curvature_min=0.10, curvature_max=0.25, tremor_amplitude=1.50, # 兴奋状态,手抖大 overshoot_probability=0.45, overshoot_distance_min=0.05, overshoot_distance_max=0.15, # 疯狂冲过头 pre_click_pause_min=0.01, pre_click_pause_max=0.05, double_click_interval_min=0.03, double_click_interval_max=0.07, # 连击速度极快 min_duration=0.05, max_duration=1.5 ), # 5. 【心不在焉 / 看剧摸鱼的用户】 # 特征:移动到一半可能会长时间停住(抬头看其他屏幕),点击前犹豫时间极长。 "distracted": MouseTimingConfig( fitts_a=0.090, fitts_b=0.160, tremor_amplitude=0.90, overshoot_probability=0.15, pre_click_pause_min=0.20, pre_click_pause_max=0.80, # 到了按钮上发呆很久才点 micro_pause_probability=0.15, micro_pause_min=0.10, micro_pause_max=0.60, # 中途长时间停顿 click_hold_min=0.05, click_hold_max=0.15, max_duration=4.0 ), # 6. 【疲惫 / 深夜工作的用户】 # 特征:整体响应迟缓,弧度大(懒得走直线),拖拽和点击都显得很“沉重”。 "tired": MouseTimingConfig( fitts_a=0.120, fitts_b=0.200, curvature_min=0.20, curvature_max=0.35, # 懒散的大弧线 tremor_amplitude=1.00, overshoot_probability=0.20, pre_click_pause_min=0.10, pre_click_pause_max=0.25, click_hold_min=0.10, click_hold_max=0.22, # 手指沉重,按压长 micro_pause_probability=0.10, max_duration=3.0 ), # 7. 【设计师 / 精准对齐用户】 # 特征:移动平滑优美,手极其稳(无微颤),几乎不产生过冲,会花额外时间精准停留在目标正中心。 "designer": MouseTimingConfig( fitts_a=0.100, fitts_b=0.170, curvature_min=0.15, curvature_max=0.25, # 优美圆滑的曲线 tremor_amplitude=0.20, # 极低的像素级抖动 overshoot_probability=0.02, # 绝对不过冲 pre_click_pause_min=0.08, pre_click_pause_max=0.15, # 确认对准后再点 micro_pause_probability=0.0, frame_interval=0.008, frame_interval_variance=0.002 # 高刷新率的高端鼠标 ), # 8. 【笨拙重手 / “重装坦克”用户】 # 特征:速度中等但每次动作幅度都偏大,容易大幅度偏离,点击鼠标时力气很大(长hold)。 "clumsy": MouseTimingConfig( fitts_a=0.080, fitts_b=0.150, tremor_amplitude=1.20, overshoot_probability=0.35, overshoot_distance_min=0.08, overshoot_distance_max=0.20, # 容易大幅度滑偏 pre_click_pause_min=0.06, pre_click_pause_max=0.15, click_hold_min=0.12, click_hold_max=0.25, # 重按 drag_start_pause_max=0.25, min_duration=0.1 ), # 9. 【旧电脑 / 卡顿网络用户】 # 特征:轨迹不平滑,帧率低且波动极大(模拟系统卡顿导致的鼠标瞬间瞬移和丢帧)。 "laggy_pc": MouseTimingConfig( fitts_a=0.070, fitts_b=0.150, frame_interval=0.035, frame_interval_variance=0.025, # 极度丢帧、跳跃 tremor_amplitude=0.70, overshoot_probability=0.10, click_hold_min=0.05, click_hold_max=0.15, micro_pause_probability=0.20, micro_pause_min=0.02, micro_pause_max=0.05, # 卡顿造成的强制停顿 ), # 10. 【中规中矩的普通用户】 # 特征:最标准的参数,各项指标居中。 "average_joe": MouseTimingConfig( fitts_a=0.070, fitts_b=0.150, frame_interval=0.012, frame_interval_variance=0.004, curvature_min=0.10, curvature_max=0.28, tremor_amplitude=0.85, overshoot_probability=0.22, overshoot_distance_min=0.03, overshoot_distance_max=0.10, pre_click_pause_min=0.04, pre_click_pause_max=0.16, click_hold_min=0.04, click_hold_max=0.12, micro_pause_probability=0.08 ) } 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] = self._read_initial_position() self._debug = debug self._debug_initialized = False self._session_profile = self._build_session_profile() self._scroll = HumanScroll(self._page) @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. 强制滚动,把元素尽量移到屏幕正中间,防止被顶部导航栏或底部悬浮窗遮盖 self._scroll.scroll_to_element(element) time.sleep(random.uniform(0.2, 0.6)) # 等待滚动动画完成,防止移动时坐标还在变化 # 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)) duration *= self._session_profile["speed_scale"] should_overshoot = ( distance > config.overshoot_speed_threshold and random.random() < config.overshoot_probability * self._session_profile["overshoot_bias"] ) 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._perform_final_correction(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) segment_pause_used = False while True: now = time.perf_counter() elapsed = now - start_time if elapsed >= duration: break progress = elapsed / duration eased = minimum_jerk(progress) x, y = bezier_2d(eased, 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 = self._sample_frame_delay() time.sleep(max(0.001, frame_delay)) if (not segment_pause_used) and random.random() < config.micro_pause_probability: pause = self._sample_micro_pause() time.sleep(pause) start_time += pause segment_pause_used = True @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: """拟人化点击""" self._move_humanized(x, y) self._micro_adjust_towards(x, y) pre_pause = self._sample_pre_click_pause() time.sleep(pre_pause) for i in range(click_count): current_count = i + 1 self._dispatch_button(MouseEventType.MOUSE_PRESSED, button, current_count) hold = self._sample_click_hold() time.sleep(hold) self._dispatch_button(MouseEventType.MOUSE_RELEASED, button, current_count) if current_count < click_count: interval = self._sample_double_click_interval() 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 _sample_frame_delay(self) -> float: config = self._timing base = config.frame_interval * self._session_profile["tempo_scale"] spread = config.frame_interval_variance * self._session_profile["tempo_jitter"] return random.gauss(base, max(0.001, spread)) def _sample_pre_click_pause(self) -> float: config = self._timing return max( 0.0, random.gauss( (config.pre_click_pause_min + config.pre_click_pause_max) / 2.0, (config.pre_click_pause_max - config.pre_click_pause_min) / 6.0, ), ) def _sample_click_hold(self) -> float: config = self._timing return max( 0.0, random.gauss( (config.click_hold_min + config.click_hold_max) / 2.0, (config.click_hold_max - config.click_hold_min) / 6.0, ), ) def _sample_double_click_interval(self) -> float: config = self._timing return max( 0.0, random.gauss( (config.double_click_interval_min + config.double_click_interval_max) / 2.0, (config.double_click_interval_max - config.double_click_interval_min) / 6.0, ), ) def _sample_micro_pause(self) -> float: config = self._timing return max( 0.0, random.gauss( (config.micro_pause_min + config.micro_pause_max) / 2.0, (config.micro_pause_max - config.micro_pause_min) / 6.0, ), ) 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) def _read_initial_position(self) -> Tuple[float, float]: try: data = self._page.run_js("return { x: window.screenX || 0, y: window.screenY || 0 };") if isinstance(data, dict): return (float(data.get("x", 0.0)), float(data.get("y", 0.0))) except Exception: pass return (0.0, 0.0) def _build_session_profile(self) -> dict: return { "speed_scale": random.uniform(0.90, 1.15), "tempo_scale": random.uniform(0.92, 1.10), "tempo_jitter": random.uniform(0.85, 1.25), "overshoot_bias": random.uniform(0.65, 1.15), } def _perform_final_correction(self, target_x: float, target_y: float) -> None: current_x, current_y = self._position distance = math.hypot(target_x - current_x, target_y - current_y) if distance <= 1.5: self._dispatch_move(target_x, target_y) return correction_end = (target_x, target_y) cp1, cp2 = self._get_control_points((current_x, current_y), correction_end) duration = min(self._timing.max_duration, max(self._timing.min_duration, 0.12 + distance / 900.0)) self._perform_movement_loop((current_x, current_y), correction_end, duration, cp1, cp2) self._dispatch_move(target_x, target_y) def _micro_adjust_towards(self, x: float, y: float) -> None: current_x, current_y = self._position distance = math.hypot(x - current_x, y - current_y) if distance < 5.0: return offset_x = random.uniform(-1.0, 1.0) offset_y = random.uniform(-1.0, 1.0) self._dispatch_move(x + offset_x, y + offset_y) time.sleep(random.uniform(0.006, 0.018)) self._dispatch_move(x, y)