简介:本资源是一套完整落地的二手车数据采集与分析毕业设计项目,面向计算机、数据科学相关专业本科生及课程设计学习者,解决从网页爬取、数据清洗到可视化呈现的全流程实践问题。压缩包共2000个文件,主体为1745个Python源码(含爬虫、ETL、绘图及Flask后端模块),辅以112个头文件(支持扩展编译)、88个说明文本、13个JSON配置及11个PDF文档(含需求分析、系统设计与答辩报告),整体54.99MB,结构清晰、模块解耦。已有658人学习下载,项目经导师指导并获97分高分答辩评价,可直接用于毕业设计、课程大作业或自学实战。用户将获得可一键运行的全栈代码、本地SQLite数据库文件、详细技术文档及典型二手车平台(如瓜子、人人车)的数据采集逻辑实现,涵盖反爬绕过、动态渲染处理与多维度可视化图表生成。
1. 这不是“爬完就扔”的毕业设计,而是一套可复用的二手车数据闭环分析链路
很多同学做毕业设计时,把爬虫写完、存进 CSV 就算交差,结果答辩被问“数据怎么清洗?”“价格异常值怎么处理?”“可视化图表能反映真实市场趋势吗?”当场卡壳。这个基于 Python 的二手车爬虫数据可视化分析项目,恰恰补上了这关键一环:它不是单点工具包,而是一条从网页抓取 → 结构化存储 → 清洗校验 → 特征工程 → 多维可视化 → 本地数据库持久化的完整闭环。项目使用 SQLite 作为嵌入式数据库(非 MySQL 或 PostgreSQL),所有表结构、索引、初始数据均预置在car_data.db中,开箱即用;可视化部分未依赖 Web 框架,而是采用 Matplotlib + Seaborn + Plotly 离线渲染,生成 HTML 报告和 PNG 图表双输出,规避了部署服务器或配置前端的麻烦。适合计算机、信息管理、统计学等专业学生快速上手复现,也适合作为数据分析入门者理解“真实业务数据流”的教学样本——你拿到的不是代码快照,而是一个能跑通、能调试、能改参数、能换源站的轻量级分析系统。
2. 为什么选 Requests + BeautifulSoup 而非 Scrapy?爬取逻辑与反爬绕过实操
2.1 选型依据:轻量、可控、易调试,契合毕业设计场景
Scrapy 功能强大但学习曲线陡峭,且默认异步机制在本地单机运行时优势不明显;而本项目面向的是典型二手车垂直平台(如某瓜、某信等类站),页面结构稳定、无强 JS 渲染、分页规则清晰。Requests + BeautifulSoup 组合具备三大不可替代优势:第一,HTTP 请求细节完全暴露(headers、cookies、timeout、session 复用),便于观察响应状态码、重定向链、Referer 验证失败原因;第二,BeautifulSoup 解析 DOM 时支持多种 parser(lxml / html.parser),对 malformed HTML 容错性强,避免因页面微小语法错误导致整个解析中断;第三,调试成本极低——可逐行 printsoup.find_all('div', class_='price')查看原始节点,无需启动 Scrapy shell 或配置 CrawlSpider 规则。项目中spider.py的核心循环仅 47 行,却覆盖了翻页控制、URL 去重、请求延迟、状态码校验四层防护,比“黑盒式”框架更利于答辩时讲清技术决策。
2.2 关键爬取逻辑拆解:动态 URL 构造与字段映射表驱动
项目未硬编码目标网站域名,而是通过config.py中的BASE_URL和SEARCH_PARAMS实现站点切换:
# config.py BASE_URL = "https://www.xxx.com" SEARCH_PARAMS = { "city": "shanghai", "brand": "toyota", "year_min": 2015, "price_max": 150000 }实际请求 URL 由url_builder.py动态拼接:
from urllib.parse import urlencode def build_list_url(base_url, params): query_str = urlencode(params) return f"{base_url}/list?{query_str}" # 示例输出: https://www.xxx.com/list?city=shanghai&brand=toyota&year_min=2015&price_max=150000提示:
urlencode()自动处理中文字符编码(如城市名“北京”转为%E5%8C%97%E4%BA%AC),避免手动调用urllib.parse.quote()出错。若目标站使用 POST 提交搜索表单,需将urlencode()替换为data=params并修改requests.post()调用方式。
字段提取采用“选择器-映射表”双层设计,在parser.py中定义:
FIELD_SELECTORS = { 'title': ('h2', {'class': 'title'}), 'price': ('span', {'class': 'price-num'}), 'mileage': ('li', {'data-index': '2'}), # 里程在第3个li标签 'reg_date': ('span', {'class': 'date'}), 'location': ('div', {'class': 'location'}) } def extract_car_info(soup): data = {} for field, (tag, attrs) in FIELD_SELECTORS.items(): elem = soup.find(tag, attrs) data[field] = elem.get_text(strip=True) if elem else None return data2.2.1 为什么用字典映射而非硬编码 find()?
- 可维护性:当网站改版时,只需修改
FIELD_SELECTORS字典中的 CSS 选择器,无需改动提取逻辑; - 容错性:
elem.get_text(strip=True)对空元素返回None,后续清洗阶段统一处理缺失值,避免AttributeError; - 扩展性:新增字段(如“排放标准”)只需追加键值对,不影响已有流程。
2.3 反爬应对三板斧:User-Agent 轮换、请求间隔、Session 复用
项目内置user_agents.py提供 12 个主流浏览器 UA 字符串,并在每次请求前随机选取:
import random USER_AGENTS = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36...", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36..." ] def get_random_headers(): return { "User-Agent": random.choice(USER_AGENTS), "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7", "Connection": "keep-alive" }spider.py中关键控制逻辑:
import time from requests import Session session = Session() session.headers.update(get_random_headers()) for page in range(1, MAX_PAGES + 1): url = build_list_url(BASE_URL, {**SEARCH_PARAMS, "page": page}) try: resp = session.get(url, timeout=10) resp.raise_for_status() # 抛出 4xx/5xx 异常 soup = BeautifulSoup(resp.text, 'lxml') cars = parse_page(soup) save_to_db(cars) # 直接写入 SQLite print(f"✅ Page {page} crawled, {len(cars)} records") time.sleep(random.uniform(1.5, 3.0)) # 随机延时 1.5~3.0 秒 except Exception as e: print(f"❌ Page {page} failed: {e}") continue注意:
Session()复用 TCP 连接,减少握手开销;time.sleep()使用random.uniform()避免固定间隔被识别为机器人;resp.raise_for_status()是必须步骤,否则 403/404 错误会被静默吞掉,导致数据缺失却不报错。
3. SQLite 数据库设计与清洗策略:从原始爬虫记录到分析就绪表
3.1 数据库结构解析:三张核心表的职责划分
项目附带的car_data.db包含以下表结构(可通过sqlite3 car_data.db ".schema"验证):
| 表名 | 字段 | 类型 | 说明 |
|---|---|---|---|
raw_cars | id, html_content, crawl_time, url | INTEGER, TEXT, DATETIME, TEXT | 原始 HTML 快照,用于二次解析或审计 |
cleaned_cars | id, title, price, mileage, reg_date, location, brand, model, year | INTEGER, TEXT, REAL, REAL, DATE, TEXT, TEXT, TEXT, INTEGER | 主分析表,所有字段已标准化 |
crawl_log | id, start_time, end_time, total_pages, success_count, error_count | INTEGER, DATETIME, DATETIME, INTEGER, INTEGER, INTEGER | 爬取过程日志,支持溯源 |
提示:
raw_cars表的存在是本项目区别于“一次性脚本”的关键——它允许你在清洗逻辑变更后,重新解析历史 HTML,无需重复爬取,极大提升迭代效率。
3.2 清洗函数clean_data.py的核心实现
清洗不是简单去空格,而是针对二手车数据特性的多层校验:
import re from datetime import datetime def clean_price(text): """提取数字价格,单位统一为万元""" if not text: return None # 匹配 ¥12.5万、12.5万元、125000元、12.5w 等格式 match = re.search(r'(\d+\.?\d*)[万wW]|[¥¥](\d+\.?\d*)', text) if match: num = float(match.group(1) or match.group(2)) # 若原字符串含"万",则直接返回;否则视为元,转为万元 if '万' in text or 'w' in text.lower(): return round(num, 2) else: return round(num / 10000, 2) return None def clean_mileage(text): """里程单位统一为万公里""" if not text: return None match = re.search(r'(\d+\.?\d*)[万公里]', text) if match: return float(match.group(1)) # 处理 "3.5万公里" → 3.5;"120000公里" → 12.0 num_match = re.search(r'(\d+\.?\d*)', text) if num_match: num = float(num_match.group(1)) return round(num / 10000, 1) if num > 1000 else round(num, 1) return None def clean_reg_date(text): """年份提取:支持 2020年、2020.03、2020-03 等格式""" if not text: return None year_match = re.search(r'(20\d{2})', text) if year_match: return int(year_match.group(1)) return None3.2.1 为什么清洗函数要独立成模块?
- 可测试性:每个函数可单独单元测试(如
assert clean_price("¥125000") == 12.5); - 可复用性:清洗逻辑可迁移到其他车型数据源;
- 可审计性:清洗前后数据差异可追溯,答辩时能展示“原始值→清洗后值”对照表。
3.3 数据库操作封装:避免 SQL 注入与事务安全
db_utils.py使用参数化查询防止注入,并确保插入原子性:
import sqlite3 def insert_cars(conn, cars): """批量插入清洗后数据,启用事务""" cursor = conn.cursor() try: cursor.execute("BEGIN TRANSACTION") for car in cars: cursor.execute(""" INSERT INTO cleaned_cars (title, price, mileage, reg_date, location, brand, model, year) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( car['title'], car['price'], car['mileage'], car['reg_date'], car['location'], car['brand'], car['model'], car['year'] )) conn.commit() print(f"✅ {len(cars)} records inserted") except Exception as e: conn.rollback() print(f"❌ Insert failed: {e}") # 使用示例 conn = sqlite3.connect("car_data.db") insert_cars(conn, cleaned_data) conn.close()注意:
?占位符是 SQLite 参数化查询的唯一安全方式,绝不能用 f-string 或.format()拼接 SQL,否则存在注入风险。BEGIN TRANSACTION+commit()/rollback()保证批量写入要么全成功、要么全失败。
4. 多维度可视化实战:用 Matplotlib/Seaborn/Plotly 生成可交付报告
4.1 价格分布分析:直方图 + KDE 密度曲线双视图
viz_price_distribution.py生成price_distribution.html,核心代码如下:
import matplotlib.pyplot as plt import seaborn as sns import pandas as pd from plotly.subplots import make_subplots import plotly.graph_objects as go # 加载数据 df = pd.read_sql("SELECT price FROM cleaned_cars WHERE price IS NOT NULL", sqlite3.connect("car_data.db")) # Matplotlib + Seaborn 生成静态图 plt.figure(figsize=(10, 6)) sns.histplot(df['price'], bins=30, kde=True, color='steelblue', alpha=0.7) plt.title("二手车价格分布(万元)", fontsize=14) plt.xlabel("价格(万元)") plt.ylabel("频数") plt.grid(True, alpha=0.3) plt.savefig("output/price_hist.png", dpi=300, bbox_inches='tight') # Plotly 生成交互式 HTML fig = make_subplots(rows=1, cols=1) fig.add_trace(go.Histogram(x=df['price'], name="价格分布", nbinsx=30)) fig.add_trace(go.Scatter(x=df['price'].sort_values(), y=sns.kdeplot(df['price'], warn_singular=False).get_lines()[0].get_ydata(), mode='lines', name="密度曲线")) fig.update_layout(title="二手车价格分布(交互式)", xaxis_title="价格(万元)", yaxis_title="频数 / 密度") fig.write_html("output/price_distribution.html")4.1.1 为什么同时输出 PNG 和 HTML?
- PNG 用于论文插图(印刷质量高、加载快);
- HTML 支持缩放、悬停查看精确数值、导出为 PNG/SVG,适合答辩现场演示;
sns.kdeplot()的warn_singular=False防止单值数据报错,增强鲁棒性。
4.2 车龄-价格散点图:识别异常低价车与高价老车
viz_age_vs_price.py使用 Seabornscatterplot并添加回归线:
df['age'] = datetime.now().year - df['reg_date'] plt.figure(figsize=(10, 6)) sns.scatterplot(data=df, x='age', y='price', hue='brand', size='mileage', sizes=(20, 200), alpha=0.6, palette='tab10') sns.regplot(data=df, x='age', y='price', scatter=False, color='red', line_kws={'linestyle':'--'}) plt.title("车龄 vs 价格(按品牌着色,里程大小编码)", fontsize=14) plt.xlabel("车龄(年)") plt.ylabel("价格(万元)") plt.legend(bbox_to_anchor=(1.05, 1), loc='upper left') plt.savefig("output/age_price_scatter.png", dpi=300, bbox_inches='tight')提示:
size='mileage'将里程映射为点大小,形成三维信息编码;palette='tab10'使用 10 色区分常见品牌(丰田、本田、大众等),避免颜色混淆;bbox_to_anchor解决图例遮挡问题。
4.3 地域价格热力图:用 Plotly Choropleth 展示城市均价
项目预置city_mapping.json将文本城市名映射为标准行政区划代码(如“上海”→“310000”),再调用 Plotly 地图:
import json import plotly.express as px with open("data/city_mapping.json") as f: city_code_map = json.load(f) # 计算各城市均价 city_avg = df.groupby('location')['price'].mean().reset_index(name='avg_price') city_avg['code'] = city_avg['location'].map(city_code_map) fig = px.choropleth(city_avg, locations='code', color='avg_price', hover_name='location', color_continuous_scale='Viridis', scope="asia", title="各城市二手车平均价格(万元)") fig.write_html("output/city_price_heatmap.html")4.3.1 如何解决中文城市名匹配失败?
city_mapping.json手动维护常见别名(如“沪”→“上海”,“京”→“北京”);- 使用
fuzzywuzzy库做模糊匹配(项目未内置,但可在requirements.txt添加fuzzywuzzy==0.18.0); - 若地图显示空白,检查
code列是否为字符串类型(Plotly 要求locations为 str)。
5. 一键运行与环境配置:VS Code + Python 3.8+ 的最小可行方案
5.1 环境搭建:避开 pip install 全局污染的推荐做法
项目根目录下提供environment.yml(Conda)和requirements.txt(pip)双方案,优先推荐 Conda:
# environment.yml name: car-analysis channels: - conda-forge dependencies: - python=3.8 - pip - pip: - beautifulsoup4==4.12.2 - matplotlib==3.7.1 - seaborn==0.12.2 - plotly==5.18.0 - pandas==1.5.3 - numpy==1.24.3执行命令:
# 创建隔离环境 conda env create -f environment.yml conda activate car-analysis # 验证安装 python -c "import sqlite3, pandas, plotly; print('✅ All libs loaded')"提示:Conda 环境自动解决
matplotlib与plotly的依赖冲突(如kiwisolver版本),比 pip 更稳定;python=3.8是因部分二手车网站仍使用较旧 TLS 协议,Python 3.9+ 默认禁用某些加密套件。
5.2 五步运行流程:从解压到生成报告
| 步骤 | 命令 | 说明 |
|---|---|---|
| 1. 解压项目 | unzip "基于Python的二手车爬虫数据可视化分析项目源码+文档说明+数据库文件(毕业设计).zip" | 得到car_project/目录 |
| 2. 进入目录 | cd car_project | 确保config.py、spider.py等文件在当前路径 |
| 3. 安装依赖 | conda env create -f environment.yml && conda activate car-analysis | 或pip install -r requirements.txt |
| 4. 运行爬虫 | python spider.py | 默认爬取 5 页,耗时约 2~5 分钟 |
| 5. 生成报告 | python main_viz.py | 输出output/下全部图表与 HTML |
5.2.1 如果爬虫卡在某页不动怎么办?
- 检查
config.py中BASE_URL是否可访问(浏览器打开确认); - 查看
output/log.txt最后一行错误信息(如ConnectionError表示网络不通,Timeout表示目标站响应慢); - 临时降低
MAX_PAGES至 1,运行python spider.py并在parser.py中print(soup.prettify()[:500])查看 HTML 结构是否变化。
5.3 毕业设计答辩高频问题预演与答案要点
| 问题 | 回答要点(答辩时说) |
|---|---|
| “为什么用 SQLite 而不用 MySQL?” | “SQLite 零配置、单文件、无需服务端,符合毕业设计‘本地可运行’要求;所有表结构已在schema.sql中定义,答辩时可现场sqlite3 car_data.db ".tables"展示。” |
| “数据有重复吗?怎么去重?” | “爬虫层通过url字段唯一索引防重复插入;清洗层在cleaned_cars表中对title+reg_date+price组合去重,SQL 语句为DELETE FROM cleaned_cars WHERE rowid NOT IN (SELECT MIN(rowid) FROM cleaned_cars GROUP BY title, reg_date, price)。” |
| “可视化图表怎么嵌入论文?” | “output/下的 PNG 图分辨率 300dpi,直接插入 Word 即可;HTML 报告可用浏览器打开,答辩时投屏演示交互功能,截图保存为高清图备用。” |
| “如果我想换爬某瓜网,改哪里?” | “只需修改config.py的BASE_URL和SEARCH_PARAMS,再调整parser.py中FIELD_SELECTORS的 CSS 选择器——比如某瓜的标题是<h3 class="tit">,就把'title': ('h2', {...})改为'title': ('h3', {'class': 'tit'})。” |
注意:所有回答必须基于项目实际代码,切勿编造未实现的功能。答辩时打开 VS Code,实时演示修改
config.py后重新运行main_viz.py生成新图表,是最有力的佐证。
本文还有配套的精品资源,点击获取