mouse.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
  1. # from DrissionPage import ChromiumPage
  2. # # 假设上面的代码保存在 human_mouse.py
  3. # from human_mouse import HumanMouse
  4. # # 初始化 DrissionPage
  5. # page = ChromiumPage()
  6. # page.get('https://example.com')
  7. # # 初始化我们移植的鼠标控制器,开启 debug 模式可在页面上看到鼠标轨迹点
  8. # mouse = HumanMouse(page, debug=True)
  9. # # 1. 拟人化移动到一个坐标
  10. # mouse.move(500, 300, humanize=True)
  11. # # 2. 拟人化点击
  12. # mouse.click(600, 400, humanize=True)
  13. # # 3. 如果需要结合 DrissionPage 元素一起使用:
  14. # ele = page.ele('@href=https://www.iana.org/domains/example')
  15. # # 获取元素在屏幕中的中点坐标
  16. # x, y = ele.rect.midpoint
  17. # mouse.click(x, y, humanize=True)
  18. import logging
  19. import math
  20. import random
  21. import time
  22. from dataclasses import dataclass
  23. from enum import Enum
  24. from typing import Optional, Tuple
  25. from DrissionPage import ChromiumPage
  26. from utils.math_utils import (
  27. bezier_2d,
  28. fitts_duration,
  29. minimum_jerk,
  30. random_control_points,
  31. )
  32. logger = logging.getLogger(__name__)
  33. class MouseButton(Enum):
  34. LEFT = "left"
  35. RIGHT = "right"
  36. MIDDLE = "middle"
  37. class MouseEventType(Enum):
  38. MOUSE_MOVED = "mouseMoved"
  39. MOUSE_PRESSED = "mousePressed"
  40. MOUSE_RELEASED = "mouseReleased"
  41. @dataclass(frozen=True)
  42. class MouseTimingConfig:
  43. """模拟人类鼠标移动物理特性的配置"""
  44. fitts_a: float = 0.070
  45. fitts_b: float = 0.150
  46. frame_interval: float = 0.012
  47. frame_interval_variance: float = 0.004
  48. curvature_min: float = 0.10
  49. curvature_max: float = 0.30
  50. curvature_asymmetry: float = 0.6
  51. short_distance_threshold: float = 50.0
  52. tremor_amplitude: float = 1.0
  53. overshoot_probability: float = 0.70
  54. overshoot_distance_min: float = 0.03
  55. overshoot_distance_max: float = 0.12
  56. overshoot_speed_threshold: float = 200.0
  57. pre_click_pause_min: float = 0.05
  58. pre_click_pause_max: float = 0.20
  59. click_hold_min: float = 0.05
  60. click_hold_max: float = 0.15
  61. double_click_interval_min: float = 0.05
  62. double_click_interval_max: float = 0.10
  63. drag_start_pause_min: float = 0.08
  64. drag_start_pause_max: float = 0.20
  65. drag_end_pause_min: float = 0.05
  66. drag_end_pause_max: float = 0.15
  67. micro_pause_probability: float = 0.03
  68. micro_pause_min: float = 0.015
  69. micro_pause_max: float = 0.04
  70. min_duration: float = 0.08
  71. max_duration: float = 2.5
  72. class HumanMouse:
  73. """
  74. DrissionPage 的高仿真人类鼠标控制器。
  75. 提供移动、点击、双击和拖拽方法,采用贝塞尔曲线、Fitts 定律、生理性微颤和过冲校正。
  76. """
  77. _DEBUG_INIT_JS = """
  78. if (!document.getElementById('__dp_mouse_debug')) {
  79. const canvas = document.createElement('canvas');
  80. canvas.id = '__dp_mouse_debug';
  81. canvas.style.cssText = 'position:fixed;top:0;left:0;width:100vw;height:100vh;'
  82. + 'pointer-events:none;z-index:2147483647;';
  83. canvas.width = window.innerWidth;
  84. canvas.height = window.innerHeight;
  85. document.body.appendChild(canvas);
  86. window.__dp_debug_ctx = canvas.getContext('2d');
  87. }
  88. """
  89. _DEBUG_DOT_JS = """
  90. if (window.__dp_debug_ctx) {
  91. const ctx = window.__dp_debug_ctx;
  92. ctx.beginPath();
  93. ctx.arc(%s, %s, %s, 0, 2 * Math.PI);
  94. ctx.fillStyle = '%s';
  95. ctx.fill();
  96. }
  97. """
  98. def __init__(
  99. self,
  100. page: ChromiumPage,
  101. timing: Optional[MouseTimingConfig] = None,
  102. debug: bool = False,
  103. ):
  104. """
  105. 初始化鼠标控制器
  106. :param page: DrissionPage 的 ChromiumPage 或 ChromiumTab 实例
  107. :param timing: 轨迹与时间配置
  108. :param debug: 是否在页面上绘制调试红蓝点
  109. """
  110. self._page = page
  111. self._timing = timing or MouseTimingConfig()
  112. self._position: Tuple[float, float] = (0.0, 0.0)
  113. self._debug = debug
  114. self._debug_initialized = False
  115. @property
  116. def timing(self) -> MouseTimingConfig:
  117. return self._timing
  118. @timing.setter
  119. def timing(self, config: MouseTimingConfig) -> None:
  120. self._timing = config
  121. @property
  122. def debug(self) -> bool:
  123. return self._debug
  124. @debug.setter
  125. def debug(self, value: bool) -> None:
  126. self._debug = value
  127. self._debug_initialized = False
  128. def move(self, x: float, y: float, *, humanize: bool = False) -> None:
  129. """移动鼠标"""
  130. if humanize:
  131. self._move_humanized(x, y)
  132. return
  133. self._dispatch_move(x, y)
  134. def human_click_ele(self, element):
  135. """
  136. 拟人化点击元素 (修复版)
  137. :param element: DrissionPage 的元素对象
  138. """
  139. # 1. 强制滚动,把元素尽量移到屏幕正中间,防止被顶部导航栏或底部悬浮窗遮盖
  140. element.scroll.to_see(center=True)
  141. time.sleep(0.5) # 等待滚动动画完成,防止移动时坐标还在变化
  142. # 2. 【核心修复】获取相对于当前屏幕视口的坐标,而不是页面绝对坐标
  143. # 注意:这里使用的是 viewport_midpoint
  144. mid_x, mid_y = element.rect.viewport_midpoint
  145. # 3. 如果元素太靠近边缘,可能会获取失败,做一个基础保护
  146. if mid_x is None or mid_y is None:
  147. logger.warning("Failed to get viewport midpoint, falling back to absolute midpoint.")
  148. mid_x, mid_y = element.rect.midpoint
  149. self.click(mid_x, mid_y, humanize=True)
  150. def click(
  151. self,
  152. x: float,
  153. y: float,
  154. *,
  155. button: MouseButton = MouseButton.LEFT,
  156. click_count: int = 1,
  157. humanize: bool = False,
  158. ) -> None:
  159. """点击鼠标"""
  160. if humanize:
  161. self._click_humanized(x, y, button, click_count)
  162. return
  163. self._dispatch_move(x, y)
  164. self._dispatch_button(MouseEventType.MOUSE_PRESSED, button, click_count)
  165. self._dispatch_button(MouseEventType.MOUSE_RELEASED, button, click_count)
  166. def double_click(
  167. self,
  168. x: float,
  169. y: float,
  170. *,
  171. button: MouseButton = MouseButton.LEFT,
  172. humanize: bool = False,
  173. ) -> None:
  174. """双击鼠标"""
  175. self.click(x, y, button=button, click_count=2, humanize=humanize)
  176. def down(self, button: MouseButton = MouseButton.LEFT) -> None:
  177. """按下鼠标按键"""
  178. self._dispatch_button(MouseEventType.MOUSE_PRESSED, button)
  179. def up(self, button: MouseButton = MouseButton.LEFT) -> None:
  180. """释放鼠标按键"""
  181. self._dispatch_button(MouseEventType.MOUSE_RELEASED, button)
  182. def drag(
  183. self,
  184. start_x: float,
  185. start_y: float,
  186. end_x: float,
  187. end_y: float,
  188. *,
  189. humanize: bool = False,
  190. ) -> None:
  191. """拖拽鼠标"""
  192. if humanize:
  193. self._drag_humanized(start_x, start_y, end_x, end_y)
  194. return
  195. self._dispatch_move(start_x, start_y)
  196. self._dispatch_button(MouseEventType.MOUSE_PRESSED, MouseButton.LEFT)
  197. self._dispatch_move(end_x, end_y)
  198. self._dispatch_button(MouseEventType.MOUSE_RELEASED, MouseButton.LEFT)
  199. def _move_humanized(self, target_x: float, target_y: float, custom_duration: Optional[float] = None) -> None:
  200. """拟人化移动核心逻辑"""
  201. start = self._position
  202. target = (target_x, target_y)
  203. distance = math.hypot(target_x - start[0], target_y - start[1])
  204. if distance < 1.0:
  205. self._dispatch_move(target_x, target_y)
  206. return
  207. config = self._timing
  208. if custom_duration is not None:
  209. duration = custom_duration
  210. else:
  211. duration = fitts_duration(distance, 20.0, config.fitts_a, config.fitts_b)
  212. duration = max(config.min_duration, min(duration, config.max_duration))
  213. should_overshoot = (
  214. distance > config.overshoot_speed_threshold
  215. and random.random() < config.overshoot_probability
  216. )
  217. if should_overshoot:
  218. self._move_with_overshoot(start, target, duration)
  219. else:
  220. cp1, cp2 = self._get_control_points(start, target)
  221. self._perform_movement_loop(start, target, duration, cp1, cp2)
  222. self._dispatch_move(target_x, target_y)
  223. def _move_with_overshoot(
  224. self,
  225. start: Tuple[float, float],
  226. target: Tuple[float, float],
  227. duration: float,
  228. ) -> None:
  229. """带过冲现象的移动"""
  230. config = self._timing
  231. overshoot_fraction = random.uniform(
  232. config.overshoot_distance_min, config.overshoot_distance_max
  233. )
  234. dx = target[0] - start[0]
  235. dy = target[1] - start[1]
  236. overshoot = (target[0] + dx * overshoot_fraction, target[1] + dy * overshoot_fraction)
  237. cp1, cp2 = self._get_control_points(start, overshoot)
  238. self._perform_movement_loop(start, overshoot, duration * 0.85, cp1, cp2)
  239. cp1, cp2 = self._get_control_points(overshoot, target)
  240. self._perform_movement_loop(overshoot, target, duration * 0.15, cp1, cp2)
  241. def _perform_movement_loop(
  242. self,
  243. start: Tuple[float, float],
  244. end: Tuple[float, float],
  245. duration: float,
  246. cp1: Tuple[float, float],
  247. cp2: Tuple[float, float],
  248. ) -> None:
  249. """按帧渲染并执行移动循环"""
  250. config = self._timing
  251. start_time = time.perf_counter()
  252. prev = (start[0], start[1], start_time)
  253. while True:
  254. now = time.perf_counter()
  255. elapsed = now - start_time
  256. if elapsed >= duration:
  257. break
  258. t = minimum_jerk(elapsed / duration)
  259. x, y = bezier_2d(t, start, cp1, cp2, end)
  260. sigma = self._compute_tremor_sigma(x, y, now, prev, config)
  261. x += random.gauss(0, sigma)
  262. y += random.gauss(0, sigma)
  263. self._dispatch_move(x, y)
  264. prev = (x, y, now)
  265. frame_delay = config.frame_interval + random.uniform(
  266. -config.frame_interval_variance, config.frame_interval_variance
  267. )
  268. time.sleep(max(0.001, frame_delay))
  269. if random.random() < config.micro_pause_probability:
  270. pause = random.uniform(config.micro_pause_min, config.micro_pause_max)
  271. time.sleep(pause)
  272. start_time += pause # 补偿停顿时间
  273. @staticmethod
  274. def _compute_tremor_sigma(
  275. x: float,
  276. y: float,
  277. now: float,
  278. prev: Tuple[float, float, float],
  279. config: MouseTimingConfig,
  280. ) -> float:
  281. """动态计算手抖幅度"""
  282. dt = now - prev[2]
  283. if dt > 0:
  284. velocity = math.hypot(x - prev[0], y - prev[1]) / dt
  285. speed_factor = max(0.2, 1.0 - velocity / 500.0)
  286. else:
  287. speed_factor = 1.0
  288. return config.tremor_amplitude * speed_factor
  289. def _click_humanized(
  290. self,
  291. x: float,
  292. y: float,
  293. button: MouseButton,
  294. click_count: int,
  295. ) -> None:
  296. """拟人化点击"""
  297. config = self._timing
  298. self._move_humanized(x, y)
  299. pre_pause = random.uniform(config.pre_click_pause_min, config.pre_click_pause_max)
  300. time.sleep(pre_pause)
  301. for i in range(click_count):
  302. current_count = i + 1
  303. self._dispatch_button(MouseEventType.MOUSE_PRESSED, button, current_count)
  304. hold = random.uniform(config.click_hold_min, config.click_hold_max)
  305. time.sleep(hold)
  306. self._dispatch_button(MouseEventType.MOUSE_RELEASED, button, current_count)
  307. if current_count < click_count:
  308. interval = random.uniform(
  309. config.double_click_interval_min,
  310. config.double_click_interval_max,
  311. )
  312. time.sleep(interval)
  313. def _drag_humanized(
  314. self,
  315. start_x: float,
  316. start_y: float,
  317. end_x: float,
  318. end_y: float,
  319. ) -> None:
  320. """拟人化拖拽"""
  321. config = self._timing
  322. self._move_humanized(start_x, start_y)
  323. self._dispatch_button(MouseEventType.MOUSE_PRESSED, MouseButton.LEFT)
  324. drag_start_pause = random.uniform(config.drag_start_pause_min, config.drag_start_pause_max)
  325. time.sleep(drag_start_pause)
  326. start = self._position
  327. distance = math.hypot(end_x - start[0], end_y - start[1])
  328. duration = fitts_duration(distance, 20.0, config.fitts_a, config.fitts_b)
  329. duration = max(config.min_duration, min(duration, config.max_duration))
  330. cp1, cp2 = self._get_control_points(start, (end_x, end_y))
  331. self._perform_movement_loop(start, (end_x, end_y), duration, cp1, cp2)
  332. self._dispatch_move(end_x, end_y)
  333. drag_end_pause = random.uniform(config.drag_end_pause_min, config.drag_end_pause_max)
  334. time.sleep(drag_end_pause)
  335. self._dispatch_button(MouseEventType.MOUSE_RELEASED, MouseButton.LEFT)
  336. def _get_control_points(
  337. self,
  338. start: Tuple[float, float],
  339. end: Tuple[float, float],
  340. ) -> Tuple[Tuple[float, float], Tuple[float, float]]:
  341. config = self._timing
  342. return random_control_points(
  343. start,
  344. end,
  345. config.curvature_min,
  346. config.curvature_max,
  347. config.curvature_asymmetry,
  348. config.short_distance_threshold,
  349. )
  350. def _dispatch_move(self, x: float, y: float) -> None:
  351. """发送 CDP 鼠标移动指令"""
  352. self._page.run_cdp(
  353. "Input.dispatchMouseEvent",
  354. type=MouseEventType.MOUSE_MOVED.value,
  355. x=int(round(x)),
  356. y=int(round(y))
  357. )
  358. self._position = (x, y)
  359. if self._debug:
  360. self._debug_draw_dot(x, y, radius=2, color='rgba(0,150,255,0.6)')
  361. def _dispatch_button(
  362. self,
  363. event_type: MouseEventType,
  364. button: MouseButton,
  365. click_count: int = 1,
  366. ) -> None:
  367. """发送 CDP 鼠标按键指令"""
  368. self._page.run_cdp(
  369. "Input.dispatchMouseEvent",
  370. type=event_type.value,
  371. button=button.value,
  372. x=int(round(self._position[0])),
  373. y=int(round(self._position[1])),
  374. clickCount=click_count
  375. )
  376. if self._debug and event_type == MouseEventType.MOUSE_PRESSED:
  377. self._debug_draw_dot(
  378. self._position[0], self._position[1], radius=6, color='rgba(255,50,50,0.9)'
  379. )
  380. def _debug_draw_dot(self, x: float, y: float, radius: int, color: str) -> None:
  381. """绘制轨迹调试点"""
  382. if not self._debug_initialized:
  383. self._page.run_js(self._DEBUG_INIT_JS)
  384. self._debug_initialized = True
  385. script = self._DEBUG_DOT_JS % (int(round(x)), int(round(y)), radius, color)
  386. self._page.run_js(script)