不用 CDP 接管 Chromium:Windows UIA 实战
一、CDP 在默认 profile 下是静默失效的
--remote-debugging-port 的工作方式是:启动时在指定端口开一个 DevTools HTTP 服务,外部通过 /json/version 拿到 webSocketDebuggerUrl,再升级 WebSocket 走 CDP。
这套流程从 Chromium 136 前后开始多了一个完全静默的前置条件:默认 user-data-dir 下不监听调试端口。
现象是:进程正常起、窗口正常显示、页面正常加载、Get-Process 有返回,但端口不存在。
Get-NetTCPConnection -LocalPort 9222 -State Listen -ErrorAction SilentlyContinue
# => 空集。不是报错,是空集
Invoke-WebRequest http://127.0.0.1:9222/json/version
# => 404。带 Upgrade 头则 403
判据必须是 -State Listen 有实际返回,不能是「进程起来了」。进程存活与端口监听是两个独立事实,前者推不出后者。
根因是设计取舍:浏览器把调试口当成配置目录级别的能力。默认 profile 装着真实登录态、扩展、书签,在这里开口等于把整台机器的浏览上下文暴露给任意本地进程。于是策略上直接忽略参数——不报错、不警告、不写日志。
修法是显式传一个独立 --user-data-dir=<临时目录>。但这条路有个不可逆的代价,值得单独讲。
独立 profile 的代价,以及为什么不能「重启回去」
独立 profile 意味着全新浏览器:无 Cookie、无 localStorage、无扩展、无已保存密码。要登录就得重新登一次。
更麻烦的是反向操作:为了回到「有登录态的 profile」而重启原浏览器,会永久丢失会话级 Cookie。现代站点大量使用 is_persistent=0 的会话 Cookie(SessionID、IAMIdentity 这类身份票据),它们只存在于进程内存,进程退出即销毁,不落盘。重启 = 注销。
所以现实约束是:已登录且不掌握凭据时,不能重启浏览器。CDP 这条路走不通,需要一条不重启、不注入、不依赖调试端口的通路。
二、UIA:读浏览器主动维护的无障碍树
Windows UI Automation 是操作系统级无障碍接口。Chromium 系浏览器会把渲染树投影成 UIA 元素树——不是模拟、不是截图识别,而是浏览器主动维护的结构化语义视图。
三个直接好处:零侵入(不碰调试端口、不注入脚本、可对已在运行的进程工作);零显存(不截屏、不做图像识别);可读可写(ValuePattern 能写入并回读,按钮能 Invoke,链接能取 URL)。
代价要说清楚:UIA 树是无障碍语义,不是 DOM。没有 div/span,只有 ControlType(Text / Button / Hyperlink / Edit / ComboBox…),定位靠 Name(通常映射可见文本或 aria-label)与 AutomationId。语义良好的站点够用,div 堆砌的站点会很难受。
跨桌面陷阱一:SetThreadDesktop 报 ERROR_BUSY(170),真凶是 ReuseThread
自动化宿主进程常运行在独立的 window station / desktop 上(WinSta0\exebox-* 之类)。实测当前线程所在桌面:
# 实测输出:exebox-XXXXXXXX
# 而 OpenInputDesktop 返回的是 "Default" —— 交互式桌面存在,只是这个线程不在上面
在这样的线程里直接 SendInput 会拿到 0 + ERROR_ACCESS_DENIED(5)——你的线程「看不见」用户的交互式桌面。
标准解法是 OpenDesktop("Default") + SetThreadDesktop。但这里有个非常隐蔽的分叉:同样是新建 STA runspace,写法不同结果完全相反。
# ❌ 失败:ERROR_BUSY (170)
$rs = [runspacefactory]::CreateRunspace()
$rs.ApartmentState = 'STA'
$rs.ThreadOptions = 'ReuseThread' # ← 真凶
$rs.Open()
# SetThreadDesktop => False, err=170
# ✅ 成功:SetThreadDesktop => True, err=0,桌面切到 Default
$rs = [runspacefactory]::CreateRunspace()
$rs.ApartmentState = 'STA'
# 不设 ThreadOptions(默认走新线程)
$rs.Open()
三种上下文实测对照(同一台机器、同一段探测代码):
| 执行上下文 | SetThreadDesktop |
err |
|---|---|---|
| 主线程 | False | 170 |
runspace + ThreadOptions='ReuseThread' |
False | 170 |
| runspace(默认 ThreadOptions) | True | 0 |
原生 System.Threading.Thread(STA) |
True | 0 |
根因不是「主线程」这个身份,而是这个线程是否已经绑定过 UI 资源。ReuseThread 的语义是「复用调用方已有的线程」——那个线程显然已经绑过窗口/消息队列,所以换桌面必然失败。默认 ThreadOptions 会开一个全新线程,它什么都没绑定,切换才成立。
这个区分很重要:ReuseThread 是个常见的「优化」写法(省一次线程创建),但它会稳定失败在 170,而错误信息(ERROR_BUSY)完全指向不了真正原因——你会以为是权限问题、去查安全策略。
跨桌面陷阱二:Add-Type -ReferencedAssemblies 会覆盖默认引用集
想加载 UIA 程序集时这样写:
# ❌ -ReferencedAssemblies 是「覆盖」不是「追加」,会把 mscorlib 等基础集挤掉
Add-Type -AssemblyName UIAutomationClient -ReferencedAssemblies UIAutomationTypes
正确写法是把多个程序集名用逗号一起传给 -AssemblyName:
Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes
三、实战:按 HWND 寻址,按 ControlType 过滤
不要按窗口标题定位
页面加载过程中标题会变(正在加载… → 实际标题 → 通知数后缀 (3)),按标题匹配的循环会在导航瞬间失败。正确做法是先取一次 HWND,之后全部按 HWND 寻址:
Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes
$AE = [System.Windows.Automation.AutomationElement]
# 先按进程取一次窗口
$cond = [System.Windows.Automation.PropertyCondition]::new($AE::ProcessIdProperty, $pid)
$win = $AE::RootElement.FindFirst([System.Windows.Automation.TreeScope]::Children, $cond)
$hwnd = [IntPtr]$win.Current.NativeWindowHandle
# 之后只用 HWND,标题变化不影响
$cond = [System.Windows.Automation.PropertyCondition]::new($AE::NativeWindowHandleProperty, $hwnd.ToInt64())
$win = $AE::RootElement.FindFirst([System.Windows.Automation.TreeScope]::Children, $cond)
# 一次取 Descendants,在内存里按 ControlType 过滤
$all = $win.FindAll([System.Windows.Automation.TreeScope]::Descendants,
[System.Windows.Automation.Condition]::TrueCondition)
# 注意:foreach 语句不能直接接管道,要用 $( ) 包起来
$texts = $(foreach ($e in $all) {
if ($e.Current.ControlType -eq [System.Windows.Automation.ControlType]::Text) { $e.Current.Name }
}) | Where-Object { $_ }
# ValuePattern 写入:SetValue 在只读控件上是静默 no-op,回读是唯一判据
function Set-Value($el, [string]$s) {
$vp = $null
if (-not $el.TryGetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern, [ref]$vp)) {
throw "不支持 ValuePattern"
}
$vp.SetValue($s)
if ($vp.Current.Value -ne $s) { throw "写入未生效:期望 '$s',实际 '$($vp.Current.Value)'" }
}
遍历时注意:每次读 .Current 都是一次跨进程 COM 往返。几千个元素逐个读 BoundingRectangle、HelpText 会耗时数秒——只取真正需要的属性。
四、踩坑清单
以下问题全部真实撞到过,共同特征是失败时没有错误信号。
4.1 SendInput 的 INPUT 是 40 字节,不是 64
按直觉用 LayoutKind.Sequential 声明 INPUT { uint type; MOUSEINPUT mi; KEYBDINPUT ki; },x64 下得到 64 字节。Win32 真实结构是共用同一块内存的联合体,40 字节。
cbSize 传 64 时 SendInput 返回 0,GetLastError = ERROR_INVALID_PARAMETER(87),键鼠输入全部静默丢失。
using System.Runtime.InteropServices;
[StructLayout(LayoutKind.Explicit)] // 必须是 Explicit
public struct INPUTUNION {
[FieldOffset(0)] public MOUSEINPUT mi;
[FieldOffset(0)] public KEYBDINPUT ki; // 所有成员都从偏移 0 开始
}
[StructLayout(LayoutKind.Sequential)]
public struct INPUT { public uint type; public INPUTUNION u; }
(MOUSEINPUT / KEYBDINPUT 是标准 32 / 24 字节结构,定义从略;HARDWAREINPUT 可选,不影响尺寸。)
一行验证:[Runtime.InteropServices.Marshal]::SizeOf([type][INPUT]) 必须等于 40。
4.2 GetLastError 只在返回 0 时有意义
SendInput 成功时不清零 GetLastError,你读到的是线程上一次失败调用的残留值。判据只能是返回值本身。
另外,在沙箱 / 隔离桌面会话里跑会拿到 ERROR_ACCESS_DENIED(5)——那是桌面隔离,不是结构体问题。必须先在 OpenDesktop("Default") + SetThreadDesktop 环境下重测。没有阳性对照的 err=5 结论不可信,因为它区分不了「代码错」和「环境不允许」。
4.3 会话级 Cookie:重启即销毁
真要重启浏览器,顺序必须是:重启前 getAllCookies 全量导出落盘 → 重启后 setCookies 回灌 → 实测一个受保护接口确认登录态回来了,而不是数 Cookie 条数。拿不到 CDP 就无法导出,那就不要重启,回到 UIA 路线。
4.4 批量文本改写必须带锚点
-replace '\.ki\.', '.u.ki.' 对已改过的文本同样成立,第二次执行变成 .u.u.ki.。必须加负向后行断言,并断言幂等:
$text = $text -replace '(?<!\.u)\.ki\.', '.u.ki.'
if (($text -replace '(?<!\.u)\.ki\.','.u.ki.') -ne $text) { throw "非幂等" }
五、什么时候用哪条路
| 场景 | 选择 | 理由 |
|---|---|---|
| 全新会话、可接受空 profile | CDP + 独立 --user-data-dir |
能力最全,可执行 JS、拦截网络 |
| 已有登录态、不掌握凭据 | UIA | 不重启、不注入,进程照常工作 |
| 需读 DOM、注入脚本 | CDP | UIA 只有无障碍语义,没有 DOM |
| 只需填表 / 点按钮 / 读文本 | UIA | 零显存、零调试端口,稳定性更好 |
| 页面语义差(大量 div 无 aria) | CDP | UIA 树里找不到可辨识 Name |
CDP 强在能力,UIA 强在无侵入。两者不是替代关系:先用 UIA 处理「不能重启的已登录会话」,再用 CDP 处理「需要深度操作的场景」。
无论走哪条路,验收标准都应是物理真值:端口是否真的在 Listen、SizeOf 是否真的是 40、ValuePattern 回读是否真的等于写入值。进程存活、窗口出现、命令没报错——这些都不是证据。
- 点赞
- 收藏
- 关注作者
评论(0)