ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Playwright 通过 CDP 连接已登录 Chrome 实战

Playwright 通过 CDP 连接已登录 Chrome 实战 做自动化测试或者数据采集的朋友大概率都撞过这堵墙你手动打开谷歌浏览器登录好账号、点掉一堆同意弹窗、把该过的验证都过完了页面状态干干净净。结果 Playwright 一跑chromium.launch()弹出来的是一个全新的窗口Cookie 没有、登录态没有、连插件都没了,相当于前面那几分钟白忙。于是问题就变成了——能不能让 Playwright 直接接管我手上这个已经打开、已经登录好的谷歌浏览器答案是能而且路径非常明确Chrome DevTools Protocol,简称 CDP。只要谷歌浏览器是通过--remote-debugging-port启动的它就会在本地开一个 WebSocket 服务任何实现了 CDP 客户端的工具都能连上去操作它Playwright 里的connect_over_cdp()就是干这个的。在 macOS 上这套玩法有几个独有的坑应用包路径特殊、open -a传参会被吞、新版 Chrome 对默认用户目录下的远程调试做了限制、还有 Playwright 同步异步 API 混用导致的报错。这篇内容我会把整套流程从原理到可复制的脚本完整拆一遍包含 macOS 下的启动命令、连接代码、参数含义、常见报错速查表以及一些只有在真机上反复折腾才会发现的细节。适合已经有 Python 或 Node 基础、正在做 Web 自动化或者需要复用已登录会话的开发者参考。文末也专门写了使用边界这部分建议先看一眼再动手。1. 先想清楚为什么非要连已打开的浏览器1.1 新开一个浏览器的三处硬伤标准的 Playwright 用法是browser p.chromium.launch(headlessFalse)然后context browser.new_context()再page context.new_page()。这套流程在跑自己写的测试用例时非常丝滑但在面对真实网站时问题会集中爆发。第一处硬伤是状态不可继承。每一次launch()出来的都是全新的上下文意味着 Cookie、localStorage、IndexedDB、Service Worker 缓存全部从零开始。很多站点在你第一次访问时会让登录而登录流程里往往夹着滑块、点选、短信验证这些必须人来参与的动作。如果每次跑脚本都要手动过一遍自动化本身就失去了意义。第二处硬伤是自动化特征太明显。Playwright 默认启动 Chromium 时会附上一批开关其中--enable-automation会让窗口顶部挂出一条Chrome 正受到自动测试软件的控制的提示条同时运行时会暴露navigator.webdriver这类属性。对于做正常业务测试的人来说这条提示条本身就很碍眼而对站点侧的风控来说这几乎等于举着牌子告诉对方我是脚本。第三处硬伤是环境和资源不连续。你配置好的代理设置、装好的扩展、习惯用的字体和窗口尺寸、甚至已经缓存好的静态资源全都要重新来一遍。跑几十次脚本就是几十次冷启动既费时间也费内存Mac 上尤其明显几个 Chromium 实例一开风扇马上就起来了。1.2 连已开浏览器时到底发生了什么理解connect_over_cdp之前先理解 CDP 是什么。谷歌浏览器内部有一层调试协议DevTools 面板之所以能看到网络请求、能改 DOM、能打断点靠的就是它。协议本身跑在 WebSocket 上Chrome 把不同的能力拆成一个个域比如Page管页面生命周期Network管请求Runtime管 JS 执行Target管标签页和 iframe。当你用--remote-debugging-port9222启动 Chrome它就在本地监听 9222 端口对外提供一个 HTTP 接口来列出可调试的目标同时提供 WebSocket 端点来实际通信。Playwright 的connect_over_cdp()做的事情就是先请求一次 HTTP 拿到目标列表再对浏览器级别的 WebSocket 建立连接然后把浏览器里已经存在的 context、page 包装成 Playwright 自己的对象模型返回给你。这里有个关键点必须说清楚连接模式下的 page 并不是 Playwright 新建的它是 Chrome 自己创建的Playwright 只是拿到了操作句柄。所以 Playwright 通常注入的那些初始化脚本不会在这个页面上生效navigator.webdriver也不会被额外改写页面的 JS 环境更接近一个真人手动打开的浏览器。这正是很多人选择这条路线的技术原因——它不是在对抗什么而是在让自动化环境尽量贴近真实使用环境。对应到 macOS这套机制完全一样差别只在启动方式和文件系统路径。/Applications/Google Chrome.app/Contents/MacOS/Google Chrome才是真正的可执行文件应用包本身只是个目录壳子这一点后面会反复用到。2. macOS 下带调试端口启动谷歌浏览器的正确姿势2.1 命令怎么写为什么不能图省事用 open -a很多人第一反应是open -a Google Chrome --args --remote-debugging-port9222。这条命令在部分 macOS 版本上确实能生效但极其不稳定open会先问系统这个应用是不是已经开着了如果已经开着它只会把窗口激活参数直接被丢掉你等半天发现端口根本没起来。而且open是把启动请求丢给 Launch Services 的返回值没有任何关于参数是否被接受的信息排查起来两眼一抹黑。稳妥的做法是直接调用二进制。先完全退出 ChromeCmdQ不是关窗口然后开一个终端/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \ --remote-debugging-port9222 \ --user-data-dir/Users/你的用户名/chrome-debug-profile \ --no-first-run \ --no-default-browser-check逐项说明这些参数。--remote-debugging-port9222是核心指定调试端口端口号可以换成别的但别用 1024 以下的特权端口。--user-data-dir指定一个独立的用户数据目录这一条在新版本里已经是强制项后面单独讲。--no-first-run跳过首次运行的引导页--no-default-browser-check跳过是否设为默认浏览器的询问弹窗这两个纯属省事不加也不影响连接。注意如果你打算临时跑一次可以用--user-data-dir$(mktemp -d)生成临时目录。但这样做等于放弃了登录态的持久化下次启动又是白纸一张。想复用登录态就应该固定一个目录让它长期存在。2.2 独立用户目录不是可选项是必选项这是近两年最容易踩的坑。谷歌浏览器出于安全考虑收紧了远程调试的行为当--user-data-dir指向默认用户数据目录时调试端口会拒绝被外部访问日志里会明确提示这类做法存在风险。你可能会遇到端口开了、curl也能通、但 Playwright 一连就报错或者连上后拿不到任何 page 的情况。根因很直白默认目录里装着你的全部登录凭据、密码库、支付信息如果任意本地程序都能通过调试端口连上来操作这个浏览器等于把这些东西全部敞开。所以官方直接把这条路封掉了。正确做法就是上面那样显式指定一个非默认目录。这个目录第一次启动时会自动初始化你会看到一个全新的、未登录的浏览器。注意这意味着你需要在这个调试专用的浏览器里重新登录一次目标站点之后就一直是登录状态了后续所有脚本连接都会复用这份登录态。如果你以前已经有大量配置在默认目录里也不想重新登录可以用--profile-directory配合复制的方式把默认目录整份拷到新位置但更省心的做法还是新建一个干净的调试专用 profile。原因有两层一是拷贝大目录在 macOS 上很容易因为文件锁和权限位出问题二是把自动化和日常冲浪的浏览器彻底隔离开脚本跑崩了不会影响你正常用浏览器。2.3 两个启动参数的取舍表参数是否必需作用不写会怎样--remote-debugging-port9222必需开启本地调试端口Playwright 无从连接--user-data-dir...必需指定非默认用户目录新版 Chrome 拒绝外部连接--remote-allow-origins*视版本而定放宽 WebSocket 的来源校验部分版本连接时被拒--no-first-run建议跳过首次引导每次多一个弹窗要关--no-default-browser-check建议跳过默认浏览器询问每次多一个弹窗要关--remote-allow-origins*这个参数值得一提。某些 Chrome 版本对 WebSocket 握手时的 Origin 头做了校验Playwright 这类客户端如果不带合法 Origin连接会直接被拒。加上这个参数相当于放行本地开发环境用它没什么问题。如果你连的时候报的是握手阶段的错误优先怀疑它。2.4 先手动验证端口别急着写代码这是我自己养成的习惯连接失败的时候永远先排除端口根本没起来这种低级问题再去看代码。在终端里跑curl -s http://127.0.0.1:9222/json/version返回的应该是一段 JSON里面有Browser、webSocketDebuggerUrl等字段。如果返回Connection refused说明端口没开回去检查 Chrome 是不是真的用带参数的命令启动了或者是不是被你之前的 Chrome 进程占了。http://127.0.0.1:9222/json/list会列出当前所有可调试的标签页能看到url、title、type等字段。这一步通了说明浏览器侧完全就绪剩下的都是 Playwright 侧的事。3. Playwright 连接已有浏览器的代码拆解3.1 最小可用示例先跑通再优化Python 版本的最小代码大概长这样import asyncio from playwright.async_api import async_playwright CDP_URL http://127.0.0.1:9222 async def main(): async with async_playwright() as p: browser await p.chromium.connect_over_cdp(CDP_URL) contexts browser.contexts if not contexts: print(没有拿到任何 context检查 Chrome 是否已开标签页) return context contexts[0] pages context.pages page pages[0] if pages else await context.new_page() await page.goto(https://example.com) print(await page.title()) asyncio.run(main())几个点解释一下。connect_over_cdp接受的是 HTTP 地址不是 WebSocket 地址Playwright 会自己去请求/json/version拿到 WebSocket 端点不用你手动填。browser.contexts返回的是浏览器里已经存在的上下文列表对于普通 Chrome 来说通常只有一个默认上下文取contexts[0]就行。context.pages是它下面的标签页列表取第一个就是你当前正在看的那个页面。Node.js 版本结构一样只是 API 名字从下划线变成了驼峰const { chromium } require(playwright); (async () { const browser await chromium.connectOverCDP(http://127.0.0.1:9222); const context browser.contexts()[0]; const pages context.pages(); const page pages.length ? pages[0] : await context.newPage(); await page.goto(https://example.com); console.log(await page.title()); })();3.2 复用已有 context 时的三个细节第一个细节不要随手new_context()。在 CDP 连接模式下浏览器是外部已经跑着的实例新建上下文的语义和launch()模式下不完全一致有时候会得到一个和你预期不相干的空上下文登录态反而丢了。既然目的就是复用已登录的会话直接拿contexts[0]才是最稳的。第二个细节遍历所有 page 找目标页而不是死取第一个。真实场景里浏览器可能开着好几个标签页第一个未必是你想要的那个。可以按 URL 关键字匹配target None for ctx in browser.contexts: for pg in ctx.pages: if 你要找的域名关键字 in pg.url: target pg break if target: break第三个细节别调用browser.close()。在连接模式下调用它行为在不同 Playwright 版本里不完全一致有的只是断开连接有的会直接把整个浏览器关掉把用户手头的窗口一起带走。如果你的脚本是跑完就退出干脆什么都不调让 Python 进程自然结束连接会自己断开。真要有洁癖用await browser.close()之前先确认过当前版本的语义或者干脆只关自己新建的 page。3.3 sync 和 async 混用报错的处理热搜里出现过一个报错it looks like you are using playwright sync。这是很典型的 API 混用问题值得展开说。Playwright 有两套 Python API同步的playwright.sync_api和异步的playwright.async_api。同步版本内部是靠绿线程驱动事件循环实现的如果你在已经运行中的 asyncio 事件循环里调用同步 APIPlaywright 会检测到并抛出这个错误。两种修法。方案一全程统一用异步async_playwright配上asyncio.run()所有调用前加await。这是推荐做法尤其你后面可能还要接 aiohttp、异步数据库这类组件。方案二把同步代码丢到单独线程里执行通过concurrent.futures起一个 worker在主线程之外跑同步版本。Jupyter Notebook 里尤其容易撞这个问题因为 Notebook 自己就在跑事件循环。我的建议是新项目一律上 async老项目如果全是同步的就别混二选一别脚踏两条船。3.4 关于反爬这件事先把边界划清楚这部分必须讲在前面。CDP 连接之所以在很多场景下表现得更自然本质原因是它没有额外注入自动化痕迹页面环境和真人手动打开的浏览器高度一致。这个能力本身是中性的用在哪里决定了它是否正当。可以考虑的使用场景包括对自己负责的站点做端到端回归测试避免每个用例都重新登录在获得明确授权的环境下做兼容性与性能验证调试自己写的页面脚本时在真实浏览器里复现问题。这些都属于正常的工程实践。不应当把它当成任意抓取他人站点数据的手段。具体来说目标站点的服务条款和 robots 协议要尊重对方明确禁止采集就不要做请求频率要克制该加延迟加延迟别把人家服务器打穿涉及个人信息、付费内容、需要登录才能访问的私有数据没有授权就不要碰。技术上能做到和应该做是两件完全不同的事写自动化脚本的人尤其要拎得清。4. 一份可复用的实战脚本结构4.1 工程骨架怎么分层把上面这些碎片拼成一个能长期用的脚本建议按四层来组织连接层负责建立 CDP 连接并拿到目标 context页面层负责定位目标标签页或者新建页面业务层写具体的操作逻辑清理层负责收尾和日志。分层的意义在于连接逻辑一旦稳定就很少改动后续换目标站点只动业务层维护成本低很多。# connector.py import asyncio from playwright.async_api import async_playwright, Browser, Page CDP_URL http://127.0.0.1:9222 class ChromeConnector: def __init__(self, cdp_url: str CDP_URL, timeout: float 10.0): self.cdp_url cdp_url self.timeout timeout self._pw None self.browser: Browser | None None async def __aenter__(self): self._pw await async_playwright().start() self.browser await self._pw.chromium.connect_over_cdp( self.cdp_url, timeoutself.timeout * 1000 ) return self async def get_page(self, url_keyword: str, create_if_missing: bool True) - Page | None: for ctx in self.browser.contexts: for pg in ctx.pages: if url_keyword in pg.url: return pg if create_if_missing and self.browser.contexts: return await self.browser.contexts[0].new_page() return None async def __aexit__(self, exc_type, exc, tb): if self.browser: await self.browser.close() if self._pw: await self._pw.stop()这里用了异步上下文管理器async with进出自动管理连接生命周期。timeout参数传的是毫秒这一点很容易写错——Playwright 里所有超时参数默认单位都是毫秒Python 里习惯用秒转换时记得乘 1000我就因为这个数字写错过一次排查了半小时才发现是单位问题。4.2 等待、重试和粘性等待连接建立之后真正的稳定性挑战才开始。CDP 连接下的页面是用户手动操作的随时可能被切走、刷新、关掉脚本必须对这种情况有预期。等待分两种。一种是元素等待用await page.wait_for_selector(..., timeout15000)让 Playwright 自己轮询。另一种是条件等待比如等页面 URL 变化、等某个 JS 变量出现这时用await page.wait_for_function(() window.someFlag true)。两种都比time.sleep()靠谱得多固定 sleep 是最常见的性能浪费来源短了不稳定长了拖慢整个流程。重试方面简单场景用一个装饰器就够import functools, asyncio def retry(times3, delay2.0): def deco(fn): functools.wraps(fn) async def wrapper(*args, **kwargs): last None for i in range(times): try: return await fn(*args, **kwargs) except Exception as e: last e print(f[retry {i1}/{times}] {type(e).__name__}: {e}) await asyncio.sleep(delay * (i 1)) raise last return wrapper return deco延迟按delay * (i 1)递增也就是退避策略避免在对方服务短暂抖动时连续重试把压力叠加上去。次数别设太大三次左右是常见实践太多反而掩盖了真正的 bug。4.3 结果落地与日志数据留下来才有意义。轻量场景直接写 JSONL一行一条追加写不用担心内存import json, aiofiles async def save(record: dict, path: str output.jsonl): async with aiofiles.open(path, a, encodingutf-8) as f: await f.write(json.dumps(record, ensure_asciiFalse) \n)日志建议同时打到文件和终端级别分开。调试期的页面 URL、页面标题、耗时这类信息在 INFO 级别异常堆栈在 ERROR 级别。CDP 连接这类脚本最容易出的问题恰恰是看起来跑完了但结果为空有日志才能快速定位是页面没切对、选择器失效、还是数据本身就没有。实操心得在业务层每次操作前把page.url打印出来。看着啰嗦但连接模式下页面被用户手动切走是高频事件有了这行日志一眼就能看出问题出在页面错位而不是逻辑错误。5. 常见问题速查表与避坑经验5.1 连接阶段的问题现象可能原因处理方式Connection refusedChrome 未带调试参数启动或进程已退出重新按 2.1 的命令启动用 curl 验证端口端口通了但connect_over_cdp超时WebSocket 握手被拒启动命令加上--remote-allow-origins*能连接但browser.contexts为空使用了默认用户数据目录被限制访问换成独立--user-data-dir目录后重启looks like you are using playwright sync同步异步 API 混用统一改成 async或用asyncio.to_thread隔离连接成功但拿不到已登录的页面Chrome 有多个实例端口连到了另一个彻底退出所有 Chrome 后重新按命令启动这里重点说两个。一个是多个 Chrome 实例的问题。macOS 上如果普通 Chrome 已经开着你再跑带参数的二进制有些情况下新进程会把参数交给已有进程然后自己退出结果调试端口根本没开。所以启动前务必CmdQ完全退出或者干脆把调试专用的 Chrome 平时就开着只在需要时用它。另一个是browser.contexts为空这几乎百分百是用户目录的问题回到 2.2 重新配一遍。5.2 运行阶段的稳定性问题连接跑起来之后最常见的反馈是有时候能跑通有时候跑到一半就没反应了。这类问题通常不是 CDP 本身的锅而是页面状态的变化没被正确处理。页面被刷新会导致之前拿到的page对象引用失效继续操作会报Target closed。稳妥的做法是在每个关键步骤前重新获取一次 page或者包一层检查async def ensure_page(browser, url_keyword): for ctx in browser.contexts: for pg in ctx.pages: if url_keyword in pg.url and not pg.is_closed(): return pg return await browser.contexts[0].new_page()另外标签页被关闭是最难处理的因为 Playwright 不会主动通知你只会在你下一次操作时抛异常。上面的重试装饰器能兜住大部分情况如果定位逻辑足够健壮重试时能重新找到目标页并继续体验就基本稳定了。还有一个容易忽略的点是内存和标签页数量。调试专用浏览器如果常年开着几十个标签页CDP 的目标列表会变得很长遍历查找的效率下降也更容易误匹配。建议定期清理或者脚本启动时先输出一次所有标签页的 URL 列表做到心里有数。5.3 几个只有真机跑过才知道的细节第一Chrome 的版本升级会悄悄改行为。同一份脚本升级 Chrome 之后可能突然连不上了大概率是安全策略又收紧了一点。所以调试专用浏览器最好锁定一个稳定版本别开自动更新或者至少在升级后立刻做一次连接验证。第二macOS 的休眠会掐断连接。合盖再打开之后原来的 Playwright 连接通常已经失效表现是操作卡住不返回。脚本里加一个总超时控制别让进程无限挂着。第三别在调试专用的 profile 里装敏感扩展。既然要长期保持登录态这个目录就相当于一个常开的凭据容器只装必要的、可信的东西其他一概不放。第四如果你想在 shell 里快速检查目标页curl那个/json/list接口非常好用返回的 JSON 里每条目标都带id、title、url、webSocketDebuggerUrl定位问题比翻代码快得多。我现在的习惯是第一步永远先curl一次确认浏览器侧一切正常再去动脚本。这一步花十秒往往能省掉半小时的无效排查。
返回列表