做网页抓取最怕的不是网站结构复杂而是程序在那儿一个请求一个请求地跑像排队买奶茶一样明明机器性能很足CPU多核都在闲着却要眼巴巴地等网络往返。前两篇我们聊了HTML结构解析和常见信息提取套路这篇直接进入我踩过最多坑的一个主题——并发抓取与数据清洗。毕竟站点的资源是有限的抓取的效率却掌握在你自己手里。如果你手里恰好有几百个、几千个页面要处理又觉得跑循环太慢这篇会是你近期读到的最实用的一篇。老规矩先交代一下本文的适用人群。控制不了抓取频率、想提高采集效率的开发者对Python异步编程一知半解想找完整抓取实例的初学者已经在用requests逐条抓取的“体力劳动者”。这篇把串行改造为异步的方案拆开揉碎顺便把提取完的脏数据怎么洗干净一并讲清楚。1. 为什么串行抓取如此煎熬1.1 瓶颈不在CPU而在等待先说一个很容易被忽视的事实requests抓取页面的时候你的程序绝大部分时间都在睡觉。网络请求从发出到服务器返回要进行DNS解析、TCP握手、发送HTTP请求、等待服务端处理、下载响应体这些环节里用户态的Python代码几乎啥都没干只是在等操作系统把数据从内核缓冲区搬过来。如果目标网站距离你所在位置较远一次往返耗时可能是几百毫秒甚至一秒以上。假设你要抓500个页面每个页面平均耗时0.8秒串行跑完全程就是400秒接近7分钟。而真正消耗CPU去解析HTML的时间每页可能只有几十毫秒。也就是说99%的时间都被网络I/O卡住了。这个场景最适合用异步I/O来解决。异步I/O的逻辑不是“同时发100个请求”而是在一个线程内发完第1个请求后不干等立刻发第2个、第3个请求。等哪个请求的数据回来了就回头去处理哪个。这样CPU永远在处理那些已经“就绪”的响应而不是傻等着某个连接。提示理解异步最关键的一点是——并发抓取并不是同时抓取而是把等待时间“挪用”给其他任务。对这个点的认知偏差会导致后续很多调试困难尤其是调试超时和资源占用的时候。1.2 为什么不在这个场景用多线程你可能想问用线程池不行吗ThreadPoolExecutor配合requests确实也能提速但对于大量网页抓取这种I/O密集型任务多线程存在两个问题一是操作系统线程切换有开销线程多了之后调度成本明显上升二是Python的GIL虽然没有完全锁死I/O操作但线程之间的上下文切换仍然会引入不必要的复杂性。异步I/O方案使用事件循环在一个线程内处理所有并发任务没有线程切换开销资源占用极低。实测下来在普通开发机上用异步方案开十个并发跑1000个页面CPU占用通常只有个位数百分比内存和文件描述符占用也很可控。异步方案真正的复杂度在于代码模式的改变。requests是“拿到响应再处理”而异步方案必须变成“注册一个回调等响应回来了执行”。不过Python的async/await语法已经把这种转变包装得很人性化熟悉之后写起来比回调地狱清爽太多。2. 核心依赖与整体设计思路2.1 选型aiohttp与解析库的搭配做异步抓取目前最成熟的HTTP客户端库是aiohttp它同时支持服务端和客户端我们这里只需要客户端功能。安装命令很简单pip install aiohttp解析库上我们依然沿用BeautifulSoup。虽然异步抓取不要求解析也必须是异步的但要注意一点BeautifulSoup是纯同步的解析库在异步代码中直接调用它会短暂阻塞事件循环。对于单页几十毫秒的解析时间来说这个阻塞是可以接受的。如果你的页面结构极端复杂单页解析耗时上百毫秒那可以考虑用lxml的增量解析模式或者把解析放到线程池里执行但绝大多数场景根本无需这么折腾。编码检测方面推荐搭配charset-normalizer或chardet。很多网站没在HTTP头里声明charset或者声明得不正确靠requests自动检测经常翻车。手动做一次编码检测配合BeautifulSoup的from_encoding参数可以少掉很多乱码的坑。2.2 抓取策略并发数与重试的平衡并发数不是越大越好。网络带宽、目标服务器承受能力、自身机器文件描述符上限都是约束条件。我实践下来比较稳妥的做法是绝大多数小型站点10到20个并发就足够了如果是抓大型站点且页面静态化程度高开到50个也不会有问题。但如果是小站点或者社区论坛并发太猛容易被封IP。一个原则宁可慢一点不要给目标服务器过高压力。访问频率是对任何抓取程序的隐形要求也是专业和业余的分水岭。重试逻辑必须有。网络环境不可能一直稳定超时、连接重置、服务器返回5xx都是常见情况。我的重试规则是对于超时类异常重试三次对于HTTP 5xx错误重试一次对于HTTP 4xx错误不重试因为重试也白搭——那是请求本身的问题。2.3 代码框架从requests到aiohttp的思路迁移如果你熟悉requestsaiohttp的客户端API上手没有太大障碍。关键差异有两点一是请求要用async with关键字来管理上下文二是获取文本要await resp.text()获取二进制要await resp.read()。async with保证异步环境下连接资源能正常释放这个不能省略。下面是异步抓取的基本骨架import asyncio import aiohttp async def fetch(session, url): async with session.get(url) as resp: return await resp.text() async def main(): async with aiohttp.ClientSession() as session: html await fetch(session, https://example.com) print(html) asyncio.run(main())这个骨架对应着之前用requests写的循环版。注意这里python3.7之后的asyncio.run会负责创建和关闭事件循环不用手动去调get_event_loop那些老接口。3. 实操从串行到异步的完整改造3.1 串行版本作为改造的参照物假设我们要抓一批文章页需要提取每篇文章的标题、正文和发布时间。先写一个串行版的基准代码便于后续对比。import time import requests from bs4 import BeautifulSoup def fetch_article(url): resp requests.get(url, timeout10) resp.raise_for_status() soup BeautifulSoup(resp.text, html.parser) title soup.select_one(h1.article-title).get_text(stripTrue) pub_time soup.select_one(.article-meta time).get_text(stripTrue) content soup.select_one(.article-content).get_text(stripTrue) return {title: title, pub_time: pub_time, content: content} def crawl_serial(urls): results [] for url in urls: try: results.append(fetch_article(url)) print(f完成: {url}) except Exception as e: print(f失败: {url} - {e}) return results if __name__ __main__: urls [fhttps://example.com/news/{i} for i in range(1, 101)] start time.perf_counter() results crawl_serial(urls) print(f串行耗时: {time.perf_counter() - start:.2f}s)跑了100个页面假设每个页面网络耗时0.7秒整体大概70秒左右。这个速度对一次小规模采集还能忍但数量一涨马上就顶不住了。3.2 异步改造步骤改造的核心步骤有三处。第一处把requests.get换成aiohttp的session.get第二处把列表循环换成任务列表加asyncio.gather第三处给并发加上信号量限制。import asyncio import aiohttp from bs4 import BeautifulSoup async def fetch_article(session, url, semaphore): async with semaphore: for attempt in range(3): try: timeout aiohttp.ClientTimeout(total15) async with session.get(url, timeouttimeout) as resp: if resp.status 200: html await resp.text() break elif resp.status in (429, 500, 502, 503): # 服务器繁忙或触发限流等待后重试 await asyncio.sleep(2 ** attempt) continue else: return {url: url, error: resp.status} except (aiohttp.ClientConnectionError, asyncio.TimeoutError) as e: if attempt 2: return {url: url, error: str(e)} await asyncio.sleep(2 ** attempt) else: return {url: url, error: 重试三次仍然失败} soup BeautifulSoup(html, html.parser) title soup.select_one(h1.article-title).get_text(stripTrue) pub_time soup.select_one(.article-meta time).get_text(stripTrue) content soup.select_one(.article-content).get_text(stripTrue) return {url: url, title: title, pub_time: pub_time, content: content} async def crawl_async(urls, max_concurrency10): semaphore asyncio.Semaphore(max_concurrency) async with aiohttp.ClientSession() as session: tasks [fetch_article(session, url, semaphore) for url in urls] results await asyncio.gather(*tasks, return_exceptionsFalse) return results if __name__ __main__: urls [fhttps://example.com/news/{i} for i in range(1, 101)] start time.perf_counter() results asyncio.run(crawl_async(urls)) print(f异步耗时: {time.perf_counter() - start:.2f}s)这段代码里有两个细节值得解释。第一个是async with semaphore的位置。它必须包住网络请求和解析的完整过程才能保证任意时刻处于“进行中”的任务数不超过并发上限。如果只包住session.get那一行实际并发数会超过设定值因为解析和重试逻辑没纳入限制。第二个是asyncio.Semaphore的初始值。这里设置10意味着最多同时有10个请求在网络中飞行。对于大部分目标站点这个值温和且安全。如果你确认目标站点性能很好可以调到20甚至30但调整前请务必评估对方服务器的承受能力。注意aiohttp的timeout参数需要传一个aiohttp.ClientTimeout对象而不是简单的数字。以前有人会写成timeout10这在requests里可以在aiohttp里会直接报类型错误。3.3 详细参数选择与耗时对比我实际跑过一组对照同一批50个页面分别用串行requests、并发数为5的异步、并发数为20的异步结果如下。串行请求平均耗时36.4秒并发5平均耗时10.2秒并发20平均耗时7.1秒可以看到从1并发到5并发时提速非常明显接近3.5倍从5到20并发只再提速了1.4倍左右。为什么并发数翻到4倍速度却没有同步翻倍原因有两方面一是单个网络连接的带宽有限20个请求同时下载每个请求分到的带宽下降二是目标服务器同为单机响应速度在高并发下会变慢。这说明一个道理设置并发数存在边际效应递减。更关键的是过高的并发数很容易触发服务器的防爬机制导致大量请求被拒绝反而拖慢整体速度。我踩过一个挺深的坑——某次把并发数调到50去抓一个中型社区网站跑到第800个页面时大量返回403重试逻辑也没来得及兜住最后跑了十来分钟有很大比例的数据没拿全。所以我的建议是初次接触一个站点时从5到10的并发起步跑几十个页面观察响应速度和成功率再逐步调高。这个“试探性并发”的做法比一上来就猛拉并发要稳妥得多。3.4 异步改造时最容易犯的两个错误第一个错误是忘了给任务列表限流。有人会把并发数写成一个列表推导式比如tasks [fetch_article(session, url) for url in urls]然后gather。这样100个URL就会瞬间发出100个请求完全不受控制。正确做法是在fetch函数内部用信号量做限制就像上面代码那样。第二个错误是响应码处理太粗暴。有的代码只判断resp.text()能否正常返回完全不检查HTTP状态码。如果服务器返回404或403HTML内容是“页面不存在”之类的错误页解析结果就是一堆空字段数据质量直接崩盘。合理的做法是遇到非200状态码时先走异常或重试分支而不是闷头解析错误页面。4. 数据清洗从HTML到结构化字典4.1 文本提取与空白规范化抓下来的HTML里有很多标签嵌套直接拿text属性会有大量空白字符、换行符和缩进混在正文里。比如这段HTMLdiv classarticle-content p这是一段内容。 里面有多余空格和换行。/p p第二段也 有干扰。/p /div直接提取的结果是这是一段内容。 里面有多余空格和换行。 第二段也 有干扰。如同一段没有格式的乱字。我的处理方式是先用get_text()拿到原始文本再统一做空白规范化。import re def clean_text(raw): # 先减少连续空白 text re.sub(r\s, , raw) # 再去除前后空白 return text.strip()这个正则的关键在于\s会匹配所有空白字符包括空格、换行、制表符统一替换成单个空格。配合strip()去掉两端多余空白就能得到比较干净的文本。如果你还想保留段落结构就不要这么干。有时候文章需要保留分段那处理逻辑会换成先把p、br等块级标签替换成换行符再清理空白。def clean_text_keep_paragraph(raw): text re.sub(rbr\s*/?, \n, raw) text re.sub(r/p, \n, text) text re.sub(r[^], , text) text re.sub(r\n{3,}, \n\n, text) return text.strip()注意这个版本的实现逻辑先把br和/p转成换行符再去除剩余标签最后把三个以上的连续换行合并成两个。做内容聚合时这个保留段落的处理方式更实用后续做摘要生成或全文搜索时不会因为丢段落而丢失语义。4.2 日期与发布时间解析提取发布时间的时候网页上的文本可能是中文格式比如“2025年1月15日”也可能是英文格式“2025-01-15”或者更复杂的“2025年1月15日 14:30”。我的习惯是收集了原始字符串之后统一用dateutil库做解析它能识别绝大多数常见自然语言日期格式。from dateutil import parser def parse_pub_time(raw, timezoneNone): try: dt parser.parse(raw) if dt.tzinfo is None and timezone is not None: dt dt.replace(tzinfotimezone) return dt except Exception: return None一个容易忽略的问题是时区处理。如果页面没有明确给出时区信息而被抓取站点的服务器又不在你所在的时区直接用本地时间解析会出现偏差。稳妥的做法是规范时间内统一保存成UTC时间展示时再转成目标时区。或者干脆在入库前显式带上时区信息。4.3 清理HTML中的残余标签与脚本有时候手动提取的正文区域里还残留着script、style之类的内容。这些标签内部通常包含大量无用代码严重影响数据质量。最简单的办法是把这些块级标签连同其内容一起删除再去做文本提取。from bs4 import BeautifulSoup def clean_html_block(raw_html): soup BeautifulSoup(raw_html, html.parser) for tag in soup([script, style, noscript]): tag.decompose() return soupdecompose方法会从解析树中彻底移除该节点及其所有子节点而不是隐藏它。这个操作做完之后再get_text数据会干净很多。另外一个常见的脏数据来源是HTML实体字符比如nbsp;、amp;。BeautifulSoup在解析HTML时会自动把实体转换成对应的Unicode字符但如果你用的是正则表达式直接处理HTML字符串这些实体就不会被替换。所以解析的时候尽量交给BeautifulSoup而不是纯正则。4.4 结构化输出的设计清洗完成后我习惯把这些数据整理成一个统一的字典结构方便后续写CSV、入数据库或者做进一步分析。一条标准化的文章记录大致是{ url: https://example.com/news/123, title: 文章标题, pub_time: datetime.datetime(2025, 1, 15, 14, 30), author: 作者名, summary: 摘要或开头一句话, content: 清洗后的正文文本, tags: [标签1, 标签2], crawl_time: datetime.datetime.utcnow() }这个结构的核心价值在于保留了来源URL和爬取时间。后续做数据更新、去重和溯源时这两个字段几乎是必需品。没有它们数据出问题想回头排查都困难。5. 常见问题排查与反爬避坑5.1 异步事件循环中的编码检测异步抓取中编码问题比同步版本更容易被忽略因为你不是在处理完响应后才看到文本的。aiohttp的resp.text()默认会尝试用HTTP头声明的编码解码但如果服务器没有正确声明编码默认的解码结果就是乱码。解决方案是在拿到响应后手动读取字节流并做编码检测import chardet async def fetch_text(session, url): async with session.get(url) as resp: raw await resp.read() encoding chardet.detect(raw)[encoding] or utf-8 try: text raw.decode(encoding) except (UnicodeDecodeError, LookupError): text raw.decode(utf-8, errorsreplace) return text注意chardet.detect是一次范式的探测对短文本非常不准但对于整个HTML页面这种几KB乃至几百KB的长内容识别率较高。用它之前最好确认目标页面不是动态渲染的空壳HTML否则检测结果没有参考价值。5.2 SSL证书错误与自定义CA有些内网或测试环境的目标站点的SSL证书不受信任aiohttp会默认验证证书并抛错。如果你确认这个站点是可信的可以通过ssl参数关闭证书验证。但注意关掉证书验证意味着放弃了加密通道的安全性校验因此这个配置要非常谨慎只应该在边界服务调试时使用。import ssl ssl_context ssl.create_default_context() ssl_context.check_hostname False ssl_context.verify_mode ssl.CERT_NONE async with aiohttp.ClientSession(connectoraiohttp.TCPConnector(sslssl_context)) as session: # ...实际上更推荐的做法是用自定义CA证书。如果目标站点的证书是企业内部CA签发的正确操作是把这个CA证书的公开部分安装到操作系统的受信任证书库中或者通过CAPath指定证书文件路径。这样既保留了证书校验又解决了信任问题。5.3 被限制后的处理策略看着HTTP 403或者“你的访问有异常”这类页面时不要急着加代理或换IP。首先检查自己是不是违反了基本的抓取礼仪。高频率请求、低质量User-Agent、缺少必要的Referer或Cookie都可能触发基础的防护机制。合理的反限制策略是分层级的。第一层是降低并发数把5秒内发出的请求数控制在个位数第二层是设置合适的User-Agent尽量模拟真实浏览器的UA字符串第三层是对关键页面加上随机延时比如0.5到1.5秒的随机等待。这三层都做到位后绝大多数小型站点就够用了。更激进的方案比如使用代理池那是另一个话题普遍涉及法律和合规风险不是这篇要展开的内容。5.4 常见异常速查表整理一份我在实际抓取中遇到的高频问题以及排查思路所有请求都返回403大概率是UA和请求头太简单先补充浏览器UA部分请求超时网络波动或目标服务器负载高检查超时时间设置考虑降低并发出现大量TCP连接错误本地文件描述符耗尽检查文件句柄上限或者降低并发数解析结果全是空白多半是页面本身是JS动态渲染需要改用无头浏览器方案请求特别慢但成功率正常确认不是被限速了尝试加延时观察是否缓解响应文本乱码优先用chardet重新检测编码不要依赖HTTP头5.5 合规与边界意识这里要专门多说一句因为这是很多入行不久的朋友容易忽略的。抓取公开信息本身没有天然问题但必须尊重网站的robots协议控制抓取频率不触碰需要登录或授权的内容。尤其要避免对目标服务器造成过大访问压力那不仅可能导致你的IP被拉黑更可能影响其他人正常访问站点。从业以来我见过太多人一上来就把并发拉到上百目标网站直接被拖挂最后连正常的浏览器访问都进不去。等到服务器管理员介入发布禁令整个IP段就被封了。得不偿失。在动手抓任何站点之前先花几分钟看看对方的robots.txt和页面底部的版权声明。这个习惯既不花钱又不费事却能让你的抓取行为在合法合规的框架内运行。就算你是做信息分析类的工作这个前置检查流程也不能省略。个人实际使用下来还有一个让我反复回味的细节并发抓取的程序往往不是在效率上出问题而是在稳定性上出问题。一个页面解析失败、一条数据编码乱掉都可能让整批数据产出废品。所以做抓取项目时我为每个阶段都加了日志输出包括每次请求的开始、完成、重试、失败这样后续排查问题时能快速定位到具体哪个URL出了问题。如果你也有抓取数据的需求强烈建议从一开始就养成记录日志的习惯这个习惯能省下后期大量的调试时间。