一句话总结:CDP 的 Input 域让你在浏览器层面直接模拟鼠标点击、键盘输入、文本插入和触摸操作,比 Selenium 更底层、更精确,甚至能模拟出真人操作的时间痕迹。


目录

  1. 为什么用 CDP 模拟输入
  2. 前置准备:连接 Chrome
  3. 理解 CDP 坐标系统
  4. 鼠标点击模拟
  5. 鼠标移动与拖拽
  6. 键盘输入模拟
  7. 文本插入
  8. 触摸事件模拟
  9. 完整参考:CDP InputSimulator 类
  10. 常见踩坑与最佳实践

为什么用 CDP 模拟输入

相比 Selenium/Playwright 的 click()send_keys(),CDP 的输入模拟有几个独特优势:

特性 Selenium CDP Input
鼠标按下/释放分离 ❌ 只有 click() ✅ 精确控制 mousePressed/mouseReleased
多按键组合 ⚠️ ActionChains 复杂 ✅ 任意组合键
拖拽操作 ⚠️ drag_and_drop 不稳定 ✅ 像素级控制
触摸事件 ❌ 需要 TouchAction ✅ 原生触摸事件
插入文本 ✅ send_keys ✅ insertText(绕过事件监听)
悬停状态 ✅ move_to ✅ mouseMoved 精确定位

关键区别:CDP 的输入模拟直接注入到浏览器的输入事件流中,不经过任何 JS 事件包装层。这意味着:

  • 模拟的点击不会被 event.isTrusted 检测发现(对反爬有重大意义)
  • 支持更精确的时间控制
  • 可以模拟键盘上没有的”虚拟键”

前置准备:连接 Chrome

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import asyncio, json, websockets

CDP_URL = "ws://127.0.0.1:9222/devtools/browser/..."

CMD_ID = [0]
async def cdp(ws, method, params=None, session_id=None):
"""发送 CDP 命令并等待返回"""
CMD_ID[0] += 1
msg = {"id": CMD_ID[0], "method": method, "params": params or {}}
if session_id:
msg["sessionId"] = session_id
await ws.send(json.dumps(msg))
async for resp in ws:
data = json.loads(resp)
if data.get("id") == CMD_ID[0]:
return data.get("result", {})

async def attach_to_page(ws):
"""连接到页面目标并返回 session_id"""
targets = await cdp(ws, "Target.getTargets")
target_id = targets["targetInfos"][0]["targetId"]
session = await cdp(ws, "Target.attachToTarget", {
"targetId": target_id, "flatten": True
})
return session["sessionId"]

理解 CDP 坐标系统

CDP 使用视口坐标(viewport coordinates)——即相对于浏览器内容区域左上角的像素坐标,不包含浏览器工具栏和标签栏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 获取页面布局信息(帮助定位)
async def get_element_bounds(ws, session_id, css_selector):
"""获取元素在视口中的位置和尺寸"""
js = f"""
(() => {{
const el = document.querySelector('{css_selector}');
if (!el) return null;
const rect = el.getBoundingClientRect();
return {{
x: rect.x, y: rect.y,
width: rect.width, height: rect.height,
centerX: rect.x + rect.width / 2,
centerY: rect.y + rect.height / 2
}};
}})()
"""
result = await cdp(ws, "Runtime.evaluate",
{"expression": js}, session_id)
return result.get("result", {}).get("value")

坐标与按钮值对照

概念 CDP 参数值
视口左上角 (0, 0)
滚动的页面 视口坐标不变,页面相对移动
鼠标左键 button: 0
鼠标中键 button: 1
鼠标右键 button: 2
点击次数 clickCount: 1(单击),2(双击)
修饰键 modifiers: 0(无),1(Alt),2(Ctrl),4(Meta),8(Shift)

修饰键位掩码计算

1
2
3
4
5
6
7
8
def compute_modifiers(alt=False, ctrl=False, meta=False, shift=False):
"""计算修饰键掩码"""
mask = 0
if alt: mask |= 1
if ctrl: mask |= 2
if meta: mask |= 4
if shift: mask |= 8
return mask

鼠标点击模拟

Input.dispatchMouseEvent

基础的鼠标点击需要三个步骤:按下(mousePressed)、释放(mouseReleased),中间可选移动。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
async def mouse_click(ws, session_id, x, y, button="left", click_count=1):
"""在指定坐标模拟鼠标点击"""
button_map = {"left": 0, "middle": 1, "right": 2}
btn = button_map.get(button, 0)

# 1. 先移动到目标位置
await cdp(ws, "Input.dispatchMouseEvent", {
"type": "mouseMoved",
"x": x, "y": y,
"button": btn,
"buttons": 0,
"clickCount": click_count
}, session_id)

