最近在帮学弟学妹们看毕业设计,发现很多“Python二手车数据分析”项目都卡在了几个关键环节:要么数据是网上找的静态CSV,毫无爬虫过程;要么代码全写在一个Jupyter Notebook里,没有后端和前端;要么项目只能在本地跑,根本不知道怎么部署上线。这样的项目在答辩时很难体现工程能力。今天,我就结合一个实战项目,梳理一条从数据抓取到Web应用部署的完整链路,希望能给大家提供一个清晰的、可落地的参考模板。

图片

1. 项目痛点与技术选型:为什么这么搭?

很多同学的项目止步于“数据分析”,但一个完整的毕业设计应该是一个“系统”。常见的痛点有:

  • 数据源虚假:使用静态、过时的数据集,无法体现数据获取能力。
  • 架构混乱:前后端代码糅杂,修改一个功能牵一发而动全身。
  • 无法演示:项目只能在本机命令行运行,缺乏一个可供访问的Web界面。
  • 扩展性差:代码写死,难以添加新功能(如用户登录、车辆推荐)。

基于“快速实现、易于理解、方便部署”的原则,我选择了以下技术栈:

  • 数据采集:Scrapy vs Requests+BeautifulSoup Scrapy是一个成熟的爬虫框架,而Requests+BS是库的组合。对于二手车网站这种可能存在分页、反爬的规模型数据采集,Scrapy的优势非常明显:

    1. 内置去重与重试:自动处理重复请求和请求失败重试,省去大量底层代码。
    2. 高性能异步:基于Twisted的异步架构,抓取速度远超同步请求。
    3. 结构化Pipeline:清晰的数据清洗、验证、存储流程(Item Pipeline),让代码更规范。
    4. 中间件扩展:方便地添加代理IP、随机User-Agent等反爬策略。
  • Web框架:Flask vs Django Django是“大而全”,自带Admin、ORM、用户认证等,但对于一个以数据展示和分析为核心的毕业设计,可能过于沉重。Flask“微”而灵活,更合适:

    1. 学习曲线平缓:核心简单,能让学生更专注于业务逻辑(API设计、数据渲染)而非框架约定。
    2. 易于集成:与Pandas、Scrapy等数据工具结合更直接,没有Django ORM的强约束。
    3. 部署轻量:应用体积小,在云服务器或容器中运行资源占用低。
  • 数据存储: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. 性能与安全考量(毕业设计加分项)

  1. 反爬策略应对

    • User-Agent轮换:在Scrapy的 settings.py 中设置 USER_AGENT 列表,并通过下载中间件随机选择。
    • 请求延迟:设置 DOWNLOAD_DELAY = 2 或使用 AutoThrottle 扩展,避免请求过快。
    • IP代理池:如果目标网站封IP严重,可以考虑集成免费的代理IP服务,但这对于毕业设计通常不是必须的。
  2. SQL注入防护: 在我们的代码中,所有数据库查询都使用了参数化查询(? 占位符),例如 cursor.execute('SELECT * FROM cars WHERE city = ?', [city])。这能有效防止SQL注入攻击,绝对不要使用字符串拼接来构造SQL语句。

  3. 基础数据验证: 在Flask的API中,对传入的参数(如 page, per_page)进行了简单的类型转换和默认值处理。在生产环境中,应使用更强大的库(如 marshmallowpydantic)进行数据验证和序列化。

4. 生产环境部署避坑指南

  1. 路径硬编码问题: 在代码中,数据库文件路径 used_cars.db 是相对路径。部署到服务器时,最好使用绝对路径,或者通过环境变量配置。可以在 app.py 开头修改:

    import os
    DATABASE_PATH = os.environ.get('DATABASE_PATH', 'used_cars.db')
    
  2. SQLite并发写入限制: SQLite在同一时间只允许一个写操作。在爬虫大规模写入时,如果并发过高可能出错。Scrapy的Pipeline是单线程处理Item的,所以问题不大。但如果你用多线程爬虫,就需要考虑使用连接池或加锁。对于毕业设计,保持Scrapy默认设置即可。

  3. 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
    
  4. 一键部署到云服务器: 可以将整个项目上传到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)-> 生产部署。这比单纯提交一个数据分析报告或一堆散乱的脚本要扎实得多。

如何让这个毕业设计更出彩?

  1. 扩展为推荐系统:基于车辆的品牌、价格、里程等属性,计算车辆之间的相似度。在API中增加一个 /api/recommend/<car_id> 接口,为某辆车推荐相似车辆。这涉及到简单的机器学习或协同过滤思想,是很好的加分项。
  2. 接入真实数据库:将SQLite替换为MySQL或PostgreSQL。这不仅能学习更专业的数据库管理,还能在简历上多写一项技能。使用SQLAlchemy等ORM可以简化操作。
  3. 增加数据可视化深度:集成ECharts等更强大的图表库,展示价格与里程的关系图、各城市车源分布地图等。
  4. 添加用户交互:实现简单的收藏、对比功能,这就需要引入用户系统(Flask-Login)和更复杂的数据库设计。

这个项目的代码结构清晰,模块分明,你可以轻松地替换其中任何一个组件(比如把爬虫目标换成另一个网站,或者把前端换成Vue/React框架)。最重要的是,你亲手搭建了一个可运行、可展示、可扩展的完整系统,这在整个毕业设计答辩过程中将极具说服力。

动手复现一遍吧,过程中遇到问题,解决问题的过程就是你最大的收获。祝你毕业设计顺利!

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