mouse.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559
  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.28
  50. curvature_asymmetry: float = 0.6
  51. short_distance_threshold: float = 50.0
  52. tremor_amplitude: float = 0.85
  53. overshoot_probability: float = 0.22
  54. overshoot_distance_min: float = 0.03
  55. overshoot_distance_max: float = 0.10
  56. overshoot_speed_threshold: float = 260.0
  57. pre_click_pause_min: float = 0.04
  58. pre_click_pause_max: float = 0.16
  59. click_hold_min: float = 0.04
  60. click_hold_max: float = 0.12
  61. double_click_interval_min: float = 0.05
  62. double_click_interval_max: float = 0.09
  63. drag_start_pause_min: float = 0.06
  64. drag_start_pause_max: float = 0.16
  65. drag_end_pause_min: float = 0.04
  66. drag_end_pause_max: float = 0.12
  67. micro_pause_probability: float = 0.08
  68. micro_pause_min: float = 0.010
  69. micro_pause_max: float = 0.030
  70. min_duration: float = 0.08
  71. max_duration: float = 2.2
  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] = self._read_initial_position()
  113. self._debug = debug
  114. self._debug_initialized = False
  115. self._session_profile = self._build_session_profile()
  116. @property
  117. def timing(self) -> MouseTimingConfig:
  118. return self._timing
  119. @timing.setter
  120. def timing(self, config: MouseTimingConfig) -> None:
  121. self._timing = config
  122. @property
  123. def debug(self) -> bool:
  124. return self._debug
  125. @debug.setter
  126. def debug(self, value: bool) -> None:
  127. self._debug = value
  128. self._debug_initialized = False
  129. def move(self, x: float, y: float, *, humanize: bool = False) -> None:
  130. """移动鼠标"""
  131. if humanize:
  132. self._move_humanized(x, y)
  133. return
  134. self._dispatch_move(x, y)
  135. def human_click_ele(self, element):
  136. """
  137. 拟人化点击元素 (修复版)
  138. :param element: DrissionPage 的元素对象
  139. """
  140. # 1. 强制滚动,把元素尽量移到屏幕正中间,防止被顶部导航栏或底部悬浮窗遮盖
  141. element.scroll.to_see(center=True)
  142. time.sleep(0.5) # 等待滚动动画完成,防止移动时坐标还在变化
  143. # 2. 【核心修复】获取相对于当前屏幕视口的坐标,而不是页面绝对坐标
  144. # 注意:这里使用的是 viewport_midpoint
  145. mid_x, mid_y = element.rect.viewport_midpoint
  146. # 3. 如果元素太靠近边缘,可能会获取失败,做一个基础保护
  147. if mid_x is None or mid_y is None:
  148. logger.warning("Failed to get viewport midpoint, falling back to absolute midpoint.")
  149. mid_x, mid_y = element.rect.midpoint
  150. self.click(mid_x, mid_y, humanize=True)
  151. def click(
  152. self,
  153. x: float,
  154. y: float,
  155. *,
  156. button: MouseButton = MouseButton.LEFT,
  157. click_count: int = 1,
  158. humanize: bool = False,
  159. ) -> None:
  160. """点击鼠标"""
  161. if humanize:
  162. self._click_humanized(x, y, button, click_count)
  163. return
  164. self._dispatch_move(x, y)
  165. self._dispatch_button(MouseEventType.MOUSE_PRESSED, button, click_count)
  166. self._dispatch_button(MouseEventType.MOUSE_RELEASED, button, click_count)
  167. def double_click(
  168. self,
  169. x: float,
  170. y: float,
  171. *,
  172. button: MouseButton = MouseButton.LEFT,
  173. humanize: bool = False,
  174. ) -> None:
  175. """双击鼠标"""
  176. self.click(x, y, button=button, click_count=2, humanize=humanize)
  177. def down(self, button: MouseButton = MouseButton.LEFT) -> None:
  178. """按下鼠标按键"""
  179. self._dispatch_button(MouseEventType.MOUSE_PRESSED, button)
  180. def up(self, button: MouseButton = MouseButton.LEFT) -> None:
  181. """释放鼠标按键"""
  182. self._dispatch_button(MouseEventType.MOUSE_RELEASED, button)
  183. def drag(
  184. self,
  185. start_x: float,
  186. start_y: float,
  187. end_x: float,
  188. end_y: float,
  189. *,
  190. humanize: bool = False,
  191. ) -> None:
  192. """拖拽鼠标"""
  193. if humanize:
  194. self._drag_humanized(start_x, start_y, end_x, end_y)
  195. return
  196. self._dispatch_move(start_x, start_y)
  197. self._dispatch_button(MouseEventType.MOUSE_PRESSED, MouseButton.LEFT)
  198. self._dispatch_move(end_x, end_y)
  199. self._dispatch_button(MouseEventType.MOUSE_RELEASED, MouseButton.LEFT)
  200. def _move_humanized(self, target_x: float, target_y: float, custom_duration: Optional[float] = None) -> None:
  201. """拟人化移动核心逻辑"""
  202. start = self._position
  203. target = (target_x, target_y)
  204. distance = math.hypot(target_x - start[0], target_y - start[1])
  205. if distance < 1.0:
  206. self._dispatch_move(target_x, target_y)
  207. return
  208. config = self._timing
  209. if custom_duration is not None:
  210. duration = custom_duration
  211. else:
  212. duration = fitts_duration(distance, 20.0, config.fitts_a, config.fitts_b)
  213. duration = max(config.min_duration, min(duration, config.max_duration))
  214. duration *= self._session_profile["speed_scale"]
  215. should_overshoot = (
  216. distance > config.overshoot_speed_threshold
  217. and random.random() < config.overshoot_probability * self._session_profile["overshoot_bias"]
  218. )
  219. if should_overshoot:
  220. self._move_with_overshoot(start, target, duration)
  221. else:
  222. cp1, cp2 = self._get_control_points(start, target)
  223. self._perform_movement_loop(start, target, duration, cp1, cp2)
  224. self._perform_final_correction(target_x, target_y)
  225. def _move_with_overshoot(
  226. self,
  227. start: Tuple[float, float],
  228. target: Tuple[float, float],
  229. duration: float,
  230. ) -> None:
  231. """带过冲现象的移动"""
  232. config = self._timing
  233. overshoot_fraction = random.uniform(
  234. config.overshoot_distance_min, config.overshoot_distance_max
  235. )
  236. dx = target[0] - start[0]
  237. dy = target[1] - start[1]
  238. overshoot = (target[0] + dx * overshoot_fraction, target[1] + dy * overshoot_fraction)
  239. cp1, cp2 = self._get_control_points(start, overshoot)
  240. self._perform_movement_loop(start, overshoot, duration * 0.85, cp1, cp2)
  241. cp1, cp2 = self._get_control_points(overshoot, target)
  242. self._perform_movement_loop(overshoot, target, duration * 0.15, cp1, cp2)
  243. def _perform_movement_loop(
  244. self,
  245. start: Tuple[float, float],
  246. end: Tuple[float, float],
  247. duration: float,
  248. cp1: Tuple[float, float],
  249. cp2: Tuple[float, float],
  250. ) -> None:
  251. """按帧渲染并执行移动循环"""
  252. config = self._timing
  253. start_time = time.perf_counter()
  254. prev = (start[0], start[1], start_time)
  255. segment_pause_used = False
  256. while True:
  257. now = time.perf_counter()
  258. elapsed = now - start_time
  259. if elapsed >= duration:
  260. break
  261. progress = elapsed / duration
  262. eased = minimum_jerk(progress)
  263. x, y = bezier_2d(eased, 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 = self._sample_frame_delay()
  270. time.sleep(max(0.001, frame_delay))
  271. if (not segment_pause_used) and random.random() < config.micro_pause_probability:
  272. pause = self._sample_micro_pause()
  273. time.sleep(pause)
  274. start_time += pause
  275. segment_pause_used = True
  276. @staticmethod
  277. def _compute_tremor_sigma(
  278. x: float,
  279. y: float,
  280. now: float,
  281. prev: Tuple[float, float, float],
  282. config: MouseTimingConfig,
  283. ) -> float:
  284. """动态计算手抖幅度"""
  285. dt = now - prev[2]
  286. if dt > 0:
  287. velocity = math.hypot(x - prev[0], y - prev[1]) / dt
  288. speed_factor = max(0.2, 1.0 - velocity / 500.0)
  289. else:
  290. speed_factor = 1.0
  291. return config.tremor_amplitude * speed_factor
  292. def _click_humanized(
  293. self,
  294. x: float,
  295. y: float,
  296. button: MouseButton,
  297. click_count: int,
  298. ) -> None:
  299. """拟人化点击"""
  300. self._move_humanized(x, y)
  301. self._micro_adjust_towards(x, y)
  302. pre_pause = self._sample_pre_click_pause()
  303. time.sleep(pre_pause)
  304. for i in range(click_count):
  305. current_count = i + 1
  306. self._dispatch_button(MouseEventType.MOUSE_PRESSED, button, current_count)
  307. hold = self._sample_click_hold()
  308. time.sleep(hold)
  309. self._dispatch_button(MouseEventType.MOUSE_RELEASED, button, current_count)
  310. if current_count < click_count:
  311. interval = self._sample_double_click_interval()
  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 _sample_frame_delay(self) -> float:
  362. config = self._timing
  363. base = config.frame_interval * self._session_profile["tempo_scale"]
  364. spread = config.frame_interval_variance * self._session_profile["tempo_jitter"]
  365. return random.gauss(base, max(0.001, spread))
  366. def _sample_pre_click_pause(self) -> float:
  367. config = self._timing
  368. return max(
  369. 0.0,
  370. random.gauss(
  371. (config.pre_click_pause_min + config.pre_click_pause_max) / 2.0,
  372. (config.pre_click_pause_max - config.pre_click_pause_min) / 6.0,
  373. ),
  374. )
  375. def _sample_click_hold(self) -> float:
  376. config = self._timing
  377. return max(
  378. 0.0,
  379. random.gauss(
  380. (config.click_hold_min + config.click_hold_max) / 2.0,
  381. (config.click_hold_max - config.click_hold_min) / 6.0,
  382. ),
  383. )
  384. def _sample_double_click_interval(self) -> float:
  385. config = self._timing
  386. return max(
  387. 0.0,
  388. random.gauss(
  389. (config.double_click_interval_min + config.double_click_interval_max) / 2.0,
  390. (config.double_click_interval_max - config.double_click_interval_min) / 6.0,
  391. ),
  392. )
  393. def _sample_micro_pause(self) -> float:
  394. config = self._timing
  395. return max(
  396. 0.0,
  397. random.gauss(
  398. (config.micro_pause_min + config.micro_pause_max) / 2.0,
  399. (config.micro_pause_max - config.micro_pause_min) / 6.0,
  400. ),
  401. )
  402. def _dispatch_button(
  403. self,
  404. event_type: MouseEventType,
  405. button: MouseButton,
  406. click_count: int = 1,
  407. ) -> None:
  408. """发送 CDP 鼠标按键指令"""
  409. self._page.run_cdp(
  410. "Input.dispatchMouseEvent",
  411. type=event_type.value,
  412. button=button.value,
  413. x=int(round(self._position[0])),
  414. y=int(round(self._position[1])),
  415. clickCount=click_count
  416. )
  417. if self._debug and event_type == MouseEventType.MOUSE_PRESSED:
  418. self._debug_draw_dot(
  419. self._position[0], self._position[1], radius=6, color='rgba(255,50,50,0.9)'
  420. )
  421. def _debug_draw_dot(self, x: float, y: float, radius: int, color: str) -> None:
  422. """绘制轨迹调试点"""
  423. if not self._debug_initialized:
  424. self._page.run_js(self._DEBUG_INIT_JS)
  425. self._debug_initialized = True
  426. script = self._DEBUG_DOT_JS % (int(round(x)), int(round(y)), radius, color)
  427. self._page.run_js(script)
  428. def _read_initial_position(self) -> Tuple[float, float]:
  429. try:
  430. data = self._page.run_js("return { x: window.screenX || 0, y: window.screenY || 0 };")
  431. if isinstance(data, dict):
  432. return (float(data.get("x", 0.0)), float(data.get("y", 0.0)))
  433. except Exception:
  434. pass
  435. return (0.0, 0.0)
  436. def _build_session_profile(self) -> dict:
  437. return {
  438. "speed_scale": random.uniform(0.90, 1.15),
  439. "tempo_scale": random.uniform(0.92, 1.10),
  440. "tempo_jitter": random.uniform(0.85, 1.25),
  441. "overshoot_bias": random.uniform(0.65, 1.15),
  442. }
  443. def _perform_final_correction(self, target_x: float, target_y: float) -> None:
  444. current_x, current_y = self._position
  445. distance = math.hypot(target_x - current_x, target_y - current_y)
  446. if distance <= 1.5:
  447. self._dispatch_move(target_x, target_y)
  448. return
  449. correction_end = (target_x, target_y)
  450. cp1, cp2 = self._get_control_points((current_x, current_y), correction_end)
  451. duration = min(self._timing.max_duration, max(self._timing.min_duration, 0.12 + distance / 900.0))
  452. self._perform_movement_loop((current_x, current_y), correction_end, duration, cp1, cp2)
  453. self._dispatch_move(target_x, target_y)
  454. def _micro_adjust_towards(self, x: float, y: float) -> None:
  455. current_x, current_y = self._position
  456. distance = math.hypot(x - current_x, y - current_y)
  457. if distance < 5.0:
  458. return
  459. offset_x = random.uniform(-1.0, 1.0)
  460. offset_y = random.uniform(-1.0, 1.0)
  461. self._dispatch_move(x + offset_x, y + offset_y)
  462. time.sleep(random.uniform(0.006, 0.018))
  463. self._dispatch_move(x, y)