# 2. 鼠标按下
await cdp(ws, "Input.dispatchMouseEvent", {
"type": "mousePressed",
"x": x, "y": y,
"button": btn,
"buttons": 1,
"clickCount": click_count
}, session_id)

# 3. 短暂延迟后释放
await asyncio.sleep(0.05)
await cdp(ws, "Input.dispatchMouseEvent", {
"type": "mouseReleased",
"x": x, "y": y,
"button": btn,
"buttons": 0,
"clickCount": click_count
}, session_id)

# 使用
async def demo_click():
async with websockets.connect(CDP_URL) as ws:
session_id = await attach_to_page(ws)
# 先导航到百度
await cdp(ws, "Page.navigate",
{"url": "https://www.baidu.com"}, session_id)
await asyncio.sleep(2)

# 点击搜索框位置(假设在页面中心附近)
await mouse_click(ws, session_id, 500, 300)

左键、右键、双击

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
async def right_click(ws, session_id, x, y):
"""右键点击(弹出上下文菜单)"""
await mouse_click(ws, session_id, x, y, button="right")

async def double_click(ws, session_id, x, y):
"""双击"""
# 第一次点击
await cdp(ws, "Input.dispatchMouseEvent", {
"type": "mousePressed",
"x": x, "y": y,
"button": 0,
"buttons": 1,
"clickCount": 1
}, session_id)
await cdp(ws, "Input.dispatchMouseEvent", {
"type": "mouseReleased",
"x": x, "y": y,
"button": 0,
"buttons": 0,
"clickCount": 1
}, session_id)

await asyncio.sleep(0.1)

# 第二次点击(clickCount=2)
await cdp(ws, "Input.dispatchMouseEvent", {
"type": "mousePressed",
"x": x, "y": y,
"button": 0,
"buttons": 1,
"clickCount": 2
}, session_id)
await cdp(ws, "Input.dispatchMouseEvent", {
"type": "mouseReleased",
"x": x, "y": y,
"button": 0,
"buttons": 0,
"clickCount": 2
}, session_id)

带修饰键的点击

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
async def ctrl_click(ws, session_id, x, y):
"""按住 Ctrl 再点击(打开新标签页等)"""
modifiers = compute_modifiers(ctrl=True)

await cdp(ws, "Input.dispatchMouseEvent", {
"type": "mousePressed",
"x": x, "y": y,
"button": 0,
"buttons": 1,
"clickCount": 1,
"modifiers": modifiers
}, session_id)

await asyncio.sleep(0.05)

await cdp(ws, "Input.dispatchMouseEvent", {
"type": "mouseReleased",
"x": x, "y": y,
"button": 0,
"buttons": 0,
"clickCount": 1,
"modifiers": modifiers
}, session_id)

鼠标移动与拖拽

Input.dispatchMouseEvent (mouseMoved)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
async def mouse_move(ws, session_id, x, y, steps=10):
"""将鼠标平滑移动到目标位置(模拟真实轨迹)"""
# 先获取当前位置(CDP 不直接提供当前位置,用 JS 获取)
pos_result = await cdp(ws, "Runtime.evaluate", {
"expression": "({x: 0, y: 0})" # 简化:从 (0,0) 开始
}, session_id)

start_x, start_y = 0, 0

for i in range(1, steps + 1):
current_x = start_x + (x - start_x) * i // steps
current_y = start_y + (y - start_y) * i // steps

# 加入微小随机抖动,模拟真人鼠标
jitter_x = current_x + (0 if i == steps else __import__('random').randint(-2, 2))
jitter_y = current_y + (0 if i == steps else __import__('random').randint(-2, 2))

await cdp(ws, "Input.dispatchMouseEvent", {
"type": "mouseMoved",
"x": jitter_x,
"y": jitter_y,
"button": 0,
"buttons": 0
}, session_id)

await asyncio.sleep(0.01) # 每步延迟 10ms

拖拽操作

拖拽 = mousePressed → 多次 mouseMoved → mouseReleased:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
async def drag_and_drop(ws, session_id, start_x, start_y, end_x, end_y, steps=20):
"""从起点拖拽到终点"""
# 1. 移动到起点
await mouse_move(ws, session_id, start_x, start_y)

# 2. 在起点按下鼠标左键
await cdp(ws, "Input.dispatchMouseEvent", {
"type": "mousePressed",
"x": start_x, "y": start_y,
"button": 0,
"buttons": 1,
"clickCount": 1
}, session_id)

