Scrapy+MongoDB构建汽车之家结构化数据管道
简介本资源是一个面向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(bodynew_body)手动注入解析后的 HTML比 Playwright 多一层 DOM 序列化开销。提示本方案默认关闭 JavaScript 渲染所有动态内容通过逆向分析 XHR 接口获取。汽车之家的车型参数、口碑列表、论坛帖子均有稳定 API只需破解 Referer 和加密参数即可。2.2 逆向关键接口三类核心数据的请求构造逻辑汽车之家未公开 API但通过浏览器开发者工具可定位以下三类高频接口已脱敏处理实际使用需替换xxx数据类型示例 URL关键 Header / 参数说明车型参数https://car.autohome.com.cn/config/series/xxxxx.htmlReferer: https://www.autohome.com.cn/xxxxx/返回完整 HTML含table classtbcs需解析表格行用户口碑https://koubei.app.autohome.com.cn/pc/GetKoubeiList?specidxxxxxpageindex1pagesize10无特殊 header但specid必须为整数返回 JSONresult.list[]含口碑摘要、评分、时间论坛帖子https://forum.app.autohome.com.cn/forum/GetPostList?forumIdxxxxxpageindex1pagesize20User-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( urlhttps://www.autohome.com.cn/grade/carhtml/A.html, callbackself.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( urlfhttps://car.autohome.com.cn/config/series/{series_id}.html, callbackself.parse_config_page, meta{series_id: series_id}, headers{Referer: fhttps://www.autohome.com.cn/{series_id}/} ) # 3. 构造口碑列表请求JSON API yield scrapy.Request( urlfhttps://koubei.app.autohome.com.cn/pc/GetKoubeiList?{urlencode({specid: series_id, pageindex: 1, pagesize: 10})}, callbackself.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_requestScrapy 默认使用 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, maxPoolSize100, minPoolSize10) 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()}}}, upsertTrue ) return item关键参数说明maxPoolSize100Scrapy 默认并发 16设为 100 预留余量防突发流量minPoolSize10保持常驻 10 个空闲连接避免冷启动延迟upsertTrue用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, maxPoolSize100, minPoolSize10) 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是NoneMongoDB 直接拒绝写入并抛出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( urlapi_url, callbackself.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 就不同导致同一页数据被重复请求如网络超时后重试又发了一次pageindex1。而 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 classtbcs但解析出的参数全是空字符串原因汽车之家在 2023 年底将参数表格改为「懒加载」初始 HTML 只有骨架trtd colspan2暂无数据/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(rwindow\.__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(fFailed 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( urlforum_api, callbackself.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(days7) 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 classparam-value时第一时间收到了告警而不是等业务方来问「为什么参数没了」。我坚持每天跑这三类检查不是为了证明爬虫多健壮而是为了守住一条底线当业务同学打开 BI 看板说「数据不对」时我能 5 分钟内定位是上游源站改版、还是我们管道漏了、还是 MongoDB 写入失败——而不是先花半小时确认「爬虫到底跑没跑」。结构化不是终点是让数据可解释、可归因、可修复的起点。希望帮到你。本文还有配套的精品资源点击获取