
简介本资源是一个面向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.htmlAJAX 加载需构造specIdcallback请求口碑页如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(allowr/price/series-\d\.html, deny_extensions[]), followTrue, callbackparse_series_page ), # 不提取二级链接避免陷入无限翻页 Rule(LinkExtractor(allowr/price/list-\d-\d\.html), followFalse), ) 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( urlfhttps://car.autohome.com.cn/spec/{series_id}.html, callbackself.parse_spec_page, meta{series_id: series_id}, priority10 ) yield Request( urlfhttps://k.autohome.com.cn/{series_id}/, callbackself.parse_koubei_index, meta{series_id: series_id}, priority8 )提示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] fhttps://k.autohome.com.cn/{request.meta.get(series_id, )}/ request.headers[X-Requested-With] XMLHttpRequest request.headers[Accept] application/json, text/javascript, */*; q0.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( urlfhttps://club.autohome.com.cn/ajax/Reply/GetFloorReplyList, formdata{ topicId: thread_id, floorNo: str(i), replyCount: 10, }, callbackself.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_uricrawler.settings.get(MONGO_URI, mongodb://localhost:27017/), mongo_dbcrawler.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)], uniqueTrue) 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()注意uniqueTrue对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(day1) # 统一为当月1日 break except ValueError: continue else: item[buy_date] datetime.now().replace(day1) # 默认当月 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不同如page1vspage2fingerprint 却一样导致第二页请求被丢弃下游 pipeline 收不到数据误以为已采集完成。解决关闭默认去重改用scrapy.downloadermiddlewares.retry.RetryMiddleware控制重试同时在Request中显式设置dont_filterTrueyield scrapy.FormRequest( urlhttps://k.autohome.com.cn/61941/evaluate/, formdata{page: 2}, callbackself.parse_koubei_page, dont_filterTrue, # 关键 meta{series_id: series_id} )4.2 现象speccollection 中image_urls字段存的是空列表[]但网页明明有图原因汽车之家图片使用懒加载img># 错误写法取 src img_urls response.xpath(//div[classpic-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(fInvalid spec_id: {item[spec_id]}) del item[spec_id] # 舍弃脏数据4.4 现象Scrapy 启动后卡在Spider opened日志无报错CPU 占用 100%原因LinkExtractor的allow正则过于宽泛匹配到海量无效链接如/js/xxx.js?v123Scrapy 尝试下载所有.js文件并解析陷入死循环。解决严格限定deny_extensions并用restrict_css缩小提取范围LinkExtractor( allowr/price/series-\d\.html, deny_extensions[js, css, png, jpg, gif, pdf], restrict_cssdiv.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 None5. 验证结构化质量用 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—— 这就是结构化采集的底气。希望帮到你。本文还有配套的精品资源点击获取