# 3. 逐步拖拽到终点(保持 buttons=1 表示按住)
for i in range(1, steps + 1):
current_x = start_x + (end_x - start_x) * i // steps
current_y = start_y + (end_y - start_y) * i // steps

await cdp(ws, "Input.dispatchMouseEvent", {
"type": "mouseMoved",
"x": current_x, "y": current_y,
"button": 0,
"buttons": 1, # 保持按住
"clickCount": 1
}, session_id)
await asyncio.sleep(0.015)

# 4. 释放鼠标
await cdp(ws, "Input.dispatchMouseEvent", {
"type": "mouseReleased",
"x": end_x, "y": end_y,
"button": 0,
"buttons": 0,
"clickCount": 1
}, session_id)

# 演示:页面内元素拖拽
async def demo_drag():
async with websockets.connect(CDP_URL) as ws:
session_id = await attach_to_page(ws)

# 打开一个支持拖拽的页面(如 sortable 示例)
await cdp(ws, "Page.navigate",
{"url": "https://sortablejs.github.io/Sortable/"}, session_id)
await asyncio.sleep(3)

# 获取第一个可拖拽元素的位置
bounds = await get_element_bounds(ws, session_id, ".item:first-child")
if bounds:
start = (int(bounds["centerX"]), int(bounds["centerY"]))
end = (start[0], start[1] + 200) # 向下拖 200px
await drag_and_drop(ws, session_id, start[0], start[1], end[0], end[1])

滚轮操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
async def scroll_page(ws, session_id, delta_x=0, delta_y=300):
"""模拟鼠标滚轮滚动"""
await cdp(ws, "Input.dispatchMouseEvent", {
"type": "mouseWheel",
"x": 400, "y": 400,
"deltaX": delta_x,
"deltaY": delta_y
}, session_id)

# 平滑滚动到指定位置
async def smooth_scroll_to(ws, session_id, target_y, step=100):
"""平滑滚动到目标 Y 位置"""
import random
current = 0
while current < target_y:
step_size = min(step + random.randint(-20, 20), target_y - current)
await scroll_page(ws, session_id, delta_y=step_size)
current += step_size
await asyncio.sleep(0.05)

键盘输入模拟

Input.dispatchKeyEvent

键盘事件分为 keyDown 和 keyUp,文本输入用 keyDown(key) → char → keyUp(key) 模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# 常用键的虚拟键码
KEY_MAP = {
"Enter": "Enter",
"Tab": "Tab",
"Backspace": "Backspace",
"Delete": "Delete",
"Escape": "Escape",
"ArrowUp": "ArrowUp",
"ArrowDown": "ArrowDown",
"ArrowLeft": "ArrowLeft",
"ArrowRight": "ArrowRight",
"Home": "Home",
"End": "End",
"PageUp": "PageUp",
"PageDown": "PageDown",
"Shift": "Shift",
"Control": "Control",
"Alt": "Alt",
"Meta": "Meta",
"Space": " ",
}

async def key_press(ws, session_id, key_text):
"""模拟单个按键"""
# 键按下
await cdp(ws, "Input.dispatchKeyEvent", {
"type": "keyDown",
"key": key_text
}, session_id)

await asyncio.sleep(0.05)

# 键释放
await cdp(ws, "Input.dispatchKeyEvent", {
"type": "keyUp",
"key": key_text
}, session_id)


async def type_text(ws, session_id, text, delay=0.05):
"""模拟键盘逐字输入文本"""
for char in text:
# keyDown
await cdp(ws, "Input.dispatchKeyEvent", {
"type": "keyDown",
"key": char,
"text": char
}, session_id)

# char(可选,用于字符输入)
await cdp(ws, "Input.dispatchKeyEvent", {
"type": "char",
"key": char,
"text": char
}, session_id)

# keyUp
await cdp(ws, "Input.dispatchKeyEvent", {
"type": "keyUp",
"key": char,
"text": char
}, session_id)

# 每字符之间的延迟——模拟真实打字速度
await asyncio.sleep(delay)

组合键

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
async def key_combination(ws, session_id, modifiers, main_key):
"""模拟组合键(如 Ctrl+A, Ctrl+C, Ctrl+V)"""
# 按下修饰键
for mod_key in modifiers:
await key_press(ws, session_id, mod_key) # 只按下不释放
# 正确做法应该单独 dispatch keyDown

# 按下主键
await key_press(ws, session_id, main_key)

# 释放修饰键(省略细化,完整示意见 InputSimulator 类)

async def select_all_and_copy(ws, session_id):
"""全选然后复制"""
modifiers = compute_modifiers(ctrl=True)

