欢迎光临
我们一直在努力

影刀RPA进阶教程:Shadow DOM元素捕获与Web Components自动化方案

影刀RPA进阶教程:Shadow DOM元素捕获与Web Components自动化方案

痛点切入

打开Chrome DevTools,Elements面板里清清楚楚能看到一个 <input> 标签。用影刀RPA的智能捕获,死活抓不到。手动输入XPath,执行时报 NoSuchElementException。换了CSS选择器,一样。

这大概率就是Shadow DOM。

Shadow DOM是Web Components的核心技术,已经在大量现代应用中普及——Google的许多产品、Salesforce Lightning、Microsoft Teams Web版、各类微前端架构都在用。它把组件的内部结构"封装"在一个隔离的DOM子树中,普通CSS选择器和XPath无法穿透。

本文基于我处理过的一个Salesforce Lightning自动化项目(整站都是Shadow DOM),给出从原理到实战的完整方案。


在这里插入图片描述

核心方法

1. 快速识别Shadow DOM

打开F12,在Elements面板中看到 #shadow-root (open) 标签,就是Shadow DOM:

<div id="app">
<custom-form> ← 宿主元素(Shadow Host)
#shadow-root (open) ← 这是Shadow Boundary
<style>…</style> ← Shadow DOM内部的样式
<div class="form-container">
<input type="text" id="username"> ← 我们要操作的元素
<button class="submit">提交</button>
</div>
</custom-form>
</div>

Chrome Console中一键检测:

// 一键扫描页面中所有Shadow DOM
(function scanShadowDOM() {
const results = [];
function traverse(node, path = '') {
if (node.shadowRoot) {
const host = node.tagName || node.nodeName;
const mode = node.shadowRoot.mode || 'closed';
const children = node.shadowRoot.querySelectorAll('*').length;
results.push({
path: path + ' > ' + host,
mode: mode,
children: children,
host: node
});
traverse(node.shadowRoot, path + ' > ' + host + '(shadow)');
}
for (const child of node.children) {
traverse(child, path + ' > ' + (child.tagName || child.nodeName));
}
}
traverse(document.documentElement);
console.table(results.map(r => ({
'宿主路径': r.path,
'Shadow模式': r.mode,
'子元素数': r.children
})));
console.log(`共发现 ${results.length} 个Shadow DOM`);
return results;
})();

2. 方案对比与选择

方案适用场景复杂度稳定性
JS注入穿透 Shadow DOM是open模式 低 高
事件穿透 只需要点击操作 低 中
在这里插入图片描述

| 直接操作宿主元素 | 已知宿主元素的内部结构 | 中 | 高 |
| CDP直接访问 | Shadow DOM是closed模式 | 高 | 最高 |

3. 方案一:JS注入穿透(最常用)

Shadow DOM如果是 open 模式,可以直接通过 element.shadowRoot 访问。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
影刀RPA — Shadow DOM JS注入操作工具
用途:穿透Shadow DOM,操作其中的元素
前提:Shadow DOM模式为 "open"
作者:林焱
"""

from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

class ShadowDOMOperator:
"""Shadow DOM操作器"""

def __init__(self, driver):
self.driver = driver

def find_in_shadow(self, host_selector, target_selector, timeout=10):
"""
在Shadow DOM中查找元素(单层穿透)

参数:
host_selector: 宿主元素的CSS选择器
target_selector: 目标元素的CSS选择器(在Shadow DOM内部)
返回:
找到的WebElement,或None
"""
js_code = """
// JS代码:穿透一层Shadow DOM查找元素
const host = document.querySelector(arguments[0]);
if (!host || !host.shadowRoot) {
return null;
}
return host.shadowRoot.querySelector(arguments[1]);
"""

element = self.driver.execute_script(js_code, host_selector, target_selector)
return element

def find_deep_in_shadow(self, selector_path):
"""
多层穿透:按路径依次进入多层Shadow DOM

