1. 为什么非得用 CDP 模式连接本地 Chrome——不是所有“自动化”都叫 Playwright你可能已经用过playwright install chromium跑起来一个干净、隔离、版本可控的 Chromium 实例写测试、抓数据、做截图一切顺滑。但某天你突然需要复用自己日常使用的 Chrome 浏览器里已登录的账号、已安装的插件、已配置的代理、甚至已缓存的 Cookie 和 localStorage——这时候playwright.launch()默认启动的“纯净版 Chromium”就彻底失效了。这就是 CDPChrome DevTools Protocol模式存在的根本理由它不启动新浏览器而是像调试器一样反向接入你本机正在运行的 Chrome 进程。它不是“控制浏览器”而是“附身于浏览器”。你看到的页面、你点击的按钮、你输入的密码全是你真实操作过的那个 ChromePlaywright 只是站在背后读取 DOM、监听网络请求、注入脚本、截取帧——就像给你的 Chrome 装上了一副可编程的“显微镜手术刀”。这解释了为什么热搜词里反复出现playwright过瑞数、scrapy playwright 动态 iframe、playwright 监听——这些场景的核心痛点从来不是“能不能打开网页”而是“能不能拿到真实用户环境下的上下文”。比如瑞数RuiShu等反爬中间件会检测navigator.webdriver、window.chrome、plugins.length等指纹特征而 Playwright 启动的 Chromium 默认暴露明显自动化痕迹但本地 Chrome尤其是关闭了--remote-debugging-port安全限制后天然具备完整浏览器指纹几乎零修改即可绕过基础检测动态 iframe 常由前端 JS 根据登录态或设备信息动态加载若用纯净 Chromium可能因缺失 UA、时区、语言、字体列表等环境变量导致 iframe 根本不渲染而本地 Chrome 已经完成了全部环境初始化page.on(response)或page.on(request)监听网络请求时CDP 模式能捕获到所有 DevTools Network 面板能看到的请求包括 Service Worker 发起的、fetch API 的、甚至 WebSocket 握手包——而普通 launch 模式下部分底层请求会被 Playwright 自身的网络栈拦截或过滤。提示CDP 模式 ≠ “更高级的自动化”它是一种权衡——你放弃的是环境一致性与隔离性换来的是环境真实性与上下文完整性。它适合“模拟真人行为”的场景而非“构建可重复测试环境”的场景。混淆这两者是绝大多数人踩坑的第一步。我第一次在金融客户项目中用 CDP 模式登录网银时就栽在这点上以为只要连上本地 Chrome 就万事大吉结果发现每次运行脚本前必须手动关闭 Chrome 的自动更新弹窗、禁用某个冲突的广告拦截插件、甚至临时清空chrome://settings/content/cookies里的第三方 Cookie 限制——因为 Playwright 并不能接管这些 UI 层面的交互。它只管“协议层”不管“人机界面层”。这个认知差直接决定了你是把 CDP 当成万能钥匙还是当成一把需要精准对准锁芯的专用工具。2. 本地 Chrome 的启动姿势不是双击图标那么简单要让 Playwright 连上本地 Chrome第一步不是写代码而是让 Chrome 主动“暴露自己”。这和你平时双击 Chrome 图标启动它有本质区别。默认情况下Chrome 启动后是“隐身”的——它不监听任何外部连接不开放调试端口不接受远程指令。Playwright 要连接它就必须让它以一种“调试友好”的方式启动。核心手段只有一个通过命令行参数强制开启远程调试端口--remote-debugging-port并指定用户数据目录--user-data-dir。2.1 为什么必须指定--user-data-dir这是最容易被忽略、却最致命的一环。Chrome 的用户数据登录态、扩展、书签、历史记录、Cookie全部存储在--user-data-dir指向的目录里。如果你不指定Chrome 会使用默认路径如 Windows 下是%LOCALAPPDATA%\Google\Chrome\User Data而 Playwright 连接时若未明确指向同一目录就会启动一个全新的、空白的用户配置——你看到的页面是登录页而不是你昨天还在操作的后台系统。更隐蔽的问题是多个 Chrome 实例不能共享同一个--user-data-dir。如果你已经有一个 Chrome 正常运行着比如你正在用它查资料此时再用相同路径启动第二个实例Chrome 会直接报错“另一个程序正在使用此用户数据目录”。所以正确做法是为自动化任务单独准备一个用户数据目录例如C:\playwright-chrome-profile每次启动 Chrome 时确保该目录下没有其他 Chrome 进程在占用它如果你需要复用日常 Chrome 的登录态那就必须先完全退出所有 Chrome 进程任务管理器里杀掉所有chrome.exe再用指定目录启动。我实测过在 Win10 上即使你只关掉了所有 Chrome 窗口后台仍可能残留chrome.exe进程负责更新、同步、GPU 渲染等。不彻底清理--user-data-dir就会锁死Playwright 连接失败报错Failed to connect to browser或Connection refused。2.2--remote-debugging-port的安全边界在哪里官方文档说“设置端口即可”但实际部署中这个端口暴露意味着什么它本质上是一个 HTTP 服务提供完整的 DevTools 协议接口。任何能访问该端口的程序包括恶意脚本理论上都能执行Page.navigate、Runtime.evaluate、Network.setCookies等高危操作。因此Playwright 官方强烈建议仅在本地开发环境使用--remote-debugging-port9222生产环境绝对禁止。如果你真要在服务器上跑必须配合--remote-debugging-address127.0.0.1只允许本机访问并确保防火墙阻断外部对该端口的访问。有趣的是chrome 109 win7这个热词背后藏着一个兼容性陷阱Windows 7 默认不支持 Chrome 109 的某些 TLS 1.3 特性而 CDP 协议依赖 HTTPS 通信。如果你在 Win7 上强行用新版 Chrome 开启调试端口Playwright 连接时可能卡在 TLS 握手阶段日志里只显示TimeoutError: Waiting for browser to be ready。解决方案不是降级 Chrome而是改用--unsafely-treat-insecure-origin-as-securehttp://localhost:9222 --user-data-dir...等组合参数绕过安全检查——但这仅限离线环境切勿用于联网场景。2.3 启动命令的完整形态与验证方法最终一个可用于自动化脚本的 Chrome 启动命令应该长这样以 Windows 为例start C:\Program Files\Google\Chrome\Application\chrome.exe ^ --remote-debugging-port9222 ^ --user-data-dirC:\playwright-chrome-profile ^ --disable-gpu ^ --no-first-run ^ --no-default-browser-check ^ --disable-extensions ^ --disable-plugins ^ --disable-logging ^ --disable-dev-shm-usage ^ --disable-ipc-flooding-protection ^ --disable-background-networking ^ --disable-background-timer-throttling ^ --disable-backgrounding-occluded-windows ^ --disable-renderer-backgrounding ^ --disable-featuresIsolateOrigins,site-per-process,TranslateUI,BlinkGenPropertyTrees,CalculateNativeWinOcclusion,WinRetrieveSuggestionsOnlyOnDemand,WebContentsForceDark,WebContentsForceDarkOnWebview,WebContentsForceDarkOnWebView,WebContentsForceDarkOnWebview,WebContentsForceDarkOnWebview ^ --disable-blink-featuresAutomationControlled ^ --disable-automation ^ --disable-web-security ^ --disable-site-isolation-trials ^ --disable-featuresIsolateOrigins,site-per-process,TranslateUI,BlinkGenPropertyTrees,CalculateNativeWinOcclusion,WinRetrieveSuggestionsOnlyOnDemand,WebContentsForceDark,WebContentsForceDarkOnWebview ^ --disable-logging ^ --log-level3 ^ --enable-loggingstderr ^ --v1 ^ about:blank注意^是 Windows CMD 的续行符实际使用时请合并为一行about:blank是为了快速打开一个空白页避免加载首页耗时。验证是否成功打开浏览器访问http://localhost:9222/json。如果返回一个 JSON 数组每个对象包含description、devtoolsFrontendUrl、faviconUrl、id、title、type、url、webSocketDebuggerUrl字段说明调试服务已就绪。其中webSocketDebuggerUrl就是 Playwright 连接时要用的 WebSocket 地址形如ws://127.0.0.1:9222/devtools/page/xxxx-xxxx-xxxx-xxxx-xxxx。3. Playwright 的连接代码从launch()到connectOverCDP()的范式切换当你习惯了playwright.chromium.launch()再转向 CDP 模式会发现整个 API 调用链发生了根本性变化。这不是“换一个参数”而是从“创建浏览器”切换到“发现并接管浏览器”。3.1connectOverCDP()的底层逻辑是什么playwright.chromium.connectOverCDP()接收的不是一个可执行文件路径而是一个WebSocket URL即上一节中http://localhost:9222/json返回的webSocketDebuggerUrl。它不启动进程只建立 WebSocket 连接然后向 Chrome 发送一系列 CDP 协议命令获取当前打开的页面列表、创建新页面、监听事件。关键点在于它连接的是整个 Chrome 实例而不是单个页面。这意味着你无法像launch()那样通过headlessTrue控制是否显示窗口——CDP 模式下Chrome 必须是可见的除非你用--headlessnew启动但此时大部分 UI 相关 API 不可用browser.contexts()返回的不是独立的上下文而是 Chrome 中所有打开的Target标签页、扩展页、DevTools 页browser.new_context()在 CDP 模式下无效因为上下文由 Chrome 自身管理Playwright 无权创建所有页面操作都必须基于browser.contexts()[0].pages()[0]或browser.new_page()后者实际是向 Chrome 发送Target.createTarget命令。3.2 完整可运行的 Python 示例含错误处理以下代码不是“Hello World”而是我在电商比价项目中实际使用的最小可靠模板包含了超时控制、重试机制、页面等待逻辑from playwright.sync_api import sync_playwright import time import requests import json def get_chrome_debug_url(debug_port9222): 从 localhost:9222/json 获取第一个可用的 page WebSocket URL try: response requests.get(fhttp://127.0.0.1:{debug_port}/json, timeout5) response.raise_for_status() pages response.json() # 过滤出 type page 的 tab优先选 url 不是 about:blank 的 target_pages [p for p in pages if p.get(type) page and not p.get(url, ).startswith(about:blank)] if target_pages: return target_pages[0][webSocketDebuggerUrl] # 如果没有现成 page就用第一个可能是 about:blank if pages: return pages[0][webSocketDebuggerUrl] raise Exception(No page targets found) except Exception as e: raise Exception(fFailed to fetch debug info: {e}) def main(): with sync_playwright() as p: # Step 1: 获取 WebSocket URL try: ws_url get_chrome_debug_url() print(fConnecting to Chrome via CDP: {ws_url}) except Exception as e: print(f❌ Cannot get debug URL: {e}) print( Please ensure Chrome is running with --remote-debugging-port9222) return # Step 2: 连接浏览器注意这里不传 executable_path try: browser p.chromium.connect_over_cdp(ws_url, timeout30000) print(✅ Connected to Chrome successfully) except Exception as e: print(f❌ Failed to connect: {e}) print( Check if Chrome is running and port 9222 is accessible) return # Step 3: 获取或创建页面 context browser.contexts[0] # CDP 模式下只有一个 context if len(context.pages) 0: page context.pages[0] print(f Reusing existing page: {page.url}) else: # 创建新 tab page context.new_page() print( Created new page) # Step 4: 关键等待——确保页面加载完成且可交互 try: # 等待 network idle所有请求完成 page.wait_for_load_state(networkidle, timeout30000) # 等待 document.readyState complete page.wait_for_function(document.readyState complete, timeout30000) # 等待 body 元素存在防白屏 page.wait_for_selector(body, timeout30000) print(✅ Page loaded and ready) except Exception as e: print(f⚠️ Page load timeout or error: {e}) # 即使加载失败也继续执行后续操作如截图诊断 pass # Step 5: 执行业务逻辑示例获取标题并截图 try: title page.title() print(f Page title: {title}) page.screenshot(pathcdp_connected_page.png, full_pageTrue) print( Screenshot saved) except Exception as e: print(f❌ Failed to interact with page: {e}) # Step 6: 清理CDP 模式下不调用 browser.close()否则会 kill Chrome 进程 # Playwright 不会关闭 Chrome由用户自行管理生命周期 print( Connection closed. Chrome remains running.) if __name__ __main__: main()这段代码的关键设计点get_chrome_debug_url()封装了对/json接口的健壮调用带超时和异常处理避免因 Chrome 未启动或端口被占导致脚本直接崩溃connect_over_cdp()明确传递ws_url不传executable_path这是范式切换的标志context.pages[0]vscontext.new_page()体现 CDP 模式下“复用”与“新建”的两种策略前者适合已有页面的自动化如监控已打开的后台后者适合全新任务三重等待逻辑networkidledocument.readyStatebody selector覆盖了现代 SPA 应用常见的加载状态比单一wait_for_load_state(load)更可靠不调用browser.close()这是最重要的安全红线。connect_over_cdp()建立的是“连接”不是“拥有”。调用close()会向 Chrome 发送Browser.close命令直接杀死整个 Chrome 进程——你辛苦打开的几十个标签页瞬间消失。正确的做法是让 Chrome 自行管理生命周期Playwright 只负责断开连接。3.3 Node.js 版本的差异点与陷阱如果你用 TypeScript/JavaScript语法类似但有一个隐藏巨坑connectOverCDP()返回的browser对象在 Node.js 环境下其context.pages()方法可能返回空数组即使 Chrome 里明明开着页面。原因在于Node.js 的 Playwright 默认使用playwright-core而 CDP 连接需要playwright包的完整实现。解决方案是确保package.json中安装的是playwright不是playwright-core启动 Chrome 时必须加上--remote-allow-origins*参数Chrome 111 强制要求否则 WebSocket 连接会被 CORS 拦截在connectOverCDP()后主动调用browser.contexts()[0].pages()前先await browser.contexts()[0].waitForEvent(page, { timeout: 5000 })等待页面事件触发。// Node.js 示例片段 const browser await chromium.connectOverCDP(ws://127.0.0.1:9222/devtools/browser/...); const context browser.contexts()[0]; // ⚠️ 必须等待 page 事件否则 pages() 可能为空 await context.waitForEvent(page, { timeout: 5000 }); const pages context.pages(); if (pages.length 0) { const page pages[0]; // ... }4. CDP 模式下的真实战场绕过瑞数、监听 iframe、滚动与定位的实战解法理论讲完现在进入硬核环节。热搜词playwright过瑞数、scrapy playwright 动态 iframe、playwright滚动页面、playwright定位元素每一个背后都是一个具体、棘手、必须现场解决的工程问题。CDP 模式不是银弹但它提供了直达底层的武器库。4.1 绕过瑞数RuiShu的三板斧指纹伪造、行为模拟、流量劫持瑞数的核心防御逻辑是识别非人类浏览器环境。它检查navigator.webdriver永远为 true、window.chrome缺失或不完整、plugins.length为 0、mimeTypes.length为 0、permissions.query拒绝访问等数十个维度。CDP 模式的优势在于它启动的是真实 Chrome这些属性天然存在。但还不够——瑞数会进一步检测“自动化行为模式”比如鼠标移动轨迹过于直线、点击间隔过于均匀、页面加载速度过快。第一板斧启动参数级指纹补全在 Chrome 启动命令中加入这些关键参数--disable-blink-featuresAutomationControlled \ --disable-automation \ --disable-featuresIsolateOrigins,site-per-process,TranslateUI \ --disable-logging \ --log-level3 \ --enable-loggingstderr \ --v1 \ --disable-gpu \ --no-sandbox \ --disable-dev-shm-usage \ --disable-ipc-flooding-protection \ --disable-background-networking \ --disable-background-timer-throttling \ --disable-backgrounding-occluded-windows \ --disable-renderer-backgrounding \ --disable-featuresIsolateOrigins,site-per-process,TranslateUI,BlinkGenPropertyTrees,CalculateNativeWinOcclusion,WinRetrieveSuggestionsOnlyOnDemand,WebContentsForceDark,WebContentsForceDarkOnWebview \ --disable-logging \ --log-level3 \ --enable-loggingstderr \ --v1 \其中--disable-blink-featuresAutomationControlrolled是关键它会让navigator.webdriver返回undefined而非true这是绕过瑞数第一道门的基石。--disable-automation则禁用 Chrome 内置的自动化标记。第二板斧JS 注入级行为模拟仅仅参数不够瑞数会监听mousemove、keydown、scroll等事件的触发频率和模式。Playwright 提供了page.add_init_script()可以在页面加载前注入 JS覆盖全局对象page.add_init_script( // 覆盖 webdriver 属性 Object.defineProperty(navigator, webdriver, { get: () undefined, }); // 伪造 plugins 和 mimeTypes Object.defineProperty(navigator, plugins, { get: () [1, 2, 3, 4, 5], }); Object.defineProperty(navigator, mimeTypes, { get: () [1, 2, 3, 4, 5], }); // 伪造 permissions const originalQuery navigator.permissions.query; navigator.permissions.query (descriptor) { return Promise.resolve({ state: granted }); }; )这段脚本在页面 JS 执行前就生效瑞数的检测代码拿到的就是伪造后的值。第三板斧CDP 协议级流量劫持对于瑞数的动态 JS 加载如https://rs.ruisu.com/xxx.js你可以用 CDP 的Network.setRequestInterception功能直接拦截并返回空响应或伪造内容# 启用网络请求拦截 page.route(**/rs.ruisu.com/**, lambda route: route.fulfill(status200, body)) # 或者更精细地只拦截特定 JS page.route(**/rs.ruisu.com/*.js, lambda route: route.fulfill(status200, body))这需要在page创建后、goto()之前调用。它比page.route()更底层能捕获到所有网络请求包括那些由 Service Worker 或 iframe 内部发起的。4.2 动态 iframe 的监听与穿透scrapy playwright 动态 iframe的真相scrapy playwright 动态 iframe这个热词暴露了一个普遍误解Scrapy 是静态 HTML 解析器它拿不到 JS 渲染后的内容而 Playwright 是浏览器自动化工具它能拿到。但“能拿到”不等于“能稳定拿到”——动态 iframe 常由postMessage、MutationObserver或定时轮询触发加载时机不可预测。CDP 模式下最佳实践是监听FrameAttached事件而不是轮询page.frames()# 监听新 iframe 的创建 page.on(frameattached, lambda frame: print(f New frame attached: {frame.url})) # 或者等待特定 iframe 出现更可靠 def wait_for_iframe(page, url_pattern): for _ in range(60): # 最多等 60 秒 frames page.frames() for frame in frames: if url_pattern in frame.url: return frame time.sleep(1) raise TimeoutError(fiframe matching {url_pattern} not found) # 使用 iframe wait_for_iframe(page, payment-iframe) iframe_content iframe.inner_html(body)但更强大的是 CDP 原生命令Page.getResourceTree。它能获取当前页面完整的 Frame 树结构包括尚未加载完成的 iframe# 通过 CDP 协议直接获取 Frame 树 client page.context.browser._channel._connection._connection._transport._client result client.send(Page.getResourceTree, {}) # result[root][children] 包含所有 iframe 的 frameId 和 url一旦拿到frameId就可以用Page.getFrameTree或Runtime.evaluate在指定 iframe 内执行 JS完全绕过 Playwright 的frame对象封装。4.3 滚动与定位为什么page.scroll_into_view_if_needed()有时失效playwright滚动页面这个热词背后是无数人遇到的“元素在视口外scroll_into_view_if_needed()却没滚动”的困惑。原因在于CDP 模式下Chrome 的滚动引擎和 Playwright 的布局计算可能存在微小偏差尤其当页面使用transform: translateY()、position: sticky或overflow: hidden时。终极解法是绕过 Playwright 的封装直接调用 CDP 的DOM.scrollIntoViewIfNeeded# 获取元素的 backendNodeId element_handle page.query_selector(#target-element) backend_node_id element_handle._channel._connection._connection._transport._client.send( DOM.querySelector, {nodeId: 1, selector: #target-element} )[nodeId] # 直接调用 CDP 滚动 element_handle._channel._connection._connection._transport._client.send( DOM.scrollIntoViewIfNeeded, {nodeId: backend_node_id} )或者更简单通用的方式用page.evaluate()执行原生 JS 滚动page.evaluate( (selector) { const el document.querySelector(selector); if (el) { el.scrollIntoView({ behavior: smooth, block: center }); // 等待滚动动画结束 return new Promise(resolve setTimeout(resolve, 500)); } } , #target-element)4.4 定位元素的可靠性playwright定位元素的底层原理playwright定位元素的核心是page.query_selector()和page.wait_for_selector()。但在 CDP 模式下由于页面可能包含 Shadow DOM、动态渲染组件、或被 CSSdisplay: none隐藏单纯靠 CSS 选择器会失败。Playwright 提供了page.locator()它是更高级的定位器支持locator.first/locator.last/locator.nth(2)—— 处理多个匹配项locator.or(another_locator)—— 多重备选方案locator.filter(has_textSubmit)—— 文本内容过滤locator.scroll_into_view_if_needed()—— 自动滚动locator.hover()/locator.click()—— 操作链式调用。但最可靠的依然是 CDP 的DOM.querySelectorDOM.describeNode组合# 获取元素的详细 DOM 信息 client page.context.browser._channel._connection._connection._transport._client result client.send(DOM.querySelector, { nodeId: 1, selector: #login-button }) node_id result[nodeId] node_info client.send(DOM.describeNode, {nodeId: node_id}) print(node_info) # 包含 visibility、computed style、box model 等这能告诉你元素是否真的“可见”visibility: visible且display ! none且opacity 0而不仅仅是“存在于 DOM 树中”。5. 那些没人告诉你的 CDP 黑盒内存泄漏、进程僵死、跨平台陷阱CDP 模式强大但它的“黑盒”属性也带来了独特的运维难题。这些不是文档里的 warning而是我在三个不同客户现场亲手填过的坑。5.1 内存泄漏为什么 Chrome 越跑越慢最后 OOM 崩溃Playwright 通过 WebSocket 连接 Chrome但每次page.goto()、page.reload()、甚至page.evaluate()都会在 Chrome 内部创建新的Page对象和ExecutionContext。CDP 协议本身不提供自动垃圾回收机制。如果你的脚本循环执行数百次Chrome 的内存占用会线性增长最终触发 Windows 的内存保护机制进程被强制终止。解决方案不是“重启 Chrome”而是“主动释放”# 每次操作后显式关闭不再需要的页面 for page in context.pages()[1:]: # 保留第一个 page关闭其余 page.close() # 或者定期清理整个 context context.close() # 然后重新获取 contextCDP 模式下context.close() 不会 kill Chrome browser p.chromium.connect_over_cdp(ws_url) context browser.contexts[0]更激进的做法是在脚本末尾调用 CDP 的Browser.crash()仅用于调试或Browser.close()会 kill Chrome慎用。5.2 进程僵死The process started from chrome location报错的根因这个错误信息非常误导人。它字面意思是“Chrome 进程启动自某个路径”但实际含义是Playwright 尝试连接的 WebSocket 地址已失效而它误以为是 Chrome 进程启动失败。常见原因Chrome 进程被用户手动关闭但 Playwright 还在尝试发送命令--remote-debugging-port被其他程序占用如另一个自动化脚本Chrome 更新后旧的--user-data-dir路径被废弃新版本 Chrome 拒绝读取防火墙或杀毒软件拦截了 localhost 的 loopback 连接。诊断流程打开任务管理器确认chrome.exe进程是否存在访问http://localhost:9222/json看是否返回 JSON如果返回ERR_CONNECTION_REFUSED说明端口未监听需重启 Chrome如果返回ERR_EMPTY_RESPONSE说明 Chrome 进程存在但调试服务未启动需检查启动参数如果返回 JSON 但webSocketDebuggerUrl为空说明 Chrome 启动时未正确加载页面需加about:blank。5.3 跨平台陷阱chrome win7、chrome mac 强制刷新的兼容性清单Windows 7 Chrome 109如前所述TLS 1.3 不兼容。解决方案是降级到 Chrome 108最后一个官方支持 Win7 的版本或在启动参数中加入--unsafely-treat-insecure-origin-as-securehttp://localhost:9222macOS M1/M2 芯片Chrome 默认安装为 Rosetta 2 模式但 Playwright 的connectOverCDP()在 ARM64 环境下对 WebSocket 的处理有细微差异。建议安装原生 Apple Silicon 版 Chrome并确保--remote-debugging-port参数生效Linux 服务器无 GUICDP 模式必须有 X11 或 Wayland 显示环境。若在 Docker 中运行需挂载/tmp/.X11-unix并设置DISPLAY:0或改用--headlessnew启动 Chrome但此时大部分 UI API 不可用chrome mac 强制刷新macOS 的 CmdR 有时被系统快捷键拦截。CDP 模式下应使用page.reload()或page.goto(url, wait_untilnetworkidle)而非模拟键盘事件。最后分享一个血泪教训在某次银行系统自动化项目中我们用 CDP 模式连接 Chrome 登录网银脚本运行 3 小时后Chrome 内存飙升至 4GB页面响应延迟超过 10 秒。排查发现是page.on(console)事件监听器未被移除每条 console.log 都被 Playwright 缓存最终撑爆内存。解决方案是所有page.on()事件监听必须配对page.remove_listener()或在page关闭前显式清理。CDP 模式不是魔法它是把 Playwright 的能力嫁接到你最熟悉的那台 Chrome 上。你付出的是环境管理的复杂度你收获的是无限接近真实用户的操作体验。理解它的边界尊重它的规则它就能成为你自动化工具箱里最锋利也最可靠的那一把刀。