# Ctrl+A
await cdp(ws, "Input.dispatchKeyEvent", {
"type": "rawKeyDown",
"windowsVirtualKeyCode": 65, # A
"key": "a",
"code": "KeyA",
"modifiers": modifiers
}, session_id)
await asyncio.sleep(0.05)
await cdp(ws, "Input.dispatchKeyEvent", {
"type": "keyUp",
"key": "a",
"modifiers": modifiers
}, session_id)

await asyncio.sleep(0.1)

# Ctrl+C
await cdp(ws, "Input.dispatchKeyEvent", {
"type": "rawKeyDown",
"windowsVirtualKeyCode": 67, # C
"key": "c",
"code": "KeyC",
"modifiers": modifiers
}, session_id)
await asyncio.sleep(0.05)
await cdp(ws, "Input.dispatchKeyEvent", {
"type": "keyUp",
"key": "c",
"modifiers": modifiers
}, session_id)

文本插入

Input.insertText

与逐字模拟键盘不同,Input.insertText 直接向焦点元素插入文本,不经过键盘事件流:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
async def insert_text(ws, session_id, text):
"""直接向焦点元素插入文本(绕过键盘事件)"""
await cdp(ws, "Input.insertText", {
"text": text
}, session_id)

# 使用:先聚焦输入框,然后直接插入文本
async def fast_text_input(ws, session_id, css_selector, text):
"""快速在输入框中填入文本"""
# 先聚焦元素
js = f"document.querySelector('{css_selector}').focus();"
await cdp(ws, "Runtime.evaluate",
{"expression": js}, session_id)

await asyncio.sleep(0.2)

# 清空已有内容(全选后删除)
# ... 此处可用组合键 Ctrl+A, Delete

# 直接插入文本(比逐字键盘输入快得多)
await insert_text(ws, session_id, text)

insertText vs dispatchKeyEvent 对比

方式 触发键盘事件 速度 适用场景
dispatchKeyEvent ✅ 触发全部事件 慢(逐字符) 需要模拟真人打字
insertText ❌ 不触发键盘事件 极快 快速填充表单
DOM 直接设置 ❌ 不触发任何事件 最快 后台数据注入

实战决策指南:三种交互方式的选择

在实际项目中,你有三种方式与页面交互。它们的差异不仅在于语法,更在行为层面:

方式 实现 是否真实事件 能否穿透 z-index 对 Radix UI 有效? 推荐场景
JS element.click() Runtime.evaluate 执行 el.click() ❌ 合成事件 ❌ 受 pointer-events 限制 ❌ 无效 简单页面、非受控元素
CDP dispatchMouseEvent CDP 协议发送 mousePressed/released ✅ OS级真实事件 ✅ 穿透 ✅ 有效 复杂组件、被遮挡元素
CDP dispatchKeyEvent CDP 协议发送 rawKeyDown/keyUp ✅ 真实键盘事件 N/A N/A Enter提交、Escape关闭

策略选择

优先用 Enter 键提交(聊天界面首选)

1
2
3
4
5
6
7
8
def press_enter(ws, session_id):
"""发送 Enter 键提交表单/消息"""
cmd(ws, 'Input.dispatchKeyEvent', {
'type': 'rawKeyDown', 'windowsVirtualKeyCode': 13, 'key': 'Enter'
})
cmd(ws, 'Input.dispatchKeyEvent', {
'type': 'keyUp', 'windowsVirtualKeyCode': 13, 'key': 'Enter'
})

适用场景:聊天式 AI 界面、搜索框、表单提交。当”发送”按钮被复杂 UI 遮挡时,Enter 键是最可靠的提交方式——它不依赖按钮的位置和可见性。

CDP 鼠标点击通用方案

1
2
3
4
5
6
7
8
9
10
def cdp_click(ws, session_id, x, y):
"""CDP 真实鼠标点击(分两步:按下 + 释放)"""
cmd(ws, 'Input.dispatchMouseEvent', {
'type': 'mousePressed', 'x': x, 'y': y,
'button': 'left', 'clickCount': 1
})
cmd(ws, 'Input.dispatchMouseEvent', {
'type': 'mouseReleased', 'x': x, 'y': y,
'button': 'left', 'clickCount': 1
})

优势:触发完整事件链(mousedown → mouseup → click + pointerdown → pointerup),适合 Radix UI、React Aria、MUI 等现代组件库。

JS click 仅用于简单场景

1
2
3
def js_click(ws, session_id, css_selector):
"""JS element.click() — 简单场景"""
js(ws, f"document.querySelector('{css_selector}').click()")

局限性:不会触发 pointer 事件、被遮挡时无效、对 portal 渲染的组件可能无响应。

