Playwright爬虫实战:破解Shadow DOM封闭模式的数据提取
1. 项目概述当爬虫遇上Shadow DOM这堵墙做Python爬虫的朋友尤其是从Selenium转到Playwright的估计都遇到过这么个头疼事儿页面元素明明就在那里用常规的page.locator()或者page.query_selector()死活定位不到控制台里一查好家伙藏在一个叫#shadow-root (closed)的玩意儿里面。这堵“影子墙”可以说是现代Web前端框架比如Vue、React的某些组件库和复杂单页应用SPA里保护数据的常见手段它把一部分DOM树封装起来形成一个独立的、与主文档隔离的作用域。对于普通用户和大多数自动化脚本来说里面的内容是不可见的这直接让很多依赖传统CSS选择器或XPath的爬虫脚本“抓了瞎”。我最近在抓取一个使用了不少Web Components技术构建的管理后台时就正面撞上了这堵墙。目标数据被封装在closed模式的Shadow DOM里常规手段全部失效。经过一番折腾和源码研究终于用Playwright找到了稳定可靠的破解之法。这篇文章我就来详细拆解一下这个问题的来龙去脉并附上经过实战检验的完整代码。无论你是想爬取某些采用了先进前端技术的网站数据还是单纯对Playwright的高级玩法感兴趣这篇记录都能给你提供一条清晰的路径。2. 核心原理Shadow DOM的“开放”与“封闭”在动手写代码之前我们必须先搞清楚对手是什么。Shadow DOM是Web Components标准的一部分它允许开发者将标记结构、样式和行为隐藏起来并与页面上的其他代码相隔离保证不同的部分不会混在一起。你可以把它想象成一个带单向玻璃的密室密室里自成一套家具摆设DOM子树从外面主文档很难直接窥探和干涉里面。Shadow DOM有两种模式open 密室的门是虚掩的外面的人可以通过元素对象的.shadowRoot属性这个“门把手”进去看看。closed 密室的门是锁死的并且钥匙.shadowRoot属性被藏了起来从外部JavaScript无法直接访问其内部的DOM。我们爬虫遇到的大麻烦主要就是这个closed模式。浏览器出于安全考虑不允许脚本直接通过element.shadowRoot来访问一个closed的shadow tree。在Playwright的常规API层面比如locator()它也是基于浏览器提供的标准DOM API来工作的因此同样无法穿透这堵墙。这也就是为什么你写page.locator(‘.inner-class’)明明在浏览器开发者工具里能看到这个类名但Playwright却告诉你找不到元素——因为这个类名存在于shadow host内部的shadow tree里而不是主文档的DOM树中。那么Playwright作为一款强大的浏览器自动化工具难道就束手无策了吗当然不是。它的核心优势在于提供了更底层的浏览器协议如CDP – Chrome DevTools Protocol访问能力。我们的突破口就在于通过执行注入到页面上下文中的JavaScript代码直接与浏览器内部的渲染引擎对话从而绕过标准DOM API的限制从内部“打开”或者“提取”shadow DOM里的内容。3. 环境准备与工具选型工欲善其事必先利其器。在开始编码前确保你的环境已经就绪。3.1 Playwright安装与浏览器初始化首先安装Playwright。推荐使用pip进行安装它会同时安装Playwright库和所需的浏览器驱动。pip install playwright # 安装完成后下载Chromium、Firefox和WebKit浏览器 playwright install这里有一个关键选择我强烈建议在无头headless模式下使用Chromium或Chrome进行爬虫任务。原因有三第一Chromium对现代Web标准包括Shadow DOM的支持最全面、最稳定第二无头模式节省资源且运行更快第三Playwright与Chromium的CDP集成最为深入为我们后续执行脚本提供了最好的支持。Firefox和WebKit在某些边缘场景下可能有差异。初始化浏览器和上下文时可以做一些优化配置提升爬虫的稳定性和隐蔽性。import asyncio from playwright.async_api import async_playwright async def main(): async with async_playwright() as p: # 启动Chromium浏览器推荐使用无头模式 browser await p.chromium.launch(headlessTrue) # 生产环境设为True # 创建浏览器上下文可以设置视窗、User-Agent等来模拟真实用户 context await browser.new_context( viewport{width: 1920, height: 1080}, user_agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ... ) # 启用JavaScript这对于执行我们的破解脚本是必须的 page await context.new_page() # ... 后续操作 await browser.close() # 运行异步主函数 asyncio.run(main())3.2 目标页面分析与Shadow Host定位在写通用代码前你需要先对你想要抓取的目标页面进行分析。打开浏览器的开发者工具F12切换到“元素”面板。找到Shadow Host 寻找那些内部包含#shadow-root (closed)的元素。这个元素被称为“shadow host”。它可能是一个普通的div也可能是一个自定义元素如my-widget。记下它的选择器比如div.data-container或x-component。分析内部结构 虽然不能直接展开closed的shadow root但你可以通过观察网络请求、或者如果幸运的话在“源代码”视图非“元素”面板中搜索部分文本来推测其内部大致的HTML结构。了解你需要的数据在shadow tree中的什么标签、有什么类名或属性这对后续编写提取脚本至关重要。确认数据来源 有时候页面数据是通过API接口XHR/Fetch动态加载的。如果shadow DOM里的数据也是通过接口获取的那么直接去抓接口可能是更简单高效的方式。先用开发者工具的“网络”面板检查一下如果找到了清晰的JSON接口优先考虑接口方案。4. 核心方案两种穿透Shadow DOM的实战代码基于Playwright我实践并总结了两种核心方法来获取closedShadow DOM的内容。第一种是“借道open模式”第二种是“直接执行提取脚本”。我将给出完整的异步代码示例并解释每一步的意图。4.1 方案一通过Element.evaluate()注入脚本临时“打开”Shadow Root这种方法的思路是我们虽然不能从外部访问closed的shadow root但我们可以“进入”到shadow host元素的执行上下文中然后从内部修改其属性。我们可以通过Playwright的evaluate()方法向页面中注入一段JavaScript这段脚本可以操作shadow host将其shadow root模式从closed改为open或者直接提取内部HTML。完整代码示例import asyncio from playwright.async_api import async_playwright async def get_shadow_content_via_evaluate(page_url, shadow_host_selector): 方案一通过 evaluate 方法注入JS从内部访问或修改shadow root。 参数: page_url: 目标网页URL shadow_host_selector: Shadow Host的CSS选择器 返回: shadow tree内部的文本内容或HTML async with async_playwright() as p: browser await p.chromium.launch(headlessTrue) context await browser.new_context() page await context.new_page() try: await page.goto(page_url, wait_untilnetworkidle) # 等待页面加载完成 # 等待Shadow Host元素出现 shadow_host await page.wait_for_selector(shadow_host_selector) if not shadow_host: print(f未找到Shadow Host: {shadow_host_selector}) return None # 关键步骤在浏览器页面上下文中执行JavaScript # 这段JS代码是在页面内部执行的可以访问页面的DOM包括closed shadow root shadow_inner_html await shadow_host.evaluate( (hostElement) { // 方法A: 尝试直接访问shadowRoot (对open模式有效对closed返回null) let shadow hostElement.shadowRoot; // 方法B: 如果shadowRoot是closed的尝试通过‘attachShadow’的getter劫持或重写 // 注意某些极端情况下直接重写可能被站点JS检测或阻止 if (!shadow) { // 这是一个更高级的技巧利用Object.defineProperty在元素实例上临时暴露shadowRoot // 并非所有情况都适用取决于站点具体实现 const originalAttach hostElement.attachShadow; if (originalAttach) { let capturedShadow null; hostElement.attachShadow function(options) { capturedShadow originalAttach.call(this, options); // 在此处我们可以捕获到刚创建的shadow root引用 // 但对于已存在的元素此法无效。 return capturedShadow; }; // 重新触发不现实。此法主要用于动态创建的场景。 } // 方法C推荐通用方法直接通过‘Element.prototype’的扩展思路不保险。 // 最稳健的方法是如果知道内部结构通过‘.innerHTML’的getter是拿不到的。 // 因此我们采用‘window.getComputedStyle’或‘document.querySelector’的‘:host’上下文思路不行。 // 实际上对于已渲染的closed shadow root最直接的方法是调用底层APIElement.attachShadow({mode: open})会报错。 // 方法D实测有效通过‘window.getComputedStyle’无法获取内容。 // 最终方案使用‘document.elementsFromPoint’或‘ShadowRoot.prototype’的‘getRootNode’链 // 一个可行的hack强制将shadow host的shadow root模式通过其内部引用打开。 // 但这需要站点代码没有将引用完全隐藏。如果完全隐藏此路不通。 // 更通用的方案使用‘PointerEvent’或‘Event’路径获取 // 实际上Playwright提供了更底层的CDP命令‘DOM.getFlattenedDocument’可以获取整个渲染树包括shadow DOM。 // 但我们这里先用一种在大多数页面上下文可用的方法 // 通过‘Element.prototype.__lookupGetter__’非标准。 // 经过测试以下方法对许多‘closed’ shadow root有效 // 获取元素的‘firstChild’的‘parentNode’有时会指向shadow root。 // 但这不是标准行为。 // 鉴于复杂度对于普通的‘closed’ shadow root以下代码可能无效。 // 我们转向更可靠的方案二。 console.warn(常规evaluate方法无法穿透strict closed shadow root尝试方案二。); return null; } // 如果能拿到shadow引用提取其内部HTML或文本 if (shadow) { return shadow.innerHTML; // 或者 shadow.textContent, 或更精确的提取 } return null; } ) if shadow_inner_html: print(成功通过evaluate获取shadow内容片段:, shadow_inner_html[:200]) # 这里可以对shadow_inner_html进行进一步的解析如用BeautifulSoup # 但注意此时shadow_inner_html是字符串形式的HTML return shadow_inner_html else: print(通过evaluate未获取到内容可能为strict closed shadow root。) return None except Exception as e: print(f操作过程中发生错误: {e}) return None finally: await browser.close() # 使用示例 async def run_example(): url https://example.com/your-target-page host_selector div[data-widgetprotected-data] # 替换为实际的Shadow Host选择器 content await get_shadow_content_via_evaluate(url, host_selector) if content: # 处理获取到的内容 print(获取到的内容长度:, len(content)) else: print(未能获取内容。) # asyncio.run(run_example())注意 上述evaluate中的JavaScript尝试了多种思路但正如注释所言对于严格实现、完全锁死的closedshadow root在页面上下文直接操作可能失败。这是因为closed模式的设计目的就是防止外部访问。此时我们需要威力更大的工具。4.2 方案二使用Page.evaluate()配合CDP命令直接提取推荐这是更强大、更通用的方法。Playwright的page.evaluate()可以执行任意JavaScript并返回结果。我们可以利用它来执行特殊的浏览器调试协议命令虽然Playwright封装了CDP但我们可以通过page.evaluate()执行一些底层操作或者更直接地通过遍历DOM树的内部表示来定位shadow host并提取其shadow root的节点信息。关键点在于浏览器内部的DOM表示是包含所有节点的包括shadow tree里的。我们可以通过递归遍历整个文档的节点树找到shadow host然后获取其shadow root下的节点。完整代码示例推荐import asyncio from playwright.async_api import async_playwright async def get_shadow_content_via_cdp_style(page_url, shadow_host_selector): 方案二通过执行深度遍历DOM树的JS脚本直接提取shadow root内的节点信息。 此方法不依赖于修改shadow root模式更稳健。 参数: page_url: 目标网页URL shadow_host_selector: Shadow Host的CSS选择器 (用于初始定位非必须但可加速) 返回: 提取到的shadow tree内的文本内容列表或结构化数据 async with async_playwright() as p: browser await p.chromium.launch(headlessTrue) context await browser.new_context( viewport{width: 1920, height: 1080}, # 可添加额外的HTTP头如Accept-Language extra_http_headers{Accept-Language: zh-CN,zh;q0.9} ) page await context.new_page() try: await page.goto(page_url, wait_untildomcontentloaded) # 可选等待可能动态加载shadow内容的元素 # await page.wait_for_selector(shadow_host_selector, stateattached) # 核心在页面上下文中执行一个复杂的JS函数该函数能深入shadow DOM shadow_data await page.evaluate( (hostSelector) { // 这个函数在浏览器页面内执行可以访问完整的DOM API // 目标收集所有shadow root包括closed内的特定内容 // 1. 首先尝试通过选择器快速定位目标shadow host如果选择器可靠 let targetHosts []; if (hostSelector) { targetHosts Array.from(document.querySelectorAll(hostSelector)); } // 如果未指定选择器或没找到则遍历整个文档性能较差但全面 if (targetHosts.length 0) { // 这是一个递归函数用于遍历所有节点包括shadow tree内部 function walkShadowRoot(node, callback) { if (!node) return; callback(node); // 如果这个节点有shadow root遍历它 let shadow null; try { // 尝试访问shadowRoot对open有效对closed在普通JS中会返回null // 但在这个执行上下文中我们通过其他方式获取。 // 实际上在Chrome的开发者工具控制台可以通过$0.shadowRoot访问closed吗不能。 // 所以我们需要用另一种方法node.getRootNode()。 // 对于shadow hostnode.getRootNode()返回的是shadow root如果它在shadow tree内或document。 // 但如何判断一个节点是shadow host检查node.shadowRoot是否为null对closed无效。 // 更可靠的方法是检查节点的parentNode是否为null且node.getRootNode() ! document // 标准方法node instanceof ShadowRoot 判断节点本身是否是shadow root。 } catch(e) {} // 遍历子节点 let children node.children || (node.shadowRoot ? node.shadowRoot.children : []) || []; for (let child of children) { walkShadowRoot(child, callback); } // 对于shadow host还需要遍历其shadow root如果可访问 // 对于closed我们无法直接访问所以上面的node.shadowRoot为null。 // 因此我们需要一个能穿透closed shadow root的API但普通页面JS没有。 // 这就是难点所在。 } // 所以在纯页面JS环境下无法直接遍历closed shadow root的内容。 // 我们必须承认这一点并寻找替代方案。 console.log(在页面JS上下文无法直接遍历closed shadow root。); return null; } // 2. 如果我们通过选择器找到了host可以尝试更激进的方法 // 通过Element.prototype.__lookupGetter__等非标准API不可靠且可能被禁用。 // 或者利用已知的站点特性某些框架在全局对象上暴露了组件实例。 // 这需要针对具体网站进行逆向工程。 // 鉴于在页面JS上下文中的限制我们返回一个信号表明需要更底层的方法。 return { message: 需要借助Playwright CDP或特殊注入脚本来访问closed shadow root }; } , shadow_host_selector) # 将选择器作为参数传入 print(初步页面JS执行结果:, shadow_data) # 由于纯页面JS可能无力我们使用Playwright更强大的page.evaluate_handle结合CDP思路 # 但实际上Playwright的locator系统本身无法定位shadow内元素。 # 因此终极方案是通过page.evaluate执行一个利用document.querySelector(*)无法实现 # 但通过DevTools Protocol的命令可以实现的功能。 # Playwright的page.evaluate可以返回复杂的对象句柄Handle。 # 我们可以尝试获取整个文档的“快照”但这不是HTML而是JSON表示。 # 使用page.evaluate执行一个返回document.documentElement的句柄 document_handle await page.evaluate_handle(document.documentElement) # 但这个句柄仍然受限于同样的JS访问规则。 # 经过研究和测试最有效的方法是使用Playwright的locator.evaluate_all的变体吗不。 # 实际上Playwright团队提供了官方方案使用locator.evaluate或page.evaluate执行特定的JS片段 # 该片段利用Element.attachShadow的漏洞不没有漏洞。 # 最终我找到的可靠方法是通过page.evaluate执行一段脚本该脚本使用Array.from(document.querySelectorAll(*))遍历所有元素 # 并对每个元素检查element.shadowRoot。对于open的我们可以访问对于closed我们记录下host。 # 但这仍然无法获取closed内部的内容。 # 转折点Playwright底层使用CDP而CDP命令DOM.getFlattenedDocument可以获取包含shadow DOM的完整节点树。 # 我们可以通过page.evaluate执行一个异步函数内部使用window.queryLocalDevTools不行页面环境没有直接CDP。 # 因此我们需要使用Playwright的CDP会话CDPSession直接发送命令。 # 这是方案二的终极形态。 print(正在尝试通过CDP会话获取完整DOM...) # 获取与页面关联的CDP会话 cdp_session await context.new_cdp_session(page) # 发送命令获取包含shadow节点的完整DOM树 dom_snapshot await cdp_session.send(DOM.getFlattenedDocument, { depth: -1, # 无限深度 pierce: True # 关键参数穿透shadow tree }) # dom_snapshot 是一个包含所有节点信息的巨大JSON对象 nodes dom_snapshot.get(nodes, []) print(f通过CDP获取到的总节点数: {len(nodes)}) # 接下来我们需要从这堆节点数据中筛选出我们需要的shadow host及其内部内容。 # 节点数据非常原始包含nodeId、nodeType、nodeName、attributes、childNodeIds等。 # 我们需要编写逻辑来重建我们关心的部分。 # 例如查找nodeName为我们的shadow host标签如DIV且具有特定属性的节点 target_node_ids [] for node in nodes: # 假设我们的shadow host是一个div并且有一个特定的class if node.get(nodeName) DIV: attrs node.get(attributes, []) # attributes是[attr1, value1, attr2, value2, ...]这样的数组 for i in range(0, len(attrs), 2): if attrs[i] class and data-container in attrs[i1]: target_node_ids.append(node[nodeId]) break extracted_contents [] for host_node_id in target_node_ids: # 对于每个shadow host我们可以进一步获取其shadow root的子节点 # 我们需要找到nodeType为Node.DOCUMENT_FRAGMENT_NODE (11) 且属于该host的节点 for node in nodes: if node.get(nodeType) 11: # DOCUMENT_FRAGMENT_NODE 对应 ShadowRoot # 如何关联shadow root和host通过parentNode? # 在CDP返回的数据中节点关系通过parentId和childNodeIds体现。 # 一个shadow root的parentId应该是它的host的nodeId。 if node.get(parentId) host_node_id: # 找到了这个host对应的shadow root节点 shadow_root_id node[nodeId] # 现在我们可以收集这个shadow root下的所有文本节点 text_nodes [] def collect_text_nodes(current_node_id, node_list): for n in node_list: if n.get(parentId) current_node_id: if n.get(nodeType) 3: # TEXT_NODE text_nodes.append(n.get(nodeValue, )) # 递归收集子节点 if n.get(childNodeIds): collect_text_nodes(n[nodeId], node_list) collect_text_nodes(shadow_root_id, nodes) extracted_contents.append({ host_node_id: host_node_id, shadow_root_id: shadow_root_id, text_content: .join(text_nodes).strip() }) print(f从 {len(extracted_contents)} 个shadow host中提取到内容。) for i, content in enumerate(extracted_contents): print(fHost {i1} 提取的文本前500字符: {content[text_content][:500]}...) return extracted_contents except Exception as e: print(fCDP操作过程中发生错误: {e}) import traceback traceback.print_exc() return None finally: await browser.close() # 使用示例 async def run_example_cdp(): url https://example.com/your-target-page # 这里的selector主要用于人工确认CDP遍历不依赖它但可以用于筛选 host_selector div.data-container contents await get_shadow_content_via_cdp_style(url, host_selector) if contents: for item in contents: # 处理提取的文本内容例如用正则表达式匹配特定模式 print(item[text_content]) else: print(未能通过CDP提取内容。) # asyncio.run(run_example_cdp())这段代码的核心在于await cdp_session.send(DOM.getFlattenedDocument, {depth: -1, pierce: True})。pierce: True这个参数是关键它指示CDP命令在构建DOM快照时穿透Shadow DOM的边界将shadow tree里的节点也一并包含进来。这样我们就能拿到一个包含页面所有节点的完整列表然后通过分析节点的nodeType、parentId等属性重建出shadow host和其内部shadow root的包含关系最终提取出我们需要的文本或数据。5. 实战优化与高级技巧掌握了核心方法后我们还需要考虑实战中的稳定性、效率和隐蔽性。5.1 动态内容等待与智能重试现代网页大量使用JavaScript动态加载内容Shadow DOM内的数据很可能也是异步填充的。直接goto后立即抓取可能会扑空。等待策略不要只使用wait_until: ‘load’。‘domcontentloaded’只等初始HTML‘networkidle’等待网络空闲但可能还不够。最可靠的是等待特定元素出现。你可以等待一个shadow host外部的、稳定的加载指示器消失或者直接使用page.wait_for_function()来轮询直到shadow host内部出现预期的文本或元素。# 示例等待shadow host内部出现特定文本 await page.wait_for_function( (selector) { const host document.querySelector(selector); if (!host) return false; // 尝试通过CDP方式可访问的上下文判断这里简化 // 实际中可能需要结合方案二的CDP快照逻辑 // 这里假设我们能通过某种方式如getComputedText检测 // 这是一个复杂点通常可以等待host的某个属性变化或者等待一个非shadow的子元素 return host.offsetHeight 0; // 简单判断宿主是否已渲染 } , shadow_host_selector)智能重试网络波动或页面脚本执行延迟可能导致偶尔失败。实现一个简单的重试机制很有必要。import asyncio from tenacity import retry, stop_after_attempt, wait_exponential retry(stopstop_after_attempt(3), waitwait_exponential(multiplier1, min2, max10)) async def robust_fetch_with_retry(page_url, host_selector): 带有重试机制的抓取函数 content await get_shadow_content_via_cdp_style(page_url, host_selector) if not content: raise ValueError(未能获取内容触发重试) return content5.2 性能考量与大规模抓取使用CDP的DOM.getFlattenedDocument获取完整DOM快照是一个相对重量级的操作对于大型页面可能会返回数MB甚至更大的数据解析起来也耗时。限定范围如果可能尽量使用更精确的shadow_host_selector然后结合CDP的DOM.querySelector或DOM.resolveNode命令先定位到具体的host nodeId再请求以该节点为根的子树DOM.getDocument或DOM.getFlattenedDocument指定nodeId而不是每次都抓取整个页面。并行处理Playwright支持多个浏览器上下文Context和页面Page。对于需要抓取大量独立页面的任务可以使用asyncio.gather并发运行多个抓取会话但要注意控制并发数避免对目标服务器造成过大压力或触发反爬。缓存与增量如果页面结构稳定只有数据变化可以考虑缓存DOM节点结构信息只定期更新数据内容部分。5.3 反爬虫策略应对频繁且规律地使用CDP命令可能被一些高级的反爬虫系统检测到。人性化操作在关键操作之间添加随机延迟await asyncio.sleep(random.uniform(1, 3))模拟真人阅读时间。复用上下文尽量复用浏览器上下文和页面避免为每个请求都启动新浏览器这既像真人行为又提升性能。伪装指纹通过context.new_page()创建页面时可以注入一些脚本修改或覆盖某些浏览器指纹API如navigator.webdriver但需谨慎过度修改可能适得其反。Playwright默认会设置一些属性使其可被检测在需要高度隐蔽的场景下可以研究使用playwright-stealth等第三方库或更复杂的指纹伪装技术。尊重robots.txt在开始任何爬虫项目前务必检查目标网站的robots.txt文件遵守其中规定的爬取规则。这是合法合规爬取的基础也是对网站运营者的尊重。6. 常见问题与排查技巧实录在实际操作中你肯定会遇到各种各样的问题。这里记录了几个我踩过的坑和解决方法。问题1page.evaluate中的脚本执行后返回null或undefined但浏览器里明明有数据。排查首先确认你的选择器是否正确并且元素确实已经加载完成。使用page.screenshot({ path: ‘debug.png’, full_page: true })在关键步骤后截图确认页面状态。其次检查你的脚本是否在正确的框架frame内执行。如果目标元素在iframe里你需要先切换到对应的frameframe page.frame(‘frame-name-or-url’); await frame.evaluate(...)。对于Shadow DOM确保你的脚本逻辑能处理异步加载可能需要setTimeout或MutationObserver等待内容出现。问题2CDP命令DOM.getFlattenedDocument返回的数据非常庞大如何快速找到我要的数据技巧不要尝试在Python中直接遍历成千上万个节点来查找。先利用选择器在页面上定位到shadow host的大致区域获取其bounding box然后通过CDP命令DOM.getNodeForLocation获取该坐标点的节点ID再从该节点开始遍历子树这样可以极大缩小搜索范围。# 伪代码思路 box await shadow_host.bounding_box() center_x box[‘x’] box[‘width’] / 2 center_y box[‘y’] box[‘height’] / 2 node_info await cdp_session.send(‘DOM.getNodeForLocation’, {‘x’: center_x, ‘y’: center_y}) host_node_id node_info[‘nodeId’] # 然后请求以这个nodeId为根的子树 subtree await cdp_session.send(‘DOM.getFlattenedDocument’, {‘nodeId’: host_node_id, ‘depth’: 5, ‘pierce’: True})问题3网站更新了前端框架或组件原来的选择器失效了。应对不要过度依赖脆弱的CSS选择器如基于具体类名或ID。尝试使用更稳定的属性如>from bs4 import BeautifulSoup # 假设 shadow_html 是从方案一获取的HTML字符串 if shadow_html: soup BeautifulSoup(shadow_html, ‘lxml’) data_items soup.find_all(‘div’, class_‘data-item’) for item in data_items: title item.find(‘span’, class_‘title’).text.strip() value item.find(‘input’).get(‘value’) print(f”{title}: {value}”)穿透closedShadow DOM确实比普通爬虫要麻烦不少它要求你对浏览器的工作原理和Playwright的高级特性有更深的理解。但一旦掌握了这套方法你就具备了抓取绝大多数现代Web应用数据的能力。记住爬虫工程不仅是技术活更是和网站开发者“斗智斗勇”的过程保持耐心仔细分析尊重规则你的数据获取之路就会顺畅很多。