Python二手车毕业设计实战:从数据爬取到Web应用部署的完整链路
数据采集(Scrapy)-> 数据存储(SQLite)-> 服务接口(Flask RESTful API)-> 数据展示(HTML/JS)-> 生产部署。这比单纯提交一个数据分析报告或一堆散乱的脚本要扎实得多。如何让这个毕业设计更出彩?扩展为推荐系统:基于车辆的品牌、价格、里程等属性,计算车辆之间的相似度。在API中增加一个接口,为某辆车推荐相似车辆。这涉及到简单的机器学习或协同过滤思想,是很好的
最近在帮学弟学妹们看毕业设计,发现很多“Python二手车数据分析”项目都卡在了几个关键环节:要么数据是网上找的静态CSV,毫无爬虫过程;要么代码全写在一个Jupyter Notebook里,没有后端和前端;要么项目只能在本地跑,根本不知道怎么部署上线。这样的项目在答辩时很难体现工程能力。今天,我就结合一个实战项目,梳理一条从数据抓取到Web应用部署的完整链路,希望能给大家提供一个清晰的、可落地的参考模板。

1. 项目痛点与技术选型:为什么这么搭?
很多同学的项目止步于“数据分析”,但一个完整的毕业设计应该是一个“系统”。常见的痛点有:
- 数据源虚假:使用静态、过时的数据集,无法体现数据获取能力。
- 架构混乱:前后端代码糅杂,修改一个功能牵一发而动全身。
- 无法演示:项目只能在本机命令行运行,缺乏一个可供访问的Web界面。
- 扩展性差:代码写死,难以添加新功能(如用户登录、车辆推荐)。
基于“快速实现、易于理解、方便部署”的原则,我选择了以下技术栈:
-
数据采集:Scrapy vs Requests+BeautifulSoup Scrapy是一个成熟的爬虫框架,而Requests+BS是库的组合。对于二手车网站这种可能存在分页、反爬的规模型数据采集,Scrapy的优势非常明显:
- 内置去重与重试:自动处理重复请求和请求失败重试,省去大量底层代码。
- 高性能异步:基于Twisted的异步架构,抓取速度远超同步请求。
- 结构化Pipeline:清晰的数据清洗、验证、存储流程(Item Pipeline),让代码更规范。
- 中间件扩展:方便地添加代理IP、随机User-Agent等反爬策略。
-
Web框架:Flask vs Django Django是“大而全”,自带Admin、ORM、用户认证等,但对于一个以数据展示和分析为核心的毕业设计,可能过于沉重。Flask“微”而灵活,更合适:
- 学习曲线平缓:核心简单,能让学生更专注于业务逻辑(API设计、数据渲染)而非框架约定。
- 易于集成:与Pandas、Scrapy等数据工具结合更直接,没有Django ORM的强约束。
- 部署轻量:应用体积小,在云服务器或容器中运行资源占用低。
-
数据存储:SQLite 选择SQLite纯粹是为了简化部署。它无需安装独立的数据库服务,一个文件搞定,非常适合演示和轻量级应用。虽然在高并发写入上有局限,但对于毕业设计级别的读多写少场景完全够用。
2. 核心实现三部曲
2.1 第一步:用Scrapy构建健壮的爬虫
目标是抓取某个二手车平台的车辆列表信息,包括标题、价格、里程、上牌时间、链接等。
关键点1:定义清晰的数据结构(Items) 在 items.py 中定义,这相当于你的数据模型。
import scrapy
class UsedcarItem(scrapy.Item):
# 定义字段,像数据库的表结构
title = scrapy.Field() # 车辆标题
price = scrapy.Field() # 价格
mileage = scrapy.Field() # 行驶里程
reg_date = scrapy.Field() # 上牌年份
source_url = scrapy.Field() # 详情页链接
city = scrapy.Field() # 城市
crawl_time = scrapy.Field() # 爬取时间
关键点2:编写爬虫解析逻辑(Spider) 在 spiders/car_spider.py 中,重点在于稳定的选择器和分页处理。
import scrapy
from usedcar.items import UsedcarItem
from urllib.parse import urljoin
class CarSpider(scrapy.Spider):
name = 'che168' # 爬虫名称
allowed_domains = ['che168.com']
start_urls = ['https://www.che168.com/beijing/list/'] # 起始URL
def parse(self, response):
# 1. 提取当前页所有车辆列表项
car_list = response.css('ul.car-list li')
for car in car_list:
item = UsedcarItem()
item['title'] = car.css('h4 a::text').get('').strip()
# 价格可能包含“万”字,需要清洗
price_text = car.css('.price em::text').get('')
item['price'] = float(price_text.replace('万', '')) if price_text else 0.0
# 同理清洗里程和年份
item['mileage'] = car.css('.mileage::text').get('').replace('万公里', '').strip()
item['reg_date'] = car.css('.year::text').get('').strip()
item['city'] = '北京' # 从URL或页面解析
item['source_url'] = urljoin(response.url, car.css('h4 a::attr(href)').get())
item['crawl_time'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
yield item # 将数据项抛给Pipeline处理
# 2. 分页逻辑:查找“下一页”按钮
next_page = response.css('a.page-next::attr(href)').get()
if next_page:
next_url = urljoin(response.url, next_page)
# 使用 `follow` 方法可以自动处理相对URL和去重
yield response.follow(next_url, callback=self.parse)
关键点3:数据清洗与去重(Pipeline) 这是保证数据质量的核心。在 pipelines.py 中实现。
import sqlite3
from itemadapter import ItemAdapter
class UsedcarPipeline:
def open_spider(self, spider):
# 爬虫启动时连接数据库
self.conn = sqlite3.connect('used_cars.db')
self.cursor = self.conn.cursor()
# 创建表(如果不存在)
self.cursor.execute('''
CREATE TABLE IF NOT EXISTS cars (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT,
price REAL,
mileage TEXT,
reg_date TEXT,
city TEXT,
source_url TEXT UNIQUE, -- 唯一约束,用于去重
crawl_time TEXT
)
''')
self.conn.commit()
def process_item(self, item, spider):
adapter = ItemAdapter(item)
# 基础清洗:去除空值或无效数据
if not adapter.get('title') or adapter.get('price', 0) <= 0:
spider.logger.warning(f"无效数据被丢弃: {item}")
return item
# 核心:基于 source_url 去重插入
try:
self.cursor.execute('''
INSERT OR IGNORE INTO cars
(title, price, mileage, reg_date, city, source_url, crawl_time)
VALUES (?, ?, ?, ?, ?, ?, ?)
''', (
adapter['title'], adapter['price'], adapter['mileage'],
adapter['reg_date'], adapter['city'], adapter['source_url'],
adapter['crawl_time']
))
self.conn.commit()
except sqlite3.Error as e:
spider.logger.error(f"数据库插入错误: {e}")
return item
def close_spider(self, spider):
# 爬虫关闭时断开连接
self.conn.close()
记得在 settings.py 中启用这个Pipeline:ITEM_PIPELINES = {'usedcar.pipelines.UsedcarPipeline': 300}。
2.2 第二步:用Flask构建RESTful API
数据有了,我们需要一个接口让前端能获取到。这里采用前后端分离的思想,后端只提供数据API。
关键点1:应用结构与API设计 项目结构如下:
usedcar_project/
├── scraper/ # Scrapy爬虫项目
├── backend/
│ ├── app.py # Flask主应用
│ ├── database.py # 数据库操作封装
│ └── requirements.txt
└── frontend/ # 静态HTML/JS文件
backend/app.py 是核心:
from flask import Flask, jsonify, request, render_template
from flask_cors import CORS # 处理跨域请求
import database as db
app = Flask(__name__)
CORS(app) # 允许前端跨域访问
@app.route('/')
def index():
# 可以返回一个简单的欢迎页,或重定向到前端
return "二手车数据API服务已启动。请访问 /api/cars"
@app.route('/api/cars', methods=['GET'])
def get_cars():
"""获取车辆列表API"""
# 从请求参数中获取分页和过滤条件
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 20, type=int)
city = request.args.get('city', '')
min_price = request.args.get('min_price', type=float)
max_price = request.args.get('max_price', type=float)
# 调用数据库模块的查询函数
cars, total = db.fetch_cars(page, per_page, city, min_price, max_price)
# 构造符合RESTful风格的响应
return jsonify({
'success': True,
'data': cars,
'pagination': {
'page': page,
'per_page': per_page,
'total': total,
'pages': (total + per_page - 1) // per_page
}
})
@app.route('/api/price_stats', methods=['GET'])
def get_price_stats():
"""获取价格分布统计(用于前端图表)"""
stats = db.get_price_statistics()
return jsonify({'success': True, 'data': stats})
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port=5000)
关键点2:数据库操作封装 backend/database.py 负责所有SQL查询,保证应用层代码干净。
import sqlite3
from contextlib import contextmanager
DATABASE = 'used_cars.db'
@contextmanager
def get_db_connection():
"""上下文管理器,自动管理数据库连接"""
conn = sqlite3.connect(DATABASE)
conn.row_factory = sqlite3.Row # 使返回结果像字典一样访问
try:
yield conn
finally:
conn.close()
def fetch_cars(page=1, per_page=20, city=None, min_price=None, max_price=None):
"""分页查询车辆数据,支持过滤"""
offset = (page - 1) * per_page
query = "SELECT * FROM cars WHERE 1=1"
params = []
if city:
query += " AND city = ?"
params.append(city)
if min_price is not None:
query += " AND price >= ?"
params.append(min_price)
if max_price is not None:
query += " AND price <= ?"
params.append(max_price)
query += " ORDER BY crawl_time DESC LIMIT ? OFFSET ?"
params.extend([per_page, offset])
with get_db_connection() as conn:
cursor = conn.cursor()
# 获取数据
cursor.execute(query, params)
cars = [dict(row) for row in cursor.fetchall()] # 转换为字典列表
# 获取总数(用于分页)
count_query = "SELECT COUNT(*) FROM cars WHERE 1=1"
count_params = params[:-2] # 去掉LIMIT和OFFSET对应的参数
if count_params:
# 这里需要根据实际条件重新构造计数查询,简化处理
cursor.execute(count_query.replace('1=1', '1=1'), count_params)
else:
cursor.execute("SELECT COUNT(*) FROM cars")
total = cursor.fetchone()[0]
return cars, total
2.3 第三步:极简前端展示
为了快速演示,我们可以直接用Flask渲染一个HTML页面,或者使用纯静态HTML通过JavaScript调用API。这里展示Flask渲染的简易版本。
在 backend/templates/index.html:
<!DOCTYPE html>
<html>
<head>
<title>二手车数据看板</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
body { font-family: sans-serif; margin: 20px; }
table { border-collapse: collapse; width: 100%; margin-top: 20px; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f2f2f2; }
.filters { margin-bottom: 20px; }
</style>
</head>
<body>
<h1>二手车市场数据分析</h1>
<div class="filters">
<label>城市:<input type="text" id="cityFilter" placeholder="例如:北京"></label>
<button onclick="loadCars()">筛选</button>
</div>
<div>
<canvas id="priceChart" width="400" height="200"></canvas>
</div>
<table id="carTable">
<thead><tr><th>标题</th><th>价格(万)</th><th>里程</th><th>上牌时间</th><th>城市</th></tr></thead>
<tbody><!-- 数据由JS动态填充 --></tbody>
</table>
<div id="pagination"><!-- 分页按钮 --></div>
<script>
let currentPage = 1;
const perPage = 15;
function loadCars(page = 1) {
currentPage = page;
const city = document.getElementById('cityFilter').value;
const url = `/api/cars?page=${page}&per_page=${perPage}&city=${encodeURIComponent(city)}`;
fetch(url)
.then(res => res.json())
.then(data => {
if(data.success) {
renderTable(data.data);
renderPagination(data.pagination);
}
});
}
function renderTable(cars) {
const tbody = document.querySelector('#carTable tbody');
tbody.innerHTML = '';
cars.forEach(car => {
const row = `<tr>
<td><a href="${car.source_url}" target="_blank">${car.title}</a></td>
<td>${car.price}</td>
<td>${car.mileage}</td>
<td>${car.reg_date}</td>
<td>${car.city}</td>
</tr>`;
tbody.innerHTML += row;
});
}
function renderPagination(pagination) {
const div = document.getElementById('pagination');
div.innerHTML = '';
for(let i=1; i<=pagination.pages; i++) {
const btn = document.createElement('button');
btn.textContent = i;
btn.disabled = (i === currentPage);
btn.onclick = () => loadCars(i);
div.appendChild(btn);
}
}
// 加载价格图表
fetch('/api/price_stats')
.then(res => res.json())
.then(data => {
if(data.success) {
new Chart(document.getElementById('priceChart'), {
type: 'bar',
data: {
labels: data.data.labels, // 价格区间
datasets: [{
label: '车辆数量',
data: data.data.counts,
backgroundColor: 'rgba(54, 162, 235, 0.5)'
}]
}
});
}
});
// 初始加载
loadCars();
</script>
</body>
</html>
然后在 app.py 中添加一个路由来渲染这个页面:
@app.route('/dashboard')
def dashboard():
return render_template('index.html')

