### 从“小龙虾”式消耗到免费自给
做桌面软件分发的兄弟都知道,给海量exe打标签、分目录是一件多么反人类的事。尤其是当你的软件库每周新增上百个未知程序,靠人工一个一个识别分类,效率低到想砸键盘。
市面上有付费的“软件识别库”,像某龙虾工具,按调用次数收费——每查一次就消耗一份“土壤”,成本随软件量线性增长,小团队根本扛不住。我们团队就曾为此每月烧掉几千块,关键是识别准确率还不尽如人意。
有个坑:我们一开始走了弯路,想用纯本地特征库(MD5+文件名)来匹配,结果新软件变种层出不穷,特征库维护成了无底洞。后来痛定思痛——既然网页版AI能免费识别,为什么不直接“借”它的能力?
于是我们转向了 WinForm + Microsoft Edge WebView2 + 免费AI网页接口 的方案。核心思路很简单:用WebView2无头加载目标AI页面,程序化提交软件信息,拦截并结构化解析AI返回的识别结果,最终通过自建HTTP接口对外提供服务。整个闭环不花一分钱API调用费,而且响应速度完全可控。
说白了,这就是用浏览器的“免费通行证”打通桌面分类的最后一公里。
技术选型:为什么是WinForm + WebView2?
不少老项目还在用WebBrowser(IE内核),但面对现代AI网页(重度使用ES6、WebSocket),它根本渲染不出来。而Microsoft Edge WebView2基于Chromium,完美兼容所有现代前端特性,且随Edge运行时自动更新,免去维护独立浏览器内核的麻烦。
.NET 8 WinForm(不是.NET Framework)提供现代化的异步编程模型,对async/await和HttpClient支持友好,非常适合做这种I/O密集型的代理服务。
我们最终的架构是一个Windows服务+托盘UI:UI用于配置AI目标URL、本地软件库路径,后台运行HTTP侦听器(基于Microsoft.AspNetCore.App自宿主),接收外部调用请求。
核心三步走:抓取、拦截、封装
整个实现拆成三个关键环节,缺一不可。
1. WebView2 无头加载与页面就绪检测
我们不展示窗口,只用WebView2的隐藏实例。难点在于如何确认AI页面已完全加载且可交互——很多AI页面是SPA,NavigationCompleted事件只能代表HTML下载完成,JS渲染和模型初始化可能还在进行。
我们的做法是轮询执行JavaScript,检测页面上独有的“输入框”和“提交按钮”是否存在且可点击。代码片段如下:
// 使用 .NET 8 WinForm + WebView2
private async Task<bool> WaitForAIPageReady(WebView2 webView, int timeoutSeconds = 30)
{
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
while (!cts.IsCancellationRequested)
{
try
{
string script = @"
(() => {
const input = document.querySelector('textarea[placeholder*="输入"]') ||
document.querySelector('input[type="text"]');
const btn = document.querySelector('button[type="submit"]') ||
document.querySelector('button:has(svg)');
return !!(input && btn && input.offsetParent !== null && btn.offsetParent !== null);
})();
";
var result = await webView.CoreWebView2.ExecuteScriptAsync(script);
if (result.ToLower().Contains("true"))
return true;
}
catch { /* 忽略执行异常,继续重试 */ }
await Task.Delay(500, cts.Token);
}
return false;
}
踩坑警告:
CoreWebView2.ExecuteScriptAsync返回的是JSON字符串("true"或"false"),不要直接当bool比较。我们曾在这里翻车,导致死等30秒超时。
2. 拦截AI响应:Hook网络请求而非DOM解析
很多教程教大家用ExecuteScript去扒页面DOM文本,但AI的流式响应往往是SSE或WebSocket推送,DOM变化频繁且结构不稳定。更稳健的方案是拦截WebView2的网络响应。
WebView2提供了CoreWebView2.WebResourceResponseReceived事件,可以捕获所有HTTP响应。我们通过过滤URL关键字(如/api/chat、/v1/completions)和响应头(content-type: text/event-stream)来精准命中AI回复。
webView.CoreWebView2.WebResourceResponseReceived += async (sender, args) =>
{
// 只拦截我们需要的API路径
if (args.Request.Uri.Contains("/api/chat") && args.Response.StatusCode == 200)
{
// 注意:WebResourceResponseReceived 的 Response 流只能读一次,且是只读的。
// 我们需要用 MemoryStream 复制一份,避免影响浏览器正常渲染。
using var originalStream = args.Response.Content;
if (originalStream != null)
{
var memStream = new MemoryStream();
await originalStream.CopyToAsync(memStream);
memStream.Position = 0;
// 解析SSE格式或纯JSON,提取最终文本
string responseBody = await ReadStreamAsStringAsync(memStream);
string aiResult = ExtractAIResultFromResponse(responseBody);
// 存入共享队列,供外部HTTP接口调用
_aiResultQueue.Enqueue(aiResult);
_readySignal.Set();
}
}
};
关键点:args.Response.Content 是只读流,读取后必须CopyToAsync到新流,否则浏览器后续处理会失败。我们曾忘记复制,导致页面卡死,整整调试了一下午。
3. 构建HTTP接口:对外提供分类服务
既然是要“自动分类”,那必须让其他桌面程序或脚本能通过HTTP调用。我们在WinForm中自宿主一个极简的ASP.NET Core管道,监听http://localhost:56789/classify。
请求体接收JSON:{ "fileName": "unknown.exe", "fileSize": 2048, "iconHash": "..." }
内部逻辑:
- 构造一段Prompt(例如:“请判断这个文件属于哪类软件:办公、开发、游戏、系统工具、其他。只输出类别名称。”)
- 通过
WebView2.ExecuteScriptAsync将Prompt填入AI页面输入框并触发提交。 - 阻塞等待
_aiResultQueue中出现新结果,超时则返回“unknown”。
[HttpPost("/classify")]
public async Task<IResult> Classify([FromBody] FileInfoRequest request)
{
if (string.IsNullOrEmpty(request.FileName))
return Results.BadRequest("Missing fileName");
// 构造并填充AI输入
string prompt = $"文件名称:{request.FileName},大小:{request.FileSize}KB。请仅输出一个类别:办公|开发|游戏|系统工具|其他。";
string fillScript = $@"
document.querySelector('textarea').value = '{EscapeJsString(prompt)}';
document.querySelector('button[type="submit"]').click();
";
await _webView.CoreWebView2.ExecuteScriptAsync(fillScript);
// 等待结果,超时5秒
if (await _readySignal.WaitAsync(TimeSpan.FromSeconds(5)))
{
if (_aiResultQueue.TryDequeue(out string category))
return Results.Ok(new { category });
}
return Results.Ok(new { category = "未知" });
}
实测效果与性能对比
我们在内部测试库中选了200个常见软件,分别用我们的方案和“某龙虾付费API”做盲测。结果如下:
| 指标 | 本方案(Edge+AI) | 龙虾付费API |
|---|---|---|
| 单次响应耗时(平均) | 4.2秒 | 1.8秒 |
| 分类准确率(人工复核) | 91.5% | 93.0% |
| 1000次调用成本 | 0元 | ~$45 |
| 是否需要联网 | 是(AI网页) | 是 |
| 可定制Prompt | ✅ 自由 | ❌ 固定 |
结论很直白:我们牺牲了约2.4秒响应时间,但换来了零成本和完全可控的识别逻辑。对于非实时批量分类场景(比如夜间定时任务),这个权衡非常划算。
生产环境必须解决的几个硬骨头
登录态与反爬
大部分免费AI页面要求登录。我们的方案是首次手动登录一次,然后持久化WebView2的User Data Folder(--user-data-dir参数),这样Cookie和LocalStorage都会保留。部署时只需在服务器上手动登录一次,后续重启均保持登录态。
var env = await CoreWebView2Environment.CreateAsync(
userDataFolder: Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "WebView2Data")
);
await webView.EnsureCoreWebView2Async(env);
有个坑:如果AI页面有频繁的图形验证码或风控,这种方式可能触发“异常登录”警告。我们的对策是控制请求频率(设置最少30秒间隔),模拟人类操作间隔。
并发处理
WebView2实例本身不是线程安全的,且同时只能执行一个ExecuteScript。所以我们把HTTP接口设计为单线程队列,外部请求依次排队,内部用SemaphoreSlim(1,1)控制。并发量要求高的场景,可以启动多个WebView2实例(每个监听不同端口),用Nginx做负载均衡。
稳定性守护
AI页面偶尔会崩溃或弹窗(比如“网络错误”)。我们加了一个健康检查协程,每30秒用ExecuteScript执行一个无副作用JS(如return 1+1),如果超时或报错,则强制webView.Reload()并重新等待页面就绪。
结语:免费且有态度的分类工具
这套方案上线两个月,稳定处理了超过4万次分类请求,成本为0。唯一付出的代价是初期调试WebView2拦截时掉了几根头发,以及偶尔需要手动刷新登录态。
如果你也在为桌面软件分类成本发愁,别再当“小龙虾”了——与其花钱买有限次数的调用,不如利用浏览器这个“免费计算器”自己搭一个。重要的是,你拥有了完全自定义Prompt的能力,甚至能根据软件行为日志动态调整识别规则。
下一步,我们计划将识别结果缓存到本地SQLite,对重复文件直接命中缓存,把平均响应从4秒压到200ms以内。等新版本稳定了,我会再写一篇缓存设计踩坑记。
你的场景里有什么类似的“免费替代付费”的实践?欢迎在评论区碰撞想法——哪怕是用WebView2去薅别的羊毛,思路都是互通的。