键盘事件的妙用

Escape 关闭下拉菜单

Radix UI 等组件库的下拉菜单选择选项后不会自动关闭,需要用 Escape 手动关闭:

1
2
3
4
5
6
7
8
def press_escape(ws, session_id):
"""发送 Escape 关闭下拉菜单/弹窗"""
cmd(ws, 'Input.dispatchKeyEvent', {
'type': 'rawKeyDown', 'windowsVirtualKeyCode': 27, 'key': 'Escape'
})
cmd(ws, 'Input.dispatchKeyEvent', {
'type': 'keyUp', 'windowsVirtualKeyCode': 27, 'key': 'Escape'
})

处理 React 受控组件

现代前端框架(React/Vue)的输入框不能简单用 el.value = x 来赋值,因为框架不会感知这种直接修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def set_react_input(ws, session_id, css_selector, value):
"""React 受控输入框设置值(绕过虚拟 DOM)"""
js(ws, f"""
(() => {{
const el = document.querySelector('{css_selector}');
if (!el) return 'NOT_FOUND';
el.focus();
// 获取原生 value setter
const ns = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype, 'value'
).set;
ns.call(el, '{value}');
// 派发事件通知 React 更新
el.dispatchEvent(new Event('input', {{bubbles: true}}));
el.dispatchEvent(new Event('change', {{bubbles: true}}));
return 'OK';
}})()
""")

原理:通过 Object.getOwnPropertyDescriptor 拿到 HTMLInputElement.prototype.value 的原始 setter,用 setter.call(el, value) 设置值后再派发 input/change 事件,React 的状态就会同步更新。

处理 contenteditable 输入框

聊天 AI 界面通常使用 [contenteditable="true"] 的 div 而非标准 input/textarea:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def focus_and_clear(ws, session_id):
"""聚焦并清空 contenteditable 输入框"""
js(ws, """
(() => {
const el = document.querySelector('[contenteditable="true"]');
if (!el) return false;
el.focus();
document.execCommand('selectAll');
document.execCommand('delete');
return true;
})()
""")

def insert_text_cdp(ws, session_id, text):
"""使用 CDP insertText 输入文本(比 dispatchKeyEvent 逐字输入更可靠)"""
cmd(ws, 'Input.insertText', {'text': text})

Input.insertText 比逐字 dispatchKeyEvent 更可靠,因为它直接模拟文本输入,触发完整的 React 受控组件事件链。

完整的三层策略(优先级降序)

1
2
3
1. Enter 键提交  → 聊天界面首选,不依赖按钮 → 验证成功后返回
2. CDP 鼠标点击 → 通用方案,处理复杂组件 → 验证成功后返回
3. JS click → 仅用于简单/已知安全的元素

Radix UI 下拉菜单完整操作流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 步骤1:用 CDP 鼠标点击触发器按钮(JS click 对此类组件无效)
cdp_click(ws, session_id, btn_x, btn_y)
time.sleep(1)

# 步骤2:在下拉菜单的 portal 内容中用 JS click 选选项
js(ws, """
(() => {
const dd = document.querySelector('[data-radix-popper-content]');
if (!dd) return 'NOT_FOUND';
const items = dd.querySelectorAll('[role="menuitem"]');
for (const item of items) {
if (item.innerText.trim() === '目标选项') {
item.click(); // portal 内的元素用 JS click 即可
return 'OK';
}
}
return 'NOT_FOUND';
})()
""")

# 步骤3:Escape 关闭菜单(重要!防止遮挡后续操作)
press_escape(ws, session_id)

关键发现:对于 portal 渲染的下拉内容,JS click 是可以用的——真正需要 CDP 鼠标点击的只是打开菜单的触发器按钮。菜单项本身在 portal 内正常渲染,JS click 即可。


触摸事件模拟

Input.dispatchTouchEvent

移动端模拟或触摸屏场景需要触摸事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
async def touch_tap(ws, session_id, x, y):
"""模拟触摸点击"""
# touchStart
await cdp(ws, "Input.dispatchTouchEvent", {
"type": "touchStart",
"touchPoints": [{
"x": x, "y": y
}],
"modifiers": 0
}, session_id)

await asyncio.sleep(0.05)

# touchEnd
await cdp(ws, "Input.dispatchTouchEvent", {
"type": "touchEnd",
"touchPoints": [],
"modifiers": 0
}, session_id)

async def touch_scroll(ws, session_id, start_x, start_y, end_x, end_y, steps=10):
"""模拟触摸滚动(滑动操作)"""
# touchStart
await cdp(ws, "Input.dispatchTouchEvent", {
"type": "touchStart",
"touchPoints": [{"x": start_x, "y": start_y}],
"modifiers": 0
}, session_id)