3. 性能与安全考量(毕业设计加分项)
-
反爬策略应对:
- User-Agent轮换:在Scrapy的
settings.py中设置USER_AGENT列表,并通过下载中间件随机选择。 - 请求延迟:设置
DOWNLOAD_DELAY = 2或使用AutoThrottle扩展,避免请求过快。 - IP代理池:如果目标网站封IP严重,可以考虑集成免费的代理IP服务,但这对于毕业设计通常不是必须的。
- User-Agent轮换:在Scrapy的
-
SQL注入防护: 在我们的代码中,所有数据库查询都使用了参数化查询(
?占位符),例如cursor.execute('SELECT * FROM cars WHERE city = ?', [city])。这能有效防止SQL注入攻击,绝对不要使用字符串拼接来构造SQL语句。 -
基础数据验证: 在Flask的API中,对传入的参数(如
page,per_page)进行了简单的类型转换和默认值处理。在生产环境中,应使用更强大的库(如marshmallow或pydantic)进行数据验证和序列化。
4. 生产环境部署避坑指南
-
路径硬编码问题: 在代码中,数据库文件路径
used_cars.db是相对路径。部署到服务器时,最好使用绝对路径,或者通过环境变量配置。可以在app.py开头修改:import os DATABASE_PATH = os.environ.get('DATABASE_PATH', 'used_cars.db') -
SQLite并发写入限制: SQLite在同一时间只允许一个写操作。在爬虫大规模写入时,如果并发过高可能出错。Scrapy的Pipeline是单线程处理Item的,所以问题不大。但如果你用多线程爬虫,就需要考虑使用连接池或加锁。对于毕业设计,保持Scrapy默认设置即可。
-
Flask生产模式: 开发时我们使用
app.run(debug=True),但部署时务必关闭Debug模式,并考虑使用WSGI服务器(如Gunicorn)来运行Flask应用,性能更好。# 安装Gunicorn pip install gunicorn # 启动应用(在backend目录下) gunicorn -w 4 -b 0.0.0.0:5000 app:app -
一键部署到云服务器: 可以将整个项目上传到GitHub,然后在云服务器(如阿里云ECS、腾讯云轻量应用服务器)上执行以下命令快速部署:
# 1. 克隆代码 git clone <你的仓库地址> # 2. 安装依赖 cd backend && pip install -r requirements.txt # 3. 运行爬虫(先获取数据) cd ../scraper && scrapy crawl che168 # 4. 启动Web服务(使用后台运行) cd ../backend && nohup gunicorn -w 2 -b 0.0.0.0:80 app:app > server.log 2>&1 &这样,你就可以通过服务器的公网IP访问你的二手车数据看板了。
总结与扩展思考
通过这个项目,我们走完了一个小型数据系统的完整生命周期:数据采集(Scrapy)-> 数据存储(SQLite)-> 服务接口(Flask RESTful API)-> 数据展示(HTML/JS)-> 生产部署。这比单纯提交一个数据分析报告或一堆散乱的脚本要扎实得多。
如何让这个毕业设计更出彩?
- 扩展为推荐系统:基于车辆的品牌、价格、里程等属性,计算车辆之间的相似度。在API中增加一个
/api/recommend/<car_id>接口,为某辆车推荐相似车辆。这涉及到简单的机器学习或协同过滤思想,是很好的加分项。 - 接入真实数据库:将SQLite替换为MySQL或PostgreSQL。这不仅能学习更专业的数据库管理,还能在简历上多写一项技能。使用SQLAlchemy等ORM可以简化操作。
- 增加数据可视化深度:集成ECharts等更强大的图表库,展示价格与里程的关系图、各城市车源分布地图等。
- 添加用户交互:实现简单的收藏、对比功能,这就需要引入用户系统(Flask-Login)和更复杂的数据库设计。
这个项目的代码结构清晰,模块分明,你可以轻松地替换其中任何一个组件(比如把爬虫目标换成另一个网站,或者把前端换成Vue/React框架)。最重要的是,你亲手搭建了一个可运行、可展示、可扩展的完整系统,这在整个毕业设计答辩过程中将极具说服力。
动手复现一遍吧,过程中遇到问题,解决问题的过程就是你最大的收获。祝你毕业设计顺利!
更多推荐
所有评论(0)