keyboard.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  1. # from DrissionPage import ChromiumPage
  2. # # 假设上述代码存为 human_keyboard.py
  3. # from human_keyboard import HumanKeyboard, Key
  4. # page = ChromiumPage()
  5. # page.get('https://www.baidu.com')
  6. # # 初始化高度拟人化键盘
  7. # keyboard = HumanKeyboard(page)
  8. # # 1. 点击搜索框以获得输入焦点
  9. # ele = page.ele('#kw')
  10. # ele.click()
  11. # # 2. 以极度拟人化的方式输入文本(有概率打错字又自己倒退回删并重新打)
  12. # # 因为开了 humanize,你会看到打字速度变化、可能突然思考停顿等效果。
  13. # keyboard.type_text("Hello World, let's test humanized typing!!!", humanize=True)
  14. # # 3. 按下回车键
  15. # keyboard.press(Key.ENTER)
  16. # # 4. 执行快捷键 (如 Ctrl + A 全选)
  17. # keyboard.hotkey(Key.CONTROL, Key.A)
  18. import logging
  19. import random
  20. import time
  21. import warnings
  22. from dataclasses import dataclass
  23. from enum import Enum
  24. from typing import Optional, Tuple, List
  25. # 引入 DrissionPage 的基础类以进行类型提示
  26. from DrissionPage import ChromiumPage
  27. logger = logging.getLogger(__name__)
  28. # ==========================================
  29. # 常量、枚举与键盘布局配置
  30. # ==========================================
  31. DEFAULT_TYPO_PROBABILITY = 0.02
  32. # 模拟错别字时需要的 QWERTY 键盘相邻按键映射表
  33. QWERTY_NEIGHBORS = {
  34. 'q': ['w', 'a', 's'], 'w': ['q', 'e', 'a', 's', 'd'], 'e': ['w', 'r', 's', 'd', 'f'],
  35. 'r': ['e', 't', 'd', 'f', 'g'], 't': ['r', 'y', 'f', 'g', 'h'], 'y': ['t', 'u', 'g', 'h', 'j'],
  36. 'u': ['y', 'i', 'h', 'j', 'k'], 'i': ['u', 'o', 'j', 'k', 'l'], 'o': ['i', 'p', 'k', 'l'],
  37. 'p': ['o', 'l'], 'a': ['q', 'w', 's', 'z', 'x'], 's': ['q', 'w', 'e', 'a', 'd', 'z', 'x', 'c'],
  38. 'd': ['w', 'e', 'r', 's', 'f', 'x', 'c', 'v'], 'f': ['e', 'r', 't', 'd', 'g', 'c', 'v', 'b'],
  39. 'g': ['r', 't', 'y', 'f', 'h', 'v', 'b', 'n'], 'h': ['t', 'y', 'u', 'g', 'j', 'b', 'n', 'm'],
  40. 'j': ['y', 'u', 'i', 'h', 'k', 'n', 'm'], 'k': ['u', 'i', 'o', 'j', 'l', 'm'],
  41. 'l': ['i', 'o', 'p', 'k'], 'z': ['a', 's', 'x'], 'x': ['a', 's', 'd', 'z', 'c'],
  42. 'c': ['s', 'd', 'f', 'x', 'v'], 'v': ['d', 'f', 'g', 'c', 'b'],
  43. 'b': ['f', 'g', 'h', 'v', 'n'], 'n': ['g', 'h', 'j', 'b', 'm'],
  44. 'm': ['h', 'j', 'k', 'n']
  45. }
  46. class TypoType(Enum):
  47. """错字类型"""
  48. ADJACENT = "adjacent" # 按到相邻键
  49. TRANSPOSE = "transpose" # 字母顺序颠倒
  50. DOUBLE = "double" # 重复按键
  51. SKIP = "skip" # 漏按
  52. MISSED_SPACE = "missed_space" # 漏按空格
  53. class Key(Enum):
  54. """
  55. 底层 CDP 需要的特殊按键定义。
  56. 格式: (key_name, windows_virtual_key_code)
  57. """
  58. BACKSPACE = ("Backspace", 8)
  59. TAB = ("Tab", 9)
  60. ENTER = ("Enter", 13)
  61. SHIFT = ("Shift", 16)
  62. CONTROL = ("Control", 17)
  63. ALT = ("Alt", 18)
  64. ESCAPE = ("Escape", 27)
  65. SPACE = (" ", 32)
  66. META = ("Meta", 91) # Win / Cmd 键
  67. @dataclass(frozen=True)
  68. class TypoResult:
  69. """错字生成结果"""
  70. typo_type: TypoType
  71. wrong_char: str = ''
  72. @dataclass(frozen=True)
  73. class TimingConfig:
  74. """高仿真打字节奏时间配置 (单位:秒)"""
  75. keystroke_min: float = 0.03
  76. keystroke_max: float = 0.12
  77. punctuation_min: float = 0.08
  78. punctuation_max: float = 0.18
  79. thinking_probability: float = 0.02
  80. thinking_min: float = 0.3
  81. thinking_max: float = 0.7
  82. distraction_probability: float = 0.005
  83. distraction_min: float = 0.5
  84. distraction_max: float = 1.2
  85. mistake_realize_min: float = 0.1
  86. mistake_realize_max: float = 0.25
  87. after_correction_min: float = 0.03
  88. after_correction_max: float = 0.08
  89. double_press_min: float = 0.02
  90. double_press_max: float = 0.05
  91. hesitation_min: float = 0.15
  92. hesitation_max: float = 0.3
  93. @dataclass(frozen=True)
  94. class TypoConfig:
  95. """各类错字发生的权重比例"""
  96. adjacent_weight: float = 0.55
  97. transpose_weight: float = 0.20
  98. double_weight: float = 0.12
  99. skip_weight: float = 0.08
  100. missed_space_weight: float = 0.05
  101. # ==========================================
  102. # 核心控制类
  103. # ==========================================
  104. class HumanKeyboard:
  105. """
  106. DrissionPage 的高仿真人类键盘控制器。
  107. 提供:
  108. - 公共按键操作(press, down, up, hotkey)
  109. - 私有文本输入,包含高度拟人的错别字产生、自我纠正、思考停顿与节奏变化。
  110. """
  111. PAUSE_CHARS = frozenset(' .,!?;:\n')
  112. def __init__(
  113. self,
  114. page: ChromiumPage,
  115. timing: Optional[TimingConfig] = None,
  116. typo_config: Optional[TypoConfig] = None,
  117. ):
  118. """
  119. 初始化键盘控制器。
  120. :param page: DrissionPage 的 ChromiumPage 或 ChromiumTab 实例
  121. :param timing: 节奏时间配置
  122. :param typo_config: 错别字权重配置
  123. """
  124. self._page = page
  125. self._timing = timing or TimingConfig()
  126. self._typo_config = typo_config or TypoConfig()
  127. def press(
  128. self,
  129. key: Key,
  130. modifiers: Optional[int] = None,
  131. interval: float = 0.1,
  132. ):
  133. """
  134. 按下一个键并释放。
  135. :param key: 要按的键 (来自 Key enum)
  136. :param modifiers: 组合键掩码 (Alt=1, Ctrl=2, Meta=4, Shift=8)
  137. :param interval: 按下和释放之间的延迟
  138. """
  139. logger.debug(f'Pressing key: {key.name} with modifiers: {modifiers}')
  140. self.down(key, modifiers)
  141. time.sleep(interval)
  142. self.up(key)
  143. def down(self, key: Key, modifiers: Optional[int] = None):
  144. """按下按键(不释放)"""
  145. key_name, code = key.value
  146. logger.debug(f'Key down: {key_name}')
  147. params = {
  148. "type": "keyDown",
  149. "key": key_name,
  150. "windowsVirtualKeyCode": code,
  151. "nativeVirtualKeyCode": code,
  152. }
  153. if modifiers is not None:
  154. params["modifiers"] = modifiers
  155. self._page.run_cdp("Input.dispatchKeyEvent", **params)
  156. def up(self, key: Key):
  157. """释放按键"""
  158. key_name, code = key.value
  159. logger.debug(f'Key up: {key_name}')
  160. self._page.run_cdp(
  161. "Input.dispatchKeyEvent",
  162. type="keyUp",
  163. key=key_name,
  164. windowsVirtualKeyCode=code,
  165. nativeVirtualKeyCode=code,
  166. )
  167. def hotkey(self, key1: Key, key2: Key, key3: Optional[Key] = None):
  168. """
  169. 执行组合快捷键(最多支持3个键)。
  170. 例如:keyboard.hotkey(Key.CONTROL, Key.C)
  171. """
  172. logger.debug(f'Hotkey: {key1.name} + {key2.name}' + (f' + {key3.name}' if key3 else ''))
  173. keys = [key1, key2]
  174. if key3 is not None:
  175. keys.append(key3)
  176. modifiers, non_modifiers = self._split_modifiers_and_keys(keys)
  177. modifier_value = self._calculate_modifier_value(modifiers)
  178. for key in non_modifiers:
  179. self.down(key, modifiers=modifier_value)
  180. time.sleep(0.05)
  181. time.sleep(0.1)
  182. for key in reversed(non_modifiers):
  183. self.up(key)
  184. time.sleep(0.05)
  185. def type_text(
  186. self,
  187. text: str,
  188. humanize: bool = False,
  189. interval: Optional[float] = None,
  190. ):
  191. """
  192. 逐字输入文本。
  193. :param text: 要输入的文本
  194. :param humanize: 是否开启拟人化(变速、犯错后退格重打、思考停顿)
  195. :param interval: (已弃用) 字符输入间隔
  196. """
  197. if interval is not None:
  198. warnings.warn(
  199. '"interval" 参数已被弃用。请使用 "humanize=True" 开启真实的人类打字模拟。',
  200. DeprecationWarning,
  201. stacklevel=2,
  202. )
  203. if humanize:
  204. self._type_text_humanized(text)
  205. return
  206. for current_char in text:
  207. self._type_char(current_char)
  208. time.sleep(0.05)
  209. def _type_text_humanized(self, text: str):
  210. """高拟人化文本输入核心逻辑"""
  211. char_index = 0
  212. while char_index < len(text):
  213. current_char = text[char_index]
  214. next_char = text[char_index + 1] if char_index + 1 < len(text) else None
  215. # 判断是否打错字并执行错字修补逻辑,返回 True 表示下个字应跳过
  216. should_skip_next = self._process_char_with_typo(current_char, next_char)
  217. if should_skip_next:
  218. char_index += 1
  219. self._apply_realistic_delay(current_char)
  220. char_index += 1
  221. def _type_char(self, char: str):
  222. """通过底层 CDP 输入单个普通字符"""
  223. # 下拉字符事件
  224. self._page.run_cdp(
  225. "Input.dispatchKeyEvent",
  226. type="keyDown",
  227. key=char,
  228. text=char,
  229. unmodifiedText=char
  230. )
  231. # 抬起字符事件
  232. self._page.run_cdp(
  233. "Input.dispatchKeyEvent",
  234. type="keyUp",
  235. key=char
  236. )
  237. def _type_backspace(self):
  238. """输入退格键(用来模拟人打错字回删)"""
  239. self.press(Key.BACKSPACE)
  240. def _process_char_with_typo(
  241. self,
  242. current_char: str,
  243. next_char: Optional[str],
  244. ) -> bool:
  245. """处理错别字产生与修复。返回 True 表示下个字符需要跳过处理"""
  246. if not self._should_make_typo():
  247. self._type_char(current_char)
  248. return False
  249. typo = self._generate_typo(current_char, next_char)
  250. return self._handle_typo(current_char, next_char, typo)
  251. def _handle_typo(
  252. self,
  253. current_char: str,
  254. next_char: Optional[str],
  255. typo: TypoResult,
  256. ) -> bool:
  257. """根据错别字类型执行具体的打错和修正行为"""
  258. if typo.typo_type == TypoType.ADJACENT:
  259. self._do_adjacent_typo(current_char, typo.wrong_char)
  260. return False
  261. if typo.typo_type == TypoType.TRANSPOSE and next_char:
  262. self._do_transpose_typo(current_char, next_char)
  263. return True
  264. if typo.typo_type == TypoType.DOUBLE:
  265. self._do_double_typo(current_char)
  266. return False
  267. if typo.typo_type == TypoType.SKIP:
  268. self._do_skip_typo(current_char)
  269. return False
  270. if typo.typo_type == TypoType.MISSED_SPACE and current_char == ' ' and next_char:
  271. self._do_missed_space_typo(current_char, next_char)
  272. return True
  273. self._type_char(current_char)
  274. return False
  275. def _do_adjacent_typo(self, correct_char: str, wrong_char: str):
  276. """打错相邻键 -> 停顿 -> 退格删除 -> 补上正确的"""
  277. timing = self._timing
  278. self._type_char(wrong_char)
  279. time.sleep(random.uniform(timing.mistake_realize_min, timing.mistake_realize_max))
  280. self._type_backspace()
  281. time.sleep(random.uniform(timing.after_correction_min, timing.after_correction_max))
  282. self._type_char(correct_char)
  283. def _do_transpose_typo(self, current_char: str, next_char: str):
  284. """字母顺序打反 -> 停顿 -> 删两格 -> 重新打正确的顺序"""
  285. timing = self._timing
  286. self._type_char(next_char)
  287. time.sleep(random.uniform(timing.keystroke_min, timing.keystroke_max))
  288. self._type_char(current_char)
  289. time.sleep(random.uniform(timing.mistake_realize_min, timing.mistake_realize_max))
  290. self._type_backspace()
  291. self._type_backspace()
  292. time.sleep(random.uniform(timing.after_correction_min, timing.after_correction_max))
  293. self._type_char(current_char)
  294. time.sleep(random.uniform(timing.keystroke_min, timing.keystroke_max))
  295. self._type_char(next_char)
  296. def _do_double_typo(self, current_char: str):
  297. """不小心连按两次 -> 停顿 -> 退格删掉多余的一个"""
  298. timing = self._timing
  299. self._type_char(current_char)
  300. time.sleep(random.uniform(timing.double_press_min, timing.double_press_max))
  301. self._type_char(current_char)
  302. time.sleep(random.uniform(timing.mistake_realize_min, timing.mistake_realize_max))
  303. self._type_backspace()
  304. def _do_skip_typo(self, current_char: str):
  305. """大脑卡壳漏打:犹豫停顿 -> 接着打正常的"""
  306. timing = self._timing
  307. time.sleep(random.uniform(timing.hesitation_min, timing.hesitation_max))
  308. self._type_char(current_char)
  309. def _do_missed_space_typo(self, space_char: str, next_char: str):
  310. """漏敲空格 -> 接着敲了下一个字 -> 发现错误 -> 退格删掉 -> 补空格 -> 重打下个字"""
  311. timing = self._timing
  312. self._type_char(next_char)
  313. time.sleep(random.uniform(timing.mistake_realize_min, timing.mistake_realize_max))
  314. self._type_backspace()
  315. time.sleep(random.uniform(timing.after_correction_min, timing.after_correction_max))
  316. self._type_char(space_char)
  317. time.sleep(random.uniform(timing.after_correction_min, timing.after_correction_max))
  318. self._type_char(next_char)
  319. def _apply_realistic_delay(self, typed_char: str):
  320. """根据输入的不同字符和人的状态,加入随机延迟"""
  321. timing = self._timing
  322. delay = random.uniform(timing.keystroke_min, timing.keystroke_max)
  323. # 遇到标点符号,延迟加长
  324. if typed_char in self.PAUSE_CHARS:
  325. delay += random.uniform(timing.punctuation_min, timing.punctuation_max)
  326. # 模拟突然思考
  327. if random.random() < timing.thinking_probability:
  328. delay += random.uniform(timing.thinking_min, timing.thinking_max)
  329. # 模拟被打断分心
  330. if random.random() < timing.distraction_probability:
  331. delay += random.uniform(timing.distraction_min, timing.distraction_max)
  332. time.sleep(delay)
  333. @staticmethod
  334. def _should_make_typo() -> bool:
  335. """决定此时是否发生手误"""
  336. return random.random() < DEFAULT_TYPO_PROBABILITY
  337. def _generate_typo(self, current_char: str, next_char: Optional[str]) -> TypoResult:
  338. """生成一个具体的错误结果"""
  339. typo_type = self._select_typo_type()
  340. return self._create_typo(typo_type, current_char, next_char)
  341. def _select_typo_type(self) -> TypoType:
  342. """通过权重随机挑选错误类型"""
  343. config = self._typo_config
  344. typo_types = [
  345. TypoType.ADJACENT,
  346. TypoType.TRANSPOSE,
  347. TypoType.DOUBLE,
  348. TypoType.SKIP,
  349. TypoType.MISSED_SPACE,
  350. ]
  351. typo_weights = [
  352. config.adjacent_weight,
  353. config.transpose_weight,
  354. config.double_weight,
  355. config.skip_weight,
  356. config.missed_space_weight,
  357. ]
  358. return random.choices(typo_types, weights=typo_weights, k=1)[0]
  359. def _create_typo(
  360. self,
  361. typo_type: TypoType,
  362. current_char: str,
  363. next_char: Optional[str],
  364. ) -> TypoResult:
  365. """构建错字返回结果"""
  366. if typo_type == TypoType.ADJACENT:
  367. return self._create_adjacent_typo(current_char)
  368. elif typo_type == TypoType.TRANSPOSE:
  369. return self._create_transpose_typo(current_char, next_char)
  370. elif typo_type == TypoType.MISSED_SPACE:
  371. return self._create_missed_space_typo(current_char)
  372. elif typo_type == TypoType.DOUBLE:
  373. return TypoResult(typo_type=TypoType.DOUBLE, wrong_char=current_char)
  374. else:
  375. return TypoResult(typo_type=TypoType.SKIP)
  376. def _create_transpose_typo(self, current_char: str, next_char: Optional[str]) -> TypoResult:
  377. """顺序颠倒错误(如果下个不是字母则降级为按错相邻键)"""
  378. if next_char and next_char.isalpha():
  379. return TypoResult(typo_type=TypoType.TRANSPOSE, wrong_char=next_char)
  380. return self._create_adjacent_typo(current_char)
  381. def _create_missed_space_typo(self, current_char: str) -> TypoResult:
  382. """漏打空格错误(如果当前本不是空格则降级为按错相邻键)"""
  383. if current_char == ' ':
  384. return TypoResult(typo_type=TypoType.MISSED_SPACE)
  385. return self._create_adjacent_typo(current_char)
  386. @staticmethod
  387. def _create_adjacent_typo(original_char: str) -> TypoResult:
  388. """生成按错相邻键的错误"""
  389. lowercase_char = original_char.lower()
  390. # 如果字典中没有对应相邻键,降级成连按两下
  391. if lowercase_char not in QWERTY_NEIGHBORS:
  392. return TypoResult(typo_type=TypoType.DOUBLE, wrong_char=original_char)
  393. adjacent_char = random.choice(QWERTY_NEIGHBORS[lowercase_char])
  394. if original_char.isupper():
  395. adjacent_char = adjacent_char.upper()
  396. return TypoResult(typo_type=TypoType.ADJACENT, wrong_char=adjacent_char)
  397. @staticmethod
  398. def _split_modifiers_and_keys(keys: List[Key]) -> Tuple[List[Key], List[Key]]:
  399. """分离修饰键(Ctrl/Shift等)和普通键"""
  400. modifier_keys = {Key.CONTROL, Key.SHIFT, Key.ALT, Key.META}
  401. modifiers = [key for key in keys if key in modifier_keys]
  402. non_modifiers = [key for key in keys if key not in modifier_keys]
  403. return modifiers, non_modifiers
  404. @staticmethod
  405. def _calculate_modifier_value(modifiers: List[Key]) -> Optional[int]:
  406. """计算 CDP 事件所需的 modifiers 掩码值"""
  407. if not modifiers:
  408. return None
  409. modifier_map = {
  410. Key.ALT: 1,
  411. Key.CONTROL: 2,
  412. Key.META: 4,
  413. Key.SHIFT: 8,
  414. }
  415. value = sum(modifier_map.get(mod, 0) for mod in modifiers)
  416. return value if value > 0 else None