| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473 |
- # 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)
|