二手车数据爬取与可视化分析:从抓包到交互看板的完整实现 简介一套基于Python的汽车信息爬取与可视化分析系统设计展示资料面向大数据、爬虫开发及数据可视化方向的学习者适合用于课程设计、毕业设计或技术方案参考。资源围绕汽车信息采集与展示场景整合Django后端框架、Scrapy爬虫、MySQL存储以及Vue3、Element-Plus、ECharts、Pinia前端技术形成从数据抓取、清洗入库到可视化交互的完整闭环。包体共39个文件以20个Python脚本、16张PNG截图、2张JPG图片及1个TXT说明文件为主整体约38.62MBPython脚本覆盖爬虫、数据库、网络请求、数据科学等主题截图呈现系统页面与ECharts图表效果可直观感受数据展示形态。已有416人学习下载。借助源码与展示图读者可快速理解项目目录结构、数据流转逻辑和前后端联调方式尤其适合需要快速搭建同类型信息采集与分析系统的开发者。1. 汽车信息爬取与可视化分析系统在设计什么最初想查一台二手车的真实成交价手动翻了几十个详情页后意识到逐个网页看车源既看不到价格全貌也很难横向对比保值率。汽车信息爬取与可视化分析系统要解决的就是把这个过程自动化用 Python 定时抓取懂车帝这类平台的在售车源把品牌、车型、价格、里程这些散乱字段清洗成结构化数据再用可视化图表把哪类车降价快哪个价位的车源多直接摆出来。这套路的适用者很宽——数据分析师需要一手样本二手车商需要盯行情爬虫工程师则关心怎么稳定拿到数据。对系统本身来说爬取只是前半场清洗和展示才决定它能不能真正用起来。2. 数据源选型与反爬策略懂车帝二手车信息爬取的常规做法动手写代码之前先花半小时定数据源比写完再换划算得多。环境要求不高照着 python 安装教程配好 Python 3.9 以上的解释器然后pip install requests beautifulsoup4 pandas pyecharts flask就能把整条链路跑起来。选错了源后面所有解析逻辑都要重写尤其反爬策略差异极大。2.1 为什么优先选懂车帝而不是汽车之家市面可选的汽车信息源有懂车帝、汽车之家、易车、瓜子二手车。我从接口友好度、字段完整度、反爬强度三个维度做了对比结论如下数据源返回格式字段完整度反爬强度实际体验懂车帝JSON 接口高含行驶证相关字段中频率限制为主列表和详情都有独立接口汽车之家HTML JS 渲染高强需要处理复杂 Cookie适合有经验的团队啃易车部分 JSON中价格字段较粗中字段少做价格分析吃亏瓜子二手车JSON 但需登录态中强验证码出现频率高不建议作为第一数据源懂车帝是目前性价比最高的选择。它在售车源列表和详情页都由后端接口直接返回 JSON字段命名规则统一品牌、车系、上牌时间、表显里程、排量、变速箱、报价都是结构化字段省去大量正则抽取值的过程。汽车之家数据最全但反爬链路复杂列表页经过多次跳转Cookie 有效期短不适合做入门级系统的数据源。如果后续要做全量历史数据再考虑把汽车之家作为补充源而不是替代源。2.2 抓包定位真实接口bs4 爬取动态页面的替代方案用 bs4 爬取动态页面时最常见的坑是requests 直接 GET 列表页 URL返回的 HTML 里根本没有车源列表因为数据全靠页面加载后的 JS 从接口取。这时候继续写 bs4 解析没有意义正确做法是抓住真实数据接口。打开懂车帝二手车频道按 F12 进入开发者工具切到 Network网络面板刷新页面并筛选 XHR 类型的请求逐个查看返回内容。找到返回车源列表的 JSON 接口后右键复制成 cURL再转成 Python requests 代码。这个接口通常带城市 ID、页码、每页条数、排序方式等参数。以下是一段示意代码实际 URL 以你抓包结果为准import requests import time headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36, Referer: https://www.dongchedi.com/usedcar/, Accept: application/json, text/plain, */*, } # 城市ID从页面切换城市时的URL里直接复制110100远郊区的编码 params { city_id: 110100, page: 1, page_size: 20, sort_by: price, is_used: 1, } # URL必须替换成Network里看到的真实接口地址 # 不同时期接口路径会调整抓包是唯一可靠手段 url https://www.dongchedi.com/motor/pc/car/used/list resp requests.get(url, paramsparams, headersheaders, timeout10) data resp.json() print(data[data][list][0])这段代码的逻辑是先用浏览器标识和来源页构造完整的请求头避免服务器直接返回 403随后用 params 显式声明查询参数让 URL 拼接和编码交给 requests 处理中文参数不会乱码最后调用接口拿到 JSON直接索引列表第一条验证字段结构。需要注意timeout10是必须的。没有超时会让爬虫在服务器无响应时一直挂起导致后续任务全部阻塞。取到 JSON 后优先把它整体落盘一次再开始写解析避免调试时频繁请求同一条接口触发频率限制。2.3 反爬应对Headers、Cookie 与请求频率控制拿到接口只是第一步能不能稳定跑到一万条数据才是关键。第一次写爬虫的人通常会在这里翻车连续请求同一个接口几百次随后所有请求都返回 418 或空数据说明 IP 或会话被服务端临时标记了。常见做法是控制请求频率并在代码里实现重试机制。下面是我常用的请求封装import random import time import requests UA_POOL [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/122.0.0.0 Safari/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 KHTML, like Gecko Chrome/121.0.0.0 Safari/537.36, ] def fetch_with_retry(url, params, headers, max_retries3): for attempt in range(max_retries): try: # 每次请求随机抽取UA并保持同一次session内一致 headers[User-Agent] random.choice(UA_POOL) resp requests.get(url, paramsparams, headersheaders, timeout10) if resp.status_code 200: return resp.json() # 418/429都代表被限流等待更长时间再试 if resp.status_code in (418, 429): wait 5 attempt * 5 time.sleep(wait) continue resp.raise_for_status() except requests.RequestException as e: print(f第{attempt 1}次请求失败: {e}) time.sleep(2) return None # 抓下一页时先休息1~3秒再请求 for page in range(1, 6): result fetch_with_retry(url, {**params, page: page}, headers) if not result: break time.sleep(random.uniform(1, 3))参数说明max_retries3表示最多失败重试三次随机 UA 池是一种常规手段换着身份请求能避免同一种指纹被持续跟踪5 attempt * 5让等待时间随重试次数递增第一次等 5 秒第二次 10 秒给服务端留出解除限制的时间。循环里random.uniform(1, 3)随机休眠 1 到 3 秒间隔不稳定反而更接近真实用户行为。实际部署时建议把每页间隔提高到 3 到 5 秒一页一页抓五万条车源大约需要三四个小时这个量级对个人系统完全够用。提示抓取前查看平台 robots.txt只采集展示用途的公开车源信息不撞登录接口、不抓用户联系方式单机频率也不要超过对方限流阈值。爬虫系统的长期生存能力取决于你有多克制。3. 数据清洗与落库从爬虫到结构化数据爬虫拿到的 JSON 是半成品价格写成9.98万里程写成1.2万公里上牌时间是2020年08月还有大量暂无报价。这些数据不洗图表根本画不出来。Pandas 负责把脏字符串转成数值和日期SQLite 负责存储去重两步做完才能进入可视化环节。3.1 字段抽取与类型规整无论用 bs4 还是直接解析接口 JSON最终都要落成一个扁平的 DataFrame。下面是一段常用的清洗逻辑覆盖价格单位、文本型里程和年月的统一转换import pandas as pd import numpy as np df pd.DataFrame(raw_rows) # 1. 报价清洗9.98万 - 9.98 元/万暂无报价 - NaN df[price] df[price].astype(str).str.replace(万, , regexFalse) df.loc[df[price] 暂无报价, price] np.nan df[price] pd.to_numeric(df[price], errorscoerce) # 2. 表显里程1.2万公里 - 1.2 万公里 df[mileage] df[mileage].astype(str).str.replace(万公里, , regexFalse) df.loc[df[mileage].str.contains(公里, naFalse), mileage] ( df[mileage].str.replace(公里, , regexFalse).astype(float) / 10000 ) df[mileage] pd.to_numeric(df[mileage], errorscoerce) # 3. 上牌时间2020年08月 - 年月字符串 df[reg_month] df[reg_date].astype(str).str.replace(年, -, regexFalse) df[reg_month] df[reg_month].str.replace(月, , regexFalse) df[reg_year] df[reg_month].str.split(-).str[0].astype(int) print(df[[car_name, price, mileage, reg_year]].head())这段代码中每一处替换都对应一类典型问题。价格处理用replace(万)去掉单位再用pd.to_numeric(..., errorscoerce)把无法转换的空值自动变成 NaN避免了后面聚合时爆错。里程处理比价格多一步如果部分数据源用公里作单位需要先换算成万公里再统一比较不换算直接画图会导致里程范围差一个量级。上牌时间拆出年份是为了后续计算车龄如果有人想按半年粒度看保值率保留reg_month即可。3.2 去重与增量更新策略同一条车源在不同日期的抓取中出现代表它还没卖出去同一个车源 ID 在同一次抓取中出现则是一个请求边界重复导致的脏数据。两种情况处理方式不同但核心都依赖 vehicle_id 这个唯一键。我在 SQLite 里用临时表加INSERT OR REPLACE做增量写入兼顾去重和字段更新import sqlite3 conn sqlite3.connect(cars.db) # 正式表vehicle_id 为唯一键 conn.execute( CREATE TABLE IF NOT EXISTS cars ( vehicle_id TEXT PRIMARY KEY, car_name TEXT, price REAL, mileage REAL, reg_year INTEGER, series TEXT, update_time TEXT ) ) # 本次爬取结果先写入临时表避免一次处理一条的低效写法 df.to_sql(cars_tmp, conn, if_existsreplace, indexFalse) conn.execute( INSERT OR REPLACE INTO cars (vehicle_id, car_name, price, mileage, reg_year, series, update_time) SELECT vehicle_id, car_name, price, mileage, reg_year, series, datetime(now, localtime) FROM cars_tmp ) conn.execute(DROP TABLE cars_tmp) conn.commit()INSERT OR REPLACE在 vehicle_id 已存在时直接整行覆盖拿到的报价、里程是最新状态相当于天然实现了按日更新。对演示型系统来说这比每天清空重爬更合理。注意临时表的名字不能和正式表重复否则to_sql会把正式数据覆盖掉。3.3 数据质量校验清洗完成后不能直接画图至少跑一遍校验规则。下面这张表是我每次都会检查的底线节省了大量后续排错时间字段校验规则异常示例处理方式vehicle_id非空且不重复重复、空字符串丢弃该行price大于 0 且小于 500-5、9999置为 NaNmileage0 到 100 之间-2、350置为 NaNreg_year1985 到当年2100置为 NaNcar_name非空字符串空列表、纯空格丢弃该行校验时用一句 Pandas 就能完成多条件过滤valid ( (df[price].notna()) (df[price].between(0, 500)) (df[mileage].notna()) (df[mileage].between(0, 100)) (df[reg_year].between(1985, pd.Timestamp.now().year)) ) clean_df df[valid].drop_duplicates(subset[vehicle_id])价格上限设 500 是考虑到跑车和罕见车源普通家用车集中在 3 到 60 万区间里程上限 100 万公里是因为大部分家用车到不了这个数超过它很可能是数据录入错误。这两个阈值可根据数据源调整但校验逻辑本身不能省。4. 可视化分析与展示用 PyECharts 搭建交互看板数据落库之后系统的价值才开始体现。可视化的重点不是把图表堆满一页而是回答三个足够具体的问题什么价位的车源最多、哪些品牌在售量大、价格和里程是什么关系。PyECharts 生成 HTML 图表再让 Flask 把这些 HTML 组织进一个页面就是一套可展示的迷你分析系统。4.1 先定分析维度再画图做可视化分析前先明确看图表的人会做哪些判断。买二手车的人最先关注预算区间所以要画价格分布直方图二手车商关注市场供给所以要看品牌在售车源排行想判断保值率的人会盯价格与里程、车龄的关系所以散点图最有说服力。选这三个维度既能覆盖主流决策场景又能体现可视化分析的系统性。维度确定后用 Pandas 做聚合结果直接喂给 PyEChartsprice_bins [0, 5, 10, 15, 20, 30, 50, 100, 500] labels [5万内, 5-10万, 10-15万, 15-20万, 20-30万, 30-50万, 50-100万, 100万] df[price_range] pd.cut(df[price], binsprice_bins, labelslabels, rightFalse) range_counts df[price_range].value_counts().reindex(labels) brand_counts df[brand].value_counts().head(10)pd.cut把连续价格切成桶每个桶的边界是左闭右开数据落在哪个区间一眼可见。注意value_counts()之后要用reindex(labels)固定桶顺序否则 Pandas 会按字典序排列图表横轴会变成乱序影响解读。4.2 用 PyECharts 生成关键图表PyECharts 的链式 API 很适合快速产出图表下面生成品牌车源量排行和价格区间分布两张图from pyecharts import options as opts from pyecharts.charts import Bar, Pie bar ( Bar() .add_xaxis(brand_counts.index.tolist()) .add_yaxis(在售车源, brand_counts.values.tolist()) .set_global_opts( title_optsopts.TitleOpts(title品牌在售车源量 TOP10), xaxis_optsopts.AxisOpts( axislabel_optsopts.LabelOpts(rotate15), ), datazoom_opts[opts.DataZoomOpts(type_slider)], ) ) pie ( Pie() .add(车源数, [list(z) for z in zip(labels, range_counts.values.tolist())]) .set_global_opts(title_optsopts.TitleOpts(title价格区间分布)) .set_series_opts(label_optsopts.LabelOpts(formatter{b}: {c})) ) bar.render(brand_bar.html) pie.render(price_pie.html)rotate15让 x 轴品牌名旋转 15 度防止一汽奥迪这种长名称互相遮挡datazoom_opts加一个滑动缩放控件车源量级大时可以在图上拖动查看局部比静态截图更像一个完整的分析系统。Pie 的add用list(z)把标签和数值拼成[(5万内, 300), ...]的结构这是 PyECharts 标准数据格式。两张图各自渲染成 HTML下一步交给 Flask 挂载。4.3 用 Flask 把图表串成可用系统单张图表文件能打开但作为系统还差一个壳。常见做法是写一个 Flask 应用路由里从 SQLite 重新聚合数据生成图表后用render_embed()内嵌到模板from flask import Flask, render_template from pyecharts import options as opts from pyecharts.charts import Bar import sqlite3 import pandas as pd app Flask(__name__) def load_data(): conn sqlite3.connect(cars.db) df pd.read_sql(SELECT brand, price, mileage FROM cars, conn) conn.close() return df def build_brand_bar(): df load_data() brand_counts df[brand].value_counts().head(10) bar ( Bar() .add_xaxis(brand_counts.index.tolist()) .add_yaxis(在售车源, brand_counts.values.tolist()) .set_global_opts(title_optsopts.TitleOpts(title品牌车源量 TOP10)) ) return bar app.route(/) def dashboard(): bar build_brand_bar() # render_embed 返回可以直接嵌入HTML的div代码 return render_template(dashboard.html, brand_chartbar.render_embed()) if __name__ __main__: app.run(host0.0.0.0, port8080, debugFalse)模板里只需要一个变量占位符和|safe过滤器div{{ brand_chart | safe }}/div。render_embed()和render()的区别在于返回值是页面片段而不是完整 HTML适合多个图表拼一个看板。整体系统按层拆分成下面几个模块方便后续替换层模块职责数据采集spider/requests 抓接口、解析 JSON存储db.pySQLite 建表、增量写入清洗clean.pyPandas 类型转换、去重、校验分析analyze.py聚合计算、生成图表展示app.py templates/Flask 路由、HTML 模板渲染5. 设计展示的技巧与验证方法系统做完后展示环节最怕的不是代码 bug而是三件小事数据是两周前爬的、接口已失效、页面加载超过十秒。这些都和数据新鲜度与运行状态有关提前用脚本监控比现场调试靠谱。演示前要确认数据不是陈旧数据。最直接的办法是查库里的最新更新时间SELECT MAX(update_time), COUNT(*) FROM cars;如果update_time是昨天之前说明定时任务没跑或被限流中断。配合 crontab 让爬虫每天凌晨运行两次避开平台访问高峰0 3,15 * * * cd /opt/car_spider /usr/bin/python3 run.py /var/log/car_spider.log 21cd /opt/car_spider先切换到项目目录保证相对路径的配置和数据文件能正确加载日志重定向到/var/log/car_spider.log排错时不用重新打印一遍。再写一个极简监控脚本每次爬完检查入库量偏差过大时在日志里标出异常import sqlite3 conn sqlite3.connect(cars.db) row conn.execute( SELECT COUNT(*), MAX(update_time) FROM cars ).fetchone() count, latest row print(f当前车源 {count} 条最新更新 {latest}) if count 100: print(警告采集量偏低可能接口结构变了或被限流)接口结构判断在演示当天不可控但采集体量能提前预警。另一个实用技巧是随机抽五条 car_name去原平台手动搜索核对价格是否一致。抽取时用ORDER BY RANDOM() LIMIT 5命中率更高因为刻意选那条总会选到没问题的数据。验证脚本建议直接放在run.py末尾爬完自动执行一次。这样每个定时周期结束后日志里既有采集量、最新更新时间也有异常提示。把这条验证脚本加进定时任务演示时打开页面之前扫一眼日志比临时刷新页面可靠得多。本文还有配套的精品资源点击获取