把某招聘网站"喂熟"了,我用了一台树莓派
把某招聘网站"喂熟"了,我用了一台树莓派
起点
公司 HR 每天要在某招聘网站上做大量重复操作:打开候选人聊天 → 完成几步交互 → 下载文件 → 切换下一个。一次循环 1~2 分钟,半天扫一遍。
"能不能搞个自动化?"
我说行,然后被这个网站虐了一个星期。
第一阶段:浏览器层的死胡同
直觉是用 Playwright/Puppeteer 操作 Chromium。一开始很顺,跑了几天某天突然就不灵了 —— 账号没封、操作"看起来成功",但服务端不认。
抓包看到对方有一套行为埋点,每次点击都上报一组指纹:事件是不是 isTrusted: true、有没有 CDP 注入痕迹、鼠标轨迹的二阶导数 (人有抖动机器没有)、navigator.webdriver / cdc_* 字段……
各种反检测分支、stealth 插件都试过,每个能撑几天然后被"软风控":请求发出去,服务端不报错也不处理。
最深的痛是想明白一件事:任何在浏览器 JS 上下文里能拿到的信息,都可能成为指纹。 我跟对方之间是一场全信息透明的猫鼠游戏,他们更新一次埋点我就被打掉一次。
我决定不在 JS 里装人 —— 在 OS 层注入鼠标点击,绕过整个浏览器。pyautogui + Quartz CGEvent,几行代码就让页面收到的 event.isTrusted === true,埋点不再拦。换了一片天。
但还远没结束 —— 要让"鼠标在屏幕上自己动"这件事真正落地业务,程序得能像人一样看懂当前页面,而不是按死坐标点。
第二阶段:Agent 架构 —— 让 LLM 决定下一步
业务页面状态多得离谱:不同候选人对应不同的下一步交互,加上对方主动行为、各种弹窗、安全验证、活动浮层、改版后变动的按钮位置……每种状态都对应不同的下一步动作。
传统做法是写一堆 if-else + DOM selector 硬编码 —— 我一开始就是这么干的,500 行的状态机长得像意大利面。结果只要对方页面改一处文案、挪一个按钮位置,某条分支就静默走错,整个流程死在中间没人知道。
最后我把这套硬编码全删了,换成另一个思路:
"决定下一步做什么"这件事交给 LLM。
程序只负责两件事 —— 看屏幕 和 执行动作。
经典的 perceive–decide–act 循环:
loop:
snapshot = perceive() # 截屏 → OCR / 简化 DOM → 简化的页面描述
action = llm_decide(snapshot) # LLM 输出一个 action JSON
result = execute(action) # 鼠标键盘真的去做
if done: break
perceive:把页面压扁成一段文字
LLM 不擅长理解一张几百万像素的截图,但很擅长理解结构化的文字描述。所以 perceive 这一步做的事情是把屏幕"压扁":
全屏截图 → OCR 出所有可见文字 + 坐标
过滤掉无意义的装饰文字、被遮挡的元素
按可视区域分组(顶部导航 / 左侧列表 / 主面板 / 右侧抽屉)
给每个可点击元素打上一个递增 id
最后输出大概长这样:
[viewport] 1680x1050
[top-nav] 1: 消息 2: 候选人 3: 简历
[chat-list] 4: 张三 · 刚刚未读 5: 李四 · 3分钟前
[main-panel] 状态: 已沟通,等待对方回复
[buttons] 6: 求简历 7: 发起视频面试 8: 关闭
整个 snapshot 不到 1KB,LLM 一眼就能看懂。
decide:LLM 输出白名单 action
让 LLM 自由发挥写代码会出事 —— 上一秒还在好好工作,下一秒幻觉一个不存在的 API 把进程搞挂。所以 LLM 的输出不是代码,而是从一个白名单里挑一个 action:
{ "action": "click", "target": 6, "reason": "求简历按钮可用" }
可选 action 一共就五六个:click(id) / scroll(dy) / wait(text) / type(text) / done / abort。每个都有限定参数和值域。LLM 选错了,顶多是动作选错,绝不会越权 —— 这是整个架构里最重要的一道护栏。
更进一步,我把 LLM 的输出 schema 用 JSON Schema 强约束:任何不符合 schema 的输出直接拒绝、重试。这样即使模型偶发抽风输出一段奇怪的自然语言,也不会影响主流程。
act:执行 + 反馈
execute 这一步只做一件事:把 action JSON 翻译成具体的鼠标键盘指令(到了第三阶段就是 HID 报告)。执行完之后,马上再 perceive 一次,把新的 snapshot 喂回 LLM —— 这是闭环里最关键的一步。
如果点击之后页面没变化,LLM 自己会发现"咦,我刚点了求简历但 snapshot 还是老样子,是不是被反爬拦了?"然后输出 abort 退出本轮。
容错性 vs 硬编码
这个架构最大的好处不是省代码,而是面对未知 UI 状态的容错性。
举个真事:某天页面突然弹出来一个之前从没见过的推广浮层 ——"Mac 客户端内测,立即下载 / 稍后再说"。硬编码方案会原地懵逼,因为流程里压根没有"识别推广弹窗"这一支。但 agent 这边什么都不用改:LLM 看到 snapshot 里多出来"立即下载 / 稍后再说"两个按钮,自动输出 click(稍后再说),流程继续。
没人提前告诉过 LLM 这个弹窗存在,它靠常识就处理掉了。
每次对方改版,大概 95% 情况 agent 自己适应。剩下那 5% 的硬骨头(比如新的滑块验证、需要长按的按钮)再单独写规则补强。
代码量从"几千行硬编码状态机"降到"几百行执行器 + 一份 prompt + 一个 OCR 后处理模块"。后续每次出问题,基本都是去调 prompt 或者补一个 OCR 后处理规则,而不是改代码逻辑 —— 维护成本断崖式下降。
一个值得记下来的副作用
写完这套架构之后我才意识到:它本质上是把"测试用例驱动开发"反过来用了。
传统 TDD 是先写测例,再让代码通过。这里相当于:让代码 (LLM) 直接看着真实页面学着工作,我作为"裁判"只在它选错的时候纠正一下 prompt。每一次纠错都是一条加进 prompt 的新规则,而新规则又会被下一次 perceive→decide 自然吸收。
写了一年硬编码爬虫之后转过来,会感觉像是从手挡换到了自动挡。
第三阶段:树莓派 —— 比 OS 更低一层
有人会问:你前面都用 pyautogui 在 Mac 上注入鼠标了,为啥后来还挂了一台树莓派?
答案是 pyautogui 在 OS 上能跑,但在更底层依然能被识别。
软件层注入留下的尾巴
macOS 的 Quartz Event API 注入鼠标时,实际调用的是 CGEventCreateMouseEvent / CGEventPost。每个 CGEvent 在内核里都带一个字段叫 CGEventSourceStateID,标识这个事件是从哪个事件源进来的:
| Source | 来源 |
|---|---|
kCGEventSourceStateHIDSystemState |
真实硬件 HID 设备 |
kCGEventSourceStateCombinedSessionState |
当前用户会话的合成态 |
kCGEventSourceStatePrivate |
代码注入的事件 |
任何一个应用程序,只要在自己进程里挂一个 CGEventTapCreate 监听器,通过 CGEventGetIntegerValueField(event, kCGEventSourceStateID) 就能拿到这个字段。判断逻辑就一行:
if (state_id != kCGEventSourceStateHIDSystemState) {
// 这是注入事件,标记为可疑
}
对方目前可能没查这个字段,但只要他们某次更新加上这一行,我过去所有的努力 —— stealth 插件、isTrusted 修复、CGEvent 注入,全部归零。
我不想再赌一次。这种"我每天提心吊胆等他们查"的状态消耗心力的程度远超技术难度本身。
选树莓派 Zero W
需求很简单:在 Mac 之外造一个真实的 USB HID 设备,通过 USB 线把鼠标键盘事件灌进去。Mac 那一侧看到的是一个标准的 USB HID 设备,从内核到应用层都无法区分这是物理键鼠还是别的什么。
候选有几个:
| 方案 | 优劣 |
|---|---|
| Arduino Leonardo + USB HID 库 | 便宜,但只能跑刷死的固件,没法 SSH 远程下发指令 |
| Teensy 4.0 | 速度快,但同上,没有 Linux 环境 |
| Raspberry Pi Zero W | 有完整 Linux,USB OTG 可模拟 HID Gadget,Wi-Fi 远程下发指令 |
| 商业 KVM-over-IP 设备 | 死贵,且大多带额外 USB 描述符特征 |
最后选了 Raspberry Pi Zero W —— 一台 30 块钱的卡片机,跑 Raspbian Lite,通过 libcomposite 注册成 USB HID Gadget。
物理连线
┌─────────────┐ ┌─────────────┐
│ Mac mini │ │ Pi Zero W │
│ │ ◄── USB-C → micro USB │ │
│ (作业机) │ │ (HID 注入器) │
└─────────────┘ └──────┬──────┘
│
│ Wi-Fi
▼
Python 控制程序
(通过 SSH 下发指令)
USB 线一头插 Pi,一头插 Mac。Pi 自身通过家里 Wi-Fi 接 SSH。
关键的 Linux 配置
Pi 启动时通过一段 shell 把自己注册成复合 HID 设备(键盘 + 鼠标):
cd /sys/kernel/config/usb_gadget/
mkdir -p mypi && cd mypi
echo 0x1d6b > idVendor # Linux Foundation
echo 0x0104 > idProduct # Multifunction Composite
mkdir -p strings/0x409
echo "Raspberry Pi" > strings/0x409/manufacturer
echo "USB HID Keyboard/Mouse" > strings/0x409/product
# 键盘
mkdir -p functions/hid.kbd
echo 1 > functions/hid.kbd/protocol
echo 1 > functions/hid.kbd/subclass
echo 8 > functions/hid.kbd/report_length
# 鼠标
mkdir -p functions/hid.mouse
echo 0 > functions/hid.mouse/protocol
echo 0 > functions/hid.mouse/subclass
echo 4 > functions/hid.mouse/report_length
mkdir -p configs/c.1
ln -s functions/hid.kbd configs/c.1/
ln -s functions/hid.mouse configs/c.1/
ls /sys/class/udc > UDC
挂载完成后,Pi 上会出现两个字符设备:
/dev/hidg0—— 键盘,写 8 字节 HID report/dev/hidg1—— 鼠标,写 4 字节 HID report
Python 那边的"发送一次点击"代码长这样:
# 鼠标移动到屏幕中央并点击
with open("/dev/hidg1", "wb") as f:
f.write(bytes([0x00, dx, dy, 0x00])) # 移动
f.write(bytes([0x01, 0x00, 0x00, 0x00])) # 左键按下
f.write(bytes([0x00, 0x00, 0x00, 0x00])) # 释放
四字节,内核走完 USB,Mac 那边看到的就是一次"鼠标在桌面上动了 (dx, dy)、左键点了一下"。整条链路里没有任何一个软件 API 调用,所以也就没有任何字段可以被查。
Mac 看到的设备
在 Mac 上用 system_profiler SPUSBDataType 看一眼,Pi 表现得就是一个普通 USB HID:
USB HID Keyboard/Mouse:
Product ID: 0x0104
Vendor ID: 0x1d6b (Linux Foundation)
Version: 1.00
Manufacturer: Raspberry Pi
Location ID: 0x14200000
Current Available (mA): 500
从 Mac 内核到应用程序,没有任何一层能在软件上分辨这是真鼠标还是 Pi 模拟的,因为它就是真的 USB HID,只不过 firmware 是我写的。要查也只能查 VID/PID 是不是常见鼠标厂商 —— 但市场上奇形怪状的 HID 设备多如牛毛,真这么查的话误杀会非常严重,没人敢这么干。
上层抽象:把硬件包装成 Python API
光有 /dev/hidg* 还不能给业务用,要给 collect_resumes.py 这种高层逻辑一个干净的接口。我在 Pi 上跑了一个小 HTTP 服务,Python 客户端这样调用:
pi = PiClient("http://192.xx.xx.xx:8080")
pi.move_to(900, 540) # 移动到屏幕坐标
pi.click() # 左键单击
pi.type_text("hello") # 走 HID 键盘
pi.scroll(-3) # 向下滚 3 格
pi.cmd_v() # Cmd+V (走剪贴板,绕过 IME)
服务端把这些高层操作拆成 HID report 序列,加上人类抖动(每次移动加 ±2 像素噪声,等待 30~120ms),写进 /dev/hidg*。
代价
| 项目 | 数值 |
|---|---|
| 多一台硬件 | Pi Zero W,30 元 |
| 多一根 USB 线 | 5 元 |
| 多一段 HTTP 调用延迟 | ~50ms / 次操作 |
| 多一个故障点 | Pi 断电 / Wi-Fi 掉线要重启 |
| 多一次写 firmware 调试 | 周末花了 4 小时 |
换来的是确定性 —— 反爬方再怎么升级,只要他们还在软件层做检测,就动不到我这条链路。这是过去一个星期里我最稀缺的资源。
顺便聊聊别的可选项
后面有人问我:"非要硬件吗?有没有纯软件代替方案?"
理论上有,实际都不如树莓派干净:
macOS 虚拟机里跑作业机,虚拟机软件模拟 USB 设备:虚拟机本身有大量可被检测的特征 (CPUID / IOReg / 显卡型号),而且对方真要查也能查出来 VM 痕迹。
改 macOS 内核扩展,把注入事件伪装成 HID 来源:理论可行,但要签内核,而且系统升级一次就废一次,得不偿失。
DriverKit 写自己的 HID 驱动:可以,但要苹果开发者账号 + 复杂的 entitlement 申请,一个人折腾不动。
Karabiner 之类的虚拟设备工具:本质上还是软件,源 ID 还是 Private。
对比下来,外接一个真硬件 HID 设备是最干净、最便宜、最难被反爬识别的路径。一台 30 块的 Pi 解决一切问题。
后话
回头看,这事最大的启发不是技术 ——
所有"在软件层隐藏自己"的对抗,长期看都会输给"我有更多手段查你"的对抗。 反爬方一旦想查,JS 上下文里的所有标志位都会被一个一个翻出来。
跳出去一层,在 OS 层操作 —— 还能撑一阵。
再跳出去一层,在硬件层操作 —— OS 这一层都分不出真假了。
下一次想搞自动化的时候,先问自己:对方能在哪一层查你?你能不能比那一层再低一层?
如果不能,那别赌。