用 Selenium 抓页面时,有时会发现某个元素在浏览器开发者工具里明明看得到,代码里却怎么都定位不到,返回 NoSuchElementException。看 DOM 结构会发现它挂在一段 #shadow-root (open) 下面——这就是 Shadow DOM。
一、为什么定位不到
Shadow DOM 是 Web Components 的封装机制:组件可以把自身内部的 HTML 结构、样式和行为封装在一个独立的 DOM 子树里,与主文档的 DOM 树隔离。
关键在于:driver.find_element() 以及所有常规的 XPath、CSS 选择器,只在主文档的 light DOM 里查找,不会进入任何 shadow root。所以像下面这样写,无论选择器多准确都会失败:
# 找不到:CSS 选择器不会穿透 shadow 边界
driver.find_element(By.CSS_SELECTOR, "#shadow-button")要拿到里面的元素,必须先定位到 shadow host(宿主元素),再进入它的 shadow root,然后在 root 内部查找。逐层进入,是这个问题的核心思路。
二、方法一:Selenium 4 的 shadow_root 属性(推荐)
Selenium 4 为 WebElement 提供了 shadow_root 属性,直接返回该元素挂载的 ShadowRoot 对象;如果该元素没有 shadow root,会抛 NoSuchShadowRoot 异常。
from selenium import webdriver
from selenium.webdriver.common.by import By
driver = webdriver.Chrome()
driver.get("http://example.com")
# 1. 先定位 shadow host
host = driver.find_element(By.CSS_SELECTOR, "my-widget")
# 2. 拿到它的 shadow root
shadow_root = host.shadow_root
# 3. 在 shadow root 内部查找真正的目标元素
button = shadow_root.find_element(By.CSS_SELECTOR, "#shadow-button")
button.click()拿到的是一个真正的 WebElement,所以 click()、send_keys()、.text 这些方法都能正常用。
这个属性对浏览器版本有要求:Chromium 96+、Firefox 96+、Safari 16.4+ 才支持。如果目标环境是更早的浏览器,用下面的方法二。
三、方法二:用 execute_script 穿透
通过注入 JavaScript 读取 element.shadowRoot,是兼容性最好的做法。
from selenium import webdriver
from selenium.webdriver.common.by import By
driver = webdriver.Chrome()
driver.get("http://example.com")
host = driver.find_element(By.CSS_SELECTOR, "my-widget")
# 方式 A:直接返回 root 内部的元素(最省事,推荐)
button = driver.execute_script(
"return arguments[0].shadowRoot.querySelector('#shadow-button')", host
)
button.click()
# 方式 B:先把 shadow root 取出来,再用 Selenium 的方法继续找
shadow_root = driver.execute_script("return arguments[0].shadowRoot", host)
button = shadow_root.find_element(By.CSS_SELECTOR, "#shadow-button")
button.click()两种方式都可行。方式 A 把查询交给浏览器执行,一步到位,不受 Selenium 版本差异影响,出问题时也最容易排查;方式 B 拿到 ShadowRoot 后可以用 Selenium 的等待、条件判断等方法,写起来更顺手。
注意传给 execute_script 的必须是通过 Selenium 定位到的 host 元素,不能在 JS 里用 document.querySelector(...).shadowRoot 这样链式去取——那样拿到的 host 是主文档里的元素没错,但一旦涉及后面要讲的 closed 模式或嵌套结构就会失效,直接用 arguments[0] 传入最稳妥。
四、嵌套 Shadow DOM:逐层进入
Web Components 互相嵌套时,shadow root 也会层层套下去。这时无法用一条选择器直达最内层,只能一层一层往下钻:
from selenium.webdriver.common.by import By
# 第一层
sr1 = driver.find_element(By.CSS_SELECTOR, "outer-widget").shadow_root
# 第二层:在外层 root 里找到内层 host,再取它的 shadow root
sr2 = sr1.find_element(By.CSS_SELECTOR, "inner-widget").shadow_root
# 第三层:目标元素
target = sr2.find_element(By.CSS_SELECTOR, "#shadow-button")
target.click()每一层都用上一层的 root 作为查找起点,这是处理嵌套结构的通用写法。
五、不确定元素在哪一层:遍历查找
如果页面结构复杂、手写层级容易出错,可以写一个帮助函数,逐层遍历页面上所有的 shadow root 去找目标元素。核心是:只在 shadow host 上递归,而不是遍历所有后代元素。
from selenium.webdriver.common.by import By
from selenium.common.exceptions import NoSuchShadowRoot
def find_in_shadow_dom(driver, css_selector):
"""广度优先遍历页面上所有 open shadow root,找到即返回,找不到返回 None。"""
roots = [driver] # 待搜索的根,可以是 driver,也可以是 ShadowRoot
while roots:
next_roots = []
for root in roots:
found = root.find_elements(By.CSS_SELECTOR, css_selector)
if found:
return found[0]
# 收集当前根下所有挂了 shadow root 的宿主元素
for el in root.find_elements(By.CSS_SELECTOR, "*"):
try:
next_roots.append(el.shadow_root)
except NoSuchShadowRoot:
pass # 该元素没有 shadow root,跳过
roots = next_roots
return None
button = find_in_shadow_dom(driver, "#shadow-button")
if button:
button.click()
else:
print("Element not found.")几个说明:
find_elements在找不到时返回空列表而不是抛异常,所以可以直接用if found判断。- 用
el.shadow_root配合捕获NoSuchShadowRoot来筛选宿主元素,比在 JS 里filter(e => e.shadowRoot)更贴近 Selenium 原生写法,也不需要额外传递对象进 JS 上下文。 - 这个函数每次都从最外层重新遍历,页面元素多时开销不小;如果同一个页面上要抓多个 shadow 元素,建议把中间结果(各层的 root)缓存下来复用。
- 只在「不知道元素在第几层」时才需要它。层级已知的情况下,第四节逐层进入的写法更快也更清晰。
六、几个容易踩的坑
1. find_element_by_* 系列方法已被移除
不少较早的教程和示例代码里还在用这种写法:
# Selenium 4.3.0 起已移除,会报 AttributeError
shadow_root.find_elements_by_css_selector(css_selector)
shadow_root.find_elements_by_tag_name("*")这类 find_element_by_* / find_elements_by_* 方法已在 Selenium 4.3.0 中正式移除(更早的 4.1 起就已废弃),继续使用会抛 AttributeError: 'WebDriver' object has no attribute 'find_elements_by_css_selector'。统一改成下面这种形式:
shadow_root.find_element(By.CSS_SELECTOR, css_selector)
shadow_root.find_elements(By.CSS_SELECTOR, "*")
shadow_root.find_element(By.TAG_NAME, "button")2. closed 模式的 shadow root 拿不到
attachShadow() 可以指定 mode: 'open' 或 mode: 'closed'。当组件使用 closed 模式时,它刻意把 element.shadowRoot 设为 null,外部脚本无法通过这个属性访问内部结构——上述所有方法都会失效。::shadow 这类选择器同样只对 open 模式生效。
开发者工具里 closed 的 shadow root 会显示成 #shadow-root (closed),可以用这一点快速判断。遇到 closed 模式的常规应对思路:
- 优先看页面是否提供了其他可达的入口(比如公开的 API、页面上的普通按钮);
- 用 CDP 在页面加载前重写
Element.prototype.attachShadow,把mode: "closed"强制改成"open",之后就能按正常流程访问了。
这类做法依赖浏览器实现细节,属于「能解决但不够稳定」的手段,用在爬虫场景没问题,但不要写进对稳定性要求高的自动化测试里。
3. ShadowRoot 不是 WebElement
这个区别很容易混:host.shadow_root 返回的是 ShadowRoot 对象,不能在它上面直接调 click() 或 send_keys()。必须先在它内部 find_element 定位到一个真正的 WebElement,再对那个元素做操作。
4. 拿到 root 不等于元素已就绪
Shadow DOM 常常是组件异步渲染出来的。host.shadow_root 拿到的可能是内容还没填充的、innerHTML 为空的根。这时可以配合显式等待:
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
wait = WebDriverWait(driver, 10)
shadow_root = wait.until(
lambda d: d.find_element(By.CSS_SELECTOR, "my-widget").shadow_root
)
button = wait.until(
lambda _: shadow_root.find_element(By.CSS_SELECTOR, "#shadow-button")
)expected_conditions 里的 presence_of_element_located 等方法接收的是 (By, locator) 形式,不能直接把 ShadowRoot 当成 driver 传进去,所以这里用 lambda 自己封装等待条件更灵活。
5. 元素找到了但点击失败
如果报 ElementClickInterceptedException,通常是元素还没滚动到可视区域,或者被浮层遮挡。可以先 driver.execute_script("arguments[0].scrollIntoView()", el),或者改用 ActionChains 派发点击、乃至直接用 JS 点击。这类问题的根源和 Shadow DOM 无关,属于常规的元素交互排查。
七、方法选择建议
| 场景 | 推荐做法 |
|---|---|
| Selenium 4 + 较新的浏览器 | host.shadow_root 属性,写法最简洁 |
| 浏览器版本较老,或需要最高兼容性 | execute_script 返回 arguments[0].shadowRoot.querySelector(...) |
| 结构固定的嵌套 shadow DOM | 逐层 .shadow_root 往下进入 |
| 不知道元素在第几层 | 遍历所有 shadow root 的帮助函数 |
| closed 模式 | 常规方法无效,需用 CDP 改写 attachShadow |
小结
核心只有一句话:常规选择器不穿透 shadow 边界,必须先定位 host、拿到 shadow root,再在 root 内部查找。Selenium 4 的 shadow_root 属性已经把这件事变成了两行代码,旧代码里那些 find_elements_by_* 的写法需要一并迁移到 find_element(By.XXX, ...)。真正无解的情况只有 closed 模式,那需要绕过浏览器限制,且稳定性要打折扣。
评论区