☰
Scrapy+MongoDB构建汽车之家结构化采集管道
2026/10/26 12:30:19 网站建设 项目流程

简介:本资源是一个面向Python爬虫开发者与数据工程初学者的实战项目,聚焦汽车垂直领域结构化数据采集与存储,解决汽车之家多源异构信息(车型参数、用户口碑、报价、新闻、图片、视频、论坛帖)难以统一获取与管理的痛点。压缩包共16个文件,含6个核心Python源码(spiders、items、pipelines等模块清晰划分爬虫逻辑)、6个编译后pyc文件、1个README.md说明文档、1个txt说明文件、1个docx附赠资料及1个cfg配置文件,整体仅41KB,轻量易部署。已有34人学习下载,适合希望掌握Scrapy分布式爬取、XPath/CSS选择器实战、MongoDB文档建模与Pipeline数据清洗全流程的开发者。项目完整呈现了从URL调度、页面解析、字段映射到MongoDB写入的端到端实现,代码结构规范,含日志监控与反爬适配设计,可直接复用或二次开发。

1. 为什么汽车之家的数据采集项目总在「跑通」和「崩掉」之间反复横跳?

你不是没试过:用 Scrapy 写个 start_urls,加几行 XPath 提取车系名、指导价、百公里油耗,本地跑起来能吐出 20 条 JSON —— 然后一开并发,3 分钟后爬虫静默退出;或者刚存进 MongoDB 的数据,查出来字段全空;又或者凌晨三点发现所有车型页返回 403,但浏览器打开完全正常。这不是玄学,是汽车之家反爬体系在真实世界里的「三重门」:动态渲染的参数页、带 Referer/UA/Token 校验的 AJAX 接口、以及嵌套在 iframe 里的用户口碑模块。本项目不是教你怎么“绕过”,而是用 Scrapy 框架 + MongoDB 做一次可长期维护、字段可追溯、失败可回溯的结构化采集落地——重点在「结构化」:把车型参数(表格型)、用户口碑(评论流)、经销商报价(多城市多门店)、新闻资讯(带发布时间与来源)、论坛帖子(含楼层回复)全部映射为 MongoDB 中有明确 schema 边界的文档结构,而不是塞进一个content字段里当黑匣子。适合正在做汽车垂直领域数据中台、竞品分析系统或智能选车推荐引擎的工程师,也适合想把 Scrapy 从「玩具级爬虫」升级到「生产级数据管道」的 Python 开发者。


2. 用 Scrapy 构建可扩展的汽车之家采集管道:从单页解析到全站调度

