mouse.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  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. :param button: 鼠标按键
  139. :param click_count: 点击次数 (默认 1 次)
  140. """
  141. mid_x, mid_y = element.rect.midpoint
  142. self.click(mid_x, mid_y, humanize=True)
  143. def click(
  144. self,
  145. x: float,
  146. y: float,
  147. *,
  148. button: MouseButton = MouseButton.LEFT,
  149. click_count: int = 1,
  150. humanize: bool = False,
  151. ) -> None:
  152. """点击鼠标"""
  153. if humanize:
  154. self._click_humanized(x, y, button, click_count)
  155. return
  156. self._dispatch_move(x, y)
  157. self._dispatch_button(MouseEventType.MOUSE_PRESSED, button, click_count)
  158. self._dispatch_button(MouseEventType.MOUSE_RELEASED, button, click_count)
  159. def double_click(
  160. self,
  161. x: float,
  162. y: float,
  163. *,
  164. button: MouseButton = MouseButton.LEFT,
  165. humanize: bool = False,
  166. ) -> None:
  167. """双击鼠标"""
  168. self.click(x, y, button=button, click_count=2, humanize=humanize)
  169. def down(self, button: MouseButton = MouseButton.LEFT) -> None:
  170. """按下鼠标按键"""
  171. self._dispatch_button(MouseEventType.MOUSE_PRESSED, button)
  172. def up(self, button: MouseButton = MouseButton.LEFT) -> None:
  173. """释放鼠标按键"""
  174. self._dispatch_button(MouseEventType.MOUSE_RELEASED, button)
  175. def drag(
  176. self,
  177. start_x: float,
  178. start_y: float,
  179. end_x: float,
  180. end_y: float,
  181. *,
  182. humanize: bool = False,
  183. ) -> None:
  184. """拖拽鼠标"""
  185. if humanize:
  186. self._drag_humanized(start_x, start_y, end_x, end_y)
  187. return
  188. self._dispatch_move(start_x, start_y)
  189. self._dispatch_button(MouseEventType.MOUSE_PRESSED, MouseButton.LEFT)
  190. self._dispatch_move(end_x, end_y)
  191. self._dispatch_button(MouseEventType.MOUSE_RELEASED, MouseButton.LEFT)
  192. def _move_humanized(self, target_x: float, target_y: float, custom_duration: Optional[float] = None) -> None:
  193. """拟人化移动核心逻辑"""
  194. start = self._position
  195. target = (target_x, target_y)
  196. distance = math.hypot(target_x - start[0], target_y - start[1])
  197. if distance < 1.0:
  198. self._dispatch_move(target_x, target_y)
  199. return
  200. config = self._timing
  201. if custom_duration is not None:
  202. duration = custom_duration
  203. else:
  204. duration = fitts_duration(distance, 20.0, config.fitts_a, config.fitts_b)
  205. duration = max(config.min_duration, min(duration, config.max_duration))
  206. should_overshoot = (
  207. distance > config.overshoot_speed_threshold
  208. and random.random() < config.overshoot_probability
  209. )
  210. if should_overshoot:
  211. self._move_with_overshoot(start, target, duration)
  212. else:
  213. cp1, cp2 = self._get_control_points(start, target)
  214. self._perform_movement_loop(start, target, duration, cp1, cp2)
  215. self._dispatch_move(target_x, target_y)
  216. def _move_with_overshoot(
  217. self,
  218. start: Tuple[float, float],
  219. target: Tuple[float, float],
  220. duration: float,
  221. ) -> None:
  222. """带过冲现象的移动"""
  223. config = self._timing
  224. overshoot_fraction = random.uniform(
  225. config.overshoot_distance_min, config.overshoot_distance_max
  226. )
  227. dx = target[0] - start[0]
  228. dy = target[1] - start[1]
  229. overshoot = (target[0] + dx * overshoot_fraction, target[1] + dy * overshoot_fraction)
  230. cp1, cp2 = self._get_control_points(start, overshoot)
  231. self._perform_movement_loop(start, overshoot, duration * 0.85, cp1, cp2)
  232. cp1, cp2 = self._get_control_points(overshoot, target)
  233. self._perform_movement_loop(overshoot, target, duration * 0.15, cp1, cp2)
  234. def _perform_movement_loop(
  235. self,
  236. start: Tuple[float, float],
  237. end: Tuple[float, float],
  238. duration: float,
  239. cp1: Tuple[float, float],
  240. cp2: Tuple[float, float],
  241. ) -> None:
  242. """按帧渲染并执行移动循环"""
  243. config = self._timing
  244. start_time = time.perf_counter()
  245. prev = (start[0], start[1], start_time)
  246. while True:
  247. now = time.perf_counter()
  248. elapsed = now - start_time
  249. if elapsed >= duration:
  250. break
  251. t = minimum_jerk(elapsed / duration)
  252. x, y = bezier_2d(t, start, cp1, cp2, end)
  253. sigma = self._compute_tremor_sigma(x, y, now, prev, config)
  254. x += random.gauss(0, sigma)
  255. y += random.gauss(0, sigma)
  256. self._dispatch_move(x, y)
  257. prev = (x, y, now)
  258. frame_delay = config.frame_interval + random.uniform(
  259. -config.frame_interval_variance, config.frame_interval_variance
  260. )
  261. time.sleep(max(0.001, frame_delay))
  262. if random.random() < config.micro_pause_probability:
  263. pause = random.uniform(config.micro_pause_min, config.micro_pause_max)
  264. time.sleep(pause)
  265. start_time += pause # 补偿停顿时间
  266. @staticmethod
  267. def _compute_tremor_sigma(
  268. x: float,
  269. y: float,
  270. now: float,
  271. prev: Tuple[float, float, float],
  272. config: MouseTimingConfig,
  273. ) -> float:
  274. """动态计算手抖幅度"""
  275. dt = now - prev[2]
  276. if dt > 0:
  277. velocity = math.hypot(x - prev[0], y - prev[1]) / dt
  278. speed_factor = max(0.2, 1.0 - velocity / 500.0)
  279. else:
  280. speed_factor = 1.0
  281. return config.tremor_amplitude * speed_factor
  282. def _click_humanized(
  283. self,
  284. x: float,
  285. y: float,
  286. button: MouseButton,
  287. click_count: int,
  288. ) -> None:
  289. """拟人化点击"""
  290. config = self._timing
  291. self._move_humanized(x, y)
  292. pre_pause = random.uniform(config.pre_click_pause_min, config.pre_click_pause_max)
  293. time.sleep(pre_pause)
  294. for i in range(click_count):
  295. current_count = i + 1
  296. self._dispatch_button(MouseEventType.MOUSE_PRESSED, button, current_count)
  297. hold = random.uniform(config.click_hold_min, config.click_hold_max)
  298. time.sleep(hold)
  299. self._dispatch_button(MouseEventType.MOUSE_RELEASED, button, current_count)
  300. if current_count < click_count:
  301. interval = random.uniform(
  302. config.double_click_interval_min,
  303. config.double_click_interval_max,
  304. )
  305. time.sleep(interval)
  306. def _drag_humanized(
  307. self,
  308. start_x: float,
  309. start_y: float,
  310. end_x: float,
  311. end_y: float,
  312. ) -> None:
  313. """拟人化拖拽"""
  314. config = self._timing
  315. self._move_humanized(start_x, start_y)
  316. self._dispatch_button(MouseEventType.MOUSE_PRESSED, MouseButton.LEFT)
  317. drag_start_pause = random.uniform(config.drag_start_pause_min, config.drag_start_pause_max)
  318. time.sleep(drag_start_pause)
  319. start = self._position
  320. distance = math.hypot(end_x - start[0], end_y - start[1])
  321. duration = fitts_duration(distance, 20.0, config.fitts_a, config.fitts_b)
  322. duration = max(config.min_duration, min(duration, config.max_duration))
  323. cp1, cp2 = self._get_control_points(start, (end_x, end_y))
  324. self._perform_movement_loop(start, (end_x, end_y), duration, cp1, cp2)
  325. self._dispatch_move(end_x, end_y)
  326. drag_end_pause = random.uniform(config.drag_end_pause_min, config.drag_end_pause_max)
  327. time.sleep(drag_end_pause)
  328. self._dispatch_button(MouseEventType.MOUSE_RELEASED, MouseButton.LEFT)
  329. def _get_control_points(
  330. self,
  331. start: Tuple[float, float],
  332. end: Tuple[float, float],
  333. ) -> Tuple[Tuple[float, float], Tuple[float, float]]:
  334. config = self._timing
  335. return random_control_points(
  336. start,
  337. end,
  338. config.curvature_min,
  339. config.curvature_max,
  340. config.curvature_asymmetry,
  341. config.short_distance_threshold,
  342. )
  343. def _dispatch_move(self, x: float, y: float) -> None:
  344. """发送 CDP 鼠标移动指令"""
  345. self._page.run_cdp(
  346. "Input.dispatchMouseEvent",
  347. type=MouseEventType.MOUSE_MOVED.value,
  348. x=int(round(x)),
  349. y=int(round(y))
  350. )
  351. self._position = (x, y)
  352. if self._debug:
  353. self._debug_draw_dot(x, y, radius=2, color='rgba(0,150,255,0.6)')
  354. def _dispatch_button(
  355. self,
  356. event_type: MouseEventType,
  357. button: MouseButton,
  358. click_count: int = 1,
  359. ) -> None:
  360. """发送 CDP 鼠标按键指令"""
  361. self._page.run_cdp(
  362. "Input.dispatchMouseEvent",
  363. type=event_type.value,
  364. button=button.value,
  365. x=int(round(self._position[0])),
  366. y=int(round(self._position[1])),
  367. clickCount=click_count
  368. )
  369. if self._debug and event_type == MouseEventType.MOUSE_PRESSED:
  370. self._debug_draw_dot(
  371. self._position[0], self._position[1], radius=6, color='rgba(255,50,50,0.9)'
  372. )
  373. def _debug_draw_dot(self, x: float, y: float, radius: int, color: str) -> None:
  374. """绘制轨迹调试点"""
  375. if not self._debug_initialized:
  376. self._page.run_js(self._DEBUG_INIT_JS)
  377. self._debug_initialized = True
  378. script = self._DEBUG_DOT_JS % (int(round(x)), int(round(y)), radius, color)
  379. self._page.run_js(script)