参数:
selector_path: 每层的选择器列表
例如某个多层嵌套的结构:
[
"custom-app", # 第1层宿主
"custom-form", # 第2层宿主
"#submit-btn" # 最终目标元素
]
返回:
找到的最终元素,或None
"""
if len(selector_path) < 2:
raise ValueError("selector_path至少需要2个元素:[host1, host2, …, target]")

# 构建多层穿透的JS代码
js_code = """
(function() {
let element = document.querySelector(arguments[0]);
const path = arguments[1];
// path[0]到path[-2]是宿主,path[-1]是目标
for (let i = 0; i < path.length – 1; i++) {
if (!element || !element.shadowRoot) {
console.error('[RPA] 第' + i + '层Shadow DOM不存在: ' + path[i]);
return null;
}
element = element.shadowRoot.querySelector(path[i + 1]);
if (!element && i < path.length – 2) {
console.error('[RPA] 第' + i + '层未找到宿主: ' + path[i + 1]);
return null;
}
}
return element;
})();
"""

target = selector_path[–1]
host_path = selector_path[:–1]
return self.driver.execute_script(js_code, host_path[0], selector_path)

def send_keys_to_shadow(self, host_css, input_css, text):
"""
向Shadow DOM中的输入框输入文本
这是最高频的操作,单独封装
"""

js_code = """
const host = document.querySelector(arguments[0]);
if (!host || !host.shadowRoot) return false;
const input = host.shadowRoot.querySelector(arguments[1]);
if (!input) return false;
// 触发React/Vue的受控组件更新机制
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype, 'value'
).set;
nativeInputValueSetter.call(input, arguments[2]);
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
return true;
"""

result = self.driver.execute_script(js_code, host_css, input_css, text)
if not result:
raise Exception(f"Shadow DOM输入失败: host={host_css}, input={input_css}")
return result

def click_in_shadow(self, host_css, button_css):
"""
点击Shadow DOM中的按钮
"""

js_code = """
const host = document.querySelector(arguments[0]);
if (!host || !host.shadowRoot) return false;
const btn = host.shadowRoot.querySelector(arguments[1]);
if (!btn) return false;
btn.click();
return true;
"""

result = self.driver.execute_script(js_code, host_css, button_css)
if not result:
raise Exception(f"Shadow DOM点击失败: host={host_css}, button={button_css}")
return result

def get_text_from_shadow(self, host_css, element_css):
"""
从Shadow DOM中读取文本内容
常用于数据提取场景
"""

js_code = """
const host = document.querySelector(arguments[0]);
if (!host || !host.shadowRoot) return '';
const el = host.shadowRoot.querySelector(arguments[1]);
return el ? el.textContent.trim() : '';
"""

return self.driver.execute_script(js_code, host_css, element_css)

def get_all_text_from_shadow(self, host_css):
"""
获取Shadow DOM中所有可见文本(用于数据提取)
"""

js_code = """
const host = document.querySelector(arguments[0]);
if (!host || !host.shadowRoot) return [];
const elements = host.shadowRoot.querySelectorAll('[data-field]');
const results = [];
elements.forEach(el => {
results.push({
field: el.getAttribute('data-field'),
value: el.textContent.trim()
});
});
return results;
"""

return self.driver.execute_script(js_code, host_css)

# ===== 在影刀流程中使用 =====
# 影刀Python节点:
def fill_salesforce_form(driver, account_name, amount):
"""填写Salesforce(Shadow DOM)表单"""
op = ShadowDOMOperator(driver)

# 填写"客户名称"字段
op.send_keys_to_shadow(
'lightning-input[data-id="accountName"]',
'input',
account_name
)

# 填写"金额"字段
op.send_keys_to_shadow(
'lightning-input[data-id="amount"]',
'input',
str(amount)
)

# 点击"保存"按钮
op.click_in_shadow(
'lightning-button[data-id="saveButton"]',
'button'
)

# 读取"商机编号"字段(验证保存结果)
opportunity_id = op.get_text_from_shadow(
'lightning-output-field[data-id="opportunityId"]',
'span'
)
print(f"商机创建成功,编号: {opportunity_id}")
return opportunity_id

4. 方案二:操作closed模式Shadow DOM

closed模式的Shadow DOM无法通过 element.shadowRoot 访问。需要上Chrome DevTools Protocol (CDP)。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
影刀RPA — 访问closed模式Shadow DOM
使用Chrome DevTools Protocol绕过封闭限制
作者:林焱
"""

import json
import requests

class CDPShadowDomAccessor:
"""
通过CDP协议访问closed模式Shadow DOM

原理:CDP是浏览器调试协议,权限高于网页JS,
可以无视closed限制访问任意Shadow DOM。
"""