# touchMove 多次
for i in range(1, steps + 1):
current_x = start_x + (end_x - start_x) * i // steps
current_y = start_y + (end_y - start_y) * i // steps

await cdp(ws, "Input.dispatchTouchEvent", {
"type": "touchMove",
"touchPoints": [{"x": current_x, "y": current_y}],
"modifiers": 0
}, session_id)
await asyncio.sleep(0.01)

# touchEnd
await cdp(ws, "Input.dispatchTouchEvent", {
"type": "touchEnd",
"touchPoints": [],
"modifiers": 0
}, session_id)

async def touch_pinch(ws, session_id, center_x, center_y, start_radius, end_radius):
"""模拟双指捏合缩放"""
# touchStart(两个触点)
await cdp(ws, "Input.dispatchTouchEvent", {
"type": "touchStart",
"touchPoints": [
{"x": center_x - start_radius, "y": center_y},
{"x": center_x + start_radius, "y": center_y}
],
"modifiers": 0
}, session_id)

# touchMove 逐步移动两个触点
for i in range(1, 11):
r = start_radius + (end_radius - start_radius) * i // 10
await cdp(ws, "Input.dispatchTouchEvent", {
"type": "touchMove",
"touchPoints": [
{"x": center_x - r, "y": center_y},
{"x": center_x + r, "y": center_y}
],
"modifiers": 0
}, session_id)
await asyncio.sleep(0.01)

# touchEnd
await cdp(ws, "Input.dispatchTouchEvent", {
"type": "touchEnd",
"touchPoints": [],
"modifiers": 0
}, session_id)

完整参考:CDP InputSimulator 类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
import asyncio
import json
import random
import websockets


class CDPInputSimulator:
"""CDP 输入模拟器"""

KEY_MAP = {
"Enter": "Enter", "Tab": "Tab",
"Backspace": "Backspace", "Delete": "Delete",
"Escape": "Escape",
"ArrowUp": "ArrowUp", "ArrowDown": "ArrowDown",
"ArrowLeft": "ArrowLeft", "ArrowRight": "ArrowRight",
"Home": "Home", "End": "End",
"PageUp": "PageUp", "PageDown": "PageDown",
"Shift": "Shift", "Control": "Control",
"Alt": "Alt", "Meta": "Meta",
"Space": " ",
}

def __init__(self, ws, session_id):
self.ws = ws
self.session_id = session_id
self._cmd_id = 0

async def _cdp(self, method, params=None):
self._cmd_id += 1
msg = {"id": self._cmd_id, "method": method,
"params": params or {}}
if self.session_id:
msg["sessionId"] = self.session_id
await self.ws.send(json.dumps(msg))
async for resp in self.ws:
data = json.loads(resp)
if data.get("id") == self._cmd_id:
return data.get("result", {})

def _modifiers(self, alt=False, ctrl=False, meta=False, shift=False):
mask = 0
if alt: mask |= 1
if ctrl: mask |= 2
if meta: mask |= 4
if shift: mask |= 8
return mask

# ===== 鼠标操作 =====

async def click(self, x: int, y: int, button: str = "left",
click_count: int = 1, modifiers: int = 0):
"""在指定坐标点击"""
btn = {"left": 0, "middle": 1, "right": 2}.get(button, 0)

# 移动
await self._cdp("Input.dispatchMouseEvent", {
"type": "mouseMoved", "x": x, "y": y,
"button": btn, "buttons": 0, "clickCount": click_count,
"modifiers": modifiers
})

# 按下
await self._cdp("Input.dispatchMouseEvent", {
"type": "mousePressed", "x": x, "y": y,
"button": btn, "buttons": 1, "clickCount": click_count,
"modifiers": modifiers
})

await asyncio.sleep(0.05)

# 释放
await self._cdp("Input.dispatchMouseEvent", {
"type": "mouseReleased", "x": x, "y": y,
"button": btn, "buttons": 0, "clickCount": click_count,
"modifiers": modifiers
})

async def double_click(self, x: int, y: int):
"""双击"""
await self.click(x, y, click_count=1)
await asyncio.sleep(0.1)
await self.click(x, y, click_count=2)

async def right_click(self, x: int, y: int):
"""右键点击"""
await self.click(x, y, button="right")

