
简介《基于Python的二手车销售数据分析设计与实现》是一份完整的计算机毕业设计论文面向Python方向毕设选题、数据库开发及数据可视化项目人群解决二手车数据提取与辅助决策问题。论文基于Python爬虫抓取人人车网站真实车辆信息使用MySQL搭建后台存储并设计合理数据库结构再通过Matplotlib、Seaborn等可视化库展示价格、里程、品牌与销量等维度关系同时涵盖前端查询界面和后台管理功能测试验证了数据爬取、存储过滤、可视化分析与系统管理能力的稳定性。包体为单个doc文档共1个文件大小1.92MB全文包含需求分析、技术方案、算法设计、功能测试与可视化结果可直接对照自身项目进行功能拆解、代码复现和论文排版参考。该资源已有344人学习对于需要快速理解爬虫结合数据可视化系统完整架构的读者具有较高参考价值。1. 二手车数据爬取与分析从人人车页面到可视化大屏的完整落地如果你买过二手车大概率体会过这种痛苦同一个车型有人挂 8 万有人挂 11 万车况描述大同小异根本不知道真实行情在哪。这篇笔记要拆的就是一个基于 Python 的二手车销售数据分析系统爬取人人车网上的车辆信息清洗后存入 MySQL再通过 K-means 聚类和可视化图表把价格、里程、车龄、品牌之间的关系呈现出来。它不是花架子演示而是我从页面结构分析、爬虫编写、数据入库到图表输出的完整过程中间还踩了不少坑。这套思路可以直接套用到任何垂直领域的数据采集与分析适合正在做 Python 爬虫或数据可视化课题的同学也适合想给自己的二手车交易决策找点数据支撑的从业者。2. 聚焦型爬虫怎么搭从人人车页面到结构化数据2.1 为什么要选聚焦型爬虫只抓二手车别的什么都不碰网络爬虫按覆盖范围分三类全网型、增量型和聚焦型。搜索引擎用的是全网型加增量型的组合先全网收录再周期性更新这对服务器和存储的要求都很高不适合个人项目。本文系统爬取的目标很明确——人人车网的二手车列表页和详情页所以选择聚焦型爬虫只针对二手车主题抓取其他内容一概忽略。这个选择的直接收益是爬取量小、规则简单、不容易触发对方的风控。聚焦型爬虫的典型工作流是指定起始 URL发起 HTTP 请求获取页面 HTML解析出列表中的车辆详情页链接再逐个请求详情页提取车型、价格、里程、上牌时间等字段。相比全网爬虫不需要维护复杂的 URL 队列和去重库但依然要处理翻页、链接去重和请求频率控制。也就是说核心难点从「怎么爬得多」转移到了「怎么爬得准、爬得干净」。我一般会用 requests 库做页面请求用正则表达式和 CSS 选择器做内容提取。请求时重点关注 User-Agent 和 Referer 字段很多网站对缺失 UA 的空请求直接返回 403。爬取节奏上每请求一个页面后强制 sleep 1 到 2 秒这个项目用的是固定延时简单可靠不引入随机延时也能跑完几万条数据。2.2 爬虫骨架与翻页规则先分析 URL 规律再写代码动手写爬虫前需要先打开人人车网站手动翻几页列表观察 URL 的变化规律。常见设计是类似/buy/page-2/这种路径参数或者?page2这种查询参数。搞清楚分页参数后爬虫骨架基本就定型了。下面是一个典型的列表页爬取框架import requests import re import time headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://www.renrenche.com/ } def fetch_list_page(page_no): url fhttps://www.renrenche.com/buy/page-{page_no}/ try: resp requests.get(url, headersheaders, timeout10) resp.encoding utf-8 if resp.status_code ! 200: print(fpage {page_no} failed, status: {resp.status_code}) return [] return resp.text except requests.RequestException as e: print(fpage {page_no} request error: {e}) return [] # 翻页采集前几页做验证 for page in range(1, 6): html fetch_list_page(page) detail_links re.findall(rhref(/buy/[\w-]-\d/), html) print(fpage {page}: got {len(detail_links)} detail links) time.sleep(2)这段代码做了三件事构造带 UA 和 Referer 的请求头请求指定页码的列表页再用正则从 HTML 中提取详情页链接。需要注意timeout10参数避免某个页面长时间无响应导致爬虫卡死。正则里的[\w-]匹配车型的英文别名和 ID 组合这里要先观察真实链接格式再调整比如有的车源链接是/buy/baoma-3xi-12345/。2.3 详情页字段提取用正则和 CSS 选择器各取所长进入详情页后需要提取的字段包括车辆标题、价格、上牌时间、表显里程、排量、变速箱、所在城市、车源编号。不同字段在 HTML 中的位置差别很大价格通常在页面顶部的span classprice里里程和上牌时间往往在一组ul列表项中。我习惯的做法是优先用正则提取有明显规律的字段再用 CSS 选择器兜底处理复杂嵌套结构。def parse_detail(html): item {} # 价格页面中通常表现为 xx.xx万 price_match re.search(rclassdetail-price[^]*[^]*\s*([\d.])\s*万, html) item[price] float(price_match.group(1)) if price_match else None # 里程类似 3.2万公里 mile_match re.search(r([\d.])\s*万公里, html) item[mileage] float(mile_match.group(1)) if mile_match else None # 上牌时间形如 2019年08月 date_match re.search(r(\d{4})年(\d{1,2})月, html) if date_match: item[reg_year] int(date_match.group(1)) item[reg_month] int(date_match.group(2)) return item这里re.search只会返回第一个匹配结果如果页面里出现多条「万公里」信息比如保养记录里也有就会取错。这个坑我实际遇到过后面清洗章节会详细说。更稳妥的方案是改用 BeautifulSoup 定位到车辆参数区块再提取但正则速度更快适合单页字段少、结构稳定的情况。实际项目中我是正则和选择器混用——价格用正则参数列表用 BeautifulSoup。2.4 链接去重与增量更新避免重复抓同一辆车列表页翻页时同一个车源偶尔会在不同页码重复出现。如果不做去重数据库里会攒下一堆重复数据后续聚类的统计结果全被带偏。最简单的方案是维护一个已采集链接的集合采集前先检查。再进一步把车源编号作为唯一键写进 MySQL 表里插入时用INSERT IGNORE跳过重复记录。seen_links set() def fetch_new_links(html): links re.findall(rhref(/buy/[\w-]-\d/), html) new_links [] for link in links: if link not in seen_links: seen_links.add(link) new_links.append(link) return new_links增量更新的思路也是一样的已抓过的车源编号存在数据库里每次爬取前先查一下car_source_id是否已存在存在就跳过。这样程序可以间隔几天再跑一次只抓新增车源对目标网站的访问压力也更小。这个设计对应的是论文里提到的增量型爬虫思路只不过应用在聚焦爬虫的更新阶段。3. 数据清洗与 MySQL 入库脏数据不处理可视化全是错的3.1 爬下来只是开始数据里的典型垃圾问题爬虫落地后数据质量才是决定后续分析能不能用的关键。这个项目里遇到的脏数据问题主要有四类。第一是价格单位不统一有的页面报价显示「8.5万」有的显示「85000」还有极少数直接缺失。第二是里程字段被「表显里程」和「真实里程」混用同一辆车页面里出现两个里程值正则取到哪个全凭运气。第三是上牌时间格式五花八门有「2019年08月」「2019-08」「2019.8」。第四是重复数据同一个车源在列表页不同位置重复出现。价格缺失和格式不统一直接影响聚类里程取错直接影响价格-里程散点图的可信度所以清洗不是可有可无的步骤而是分析的前置条件。我一般会把清洗拆成三步格式标准化、异常值剔除、缺失值处理。每一步都写到独立函数里方便单独测试。3.2 异常值检测里程和价格明显超纲的直接丢掉价格 0 元、里程 0 公里、车龄超过 30 年这些数据大概率是页面渲染异常或者数据录入错误没有分析价值。处理策略很简单设定合理范围超出范围直接剔除。价格范围我按「1 万到 200 万」过滤超过这个区间的二手车在普通消费场景里意义不大。里程范围按「0.1 万到 50 万公里」过滤低于 0.1 万公里的通常是新车或测试数据。def clean_price(value): if value is None: return None # 统一单位为万元 if value 1000: value value / 10000 return value if 1 value 200 else None def clean_mileage(value): if value is None: return None return value if 0.1 value 50 else None这两个函数是清洗管线的第一道闸。注意价格判断里value 1000的处理如果页面中价格字段给的是「85000」这类元单位数值除以 10000 转成万元保证全表口径统一。异常值剔除后再做缺失值统计这时缺失比例超过 5% 的字段就要回头检查爬虫解析逻辑谈不上填补先修解析才对。3.3 MySQL 表结构设计与批量插入单条插入慢到怀疑人生数据清洗之后面临的问题就是入库速度。最开始我逐条执行INSERT爬到 2000 条数据耗时近 20 分钟。改成批量插入后同样数据量几秒就完成。核心原因是 MySQL 的写入开销主要在网络往返和事务提交上批量插入把大量记录合并到一条 SQL 里减少交互次数。import pymysql def get_connection(): return pymysql.connect( hostlocalhost, userroot, password123456, databasecar_sales, charsetutf8mb4 ) def batch_insert(items): sql INSERT IGNORE INTO car_info (car_source_id, brand, model, price, mileage, reg_year, reg_month, city) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) conn get_connection() cursor conn.cursor() cursor.executemany(sql, items) conn.commit() cursor.close() conn.close()参数说明car_source_id是车源唯一编号来自详情页链接末尾的数字串brand和model分别存品牌与车型名price单位为万元浮点数存储mileage单位也是万公里reg_year和reg_month分开存整型方便后续算车龄city存车辆所在城市。executemany是 pymysql 提供的批量执行接口传入一个元组列表即可。INSERT IGNORE配合car_source_id的唯一索引重复数据直接跳过不报错也不占用额外空间。3.4 车龄计算与特征构造聚类要的不是日期是数字数据库里存了上牌年份和月份但聚类算法要求输入是数值型特征。所以分析前必须先把日期转成车龄公式是当前年份减去上牌年份月份差再除以 12 做细化。另外可以构造一个「每公里价格」指标即价格除以里程用于衡量车辆性价比这个指标在聚类里往往比价格或里程单独使用更有区分度。def calc_age(row, current_year2024): age current_year - row[reg_year] month_adjust row[reg_month] / 12.0 if row[reg_month] else 0 return round(age - month_adjust, 2) def calc_unit_price(row): if row[mileage] and row[mileage] 0: return round(row[price] / row[mileage], 2) return None车龄计算里没有直接用current_year - reg_year完事是因为同年 1 月和 12 月上牌的车实际车龄差了将近一年用月份折算后聚类结果更符合直觉。calc_unit_price直接除有除零风险所以加了里程大于 0 的判断。这两个特征构造完后数据就可以从 MySQL 导出到 DataFrame 里做分析了。4. K-means 聚类与可视化把价格-里程关系画成人看得懂的图4.1 为什么这里要上聚类算法直方图能展示价格分布散点图能展示价格和里程的关系但它们只是把数据画出来不能告诉用户「这辆车在市场上属于哪个档位」。K-means 聚类的价值在于无监督地把数据分成几簇让用户直观看到「6 万以下代步车」「10-15 万家庭轿车」「25 万以上豪华车」这样的分组边界。论文项目里提到评分和 K-means 聚类算法分析二手车销售数据趋势实际落地时我用聚类算法划分车源价格档位再用群体的统计特征解释每个簇的含义。K-means 的核心逻辑是随机初始化 K 个中心点计算每个样本到中心的距离分配到最近的中心然后重新计算每个簇的中心位置重复迭代直到中心点不再明显变化。Python 里直接用 scikit-learn 的KMeans类不用自己实现迭代过程但特征标准化和 K 值选择必须自己做否则聚类结果会失真。4.2 特征标准化价格和里程量纲不同不归一化就是乱分这个坑特别典型价格范围是 1 到 200里程范围是 0.1 到 50数值上价格比里程大一个量级。K-means 基于欧氏距离计算样本相似性量纲大的特征会主导距离计算导致聚类结果几乎只由价格决定里程变成了摆设。解决方法是标准化让所有特征均值接近 0、方差接近 1。import pandas as pd from sklearn.preprocessing import StandardScaler from sklearn.cluster import KMeans import matplotlib.pyplot as plt import matplotlib matplotlib.rcParams[font.sans-serif] [SimHei] matplotlib.rcParams[axes.unicode_minus] False df pd.read_sql(SELECT price, mileage, age FROM car_info WHERE price IS NOT NULL AND mileage IS NOT NULL, conn) features df[[price, mileage, age]].dropna() scaler StandardScaler() X_scaled scaler.fit_transform(features)StandardScaler对每一列独立计算均值和标准差然后做(x - mean) / std变换。变换后的数据分布在 0 附近不同特征对距离计算的贡献就均衡了。这里我把车龄也加进去了因为车龄对二手车定价的影响很大同价位车龄不同的车市场定位完全不同。4.3 用肘部法则选 K 值不要拍脑袋定 3 或 4K 值选多少直接决定聚类结果粒度。选 3 可能把 15 万和 20 万的车硬划到一起选 6 又可能把一个连续区间切得支离破碎。我一般用肘部法则辅助判断计算不同 K 值下的簇内误差平方和SSE画折线图找到拐点位置。sse [] K_range range(2, 9) for k in K_range: km KMeans(n_clustersk, random_state42, n_init10) km.fit(X_scaled) sse.append(km.inertia_) plt.plot(list(K_range), sse, markero) plt.xlabel(K value) plt.ylabel(SSE) plt.title(Elbow Method for K Selection) plt.show()km.inertia_返回的是簇内样本到中心点距离的平方和K 增大时 SSE 单调下降但下降幅度在某个点后明显变缓这个拐点就是比较合理的 K 值。n_init10表示对同一 K 值用不同随机初始中心跑 10 次取最优结果降低随机性带来的不稳定。跑完这个数据集后我把 K 定在了 4因为 SSE 在 K4 之后下降曲线明显变平且 4 个簇的经济解释也清晰——代步车、家用车、中高端、豪华。4.4 聚类结果可视化散点图和簇中心一起画聚类完成后把聚类标签加到原始数据里做一个价格-里程散点图不同簇用不同颜色标记同时把簇中心点标注出来。这样用户一眼就能看到价格带上哪些车集中、哪些区间竞争激烈。km_final KMeans(n_clusters4, random_state42, n_init10) features[cluster] km_final.fit_predict(X_scaled) # cluster centers are in scaled space, inverse_transform back to original centers scaler.inverse_transform(km_final.cluster_centers_) plt.figure(figsize(10, 6)) scatter plt.scatter(features[mileage], features[price], cfeatures[cluster], cmapviridis, alpha0.6, s10) plt.scatter(centers[:, 1], centers[:, 0], cred, markerX, s200, labelCenters) plt.xlabel(Mileage (10k km)) plt.ylabel(Price (10k CNY)) plt.title(Second-hand Car Price Clusters by Mileage) plt.colorbar(scatter) plt.legend() plt.show()需要注意centers的列顺序要和标准化之前的特征顺序对应features的列顺序是[price, mileage, age]所以centers[:, 0]是价格坐标centers[:, 1]是里程坐标。如果列顺序搞混图上的中心点位置就会错位。这个细节我调试的时候浪费了小半天只看代码根本发现不了问题一定要回头核对列顺序。可视化输出这里用的 Matplotlib但论文系统里展示给用户的页面用 ECharts 更多——价格分布柱状图、品牌销量 Top10 条形图、价格-里程散点图、各地区车源数量饼图。Matplotlib 负责分析阶段的探索ECharts 负责最终呈现两者各司其职。数据大屏上我还会把每个聚类的中心点参数和簇内样本数一起展示形成一个「车源分段速览」板块。5. 上线前避坑记录反爬、编码、数据量和可视化乱码5.1 请求频率稍高就被拦截返回 403 或验证码现象爬虫跑到几百页之后突然连续返回 403再往后直接弹验证码页面。原因请求频率超过网站阈值IP 被短期封禁UA 和 Referer 如果不够真实也会被风控识别。解决第一请求头补全包括 UA、Referer、Accept、Accept-Language第二每次请求之间固定 sleep 1-2 秒不要并发第三给爬虫加一个失败重试机制遇到 403 先停 30 秒再重试第四数据量够用就收手不要追求全量爬完。这个项目我最终只爬了前 50 页就停了分析样本量已经足够支撑聚类和可视化。def safe_request(url, retries3): for i in range(retries): try: resp requests.get(url, headersheaders, timeout10) if resp.status_code 200: return resp elif resp.status_code 403: print(fblocked by anti-crawler, wait 30s, attempt {i1}) time.sleep(30) except requests.RequestException as e: print(frequest error: {e}) time.sleep(2) return None重试逻辑是硬需求。第 1 次失败可能是网络抖动第 3 次失败大概率是被封这时候该做的是停止请求而不是继续撞墙。5.2 中文标题和城市名乱码入库后全是问号现象爬下来在控制台打印正常入库后 SELECT 出来全是问号或乱码。原因HTML 页面是 GBK 编码requests 默认按 ISO-8859-1 解码另外 MySQL 表或连接没有指定 utf8mb4 字符集。解决请求时手动指定resp.encoding gbk或根据页面 meta 标签判断编码MySQL 建表时字符集设为utf8mb4连接参数里也带上charsetutf8mb4。这两个环节缺一个都会乱码。resp.encoding resp.apparent_encoding # 会根据页面内容猜测编码apparent_encoding是通过页面字节流统计特征判断编码对中文页面正确率很高但速度慢一点。项目里如果确认目标网站是 GBK 或 UTF-8直接写死效率更高不用每次猜。5.3 数据量过万后单条 SELECT 查询越来越慢现象数据量到 2 万条以后前端按品牌或城市查询时明显卡顿要等好几秒。原因没有给查询字段建索引MySQL 每次做全表扫描。解决对brand、city、price分别建立普通索引。ALTER TABLE car_info ADD INDEX idx_brand (brand); ALTER TABLE car_info ADD INDEX idx_city (city); ALTER TABLE car_info ADD INDEX idx_price (price);索引对查询的加速效果立竿见影。需要注意不要在聚类用的数值字段上建立过多冗余索引因为每次批量插入时索引也要同步更新索引太多反而拖慢写入速度。这里只对高频查询字段建索引就够了。5.4 聚类结果每次跑都不一样现象同一份数据同一份代码聚类跑两次结果不完全相同簇边界有偏移。原因KMeans 的初始中心点是随机生成的不同随机种子对应的迭代结果不同。解决固定random_state参数让每次运行使用相同的随机序列同时设置n_init10对同一 K 值跑多次取最优降低陷入局部最优的概率。项目代码里random_state42就是干这个用的。如果做完这些结果仍然不稳定检查是否用了流式分批训练KMeans 的partial_fit方式不适合这种场景。5.5 Matplotlib 图里的中文标签显示成方块现象标题和图例中文全部显示成小方框。原因Matplotlib 默认字体不包含中文字形Linux 系统下尤其常见。解决在脚本开头指定中文字体Windows 用SimHeiLinux 检查系统有没有文泉驿或 Noto Sans CJK。matplotlib.rcParams[font.sans-serif] [SimHei] matplotlib.rcParams[axes.unicode_minus] False第二行unicode_minus用来修复负号显示问题不设置的话坐标轴负号也会变成方块。这个坑在所有可视化项目里都会遇到提前写在开头省得每次调试。6. 进阶用法把爬虫和分析串成定时任务大屏数据每天自动更新系统做完以后最难受的问题是数据是死的——爬一次只能看到那一批数据。论文项目里的「大屏显示」功能真正要运转起来必须让数据自动更新。我用 APScheduler 把爬虫、清洗、聚类、可视化四个环节串成一个完整流程每天早上自动跑一遍大屏上看到的就是最新行情。from apscheduler.schedulers.blocking import BlockingScheduler from datetime import datetime def daily_job(): print(f[{datetime.now()}] start daily crawling job) try: # 1. 增量爬取新上车源 crawl_new_cars() # 2. 清洗并写入 MySQL clean_and_store() # 3. 重新训练聚类模型 retrain_cluster_model() # 4. 输出最新图表和大屏 JSON 数据 export_visualization_data() except Exception as e: print(fjob failed: {e}) scheduler BlockingScheduler() scheduler.add_job(daily_job, cron, hour6, minute30) scheduler.start()这个定时任务的核心是把四个函数串成事务式流程某一步失败就整体记录日志不推进后续步骤避免用不完整的脏数据刷新大屏。实际部署时我用cron触发器指定在早上 6:30 执行因为那个时间目标网站访问量低请求成功率更高BlockingScheduler适合单独跑一个爬虫脚本文件如果系统里还有其他 Web 服务建议改用BackgroundScheduler避免阻塞主进程。另一方面聚类模型没必要每天都重训。二手车价格分布变化是渐进的每周重新训练一次足够。我把retrain_cluster_model单独提出来用周级调度任务跑日级任务只做增量爬取和数据清洗。这样既保证大屏数据新鲜又不浪费计算资源。从那以后我每次部署这类采集分析系统都会强制走一遍这个流程先看页面结构再写爬虫清洗逻辑跑在入库之前聚类前检查特征量纲所有定时任务带异常重试和日志最后用固定随机种子保证结果可复现。这套习惯让项目从「能跑」变成了「每天都能自动跑」少踩了很多回头坑。希望这次的拆解对你有帮助照着这个思路去做你也能把一套爬虫加可视化的系统完整落地。本文还有配套的精品资源点击获取