def __init__(self, driver):
self.driver = driver
# 获取CDP session(Selenium 4.0+支持)
self.cdp_endpoint = f"http://localhost:{self._get_debugger_port()}"
self.session_id = self._get_session_id()

def _get_debugger_port(self):
"""获取Chrome调试端口"""
# 从driver的capabilities中获取
caps = self.driver.capabilities
debugger_address = caps.get('goog:chromeOptions', {}).get('debuggerAddress', '')
if debugger_address:
return debugger_address.split(':')[–1]
# 默认端口(需要在启动Chrome时指定 –remote-debugging-port=9222)
return 9222

def _get_session_id(self):
"""获取当前CDP session ID"""
# Selenium 4方式
try:
return self.driver.execute_script("return 1;") # 触发session初始化
except:
pass
# 从endpoint获取
resp = requests.get(f"{self.cdp_endpoint}/json")
pages = resp.json()
current_url = self.driver.current_url
for page in pages:
if page.get('url') == current_url:
return page.get('id')
return pages[0]['id'] if pages else ''

def _cdp_command(self, method, params=None):
"""发送CDP命令"""
payload = {
"id": 1,
"method": method,
"params": params or {}
}
url = f"{self.cdp_endpoint}/session/{self.session_id}/chromium/send_command_and_get_result"
resp = requests.post(url, json=payload)
return resp.json().get('result', {})

def find_in_closed_shadow(self, host_css, target_css):
"""在closed模式Shadow DOM中查找元素"""
# 步骤1:获取宿主元素的节点ID
node_id = self._get_node_id(host_css)
if not node_id:
raise Exception(f"未找到宿主元素: {host_css}")

# 步骤2:请求Shadow DOM的根节点(CDP可无视closed限制)
result = self._cdp_command("DOM.requestChildNodes", {
"nodeId": node_id,
"depth": 1
})

# 步骤3:获取shadowRoot的节点ID
node_info = self._cdp_command("DOM.describeNode", {"nodeId": node_id})
shadow_root_info = node_info.get('node', {}).get('shadowRoots', [])
if not shadow_root_info:
raise Exception(f"宿主元素没有Shadow DOM: {host_css}")

shadow_root_id = shadow_root_info[0].get('nodeId')

# 步骤4:在Shadow Root中查询目标元素
search_result = self._cdp_command("DOM.performSearch", {
"query": target_css,
"includeUserAgentShadowDOM": True
})
search_id = search_result.get('searchId')
node_ids = self._cdp_command("DOM.getSearchResults", {
"searchId": search_id,
"fromIndex": 0,
"toIndex": 10
}).get('nodeIds', [])

return node_ids[0] if node_ids else None

def _get_node_id(self, css_selector):
"""通过CSS选择器获取节点ID"""
result = self._cdp_command("DOM.getDocument")
root_node_id = result.get('root', {}).get('nodeId')
if not root_node_id:
return None

query_result = self._cdp_command("DOM.querySelector", {
"nodeId": root_node_id,
"selector": css_selector
})
return query_result.get('nodeId')

实际踩坑:closed Shadow DOM在生产中非常少见(Google Chrome统计不到0.1%的网站使用closed模式)。大多数情况下方案一就够了。上述CDP方案主要用在对安全有极端要求的金融系统自动化中。

5. 方案三:通用捕获策略(适配任意Web Component框架)

在这里插入图片描述

不同的Web Components框架(Lit、Stencil、Fast、原生Custom Elements)内部实现不同,需要一个统一的策略。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
影刀RPA — Web Components通用捕获策略
适配Lit / Stencil / Fast / 原生Custom Elements
作者:林焱
"""

class UniversalWebComponentOperator:
"""通用Web Component操作器"""

def __init__(self, driver):
self.driver = driver
self.shadow_op = ShadowDOMOperator(driver)

def interact_with_component(self, component_css, value, action='input'):
"""
统一接口:与任意Web Component交互

