接手这个需求的时候我第一反应是这有什么好爬的API文档网站说穿了不就是展示接口说明的页面嘛requests拉下来正则一匹配半天完事。等到真正动手才发现现在的文档站早就不是十年前那种一个HTML文件走天下的形态了。Swagger UI、Redoc、ReadMe、GitBook轮番上阵页面几乎全靠JavaScript动态渲染接口数据藏在一次次XHR请求里有的站点甚至套着iframe直接请求HTML根本拿不到任何有效信息。这篇文章就完整复盘这次的Python爬虫实战为什么最后选型是Playwright加异步编程以及等待渲染、拦截请求、并发控制、问题排查这些环节具体怎么做。如果你正卡在HTML拿到了但里面啥都没有这种问题上这篇应该能帮你省不少时间。1. 项目背景API文档网站是典型的会动的壳1.1 这个需求的来源与目标拆解当时要处理的需求很具体把某开发平台的API接口文档批量整理成一份内部接口清单包含接口路径、请求方法、请求参数、响应结构还要保留文档原有的分类层级。目的倒不是搞什么数据生意纯粹是测试团队要做接口覆盖率统计人工去几百个页面里复制粘贴既慢又容易漏写个自动化脚本是唯一靠谱的解法。把需求拆开看核心目标有三个。第一拿到每个接口的完整元信息路径、方法、参数、响应模型一样不能少第二维持文档页面本身的分类结构不能抓回来一堆扁平链接否则后续没法用第三脚本要可重复执行文档站更新后重新跑一遍就能得到最新清单不需要人工干预。这几个目标看着简单其实直接把方案引向了一个关键问题目标网站的HTML里到底有没有数据。我先做了个最简单的小实验requests请求首页拿回来一看body里除了脚本标签和几个div骨架什么都没有。这个现象在现在的文档站里太常见了——页面只是个壳真正的内容都是浏览器执行JavaScript之后再通过异步请求填进去的。所以方案选型从一开始就注定了不能只靠requests。1.2 常见API文档站的四种渲染形态动手写代码之前先花点时间认清目标站点的技术形态这一步能省掉后面大量的返工。我盘点下来市面上的API文档网站基本逃不出这四类。第一种是Swagger UI / OpenAPI规范页面。这是最经典的形态Spring Boot项目随便接个springdoc或Swagger注解就能在/swagger-ui.html或者/docs路径下生成一套交互式文档。它的特点是页面外壳很轻真正的OpenAPI JSON文件swagger.json或openapi.json是页面加载后用XHR拉取的浏览器拿到JSON再渲染成左侧接口列表、右侧参数详情。对爬虫来说这套结构反而是最友好的因为数据源头就是一个清晰的JSON文件。第二种是Redoc。渲染方式跟Swagger UI有差异内容经常直接塞在HTML里的预加载状态中或者存在一个单独的JSON规范地址里。抓取逻辑跟第一种类似但要额外处理内容在HTML内联脚本里的情况。第三种是ReadMe、GitBook、MkDocs这类文档托管平台。很多开发团队的自定义API文档用它们搭建页面是典型的前端框架SPA数据可能挂在window.__INITIAL_STATE__这类全局变量里也可能走路由懒加载甚至需要用户点击某个菜单才触发接口请求。这类站点最不适合无脑上requests。第四种是iframe嵌套型。在不少企业级API网关上父页面只是一个目录框架真正的文档内容放在子iframe里加载跨域、内嵌、多层嵌套搅在一起。如果你用requests直连拿到的只有框架页面内容区块空空如也。搞明白这四种形态最大的收获是能建立起一个判断标准先想清楚数据是静态存在的还是需要浏览器跑完JS才出现。这个判断直接决定了你是走轻量直连还是重浏览器渲染。1.3 纯requests方案为什么会在实战里翻车我第一版方案确实是requests当时的想法还挺美既然Swagger UI底层就是个JSON文件那我找到那个接口地址直接GET不就完了这个思路在理想环境下确实能跑通但实际一跑全是坑。第一个坑是登录态。很多企业内部文档挂在统一身份认证后面requests的Session虽然能管Cookie但面对Token刷新、SSO跳转、前端加密参数这些机制处理起来极其痛苦。你手动在浏览器登录很容易让脚本模拟整套认证流程工作量直接翻倍。第二个坑是前端二次加工。部分文档平台会对接口数据做分步加载列表页先拉分类目录点击某一项才去拉详情或者对请求参数做签名校验。这种情况下requests就像个门外汉根本不知道下一步该请求哪个地址因为你看到的地址是前端脚本动态拼接出来的。第三个坑是风控。现在很多网关类站点会对无浏览器特征的请求做识别校验User-Agent、TLS指纹、请求头顺序、Cookie合法性这些细枝末节。requests这类库的默认特征太明显返回结果经常是一个验证页面或者429状态码。当然也不能一棍子打死requests。在我最终的方案里它仍然承担了探路角色——先用轻量请求试试能不能直连到OpenAPI JSON试通了就走快路试不通再切换浏览器方案。这个渐进式降级的思路在爬虫工程里非常实用后面我会展开讲。2. 选型分析Playwright和异步到底解决了什么问题2.1 三个候选方案的横向对比既然确认了必须走浏览器渲染这条路接下来就是选型。市面主流的浏览器自动化方案无非是Selenium、Puppeteer、Playwright这几个Python生态里还有Pyppeteer这种老古董。我当时重点对比了Selenium和Playwright。论生态Selenium成名最早教程多老项目里存量巨大。但它的短板也很明显底层是WebDriver协议启动方式和浏览器通信都比较重同步API用得顺手异步支持后加上去用起来别扭等待机制全靠显式等待和强制sleep并发场景下资源控制很不灵活。Playwright是微软开源的方案走的CDPChrome DevTools Protocol路线对Chromium系浏览器支持最好。我选它有几个硬理由自带同步和异步两套API原生配合asyncio这对Python异步爬虫几乎是量身定做的自动等待机制做得很细元素可见、可交互、网络空闲都能判断不用靠拍脑袋sleepBrowserContext上下文隔离设计得干净一个浏览器实例里能开多个互不干扰的会话网络拦截能力强既能看请求也能改请求改响应这对抓取SPA内部数据是解放级别的功能。至于Puppeteer它是Node生态的虽然也可以通过pyppeteer桥接过来但维护状态一般调试体验不如Playwright顺畅。Python项目里我不会首选它。2.2 异步编程在爬虫场景里的真实价值很多新手听到异步就觉得高大上其实在爬虫这个场景里它的价值非常朴素让你用有限的线程资源同时管理海量的IO任务。爬虫是典型的IO密集型任务。浏览器发请求、加载JS、渲染DOM、拉取网络数据大部分时间是在等网络、等渲染CPU反而闲着。用多线程也能等但线程本身有开销Python还有GIL的限制线程开多了只会让调度混乱。asyncio事件循环搭配Playwright的async API可以在单线程内同时管理几十个并发页面因为Playwright和浏览器之间是走WebSocket的异步通信天然适配事件循环。拿我这次的项目算笔账目标大约300个接口页面。如果串行抓取每个页面从打开到渲染完成平均2.5秒算下来至少要12分钟。用Semaphore把并发控制在10个整个抓取时间压缩到1.5分钟左右。这个提升是实打实的代码复杂度增加其实很小性价比极高。2.3 整体架构与代码结构设计架构上我分成五层目标解析层负责从入口URL提取文档目录结构数据获取层由Playwright控制浏览器并拦截网络请求数据解析层负责从JSON、DOM、内联脚本里提取结构化数据调度层用asyncio的队列和信号量管理并发存储层最后把结果统一落盘成JSON或Markdown。crawler/ ├── config.py # 目标站点、并发数、超时时间等配置 ├── dispatcher.py # asyncio 调度层维护任务队列与信号量 ├── fetcher.py # Playwright 封装负责页面加载与网络拦截 ├── parser.py # 数据解析层处理 OpenAPI JSON / DOM / 内联脚本 ├── storage.py # 结果落盘输出 JSON / Markdown └── main.py # 入口编排这样分层的好处是每层都能独立替换。比如今天目标站点是Swagger UI解析层处理OpenAPI JSON明天换成GitBook只需要改parser浏览器和调度都不用动。这个架构在后续几轮迭代里帮我省了很多事。3. 核心实现手把手搭一个Playwright异步爬虫3.1 环境准备与最小可运行骨架先说环境。Playwright的安装比Selenium省心一条pip命令搞定Python包再跑一条命令下载浏览器内核。pip install playwright playwright install chromium如果在内网环境或者下载慢可以手动指定镜像源或者直接用系统里已装的Chrome启动时用channelchrome参数。这一步卡住的人不少其实官方文档写得很清楚照着来就行。安装完先跑一个最小骨架验证环境和基础API。用async API时入口函数就是async_playwright这个上下文管理器。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://docs.example.com/, wait_untildomcontentloaded, timeout45000) print(页面标题:, await page.title()) await browser.close() asyncio.run(main())这里有几个参数值得解释。headlessTrue是无头模式生产环境抓取必须开有头浏览器既慢又占资源调试的时候才开headlessFalse加slow_mo300慢慢看过程。wait_until默认是load但我实际用下来更推荐domcontentloaded因为很多页面有第三方统计脚本、埋点请求等load容易超时。超时时间也不要省网络差的目标站45秒不算多。3.2 等待页面渲染完成的几种策略Playwright最核心的用法之一就是等待真正需要的内容出现。很多人习惯用page.wait_for_timeout硬等几秒这个办法不是不行但效率太低网络稍慢就容易拿不到数据网络快了又白白浪费等待时间。正确的做法是按优先级选策略。第一优先是wait_for_selector等关键DOM节点出现。比如Swagger UI页面接口列表渲染完成后会出现.swagger-ui .opblock-tag这个节点那就等它。await page.goto(url, wait_untildomcontentloaded, timeout45000) await page.wait_for_selector(.swagger-ui .opblock-tag, timeout30000)第二优先是把等DOM和等请求响应结合起来。有些站点的数据不是一次渲染完的点击某个接口标签才会触发详情请求这时用page.expect_response来同步等待。async with page.expect_response(lambda r: /api/endpoint/detail in r.url, timeout15000): await page.locator(.opblock-tag).first.click() response await context.value data await response.json()第三优先才是wait_for_load_state(statenetworkidle)但我要提醒你这个状态在长连接、轮询心跳、WebSocket的页面上几乎必超时慎用。我一般在确认页面没有长连接任务时才用它兜底。提示等待策略的本质是等待你必然需要的那个条件成立而不是等一个固定的时间。抓到页面后先手动在开发者工具里看一遍确认哪些节点、哪些请求是渲染完成的关键标志再把这些标志写进代码。3.3 拦截网络请求直接拿OpenAPI规范数据很多Scrapy玩家第一次接触Playwright会觉得别扭因为思维方式变了不是我主动发起请求拿数据而是页面自己去请求我在旁边看着数据经过我手的时候抄一份下来。这种拦截思路在处理动态拼接、带签名、带Token的API文档时特别好用因为你根本不用去复刻前端那套复杂的请求构造逻辑浏览器替你把什么都做了。from playwright.async_api import async_playwright async def capture_openapi_spec(url: str): captured {} async with async_playwright() as p: browser await p.chromium.launch(headlessTrue) page await browser.new_page() async def on_response(response): if any(key in response.url for key in (openapi, swagger, api-docs)): if response.url.endswith((.json, .yaml, .yml)) or json in response.headers.get(content-type, ): try: captured[url] response.url captured[body] await response.json() except Exception: captured[url] response.url captured[text] await response.text() page.on(response, on_response) await page.goto(url, wait_untildomcontentloaded, timeout45000) await page.wait_for_selector(.swagger-ui, timeout30000) await browser.close() return captured这段代码的核心在于page.on(response, on_response)。事件回调里做了两层过滤URL关键字过滤避免把埋点和统计接口也抓回来Content-Type过滤确保拿的是JSON或YAML格式的规范文件。response.json()是Playwright封装的解析方法比手动再发一次请求干净利落。有的文档平台没有标准规范文件数据存在页面的内联变量里。比如很多Vue/React项目会把首屏数据写进window.__INITIAL_STATE__这种情况直接用page.evaluate取全局变量就行。state await page.evaluate(() window.__INITIAL_STATE__)拿到OpenAPI规范之后解析就很顺手了。规范文件里paths字段就是全部接口路径每个路径下面的get、post等字段就是请求方法parameters是参数定义responses是响应结构。只要规范文件抓到手整个文档的数据基本就到手了一半。3.4 并发控制与最终数据落盘并发抓取是这次实战的重头戏。Playwright的async API配合asyncio实现并发其实很自然难的是把并发控制在合理范围避免把目标站打挂也避免自己内存爆掉。我用asyncio.Semaphore做并发闸门同时每个URL独立创建BrowserContext。独立Context这一步至关重要否则多个并发任务共享一个Context的Cookie容易出现会话串味A页面的登录态跑到B页面上数据就全乱了。import asyncio import json from playwright.async_api import async_playwright CONCURRENCY 5 semaphore asyncio.Semaphore(CONCURRENCY) async def fetch_doc_page(browser, url): async with semaphore: context await browser.new_context( user_agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ... ) page await context.new_page() try: await page.goto(url, wait_untildomcontentloaded, timeout30000) await page.wait_for_selector(.opblock, timeout20000) title await page.locator(h1).inner_text() content await page.inner_text(.opblock) return {url: url, title: title, content: content} finally: await context.close() async def main(urls): async with async_playwright() as p: browser await p.chromium.launch(headlessTrue) results await asyncio.gather(*(fetch_doc_page(browser, u) for u in urls)) with open(docs_result.json, w, encodingutf-8) as f: json.dump(results, f, ensure_asciiFalse, indent2) asyncio.run(main(url_list))几个细节值得讲透。context.close()一定要放在finally里否则异常发生时上下文资源泄露跑久了内存直线飙升。asyncio.gather负责把所有协程聚合起来并发执行它默认是短路模式一个任务抛异常会影响整体所以我一般在生产代码里用return_exceptionsTrue或者往里包一层错误捕获保证单个页面失败不拖垮整个任务。最终落盘我选了JSON为主格式因为后续要转Excel、对接口、做覆盖率统计都很方便。数据量不大不需要上数据库一个带indent2的JSON文件足够。4. 实战踩坑记录与排查方法4.1 高频错误速查表爬虫跑起来之后问题一个接一个。这里把最容易踩的坑整理成一张表方便你对照排查。错误或现象触发场景我的排查思路Target closed页面操作前浏览器或页面已关闭检查Context生命周期最常见的是finally里关了Context但协程还在用或者多个任务共享了同一个Page对象并发操作wait_for_selector超时关键元素始终没出现先用浏览器DevTools确认选择器正确再看网络面板里有没有接口请求失败最后考虑内容是否在iframe里iframe要用frame_locatornet::ERR_ABORTED请求加载阶段被中止有些是统计脚本、预加载请求主动中止忽略即可如果是关键接口被中止考虑是否并发太高触发限流内存持续上涨长期运行后内耗越来越严重八成是Context或Page没关闭严格用finally兜住或者并发数设得太高降低到5以内429 Too Many Requests访问频率过高触发风控降低并发数增加随机延迟做好失败重试不要硬刚playwright: target closed中文报错在浏览器启动或页面跳转阶段偶发出现这个报错时先看是不是把browser.close()写到了协程执行前面或者多个协程抢着关闭了同一个浏览器实例Target closed这个错误我单独多说两句。它的典型场景是你用了asyncio.gather某个任务执行得特别快先把Context关了另一个还在排队等资源等它拿到Context准备操作时发现页面已经没了。解决办法就是把浏览器实例和Context实例的生命周期管理清楚浏览器启动一次全局共享Context按任务独立创建且由任务自己负责关闭。4.2 关于风控与访问频率的几点心得先摆个原则我做的所有事情都限定在抓取公开可见的文档数据、且不违反站点服务条款的范围内。爬虫是工程能力但不是用来突破访问控制或盗取非公开内容的工具这个边界得守住。在这个前提下谈几点实际经验。第一收敛浏览器特征。我在new_context里显式设置User-Agent、语言、时区关掉一些容易暴露自动化特征的东西。第二别把频率拉满。即便你有合法抓取需求也不建议一上来就20并发跑满速目标站点不是压力测试场。我这里通常控制在3到5并发抓几百页也就一两分钟完全够用。第三加上随机延迟和失败退避既显得像人也给自己的脚本留下缓冲余地。import random await asyncio.sleep(random.uniform(0.3, 0.8))4.3 性能与稳定性调优并发数量是第一步调优更大的优化空间在少加载没用的资源。文档站上那些图片、字体、媒体文件对我们抓数据毫无价值却占用了大量网络带宽和渲染时间。用路由拦截把它们直接扼杀在请求阶段。async def block_resource(route): if route.request.resource_type in (image, media, font, stylesheet): await route.abort() else: await route.continue_() await context.route(**/*, block_resource)这一条规则上去页面平均加载时间能下降30%到50%效果非常明显。需要注意的是别把脚本和XHR也禁了否则页面渲染的核心逻辑都没了。另外如果是批量抓页面还可以把浏览器窗口尺寸调到最小减少布局计算量无头模式下默认视口够用就行不需要大屏窗口。稳定性方面我建议每个页面任务里都套一层try/except把单页失败的日志打出来不要中断整个流程。抓取结束后再统一处理失败列表重新跑一遍就好。没有哪次大规模爬虫是一次跑完的重试机制是稳定性的一部分。5. 复盘下来最想分享的几个经验5.1 先探路再上重武器这次项目给我最大的教训是不要一上来就掏Playwright。我第一版请求失败后其实应该先花十分钟做静态探测——用requests试试/v3/api-docs、/swagger.json这类的规范地址也许问题早就解决了。现在很多功能完善的文档站确实会暴露OpenAPI规范文件能直连就不需要浏览器轻量、快速、稳定。把这个渐进式降级的思路记在心里先试静态再上动态先试轻量请求再上浏览器自动化。反过来操作只会让简单问题复杂化。5.2 用关键元素代替固定sleep写浏览器爬虫最忌讳的就是拍脑袋sleep。我见过太多代码是time.sleep(5)然后祈祷页面能加载完这种代码换个网络环境就废了。Playwright的wait_for_selector和expect_response就是用来解决这个问题的你要等的是那个元素出现了、那个请求返回了这些是业务条件而不是固定的时间长度。把所有sleep都改成条件等待脚本的鲁棒性会有一个质的提升。5.3 并发要克制隔离要彻底最后再分享一个小技巧。异步并发很诱人但在爬虫场景里稳定大于速度。我实测下来文档站这类以JSON渲染为主的轻页面并发5到8已经接近吞吐极限再往上加并发目标站开始出现超时和限流整体完成时间反而变长。控制并发的同时务必保证每个任务的Context隔离这不仅是数据正确性的要求也是崩溃后能快速定位问题的基础。宁可慢一点也要保证每一批数据都是干净、可用的。
