
1. 真实场景一晚上没跑完的爬虫到底卡在哪去年年底我接到一个需求要把某个科技媒体站的资讯文章抓下来做本地语料库。站点不算复杂但它是典型的前后端分离架构首页、列表页、详情页全部由前端框架动态渲染requests直接请求返回的 HTML 里只有一堆空的div idapp容器节点真正的内容是页面加载后通过 XHR 接口异步填充进去的。如果只是拿不到数据也就算了更麻烦的是列表页里埋了懒加载——用户往下滚动浏览器才会发起下一次分页请求。你拿requests去模拟还得自己去逆向那几个分页接口的参数。但用浏览器自动化工具就完全绕过这个问题了让浏览器自己滚动、自己发请求、自己渲染我们只负责从渲染完的 DOM 里把链接挑出来。我最早用的是 Selenium它能跑但速度实在不敢恭维。一个列表页从启动到加载完再等滚动触发最后一篇篇解析链接平均下来每个页面要两三秒。当时库里有大概 3000 多篇文章要抓光列表页翻页就花了一个多小时详情页又一个一个请求整个爬虫跑了一整晚。第二天早上看日志发现跑到一半进程崩了也没做断点续爬那叫一个绝望。后来我重新设计了这个项目核心思路就一句话能不用浏览器就绝不用浏览器。浏览器自动化工具只负责一件事——把列表页里所有文章的真实 URL 提取出来剩下那 3000 多个详情页的请求全部交给aiohttp异步并发去完成。改造之后整个抓取流程在 10 分钟左右跑完失败的任务自动重试中途断了也能从断点继续。这篇文章把整套方案和踩过的坑完整记录下来。2. 环境准备这两个坑不提前解决后面全是幺蛾子2.1 安装的并不只是playwright一个包环境部分看着简单实际操作中的坑比想象中多。先列一下依赖清单pip install playwright aiohttp beautifulsoup4 lxml playwright install chromium第一行命令好理解playwright是浏览器控制库aiohttp是异步 HTTP 客户端beautifulsoup4和lxml用来解析 HTML。第二行很多人会漏掉——playwright和 Selenium 不一样它不是自带的浏览器驱动而是通过 CDP 协议去控制一个真实的 Chromium 内核这个内核需要单独下载安装。第一次执行playwright install chromium的时候它会去下载一个约 150MB 左右的 Chromium 构建版本如果服务器网络状况一般这一步容易超时。而且playwright在不同版本里默认下载的内核版本还在变建议不管装哪个版本都先执行一次playwright install再配合执行playwright install-deps解决系统库问题。2.2 Linux服务器缺系统库的经典报错在 Linux 服务器上跑有一个环境问题基本绕不开Chromium 依赖一系列系统动态库包括libnss3、libatk、libgbm、libasound等。如果你是在干净的 CentOS 或 Ubuntu 镜像上装完 Python 就跑代码启动浏览器时会看到一堆OSError的报错提示libnss3.so找不到。这个问题的根源不是 Python 代码而是 Chromium 渲染进程需要的图形和加密相关的底层库缺失。解决办法是在安装内核时顺手装依赖playwright install-deps chromium这个命令会自动调用系统的包管理器yum或apt把需要的库装上。注意执行它需要 root 权限。如果公司服务器不允许直接 root那就只能把缺失的库名抄下来让运维同事帮忙装。这一条建议在项目开始前就确认好我见过不少同事在环境环节卡了一下午。2.3 验证环境的脚本不能省环境装好以后建议先写一个极小的验证脚本确认浏览器能正常启动再开始开发复杂逻辑。我一般这样验证import asyncio from playwright.async_api import async_playwright async def check(): async with async_playwright() as p: browser await p.chromium.launch(headlessTrue) page await browser.new_page() await page.goto(https://example.com, timeout30000) print(await page.title()) await browser.close() asyncio.run(check())能正常打印出Example Domain说明 playwright、浏览器内核、系统依赖全部通了。这里用的是async_api不是同步的sync_api原因后面会讲——整篇项目是异步架构用async_playwright能和aiohttp共用同一个事件循环。2.4 headless模式不是所有场景都合适headlessTrue是无头模式不弹出浏览器窗口服务器上跑爬虫一般都用它。但这里有个实际经验如果你调试时有脑模式跑通了、无头模式跑不通的情况先别急着怀疑代码优先检查页面里是不是有需要加载特定字体、WebGL 或 Canvas 指纹执行的逻辑。我实际遇到过一次目标站列表页在无头模式下只加载第一屏内容滚动事件不触发。后来排查发现是页面某段 JS 会检测浏览器的navigator.webdriver标记和窗口尺寸如果配置不理想就会阻止后续懒加载逻辑。这种情况我采取的办法是把窗口设置成一个合理的真实屏幕尺寸同时启动参数里关闭自动化提示信息。这是常规操作不是要伪装成什么而是让浏览器行为更接近正常访问者。browser await p.chromium.launch( headlessTrue, args[--disable-blink-featuresAutomationControlled] ) page await browser.new_page( viewport{width: 1920, height: 1080} )3. 架构设计先画数据流再写代码3.1 两阶段抓取的设计思路代码写多的人都会认同一个道理爬虫项目里代码本身不是难点难点在于把抓取流程设计成一条清晰的数据流。我最终设计的流程分两阶段第一阶段发现阶段用 Playwright 打开列表页模拟滚动加载直到所有文章链接都出现在 DOM 中。把链接收集起来、去重、过滤得到一个待抓取的 URL 队列。第二阶段抓取阶段用 aiohttp 对队列里的 URL 做高并发请求拿到 HTML 后解析标题、正文、发布时间、标签等结构化字段最终写入数据库。这两个阶段是串联的第一阶段跑完得到完整的 URL 列表第二阶段才开始。中间用一个 Python 列表或 queue 来传数据。你可能会想为什么不两个阶段同时跑一边发现新链接一边抓取设计上是可行的但我在实际项目里没有这样做原因很简单如果列表页是分页加载的你不知道总共有多少页也不知道什么时候算结束流式处理会引入额外的状态管理复杂度。先拿到全量 URL再集中抓取整个项目的状态控制会简单很多。3.2 为什么列表页必须用Playwright详情页却不用回到最初的问题为什么这么分工因为两者的技术特征完全不同。列表页在前端框架架构里是一个活动页面它的 DOM 是 JS 动态生成的并且只有当用户滚动到屏幕边缘时才会加载下一页数据。这个交互逻辑用纯 HTTP 客户端模拟起来非常痛苦而 Playwright 作为一个真实浏览器天生就能处理这种行为。详情页则不同。文章一旦发布它的 HTML 内容基本是静态的服务器返回的响应里就包含完整正文不需要执行额外的 JS 去渲染。这种情况下再用 Playwright 一个页面一个页面打开就太浪费了——每个页面都要重新创建浏览器上下文加载一大堆渲染资源耗时和内存开销都成倍增加。所以最优解是渲染交给 Playwright并发抓取交给 aiohttp。这是整套架构的骨架也是本篇博文标题里两个库各自的分工定位。3.3 队列、信号量、并发数这些概念怎么落到代码里第二阶段用 aiohttp 做的核心工作可以拆成三层事件循环asyncio.run()或asyncio.create_task()来管理所有并发协程信号量asyncio.Semaphore限制同时进行的请求数量连接池aiohttp 的TCPConnector默认会复用 TCP 连接避免重复握手信号量和连接池这两个概念特别容易被新手忽略。信号量解决的是你不能给目标服务器一瞬间打几百个请求的问题连接池解决的是每次请求都重新建立 TCP 连接很浪费的问题。这两个设置一配合既保证了对目标站点的礼貌又大幅提高了吞吐量。sem asyncio.Semaphore(20) connector aiohttp.TCPConnector(limit30, limit_per_host15) async with aiohttp.ClientSession(connectorconnector) as session: async with sem: async with session.get(url) as resp: html await resp.text()这个limit和limit_per_host的区别值得注意limit是连接器全局允许的最大并发连接数limit_per_host是同一主机名下允许的最大连接数。如果目标是同一个域名下的文章limit_per_host应该设置得比limit更小避免对单个服务器造成过大压力。3.4 去重与断点续爬每次失败不用从头来爬虫项目跑到一半失败是家常便饭。网络抖动、服务器临时拒绝、目标站改版任何一个环节出错都可能导致中断。如果每次重跑都得从头扫列表页那列表页的翻页请求又得重新执行一遍纯属浪费时间。我在设计时加了两层防护第一层是 URL 去重。从列表页提取的链接先和数据库里已有的 URL 做一次比对已经存在的直接过滤掉。这样重跑脚本时不会重复抓取已入库的文章。第二层是失败任务记录。把每次 aiohttp 请求失败、重试也失败的 URL 单独落盘到一个文本文件或 SQLite 表里下次启动时先把这些历史失败任务补充到队列头部优先重跑它们。这两层逻辑让整个爬虫具备了断点续爬能力稳跑一整天也不会出现开头几百篇重复、后面几百篇没抓到的情况。4. 核心代码从收集链接到并发抓取4.1 用Playwright滚动页面并收集文章链接第一阶段的核心目标是打开列表页模拟滚动收集所有文章链接。代码逻辑可以抽象为下面这个函数async def collect_links(page, max_scrolls20): links set() for i in range(max_scrolls): await page.mouse.wheel(0, 1200) await page.wait_for_timeout(1200) hrefs await page.eval_on_selector_all( a.post-title, els els.map(el el.href) ) links.update(hrefs) if await page.evaluate( document.documentElement.scrollHeight ) await page.evaluate( document.documentElement.clientHeight ): break return list(links)这段代码有几个细节值得单独说。page.mouse.wheel(0, 1200)模拟鼠标滚轮向下滚动 1200 像素。page.wait_for_timeout(1200)是每次滚动后等待 1.2 秒给懒加载接口留出响应时间。这里为什么不直接wait_for_selector等待某个加载更多按钮出现因为很多站点的懒加载是滚动到页面底部后自动触发 XHR 请求并没有可见的按钮元素。用固定间隔等待是无奈但可靠的办法。eval_on_selector_all是 playwright 里非常实用的方法传入一个 CSS 选择器和一段 JS 表达式它会在所有匹配元素上执行这段 JS 并返回结果数组。这里用el el.href直接提取每个a标签的href属性。用set去重是因为滚动过程中已经加载过的链接会反复出现在 DOM 里。退出滚动循环的条件是页面滚动高度等于可视高度说明没有更多内容可以滚动了。这个判断偶尔会出现误差所以我又加了一个max_scrolls上限防止死循环。4.2 aiohttp并发下载详情页代码拿到链接列表后第二阶段的大杀器登场async def fetch_one(session, sem, url, retries3): for attempt in range(retries): try: async with sem: async with session.get( url, timeoutaiohttp.ClientTimeout(total15) ) as resp: if resp.status ! 200: return None, fHTTP {resp.status} html await resp.text() return html, None except (asyncio.TimeoutError, aiohttp.ClientError) as e: last_err str(e) await asyncio.sleep(2 ** attempt) return None, last_err async def fetch_all(urls, concurrency20): sem asyncio.Semaphore(concurrency) connector aiohttp.TCPConnector(limitconcurrency 10) headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Accept-Language: zh-CN,zh;q0.9, } async with aiohttp.ClientSession( connectorconnector, headersheaders ) as session: tasks [fetch_one(session, sem, url) for url in urls] results await asyncio.gather(*tasks, return_exceptionsFalse) return results这里asyncio.gather一次性把所有任务注册到事件循环里它们会同时排队等待执行。信号量sem负责控制真正同时在途的请求数量比如设置concurrency20就保证最多只有 20 个请求在网络上飞其余任务在信号量队列里等待。重试机制用了指数退避exponential backoff第一次失败等 1 秒第二次失败等 2 秒第三次失败等 4 秒。这是标准的网络重试策略给目标服务器一个恢复的窗口也能避免连不上时反复请求给服务器造成压力。4.3 用超时、重试和信号量控制并发上面代码里两个参数值得根据实际情况调整timeout和concurrency。timeout设置成 15 秒是因为文章详情页不太可能超过 15 秒还没响应首包。如果网络环境差可以放宽到 30 秒但不要设置成完全不超时——一旦目标站点出现连接挂死的情况await resp.text()可能一直卡住不返回整个任务队列被阻塞。concurrency也不是越大越好。我在后面第 5 章有具体对比数据。这里只说结论对于一般的科技媒体站10~30 并发是一个相对安全的区间既能保证速度又不会让服务器日志里出现明显的异常访问模式。还有一个容易踩坑的细节aiohttp.ClientSession在整个抓取流程中只需要创建一次不要在每个请求里都创建。ClientSession 内部维护着一组连接池和 cookie 存储反复创建销毁会损失连接复用的收益还可能造成端口资源泄漏。4.4 解析正文与发布时间aiohttp 拿到的是 HTML 字符串接下来需要解析出结构化字段。推荐使用 BeautifulSoup 搭配 lxml 解析器性能比默认的 html.parser 好很多from bs4 import BeautifulSoup def parse_article(html, url): soup BeautifulSoup(html, lxml) title soup.select_one(h1.article-title) content_div soup.select_one(div.article-content) time_tag soup.select_one(time.article-time) return { url: url, title: title.get_text(stripTrue) if title else None, content: content_div.get_text(\n, stripTrue) if content_div else None, publish_time: time_tag.get(datetime, time_tag.get_text(stripTrue)) if time_tag else None, }选选择器的时候建议直接用 Playwright 的 codegen 工具辅助定位playwright codegen 目标页面URL会打开一个录制窗口你在页面上点哪个元素它就自动生成对应的选择器。这功能不是广告是真的能节省大量调试时间。不过生成的 CSS 选择器往往带有过分具体的前缀路径手动精简一下更好用。正文解析有一个实操经验不要只取纯文本。如果你后续要做语义分析或训练文本模型建议把标题、正文、发布时间这些字段分开存储HTML 原文也保留一份。这样即使日后发现解析逻辑有 bug还能从原文里重新解析不用重新爬一遍站点。5. 实测数据20并发和50并发差别不是你想的那样5.1 一组同目标站的对比数据我在同一台 4 核 8G 的服务器上对同一个科技媒体站的 3000 个详情页做了三组测试结果如下表并发数平均耗时失败率重试后CPU占用峰值备注10约18分钟0.3%15%表现稳定请求间隔细腻20约9分钟0.5%25%推荐值速度和稳定性均衡50约5分钟3.2%55%失败率明显上升部分请求被拒100约4分钟11%85%不推荐重试占用了大量时间结论非常直观并发数超过 50 以后抓取速度提升非常有限失败率却直线上升。原因也不难理解——目标服务器有自身的连接和带宽上限当请求洪峰逼近这个上限时部分请求会被主动拒绝或超时这些失败请求重试时又占据了新的并发资源形成了恶性循环。对于大多数中小型资讯站20~30 并发是性价比最高的区间。这个数据你可以作为参考但每个站点的承受能力不同建议在规模化抓取前先拿 50 个 URL 做并发测试找到自己的平衡点。5.2 重试策略和超时参数怎么调重试策略的设计里有一个容易忽略的细节重试不仅要判断是否超时还要看 HTTP 状态码。429 Too Many Requests和503 Service Unavailable都是服务器在委婉地告诉你你太快了这时候光是重试没用需要主动降低速度。我在项目里对 429 和 503 做了额外处理遇到这两个状态码时不按常规退避时间重试而是额外增加一个随机等待窗口比如 5~15 秒。这是对服务器表示尊敬的方式也是保持长期稳定抓取的关键。超时设置可以细化成两个维度connect超时和total超时。aiohttp 的ClientTimeout支持这种区分timeout aiohttp.ClientTimeout(connect5, total20)connect5表示 TCP 连接建立的最长等待时间是 5 秒如果 5 秒还没建立连接直接放弃这次请求total20表示整个请求从开始到响应体读取完成的最长时间是 20 秒。这种双维度超时比单一总超时更健壮能快速排除连不上和响应太慢两类问题。5.3 编码判断、防盗链这些细节优化这里整理几个提升抓取质量的细节都是实际操作中积累的编码检测有些服务器响应头里没有charset字段或者返回的Content-Type写错了。用resp.text()时它内部会根据响应头推断编码推断失败就用 UTF-8。碰到这个情况乱码率不低。稳妥做法是先取resp.content再用charset_normalizer或BeautifulSoup的detect_encoding判断实际编码最后再 decode。防盗链设置很多图片和其他静态资源的加载会检查Referer头。如果页面正文里的图片 URL 指向另一台 CDN 服务器带一个来源域名作为Referer往往能让资源正常加载。对文章 HTML 抓取来说一般不用管图片但如果你要把正文和图片一起保存这个头就必须加上。URL 清洗列表页提取的链接里可能带utm_source、utm_medium这类跟踪参数。入库之前统一用urllib.parse.urlparse把 query 部分清掉只保留干净的 URL。否则同一个文章可能会因为参数不同被当成两个 URL占满了数据库的唯一索引。5.4 频率控制与合规边界这里要专门聊一下频率控制。之前热搜词里有人搜网站如何检测到被playwright控制——这个话题的背景是很多动态站点确实能识别出浏览器自动化工具。常见的识别原理包括检测navigator.webdriver属性、检查浏览器窗口尺寸是否为不规则值、分析鼠标轨迹和滚动行为是否太过机械等。这部分技术原理作为知识了解没问题但我的实际经验是与其琢磨怎么伪装不如从源头控制请求频率。对于个人学习用途、抓取公开文章的爬虫只要做到三点基本不会出问题一是遵循目标网站的robots.txt规则二是控制合理的抓取频率加随机延迟三是明确抓取数据仅用于个人学习和研究不对外传播、不商用。这既是合规的底线也是保护自己 IP 不被封禁的最好策略。我在项目里实际配置的延迟策略是这样的信号量控制并发数的同时每个任务开始前加一个 0~1 秒的随机 sleep让请求时间轴看起来有自然的抖动。不要小看这个随机 sleep它能明显降低请求集中到达的概率是性价比最高的稳定化手段。6. 踩坑记录进程泄漏、等不到元素、连接池警告6.1 Playwright的浏览器实例和上下文生命周期这个坑我印象特别深。第一次跑完整流程时我发现服务器内存持续上涨跑完 3000 个页面后8G 内存被吃掉了接近 4G。排查了半天问题的根源在于 Playwright 的浏览器对象没有正确关闭。Playwright 的对象模型是这样的browser是一个浏览器进程context是浏览器上下文相当于一个独立的 Cookie 会话page是具体标签页。如果你在循环里反复创建 context 和 page却不显式关闭每个 context 都会持有一批 JS 执行环境和网络连接资源。正确的做法是全程只创建一个 browser每次打开页面用独立的 context用完立即关闭 context。如果只是抓取列表页连 context 都可以复用但要注意 cookie 和 localStorage 状态会累积。我在项目里对每个分类页开一个新的 context处理完关闭避免站点把多个页面的访问行为凑成一个持续跟踪的会话。context await browser.new_context(viewport{width: 1920, height: 1080}) page await context.new_page() try: await page.goto(url) finally: await context.close()finally里关闭 context 是必须的否则一旦页面加载出错异常会跳过关闭逻辑泄漏一个浏览器上下文。你可能觉得一个 context 才几十 MB 不算什么但跑上几百个页面之后就是灾难。6.2 用了一个下午排查为什么等不到元素调试列表页时我遇到过这样一个情况用wait_for_selector(a.post-title)等待文章链接出现但头部几篇始终抓不到。后来发现页面首屏加载完成后列表的前两篇文章链接已经在 DOM 里了但我把wait_for_selector放在了滚动循环里——每次滚动前都等这个选择器结果它早就存在了判断立刻通过滚动代码还没来得及加载下一页。这个问题的本质是对页面加载时序的理解不够细致。正确的顺序应该是先等页面达到稳定状态比如某个固定的导航栏元素出现然后执行滚动滚动后等固定时间让懒加载接口响应最后再读取当前 DOM 中的所有链接我用了一个更稳妥的等待方式page.wait_for_function()监听页面滚动高度是否变化await page.wait_for_function( oldHeight document.documentElement.scrollHeight oldHeight, argprevious_height )这个函数会阻塞执行直到滚动后页面高度增加说明有新的内容渲染出来了。相比固定 sleep它能自适应慢网络不会因为网络波动导致元素还没加载完就去读。6.3 aiohttp连接池告警与限制跑高并发时终端经常会刷出这样的警告Unclosed client session Unclosed connector这两个警告的根源都是 ClientSession 和连接器没有正确关闭。注意async with aiohttp.ClientSession(...) as session:这种写法它的作用范围是整个缩进块缩进块结束时 session 会自动关闭。如果你在函数里创建了 session 却没有用async with包裹或者请把await session.close()写在异常处理里就会出现上面的告警。还有一种情况信号量设置的并发数超过了连接池的limit上限。比如信号量是 50但TCPConnector(limit30)那 50 个任务里只有 30 个能同时获得连接另外 20 个会等待连接释放。这在逻辑上没有问题但实际上会造成不必要的排队性能反而不如直接把信号量同步调整到和 limit 一致。6.4 页面被识别和控制频率的平衡最后再回到网站如何检测到被 playwright 控制这个问题。前面说过检测原理主要围绕浏览器指纹特征navigator.webdriver标记、自动化相关的 CDP 事件、非典型的鼠标轨迹、以及请求频率的模式等。这是行业里公开讨论的技术点理解它有助于你写出更稳健的采集程序。但我要强调一个实际做法上的建议不要试图拟人化到极致合理控制频率才是长期方案。我见过一些项目花大量精力去伪造鼠标轨迹、随机点击位置短期有效但一旦目标站升级了检测策略所有努力全部作废。反而那些简单、慢速、稳定抓取的项目生命周期长得多的多。把精力放在断点续爬、失败重试、数据质量这些能确定性提升效果的地方才是正确方向。7. 数据落库从JSON到一张干净的SQLite表7.1 表结构设计抓下来一堆 JSON 文件确实能用但可查询性太差了。我建议直接建一张干净的 SQLite 表也可以用 MySQL 或 PostgreSQL逻辑一致。CREATE TABLE IF NOT EXISTS articles ( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT NOT NULL UNIQUE, title TEXT, author TEXT, publish_time DATETIME, content TEXT, tags TEXT, raw_html TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_publish_time ON articles(publish_time);url字段加UNIQUE约束是这套表设计的核心——它是天然的去重键。raw_html字段存原始 HTML后面解析逻辑有调整时可以随时从原始数据重新提取不需要重新抓取。7.2 增量抓取的基本逻辑前面第 3 章提到过断点续爬落库之后的增量抓取逻辑会更清晰启动爬虫时先从库里查询所有已有 URL生成一个 setPlaywright 收集到的链接先和这个 set 比对已存在的跳过新链接写入待抓取队列抓取结果用INSERT OR IGNORE写入INSERT OR IGNORE遇到 URL 冲突时会直接忽略不会报错。这个特性非常适合批量插入不用每次插入前都手动检查是否重复。对于更新场景可以改用ON CONFLICT DO UPDATE来刷新已有记录。比如某个站点允许编辑文章你希望每次抓取都覆盖旧内容就用它。7.3 整个项目还能怎么扩展这套 Playwright aiohttp 的架构可以复用到很多场景不只是科技媒体文章。我后来扩展了几个方向都建立在同一套代码骨架上RSS 聚合器用 Playwright 收集多个站点的最新文章列表aiohttp 抓全文落库后提供一个统一搜索接口关键词监控定时抓取某个站点的文章用 jieba 或简单正则做关键词匹配命中后推送通知语料库构建配合分类标签和正文文本通过datasets库做成高质量的中文技术语料集这个项目做到后面其实已经不太像爬虫项目了更像一个数据工程管道采集、解析、结构化、增量更新、查询。但核心还是那两句话能不用浏览器就不碰浏览器能用异步就不用同步。希望这篇文章能帮你少走一些我走过的弯路。