
为了给一份城市居住环境评估报告供数我需要在短时间内拿到全市两千多个小区的基础信息名称、地址、建成年代、容积率、绿化率、物业费、总户数、停车位甚至还要附带周边两公里内的学校、医院和地铁站。这些数据分散在好几个网页栏目里手动去复制粘贴根本不可能。最开始我用requests写了一个串行脚本一个页面一个页面去请求跑到一半就被对方服务器限流后来换了代理也没用IP反而进了黑名单。反复折腾了几天我索性把整个采集系统推翻重来改用asyncio aiohttp做异步下载再在最外层加了一层“智能解析”来兼容不同页面结构的差异。重写之后的系统40分钟就跑完了原先预估一整天的采集量更重要的是最后人工抽检的数据完整率从83%升到了97%。这篇文章不讲那些教科书里都有的大道理只把我在这个项目里的选型判断、代码结构、踩过的坑和调优思路完整拆出来希望正在做同类信息采集的朋友能少走几步弯路。1. 为什么现代小区信息采集必须抛弃requestsBFS而转向异步协程1.1 小区信息采集是一个被网络等待主导的IO密集场景先说一个容易被忽略的事实我们采集小区信息真正花在“解析HTML”上的CPU时间可能连1%都不到剩下99%的时间都在等待网络响应。一个小区详情页从发起请求到拿到完整HTML平均要300到500毫秒这个时间主要由网络延迟和服务器处理速度决定本地代码再优化也无法缩短。如果按照传统的同步写法一个页面等完再请求下一个两千个页面至少需要10分钟以上的纯请求时间中间再遇上几次超时重试总耗时直接翻倍。所以这个场景天然是IO密集型的。所谓“异步”本质上就是把这个等待时间利用起来当一个请求在等待网络响应时CPU并不闲着而是立刻切换到另一个请求去继续发起连接。上面提到的场景里一个服务员同时服务多桌客人每桌点完菜后不用站在原地等菜而是先去下一桌记录需求等到后厨出菜时再端着菜去找对应桌号。异步爬虫里的连接、等待、收包就是那一盘盘菜品。1.2 多线程、多进程和异步协程之间的取舍很多新手第一反应是等待IO不是可以开线程池吗确实可以但你需要想清楚三个层面的差异。多线程Python的GIL使得多线程在CPU密集场景下几乎是摆设但在IO密集场景下线程也会在阻塞时释放GIL所以线程池也能提升并发。问题是线程的创建和切换开销比较大线程栈默认占用内存以MB级别计算想在单进程内维持上千并发线程内存会先爆掉。另外线程之间共享变量还需要锁处理起来很繁琐。多进程绕开了GIL但进程创建开销更大进程间通信也需要额外设计比如管道、套接字对IO等待的利用率和线程差不多但资源占用量高出一个量级。异步协程协程本质上是在同一个线程内进行用户态任务切换每次切换的开销比线程小一两个数量级单进程轻松维持上万连接。配合aiohttp的非阻塞IO代码写起来是线性的async/await风格维护成本比回调函数低得多。做一个直观对比我在实际项目里测试过三种方案的资源占用与完成时间目标是一千个小区详情页网络环境正常每个页面耗时时长为300ms左右方案并发数量500个页面平均耗时占用内存代码复杂度threading线程池50线程约10s约120MB一般需要线程池管理multiprocessing进程池10进程约15s约300MB一般需要传参和收结果asyncio协程100协程约3.5s约60MB低单线程无锁这个测试虽然不够严谨但很能说明问题异步协程在IO密集场景里的吞吐量和资源效率都明显更优这也是我最终选择重写为异步方案的根本原因。1.3 asyncio aiohttp的最小可运行骨架在重写前我先把一个最小可运行的异步请求骨架在本地验证通过再往上面叠加业务逻辑。核心代码非常简单。import asyncio import aiohttp async def fetch(session, url): async with session.get(url, timeoutaiohttp.ClientTimeout(total10)) as resp: return await resp.text() async def main(): # 这里用空白URL占位实际换成目标站点的小区详情URL urls [fhttps://example.com/community/{i} for i in range(100)] async with aiohttp.ClientSession() as session: tasks [fetch(session, url) for url in urls] # return_exceptionsTrue 可以保证一个请求失败不影响其他任务 htmls await asyncio.gather(*tasks, return_exceptionsTrue) asyncio.run(main())这里有三个细节值得解释aiohttp.ClientTimeout(total10)给每个请求加了10秒超时避免某个挂死的连接拖慢整个队列。asyncio.gather的return_exceptionsTrue让个别请求报错时不会中断主流程而是把异常对象放到结果列表里稍后统一处理。asyncio.run作为入口是Python 3.7以后的标准写法不需要手动管理事件循环的开闭。这个骨架跑通后接下来的问题就变成拿到HTML之后怎么把字段准确提出来这就轮到“智能解析”出场了。2. 从URL规则到页面解析智能解析不是套XPath那么简单2.1 先做站点结构分析再决定切“静态”还是“动态”面对一个目标站点我习惯先花半小时看它的URL规律和网页加载方式。小区信息类站点通常有两种形态老式站点详情页是完全的静态HTMLURL带有城市代码和小区ID直接请求详情页URL就能拿到完整页面。我可以直接用XPath/CSS选择器提取字段。现代站点列表页和部分详情页是前端渲染的HTML源码里只有一堆script标签真实数据是通过异步接口返回的JSON。这里就需要通过浏览器开发者工具里的“网络”面板去分析XHR请求找到真正返回数据的API地址。例如某站点的小区详情页HTML里并不是直接渲染物业费而是先调用了一个/api/community/detail?id123的JSON接口返回结构如下{ communityId: 123, name: 锦绣花园, buildYear: 2010, volumeRate: 2.5, greeningRate: 35, propertyFee: 2.8, totalHouseholds: 1200, parkingSpaces: 900, address: 某市某某区某某路88号, subway: [地铁2号线某某站] }如果我还在用requests去解析HTML不仅拿不到这些字段还会被页面里的大量无意义标签干扰。而直接请求JSON接口数据已经是结构化好的解析成本几乎为零。这也是很多“现代站点”和“老式站点”在爬虫工作量上的核心差别。2.2 从正则、XPath到“智能解析”的演进以前我写解析代码最常用的就是XPath硬编码比如//div[classprop-price]/span/text()。这种代码写起来很快但有个致命问题只要对方前端工程师把class名换个单词整个字段提取逻辑就废了。尤其是小区这类多字段页面十个字段里只要有两三个失效数据质量就崩塌。所以我尝试引入“智能解析”的概念。这里的智能并不是说一定要上深度模型而是一种多策略自动降级的解析策略。我的做法是对每个字段定义一组候选提取器按优先级依次尝试直到取出合理值为止。优先级顺序大致如下JSON-LD结构化数据现在很多站点会在script typeapplication/ldjson里嵌入页面的结构化数据包含名称、地址、日期等这是最干净的信息。meta标签有些页面在meta namedescription content...里汇总了关键词例如物业费、容积率都可能出现在描述文本里。常见DOM容器通过dl/dt/dd或table/tr/td这类语义化标签把“标签名”和“值”配对提取。属性词正则兜底如果上面都没有再用正则去全文匹配像“物业费[:]\s*([\d.])元”这样的模式。下面是一个简化的解析器骨架import re import json from bs4 import BeautifulSoup class SmartParser: def __init__(self, html): self.soup BeautifulSoup(html, html.parser) self.text self.soup.get_text(\n) def _extract_json_ld(self): data_list [] for script in self.soup.find_all(script, typeapplication/ldjson): try: data_list.append(json.loads(script.get_text())) except json.JSONDecodeError: pass return data_list def _extract_meta(self, key): tag self.soup.find(meta, attrs{name: re.compile(key, re.I)}) if tag and tag.get(content): return tag[content].strip() return None def parse_field(self, field_name): # 按优先级依次尝试 # 1. JSON-LD for data in self._extract_json_ld(): if isinstance(data, dict) and field_name in data: return data[field_name] # 2. meta meta_value self._extract_meta(field_name) if meta_value: return meta_value # 3. dt/dd key-value dt_list self.soup.find_all(dt) for dt in dt_list: if field_name in dt.get_text(): dd dt.find_next_sibling(dd) if dd: return dd.get_text(stripTrue) # 4. 正则兜底 pattern rf{field_name}[:]\s*([\d.]) match re.search(pattern, self.text) if match: return match.group(1) return None虽然代码不复杂但它在页面改版时的容错能力比单纯的XPath硬编码高得多。如果JSON-LD可用几乎所有字段都能命中如果JSON被移除meta和dt/dd还能接着扛。真正全都不行时才需要人工看页面结构重新适配。2.3 字段归一化与数据清洗解析拿到原始文本后还有一个容易忽略的环节字段归一化。比如物业费“2.5元/㎡/月”“2.5元/平/月”“每月每平米2.5元”其实代表同一个数值。如果直接存字符串后续做统计分析时根本没法用。所以我在解析器后面加了一层字段清洗函数做三件事抽取出数字用正则把所有字段里的第一个有效浮点数抽出来再结合单位判断是否要做倍率转换。统一单位容积率通常是数字“绿化率”可能是百分数“35%”统一转成小数或百分数字符串入库时固定为0.35。缺失值标记如果字段实在解析不出来不直接存None而是存一个特殊标记-1便于后续做数据质量统计同时避免把“缺失”和“数值0”混淆。3. 采集系统核心模块设计与代码落地3.1 异步任务调度器与信号量限流有了异步请求骨架最重要的事情是对并发量做限制。如果不控制并发一上来就把两千个请求同时打过去对方的防火墙大概率会立刻把IP封掉而且本机TCP连接数也会爆。所以我在事件循环里维护了一个asyncio.Semaphore信号量把它当作“协程令牌”只有拿到令牌的请求才能发出。任务调度采用经典的生产者-消费者模型生产者负责发现详情页URL放入asyncio.Queue消费者从队列里取出URL发起请求、解析、落库。import asyncio import aiohttp async def worker(session, queue, semaphore): while True: url await queue.get() async with semaphore: try: html await fetch(session, url) # fetch里已有超时 await parse_and_save(html) except Exception as exc: # 记录失败URL后续单独处理 await log_failed_url(url, exc) finally: queue.task_done() async def main(): queue asyncio.Queue() semaphore asyncio.Semaphore(50) # 往queue里放入初始列表页URL async with aiohttp.ClientSession() as session: workers [asyncio.create_task(worker(session, queue, semaphore)) for _ in range(20)] # 生产者代码略 await queue.join() # 等所有任务完成 for w in workers: w.cancel()这里我开了20个worker协程每个协程循环从队列拿URL。信号量设置为50所以全局并发限制在50左右这是一个比较保守但不容易触发反爬的频率。需要说明的是worker数量和信号量大小不一定相等worker只是消费者实例数真正决定瞬时请求上限的是信号量。3.2 可插拔的智能解析器接口因为目标站点可能不止一个或者同一站点存在PC版和WAP版两种页面结构解析逻辑也千差万别。我在代码里设计了一个简单但可扩展的解析器接口每种页面格式实现一个子类上层根据URL特征或页面指纹自动选择对应解析器。class BaseParser: def __init__(self, html, url): self.html html self.url url self.soup BeautifulSoup(html, html.parser) def parse(self) - dict: raise NotImplementedError class JsonLdParser(BaseParser): 优先从JSON-LD提取字段 def parse(self): return smart_fields(self.html) class WapPageParser(BaseParser): 适配WAP版页面的解析器 def parse(self): # 略使用不同的XPath规则 pass PARSER_REGISTRY [ (lambda url: community_id in url, JsonLdParser), (lambda url: mobile in url, WapPageParser), ] def get_parser(html, url): for match, parser_cls in PARSER_REGISTRY: if match(url): return parser_cls(html, url) return BaseParser(html, url)这种设计在后期维护时会非常舒服。发现某个URL规则改版只需要修改对应的解析器类不需要动其他模块。3.3 持久层设计SQLite还是MySQL对于两万条以下的小区数据我强烈推荐SQLite理由有几点不需要单独部署数据库服务启动快备份简单。Python内置sqlite3模块就能用减少依赖。写入速度完全够用采集瓶颈在网络而不在数据库。但要注意的是SQLite的写入是全局锁多个协程并发写入会报“database is locked”。我的处理方式是不在协程里实时写库而是把解析结果放进一个asyncio.Queue由单独的落库协程批量执行INSERT OR REPLACE事务。CREATE TABLE IF NOT EXISTS community_info ( community_id INTEGER PRIMARY KEY, name TEXT, address TEXT, build_year INTEGER, volume_rate REAL, greening_rate REAL, property_fee REAL, total_households INTEGER, parking_spaces INTEGER, subway TEXT, crawl_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP );唯一键设为community_id这样即便同一小区被多次爬取也只会更新记录不会产生重复行。对于更大型的分布式爬虫可以考虑MySQL/PostgreSQL但现代小区信息采集这个体量SQLite足够。3.4 增量更新与断点续爬数据爬完一次之后后续需要每日增量更新部分小区信息比如物业费变动、周边地铁新开通站点。如果每次都全量重爬既浪费资源又容易触发反爬。我的方案是维护一个crawl_record表记录每个小区最近一次爬取时间。在生产者生成URL时先查库中该小区数据的最近更新时间如果距今天数小于7天则跳过。如果某次采集因为程序崩溃或网络中断终止我会把尚未成功的URL保存到failed_urls表下次启动时自动读取并优先重新放入队列。断点续爬的思路很简单异步爬虫的执行顺序不是固定的所以不能依赖“执行到第几个URL”必须在URL级别记录状态。每次请求前先标记为“进行中”完成后再标记为“成功”异常则保留为“失败”并记录错误信息。4. 实测中最容易翻车的四个细节与排查思路4.1 并发数不是越大越好一次压测引发的90秒超时排查我一开始很兴奋地把信号量调到了500想着两千个页面分四批就能跑完。结果程序跑了不到10秒日志里就开始刷ConnectTimeout随后是大量的ConnectionResetError最后整个事件循环几乎卡死。我当时的排查链路是这样的先看对方服务器没有响应把超时时间调长一点发现超时是客户端自己连不上。curl手动请求同一个URL正常返回说明服务端没有挂。检查本机连接数发现默认的临时端口范围不够用大量连接处于SYN_SENT状态。把并发数从500降到100问题依然偶尔出现再降到50稳定了。后来我做了个对比实验用不同并发数跑同一批2000个小区URL记录成功率信号量并发数总耗时失败次数备注500突然大量超时200客户端端口资源耗尽200约12分钟34有零星超时需重试100约16分钟6成功率尚可偶尔触发限流50约21分钟0稳定且不会影响其他业务最终我选定了100并发配合每请求成功后随机等待0.1-0.3秒让QPS维持在300左右。这里也印证了一个老生常谈的道理并发数是调出来的不是拍脑袋定出来的。不同目标服务器的防护阈值不同只要看到有规律的请求失败先降并发再谈加密和伪装。4.2 反爬检测的边界试探Cookie失效、请求头伪装与随机延迟小区信息站点虽然不像电商平台那么激进但也会有基础的反爬措施。我遇到的典型情况是请求连续成功一段时间后返回的HTML内容变成了一段JavaScript挑战脚本或者接口返回{code: -1, msg: request too often}。排查的时候我先把自己的请求头里的User-Agent换成浏览器真实的UA结果仍然触发。接着我把Accept-Language、Referer也都补上依旧偶尔失败。最后发现是同一个IP单位时间内的请求次数超过阈值而我的本地IP又是共享出去的公网IP其他同事也在访问该网站导致我“替别人挨了刀”。针对这种情况我采用的组合拳是准备一个包含常见UA的列表每次请求随机取一个。每次请求后随机延迟0.2~0.5秒防止请求间隔过于均匀。如果返回内容长度小于某个阈值例如500字节判定为反爬页面丢弃该次请求并等待5秒后重试。连续失败超过3次就不再重试该URL记录下来留给下一轮。我并不推荐一上来就上IP代理池因为免费代理的稳定性极差质量参差不齐甚至可能连接的是毒IP。对现代小区这样的低强度反爬目标控制频率和正常UA就够了。4.3 智能解析在页面改版后的容错降级这是我这个项目里最有成就感的一次调试。某天跑增量更新时发现property_fee字段大部分变成了-1。我没有先去改XPath而是按照下面两步定位先随机抽三个URL手动在浏览器打开看页面是否正常。页面是正常显示的。再用脚本拉取其中一个页面的HTML源码搜索“物业费”三个字结果只在最底部的注释里出现了一次实际数值被改成了从接口异步加载。也就是说对方把详情页的静态字段改成了动态渲染。我的“智能解析器”在JSON-LD和meta里都没找到字段正则兜底也没匹配到“物业费”这个字段就丢失了。解决方案不是去补一个XPath而是修改解析器的策略如果发现字段大面积缺失就自动触发“降级模式”。在降级模式下解析器会重新分析页面源码中的script标签查找里面是否包含window.__DATA__ {...}这种全局变量的赋值把这些变量解析成JSON后提取字段。这样写的原因很朴素动态渲染意味着数据一定会在某一段JavaScript里被绑定到页面而这个绑定点通常暴露在script里。最终我在解析器里增加了一个_extract_js_variable方法在字段解析失败时才调用不影响正常页面的解析速度。4.4 日志与进度监控别等跑完才发现数据全错异步爬虫的一个麻烦是执行顺序是乱的无法通过打印输出来判断进度。我一开始没写日志等到跑完回头一看数据表里只有600条另外1400条都因为各种异常被gather“吞”了但什么错误信息都没留下。从那次以后我在代码里强制加了两个东西一个logging配置按时间戳输出到文件内容包括URL、当前并发数、请求耗时、解析字段数、异常堆栈。一个进度统计器每成功或失败一条就更新计数并每5秒打印一次“已完成/总数/成功率”。用一段简单的装饰器统计请求耗时import time from functools import wraps def log_request(func): wraps(func) async def wrapper(*args, **kwargs): start time.perf_counter() try: result await func(*args, **kwargs) cost time.perf_counter() - start logging.info(fOK {cost:.2f}s {kwargs.get(url, )}) return result except Exception as exc: cost time.perf_counter() - start logging.error(fERR {cost:.2f}s {kwargs.get(url, )} {exc}) raise return wrapper在调试阶段日志里能看到每一个请求的耗时哪些URL有超时趋势一目了然等到后期稳定运行时我又把INFO级别日志关掉只保留ERROR减少磁盘写入。5. 最后的优化空间与个人经验沉淀5.1 分布式化不是银弹什么时候才需要引入消息队列如果小区覆盖面从一座城市扩大到全国几百座城市单机异步爬虫可能会面临两个问题一是单机出口IP的请求频率很容易触发反爬二是单机带宽和内存成为瓶颈。这时才需要引入分布式爬虫把采集任务下发到多台机器上执行。分布式架构常用的做法是引入Redis作为任务队列把URL放到Redis的LPUSH/LPOP队列中由多个爬虫节点消费。再配合Redis的去重集合保证不同节点不会重复采集同一个URL。但如果你只是采集一个中等城市的小区信息单机异步方案完全足够。分布式会引入额外的运维复杂度Redis、部署、监控、任务调度每一项都不是白送的。我个人的判断标准是目标URL数量低于10万且请求频率在几百QPS以内不要上分布式。5.2 数据质量评估与去重策略采集完成不等于数据可用。我在整个项目结束后做了一次数据质量评估随机抽取了100条记录人工比对小区官方页面统计了各字段的缺失率和错误率字段缺失率错误率物业费2%0.5%容积率3%1%开业年份/建成年代1.5%0.8%总户数8%3%总户数缺失率偏高的原因很明显很多小区并不在详情页公开总户数解析器自然取不到。对于这些缺失字段我单独建了一个incomplete_data表在后续增量采集中着重补充。去重策略除了依靠community_id主键还在写入前检查了“名称地址”这个业务唯一键因为有些站点的详情页URL会携带追踪参数导致同一个小区被认成两个ID。5.3 我对“智能解析”的重新定义经过这个项目我越来越觉得“智能解析”这个词被过度神化了。很多人一听说智能就想到机器学习、训练模型、自动识别页面。但在真实业务里最能解决存量问题的方案往往是“规则多策略降级可插拔变更”这比强行上一个深度学习模型要实用得多。我在这个项目里用的“智能解析”本质上就是一种工程化的兜底思维假设任何单一解析手段都会失效所以预先准备多条路径当一条路径断了自动切换到下一条。这与“人工智能”无关却能在页面改版时为我争取至少一周的缓冲时间。当然如果后续页面的结构变得更加复杂比如所有字段都通过接口动态下发且带签名校验那么规则解析的尽头就必须引入浏览器自动化或更底层的接口逆向。但到那时候我也会认真评估采集行为的合法边界以及是否值得为这些数据承担额外的技术和合规风险。做爬虫控制风险和控制并发同样重要。