不用 CDP 接管 Chromium:Windows UIA 实战

举报
yd_212490189 发表于 2026/10/22 17:35:04 2026/10/22
【摘要】 一、CDP 在默认 profile 下是静默失效的--remote-debugging-port 的工作方式是:启动时在指定端口开一个 DevTools HTTP 服务,外部通过 /json/version 拿到 webSocketDebuggerUrl,再升级 WebSocket 走 CDP。这套流程从 Chromium 136 前后开始多了一个完全静默的前置条件:默认 user-dat...

一、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 回读是否真的等于写入值。进程存活、窗口出现、命令没报错——这些都不是证据。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。