简介:本资源为基于Python的二手车爬虫数据分析可视化系统毕业设计完整项目包,面向计算机、软件工程、人工智能、通信工程等专业的在校学生与教师,可用于毕业设计、课程设计、作业提交或项目初期立项演示。项目已通过测试运行,个人评分98分,适合具备一定Python基础、希望快速搭建数据采集与分析可视化流程的学习者参考或二次开发。压缩包共约2000个文件,以1745个py源码文件为核心,辅以112个h头文件、88个txt说明、15个html页面、13个json配置、11个pdf文档及少量js、css、c文件,整体约53.99MB,涵盖爬虫、数据处理、可视化与依赖库等模块。目前已有536人学习下载。读者可获得完整源码、数据库与使用文档,理解二手车数据从抓取、清洗到图表展示的实现思路,并在此基础上修改功能或迁移到其他数据场景。
1. 二手车数据从哪来:一个毕业设计选题的落地拆解
二手车价格预测、车况分析、市场行情可视化,这三个词放在一起,基本就是「基于 Python 的二手车爬虫数据分析可视化系统」这个毕业设计选题的全部野心。我见过太多同学卡在第一步:数据从哪来?要么手动编几十条假数据糊弄过去,要么爬了两天发现字段对不上、价格全是「面议」。这个选题真正值钱的地方,不是画几张 ECharts 图,而是把「采集 → 清洗 → 入库 → 分析 → 可视化」这条链路完整跑通,并且每一步都有可复现的代码和可解释的参数。它适合两类人:一是需要交出一个能演示、能答辩、代码能讲清楚的毕业设计;二是想用一个小而完整的项目练手 Python 爬虫和数据分析的入门者。下面我按自己带学生做这类项目的实际路径,把每个环节拆开讲。
2. 采集层:用 requests + parsel 抓二手车列表页
2.1 目标站点结构与请求参数拆解
二手车平台(如懂车帝二手车、瓜子二手车、二手车之家)的列表页通常采用「URL 参数 + 分页」的结构。以常见的列表页为例,核心参数一般包括城市编码、价格区间、车龄区间、分页页码。不要一上来就写代码,先用浏览器开发者工具(F12)的 Network 面板,筛选 XHR 或 Doc 请求,找到返回 HTML 或 JSON 的那一条,把它的 Request URL 和 Query String Parameters 完整复制下来。这一步决定了你后面能不能稳定翻页。
我一般会先手动改几个参数值,观察返回内容的变化:把页码从 1 改成 2,看列表是否更新;把价格区间改成 5-10 万,看结果是否收窄。确认参数生效后,再把这些参数整理成一个字典,作为请求的 params。注意,很多平台对分页有上限(比如只返回前 50 页),超过后要么返回空,要么跳回第一页,这个边界要在代码里做判断。
2.2 最小可运行爬虫代码与字段映射
下面这段代码是我常用的最小骨架,用 requests 发请求,parsel 做解析。字段映射部分需要你根据实际页面结构调整 XPath 或 CSS 选择器。
import requests from parsel import Selector import time import random # 请求头:至少带 User-Agent,有 Referer 更好 HEADERS = { "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", "Referer": "https://www.example.com/", } # 列表页基础 URL 和参数 BASE_URL = "https://www.example.com/usedcar/list" PARAMS = { "city": "110100", # 城市编码,北京为例 "price": "5-10", # 价格区间,单位万元 "page": 1, # 页码,从 1 开始 } def fetch_page(page): """抓取单页列表,返回 HTML 文本""" params = PARAMS.copy() params["page"] = page try: resp = requests.get(BASE_URL, headers=HEADERS, params=params, timeout=10) resp.raise_for_status() resp.encoding = resp.apparent_encoding # 防止中文乱码 return resp.text except requests.RequestException as e: print(f"第 {page} 页请求失败: {e}") return None def parse_list(html): """解析列表页,提取车辆卡片信息""" sel = Selector(text=html) items = [] # 以下选择器需根据实际页面调整 for card in sel.css(".car-card"): item = { "title": card.css(".car-title::text").get(default="").strip(), "price": card.css(".car-price::text").get(default="").strip(), "reg_date": card.css(".car-reg-date::text").get(default="").strip(), "mileage": card.css(".car-mileage::text").get(default="").strip(), "city": card.css(".car-city::text").get(default="").strip(), "detail_url": card.css("a::attr(href)").get(default=""), } items.append(item) return items if __name__ == "__main__": for p in range(1, 6): # 先抓 5 页测试 html = fetch_page(p) if not html: continue rows = parse_list(html) print(f"第 {p} 页抓到 {len(rows)} 条") for r in rows: print(r) time.sleep(random.uniform(1.5, 3.5)) # 随机延时,降低被封风险逻辑说明:fetch_page负责发请求并处理编码,parse_list用 CSS 选择器提取字段。参数方面,timeout=10防止卡死,apparent_encoding能自动识别 GBK 或 UTF-8,避免中文变问号。time.sleep的随机区间建议 1.5 到 3.5 秒,太短容易触发反爬,太长效率低。字段映射时,如果页面是 JSON 接口,直接把resp.json()拿到的字典按层级取值即可,比解析 HTML 更稳。
2.3 翻页策略与增量抓取
翻页不要用while True无限循环,而是先确定最大页数。常见做法是:在列表页底部找到「下一页」按钮,如果它的 class 里包含disabled,说明已到最后一页。或者观察返回的车辆数量,如果某页返回 0 条,就停止。增量抓取则是在数据库里对detail_url或车辆 ID 建唯一索引,每次插入前先查重,避免重复跑时数据翻倍。
我一般会把已抓取的页码记录到一个本地文件或 Redis 里,下次启动时从断点继续。对于毕业设计,数据量控制在 2000 到 5000 条就足够做分析和可视化了,没必要追求几十万条,反而增加清洗难度。
3. 清洗与入库:把「面议」「准新车」变成可计算字段
3.1 字段标准化:价格、里程、车龄的转换规则
爬下来的原始数据里,价格可能是「8.68万」「面议」「新车指导价 12.99 万」,里程可能是「3.2万公里」「2.8万公里」,车龄需要从「2019年3月」推算。这些字段不处理,后面做不了任何统计。我通常写一个清洗函数,用正则提取数字,再统一单位。
import re from datetime import datetime def clean_price(raw): """把价格字符串转成万元浮点数,面议返回 None""" if not raw or "面议" in raw: return None # 匹配 8.68万、8.68 万、8.68万元 m = re.search(r"(\d+\.?\d*)", raw) if m: return float(m.group(1)) return None def clean_mileage(raw): """把里程转成万公里浮点数""" if not raw: return None m = re.search(r"(\d+\.?\d*)", raw) if m: return float(m.group(1)) return None def clean_reg_date(raw): """把注册日期转成车龄(年),当前年份减去注册年份""" if not raw: return None m = re.search(r"(\d{4})", raw) if m: year = int(m.group(1)) return datetime.now().year - year return None逻辑说明:clean_price用正则抓第一个数字,兼容「8.68万」和「8.68 万元」;clean_mileage同理;clean_reg_date只取年份算车龄,忽略月份,对毕业设计足够。参数上,如果价格单位是「元」而不是「万」,需要在正则后除以 10000。清洗后的数据建议先存成 CSV 看一眼,确认没有异常值(比如价格 0.1 万、车龄 50 年)再入库。
3.2 用 SQLite 建表与批量插入
毕业设计用 SQLite 最省事,不需要装 MySQL 服务,一个文件就能带走。建表时把清洗后的字段设为 REAL 或 INTEGER,原始字段保留 TEXT 以便追溯。
import sqlite3 def init_db(db_path="usedcar.db"): conn = sqlite3.connect(db_path) cur = conn.cursor() cur.execute(""" CREATE TABLE IF NOT EXISTS car ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, price REAL, mileage REAL, car_age INTEGER, city TEXT, detail_url TEXT UNIQUE, raw_price TEXT, raw_mileage TEXT, raw_reg_date TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) conn.commit() return conn def insert_cars(conn, rows): """批量插入,detail_url 重复则忽略""" cur = conn.cursor() for r in rows: cur.execute(""" INSERT OR IGNORE INTO car (title, price, mileage, car_age, city, detail_url, raw_price, raw_mileage, raw_reg_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( r.get("title"), clean_price(r.get("price")), clean_mileage(r.get("mileage")), clean_reg_date(r.get("reg_date")), r.get("city"), r.get("detail_url"), r.get("price"), r.get("mileage"), r.get("reg_date") )) conn.commit()逻辑说明:detail_url UNIQUE配合INSERT OR IGNORE实现去重,重复抓取不会产生脏数据。raw_开头的字段保留原始文本,方便答辩时展示清洗前后对比。批量插入时不要每条都 commit,攒 100 条提交一次,速度会快很多。
3.3 缺失值与异常值处理
清洗后先跑几条 SQL 看看分布:SELECT COUNT(*), AVG(price), MIN(price), MAX(price) FROM car;。如果 MIN(price) 是 0.1,MAX 是 999,基本可以判定是异常值。常见处理方式:价格低于 0.5 万或高于 200 万的直接标记为异常,分析时排除;里程为 0 的可能是新车,保留但单独标注;车龄超过 30 年的可能是经典车,样本极少,可以剔除。缺失值方面,价格缺失占比超过 30% 时,这个字段就不适合做核心分析,只能作为辅助。
4. 分析层:用 pandas 做价格分布与影响因素
4.1 读库到 DataFrame 与基础统计
pandas 读 SQLite 只需要一行,然后就能做分组、透视、相关性分析。毕业设计里最常做的三张图:价格分布直方图、不同城市均价柱状图、车龄与价格散点图。
import pandas as pd import sqlite3 conn = sqlite3.connect("usedcar.db") df = pd.read_sql("SELECT * FROM car WHERE price IS NOT NULL", conn) # 基础统计 print(df["price"].describe()) print(df.groupby("city")["price"].mean().sort_values(ascending=False).head(10)) # 车龄分箱 df["age_bin"] = pd.cut(df["car_age"], bins=[0, 3, 6, 10, 20], labels=["3年内", "3-6年", "6-10年", "10年以上"]) print(df.groupby("age_bin")["price"].agg(["count", "mean"]))逻辑说明:WHERE price IS NOT NULL排除面议车,避免均值失真。groupby后接agg可以同时看数量和均价,判断某个车龄段的样本是否足够。如果某个城市只有 3 条数据,它的均价没有代表性,可视化时应该过滤掉样本量小于 30 的城市。
4.2 价格影响因素的相关性分析
用df[["price", "mileage", "car_age"]].corr()看皮尔逊相关系数。通常车龄和价格负相关(车越老越便宜),里程和价格也负相关,但两者之间可能高度共线。毕业设计里不需要做复杂的回归模型,用散点图加趋势线就能说明问题。如果要做线性回归,用sklearn.linear_model.LinearRegression拟合price ~ mileage + car_age,看 R² 和系数符号是否符合常识。
注意,相关性不等于因果,答辩时如果老师问「为什么里程影响价格」,要能说出「里程高意味着磨损大、剩余寿命短」这类业务解释,而不是只念系数。
5. 可视化与避坑:ECharts 大屏和五个血泪教训
5.1 用 Flask + ECharts 搭一个可交互的展示页
后端用 Flask 提供 JSON 接口,前端用 ECharts 渲染。最小结构:一个app.py,一个templates/index.html。接口返回聚合后的数据,比如各城市均价、价格区间分布。
from flask import Flask, jsonify, render_template import sqlite3 import pandas as pd app = Flask(__name__) def query_df(sql): conn = sqlite3.connect("usedcar.db") df = pd.read_sql(sql, conn) conn.close() return df @app.route("/") def index(): return render_template("index.html") @app.route("/api/city_price") def city_price(): df = query_df(""" SELECT city, AVG(price) AS avg_price, COUNT(*) AS cnt FROM car WHERE price IS NOT NULL GROUP BY city HAVING cnt >= 30 ORDER BY avg_price DESC LIMIT 15 """) return jsonify(df.to_dict(orient="records")) if __name__ == "__main__": app.run(debug=True)前端在index.html里用fetch("/api/city_price")拿数据,传给 ECharts 的setOption。图表类型建议:柱状图看城市均价,饼图看价格区间占比,折线图看车龄与均价关系。不要堆太多图,三到四张能讲清楚就行。
5.2 避坑:五个真实翻车记录
现象一:爬了 200 条后全部返回同一页内容。原因:分页参数名写错,或者平台用 POST 请求而你在用 GET。解决:回到 Network 面板确认请求方法和参数名,用requests.post重试。
现象二:价格字段全是「面议」,清洗后 DataFrame 为空。原因:目标城市或价格区间筛选太窄,或者列表页默认按「最新发布」排序,前几页都是面议车。解决:放宽价格区间,或换一个筛选条件(如「个人车源」),先保证有足够多的有效价格样本。
现象三:中文乱码,标题变成「测试」。原因:resp.encoding没设置,requests 默认用 ISO-8859-1。解决:加resp.encoding = resp.apparent_encoding,或者手动指定resp.encoding = "utf-8"。
现象四:SQLite 插入速度极慢,几千条要跑几分钟。原因:每条INSERT后都commit。解决:改成批量插入,每 100 到 500 条commit一次,或者用executemany。
现象五:ECharts 图表空白,控制台报 CORS 错误。原因:前端直接请求了外部接口,或者 Flask 没开跨域。解决:数据统一走自己的 Flask 接口,不要在前端直接请求第三方;如果必须跨域,用flask-cors加响应头。
6. 从能跑到能讲:答辩演示与代码组织的几个技巧
答辩时老师最常问的三个问题:数据怎么来的、清洗规则是什么、可视化结论有没有业务意义。我的习惯是准备一个README.md,把爬虫入口、数据库文件、分析脚本、Flask 启动命令按顺序写清楚,再配一张数据流转图(用文字描述即可,不要画复杂架构)。演示时先跑python crawler.py抓 2 页,再跑python clean.py入库,最后python app.py打开页面,整个流程控制在 3 分钟内。
代码组织上,不要把全部逻辑塞进一个文件。我一般拆成crawler.py(采集)、cleaner.py(清洗)、db.py(建表和插入)、analysis.py(pandas 分析)、app.py(Flask 接口)。每个文件只做一件事,答辩时老师翻代码也能快速定位。另外,把requirements.txt写好,pip install -r requirements.txt能一键装依赖,避免换电脑后跑不起来。
最后一个技巧:在数据库里留一张crawl_log表,记录每次抓取的页码、时间、条数。答辩时如果老师问「你抓了多少数据」,直接SELECT SUM(cnt) FROM crawl_log就能回答,比临时数 CSV 行数靠谱得多。这个习惯我从第一个项目保持到现在,希望帮到你。
本文还有配套的精品资源,点击获取