简介这是一份基于Python语言的58同城全站爬取信息框架设计源码主要面向具有Python基础、正在学习网络爬虫的开发者以及有数据分析需求的用户针对58同城站内房产、招聘、车辆、二手交易等多类信息提供了一套自动化采集方案能够有效提升数据收集效率并扩大覆盖范围。压缩包共含26个文件包括13个Python源代码文件、10个预编译的pyc字节码文件以及配置文件、说明文件、git忽略文件等整体体积仅有29KB结构紧凑没有多余附件。代码基于Scrapy框架组织按爬虫、管道、中间件等模块做了清晰拆分同时加入用户代理列表、代理IP测试、请求伪装等反爬处理策略从请求发送、页面解析到结果存储形成了完整闭环。目前已有296人学习下载适合想掌握Scrapy实战、借鉴反爬设计的开发者。通过阅读源码可以快速理解一个完整爬虫框架的模块划分与协作方式还能直接参考代理切换、请求头构造等实用技巧为后续开发自有数据采集工具打下基础。1. 为什么自己搭58同城爬虫框架现成轮子的边界和这套源码的定位做58同城的数据抓取很多人第一反应是去GitHub上找一个爬虫脚本直接跑。但真跑起来你会发现那些脚本大多是单页、单城市、写死字段的demo换一个类目就要改代码换一个城市就要改URL更不用说58的页面结构几个月就调整一次正则一失效整个脚本直接报废。这套源码解决的不是“爬一个页面”而是“把58同城全站跑完”这件事——城市、类目、列表页、详情页、翻页、去重、断点续爬、反爬兜底全部整合在一个可配置的框架里。它的价值在于你把配置文件填好启动入口剩下的调度和容错交给框架不需要每天盯着日志改代码。适合谁用如果你需要批量获取58上的招聘、房产、二手车等公开信息用于数据分析、市场调研或者业务决策且不想从零开始写调度和重试逻辑这个框架能帮你省下至少一周的踩坑时间。它不是一个开箱即食的成品软件而是一套结构清晰、能跑通、能二次改的源码工程。接下来我会从框架设计、落地运行、踩坑点、二次改造四个维度拆开讲。2. 框架整体设计从入口到落库的模块划分与关键数据结构2.1 请求调度层代理池、Headers轮换和重试队列58的反爬主要集中在IP频率限制和User-Agent校验所以框架把请求调度单独拆了一个模块不跟业务解析混在一起。调度层维护三个核心对象代理池、Headers池、重试队列。代理池默认支持从文本文件和HTTP接口两种方式加载代理文本格式一行一个ip:port接口形式则是返回JSON数组。每次请求前调度器随机取一个代理并随机组合UA头。# scheduler/request_scheduler.py import random from queue import PriorityQueue class RequestScheduler: def __init__(self, proxy_poolNone, headers_poolNone): self.proxy_pool proxy_pool or [127.0.0.1:8888] self.headers_pool headers_pool or [ {User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36}, {User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36}, ] self.retry_queue PriorityQueue() def get_proxy(self): return random.choice(self.proxy_pool) def get_headers(self): return random.choice(self.headers_pool) def add_retry(self, request, priority3): self.retry_queue.put((priority, request))这里PriorityQueue是重试队列的核心默认priority越小越先执行。我第一次写的时候用的普通list结果失败请求越堆越多后面高优先级的新请求一直被阻塞。改成优先队列之后低优先级重试会被延迟处理不会拖垮主流程。代理池不要直接用requests库的proxies字典框架里统一封装成get_proxy()方便后续接入付费代理API时不用改业务代码。2.2 解析层基于CSS选择器的字段抽取与58页面结构适配58的列表页和详情页结构不完全一样但解析层没有用正则也没有依赖lxml的XPath而是选了CSS选择器加自定义映射字典。原因是58的前端代码经常调整CSS class的变化比XPath绝对路径的变化频率低一些而且浏览器devtools里直接复制选择器很方便。框架里定义了一个字段映射文件用JSON描述每个字段的选择器和类型。{ list_item: { title: div.des h2 a, price: span.price, area: div.place, url: div.des h2 ahref }, detail: { publish_time: .info li[data-time]data-time, desc: #desc, phone: .contact_phone } }解析层读取这个JSON通过parsel库提取内容。注意URL字段用了href后缀框架内部会识别这个标记并且把相对路径拼成绝对地址。我还遇到过一个坑列表页的标题链接在PC端是div.des h2 a但移动端页面变成了a.title所以框架里支持“备选选择器”数组第一个选不到就试第二个。from parsel import Selector class Parser: def __init__(self, mapping): self.mapping mapping def parse_list(self, html): sel Selector(texthtml) items [] for node in sel.css(self.mapping[list_item][title][0]): title node.css(::text).get() if not title: continue item {} for field, selector in self.mapping[list_item].items(): if selector.endswith(href): css_part selector.split()[0] item[field] node.css(f{css_part}::attr(href)).get() else: item[field] node.css(f{selector}::text).get() items.append(item) return items这个parse_list只处理列表项详情页的解析是另一个方法。注意node.css(::text).get()拿的是第一个文本节点如果标题里有子标签会可能拿到空值。我一般会先strip()再判空空值就跳过这条数据避免脏数据入库。2.3 存储层MySQL与JSON双通道断点续爬的设计存储层做了双通道设计MySQL存结构化数据JSON文件存原始响应。这样设计的理由很简单MySQL用于查询和分析JSON用于排查解析问题和重新清洗。框架使用SQLAlchemy作为ORM但写文件部分直接基于open不引入多余依赖。pip install sqlalchemy pymysql parsel requestssettings.py里有一个DUMP_RAW_HTML开关打开后每页响应会存到raw_pages/日期/类目/页码.html。这个开关在调试阶段建议开启因为58页面结构变了之后你拿到HTML才能分析是哪里变了没有原始页面就只能瞎猜。断点续爬的关键是记录“已经完成的分页URL”我用Redis的Set做去重但也支持SQLite的crawl_state表做降级方案。class StateStore: def __init__(self, redis, table_namecrawl_state): self.redis redis self.table_name table_name def is_done(self, key): if self.redis: return self.redis.sismember(self.table_name, key) return False def mark_done(self, key): if self.redis: self.redis.sadd(self.table_name, key) else: # SQLite 实现略 pass这个StateStore的key我一般用城市|类目|页码拼起来。重新启动时框架会检查这个key如果已经存在就跳过该页。没有这个设计的话爬了几万条之后程序崩一次你得从头再来那个感觉太难受了。3. 落地跑通第一版配置、启动和验证结果3.1 环境依赖与配置文件说明拿到源码后第一步不是运行而是修改config.ini。这个文件的每一项都直接对应框架的核心行为包括起始城市、目标类目、并发数、超时时间、代理开关、数据库连接字符串。默认的并发数是4这个数字在本地跑比较安全太大容易被封IP太小效率低。[spider] cities 北京,上海,广州,深圳 categories ershoufang,zhaopin,ershouche start_page 1 max_page 100 concurrent 4 timeout 15 retry_times 3 [database] host 127.0.0.1 port 3306 user root password your_password dbname 58_crawl [proxy] enabled false proxy_file proxies.txt注意categories用的是58的URL路径里的拼音缩写不是中文名。ershoufang是二手房zhaopin是招聘ershouche是二手车。这个缩写可以从58网站的分类导航链接里看到不要手敲中文进去否则URL拼不出来。如果你只打算跑一个城市就把cities只留一个避免刚开始就触发大面积IP限制。3.2 从城市到类目的全站遍历入口启动入口是run.py它读取配置后生成URL队列。每一条URL是https://{city}.58.com/{category}/pn{page}/的形式城市是拼音子域名比如北京就是bj.58.com。框架内部有一个城市拼音映射表配置里写中文启动时自动转换。# run.py from scheduler import RequestScheduler from parser import Parser from storage import Storage cities config.get(spider, cities).split(,) categories config.get(spider, categories).split(,) start_page config.getint(spider, start_page) max_page config.getint(spider, max_page) for city in cities: city_code city_pinyin_map[city.strip()] for category in categories: for page in range(start_page, max_page 1): url fhttps://{city_code}.58.com/{category}/pn{page}/ scheduler.add_url(url, callbackparse_list)这里callback字段决定了解析方法名框架按字符串反射调用。这样做的好处是新增类目时不用改循环逻辑只要在解析器里注册对应的方法。实际跑的时候建议先单独测一个城市的一个类目比如北京二手房第一页确认能拿到数据后再放开全量。3.3 断点续爬与日志跑挂了怎么恢复run.py里启动时会初始化StateStore然后进入一个while not scheduler.empty()的主循环。每次请求完成后不管成功还是失败都会写日志到logs/crawler.log同时更新状态。如果你用的是SQLite降级方案状态存储在state.db里重启后框架自动读取未完成的分页继续爬。tail -f logs/crawler.log运行半路崩了是家常便饭原因大多是代理失效、数据库连接断掉、内存里的未写队列丢失。所以我习惯每爬完10页就主动flush一次存储强制把内存中的数据写入MySQL。这个配置在config.ini里是flush_interval 10。flush太频繁会降低性能太少则崩溃丢失多10页是一个比较稳的平衡点。4. 反爬应对与数据质量常见问题排查与避坑清单4.1 现象验证码突然弹出请求返回的HTML里出现verify相关字段原因58的反爬机制会监控单个IP的请求频率和流量特征短时间高频访问会触发滑块或点击验证码。我的经验是同一个IP每分钟超过20个请求基本就会触发。解决启用代理池并限制单IP并发。框架config.ini里把concurrent降到2同时在代理池配置里增加“每IP每秒最多3次请求”的限制。如果你用的是稳定的付费代理把超时时间调到20秒减少连接被重置的概率。4.2 现象抓下来的数据价格字段为空或者出现面议字样原因58的列表页价格分两类一类是明码标价一类是面议。span.price选择器只能抓到标价的抓不到的节点是span.sell或者自定义的div.price。另外部分城市列表页的DOM结构和北京不一样。解决在每个城市的抓取后面加一个“字段缺失率统计”。如果缺失率超过30%自动把该城市页面的HTML dump到raw_pages下并跳过该城市。这是我自己加的监控逻辑因为58不同城市的页面模板确实有差异不能一个城市跑通了就认为全部城市都能跑通。4.3 现象翻页PN循环抓取时页码到某一页后一直是同样的内容原因58的列表页超过一定页数后会显示“仅展示前xx页”实际上不是真正的第N页而是服务器端做了截断。框架里max_page设置成100可能在30页之后就重复了。解决去重逻辑需要加上URL和详情页title的双重标记。列表页的URL虽然页码不同但多次请求返回的详情链接集合会重叠。我在解析详情页之前会先把详情页URL的hash存入StateStore如果已经存在就不重复入库。实测中58二手车的有效页数一般到70页左右但不同城市差异很大所以建议把max_page先设成50跑完统计去重率后再调整。4.4 现象数据库里出现大量重复记录同一个帖子出现多次原因列表页和详情页都会包含部分相同的信息而且58的“刷新”功能会让同一帖子在列表页的不同位置反复出现。如果主键只设置为详情页URL的hash刷新后的URL相同但内容时效变了还是会被视为同一记录。解决主键建议设置为城市|类目|帖子ID帖子ID从详情页URL里提取。58的详情页URL通常包含一个数字ID比如https://bj.58.com/ershoufang/1234567890x.shtml直接用正则提取\d\.shtml前面的部分。这样即使标题变了、价格变了只要ID不变就视为同一条记录更新字段而不是插入新行。5. 把框架改造成自己的二次开发的几个进阶点5.1 新增平台抽象基类与插件化解析器这套源码的架构是围绕58设计的但它的调度和存储层是通用的。如果你想爬58的竞争对手比如某个分类信息网站只需要新增一个parser_xxx.py实现parse_list(html)和parse_detail(html)两个方法然后在settings.py里注册平台的SPIDER_NAME。# parsers/ganji.py class GanjiParser: def __init__(self): self.list_selector div.item a.title def parse_list(self, html): items [] sel Selector(texthtml) for node in sel.css(self.list_selector): title node.css(::text).get() url node.css(::attr(href)).get() if title and url: items.append({title: title.strip(), url: url}) return items注册时在run.py里改为读取PLATFORM配置项然后动态导入对应的解析器模块。这个改造的核心是把解析器变成“插件”而不是在同一个类里堆if分支。我见过很多半吊子框架用.xpath和.css混写加上大量if平台 xx最后根本没法维护。插件化之后每个平台只需要关注自己的选择器变化出问题也容易隔离。5.2 增量抓取基于发布时间与数据库时间戳的同步策略58的帖子有发布时间列表页里通常显示“今天更新”“昨天更新”或者具体时间。框架里有一个场景每天跑一遍全站太浪费时间反而容易触发反爬所以我把全量爬取拆成了“每周一次全量 每天一次增量”。增量爬取只处理列表页前3页的帖子因为刷新帖子会沉到前几页发布时间跨度为24小时。SELECT id, title, publish_time FROM items WHERE citybj AND categoryershoufang AND publish_time NOW() - INTERVAL 1 DAY;这个SQL是增量策略的验证方式。爬完增量后把新增记录和数据库里已有的记录做对比发现publish_time在24小时内的帖子都入库了就说明增量逻辑正常。实现增量时解析器需要从详情页提取发布时间58的详情页发布时间在li[data-time]里是一个时间戳需要转成标准格式。import datetime def parse_publish_time(timestap_str): try: ts int(timestap_str[:10]) return datetime.datetime.fromtimestamp(ts).strftime(%Y-%m-%d %H:%M:%S) except (ValueError, TypeError): return None这个转换不做好增量策略没法落地。我因为懒有一段时间没用时间戳直接比对URL结果每天大量重复抓取数据量翻了3倍数据库跑得巨慢。后来才老老实实把时间戳解析写了。5.3 数据上报与异常告警Webhook接入爬虫挂掉如果人不在电脑前很容易跑半天空转。框架里做了一个极简的告警每次连续3个请求失败就通过Webhook发送一条消息。Webhook的地址配置在config.ini的[notify]段实现上用了requests的post非常简单。def send_alert(message): webhook config.get(notify, webhook_url) data {msgtype: text, text: {content: message}} try: requests.post(webhook, jsondata, timeout5) except Exception: # 告警失败不要影响主流程 pass这里有个细节requests.post一定要设timeout否则Webhook服务无响应时主线程会被卡住。我遇到过Webhook地址直接挂了结果告警线程阻塞在post上把整个爬虫拖停了。现在send_alert里加了异常捕获和timeout告警失败就打印一条日志绝不影响采集。6. 实战验证用一小时跑完一个城市的数据并做质量检查开了代理池和3个并发我在本地跑了一小时抓了北京二手房前50页的数据大约2200条详情。跑完之后不要急着入库先做数据质量检查。我习惯用SQL统计几个关键指标。SELECT COUNT(*) AS total, SUM(CASE WHEN price IS NULL OR price0 THEN 1 ELSE 0 END) AS no_price, SUM(CASE WHEN title THEN 1 ELSE 0 END) AS no_title, SUM(CASE WHEN publish_time IS NULL THEN 1 ELSE 0 END) AS no_time FROM items;如果no_price的比例超过5%我会把raw_pages里当天对应的HTML重新解析看看是不是选择器变了。这个方法帮我避过两次页面改版的坑——一次是58把价格从span.price改成了span.price_now另一次是发布时间从li[data-time]移动到了div.publish-time。没有原始HTML留存的话你根本不知道之前解析成功的字段现在为什么变成空的了。验证完这些指标我会再抽查大约10条记录对比线上页面确认标题、价格、面积、描述字段没有错位。字段错位是这个框架最容易出的问题尤其当页面里出现置顶帖或者推荐帖DOM结构和普通列表项不一样——它们可能没有div.place结构导致解析时把下一个字段的值往前顶。抽查的方式很简单用ORDER BY RAND() LIMIT 10取10条记录然后打开对应的URL人工看一眼。从那以后我每次跑完一批数据都会强制走一遍“指标统计 → 抽样比对 → 异常反馈到解析器映射”这流程不再觉得能跑出数据就等于跑对了。这也是这套源码里最值得你借鉴的部分解析器、调度器、存储层都只是骨架真正的价值在于围绕数据质量做的那一圈拳脚。希望这些拆解能帮你在自己的抓取任务上少走几步弯路快速把全站数据跑成你能用的结构。本文还有配套的精品资源点击获取