mouse.py 15 KB

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