1. 真实场景:一晚上没跑完的爬虫,到底卡在哪
去年年底我接到一个需求,要把某个科技媒体站的资讯文章抓下来做本地语料库。站点不算复杂,但它是典型的前后端分离架构,首页、列表页、详情页全部由前端框架动态渲染,requests直接请求返回的 HTML 里只有一堆空的<div id="app">容器节点,真正的内容是页面加载后通过 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(headless=True) page = await browser.new_page() await page.goto("https://example.com", timeout=30000) print(await page.title()) await browser.close() asyncio.run(check())能正常打印出Example Domain,说明 playwright、浏览器内核、系统依赖全部通了。这里用的是async_api,不是同步的sync_api,原因后面会讲——整篇项目是异步架构,用async_playwright能和aiohttp共用同一个事件循环。
2.4 headless模式不是所有场景都合适
headless=True是无头模式,不弹出浏览器窗口,服务器上跑爬虫一般都用它。但这里有个实际经验:如果你调试时有脑模式跑通了、无头模式跑不通的情况,先别急着怀疑代码,优先检查页面里是不是有需要加载特定字体、WebGL 或 Canvas 指纹执行的逻辑。
我实际遇到过一次:目标站列表页在无头模式下只加载第一屏内容,滚动事件不触发。后来排查发现是页面某段 JS 会检测浏览器的navigator.webdriver标记和窗口尺寸,如果配置不理想就会阻止后续懒加载逻辑。这种情况我采取的办法是:把窗口设置成一个合理的真实屏幕尺寸,同时启动参数里关闭自动化提示信息。这是常规操作,不是要伪装成什么,而是让浏览器行为更接近正常访问者。
browser = await p.chromium.launch( headless=True, args=["--disable-blink-features=AutomationControlled"] ) 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(limit=30, limit_per_host=15) async with aiohttp.ClientSession(connector=connector) 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_scrolls=20): 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, retries=3): for attempt in range(retries): try: async with sem: async with session.get( url, timeout=aiohttp.ClientTimeout(total=15) ) as resp: if resp.status != 200: return None, f"HTTP {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, concurrency=20): sem = asyncio.Semaphore(concurrency) connector = aiohttp.TCPConnector(limit=concurrency + 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;q=0.9", } async with aiohttp.ClientSession( connector=connector, headers=headers ) as session: tasks = [fetch_one(session, sem, url) for url in urls] results = await asyncio.gather(*tasks, return_exceptions=False) return results这里asyncio.gather一次性把所有任务注册到事件循环里,它们会同时排队等待执行。信号量sem负责控制真正同时在途的请求数量:比如设置concurrency=20,就保证最多只有 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(strip=True) if title else None, "content": content_div.get_text("\n", strip=True) if content_div else None, "publish_time": time_tag.get("datetime", time_tag.get_text(strip=True)) 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(connect=5, total=20)connect=5表示 TCP 连接建立的最长等待时间是 5 秒,如果 5 秒还没建立连接,直接放弃这次请求;total=20表示整个请求从开始到响应体读取完成的最长时间是 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", arg=previous_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(limit=30),那 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,生成一个 set
- Playwright 收集到的链接,先和这个 set 比对,已存在的跳过
- 新链接写入待抓取队列,抓取结果用
INSERT OR IGNORE写入
INSERT OR IGNORE遇到 URL 冲突时会直接忽略,不会报错。这个特性非常适合批量插入,不用每次插入前都手动检查是否重复。对于更新场景,可以改用ON CONFLICT DO UPDATE来刷新已有记录。比如某个站点允许编辑文章,你希望每次抓取都覆盖旧内容,就用它。
7.3 整个项目还能怎么扩展
这套 Playwright + aiohttp 的架构可以复用到很多场景,不只是科技媒体文章。我后来扩展了几个方向,都建立在同一套代码骨架上:
- RSS 聚合器:用 Playwright 收集多个站点的最新文章列表,aiohttp 抓全文,落库后提供一个统一搜索接口
- 关键词监控:定时抓取某个站点的文章,用 jieba 或简单正则做关键词匹配,命中后推送通知
- 语料库构建:配合分类标签和正文文本,通过
datasets库做成高质量的中文技术语料集
这个项目做到后面,其实已经不太像爬虫项目了,更像一个数据工程管道:采集、解析、结构化、增量更新、查询。但核心还是那两句话:能不用浏览器就不碰浏览器,能用异步就不用同步。希望这篇文章能帮你少走一些我走过的弯路。