简介:本资源是一款面向Python爬虫学习者与数据采集工程师的58同城全站信息抓取框架源码,聚焦房产、招聘、二手车、二手交易等多类垂直信息的自动化采集,解决人工收集效率低、覆盖面窄的问题。压缩包共26个文件(含13个.py源码、10个.pyc字节码、1个.cfg配置文件、1个.txt说明文档及1个.gitignore),总大小仅29KB,轻量紧凑;其中spiders、items、pipelines、settings等模块构成标准Scrapy风格架构,agent_list.py与proxy_test.py体现反反爬策略设计,test58.py提供快速验证入口。目前已有291人学习下载,适合具备Python基础并了解HTTP协议、HTML解析的中级开发者用于实战复现、框架二次开发或反爬机制研究。 做一个能真正支撑“全站级”信息采集的Python爬虫框架,远比写一个能跑通单页的脚本复杂得多。58同城这类分类信息网站,页面结构不像新闻站那么单一,城市分支、行业分类、列表页与详情页的层级关系叠在一起,数据字段又散落在不同板块的不同HTML结构里,如果一上来就写面条式脚本,爬到一半基本就废了。
这篇就把我近期实践的一套“58同城全站爬取信息框架”的核心设计思路和源码拆解开来讲。它不是一个只能抓单个页面的玩具,而是把调度、下载、解析、存储、去重、断点续爬拆成分层模块的可扩展框架。适合正在做分类信息站采集、或者想把爬虫代码从“一次性脚本”升级成“可维护系统”的朋友参考。
1. 项目整体设计与思路拆解
1.1 为什么要做成框架而不是一次性脚本
先说一个很现实的问题:58同城这个站点的数据组织形式,天然就是一棵深度树。
根节点是主站首页,往下分城市(北京、上海、广州……),每个城市下面又分一级分类(房产、招聘、二手车、二手物品、生活服务……),每个一级分类下面还有二级分类(房产-二手房、房产-租房、房产-写字楼……),再到具体列表页,最后才是详情页。全站爬取意味着每一层都要遍历,而这个“遍历”动作必须是有序、可控、可中断后恢复的。
如果只是临时抓某一个城市、某一个分类下的几百条数据,写个requests循环完全够用。但“全站”两个字就把复杂度提升了一个量级:几百个城市、几十个分类交叉组合,涉及成千上万个URL入口。一次性脚本在这个规模下会出现几个致命问题:
第一,无法控制抓取节奏。全站遍历时,如果所有请求挤在一起,极容易触发目标站点的风控机制,导致IP被限制访问。
第二,错误无法隔离。某一个分类页面结构变了,脚本可能直接抛异常退出,前面抓的数据全白干。
第三,无法增量更新。全站数据抓完之后,下一次只需要抓新增和变更的部分,一次性脚本做不到这种状态记录。
所以框架的价值不在于“抓取”本身,而在于把抓取过程拆成可管理、可监控、可恢复的组件。每一层只干一件事,层与层之间用数据结构解耦。这套思路迁移到其他分类信息网站(比如各类本地生活平台、招聘平台)的时候,只需要替换解析层,调度和存储层可以原样复用。
1.2 技术选型与理由
框架底层的技术栈我最终确定的是:requests + parsel + pymongo + Redis,并发用ThreadPoolExecutor。
很多人会问为什么不用Scrapy。Scrapy确实是业界标准,但如果目标不是分布式、不是千万级URL调度,那么在Scrapy和一套轻量自研框架之间,我倾向于后者。原因很简单:Scrapy的组件模型虽然强大,但学习曲线和定制成本都在“中间件”和“管道”的机制里,对于58同城这种页面结构复杂但URL规模可控的场景,自研框架反而更容易控制每一个环节的行为。当然,如果你是团队协作、需要爬虫管理平台、需要对接分布式采集集群,那直接上Scrapy或者基于Scrapy二次开发更合适。
具体到每个组件的选择,我是这么考虑的:
网络请求:用
requests而不是httpx。虽然httpx支持HTTP/2和异步,但58同城PC端的页面响应并不依赖HTTP/2特性,requests的会话管理和重试机制都非常成熟,生态里各种插件也最全。异步方案(aiohttp/httpx)在这个场景下收益不明显,因为瓶颈往往在目标站点的响应速度和服务端限制,而不是本地IO。页面解析:用
parsel,它是Scrapy底层用的解析库,基于lxml,同时支持XPath和CSS选择器。相比直接用lxml,parsel的extract_first()和getall()接口更符合写爬虫的习惯;相比BeautifulSoup,parsel的性能要好不少,在大量列表页解析场景下差距很明显。数据存储:正文数据结构复杂,字段数量和层级在不同分类下都不一样(房源的字段和二手物品的字段完全不同),所以用MongoDB这种文档型数据库,不需要提前定义强Schema,每条数据按自己的结构存即可。如果必须用MySQL,也可以,但要做字段的JSON序列化存储或者建宽表,灵活性差很多。
URL去重与请求指纹:用的是Redis的
SET结构,把所有请求过的URL指纹存进去,利用SADD的原子性判断是否已经请求过。全站URL规模估计在百万级别,Redis内存完全扛得住。如果要更大规模,可以换成布隆过滤器,但当前场景没必要增加这个复杂度。
1.3 整体架构分层
这套框架最终落地的架构是五层:
调度层:负责任务队列管理,维护“城市-分类-列表页-详情页”的遍历状态,产出所有待抓取URL。调度层本身不发起请求。
下载层:负责发起HTTP请求、处理重试、随机延时、请求头管理。职责就是把URL变成HTML文本,不关心内容是什么。
解析层:负责从HTML中提取结构化数据,以及提取下一页链接和详情页链接。这一层是分站点、分板块定制的,也是整个框架中改动最频繁的地方。
存储层:负责数据入库和去重。对外只暴露save(item)和is_duplicate(key)两个接口。
监控层:负责日志记录、统计抓取成功数和失败数,并在异常时输出告警信息。
数据流是单向的:调度层产出URL -> 下载层获取HTML -> 解析层提取数据和新URL -> 存储层持久化 -> 调度层继续遍历新URL。
这种分层有一个很直接的好处:每一层都可以单独测试。我可以不启动抓取,直接喂一个URL给解析层,看解析结果是否正确;也可以不加载解析规则,先测试下载层对目标网站各种状态码的响应处理。这对排错来说效率提升非常明显。
2. 核心细节解析与实操要点
2.1 URL规划与遍历路径设计
“全站爬取”的第一步是梳理URL规则,这步做不好,后面全是空中楼阁。
58同城的URL结构大致可以归纳为三种模式:
城市入口:
- 北京列表页:
https://bj.58.com/ - 上海列表页:
https://sh.58.com/
城市前缀是固定的拼音缩写,理论上只需要维护一份城市代码表,就可以生成所有城市入口URL。但实际有例外,比如一线城市的二手房是https://bj.58.com/ershoufang/,而某些城市可能在主站域名下没有独立子域。所以城市列表不建议用规则硬拼,而是从主站导航页解析出来。
分类入口:
- 北京房产:
https://bj.58.com/ershoufang/ - 北京招聘:
https://bj.58.com/zhaopin/ - 北京二手车:
https://bj.58.com/ershouche/
分类代码相对固定,可以从每个城市首页的导航链接里提取。
列表页与分页:
- 列表页第1页:
https://bj.58.com/ershoufang/ - 列表页第2页:
https://bj.58.com/ershoufang/pn2/ - 列表页第3页:
https://bj.58.com/ershoufang/pn3/
分页规则非常规整,pn{n}就是第n页。但需要注意:部分分类没有分页,或者只展示前50页,后50页需要通过搜索接口才能触达。这些都需要在解析层做兜底判断。
详情页:
- 典型详情页URL:
https://bj.58.com/ershoufang/1234567890x.shtml
详情页URL从列表页解析出来,数据中携带房源唯一编号。
到这一步,调度层的遍历路径就清晰了:
城市列表 -> 城市首页 -> 分类链接 -> 分类列表页 -> 分页处理 -> 详情页URL -> 详情页数据遍历路径必须在调度层维护成状态,而不是临时现算。我设计了一个CrawlTask数据结构,包含URL、任务类型(CITY/CATEGORY/LIST/DETAIL)、所属城市、所属分类、当前重试次数、时间戳。调度层根据任务类型决定下一步动作:解析完列表页后,会把详情页任务和下一页任务同时写回任务队列。
2.2 请求头与访问节奏控制
下载层是整个框架中和目标站点博弈最直接的部分。第一道门槛就是请求头。
我在这套框架里采用了一份“会话级”的请求头池。每次新建会话时,从池子里随机抽取一组User-Agent,并把常用的Accept、Accept-Language、Accept-Encoding设置成与浏览器一致。
USER_AGENTS = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0", ]请求头只是基础,真正决定成败的是访问节奏。这里的原则是:单位时间内的请求数必须低于目标站点正常人类用户的上限。
我用了一个简单的限速器:
class RateLimiter: def __init__(self, min_interval=1.5, max_interval=3.5): self.min_interval = min_interval self.max_interval = max_interval def wait(self): interval = random.uniform(self.min_interval, self.max_interval) time.sleep(interval)每个线程在发起请求前调用limiter.wait(),把两次请求之间的间隔控制在1.5到3.5秒之间随机波动。这个波动很关键:固定间隔比高频请求更容易被识别,因为人类不可能每2.5秒精确地发一次请求。
除了请求间隔,还需要控制单城市的并发数量。我设置了“每城市单线程”策略,即同一时间同一个城市只允许一个线程在跑,不同城市可以并行。这样即使分类很多,实际对目标站点的请求压力也被天然分散了。
2.3 页面解析策略与常见坑位
58同城的列表页HTML结构有一个特点:列表项的数据绝大多数放在li标签里,但不同分类的li内部结构完全不同。房产列表项包含小区名、户型、面积、总价、单价;二手车列表项包含车龄、里程、变速箱类型;招聘列表项包含公司名、薪资、经验要求。
所以解析层不能写一个万能解析器,而是要做“分类注册制”:每个分类对应一个解析器实例。
class ListParser: def parse(self, html): raise NotImplementedError class ErshoufangListParser(ListParser): def parse(self, html): selector = parsel.Selector(text=html) items = [] for li in selector.css("ul.house-list-wrap li"): items.append({ "title": li.css(".title a::text").get(), "url": li.css(".title a::attr(href)").get(), "total_price": li.css(".total-price span::text").get(), "unit_price": li.css(".unit-price span::text").get(), }) return items解析时最容易踩的坑是“取了空值但没报错”。CSS选择器匹配不到元素时,parsel返回的是空列表,get()返回None。如果直接忽略None,会造成字段缺失;但如果一遇到None就报错重试,又会有大量URL白白浪费。我的做法是:详情页关键字段(标题、价格、链接)为None时标记为解析失败,非关键字段为None时记录为缺失字段。
还有个很隐蔽的问题:列表页的详情链接有两种,一种是相对路径(/ershoufang/123.shtml),一种是绝对路径(https://bj.58.com/ershoufang/123.shtml)。入库前必须统一转成绝对路径,否则后续调度层没法用。
from urllib.parse import urljoin def normalize_url(base_url, url): if url.startswith("http"): return url return urljoin(base_url, url)2.4 数据存储设计与去重键选择
存储层我选了MongoDB,但设计时还是有讲究。集合按分类维度拆分:house_ershou存二手房、house_zufang存租房、job_zhaopin存招聘、car_ershou存二手车。这样做的原因是不同分类的查询模式完全不同,按分类拆集合后,后续做数据分析和导出都更干净。
去重键的选择是另一个关键点。58同城详情页URL里的那串数字是信息唯一ID,天然适合当去重键。我设置了两个去重键:url_md5(URL的MD5哈希)和info_id(提取出的数字ID)。前者用于请求层去重,后者用于数据层去重。
为什么数据层去重不用URL?因为同一个信息ID可能在不同时间点生成不同的URL(比如加了跟踪参数),但ID不变。如果只对URL去重,可能出现同一条信息重复入库。
def make_data_key(item): if item.get("info_id"): return f"data:{item['city']}:{item['category']}:{item['info_id']}" return f"data:{item['city']}:{item['category']}:{item['url']}"Redis里维护两套SET:req:done存已经请求过的URL指纹,data:done存已经入库的数据指纹。每次调度层从队列取URL时,先查req:done;每次存储层入库前,先查data:done。
3. 实操过程与核心环节实现
3.1 框架目录结构
最终落地的目录结构是这样的:
spider_framework/ ├── config.py # 全局配置:数据库连接、Redis连接、限速参数 ├── models.py # 数据模型:CrawlTask、Item ├── scheduler/ │ ├── __init__.py │ └── scheduler.py # 任务调度器:任务队列、入队、出队 ├── downloader/ │ ├── __init__.py │ └── downloader.py # HTTP下载器:请求头、重试、限速 ├── parser/ │ ├── __init__.py │ ├── base.py # 解析器基类 │ ├── list_parsers.py # 各分类列表页解析器 │ └── detail_parsers.py # 各分类详情页解析器 ├── storage/ │ ├── __init__.py │ └── mongo_storage.py # MongoDB存储与去重 ├── monitor/ │ ├── __init__.py │ └── logger.py # 日志与统计 └── main.py # 入口这个结构是按“层”划分目录的,每个目录对应一个独立模块。后续如果要增加新的目标站点,只需要新增一个parser包,复用download和storage。
3.2 调度器与任务模型实现
models.py定义了核心的任务数据结构:
from dataclasses import dataclass, field import time @dataclass class CrawlTask: url: str task_type: str # city / category / list / detail city: str = "" category: str = "" retry_count: int = 0 created_at: float = field(default_factory=time.time) extra: dict = field(default_factory=dict)task_type是调度器的判断依据。不同类型的任务产出不同的后续任务:
city任务:解析城市首页,产出category任务category任务:解析分类列表页第一页,产出list任务和detail任务list任务:解析列表页,产出下一页list任务和当前页所有detail任务detail任务:解析详情页,产出最终的Item数据
调度器维护一个线程安全的任务队列和一个进度计数器:
import threading import queue class Scheduler: def __init__(self): self.task_queue = queue.Queue() self.lock = threading.Lock() self.total_count = 0 self.done_count = 0 def add_task(self, task): self.task_queue.put(task) with self.lock: self.total_count += 1 def get_task(self): try: return self.task_queue.get(timeout=1) except queue.Empty: return None def task_done(self): with self.lock: self.done_count += 1 self.task_queue.task_done() def progress(self): with self.lock: return self.done_count, self.total_countmain.py里通过一个线程池来消费任务:
from concurrent.futures import ThreadPoolExecutor def worker(): while True: task = scheduler.get_task() if not task: continue try: html = downloader.fetch(task) if not html: scheduler.task_done() continue new_tasks = dispatcher.dispatch(task, html) for t in new_tasks: scheduler.add_task(t) except Exception as e: logger.error(f"task failed: {task.url}, error: {e}") finally: scheduler.task_done() pool = ThreadPoolExecutor(max_workers=4) for _ in range(4): pool.submit(worker)这里有一个很关键的工程细节:new_tasks里必须包含“下一页”任务。也就是说,只要列表页有下一页,调度器就会一直往下派任务,直到没有下一页为止。这样全站遍历才不会漏。
3.3 下载器的请求与重试实现
下载器直接决定了你能不能在目标站点稳定抓数据。核心逻辑是:
import requests import random import time class Downloader: def __init__(self): self.session = requests.Session() self.retry_count = 3 self.timeout = 10 def fetch(self, task): headers = self._random_headers() for attempt in range(self.retry_count): try: resp = self.session.get( task.url, headers=headers, timeout=self.timeout, allow_redirects=True ) if resp.status_code == 200: resp.encoding = resp.apparent_encoding return resp.text elif resp.status_code in (403, 429): # 风控限制,等待较长时间再重试 time.sleep(30 + random.randint(10, 30)) else: time.sleep(random.uniform(1, 3)) except requests.RequestException: time.sleep(random.uniform(2, 5)) return None有几个细节值得讲:
1. 编码处理。58同城的页面编码是UTF-8,但某些分类或某些老页面可能声明GBK。直接用resp.text偶尔会出现乱码。保险做法是先用apparent_encoding探测,再手动指定。
2. 重定向跟随。列表页和详情页的URL偶尔会跳转到城市首页或登录页。如果发现跳转后的URL和原始URL域名不一致、或者URL变成了登录页面,直接视为抓取失败,不要继续传下去。
3. 会话保持。用同一个requests.Session()实例可以让Cookie在请求间传递。有些列表页第一次访问会种下Cookie,后续请求带上这个Cookie后,详情页的访问成功率会高很多。
3.4 列表页与详情页解析实现
列表页解析器的用法前面已经展示过,这里重点说详情页。
class ErshoufangDetailParser: def parse(self, html, task): selector = parsel.Selector(text=html) info_id = task.url.split("/")[-1].split(".")[0] title = selector.css("h1::text").get("").strip() total_price = selector.xpath("//span[@class='total']/text()").get() unit_price = selector.xpath("//span[@class='unit']/text()").get() info_panel = selector.css("div.house-info-panel ul li") info_dict = {} for li in info_panel: key = li.css("span.label::text").get() value = li.css("span.value::text").get() if key and value: info_dict[key.strip()] = value.strip() item = { "info_id": info_id, "url": task.url, "title": title, "total_price": total_price, "unit_price": unit_price, "info": info_dict, "city": task.city, "category": task.category, "crawled_at": time.time(), } return item二手房详情页的房屋信息是一个键值对列表(小区名称、建造年份、楼层、朝向等),但不同房源的字段数量不一致,有的房源没有配套信息。这里直接统一放进info字段,将来需要做数据分析时再从中提取具体指标,这是文档型数据库比较舒服的用法。
另一种需要处理的页面是“信息已过期”或“信息已删除”的详情页。58同城的房源下架后,会返回一个提示“房源信息已删除”的页面。这种页面解析出来标题为空、价格为空,需要在解析阶段就识别并丢弃,避免把垃圾数据存进库里。
if not title or not total_price: return None3.5 存储与去重实现
MongoDB存储层实现如下:
import hashlib import pymongo import redis class MongoStorage: def __init__(self, mongo_uri, db_name, redis_uri): self.client = pymongo.MongoClient(mongo_uri) self.db = self.client[db_name] self.redis = redis.Redis.from_url(redis_uri) def is_url_done(self, url): url_key = "req:" + hashlib.md5(url.encode()).hexdigest() return self.redis.sadd("req:done", url_key) == 0 def save(self, item): if not item: return False data_key = f"data:{item['city']}:{item['category']}:{item['info_id']}" if self.redis.sadd("data:done", data_key) == 0: return False collection_name = self._collection_name(item["category"]) self.db[collection_name].update_one( {"_id": item["info_id"]}, {"$set": item}, upsert=True ) return True def _collection_name(self, category): mapping = { "ershoufang": "house_ershou", "zufang": "house_zufang", "zhaopin": "job_zhaopin", "ershouche": "car_ershou", } return mapping.get(category, "other")update_one配合upsert=True可以实现“有则更新、无则插入”。这在增量抓取场景下非常有用:同一套房源下次再被抓到,不会重复插入,而是更新价格、状态等变化字段。
去重逻辑放在入库前,避免重复的解析和存储开销。但这里有一个权衡点:如果去重太严格,会导致某些URL因为网络波动第一次没抓到详情,第二次再请求时发现URL已在req:done里,就直接跳过,造成数据永久缺失。所以我将“请求去重”和“入库去重”分开:请求去重在下载前检查,但只对“已经成功返回200且已经完成解析”的URL标记完成;请求失败或者解析失败的URL不会被标记,保证了重试机会。
4. 常见问题与排查技巧实录
4.1 高频访问触发风控怎么办
实际抓取过程中,最容易遇到的现象是:刚开始跑的时候一切正常,跑了半小时后突然大量请求返回403或者跳转验证码页面。
排查时要先区分是单IP触发风控,还是被识别为异常流量。判断方法是看报错比例。如果某个时刻开始,所有请求的返回状态全部变成403,基本可以确定是该出口IP被限制了。此时继续跑没有任何意义,只会加重限制。
我的应对策略是:在下载器里对403/429状态码做特殊计数,连续触发3次就自动暂停该城市的所有任务,暂停时间从30分钟起步,之后逐步递增。同时,在合规前提下降低抓取并发数、拉大请求间隔,让流量特征更接近人工浏览。
注意:任何形式的采集行为都要遵守目标网站的服务条款和相关法律法规。不要尝试绕过网站的风控机制。合理限速、控制频率、在允许范围内抓取公开信息,才是可持续的做法。
另外,本地调试时不要直接从列表页第1页扫到第100页。先抓一页,确认解析结果正确后再放量,这样能减少很多无效请求。
4.2 列表页结构与分类不一致
这是做全站爬取时最头疼的问题。58同城有数十个一级分类,每个分类的列表页HTML结构都不同,甚至同一个分类在不同城市也可能出现结构微调。
比如二手房列表页的数据在ul.house-list-wrap li里,但租房列表页的数据在div.house-cell里,二手车列表页的数据则在ul.listUl li里。如果一个万能解析器在所有分类上跑,结果就是大部分分类抓不到数据。
解决思路是“解析器注册表”模式:
PARSER_REGISTRY = { "ershoufang": ErshoufangListParser, "zufang": ZufangListParser, "ershouche": ErshoucheListParser, "zhaopin": ZhaopinListParser, } def get_parser(category): parser_cls = PARSER_REGISTRY.get(category) if not parser_cls: raise ValueError(f"no parser for category: {category}") return parser_cls()新增一个分类时,只需要实现对应的解析器并在注册表里登记,调度层和存储层不用改一行代码。这就是分层架构的好处。
结构不一致的问题还会出现在详情页。有些房源标题在h1里,有些在div.house-title里。我的做法是:在解析器里编写多组候选选择器,按优先级依次尝试,直到命中非空值。
def extract_first(selector, candidates): for css in candidates: value = selector.css(css).get() if value: return value.strip() return None4.3 抓取中断如何断点续爬
全站爬取通常会跑很长时间,中途断电、程序崩溃、网络中断都可能导致进程退出。如果没有状态保存,重启后就要从头开始,代价很大。
我在这套框架里把任务状态保存在Redis里,所以天然支持断点续爬。具体实现是:任务在入队前先写入task:queue队列的源头——一个备份的Redis列表,任务被成功处理后才从列表里移除。重启时,扫描这个列表,把剩余任务重新放回内存队列。
还有一种更简单的方案:每处理完100个任务,就把done_count写入一个本地文件或Redis键。重启后读取这个计数,虽然不能精确恢复到每个任务的粒度,但至少能知道大概进度。如果再配合前面说的“请求去重”,即使任务重复执行,也不会重复入库,所以断点续爬其实是一个软性需求,主要价值在于减少重启后的无谓请求。
4.4 日志与监控怎么做
爬虫跑起来之后,最怕的不是出错,而是出错了你不知道。
我在这套框架里加了一个轻量级的统计模块:每抓完100个详情页就打印一次进度,内容包括:已抓取详情页数、列表页数、当前队列积压数、失败数、平均响应时间。
class ProgressMonitor: def __init__(self, interval=100): self.count = 0 self.fail_count = 0 self.interval = interval self.start_time = time.time() def success(self): self.count += 1 if self.count % self.interval == 0: elapsed = time.time() - self.start_time rate = self.count / elapsed if elapsed > 0 else 0 logger.info( f"progress: success={self.count}, fail={self.fail_count}, " f"rate={rate:.2f}/s, elapsed={elapsed:.0f}s" ) def fail(self): self.fail_count += 1日志千万不要什么都打。请求成功不用打日志,因为数量太大会刷屏;但请求失败一定要打,并且要带上URL、状态码、异常类型。我见过不少爬虫工程的问题,就是因为日志只打了“request failed”却没打URL,最后只能靠猜去定位问题。
还有一个很实用的监控技巧:实时监控MongoDB里的数据总量。如果跑了一个小时数据条数纹丝不动,基本可以断定解析链路出了问题,这个时候直接去看最近有没有解析成功的日志记录即可。
5. 后续可以怎么扩展
这套框架目前跑的是58同城,但架构本身是通用的。我实际测试过,换一个分类信息站只需要做三件事:写一份新的城市入口URL规则、实现对应的列表页解析器和详情页解析器、修改存储层的分类映射。调度和下载完全不用动。
如果后续数据量上来了,建议做两件事:
第一,引入消息队列。当任务量超过单机内存队列的承受能力时,把调度器的任务队列替换成RabbitMQ或Kafka,这样天然支持多机分布式抓取。
第二,解析规则配置化。把解析器的选择器抽成JSON配置,存到数据库里,这样改解析规则不需要改代码、不需要重启进程。对于维护成本来说,这个改进是立竿见影的。
最后再说一个体会:爬虫项目最需要投入精力的地方,往往不是写代码本身,而是“发现规则变化”和“处理边界情况”。页面改版、字段调整、反爬策略升级,这些都会让你的代码突然失效。所以不要把一切都写死在代码里,尽量让规则可配置、可监控、可恢复。这也是为什么我坚持要做一个“框架”而不是写一个“脚本”的原因——脚本跑通了只是开始,框架能撑住后续所有的变化,才是真正能用的东西。
本文还有配套的精品资源,点击获取