汽车之家网站结构复杂,但并非无规律可循。其核心数据源分五类:

  • 车型库页(如https://car.autohome.com.cn/price/series-2758.html):静态 HTML,含基础参数、图片链接、配置表;
  • 参数详情页(如https://car.autohome.com.cn/spec/61941.html):AJAX 加载,需构造specId+callback请求;
  • 口碑页(如https://k.autohome.com.cn/61941/):iframe 嵌套,实际内容来自https://k.autohome.com.cn/61941/evaluate/,且含分页与动态加载;
  • 经销商报价页(如https://dealer.autohome.com.cn/61941/):依赖城市 ID 和 dealerId,接口返回 JSON;
  • 论坛帖页(如https://club.autohome.com.cn/bbs/thread-c-1000001001-123456789-1.html):楼层结构清晰,但需处理楼中楼回复与图片懒加载。

常见做法是写 5 个独立 Spider,但后期维护成本爆炸。我一般会用Scrapy 的 Rule + CrawlSpider + 自定义中间件组合,构建统一调度层:

2.1 定义主入口与 URL 路由规则

不硬编码所有 series-id,而是从首页车型库索引页开始递归抓取:

# spiders/autohome_crawler.py import scrapy from scrapy.spiders import CrawlSpider, Rule from scrapy.linkextractors import LinkExtractor from scrapy.http import Request class AutohomeCrawler(CrawlSpider): name = 'autohome_full' allowed_domains = ['autohome.com.cn'] start_urls = ['https://car.autohome.com.cn/price/'] # 抓取所有车系列表页(如 /price/series-*.html) rules = ( Rule( LinkExtractor(allow=r'/price/series-\d+\.html', deny_extensions=[]), follow=True, callback='parse_series_page' ), # 不提取二级链接,避免陷入无限翻页 Rule(LinkExtractor(allow=r'/price/list-\d+-\d+\.html'), follow=False), ) def parse_series_page(self, response): """解析车系页:提取车型ID、车系名、官方指导价区间""" series_id = response.url.split('-')[-1].split('.')[0] series_name = response.css('div.main-title h1::text').get('').strip() price_range = response.css('div.price-range::text').get('') # 构造下游任务:参数页、口碑页、经销商页、论坛入口 yield { 'series_id': series_id, 'series_name': series_name, 'price_range': price_range, 'crawl_timestamp': int(time.time()), } # 主动发起后续请求(非 Rule 触发) yield Request( url=f'https://car.autohome.com.cn/spec/{series_id}.html', callback=self.parse_spec_page, meta={'series_id': series_id}, priority=10 ) yield Request( url=f'https://k.autohome.com.cn/{series_id}/', callback=self.parse_koubei_index, meta={'series_id': series_id}, priority=8 )

提示:CrawlSpider的 Rule 适合「广度优先」发现链接,但汽车之家大量关键页(如口碑、报价)不通过<a>标签暴露,必须用Request主动构造。priority参数确保高价值页面(如参数页)先被调度,避免因队列积压导致超时。

2.2 处理动态 iframe 与 AJAX 接口:绕过前端陷阱

口碑页https://k.autohome.com.cn/61941/实际是壳页面,真实数据在https://k.autohome.com.cn/61941/evaluate/下,且返回 JSON。但直接请求会 403 —— 因为它校验Referer、User-Agent,还要求X-Requested-With: XMLHttpRequest:

# middlewares.py class AutohomeHeadersMiddleware: def process_request(self, request, spider): if 'k.autohome.com.cn' in request.url: request.headers['Referer'] = f'https://k.autohome.com.cn/{request.meta.get("series_id", "")}/' request.headers['X-Requested-With'] = 'XMLHttpRequest' request.headers['Accept'] = 'application/json, text/javascript, */*; q=0.01' elif 'dealer.autohome.com.cn' in request.url: # 经销商页需城市ID,从 cookie 或前置请求获取 if 'cityid' not in request.cookies: request.cookies['cityid'] = '110100' # 北京

而论坛页的楼中楼回复(即“回复此楼”)是典型动态加载场景。Scrapy 默认不执行 JS,但不必上 Playwright—— 汽车之家论坛的楼层数据实际通过https://club.autohome.com.cn/ajax/Reply/GetFloorReplyList接口返回,只需逆向该 URL 模式:

def parse_forum_thread(self, response): thread_id = response.url.split('-')[-2] # 获取首层楼层 for floor in response.css('div.floor'): floor_no = floor.css('span.floor-no::text').get() content = ''.join(floor.css('div.text *::text').getall()).strip() yield { 'thread_id': thread_id, 'floor_no': floor_no, 'content': content, 'is_top': 'top-floor' in floor.attrib.get('class', ''), } # 主动请求楼中楼(最多3层嵌套) for i in range(1, 4): yield scrapy.FormRequest( url=f'https://club.autohome.com.cn/ajax/Reply/GetFloorReplyList', formdata={ 'topicId': thread_id, 'floorNo': str(i), 'replyCount': '10', }, callback=self.parse_floor_replies, meta={'thread_id': thread_id, 'floor_no': str(i)}, )

参数说明:formdata是关键,汽车之家论坛接口不接受 JSON body,只认application/x-www-form-urlencoded。replyCount控制每层拉取条数,设为 10 是平衡速度与完整性;实测超过 20 易触发风控。


3. MongoDB 结构化建模:让每类数据都有「身份证」和「血缘关系」

汽车之家数据天然具备强关联性:一个车系(series)下有多个车型(spec),每个车型对应多条口碑(koubei)、多个经销商报价(dealer_quote)、若干论坛帖(forum_thread)。若全塞进一个 collection,查询慢、更新难、字段易冲突。我坚持按业务域分 collection + 用 ObjectId 建立引用,而非嵌套文档(避免单文档超 16MB 限制):

Collection核心字段(精简示意)关联方式典型查询场景
series_id,name,price_range,logo_url,crawl_time主键查所有在售车系
spec_id,series_id(ObjectId),name,year,engine,gearbox,fuel_consumption,image_urlsseries_id→series._id查某车系下所有配置
koubei_id,spec_id(ObjectId),user_level,buy_date,km_driven,score,content,useful_countspec_id→spec._id查某车型口碑平均分 & 首页展示
dealer_quote_id,spec_id,city_id,dealer_name,quote_price,down_payment,monthly_payment,valid_untilspec_id→spec._id查北京地区某车型最低报价
forum_thread_id,series_id,title,author,post_time,view_count,reply_countseries_id→series._id查某车系最新热门帖

3.1 初始化数据库与索引:别等慢了才补

创建数据库前,先预置唯一索引与复合索引——这是后期查询性能的「后悔药」:

# pipelines.py from pymongo import MongoClient, ASCENDING, DESCENDING class MongoPipeline: def __init__(self, mongo_uri, mongo_db): self.mongo_uri = mongo_uri self.mongo_db = mongo_db @classmethod def from_crawler(cls, crawler): return cls( mongo_uri=crawler.settings.get('MONGO_URI', 'mongodb://localhost:27017/'), mongo_db=crawler.settings.get('MONGO_DATABASE', 'autohome') ) def open_spider(self, spider): self.client = MongoClient(self.mongo_uri) self.db = self.client[self.mongo_db] # 创建索引(仅首次运行执行) self.db['series'].create_index([('name', ASCENDING)], unique=True) self.db['spec'].create_index([('series_id', ASCENDING), ('name', ASCENDING)]) self.db['koubei'].create_index([('spec_id', ASCENDING), ('buy_date', DESCENDING)]) self.db['dealer_quote'].create_index([('spec_id', ASCENDING), ('city_id', ASCENDING), ('quote_price', ASCENDING)]) self.db['forum_thread'].create_index([('series_id', ASCENDING), ('post_time', DESCENDING)]) def close_spider(self, spider): self.client.close()

注意:unique=True对series.name是必要的——汽车之家存在同名不同代际车系(如“凯美瑞”),但实际采集时我们用series_id(数字)作主键,name仅作检索辅助,故加唯一索引防重复插入。buy_date降序索引让「最新购买用户口碑」查询毫秒级返回。

3.2 数据清洗与标准化:结构化的真正门槛

汽车之家原始数据充满噪声:

  • 参数页的「变速箱」字段可能是「7挡双离合」「7DCT」「7速双离合」;
  • 口碑页的「购车时间」格式混杂:「2023年05月」、「2023.05」、「23年5月」;
  • 经销商报价的「裸车价」含「约」「起」「万」等干扰字符。

Pipeline 中必须做字段级清洗,而非丢给下游处理:

# items.py import re from datetime import datetime class SpecItem(scrapy.Item): _id = scrapy.Field() series_id = scrapy.Field() # 外键,存 ObjectId name = scrapy.Field() year = scrapy.Field() # 整数,如 2023 engine = scrapy.Field() # 标准化:'1.5L', '2.0T' gearbox = scrapy.Field() # 标准化:'AT', 'DCT', 'CVT' fuel_consumption = scrapy.Field() # 浮点,单位 L/100km image_urls = scrapy.Field() # list of str class KoubeiItem(scrapy.Item): _id = scrapy.Field() spec_id = scrapy.Field() user_level = scrapy.Field() # 整数,1~5 buy_date = scrapy.Field() # datetime 对象 km_driven = scrapy.Field() # 整数,公里数 score = scrapy.Field() # float, 1.0~5.0 content = scrapy.Field() # 清洗后纯文本 useful_count = scrapy.Field() # 整数 # pipelines.py 中的清洗逻辑 def process_item(self, item, spider): if isinstance(item, SpecItem): # 标准化变速箱 raw_gearbox = item.get('gearbox', '') if '双离合' in raw_gearbox or 'DCT' in raw_gearbox.upper(): item['gearbox'] = 'DCT' elif '自动' in raw_gearbox or 'AT' in raw_gearbox.upper(): item['gearbox'] = 'AT' elif '手动' in raw_gearbox or 'MT' in raw_gearbox.upper(): item['gearbox'] = 'MT' else: item['gearbox'] = 'OTHER' # 提取排量(正则) engine_match = re.search(r'(\d+\.\d+)L', item.get('engine', '')) item['engine'] = engine_match.group(1) if engine_match else None elif isinstance(item, KoubeiItem): # 标准化购车时间 buy_date_str = item.get('buy_date', '') for fmt in ['%Y年%m月', '%Y.%m', '%y年%m月']: try: dt = datetime.strptime(buy_date_str, fmt) item['buy_date'] = dt.replace(day=1) # 统一为当月1日 break except ValueError: continue else: item['buy_date'] = datetime.now().replace(day=1) # 默认当月 return item

血泪经验:不做这一步,后期用 MongoDB$regex查询「所有 AT 变速箱车型」会慢到怀疑人生。标准化后,gearbox: "AT"可走索引,$eq查询 10ms 内返回。


4. 避坑:Scrapy + MongoDB 在汽车之家项目中的 5 个真实翻车现场

这些坑不是理论风险,是我在三个版本迭代中亲手踩过的、导致整批数据报废或服务中断的硬伤。每一条都附带可复现现象、根因定位方法和验证命令。

4.1 现象:MongoDB 插入时抛DuplicateKeyError,但_id明明是新生成的

  • 原因:Scrapy 默认启用DUPEFILTER_CLASS = 'scrapy.dupefilters.RFPDupeFilter',它基于 request fingerprint 去重。但汽车之家部分接口(如口碑分页)URL 相同,仅POST body不同(如page=1vspage=2),fingerprint 却一样,导致第二页请求被丢弃,下游 pipeline 收不到数据,误以为已采集完成。
  • 解决:关闭默认去重,改用scrapy.downloadermiddlewares.retry.RetryMiddleware控制重试,同时在Request中显式设置dont_filter=True:
    yield scrapy.FormRequest( url='https://k.autohome.com.cn/61941/evaluate/', formdata={'page': '2'}, callback=self.parse_koubei_page, dont_filter=True, # 关键! meta={'series_id': series_id} )

4.2 现象:speccollection 中image_urls字段存的是空列表[],但网页明明有图

  • 原因:汽车之家图片使用懒加载,<img># 错误写法(取 src) img_urls = response.xpath('//div[@class="pic-list"]//img/@src').getall() # 正确写法(优先取>from bson import ObjectId if isinstance(item, KoubeiItem): try: item['spec_id'] = ObjectId(item['spec_id']) except Exception: spider.logger.warning(f"Invalid spec_id: {item['spec_id']}") del item['spec_id'] # 舍弃脏数据

4.4 现象:Scrapy 启动后卡在Spider opened,日志无报错,CPU 占用 100%

  • 原因:LinkExtractor的allow正则过于宽泛,匹配到海量无效链接(如/js/xxx.js?v=123),Scrapy 尝试下载所有.js文件并解析,陷入死循环。
  • 解决:严格限定deny_extensions,并用restrict_css缩小提取范围:
    LinkExtractor( allow=r'/price/series-\d+\.html', deny_extensions=['js', 'css', 'png', 'jpg', 'gif', 'pdf'], restrict_css='div.list-content a' # 只在指定 CSS 选择器内找链接 )

4.5 现象:经销商报价数据中quote_price字段存的是字符串"15.8万起",无法排序

  • 原因:未在 Pipeline 中做数值清洗,直接存原始文本。
  • 解决:正则提取数字并转 float,单位统一为「万元」:
    price_str = item.get('quote_price', '') price_match = re.search(r'([\d.]+)(?:万|万元)', price_str) if price_match: item['quote_price'] = float(price_match.group(1)) else: # 尝试提取纯数字(如 "158000") num_match = re.search(r'(\d+)', price_str) item['quote_price'] = float(num_match.group(1)) / 10000 if num_match else None

5. 验证结构化质量:用 MongoDB 原生命令做「数据健康度快检」

结构化不是目标,而是手段。最终要回答:这批数据能不能支撑「查某车型近3个月口碑趋势」或「对比两个车系在北京的报价分布」?我每天上线前必跑这 5 条命令,10 秒内给出结论:

5.1 检查外键完整性:是否存在「孤儿文档」

# 查 spec 表中指向不存在 series 的记录 db.spec.find({ "series_id": { "$not": { "$in": db.series.distinct("_id") } } }).count() # 查 koubei 表中 spec_id 为空或非法的记录 db.koubei.find({ "spec_id": { "$exists": false } }).count() db.koubei.find({ "spec_id": { "$type": "string" } }).count()

预期结果:全部返回0。若有非零值,说明 Pipeline 中spec_id转换失败或上游数据缺失,需立即停机修复。

5.2 验证字段覆盖率:关键字段是否普遍缺失

// 计算 spec 表中各字段的非空率(以 gearbox 为例) db.spec.aggregate([ { $group: { _id: null, total: { $sum: 1 }, has_gearbox: { $sum: { $cond: [{ $ne: ["$gearbox", null] }, 1, 0] } } } }, { $project: { coverage: { $multiply: [{ $divide: ["$has_gearbox", "$total"] }, 100] } } } ])

阈值建议:gearbox、engine、fuel_consumption覆盖率应 ≥95%;image_urls≥80%(部分老款车可能无图)。低于阈值需检查 XPath 是否适配新版页面。

5.3 测试关联查询性能:模拟真实业务场景

// 场景:查「凯美瑞」车系下,2023年购买、评分≥4.5的口碑(含车型名) db.koubei.aggregate([ { $lookup: { from: "spec", localField: "spec_id", foreignField: "_id", as: "spec_info" } }, { $unwind: "$spec_info" }, { $lookup: { from: "series", localField: "spec_info.series_id", foreignField: "_id", as: "series_info" } }, { $unwind: "$series_info" }, { $match: { "series_info.name": "凯美瑞", "buy_date": { $gte: ISODate("2023-01-01") }, "score": { $gte: 4.5 } } }, { $project: { "spec_name": "$spec_info.name", "buy_date": 1, "score": 1, "content": 1 } } ]).explain("executionStats")

关键指标:executionStats.executionTimeMillis应 < 200ms(数据量 100 万以内)。若 >500ms,检查koubei.spec_id和spec._id索引是否生效(executionStats.executionStages.stage中IXSCAN节点indexName字段)。

5.4 发现隐藏数据漂移:用聚合识别异常值

汽车之家偶尔会注入测试数据或占位符。用$max/$min 快速扫描:

// 查 fuel_consumption 的极值(单位 L/100km) db.spec.aggregate([ { $group: { _id: null, min_fuel: { $min: "$fuel_consumption" }, max_fuel: { $max: "$fuel_consumption" } } } ])

合理范围:燃油车min_fuel≥ 4.0(混动/插混除外),max_fuel≤ 18.0;超出即为脏数据(如0.0或999.0)。用db.spec.updateMany({fuel_consumption: {$lt: 4}}, {$set: {fuel_consumption: null}})批量修正。

我习惯把这 5 条命令存成health_check.js,配合mongo autohome health_check.js > report.log每日定时执行。它不保证数据 100% 正确,但能让你在老板问「昨天爬的数据能用了没?」时,不用翻日志、不猜、不赌,直接甩出report.log里那行executionTimeMillis: 142—— 这就是结构化采集的底气。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询