
说实话现在做爬虫早就不是十年前那种拿个 requests 就能通吃的时代了。你打开一个知识分享平台用 requests 拿到的是空壳 HTML真正有价值的内容全藏在 JS 动态渲染和异步接口里有的接口还带着签名校验。这次的项目标题是“Python爬虫实战利用Playwright与Asyncio高效抓取知识分享平台”我就基于这个标题聊聊这套组合拳怎么打以及我在实战中踩过的坑、沉淀下来的经验。我会把整件事拆成四块来讲为什么选 Playwright 和 Asyncio、怎么搭环境和上手、怎么写出一个真正高效的异步抓取流程、以及遇到问题怎么排查。无论你是刚接触爬虫的新手还是被动态页面折磨得想摔键盘的进阶玩家这篇都能给你一些能直接抄作业的思路。1. 整体设计与技术选型思路1.1 知识平台抓取的核心痛点先说说我现在面临的实际情况。现在主流的知识分享平台普遍采用前后端分离架构页面数据的渲染路径大致分为三类第一种是传统的 SSR 服务端渲染直接能拿到 HTML 内容第二种是页面框架先返回空壳再由浏览器执行 JavaScript 脚本随后通过 XHR 或 Fetch 异步拉取数据并渲染到 DOM 上这类是最常见的第三种更恶心数据就在页面源码里但被 JavaScript 做了一层或多层编码混淆直接抓取 HTML 拿到的是一堆看不懂的密文。用 requests 这类纯 HTTP 库去处理第二种和第三种场景通常得先去抓包找接口分析请求参数、加密逻辑、签名规则。你要知道平台一般不会把核心知识接口的签名算法放在前端明文里让人随便读不仅要处理 JavaScript 加密还得模拟完整的 cookie 注入流程。我自己的体会是遇到这种情况与其逆向接口签名不如老老实实换一条路——直接把浏览器本身变成抓取工具。1.2 为什么选 Playwright 而不是 Selenium 和 Requests既然要驱动真实浏览器市面上无非是 Selenium、Puppeteer、Playwright 这几个选择。我早期用过 Selenium功能是齐全但它的 API 设计老派、速度慢对现代浏览器的 DevTools 协议支持也一般。而 Playwright 出自微软核心设计理念就是“现代 Web 测试与自动化”底层直接对接 CDPChrome DevTools Protocol协议不需要额外塞一个 WebDriver 二进制文件安装和使用都轻量很多。用表格对比更直观对比项RequestsSeleniumPlaywright数据渲染不支持拿源码需另做解析支持驱动完整浏览器支持驱动完整浏览器执行速度快但依赖接口分析能力慢每次操作都走完整协议栈快API 精简且支持异步反爬对抗较弱容易暴露指纹特征中等浏览器指纹明显较强可配合启动参数做隐藏编写复杂度中接口分析成本高较高同步等待和元素操作繁琐低自带自动等待和智能定位异步支持需另用 aiohttp 实现弱同步阻塞为主原生支持 Async API社区和文档成熟成熟增长迅速官方文档齐全还有一点特别关键Playwright 提供了 codegen 工具可以在浏览器里录制你的点击操作自动生成完整代码。这对抓取页面结构和定位元素的帮助非常大后面我会单独开一节详细说。1.3 为什么要用 Asyncio 配合并发很多人装好 Playwright 后第一反应是用同步 API一步一步地抓一个页面一个页面地跳转。如果只是抓几十个页面同步写法问题不大但知识分享平台的文章、评论、用户主页动辄上千条同步串行就意味着每次完整的浏览器渲染、滚动、等待都要白白耗时。这个时候异步的优势就体现出来了。Python 的 asyncio 是协程调度框架本质上是事件循环在执行任务碰到网络 IO 等待时能主动切换出去让其他任务先跑。Playwright 的异步 APIasync_playwright与 asyncio 天然契合二者配合可以做到一个进程同时控制多个页面上下文开启多路并发抓取。我用一个不太严谨但很形象的类比同步爬虫是一个服务员挨个给顾客上菜一盘菜没上完不能接下一单异步爬虫是多个服务员同时服务多桌客人遇到需要等后厨做菜的间隙就去给另一桌上饮料。你说哪个效率高2. 环境准备与核心 API 上手2.1 安装与踩坑记录不管是 Windows、macOS 还是 LinuxPython 环境里装 Playwright 就这么几步pip install playwright playwright install chromium第一条命令安装 Python 库本身第二条命令会自动下载 Chromium 内核。这里有几个非常容易踩的坑我挨个说一下。第一个坑是 Linux 服务器上跑起来报缺依赖库。playwright install chromium只是把浏览器二进制文件拉下来了但 Linux 系统的动态链接库不一定齐全运行时会报类似libX11-xcb.so.1: cannot open shared object file这种错误。解决方法是用官方提供的命令安装系统依赖playwright install-deps chromium如果是 Ubuntu/Debian 系统它会自动apt-get install一堆基础库。第二个坑是下载速度极慢甚至超时。Playwright 的浏览器二进制文件托管在境外服务器上国内网络环境经常会卡在下载进度条上。解决方法有两个一是配置环境变量指定国内镜像源export PLAYWRIGHT_DOWNLOAD_HOSThttps://npmmirror.com/mirrors/playwright/二是找到浏览器的缓存目录从别的机器上拷贝一份浏览器内核直接解压进去。我实测下来用 npmmirror 镜像是最省事的。第三个坑是版本匹配问题。Playwright 库升级后旧版本浏览器内核不一定兼容运行时会提示你要重新执行playwright install。我的习惯是固定版本号例如playwright1.44.0避免自动升级导致不可控。2.2 同步 API 与异步 API 的切换逻辑很多初学者看到 Playwright 的文档就懵了为什么既有sync_playwright又有async_playwright两者之间怎么选其实核心就一句话同步 API 用起来简单适合脚本任务异步 API 适合大规模并发场景但代码里必须全程保持异步风格。我的实际经验是如果目标平台数据量不大、请求频率要求不高先用同步 API 快速跑通流程确认逻辑没问题之后再花十分钟把同步代码改成异步版本性能提升立竿见影。看一下两种写法的最小对比# 同步写法 from playwright.sync_api import sync_playwright with sync_playwright() as p: browser p.chromium.launch(headlessTrue) page browser.new_page() page.goto(https://example.com) print(page.title()) browser.close() # 异步写法 import asyncio from playwright.async_api import async_playwright async def main(): async with async_playwright() as p: browser await p.chromium.launch(headlessTrue) page await browser.new_page() await page.goto(https://example.com) print(await page.title()) await browser.close() asyncio.run(main())你们注意看异步 API 中的每个操作前面都加了await这是因为 Playwright 内部的操作本质上是异步非阻塞的必须等浏览器返回结果后才能继续往下走。2.3 页面元素定位与自动化等待如果你在动态页面里用过 Selenium一定被它的WebDriverWait和expected_conditions折磨过写显式等待代码非常啰嗦不写显式等待页面元素还没加载出来就报NoSuchElementException。Playwright 在这块做了非常大的改进。它自带自动等待机制调locator.click()、locator.fill()这类操作时Playwright 会等待元素出现在 DOM 中、处于可见状态、不再处于禁用状态然后才去执行操作。也就是说你不必手动写一大堆复杂的等待条件代码会简洁很多。下面是我最常用到的几个定位方法# 按文本定位 await page.locator(text登录).click() # 按 CSS 选择器定位 await page.locator(.article-list .item).count() # 按 XPath 定位 await page.locator(xpath//h2[contains(class, title)]).text_content() # 链式定位 item page.locator(.list-item).filter(has_textPython爬虫) await item.locator(a).first.click()定位不到元素是最常见的问题原因通常是页面是懒加载的或者元素在 iframe 里。处理懒加载可以用page.mouse.wheel()模拟滚动处理 iframePlaywright 也提供了frame_locator接口可以直接在指定框架内定位元素注意不要直接尝试在父页面里定位 iframe 内部的节点。2.4 利用 codegen 快速生成定位脚本Playwright 的录制工具是我建议每个爬虫开发者的必须掌握的。在命令行里敲playwright codegen https://target-platform.com它就会弹出一个窗口并自动打开目标网站。你在网页上点击、填写、翻页的所有操作都会被实时翻译成 Playwright 的 Python 代码显示在旁边的代码面板里同时还会给出每一步的定位器建议。这个工具的价值在于它帮我极大地降低了元素定位的试错成本。我会先录一段完整操作把生成的代码复制到项目里再把中间的硬编码等待全部删掉替换成 Playwright 的自动等待和条件判断最后用一个循环包起来。3. 异步抓取核心流程实现3.1 整体抓取流程怎么设计知识分享平台的数据结构一般分两类列表页和详情页。列表页通常包含一批文章的标题、作者、摘要、链接详情页则是正文内容和评论区。我的抓取流程设计成四个阶段第一阶段启动浏览器上下文访问列表页等待列表数据加载完成第二阶段从列表页提取所有详情页的 URL放到一个异步任务队列里第三阶段使用协程并发处理详情页每个协程负责打开一个 URL、滚动页面、等待正文渲染完成、提取正文内容和元数据第四阶段把结构化数据写入本地文件或者数据库。为什么不在列表页阶段就开大量并发因为列表页和详情页的加载逻辑不一样列表页往往有反爬校验访问频率太大会触发验证码而详情页通常只是一个普通的文章页面读取频率稍高一些问题不大。我个人的经验是列表页用单协程串行访问控制节奏详情页再用并发批量处理。3.2 并发控制信号量与连接池思路Playwright 的异步 API 虽然好写但要充分压榨它的性能必须理解并发控制。直接在一个事件循环里开几百个协程同时访问浏览器是不行的因为一个浏览器实例同时能处理的页面上下文是有限度的超出限制页面就会大量崩溃或超时阻塞。我的做法是用asyncio.Semaphore来控制活跃协程的数量。信号量本质上是一个计数器用它限制同一时刻最多有多少个协程在运行。比如我设置最大并发数为 5意思就是同一时刻最多只有 5 个详情页协程在真正执行页面跳转和数据读取其余的协程都在等待信号量释放。import asyncio from playwright.async_api import async_playwright MAX_CONCURRENT 5 async def scrape_detail(page, url, semaphore): async with semaphore: try: await page.goto(url, wait_untildomcontentloaded, timeout30000) # 滚动到底部触发懒加载 for _ in range(3): await page.mouse.wheel(0, 800) await asyncio.sleep(0.5) title await page.locator(h1.article-title).text_content() content await page.locator(div.article-content).inner_text() article { url: url, title: title.strip(), content: content[:2000] } return article except Exception as exc: print(f[ERROR] {url} - {exc}) return None async def main(): detail_urls [ https://target-platform.com/article/1, https://target-platform.com/article/2, # ... 实际从列表页解析获得 ] semaphore asyncio.Semaphore(MAX_CONCURRENT) async with async_playwright() as p: browser await p.chromium.launch(headlessTrue) # 每个详情页协程都创建一个新页面但共享同一个浏览器实例 tasks [] for url in detail_urls: page await browser.new_page() tasks.append(scrape_detail(page, url, semaphore)) results await asyncio.gather(*tasks) for result in results: if result: print(result[title]) await browser.close() asyncio.run(main())这里有几个细节需要注意。一是每个协程创建的page对象数量等于任务数量如果任务量很大建议不要一次开太多否则内存会涨得飞快。二是给goto设了timeout30000防止某个页面卡死导致协程永远等待。三是我在滚动循环里加了await asyncio.sleep(0.5)目的是给页面一个缓冲时间避免连续滚动太快导致部分图片或评论没加载出来。3.3 限流策略别把目标网站打崩写爬虫最忌讳的就是毫无节制地疯狂请求。对于知识分享平台这种内容型网站服务器扛不住高频并发时通常会触发限流规则轻则返回验证码重则直接封禁 IP 甚至封账号。我的限流策略分三层。第一层是控制最大并发数我在上面的代码里已经实现了根据目标平台的稳定程度设置MAX_CONCURRENT的值一般 3 到 8 之间比较稳妥。第二层是随机延迟在协程内访问完一个页面之后随机sleep一段时间比如await asyncio.sleep(random.uniform(0.5, 2.0))。第三层是设置合理的请求头虽然 Playwright 已经模拟了真实浏览器的请求头但有些平台的防爬策略会检查User-Agent、Accept-Language等字段的频率分布建议在创建浏览器上下文时自定义这些参数context await browser.new_context( user_agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ..., localezh-CN, viewport{width: 1920, height: 1080} )3.4 登录态处理复用会话避免反复验证知识平台的很多内容需要登录才能完整查看。如果每个页面都去登录一次不仅效率低而且频繁登录非常容易触发安全策略。我的做法是先用 Playwright 打开浏览器手动登录一次然后把登录后的上下文状态保存到本地文件后续抓取时直接加载这个状态类似浏览器的“保持登录”效果。代码实现很简单# 第一次手动登录并保存状态 async with async_playwright() as p: browser await p.chromium.launch(headlessFalse) context await browser.new_context() page await context.new_page() await page.goto(https://target-platform.com/login) # 手动扫码或输入账号密码 input(登录完成后按回车保存...) await context.storage_state(pathstate.json) await browser.close() # 后续直接加载状态 context await browser.new_context(storage_statestate.json)storage_state保存的是 cookie、localStorage、IndexedDB 等完整状态比单纯复制 cookie 要可靠得多。我特别提醒一点手动登录时一定要用headlessFalse打开有头浏览器因为很多平台的登录页面会做前端环境检测在无头模式 普通状态下容易被识别为机器人自己在有头模式里登录反而最省事。3.5 数据存储从内存到落盘抓到的数据最终要落盘。最简单的方案是写入 JSONL 文件每行一个 JSON 对象方便后续用 pandas 做数据分析。更复杂一点的方案是入库 SQLite 或 PostgreSQL但这要看项目需求。我个人的习惯是先用 JSONL 快速验证抓取结果再顺手写一段转换脚本捣腾到数据库。知识分享平台的正文内容可能很长抓取时限制内容长度只是为了调试实际存储时一定要把全文存下来不要做截断处理不然后面分析关键词、做摘要都缺数据。4. 常见问题与避坑经验4.1 浏览器启动慢、连接被拒绝Playwright 在启动浏览器时偶尔会报Connection refused或Target page, context or browser has been closed这类问题八成是浏览器启动出问题或者之前的浏览器进程没有正常关闭。排查思路分两步看。第一步确认自己是不是用了asyncio.run()跑异步代码而事件循环内部又去创建了什么线程导致循环冲突。第二步检查机器上的 Chrome 或 Chromium 进程是否残留了一堆孤儿进程。可以手动执行pkill chromium清理也可以在代码里用browser.close()收尾。如果是在 Linux 服务器上部署还要注意无头模式虽然不需要显示器但浏览器的沙箱机制在 root 用户下会受限跑起来报Running as root without --no-sandbox is not supported。这种情况下需要加启动参数browser await p.chromium.launch( headlessTrue, args[--no-sandbox, --disable-dev-shm-usage] )不过我不建议一上来就关沙箱这属于绕过安全检查的行为能用普通用户跑就尽量用普通用户跑。4.2 页面元素定位不到先看页面结构再写代码定位不到元素是我见过最多的问题新手尤甚。我的排查流程是先在浏览器开发者工具里确认元素确实存在然后检查它是不是在 iframe 里、是不是在 Shadow DOM 里、是不是通过异步加载才出现的。确认之后用page.wait_for_selector做一个显式等待再操作或者干脆调试时把headlessFalse打开人眼看着页面加载到哪一步了。还有一种情况比较隐蔽页面内容是在滚动到底部后才加载的不滚动就定位不到元素。我之前抓一个瀑布流页面时就是这个问题列表页只加载了最上面的 10 条数据下面的内容必须滚动才能触发加载。解决办法我前面写了用page.mouse.wheel模拟滚动或者用page.evaluate直接操作window.scrollTo更高效await page.evaluate(window.scrollTo(0, document.body.scrollHeight))滚动之后加一个短等待再检查元素数量是否增长如果没增长就再滚一次。4.3 长时间抓取后内存飙高爬虫跑久了内存占用越来越大最后被系统杀掉这是异步爬虫的常见问题。主要原因是没有及时关闭用过的 page 对象。每个browser.new_page()都会在浏览器进程中创建一个新的标签页标签页开着就会持续占用内存。我的习惯是在每个协程内部处理完一个 URL 之后立刻关掉这个 pageasync def scrape_detail(page, url, semaphore): async with semaphore: try: ... finally: await page.close() return article如果任务量极大开了几百个 page 之后再关掉内存虽然会回落但浏览器进程本身可能已经产生了碎片和膨胀。这种情况下更推荐的做法是控制 page 数量用固定大小的连接池也就是我在第 3.2 节里讲的信号量思路不要让它无限创建。4.4 关于反爬与合规边界我多讲几句写爬虫的人绕不开反爬但我在这里想明确一个边界合规是底线。抓取公开的、允许被访问的知识内容加上合理的限速和身份标识是行业普遍接受的但如果你去破解加密参数、绕过验证码、伪造指纹去规避平台的安全验证这就越界了既可能违反平台服务条款也可能踩到法律红线。我建议在项目里写一个robots.txt检查逻辑抓取目标之前先看看目标平台的robots.txt是否允许爬取相应路径。这是一个好习惯也是职业性的体现。还有抓下来的数据如果涉及用户的原创内容不要直接商用版权问题在知识领域尤其敏感。4.5 调试利器一步步看清页面状态写爬虫的过程中调试占的时间远比写代码要多。我常用三种调试方式。第一种最原始也最有效把headless设成 False让浏览器直接展示出来用page.screenshot()保存任意时刻的页面截图肉眼观察页面状态。第二种是利用 Playwright 的page.on(console)监听浏览器端 console 日志某些页面报 js 错误时从终端就能看到。第三种是结合playwright codegen在网站上先录一段操作再用生成的代码跑通流程确实能省很多力气。如果你用 VS Code我建议装一下 Playwright 官方插件。它可以把测试步骤可视化地展示在调试面板里能直接点选元素生成 locator还可以方便地录制交互过程。结合 IDE 的断点调试排查定位问题的效率能翻倍。最后分享两个我实际操作中的体会。第一个是不要一上来就追求复杂方案。把同步版本的抓取流程跑通再去改造异步并发你会对每一步的效果有更直观的感知而不是在这里照抄代码然后跑出一堆报错不知道怎么改。第二个是知识分享平台的页面结构经常改版写好的选择器过两个月可能就失效了建议把定位器统一抽成一个配置文件别散落在代码各处改版时只改配置就行。至于下一步怎么扩展我建议你尝试把 Playwright 抓下来的数据接进大模型做内容总结或标签抽取这会让你的爬虫项目从“数据搬运工”变成“知识加工器”价值提升不止一个档次。