参数:
component_css: 组件的CSS选择器(宿主元素)
value: 输入值
action: 操作类型 'input' | 'click' | 'select' | 'getValue'
返回:
操作结果
"""
if action == 'input':
return self._universal_input(component_css, value)
elif action == 'click':
return self._universal_click(component_css)
elif action == 'select':
return self._universal_select(component_css, value)
elif action == 'getValue':
return self._universal_get_value(component_css)
else:
raise ValueError(f"不支持的操作类型: {action}")

def _universal_input(self, component_css, value):
"""
通用输入函数
兼容 Lit 的 @property setter、React的 onChange、Vue的 v-model、原生 input.value
"""

# 这个JS代码块会在浏览器中执行,尝试多种方式设置值
js_code = """
(function() {
const component = document.querySelector(arguments[0]);
const value = arguments[1];
if (!component) {
console.error('[RPA] 未找到组件: ' + arguments[0]);
return { success: false, error: '组件未找到' };
}

let inputEl = null;

// ===== 策略1:在Shadow DOM中查找input/textarea =====
if (component.shadowRoot) {
inputEl = component.shadowRoot.querySelector('input, textarea, select');
}

// ===== 策略2:查找Light DOM中的input(slot内容)=====
if (!inputEl) {
inputEl = component.querySelector('input, textarea, select');
}

// ===== 策略3:组件本身可能就是input(如 <input is="custom-input">)=====
if (!inputEl && (component.tagName === 'INPUT' || component.tagName === 'TEXTAREA')) {
inputEl = component;
}

if (!inputEl) {
// 尝试直接在组件上设置(可能组件通过内部逻辑处理)
component.value = value;
component.setAttribute('value', value);
component.dispatchEvent(new CustomEvent('value-changed', {
detail: { value: value },
bubbles: true,
composed: true // 穿透Shadow DOM边界
}));
return { success: true, method: 'custom-event' };
}

// ===== 模拟原生输入事件(兼容React/Vue/Angular)=====
const nativeSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype, 'value'
).set;
nativeSetter.call(inputEl, value);

// 触发完整的输入事件链
const events = [
new Event('input', { bubbles: true, composed: true }),
new Event('change', { bubbles: true, composed: true }),
new Event('blur', { bubbles: true, composed: true })
];

events.forEach(event => {
inputEl.dispatchEvent(event);
});

// 额外触发keyup事件(某些框架检测键盘事件)
inputEl.dispatchEvent(new KeyboardEvent('keyup', {
bubbles: true, composed: true, key: 'Enter'
}));

return {
success: true,
method: 'input-event-chain',
targetTag: inputEl.tagName
};
})();
"""
result = self.driver.execute_script(js_code, component_css, value)
if not result.get('success'):
raise Exception(f"通用输入失败: {result.get('error')}")
print(f"[RPA] 输入成功: 方式={result.get('method')}, 目标={result.get('targetTag')}")
return True

def _universal_click(self, component_css):
"""通用点击函数"""
js_code = """
(function() {
const component = document.querySelector(arguments[0]);
if (!component) return { success: false, error: '组件未找到' };

// 尝试在Shadow DOM中找button
if (component.shadowRoot) {
const btn = component.shadowRoot.querySelector('button, [role="button"], a');
if (btn) {
btn.click();
return { success: true, method: 'shadow-btn' };
}
}

// 直接点击组件
component.click();
// 某些组件需要mousedown+mouseup
component.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, composed: true }));
component.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, composed: true }));
return { success: true, method: 'direct-click' };
})();
"""
result = self.driver.execute_script(js_code, component_css)
if not result.get('success'):
raise Exception(f"通用点击失败: {result.get('error')}")
return True

def _universal_select(self, component_css, value):
"""通用下拉选择函数"""
js_code = """
(function() {
const component = document.querySelector(arguments[0]);
const value = arguments[1];
if (!component) return { success: false };

let selectEl = null;
if (component.shadowRoot) {
selectEl = component.shadowRoot.querySelector('select');
}
if (!selectEl) {
selectEl = component.querySelector('select');
}

if (selectEl) {
selectEl.value = value;
selectEl.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
return { success: true, method: 'select-native' };
}

// 尝试设置组件属性
component.value = value;
component.setAttribute('value', value);
component.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
return { success: true, method: 'property-set' };
})();
"""
result = self.driver.execute_script(js_code, component_css, value)
if not result.get('success'):
raise Exception("通用选择失败")
return True

def _universal_get_value(self, component_css):
"""通用获取值函数"""
js_code = """
(function() {
const component = document.querySelector(arguments[0]);
if (!component) return null;

// 策略1:获取.value属性
if (component.value !== undefined && component.value !== null) {
return component.value;
}

// 策略2:在Shadow DOM中查找
if (component.shadowRoot) {
const input = component.shadowRoot.querySelector('input, textarea, select');
if (input) return input.value;
const display = component.shadowRoot.querySelector('[data-value], .value, .display-value');
if (display) return display.textContent.trim();
}

// 策略3:获取textContent
return component.textContent.trim();
})();
"""
return self.driver.execute_script(js_code, component_css)

# ===== 使用示例 =====
# def universal_demo(driver):
# op = UniversalWebComponentOperator(driver)
# # 无论是什么框架的组件,统一调用
# op.interact_with_component('custom-input[name="username"]', 'admin', 'input')
# op.interact_with_component('custom-input[name="password"]', '123456', 'input')
# op.interact_with_component('custom-button[type="submit"]', '', 'click')


常见问题速查

Q1: JS注入后元素找到了但click报错

现象:element.click() 报错 element is not attached to the page document。

原因:JS返回的是DOM节点引用,不在Selenium的管理范围内。Selenium期望的是一个"被追踪的"WebElement。

解决:不要返回JS找到的元素给Python操作,直接在JS中完成所有操作:
在这里插入图片描述

# 错误做法
element = driver.execute_script("return document.querySelector('input')")
element.click() # 可能报错

# 正确做法 — 所有操作都在JS中完成
driver.execute_script("document.querySelector('input').click()")

Q2: React组件值设成功了但提交时变成空

现象:输入框显示有值,点击提交后端收到的却是空字符串。

原因:React使用受控组件,只设置DOM的value属性不会触发React的state更新。React监听的是input/change事件。

解决:使用上面的 _universal_input 方法中完整的"事件链"触发方式,关键是 nativeInputValueSetter + dispatchEvent。

Q3: 同样的Shadow DOM脚本,在有的页面能跑有的不行

现象:脚本在开发环境正常,到了UAT环境就报"Shadow DOM不存在"。

原因:前端代码打包后,Shadow DOM的宿主元素标签名或属性变化了(如 custom-form 被minify成 c-f)。

解决:使用更稳定的定位方式——基于 data-testid(如果团队规范好)或 [aria-label]:
在这里插入图片描述

# 不稳定 — 依赖标签名
'custom-form'

# 稳定 — 依赖语义化属性
'[data-testid="login-form"]'
'[aria-label="登录表单"]'
'[role="form"]:first-of-type'

Q4: 多个Shadow DOM,结构完全相同,定位到错误的目标

现象:页面有两个 custom-input[name="username"],脚本操作了错误的那个。

原因:多个相同的Web Component实例并存,CSS选择器匹配到了第一个但实际要操作第二个。

解决:用父容器限定范围:

# 不精确
'custom-input[name="username"]'

# 精确 — 限定在第2个form中
'form:nth-of-type(2) custom-input[name="username"]'

Q5: 动态创建的Shadow DOM,脚本执行时还没渲染

现象:JS穿透脚本报"host.shadowRoot is null"。

原因:组件还在渲染中,shadowRoot还没挂载。

在这里插入图片描述

解决:

def wait_for_shadow_root(driver, host_css, timeout=10):
"""等待Shadow DOM渲染完成"""
js_code = """
return new Promise((resolve, reject) => {
const start = Date.now();
const check = () => {
const host = document.querySelector(arguments[0]);
if (host && host.shadowRoot) {
resolve(true);
} else if (Date.now() – start > arguments[1] * 1000) {
resolve(false);
} else {
setTimeout(check, 200);
}
};
check();
});
"""

result = driver.execute_script(js_code, host_css, timeout)
if not result:
raise TimeoutError(f"Shadow DOM渲染超时: {host_css}")
return True


推荐资源

  • MDN Shadow DOM文档:https://developer.mozilla.org/zh-CN/docs/Web/Web_Components/Using_shadow_DOM
  • Lit框架:https://lit.dev/ — Google的Web Components框架
  • Chrome DevTools Protocol:https://chromedevtools.github.io/devtools-protocol/
  • Selenium 4 CDP支持:https://www.selenium.dev/documentation/webdriver/bidirectional/chrome_devtools/

内容标签

#影刀RPA #ShadowDOM #WebComponents #JS注入 #CDP #React自动化 #Salesforce

在这里插入图片描述


作者:林焱

赞(0)
未经允许不得转载:171主机测评 » 影刀RPA进阶教程:Shadow DOM元素捕获与Web Components自动化方案
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址