说实话,现在做爬虫早就不是十年前那种拿个 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 测试与自动化”,底层直接对接 CDP(Chrome DevTools Protocol)协议,不需要额外塞一个 WebDriver 二进制文件,安装和使用都轻量很多。
用表格对比更直观:
| 对比项 | Requests | Selenium | Playwright |
|---|---|---|---|
| 数据渲染 | 不支持,拿源码需另做解析 | 支持,驱动完整浏览器 | 支持,驱动完整浏览器 |
| 执行速度 | 快,但依赖接口分析能力 | 慢,每次操作都走完整协议栈 | 快,API 精简且支持异步 |
| 反爬对抗 | 较弱,容易暴露指纹特征 | 中等,浏览器指纹明显 | 较强,可配合启动参数做隐藏 |
| 编写复杂度 | 中,接口分析成本高 | 较高,同步等待和元素操作繁琐 | 低,自带自动等待和智能定位 |
| 异步支持 | 需另用 aiohttp 实现 | 弱,同步阻塞为主 | 原生支持 Async API |
| 社区和文档 | 成熟 | 成熟 | 增长迅速,官方文档齐全 |
还有一点特别关键:Playwright 提供了 codegen 工具,可以在浏览器里录制你的点击操作,自动生成完整代码。这对抓取页面结构和定位元素的帮助非常大,后面我会单独开一节详细说。
1.3 为什么要用 Asyncio 配合并发
很多人装好 Playwright 后第一反应是用同步 API,一步一步地抓,一个页面一个页面地跳转。如果只是抓几十个页面,同步写法问题不大,但知识分享平台的文章、评论、用户主页动辄上千条,同步串行就意味着每次完整的浏览器渲染、滚动、等待都要白白耗时。这个时候异步的优势就体现出来了。
Python 的 asyncio 是协程调度框架,本质上是事件循环在执行任务,碰到网络 IO 等待时能主动切换出去,让其他任务先跑。Playwright 的异步 API(async_playwright)与 asyncio 天然契合,二者配合可以做到一个进程同时控制多个页面上下文,开启多路并发抓取。
我用一个不太严谨但很形象的类比:同步爬虫是一个服务员挨个给顾客上菜,一盘菜没上完不能接下一单;异步爬虫是多个服务员同时服务多桌客人,遇到需要等后厨做菜的间隙,就去给另一桌上饮料。你说哪个效率高?
2. 环境准备与核心 API 上手
2.1 安装与踩坑记录
不管是 Windows、macOS 还是 Linux,Python 环境里装 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_HOST=https://npmmirror.com/mirrors/playwright/二是找到浏览器的缓存目录,从别的机器上拷贝一份浏览器内核直接解压进去。我实测下来,用 npmmirror 镜像是最省事的。
第三个坑是版本匹配问题。Playwright 库升级后,旧版本浏览器内核不一定兼容,运行时会提示你要重新执行playwright install。我的习惯是固定版本号,例如playwright==1.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(headless=True) 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(headless=True) 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_text="Python爬虫") await item.locator("a").first.click()定位不到元素是最常见的问题,原因通常是页面是懒加载的,或者元素在 iframe 里。处理懒加载,可以用page.mouse.wheel()模拟滚动;处理 iframe,Playwright 也提供了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_until="domcontentloaded", timeout=30000) # 滚动到底部触发懒加载 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(headless=True) # 每个详情页协程都创建一个新页面,但共享同一个浏览器实例 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设了timeout=30000,防止某个页面卡死导致协程永远等待。三是我在滚动循环里加了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_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ...", locale="zh-CN", viewport={"width": 1920, "height": 1080} )3.4 登录态处理:复用会话避免反复验证
知识平台的很多内容需要登录才能完整查看。如果每个页面都去登录一次,不仅效率低,而且频繁登录非常容易触发安全策略。我的做法是,先用 Playwright 打开浏览器,手动登录一次,然后把登录后的上下文状态保存到本地文件,后续抓取时直接加载这个状态,类似浏览器的“保持登录”效果。
代码实现很简单:
# 第一次:手动登录并保存状态 async with async_playwright() as p: browser = await p.chromium.launch(headless=False) context = await browser.new_context() page = await context.new_page() await page.goto("https://target-platform.com/login") # 手动扫码或输入账号密码 input("登录完成后按回车保存...") await context.storage_state(path="state.json") await browser.close() # 后续:直接加载状态 context = await browser.new_context(storage_state="state.json")storage_state保存的是 cookie、localStorage、IndexedDB 等完整状态,比单纯复制 cookie 要可靠得多。我特别提醒一点:手动登录时一定要用headless=False打开有头浏览器,因为很多平台的登录页面会做前端环境检测,在无头模式 + 普通状态下容易被识别为机器人,自己在有头模式里登录反而最省事。
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( headless=True, args=["--no-sandbox", "--disable-dev-shm-usage"] )不过我不建议一上来就关沙箱,这属于绕过安全检查的行为,能用普通用户跑就尽量用普通用户跑。
4.2 页面元素定位不到:先看页面结构再写代码
定位不到元素是我见过最多的问题,新手尤甚。我的排查流程是:先在浏览器开发者工具里确认元素确实存在,然后检查它是不是在 iframe 里、是不是在 Shadow DOM 里、是不是通过异步加载才出现的。确认之后,用page.wait_for_selector做一个显式等待再操作,或者干脆调试时把headless=False打开,人眼看着页面加载到哪一步了。
还有一种情况比较隐蔽:页面内容是在滚动到底部后才加载的,不滚动就定位不到元素。我之前抓一个瀑布流页面时就是这个问题,列表页只加载了最上面的 10 条数据,下面的内容必须滚动才能触发加载。解决办法我前面写了,用page.mouse.wheel模拟滚动,或者用page.evaluate直接操作window.scrollTo更高效:
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")滚动之后加一个短等待,再检查元素数量是否增长,如果没增长,就再滚一次。
4.3 长时间抓取后内存飙高
爬虫跑久了,内存占用越来越大,最后被系统杀掉,这是异步爬虫的常见问题。主要原因是没有及时关闭用过的 page 对象。每个browser.new_page()都会在浏览器进程中创建一个新的标签页,标签页开着就会持续占用内存。
我的习惯是,在每个协程内部处理完一个 URL 之后,立刻关掉这个 page:
async 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 抓下来的数据接进大模型做内容总结或标签抽取,这会让你的爬虫项目从“数据搬运工”变成“知识加工器”,价值提升不止一个档次。