简介:这是基于Python的二手车爬虫数据可视化分析毕业设计项目,面向计算机相关专业学生,适用于毕业设计、课程设计或期末大作业场景。项目覆盖从二手车网站数据爬取、清洗存储到可视化分析展示的完整流程,包含爬虫脚本、数据库文件、可视化前端页面及配套文档说明,已获导师指导并通过答辩评审,得分97分。资源共2000个文件,以1745个Python源码文件为主,其中Python源码用于爬虫采集与数据分析,HTML、JavaScript、CSS文件用于构建可视化展示界面,JSON与TXT为结构化数据及注解,PDF为项目文档说明,压缩包大小约55MB,整体结构清晰,便于按模块查阅与二次开发。目前已有658人学习下载。项目下载即用、无需修改,既能支撑毕业答辩,也可作为爬虫与数据可视化课程的综合实战参考。
1. 二手车爬虫数据可视化:一套能直接答辩的毕设源码
二手车数据的爬取与可视化分析,是这几年 Python 方向毕业设计里翻牌率相当高的题目,也是课程设计里少有的「数据来源明确、技术栈完整、演示效果好」的组合。这套基于 requests + BeautifulSoup 实现爬虫、SQLite 落库、Flask 提供数据接口、ECharts 渲染图表的毕设项目,把整条链路完整串好,源码、数据库文件和文档说明都打包在同一个压缩包里,解压后按依赖装完就能跑出可视化页面。它解决的核心问题是一回事:二手车平台上分散的车型、价格、里程、年份数据,如何自动抓取、清洗入库,再变成答辩现场能展示的柱状图、饼图和趋势线。适合爬虫经验不深又想短期拿出完整系统的毕设党,也适合课程设计需要一个可演示项目的场景。
2. 项目拆解:数据链路与三个核心模块的分工
2.1 目录结构:先分清爬虫、数据库和 Web 三层
拿到压缩包解压之后,第一件事不是跑代码,而是先看目录结构。这套项目的组织方式在国内毕设源码里很有代表性,分成爬虫、数据、Web 展示、文档四块。常见的文件布局如下:
second_hand_car/ ├── car_spider/ │ ├── spider.py # 主爬虫入口,负责翻页循环 │ ├── parser.py # HTML 解析与字段提取 │ ├── database.py # SQLite 建表、插入、去重 │ └── config.py # 请求头、URL、翻页参数 ├── data/ │ ├── car_data.db # SQLite 数据库文件 │ └── car_data.csv # 清洗后的中间数据导出 ├── web/ │ ├── app.py # Flask 应用入口 │ ├── templates/ │ │ └── index.html # 可视化页面 │ └── static/ │ ├── js/ # ECharts 配置脚本 │ └── css/ # 页面样式 ├── docs/ │ ├── 需求分析.md │ ├── 设计文档.md │ └── 答辩准备.md ├── requirements.txt └── run.py # 一键启动脚本car_spider负责从目标站点抓取数据,data目录下预置了已经跑好的car_data.db,即使暂时不想碰爬虫,直接启动 Web 服务也能看到完整可视化结果。web目录是系统的展示层,Flask 提供接口,HTML 页面负责渲染。run.py是整个项目的统一入口,我一般会先打开它确认有没有做依赖检查,很多毕设源码的 run.py 只是简单调用app.run(),这个项目在这里补了一层依赖缺失提示,对新手友好不少。
依赖安装是整个复现过程的第一步。打开终端定位到项目根目录后执行:
pip install -r requirements.txtrequirements.txt里锁定了四个核心库:requests、beautifulsoup4、flask、pandas。这里我想单独说下pandas的作用——它看起来跟爬虫没关系,但实际上parser.py里的字段清洗和最终 CSV 导出都依赖它的DataFrame操作,后面在database.py里做批量插入时也用得上。安装完成后,先别急着启动,确认一下数据库文件是否完整可读,这一步能省掉后面大量排查时间。
2.2 SQLite 表结构与字段设计背后的意图
数据库是这套系统里的中转站。爬虫抓下来的数据是字符串,经过清洗后写入 SQLite,Web 端再按维度聚合查询。表结构设计直接决定了可视化接口好不好写,项目里的建表语句大致如下:
CREATE TABLE IF NOT EXISTS car_info ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, brand TEXT, price REAL, mileage REAL, reg_year INTEGER, gearbox TEXT, displacement REAL, location TEXT, crawl_date TEXT ); CREATE INDEX IF NOT EXISTS idx_price ON car_info(price); CREATE INDEX IF NOT EXISTS idx_brand ON car_info(brand);字段类型的选择有几个细节值得展开。price和mileage用 REAL 而不是 TEXT,是为了在 SQL 里可以直接写AVG(price)、MAX(mileage)这类聚合函数,不用每次都显式 CAST。reg_year存整数,方便按年份分组做趋势图。crawl_date记录爬取批次,调试爬虫或比对两次抓取结果是否稳定时非常有用。
索引的添加是项目里容易被忽略但很关键的部分。毕设数据量不大,几千条记录不加索引也能秒出结果,但加上idx_price和idx_brand后,价格区间聚合和品牌分组这两条高频查询的响应速度会明显改善,答辩演示时页面刷新不卡顿。建表语句写在car_spider/database.py里,每次运行爬虫前会自动执行CREATE TABLE IF NOT EXISTS,幂等设计,重复跑不会报错。
单条插入的核心逻辑长这样:
import sqlite3 def insert_car_item(conn, item): cursor = conn.cursor() cursor.execute(""" INSERT INTO car_info (title, brand, price, mileage, reg_year, gearbox, displacement, location, crawl_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( item['title'], item['brand'], item['price'], item['mileage'], item['reg_year'], item['gearbox'], item['displacement'], item['location'], item['crawl_date'] )) conn.commit()参数化查询的?占位符是这段代码里必须坚持的写法,它既防止 SQL 注入,也自动处理了字符串里的特殊字符。commit()必须在close()之前调用,顺序一旦反了,数据不会真正落盘而且不报错,属于最隐蔽的翻车点之一。批量入库时可以改用executemany,把清洗好的数据组成 list 一次性提交,性能差距在小数据量下不明显,但代码更紧凑。
2.3 数据验证与导出:跑通链路前先检查存量数据
数据库文件预置了爬取结果,但我建议复现时先做一轮数据核查,确认存量数据能不能支撑可视化。快速检查方式是在项目根目录打开终端执行:
sqlite3 data/car_data.db "SELECT COUNT(*), AVG(price), MAX(mileage) FROM car_info;"这里的sqlite3是命令行工具,不用额外安装 Python 包。如果输出结果里COUNT(*)明显偏少,比如只有几十条,可视化图表再好看也会显得单薄,这时候需要重新触发一次爬虫补数据。如果AVG(price)为 0 或空,说明清洗环节出了问题,优先排查parser.py里的类型转换逻辑。
数据导出是另一个高频需求。很多同学答辩时要额外交一份 Excel 或 CSV 作为过程材料,database.py里封装了对应的导出函数:
import pandas as pd import sqlite3 def export_to_csv(db_path='data/car_data.db', output_path='data/car_data.csv'): conn = sqlite3.connect(db_path) df = pd.read_sql_query("SELECT * FROM car_info ORDER BY price DESC", conn) df.to_csv(output_path, index=False, encoding='utf-8-sig') conn.close() print(f'exported {len(df)} rows to {output_path}')这里的encoding='utf-8-sig'是有讲究的。直接写utf-8导出的 CSV 用 Excel 打开时中文会乱码,utf-8-sig加了 BOM 头,Excel 才能正确识别编码。如果在答辩材料里需要这份 CSV,这个参数不要省。跑完导出后,csv 文件里应该能看到完整的品牌、价格、里程、年份信息,数据链路就算验证通了。
3. 爬虫模块实战:requests + BeautifulSoup 的实现细节
3.1 请求头伪装与目标站点适配
爬虫模块是整个系统的数据来源,也是最容易出问题的环节。项目默认适配的是静态渲染的二手车列表页,requests 请求拿到 HTML 后用 BeautifulSoup 解析。config.py里的请求头配置是绕开基础反爬的第一道门槛:
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', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', 'Referer': 'https://www.che168.com/', 'Connection': 'keep-alive' }请求头的核心是 User-Agent,很多站点对空 UA 或 Python-requests 默认 UA 直接返回 403。Referer 字段在部分站点会被校验,如果发现响应码异常,优先检查这两项。timeout参数必须显式设置,没有它,网络抖动时请求会长时间挂起,爬虫进程看起来像死了一样。项目里spider.py的请求调用方式如下:
import requests import time def fetch_page(url, params=None, retries=2): for attempt in range(retries): try: response = requests.get( url, params=params, headers=HEADERS, timeout=10 ) if response.status_code == 200: return response.text else: print(f'request failed, status: {response.status_code}, attempt: {attempt + 1}') except requests.exceptions.Timeout: print(f'timeout, attempt: {attempt + 1}') except requests.exceptions.ConnectionError: print(f'connection error, attempt: {attempt + 1}') time.sleep(1) return None这个fetch_page做了三层保护:timeout=10防止无限挂起,retries循环处理偶发网络错误,sleep(1)让重试之间隔开一秒。如果两次重试都失败,函数返回None,调用方根据返回值决定是跳过还是终止。在毕设答辩场景里,爬虫偶尔失败是允许的,关键在于失败后程序不能崩溃,要有清晰的状态输出。
3.2 翻页循环、重试机制与去重策略
单页数据量撑不起可视化,翻页是爬虫的必备能力。项目的翻页逻辑采用 URL 参数拼接方式,汽车之家的列表页页码在page参数里,循环控制页数:
def crawl_pages(start_page=1, max_pages=50): all_data = [] base_url = 'https://www.che168.com/changsha/list/' for page in range(start_page, start_page + max_pages): params = {'page': page, 'sort': 'price-asc'} html = fetch_page(base_url, params=params) if html is None: print(f'page {page} fetch failed, skip to next') continue page_data = parse_car_list(html) if not page_data: print(f'page {page} has no data, stop crawling') break all_data.extend(page_data) print(f'page {page}: got {len(page_data)} items, total {len(all_data)}') time.sleep(2) return all_data翻页循环里有几个判断值得留意。fetch_page返回None时用continue而不是break,因为单次请求失败后下一页可能是正常的,直接中断会丢失大量数据。page_data为空时用break,表示已经翻到最后一页或页面结构发生变化,再往下翻只会浪费请求。sort=price-asc让数据在价格维度上有序分布,后面画价格区间图时分布曲线会比较平滑。
time.sleep(2)是爬虫能跑多长时间的关键参数。2 秒间隔面对普通站点足够,但遇到反爬严格的平台,建议改成 5 到 8 秒。爬取时间拉长换稳定性,这对毕设来说完全划算,反正数据量几千条就够了。
去重策略是数据质量的重要保障。二手车列表页在翻页过程中可能出现重复车源,直接入库会让统计结果失真。项目在database.py里用两个层面去重,第一层是内存里的set记录已经见过的标题:
def deduplicate(items, seen_titles): unique_items = [] for item in items: title = item['title'] if title not in seen_titles: seen_titles.add(title) unique_items.append(item) return unique_items第二层是数据库层面的兜底,给title字段建唯一索引,并用INSERT OR IGNORE方式写入。这样即使爬虫中途崩溃后重跑,也不会产生重复记录。两层结合,数据表里的记录数就是实际去重后的数量,答辩时讲这个设计能体现出项目完整性。
3.3 字段清洗与类型转换的边界问题
爬虫拿到的原始字段几乎全是字符串,带「万」字的价格、含「万公里」的里程、格式不统一的年份,直接写库会让后续聚合查询一塌糊涂。项目的清洗逻辑集中在parser.py的clean_data()函数:
import re def clean_data(raw_items): cleaned = [] for item in raw_items: price_text = item.get('price', '0') mileage_text = item.get('mileage', '0') year_text = item.get('reg_year', '0') cleaned.append({ 'title': item.get('title', '').strip(), 'brand': extract_brand(item.get('title', '')), 'price': float(re.sub(r'[^\d.]', '', price_text)) if price_text else 0.0, 'mileage': float(re.sub(r'[^\d.]', '', mileage_text)) if mileage_text else 0.0, 'reg_year': int(re.search(r'\d{4}', year_text).group()) if re.search(r'\d{4}', year_text) else 0, 'gearbox': item.get('gearbox', '未知'), 'displacement': parse_displacement(item.get('displacement', '')), 'location': item.get('location', '未知'), }) return cleaned def extract_brand(title): if not title: return '未知' # 取标题第一个空格前的词作为品牌 parts = title.split() if parts: return parts[0] # 兜底:用正则匹配连续中文 m = re.match(r'[\u4e00-\u9fa5]{2,4}', title) return m.group() if m else '未知'清洗代码里有几个处理逻辑值得展开。价格字段用re.sub(r'[^\d.]', '', price_text)把「万」「万元」「¥」等无关字符全部清除,只保留数字和小数点,再交给float()转换。这样即使不同批次的页面单位格式有微小差异,也能统一处理。
车型年份字段更刁钻,页面里经常显示「2015款」或「2015年上牌」,直接int()会抛异常。正则re.search(r'\d{4}', year_text)只提取四位数年份,匹配不到就给默认值 0,避免单条脏数据导致整个清洗流程崩掉。extract_brand的兜底逻辑同样重要——有些平台的标题以「【精品】」开头,直接用split()[0]会拿错词,所以先判断有没有空格,没有空格的标题再用正则按连续中文匹配前几个字。清洗完成后,所有字段的类型和单位都统一了,后面的 SQL 查询和图表渲染就不用再操心格式问题。
4. 可视化与 Web 展示:Flask + ECharts 的对接方式
4.1 Flask API 层的查询设计与 JSON 返回
可视化页面的数据来源是 Flask 提供的一组 API。web/app.py的角色是查询 SQLite 并按前端需要的维度返回 JSON。这层的设计原则是:聚合计算尽量在 SQL 里完成,前端只负责渲染,不要在前端代码里做 group by 或循环统计。app.py的核心代码:
from flask import Flask, jsonify, render_template import sqlite3 app = Flask(__name__) def query_db(sql): conn = sqlite3.connect('data/car_data.db') conn.row_factory = sqlite3.Row cur = conn.cursor() cur.execute(sql) rows = cur.fetchall() conn.close() return [dict(row) for row in rows] @app.route('/api/price_distribution') def price_distribution(): data = query_db(""" SELECT CASE WHEN price < 5 THEN '0-5万' WHEN price < 10 THEN '5-10万' WHEN price < 20 THEN '10-20万' ELSE '20万以上' END as price_range, COUNT(*) as cnt FROM car_info GROUP BY price_range ORDER BY MIN(price) """) return jsonify(data) @app.route('/api/brand_distribution') def brand_distribution(): data = query_db(""" SELECT brand, COUNT(*) as cnt FROM car_info WHERE brand != '未知' GROUP BY brand ORDER BY cnt DESC LIMIT 10 """) return jsonify(data) @app.route('/') def index(): return render_template('index.html') if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=True)row_factory = sqlite3.Row让每一行数据可以通过字段名访问,配合dict(row)转成 JSON 可序列化的字典,前端拿到的就是标准的[{}, {}]结构。价格分布的CASE WHEN语句把连续的价格值离散成四个区间,这是直方图的标准做法。ORDER BY MIN(price)保证区间按价格从低到高排列,前端不用再做排序。
接口层的另一个细节是brand_distribution里加了WHERE brand != '未知'和LIMIT 10,防止清洗时产生的无效数据污染图表,同时只展示 top10 品牌,避免品牌太多导致饼图标签挤在一起。接口调试时可以直接在浏览器地址栏访问http://localhost:5000/api/price_distribution,返回的 JSON 结构一目了然,前端图表空白时先看接口通不通。
4.2 ECharts 三类图表的配置与数据映射
前端页面通过浏览器端的 fetch 请求接口拿数据,再喂给 ECharts 渲染。项目里配置了三张图:价格分布柱状图、品牌占比饼图、年份趋势折线图。index.html里柱状图的核心逻辑:
fetch('/api/price_distribution') .then(response => response.json()) .then(data => { const chart = echarts.init(document.getElementById('priceChart')); chart.setOption({ title: { text: '二手车价格分布', left: 'center', textStyle: { fontSize: 16 } }, tooltip: { trigger: 'axis' }, grid: { left: '10%', right: '5%', bottom: '10%' }, xAxis: { type: 'category', data: data.map(item => item.price_range) }, yAxis: { type: 'value', name: '车辆数量' }, series: [{ type: 'bar', data: data.map(item => item.cnt), barWidth: '50%', itemStyle: { color: '#2f7be4', borderRadius: [4, 4, 0, 0] } }] }); });这里的两个data.map是 ECharts 接入数据最常见的写法,分别把接口返回的price_range和cnt字段提取成两个数组。xAxis 的数据源是类别数组,series 的 data 是对应的数值数组,两者顺序一致才能对齐渲染。如果接口字段名改了怎么排查:先在浏览器控制台打印data,确认字段名是不是price_range和cnt,再检查map里写的字段跟接口返回是否一致,这两个地方对不上图表必然是空白。
饼图配置与柱状图异曲同工,series.type换成'pie',数据格式变成{ name: '大众', value: 128 }的结构:
fetch('/api/brand_distribution') .then(res => res.json()) .then(data => { const chart = echarts.init(document.getElementById('brandChart')); chart.setOption({ title: { text: '品牌分布 TOP10', left: 'center' }, tooltip: { trigger: 'item' }, legend: { bottom: '0%' }, series: [{ type: 'pie', radius: '60%', data: data.map(item => ({ name: item.brand, value: item.cnt })) }] }); });饼图的radius: '60%'控制圆环大小,legend放在底部避免遮挡图表主体。折线图配置同柱状图相似,series.type换成'line',smooth: true让曲线更平滑。ECharts 的 CDN 引入放在<body>标签末尾:
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>4.3 页面启动方式与演示环境的网络问题
启动整个 Web 服务的命令是:
python run.py正常情况控制台会打印 Flask 的启动信息,浏览器访问http://localhost:5000即可看到页面。host='0.0.0.0'意味着局域网内其他设备也能访问,答辩演示时如果现场电脑有问题,可以拿手机连同一 WiFi 打开页面兜底。debug=True开发阶段能看到详细报错,但演示前建议把debug改成False,避免误触文件保存导致服务自动重启。
网络问题是答辩现场最容易翻车的一环。ECharts 的 CDN 依赖外网,如果教室没有外网,图表就是空白。预防方式很简单——把echarts.min.js下载到项目里,改成本地引用:
<script src="{{ url_for('static', filename='js/echarts.min.js') }}"></script>用自己的电脑跑项目时不存在这个问题,但答辩用的可能是教室机器,提前把静态资源本地化能少一件心事。第 5 章的避坑部分还会再展开讲这类问题。
5. 避坑指南:毕设复现与答辩演示的常见问题
5.1 爬虫请求被拒绝,返回 403
现象:运行spider.py时控制台连续输出status: 403,一页数据都抓不到。
原因:目标站点反爬升级,校验了 User-Agent 之外的请求头字段,或者当前 IP 已被临时限制。项目里的请求头配置在编写时有效,遇到站点调整就需要手动适配。
解决:先确认config.py里的请求头是不是最新版本,打开浏览器无痕窗口访问目标页面,从开发者工具 Network 面板拷贝实际请求头。对比config.py,把缺失的字段补上。其次检查访问频率,time.sleep(2)在反爬严格的站点上不够,改成time.sleep(5)或更大。如果都无效,可能被限制了 IP,换 WiFi 用手机热点试试。临时封禁一般几分钟到几小时会解除,不用急着换 IP 池方案。批量抓取时建议加一个随机延时:
import random time.sleep(random.uniform(3, 6))固定间隔的请求有规律性,随机间隔能降低被识别为脚本的概率。
5.2 SQLite 报错 database is locked
现象:启动 Flask 后访问页面,服务端报sqlite3.OperationalError: database is locked,页面数据加载失败。
原因:SQLite 在同一时刻只允许一个写入方。上一个 Python 进程没有正常关闭数据库连接,或者爬虫和 Web 服务同时打开了同一个数据库文件。
解决:先看看是不是有残留的 Python 进程在后台运行,Windows 下用任务管理器结束对应进程,macOS 用ps aux | grep python找到 PID 并杀掉。根源在于代码里没有保证连接关闭,修复方式是把连接操作放到with上下文管理器中:
import sqlite3 from contextlib import closing def query_db(sql): with closing(sqlite3.connect('data/car_data.db')) as conn: conn.row_factory = sqlite3.Row cur = conn.cursor() cur.execute(sql) return [dict(row) for row in cur.fetchall()]closing保证无论查询是否出错,连接都会在退出with块时关闭。把项目里所有手动conn.close()的地方都改成这种写法,database is locked 基本不会再出现。另外要注意,爬虫和 Flask 不要同时运行两个进程,数据采集完再启动 Web 服务。
5.3 ECharts 图表空白,控制台报错
现象:页面能打开,但三张图表区域全是空白,浏览器控制台报红色错误。
原因:最常见的是echarts.init执行时对应的 DOM 元素还未渲染完成,其次是接口返回的数据是空数组,第三种是 CDN 加载失败导致echarts对象不存在。
解决:按顺序排查。先看控制台报错类型——如果是ReferenceError: echarts is not defined,说明 CDN 没加载成功,网络不通或地址已失效,改用本地static/js/echarts.min.js引用。如果是Cannot read property 'init' of undefined,同样指向 echarts 对象不存在。如果没有任何报错但页面空白,在 fetch 的.then里加一行console.log(data)确认接口返回是否有数据。接口返回空数组时,检查数据库里car_info表有没有记录,以及查询条件是否写错。初始化时机的问题,把 echarts 相关脚本放到<body>底部,或在window.onload事件里执行。
答辩前有一个习惯值得养成:断网状态下完整打开一次页面。ECharts 本地化引用后才能通过这个测试,现场没有外网的场景下才不会手忙脚乱。
5.4 Python 多版本环境下依赖冲突
现象:按requirements.txt装完依赖,运行run.py时报ModuleNotFoundError: No module named 'flask',但pip list里明明有 Flask。
原因:系统装了多个 Python 版本,python run.py用的解释器和pip install装的解释器不是同一个。这是 Windows 环境最常见的毕设翻车点。
解决:统一用python -m pip形式安装依赖,确保包进入当前解释器环境:
python -m pip install -r requirements.txt python run.py如果项目里有用到虚拟环境,创建并激活后再安装:
python -m venv venv source venv/bin/activate # Windows: venv\Scripts\activate pip install -r requirements.txt python run.py下一步确认当前python指向哪个解释器,在终端里敲python --version和which python。如果发现默认指向了某个旧版本,所有命令都用/path/to/python显式调用,不与系统默认环境混用。这个坑排查成本不高,但很多人卡在里面半天出不来。
5.5 控制台中文乱码
现象:爬虫运行时控制台输出的中文是乱码,比如PAGE 1正常,但车辆信息显示成鍑虹巟。
原因:Windows 控制台默认编码是 GBK,而 Python 打印的是 UTF-8 字符串,两者不匹配导致显示错乱。这个问题在运行爬虫时影响不大,但答辩演示时考官看到乱码会很减分。
解决:项目根目录创建.env或用标准库适配。最简单的处理是在spider.py文件头部加一行:
import sys import io sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')或者在启动命令层面设置环境变量。Windows PowerShell 下先执行:
$env:PYTHONIOENCODING='utf-8' python spider.pyPYTHONIOENCODING环境变量让 Python 在输出时统一使用 UTF-8 编码,控制台显示就不再有乱码。注意这个只在当前终端会话有效,重开终端要重新设置。跑数据导出的 CSV 时,记得沿用第 2 章提到的utf-8-sig编码,Excel 打开才不会乱码。
6. 跑通后的进阶方向:换数据源、加模型与演示验证
6.1 目标站点改成 Ajax 接口时的爬虫改造
项目默认爬的是静态 HTML 页面,但现在不少二手车平台的前端改成了 Vue 或 React,列表数据通过 Ajax 接口动态加载。直接requests.get页面拿不到车辆信息,返回的 HTML 里只有空壳的div#app。遇到这种站点,改造方式很直接:打开浏览器开发者工具,切到 Network 面板,刷新页面后筛选 XHR 请求,找到返回 JSON 数据的那个接口。
def crawl_from_api(): api_url = 'https://example.com/api/car/list' params = { 'page': 1, 'pageSize': 20, 'sort': 'price' } response = requests.get(api_url, params=params, headers=HEADERS, timeout=10) data = response.json() for item in data['data']['list']: car_item = { 'title': item.get('title', ''), 'price': item.get('price', 0), 'mileage': item.get('mileage_km', 0) / 10000, 'reg_year': item.get('first_reg_year', 0), 'gearbox': item.get('gearbox_type', '未知'), 'displacement': item.get('engine_volume', 0), } insert_car_item(conn, car_item)接口返回的字段名通常和页面展示不完全一致,比如mileage_km是原始公里数,除以 10000 转成万公里,才能和项目里已有的单位对齐。reg_year可能叫first_reg_year,价格可能叫dealer_price。要对着实际返回的 JSON 结构做字段映射,清洗逻辑整体可以复用。响应里如果含total字段,翻页循环可以用总条数除以 pageSize 来计算终止页,比静态页面的空数据判断更精确。
6.2 在可视化基础上加一个价格预测模块
答辩时想加分,可以在可视化之外补一个简单的价格预测分析。随机森林或线性回归都可以,基于现有的price、mileage、reg_year三列数据,用 scikit-learn 训练模型。项目数据量不大,线性回归足够:
from sklearn.linear_model import LinearRegression import numpy as np def prepare_data(): rows = query_db("SELECT mileage, 2025 - reg_year, price FROM car_info WHERE price > 0") X = np.array([[row[0], row[1]] for row in rows]) y = np.array([row[2] for row in rows]) return X, y X, y = prepare_data() model = LinearRegression() model.fit(X, y) # 预测一辆里程 5 万公里、车龄 3 年的车 prediction = model.predict([[5.0, 3.0]]) print(f'estimated price: {prediction[0]:.2f} 万元')这个模型的业务解释是清晰的:里程和车龄是两个特征,价格是目标变量。2025 - reg_year把年份转换成车龄,拟合出的系数能直观地看到「里程每增加 1 万公里,价格大约下降多少」。这个结论可以直接写进论文的分析章节,比单纯几张图表更有内容。当然,几百条数据的预测精度有限,论文里表述为「趋势参考」而非「精确估价」,逻辑上站得住。
6.3 答辩演示前的一轮冷启动验证
我复现过不少毕设项目,也逐渐形成了自己固定的一套验证流程。拿到压缩包后第一次跑通时极其顺利,但答辩现场换了一台电脑后全军覆没的情况并不少见。从那以后我每次演示前都会强制走一遍冷启动流程:从重启电脑开始,到最终页面完整展示三个图表为止,全程不再碰任何无关操作,只按顺序执行「安装依赖 → 启动服务 → 打开页面 → 刷新验证图表 → 截图留底」。这一步能把绝大多数环境问题提前暴露出来,不用把风险留到现场。
数据备份也是一个教训。跑爬虫之前,把data/car_data.db先复制一份到data/backup.db。为什么这么操作?爬虫脚本和数据库读写如果存在逻辑错误,可能污染原始数据,导致图表无法还原。有了备份,随时可以恢复干净状态,不用重新跑一遍爬虫,省时间也省心。每次改动代码前备份,久而久之在项目里也成了一种习惯。
开发过程中遇到页面空白,我会先看 Flask 控制台有没有报错,再看浏览器 Network 面板里/api/price_distribution是否返回 200,最后看响应体里的 JSON 数据是否正常。这套排查顺序可以覆盖九成以上的前端显示问题。答辩演示前最后一个小时,通常已经不再做任何代码改动了,只做流程演练。这套项目本身是直接可用的状态,但现场演示的流畅度还得靠自己提前把关。希望这些经验能帮你的答辩过程少一些不确定性,把时间花在讲技术逻辑本身,而不是在环境配置里挣扎。
以上是我对这个项目从拆解到复现的全部记录。压缩包里包含了源码、文档说明和数据库文件,按文中流程跑一遍就能看到完整可视化效果,需要的话可以直接拿去用。
本文还有配套的精品资源,点击获取