async def move_mouse(self, x: int, y: int, steps: int = 10):
"""平滑移动鼠标"""
start_x, start_y = 0, 0
for i in range(1, steps + 1):
cx = start_x + (x - start_x) * i // steps
cy = start_y + (y - start_y) * i // steps
if i < steps:
cx += random.randint(-2, 2)
cy += random.randint(-2, 2)
await self._cdp("Input.dispatchMouseEvent", {
"type": "mouseMoved", "x": cx, "y": cy,
"button": 0, "buttons": 0, "clickCount": 1
})
await asyncio.sleep(0.01)

async def drag(self, start_x: int, start_y: int,
end_x: int, end_y: int, steps: int = 20):
"""拖拽操作"""
# Press
await self._cdp("Input.dispatchMouseEvent", {
"type": "mousePressed", "x": start_x, "y": start_y,
"button": 0, "buttons": 1, "clickCount": 1
})
# Move while holding
for i in range(1, steps + 1):
cx = start_x + (end_x - start_x) * i // steps
cy = start_y + (end_y - start_y) * i // steps
await self._cdp("Input.dispatchMouseEvent", {
"type": "mouseMoved", "x": cx, "y": cy,
"button": 0, "buttons": 1, "clickCount": 1
})
await asyncio.sleep(0.015)
# Release
await self._cdp("Input.dispatchMouseEvent", {
"type": "mouseReleased", "x": end_x, "y": end_y,
"button": 0, "buttons": 0, "clickCount": 1
})

async def scroll(self, delta_x: int = 0, delta_y: int = 300,
x: int = 400, y: int = 400):
"""鼠标滚轮"""
await self._cdp("Input.dispatchMouseEvent", {
"type": "mouseWheel", "x": x, "y": y,
"deltaX": delta_x, "deltaY": delta_y
})

# ===== 键盘操作 =====

async def press_key(self, key: str):
"""按下一个键并释放"""
await self._cdp("Input.dispatchKeyEvent", {
"type": "keyDown", "key": key
})
await asyncio.sleep(0.05)
await self._cdp("Input.dispatchKeyEvent", {
"type": "keyUp", "key": key
})

async def type_text(self, text: str, delay: float = 0.05):
"""逐字输入文本"""
for char in text:
await self._cdp("Input.dispatchKeyEvent", {
"type": "keyDown", "key": char, "text": char
})
await self._cdp("Input.dispatchKeyEvent", {
"type": "char", "key": char, "text": char
})
await self._cdp("Input.dispatchKeyEvent", {
"type": "keyUp", "key": char, "text": char
})
await asyncio.sleep(delay)

async def insert_text(self, text: str):
"""直接插入文本(绕过键盘事件)"""
await self._cdp("Input.insertText", {"text": text})

async def hotkey(self, *keys):
"""组合键:hotkey('Control', 'a') → Ctrl+A"""
modifiers = 0
mod_map = {"Control": 2, "Alt": 1, "Shift": 8, "Meta": 4}

# 按下所有修饰键
for k in keys[:-1]:
if k in mod_map:
modifiers |= mod_map[k]
await self._cdp("Input.dispatchKeyEvent", {
"type": "keyDown", "key": k
})

# 按下主键
main_key = keys[-1]
await self._cdp("Input.dispatchKeyEvent", {
"type": "rawKeyDown",
"key": main_key,
"modifiers": modifiers
})
await asyncio.sleep(0.05)
await self._cdp("Input.dispatchKeyEvent", {
"type": "keyUp", "key": main_key,
"modifiers": modifiers
})

# 释放修饰键
for k in reversed(keys[:-1]):
await self._cdp("Input.dispatchKeyEvent", {
"type": "keyUp", "key": k
})

# ===== 触摸操作 =====

async def touch_tap(self, x: int, y: int):
"""触摸点击"""
await self._cdp("Input.dispatchTouchEvent", {
"type": "touchStart",
"touchPoints": [{"x": x, "y": y}],
"modifiers": 0
})
await asyncio.sleep(0.05)
await self._cdp("Input.dispatchTouchEvent", {
"type": "touchEnd",
"touchPoints": [],
"modifiers": 0
})

async def touch_swipe(self, start_x: int, start_y: int,
end_x: int, end_y: int, steps: int = 15):
"""触摸滑动"""
await self._cdp("Input.dispatchTouchEvent", {
"type": "touchStart",
"touchPoints": [{"x": start_x, "y": start_y}],
"modifiers": 0
})
for i in range(1, steps + 1):
cx = start_x + (end_x - start_x) * i // steps
cy = start_y + (end_y - start_y) * i // steps
await self._cdp("Input.dispatchTouchEvent", {
"type": "touchMove",
"touchPoints": [{"x": cx, "y": cy}],
"modifiers": 0
})
await asyncio.sleep(0.01)
await self._cdp("Input.dispatchTouchEvent", {
"type": "touchEnd",
"touchPoints": [],
"modifiers": 0
})

