← cd ../posts

把某招聘网站"喂熟"了,我用了一台树莓派

2026-05-19

把某招聘网站"喂熟"了,我用了一台树莓派

起点

公司 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 这一层都分不出真假了。

下一次想搞自动化的时候,先问自己:对方能在哪一层查你?你能不能比那一层再低一层?

如果不能,那别赌。