1. 从轮询到钩子:Windows 鼠标行为捕获到底难在哪
如果你写过桌面行为记录、自动化测试回放、或者给内部工具加一个「操作轨迹」功能,大概率绕不开一个需求:把用户在 Windows 上的鼠标点击和移动抓下来。最直觉的做法就是开一个死循环,每隔几十毫秒调一次GetCursorPos读坐标,再用GetAsyncKeyState判断左右键有没有按下。这个思路能跑,但真放到实际场景里,问题会一个接一个冒出来。
先说轮询方案的天生缺陷。Sleep(100)意味着你每 100 毫秒才采样一次,鼠标快速划过屏幕时,中间一大段轨迹直接丢了,回放出来就是「瞬移」。而且GetAsyncKeyState的最低有效位表示「自上次调用以来是否按过」,Windows 是多任务抢占式的,别的程序也可能调用这个函数把「刚按下」的状态抢走,所以你不能依赖它做精确的按下/抬起判定。更麻烦的是,轮询是主动查询,CPU 一直被你占着,长时间跑下来风扇都转起来了。
真正靠谱的做法是装钩子(Hook)。SetWindowsHookEx配合WH_MOUSE_LL低级鼠标钩子,能让系统在鼠标事件发生时主动回调你的函数,坐标、按键、滚轮、时间戳一次性给全,精度和实时性都不是轮询能比的。代价是你要理解 Windows 的消息循环机制,钩子回调必须挂在有消息循环的线程上,否则收不到事件。
这篇文章就按「先跑通轮询版 → 再升级到钩子版 → 最后把采集到的数据通过统一 API 通道上报」这条线走。中间会给出可以直接复制的 Win32 代码、编译命令、以及一个容易踩的坑:钩子回调里不能做耗时操作,否则系统会把你的钩子踢掉。至于数据上报那一环,我会用 TaoToken 的统一 Key/API 通道来演示,这样你不用为每个模型或服务单独维护一套鉴权逻辑。
适合谁看:有 C 语言基础、想在 Windows 上做行为采集的开发者;做自动化测试需要录制鼠标操作的测试工程师;以及想理解 Win32 钩子机制的学生。下面所有代码都在 Windows 10/11 + MinGW-w64(g++)环境下实测过,MSVC 也能编译,差异我会标注。
2. TaoToken 前置准备:统一 Key 与 API 通道接入
在写钩子代码之前,先把数据上报的通道准备好。你采集到的鼠标事件最终要落到某个地方——可能是本地日志,也可能是发给一个模型做行为分析,或者存进后端。如果每个目标都单独配一套 Key 和 Base URL,代码里会散落一堆硬编码,换环境就崩。TaoToken 的思路是给你一个统一的入口,Key 和 API 地址固定,模型或服务通过参数切换。
先注册并拿到 Key。打开官网 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,完成账号注册后进入控制台。控制台地址是 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,在左侧菜单找到「API Keys」,点新建,复制那串以sk-开头的字符串。这个 Key 只显示一次,丢了就得重建,所以先存到环境变量里,别写死在源码。
API 的基础地址是 https://taotoken.net/api ,注意这个地址不带任何查询参数,是纯粹的接口根路径。所有请求都往这个根路径拼,比如对话接口就是/v1/chat/completions。如果你用的是 OpenAI 兼容的 SDK,把base_url设成https://taotoken.net/api就行。
这里有个细节值得说清楚:TaoToken 不是让你绕过什么,它就是一个正常的 API 聚合入口,把不同模型的调用统一成一套鉴权和计费。你在代码里只需要维护一个Authorization: Bearer sk-xxx头,换模型只改请求体里的model字段。对于鼠标行为采集这种场景,你可能想把事件流批量发给一个模型做「异常操作检测」,或者只是简单地上报到自己的服务,统一通道能省掉大量配置工作。
配置建议用环境变量,Windows 下可以这样设(PowerShell):
$env:TAOTOKEN_API_KEY = "sk-你的实际Key" $env:TAOTOKEN_BASE_URL = "https://taotoken.net/api"设完之后新开的终端都能读到。如果你要在 C 代码里读取,用getenv("TAOTOKEN_API_KEY")即可,注意返回值可能是 NULL,要做判空。这样做的另一个好处是:源码提交到 Git 时不会泄露 Key,团队协作时每人本地配自己的。
模型 ID 怎么选?如果你只是做事件上报,用任意一个便宜的对话模型都行;如果要做行为语义分析,选一个上下文长一点的。具体可用列表在文档里查:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。文档里会列出当前支持的模型 ID 和对应的计费方式,照着填进请求体就行。
前置准备就这些:一个 Key、一个 Base URL、一个模型 ID。三件套齐了,后面代码里直接引用。别小看这一步,很多人卡在 401 就是因为 Key 复制时带了空格,或者环境变量没生效。下一节开始写真正的捕获代码。
3. 可复制配置:轮询版与钩子版鼠标捕获代码
先给一个能立刻跑起来的轮询版,理解基本 API,再升级到钩子。轮询版的核心就是GetCursorPos和GetAsyncKeyState两个函数,头文件只需要windows.h。
#include <windows.h> #include <stdio.h> int main(void) { POINT pt; while (1) { Sleep(50); if (GetCursorPos(&pt)) { printf("move x=%ld y=%ld\n", pt.x, pt.y); } if (GetAsyncKeyState(VK_LBUTTON) & 0x8000) { printf("left down at x=%ld y=%ld\n", pt.x, pt.y); } if (GetAsyncKeyState(VK_RBUTTON) & 0x8000) { printf("right down at x=%ld y=%ld\n", pt.x, pt.y); } if (GetAsyncKeyState(VK_MBUTTON) & 0x8000) { printf("middle down at x=%ld y=%ld\n", pt.x, pt.y); } } return 0; }编译命令(MinGW-w64):
g++ mouse_poll.cpp -o mouse_poll.exe -lgdi32 -luser32跑起来你会看到坐标刷屏,按键按下时打印一行。但正如第一节说的,这个版本有两个硬伤:采样间隔丢轨迹,以及GetAsyncKeyState的按下判定不可靠(按住不放会一直打印)。所以它只适合快速验证环境,不适合生产。
钩子版才是正解。低级鼠标钩子用WH_MOUSE_LL,回调函数签名是LRESULT CALLBACK LowLevelMouseProc(int nCode, WPARAM wParam, LPARAM lParam)。lParam指向一个MSLLHOOKSTRUCT,里面有坐标、时间戳、鼠标数据。关键点:钩子必须装在带消息循环的线程上,主线程跑一个GetMessage循环即可。
#include <windows.h> #include <stdio.h> static LRESULT CALLBACK MouseProc(int nCode, WPARAM wParam, LPARAM lParam) { if (nCode == HC_ACTION) { MSLLHOOKSTRUCT *p = (MSLLHOOKSTRUCT *)lParam; switch (wParam) { case WM_MOUSEMOVE: printf("move x=%ld y=%ld t=%lu\n", p->pt.x, p->pt.y, p->time); break; case WM_LBUTTONDOWN: printf("left down x=%ld y=%ld\n", p->pt.x, p->pt.y); break; case WM_LBUTTONUP: printf("left up x=%ld y=%ld\n", p->pt.x, p->pt.y); break; case WM_RBUTTONDOWN: printf("right down x=%ld y=%ld\n", p->pt.x, p->pt.y); break; case WM_MOUSEWHEEL: printf("wheel delta=%d\n", (short)HIWORD(p->mouseData)); break; } } return CallNextHookEx(NULL, nCode, wParam, lParam); } int main(void) { HHOOK hHook = SetWindowsHookEx(WH_MOUSE_LL, MouseProc, GetModuleHandle(NULL), 0); if (!hHook) { printf("hook failed: %lu\n", GetLastError()); return 1; } printf("hook installed, press Ctrl+C to exit\n"); MSG msg; while (GetMessage(&msg, NULL, 0, 0)) { TranslateMessage(&msg); DispatchMessage(&msg); } UnhookWindowsHookEx(hHook); return 0; }编译:
g++ mouse_hook.cpp -o mouse_hook.exe -luser32注意SetWindowsHookEx的第三个参数:对于WH_MOUSE_LL这种全局低级钩子,传GetModuleHandle(NULL)即可,不需要 DLL 注入。第四个参数传 0 表示全局。如果你只想钩当前进程,传GetCurrentThreadId()。
这里有个必须强调的坑:回调函数里绝对不能做耗时操作。printf在控制台其实已经算慢的了,高频移动时可能拖慢系统。生产环境应该把事件写进一个环形缓冲区,由另一个线程消费。另外,回调里不要调用会触发消息循环的函数,否则可能死锁。
如果你要把事件上报到 TaoToken,建议在回调里只做「入队」,上报逻辑放到独立线程。下面是一个简化的上报函数,用 WinHTTP 发 POST:
#include <windows.h> #include <winhttp.h> #include <stdio.h> void report_event(const char *json) { HINTERNET hSession = WinHttpOpen(L"MouseReporter/1.0", WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); HINTERNET hConnect = WinHttpConnect(hSession, L"taotoken.net", INTERNET_DEFAULT_HTTPS_PORT, 0); HINTERNET hRequest = WinHttpOpenRequest(hConnect, L"POST", L"/api/v1/chat/completions", NULL, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE); const wchar_t *headers = L"Content-Type: application/json\r\nAuthorization: Bearer sk-你的Key\r\n"; WinHttpSendRequest(hRequest, headers, -1L, (LPVOID)json, (DWORD)strlen(json), (DWORD)strlen(json), 0); WinHttpReceiveResponse(hRequest, NULL); WinHttpCloseHandle(hRequest); WinHttpCloseHandle(hConnect); WinHttpCloseHandle(hSession); }编译时加-lwinhttp。注意这里 Key 是硬编码演示,实际请从环境变量读。上报的 JSON 结构按文档要求拼,模型 ID 填你选的那个。这样一套下来,捕获和上报就打通了。
4. 验证请求与成功结果:实际点击移动动作测试
代码写完,得验证它真的在工作。分两步:先验证钩子捕获,再验证上报通道。
验证钩子:编译mouse_hook.exe后直接运行,会看到hook installed提示。然后在桌面上慢慢移动鼠标,控制台应该实时打印move x=... y=...。点一下左键,打印left down和left up。滚一下滚轮,打印wheel delta=120(向前)或-120(向后)。如果这些都有,说明钩子装成功了。
我实测时发现一个现象:移动事件非常密集,一秒钟能刷几百行,控制台滚动太快根本看不清。这时候可以把WM_MOUSEMOVE的打印改成「每 10 个事件打印一次」,或者加个计数器。生产环境肯定要限流,不然日志文件瞬间几个 G。
验证上报:把上报函数接进回调的队列消费线程,发一条测试事件。你可以先用一个固定的 JSON 字符串手动调report_event,看返回。更直观的方式是用 curl 先测通道通不通:
curl -X POST https://taotoken.net/api/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk-你的Key" \ -d '{"model":"你的模型ID","messages":[{"role":"user","content":"ping"}]}'如果返回里有choices字段和内容,说明 Key 和通道都没问题。这一步能排除掉大部分配置错误。返回 401 就是 Key 错了,返回 404 就是路径拼错了,返回超时就是网络问题。
把 curl 测通之后,再跑 C 程序的上报。建议在report_event里加日志,把 HTTP 状态码打出来。WinHttpQueryHeaders可以拿到状态码,200 就是成功。如果一直拿不到响应,检查WinHttpSendRequest的返回值,以及是不是忘了WinHttpReceiveResponse。
一个完整的验证流程是这样的:运行钩子程序 → 在屏幕上画个圈 → 点几下左右键 → 滚轮上下滚 → 观察控制台输出是否覆盖了 move/down/up/wheel 四类事件 → 同时看上报线程的日志有没有 200。全部通过,说明捕获和上报链路都通了。
如果你想让验证更直观,可以把捕获到的坐标写进一个 CSV,然后用脚本画轨迹图。不过那是另一个话题了。这里的关键是:先确保事件类型齐全,再确保上报成功,最后才考虑数据怎么用。
5. 本篇常见错误排查:401、钩子失效与编译报错
这一节把最容易卡住人的几个报错列出来,对照着查。
401 Unauthorized。这是上报环节最常见的。原因通常有三个:Key 复制时带了首尾空格;环境变量没生效(比如在旧终端里跑);请求头拼写错误,比如Authorization写成了Authorisation,或者漏了Bearer前缀。排查方法:先用 curl 测,curl 通了再查 C 代码。如果 curl 也 401,那就是 Key 本身的问题,去控制台重新生成一个。
hook failed: 1428。SetWindowsHookEx返回 NULL,GetLastError是 1428,意思是「不能设置非全局钩子而没有模块句柄」。这通常发生在你用了WH_MOUSE_LL但第三个参数传了 NULL。改成GetModuleHandle(NULL)即可。另一个可能是你在没有消息循环的线程里装钩子,低级钩子要求线程有消息队列。
钩子装上了但收不到事件。检查你的消息循环是不是GetMessage而不是PeekMessage空转。GetMessage会阻塞等待消息,钩子回调才能被派发。如果你用while(1)空转,回调永远不会触发。另外,如果程序没有以管理员权限运行,某些高权限窗口(比如任务管理器)上的鼠标事件可能收不到,这是 UIPI 机制限制,不是代码问题。
编译报错 undefined reference toSetWindowsHookEx。链接时漏了库。MinGW 下加-luser32,MSVC 下在项目属性里链接User32.lib。GetCursorPos也在 user32 里。WinHttp相关函数要加-lwinhttp。
回调里 printf 导致系统卡顿。前面提过,回调必须快。如果你发现鼠标移动变卡,八成是回调里做了同步 IO。解决办法:回调只往无锁队列里塞数据,另开线程消费。队列可以用简单的环形缓冲区加临界区实现。
上报时WinHttpSendRequest返回 FALSE。用GetLastError看具体码。常见的是 12007(域名解析失败)或 12029(连接失败)。检查网络,以及 URL 是不是写成了http://而不是https://。TaoToken 的 API 走 HTTPS,端口 443。
OAuth 相关报错。如果你用的是某些需要 OAuth 流程的客户端(比如 Claude Code 这类工具),报错信息里可能出现 OAuth 字样。这种情况通常是客户端配置里的鉴权方式选错了。对于直接调 API 的场景,用 Bearer Token 就行,不需要 OAuth。如果你在用 Claude Code 接入,参考文档里的配置说明:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,里面会讲清楚 Base URL、Key、Model ID 三件套怎么填。
reading choices 报错。这通常出现在解析响应时,说明返回的 JSON 里没有choices字段。原因可能是模型 ID 填错了,或者请求体格式不对。先用 curl 看原始返回,确认结构再改解析代码。
把这几类错误过一遍,基本能覆盖 90% 的卡点。剩下的就是网络环境和权限问题了。
6. 把捕获数据用起来:接入与后续方向
钩子跑通、上报验证成功之后,你手里就有了一条实时的鼠标事件流。接下来怎么用,取决于你的场景。做自动化测试的,可以把事件流存成脚本,回放时用SendInput模拟;做行为分析的,可以把事件批量发给模型,让它判断「这段操作是否异常」;做内部工具的,可以做个热力图,看用户在哪块区域点击最频繁。
如果你要把事件流接到模型做分析,用 TaoToken 的对话接口就行。把一批事件拼成 JSON 数组塞进messages,让模型输出结构化结果。模型对话入口在 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,可以先在网页上试提示词,调好了再写进代码。如果你要长期跑编码或 Agent 类任务,Coding Plan 更划算:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。
API Key 管理在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,可以建多个 Key 分别给不同程序用,方便排查和吊销。接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,遇到参数问题先查文档。
最后给一个实用建议:钩子程序长时间运行一定要做异常保护。用SetUnhandledExceptionFilter兜底,崩溃时自动卸载钩子,不然钩子残留会导致整个系统的鼠标响应变慢,只能重启解决。这个坑我踩过,排查了半天才发现是上次调试时程序异常退出没清理钩子。