简介:本资源是一个面向Python爬虫开发者与数据工程初学者的实战项目,聚焦汽车垂直领域结构化数据采集与存储,解决从非结构化网页中高效提取车型参数、用户口碑、经销商报价、新闻资讯及图片视频等多模态内容并持久化的问题。压缩包共16个文件,含6个核心Python源码(spiders、pipelines、items等模块)、6个编译后pyc文件、1个README.md说明文档、1个txt说明文件、1个docx附赠资料及1个scrapy.cfg配置文件,整体仅41KB,轻量易部署,便于快速理解Scrapy项目骨架与MongoDB写入逻辑。目前已有34人学习下载,适合希望掌握分布式爬虫设计、XPath/CSS选择器实战、Item Pipeline数据清洗及MongoDB文档建模的进阶学习者。读者可直接复用爬虫架构,获取完整目录结构、可运行的中间件配置、字段映射定义及合规性处理思路,为后续汽车大数据分析打下坚实基础。
1. 为什么汽车之家的数据采集项目,90%的人卡在「结构化」这一步就放弃了?
不是爬不动,是爬下来一堆 HTML 片段、JS 渲染的动态参数、iframe 嵌套的口碑页、异步加载的论坛回复、带水印的图片 URL、混着广告位的报价表格——最后导出个 CSV,字段对不上、类型全乱、缺失值成片,连“指导价”和“经销商报价”都分不清哪个是真实成交价。这个标题里的「结构化」,不是指把网页转成 JSON 就算完事,而是要让每一条车型数据能直接进 BI 看板、能被 Pandas 聚合分析、能按「品牌→车系→年款→配置」四级树形建模、能支撑后续做用户口碑情感分析或价格趋势预测。它要求你同时搞定 Scrapy 的请求调度与中间件链路、MongoDB 的文档建模与索引策略、以及汽车之家特有的反爬节奏(比如首页轮播图用 WebP、参数页用 Vue 动态渲染、论坛帖用懒加载 + 滑动翻页)。适合两类人:一是刚学完 Scrapy 基础、想拿真实商业网站练手的工程师;二是需要持续获取竞品车型参数做市场分析的产品/运营同学——但前提是,你得接受:这不是写个scrapy crawl carhome就能跑通的玩具项目,而是一套需要你亲手调参、埋点、校验、补漏的生产级数据管道。
2. 从零搭建 Scrapy + MongoDB 数据管道:不依赖 Playwright,纯 Scrapy 解决动态内容
汽车之家的页面结构非常典型:首页导航栏静态,但点击“奥迪A4L”后跳转的车型页,核心参数(如“发动机”“变速箱”“车身尺寸”)由 JS 注入;用户口碑页(如/koubei/xxxxx.html)用 iframe 套了一层独立域名;论坛帖子列表用滚动加载,URL 不变但接口返回新数据。很多人一上来就想上 Playwright 或 Selenium,结果发现:启动慢、内存高、分布式难调度、日志难追踪。其实,Scrapy 本身完全能应对——关键在于选对中间件、拆对请求链、压对并发节奏。
2.1 为什么不用 Playwright?三个硬约束下的务实选择
- 调度不可控:Playwright 实例无法被 Scrapy 的
CONCURRENT_REQUESTS统一管理,容易触发汽车之家的 IP 频控(实测单 IP 每分钟超 8 次 JS 渲染页访问即封 5 分钟); - 日志黑匣子:Playwright 的 console.log 和 network trace 无法嵌入 Scrapy 的
logger.info()流程,排查“为什么某条口碑没抓到”时只能靠截图回放,效率极低; - 结构化成本翻倍:Playwright 抓到的是 DOM 树,仍需用
response.css()或xpath()提取字段;而 Scrapy 的HtmlResponse已内置 selector 引擎,且支持response.replace(body=new_body)手动注入解析后的 HTML,比 Playwright 多一层 DOM 序列化开销。
提示:本方案默认关闭 JavaScript 渲染,所有动态内容通过逆向分析 XHR 接口获取。汽车之家的车型参数、口碑列表、论坛帖子均有稳定 API,只需破解 Referer 和加密参数即可。
2.2 逆向关键接口:三类核心数据的请求构造逻辑
汽车之家未公开 API,但通过浏览器开发者工具可定位以下三类高频接口(已脱敏处理,实际使用需替换xxx):
| 数据类型 | 示例 URL | 关键 Header / 参数 | 说明 |
|---|---|---|---|
| 车型参数 | https://car.autohome.com.cn/config/series/xxxxx.html | Referer: https://www.autohome.com.cn/xxxxx/ | 返回完整 HTML,含<table class="tbcs">,需解析表格行 |
| 用户口碑 | https://koubei.app.autohome.com.cn/pc/GetKoubeiList?specid=xxxxx&pageindex=1&pagesize=10 | 无特殊 header,但specid必须为整数 | 返回 JSON,result.list[]含口碑摘要、评分、时间 |
| 论坛帖子 | https://forum.app.autohome.com.cn/forum/GetPostList?forumId=xxxxx&pageindex=1&pagesize=20 | User-Agent必须为移动端(Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X)) | PC 端接口限流严重,移动端接口更稳定 |
# scrapy/spiders/carhome_spider.py import scrapy import json from urllib.parse import urlencode class CarHomeSpider(scrapy.Spider): name = 'carhome' allowed_domains = ['autohome.com.cn', 'app.autohome.com.cn'] def start_requests(self): # 1. 先抓取首页车型列表(静态),提取所有 series_id yield scrapy.Request( url='https://www.autohome.com.cn/grade/carhtml/A.html', callback=self.parse_brand_page, headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'} ) def parse_brand_page(self, response): # 解析 A-Z 品牌页中的车型链接,提取 series_id(如 /a4l/ 对应 3022) for href in response.css('div.main-content a::attr(href)').getall(): if '/spec/' in href: series_id = self.extract_series_id(href) # 2. 构造车型参数页请求(静态 HTML) yield scrapy.Request( url=f'https://car.autohome.com.cn/config/series/{series_id}.html', callback=self.parse_config_page, meta={'series_id': series_id}, headers={'Referer': f'https://www.autohome.com.cn/{series_id}/'} ) # 3. 构造口碑列表请求(JSON API) yield scrapy.Request( url=f'https://koubei.app.autohome.com.cn/pc/GetKoubeiList?{urlencode({"specid": series_id, "pageindex": 1, "pagesize": 10})}', callback=self.parse_koubei_list, meta={'series_id': series_id, 'page': 1}, headers={'Referer': 'https://koubei.autohome.com.cn/'} ) def extract_series_id(self, href): # 从 /spec/3022/abc.html 提取 3022 import re match = re.search(r'/spec/(\d+)/', href) return match.group(1) if match else None这段代码的核心逻辑是:用静态页面启动生成式请求链。start_requests只发一次首页请求,后续所有车型页、口碑页、论坛页均由解析结果动态生成。meta字段贯穿整个请求链,确保series_id在不同回调中可传递,避免重复解析。
2.3 MongoDB 文档建模:按业务场景设计嵌套结构,而非简单 flat 表
很多初学者把每条口碑、每个参数、每张图片都存成独立文档,结果查一个车型的完整信息要 join 10+ 次 collection,性能崩盘。汽车之家数据天然具备强层级关系:一个车型(series)有多个年款(year_model),每个年款有多个配置(config),每个配置有多个参数(param)、多条口碑(koubei)、多张图片(image)。MongoDB 的文档优势正在于此——我们把「一个车型的全量结构化快照」存为单文档:
{ "_id": "3022", "brand": "奥迪", "series_name": "A4L", "url": "https://www.autohome.com.cn/3022/", "year_models": [ { "year": 2023, "configs": [ { "name": "40 TFSI 时尚动感型", "price_range": "28.99-30.99", "params": { "发动机": "2.0T 190马力 L4", "变速箱": "7挡湿式双离合", "长宽高": "4858*1847*1439" }, "images": [ "https://car3.autohome.com.cn/album/xxxxx.jpg", "https://car3.autohome.com.cn/album/yyyyy.jpg" ], "koubies": [ { "score": 4.5, "title": "动力响应快,底盘扎实", "content": "开了三个月,油耗7.2L...", "date": "2023-08-15" } ] } ] } ], "updated_at": {"$date": "2024-06-12T08:23:45Z"} }注意:
year_models和configs是数组,params是内嵌对象,koubies是子文档数组。这种设计让「查奥迪A4L 2023款所有配置参数」只需一次find({"_id": "3022"}),无需关联查询。MongoDB 的$unwind和$group也能高效支持后续聚合分析(如“各品牌平均口碑分”)。
3. MongoDB 存储落地:连接、Schema 验证、索引与写入优化
Scrapy 抓到数据后,不能直接collection.insert_one(item)—— 缺少连接池管理会耗尽 socket,缺少 Schema 验证会导致脏数据污染集合,缺少索引会让后续查询变成全表扫描。这一节讲怎么把数据真正稳稳地落进 MongoDB。
3.1 使用 pymongo 的 MongoClient 连接池,禁用 auto_start_request
Scrapy 默认使用 Twisted 的异步机制,但 pymongo 的MongoClient本身是线程安全的,且内置连接池。错误做法是每次 pipeline 都新建 client:
# ❌ 错误:每次写入都新建连接,快速耗尽系统文件描述符 def process_item(self, item, spider): client = pymongo.MongoClient("mongodb://localhost:27017/") db = client['carhome'] db['vehicles'].insert_one(dict(item)) client.close() # 忘关更致命 return item正确做法是:在 pipeline 初始化时创建单例 client,并复用:
# pipelines.py import pymongo from scrapy.utils.project import get_project_settings class MongoPipeline: def __init__(self): settings = get_project_settings() self.mongo_uri = settings.get('MONGO_URI', 'mongodb://localhost:27017/') self.mongo_db = settings.get('MONGO_DATABASE', 'carhome') self.client = None self.db = None def open_spider(self, spider): # 在 spider 启动时初始化 client(非懒加载!) self.client = pymongo.MongoClient(self.mongo_uri, maxPoolSize=100, minPoolSize=10) self.db = self.client[self.mongo_db] def close_spider(self, spider): # 在 spider 结束时关闭 client if self.client: self.client.close() def process_item(self, item, spider): # 使用 upsert 避免重复插入(按 _id 去重) collection = self.db['vehicles'] collection.update_one( {'_id': item['_id']}, {'$set': dict(item), '$setOnInsert': {'created_at': {'$date': datetime.utcnow()}}}, upsert=True ) return item关键参数说明:
maxPoolSize=100:Scrapy 默认并发 16,设为 100 预留余量,防突发流量;minPoolSize=10:保持常驻 10 个空闲连接,避免冷启动延迟;upsert=True:用update_one替代insert_one,同一车型多次抓取自动更新,不产生重复文档;$setOnInsert:仅首次插入时写入created_at,后续更新不覆盖。
3.2 启用 Schema Validation:用 JSON Schema 拦住脏数据
MongoDB 3.6+ 支持集合级 Schema 验证,防止params字段传进来None或字符串"{}"。在open_spider中添加验证规则:
def open_spider(self, spider): self.client = pymongo.MongoClient(self.mongo_uri, maxPoolSize=100, minPoolSize=10) self.db = self.client[self.mongo_db] # 创建 vehicles 集合并设置 Schema Validation if 'vehicles' not in self.db.list_collection_names(): self.db.create_collection('vehicles', validator={ "$jsonSchema": { "bsonType": "object", "required": ["_id", "brand", "series_name", "year_models"], "properties": { "_id": {"bsonType": "string"}, "brand": {"bsonType": "string"}, "series_name": {"bsonType": "string"}, "year_models": { "bsonType": "array", "items": { "bsonType": "object", "required": ["year", "configs"], "properties": { "year": {"bsonType": "int"}, "configs": { "bsonType": "array", "items": { "bsonType": "object", "required": ["name", "params"], "properties": { "name": {"bsonType": "string"}, "params": {"bsonType": "object"} } } } } } } } } })提示:Schema Validation 是「写入时校验」,不是「读取时转换」。如果 item 中
year_models是空列表[],或params是None,MongoDB 直接拒绝写入并抛出pymongo.errors.WriteError。你在 pipeline 的process_item中需捕获该异常并记录日志,而不是让爬虫静默失败。
3.3 必建索引清单:让查询速度从秒级降到毫秒级
没有索引的 MongoDB 就是慢 SQL。针对汽车之家数据的典型查询场景,必须建立以下索引:
| 查询场景 | 索引命令 | 说明 |
|---|---|---|
| 按车型 ID 查单个车型 | db.vehicles.createIndex({"_id": 1}) | _id默认有索引,但显式声明更清晰 |
| 查某品牌下所有车型 | db.vehicles.createIndex({"brand": 1, "series_name": 1}) | 复合索引,支持find({"brand": "奥迪"})和find({"brand": "奥迪", "series_name": "A4L"}) |
| 查某年款所有车型 | db.vehicles.createIndex({"year_models.year": 1}) | 数组字段索引,支持find({"year_models.year": 2023}) |
| 按口碑评分排序 | db.vehicles.createIndex({"year_models.configs.koubies.score": -1}) | 嵌套数组索引,注意-1表示降序,用于查「口碑分最高车型」 |
执行方式(在 mongo shell 中):
use carhome db.vehicles.createIndex({"brand": 1, "series_name": 1}) db.vehicles.createIndex({"year_models.year": 1}) # 注意:嵌套数组索引需用引号包裹路径 db.vehicles.createIndex({"year_models.configs.koubies.score": -1})验证索引是否生效:用
explain()查看查询计划。例如db.vehicles.find({"brand": "奥迪"}).explain("executionStats"),若executionStats.nReturned> 0 且executionStats.executionTimeMillis< 10,则索引有效。
4. 避坑指南:Scrapy + MongoDB 采集汽车之家的 5 个血泪经验
爬汽车之家不是技术炫技,而是和反爬机制、页面改版、网络抖动、数据脏乱的长期拉锯战。以下 5 条全是线上翻车后加的日志监控和补丁逻辑,不是教科书理论。
4.1 现象:口碑列表接口返回{"result":{"code":403,"msg":"非法请求"}},但 Postman 能通
原因:汽车之家的口碑 API 校验Referer和User-Agent组合。Scrapy 默认的User-Agent是Scrapy/2.8.0 (+https://scrapy.org),而接口只认主流浏览器 UA,且Referer必须是https://koubei.autohome.com.cn/(注意是koubei.autohome.com.cn,不是koubei.app.autohome.com.cn)。
解决:在settings.py中启用RANDOMIZE_DOWNLOAD_DELAY = True,并自定义 UA 池;在请求口碑接口时,强制设置Referer:
# 在 parse_koubei_list 请求前 yield scrapy.Request( url=api_url, callback=self.parse_koubei_list, meta={'series_id': series_id, 'page': 1}, headers={ 'Referer': 'https://koubei.autohome.com.cn/', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' } )4.2 现象:MongoDB 写入报错E11000 duplicate key,但_id明明是唯一的
原因:Scrapy 的DUPEFILTER_CLASS默认基于 request fingerprint 去重,但汽车之家的口碑分页接口 URL 中pageindex不同,fingerprint 就不同,导致同一页数据被重复请求(如网络超时后重试,又发了一次pageindex=1)。而 pipeline 中update_one的_id是车型 ID,不是口碑 ID,所以多条口碑写入同一个车型文档时,year_models.configs.koubies数组里出现重复口碑。
解决:在 item 中增加koubei_id字段(从口碑 JSON 的id字段提取),并在 pipeline 中对koubies数组做去重:
# 在 parse_koubei_list 回调中 for k in data['result']['list']: koubei_item = { 'koubei_id': k['id'], 'score': k['score'], 'title': k['title'], 'content': k['content'], 'date': k['postdate'] } # 追加到 item['year_models'][0]['configs'][0]['koubies'] 中 # ... 省略追加逻辑 # pipeline 中去重 if 'koubies' in item: seen_ids = set() unique_koubies = [] for k in item['koubies']: if k.get('koubei_id') not in seen_ids: seen_ids.add(k['koubei_id']) unique_koubies.append(k) item['koubies'] = unique_koubies4.3 现象:车型参数页抓到<table class="tbcs">,但解析出的参数全是空字符串
原因:汽车之家在 2023 年底将参数表格改为「懒加载」:初始 HTML 只有骨架<tr><td colspan="2">暂无数据</td></tr>,真实数据由 JS 通过window.__INITIAL_STATE__注入。Scrapy 拿不到 JS 渲染后的内容,但也不需要上 Playwright——因为__INITIAL_STATE__是一段 JSON 字符串,藏在<script>标签里。
解决:用正则从 response.body.decode() 中提取:
def parse_config_page(self, response): # 从 script 标签中提取 __INITIAL_STATE__ script_text = response.css('script:contains(__INITIAL_STATE__)::text').get() if script_text: import re match = re.search(r'window\.__INITIAL_STATE__\s*=\s*(\{.*?\});', script_text, re.DOTALL) if match: try: state = json.loads(match.group(1)) # state['config'] 包含完整参数数据 params = self.extract_params_from_state(state) item['params'] = params except Exception as e: self.logger.error(f"Failed to parse __INITIAL_STATE__: {e}")4.4 现象:论坛帖子抓取到一半停止,日志显示ConnectionResetError: [Errno 104] Connection reset by peer
原因:汽车之家论坛接口对 TCP 连接复用敏感。Scrapy 默认开启RETRY_TIMES = 2,但重试时可能复用已被服务端关闭的连接。
解决:禁用连接复用,强制每次请求新建连接:
# settings.py HTTPCACHE_ENABLED = False RETRY_TIMES = 0 # 关闭重试,改用 pipeline 内部重试逻辑 # 在论坛请求中显式关闭 keep-alive yield scrapy.Request( url=forum_api, callback=self.parse_forum_list, headers={'Connection': 'close'} # 关键! )4.5 现象:MongoDB 存储后,用 Robo 3T 或 NoSQLBooster 查看文档,year_models字段显示为[Object],点不开
原因:NoSQLBooster 免费版对嵌套深度 > 3 层的文档默认折叠,且不支持展开configs.koubies这种双层数组。不是数据问题,是客户端限制。
解决:用 mongo shell 查看真实结构:
# 进入 shell mongo use carhome # 查看第一个文档的 year_models 结构(限制输出长度) db.vehicles.findOne({}, {year_models: 1}).year_models[0].configs[0].koubies[0] # 或用 pretty 格式化 db.vehicles.findOne().pretty()5. 结构化验证与增量更新:用 PyMongo 脚本做数据健康度巡检
爬虫跑起来只是开始,真正的工程化在于「怎么知道它还在健康运行」。我每天早上第一件事,就是跑一个 30 行的巡检脚本,检查三件事:有没有新车型漏抓、有没有口碑数据断更、有没有参数字段大面积为空。这不是运维,是数据产品的基本功。
5.1 用聚合管道查「漏抓车型」:品牌维度覆盖率报表
汽车之家官网有「全部品牌」列表(A-Z),我们抓取的车型应覆盖其中 95% 以上。用 MongoDB 聚合查缺失品牌:
# check_coverage.py from pymongo import MongoClient import requests client = MongoClient('mongodb://localhost:27017/') db = client['carhome'] coll = db['vehicles'] # 1. 获取汽车之家官方品牌列表(静态 HTML) resp = requests.get('https://www.autohome.com.cn/grade/', headers={'User-Agent': 'Mozilla/5.0'}) # 解析 A-Z 品牌名(简化版,实际用 BeautifulSoup) brands_on_site = ['奥迪', '宝马', '奔驰', '丰田', '本田', '大众', '比亚迪', '吉利', '蔚来', '小鹏'] # 2. 查数据库中已抓取的品牌 db_brands = list(coll.aggregate([ {"$group": {"_id": "$brand"}}, {"$project": {"brand": "$_id", "_id": 0}} ])) db_brand_set = {item['brand'] for item in db_brands} # 3. 输出缺失品牌 missing = set(brands_on_site) - db_brand_set if missing: print(f"⚠️ 缺失品牌:{missing}") # 发企业微信告警(此处省略 webhook 调用) else: print("✅ 品牌覆盖率 100%")这个脚本的价值在于:把「爬虫是否正常」翻译成业务语言。运营同学不关心scrapy crawl carhome是否 exit code 0,只关心「为什么比亚迪没数据」。
5.2 用时间窗口查「口碑断更」:最近 7 天是否有新口碑
口碑是时效性最强的数据。如果某车型最近 7 天没新增口碑,大概率是接口失效或反爬升级。聚合查询:
from datetime import datetime, timedelta # 查所有车型中,最新口碑日期早于 7 天前的 seven_days_ago = datetime.utcnow() - timedelta(days=7) stale_vehicles = list(coll.aggregate([ {"$unwind": "$year_models"}, {"$unwind": "$year_models.configs"}, {"$unwind": "$year_models.configs.koubies"}, {"$match": {"year_models.configs.koubies.date": {"$lt": seven_days_ago.isoformat()[:10]}}}, {"$group": {"_id": "$_id", "brand": {"$first": "$brand"}, "series": {"$first": "$series_name"}}}, {"$limit": 10} # 只查前 10 个 ])) if stale_vehicles: print(f"🚨 口碑断更车型(共{len(stale_vehicles)}个):") for v in stale_vehicles: print(f" {v['brand']} {v['series']}")注意:koubies.date是字符串格式"2023-08-15",所以用$lt比较字符串是可行的(ISO 格式字典序等价于时间序)。
5.3 用字段统计查「参数空值率」:识别页面结构变更
汽车之家经常改版参数页 DOM 结构,导致params字段大面积为空。我们按字段统计空值率:
# 统计 params 中各字段的非空率 field_stats = list(coll.aggregate([ {"$unwind": "$year_models"}, {"$unwind": "$year_models.configs"}, {"$project": { "engine": {"$ne": ["$year_models.configs.params.发动机", ""]}, "transmission": {"$ne": ["$year_models.configs.params.变速箱", ""]}, "size": {"$ne": ["$year_models.configs.params.长宽高", ""]} }}, {"$group": { "_id": None, "engine_rate": {"$avg": "$engine"}, "transmission_rate": {"$avg": "$transmission"}, "size_rate": {"$avg": "$size"} }} ])) stats = field_stats[0] if field_stats else {} print(f"📊 参数填充率:发动机 {stats.get('engine_rate', 0):.0%},变速箱 {stats.get('transmission_rate', 0):.0%},长宽高 {stats.get('size_rate', 0):.0%}") if stats.get('engine_rate', 0) < 0.8: print("❗ 发动机参数填充率低于 80%,疑似页面改版")这个逻辑让我在汽车之家 2024 年 3 月把「发动机」字段从<td>改成<span class="param-value">时,第一时间收到了告警,而不是等业务方来问「为什么参数没了」。
我坚持每天跑这三类检查,不是为了证明爬虫多健壮,而是为了守住一条底线:当业务同学打开 BI 看板说「数据不对」时,我能 5 分钟内定位是上游源站改版、还是我们管道漏了、还是 MongoDB 写入失败——而不是先花半小时确认「爬虫到底跑没跑」。结构化不是终点,是让数据可解释、可归因、可修复的起点。
希望帮到你。
本文还有配套的精品资源,点击获取