# ===== 辅助方法 =====

async def focus_element(self, css_selector: str):
"""聚焦元素"""
await self._cdp("Runtime.evaluate", {
"expression": f"document.querySelector('{css_selector}').focus()"
})

async def click_element(self, css_selector: str):
"""通过 JS 直接点击元素(不模拟鼠标)"""
await self._cdp("Runtime.evaluate", {
"expression": f"document.querySelector('{css_selector}').click()"
})


async def human_like_typing(self, text: str, target_wpm: int = 120):
"""模拟真人打字(带随机延迟变化)"""
# 120 WPM ≈ 每字符 80-120ms
base_delay = 60.0 / (target_wpm * 5)
for char in text:
delay = base_delay * random.uniform(0.5, 1.8)
await self.type_text(char, 0)
await asyncio.sleep(delay)

使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
async def demo_input_simulator():
async with websockets.connect(CDP_URL) as ws:
session_id = await attach_to_page(ws)
sim = CDPInputSimulator(ws, session_id)

# 导航到百度
await sim._cdp("Page.navigate", {"url": "https://www.baidu.com"})
await asyncio.sleep(2)

# 聚焦搜索框
await sim.focus_element("#kw")
await asyncio.sleep(0.5)

# 输入搜索关键词(模拟真人打字速度)
await sim.human_like_typing("Python CDP 教程", target_wpm=100)
await asyncio.sleep(1)

# 点击"百度一下"按钮
await sim.click_element("#su")

常见踩坑与最佳实践

踩坑 1:坐标是视口坐标,不是页面坐标

1
2
3
4
5
6
7
# ❌ 页面滚动了,元素坐标变了
scroll_y = 500
await mouse_click(ws, session_id, 100, scroll_y) # 点到了错误位置

# ✅ 使用 getBoundingClientRect 获取当前视口坐标
bounds = await get_element_bounds(ws, session_id, "#my-button")
await mouse_click(ws, session_id, bounds["centerX"], bounds["centerY"])

踩坑 2:input[type=”file”] 不能被 click

1
2
3
4
# ❌ 文件输入框不能用 CDP click 模拟点击
await mouse_click(ws, session_id, x, y) # 不会弹出文件选择器

# ✅ 应该用 DOM.setFileInputFiles 或 Page.setInterceptFileChooserDialog

踩坑 3:按键事件缺少必要参数

1
2
3
4
5
6
7
8
9
10
11
12
13
# ❌ 缺少 code 字段可能导致某些应用不识别
await cdp(ws, "Input.dispatchKeyEvent", {
"type": "keyDown", "key": "a"
}, session_id)

# ✅ 提供完整参数
await cdp(ws, "Input.dispatchKeyEvent", {
"type": "keyDown",
"key": "a",
"code": "KeyA",
"text": "a",
"windowsVirtualKeyCode": 65
}, session_id)

踩坑 4:insertText 需要先聚焦

1
2
3
4
5
6
7
# ❌ 没有焦点元素,文本不知道插到哪
await insert_text(ws, session_id, "hello")

# ✅ 先聚焦目标元素
await sim.focus_element("#input-box")
await asyncio.sleep(0.2)
await insert_text(ws, session_id, "hello")

踩坑 5:Chrome 输入法拦截问题

某些情况下 Chrome 的输入法会拦截 insertText,需要使用更底层的 IME 命令:

1
2
3
4
5
6
7
async def ime_set_composition(ws, session_id, text, selection_start, selection_end):
"""设置输入法组合文本"""
await cdp(ws, "Input.imeSetComposition", {
"text": text,
"selectionStart": selection_start,
"selectionEnd": selection_end
}, session_id)

最佳实践清单

注意点 建议
坐标获取 getBoundingClientRect() 动态获取
模拟真实性 加入随机延迟和鼠标抖动
事件顺序 始终为 mousePressed → … → mouseReleased
按键完整 提供 key + code + windowsVirtualKeyCode
触摸模拟 移动端页面必须用触摸事件
聚焦前提 insertText/键盘输入前确保元素已聚焦

总结:CDP 的 Input 域提供了从鼠标点击到键盘输入、从拖拽到触摸事件的完整输入模拟能力。通过 InputSimulator 封装类,你可以模拟出几乎任何用户操作,甚至能控制到像素级别的时间延迟,实现高仿真的人机交互自动化。

上一篇回顾:CDP 多标签页管理:用 Python 控制多个页面。

下一篇预告:CDP 文件上传与下载:用 Python 处理文件操作。