简介:这是一套基于Python与Flask框架的爬虫数据可视化分析大作业项目源码,面向计算机相关专业学生及Python从业者,尤其适合作为期末课程设计或综合大作业参考。项目实现中国省份GDP数据可视化系统,后端采用Flask接口,前端基于Vue组件化开发,内置数据库文件,可快速运行并理解从爬虫采集、接口开发到前端展示的完整业务链路。
压缩包共一百三十个文件,包含18个Python源码文件、12个Vue组件、9个JavaScript脚本、24个JSON数据文件(承载省份GDP数据集)、1个SQL数据库脚本,另有图片、CSS、配置文件等辅助内容,整体大小6.83MB,目录结构清晰,便于按模块逐一学习。目前已有171人学习下载。
项目已经过严格调试,导师评审评分达96分以上,完成度较高。读者除了获得可直接运行的完整代码,还能从中拆解Flask接口设计、Vue前端交互、数据库表结构及GDP数据可视化图表实现等关键环节,非常适合用于毕业设计、课程报告或技能提升时的参考与二次开发。
1. 这门“Python+Flask 爬虫数据可视化大作业”难的不是爬虫,而是整条链路
只看标题会以为这是一道爬虫题,真正动手才发现,爬虫只是这条数据流水线的第一站。整套大作业要交付的是:用 requests 抓取公开网页,把字段解析后写入数据库文件,再用 Flask 起一个 Web 服务暴露查询接口,最后在浏览器里用 ECharts 把数据渲染成可视化大屏。任何一个环节断裂,导师打开你的项目就立刻失去兴趣。“源码 + 数据 + 数据库文件”这三个交付物,分别对应爬虫模块、CSV 或抓取结果、以及带结构化表的设计文件,比单点技术 demo 更能体现工程思路。这篇内容适合正在做数据库课程设计、毕业设计,或者想把往期作业改造成面试作品集项目的开发者。下文按真实开发顺序推演每一层的实现,并给出可以直接抄的参数和排错经验。
2. Python 爬虫层:requests 抓取、字段解析和多页并发落库
2.1 先定数据量,再决定用 requests 还是 Scrapy
大作业的数据规模通常是几百到几万条,来源是几十个列表页或少量详情页。这个量级下,requests + BeautifulSoup 是最稳妥的选型:代码量小、依赖少、答辩时容易讲清每个步骤。Scrapy 的下载中间件、Twisted 异步模型和 Item Pipeline 在这个规模上是负资产,除非你要抓的是几十万条商品快照,才值得为它付出学习和调试成本。分布式爬虫在这类作业里通常表现为“预留了分段抓取的接口”,而不是真的部署多台机器,导师更在意的是你能否说清并发上限和数据库去重策略。
我自己判定选型只看两个指标:单页字段超过 20 个、页面层级超过两层,才考虑 Scrapy;否则 requests 的前 200 行代码足够覆盖全部需求。
| 选型 | 适合规模 | 学习成本 | 大作业答辩加分点 |
|---|---|---|---|
| requests + BeautifulSoup | 万条以内、字段少于 20 个 | 低 | 代码直观,逻辑好讲 |
| Scrapy | 大规模、多层级页面 | 中高 | 体现爬虫工程完整性 |
| 自研并发 + 代理池 | 请求频率受限的站点 | 中 | 展示对反爬边界理解 |
初学者最容易掉进的坑是用正则直接从整页 HTML 里抠字段。页面结构稍微一变,正则表达式立刻失效,而且报错时你根本分不清是标签嵌套问题还是引号转义问题。我一般只用正则清洗价格、日期这类半结构化文本,字段定位全部交给 BeautifulSoup 的select()方法。
2.2 最小抓取单元:请求头、字符集和字段解析参数
以抓一个图书列表页为例,目标是提取每本书的标题、价格和分类。最小可运行的抓取函数如下:
import requests from bs4 import BeautifulSoup HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36" } def crawl_page(page: int): url = f"https://example-books.com/list?page={page}" resp = requests.get(url, headers=HEADERS, timeout=6) resp.encoding = resp.apparent_encoding soup = BeautifulSoup(resp.text, "html.parser") books = [] for node in soup.select(".book-item"): books.append({ "title": node.select_one(".title").text.strip(), "price": float(node.select_one(".price").text.strip().lstrip("¥")), "category": node.select_one(".category").text.strip(), }) return books这段代码里有三个参数直接决定爬虫的存活率。第一是timeout=6,网络请求超过 6 秒直接抛异常,避免某个坏页面卡住整个线程。第二是resp.encoding = resp.apparent_encoding,很多目标站没有在响应头里声明 charset,requests 默认用 ISO-8859-1 解码就会得到乱码,这里用 apparent_encoding 让 requests 从页面内容推断字符集。第三是HEADERS里的 User-Agent,不携带浏览器标识的请求很容易被直接拒绝。.book-item、.title这类选择器来自目标页面的 HTML 结构,实际作业中先打开控制台确认 class 名再写选择器,比在代码里反复猜测高效得多。
如果目标站返回的是 JSON 接口而不是 HTML 页面,把soup换成resp.json()即可,后面的字段提取逻辑完全不用变。很多所谓“反爬严格的站点”其实是接口数据没被发现。
2.3 多页并发与反爬克制:ThreadPoolExecutor、延时和 User-Agent 池
爬虫需要遍历多个页码,串行请求慢到让人想放弃,但直接开 20 个线程又会被站点限流。并发设计的核心不是“开更多线程”,而是“把请求频率控制在不触发封禁的区间内”。我用 ThreadPoolExecutor 时默认从 4 个线程起步,每抓一页后主动 sleep 0.5 到 1.5 秒:
from concurrent.futures import ThreadPoolExecutor import random import time def run(): with ThreadPoolExecutor(max_workers=4) as executor: futures = {executor.submit(crawl_page, page): page for page in range(1, 6)} for future in futures: books = future.result() print(f"page {futures[future]}: {len(books)} 条") save_books(books) time.sleep(random.uniform(0.5, 1.5))max_workers=4意味着同时只有 4 个请求在途,配合随机延时后,每秒请求数被压到个位数。CPU 和 IO 的平衡点在于:线程数超过 8 之后,抓取速度受目标站响应时间限制,收益快速递减,封禁风险却直线上升。另一个低成本方案是准备一个 UA 池,每次请求随机更换 User-Agent,但注意不要在代码里写死一长串看起来像真人、实则语义矛盾的无效 UA。如果站点对 IP 有严格频率限制,才需要引入代理池,这类作业的场景一般用不到。
并发设计在答辩时的讲解顺序应该是:先说明串行太慢,再说明为什么 4 个线程而不是 20 个,最后用 sleep 证明你考虑了目标站负载。这条逻辑比“我用了多线程”一句话有力得多。
2.4 数据入库与去重:SQLite 表结构、INSERT OR IGNORE
抓下来的数据要变成“数据库文件”交付,SQLite 是唯一合理选择:单文件、零配置、导师拿到手就能用可视化工具打开。表结构设计遵循“一表一主题”原则,先建一张图书表:
CREATE TABLE IF NOT EXISTS book ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT UNIQUE NOT NULL, price REAL, category TEXT, crawl_time DATETIME DEFAULT CURRENT_TIMESTAMP );写入时用INSERT OR IGNORE而不是普通INSERT,因为重复运行爬虫是常态,title 上的唯一约束会让第二次运行自动跳过重复数据。
import sqlite3 conn = sqlite3.connect("books.db") def save_books(books): with sqlite3.connect("books.db") as conn: conn.executemany( "INSERT OR IGNORE INTO book(title, price, category) VALUES(?, ?, ?)", [(b["title"], b["price"], b["category"]) for b in books], ) return conn.total_changesexecutemany一次提交一批数据,比逐条execute快一个数量级。total_changes返回本次实际插入的行数,可以用来确认去重是否生效。如果你在做个数据库课程设计,可以再加一张 crawl_log 表记录每次抓取的页码和结果条数,这会让数据文件显得更完整,也是评分的隐藏加分项。爬虫和数据准备到这里结束,下一步是把这份数据库文件交给 Flask 使用。
3. Flask 后端与数据库文件:路由分组、SQLAlchemy 聚合查询
3.1 为什么是大作业场景用 Flask 而不是 Django
Flask 的核心优势是“把一个 Python 文件跑起来就是服务”,这对课程设计和作品集展示极其友好。Django 的 ORM、Admin 后台和模板系统是强大,但它的项目结构对 500 行代码的作业来说是负担。Flask 直接返回 JSON 的天性也正好匹配可视化大屏的前后端分离模式。我习惯把单文件 app.py 按三层组织:顶部是配置和数据库初始化,中间是 ORM 模型,底部是路由。不超过 300 行时,单文件比包结构更好讲解;超过 300 行再拆成 models.py、routes.py、service.py 也不迟。
这个分层方式不是 Flask 强制的,但它让答辩时“配置”、“数据模型”、“接口逻辑”三个部分可以顺次展开,不会来回跳文件。
3.2 配置 SQLite 数据库路径:绕开 instance 文件夹的坑
Flask-SQLAlchemy 3.x 对sqlite:///books.db有个容易忽视的默认行为:它会把这个相对路径解析到当前项目下的instance/目录,而不是 app.py 所在的目录。如果第 2 章的爬虫把 books.db 写在项目根目录,Flask 启动后就会查不到表,症状是接口返回 500。
解决方法是一开始就用绝对路径:
import os from flask import Flask, render_template, jsonify from flask_sqlalchemy import SQLAlchemy from sqlalchemy import func BASE_DIR = os.path.abspath(os.path.dirname(__file__)) app = Flask(__name__) app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + os.path.join(BASE_DIR, "books.db") app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False db = SQLAlchemy(app)BASE_DIR取 app.py 所在目录的绝对路径,然后显式拼接books.db,无论从哪个目录执行python app.py,连接的都是同一份数据库文件。它同时解决了两个交付问题:数据库文件始终在项目根目录可见,导师不用去 instance 目录找数据;爬虫和 Flask 共用一个路径常量,不会因为工作目录差异产生两份不同的数据。
3.3 SQLAlchemy 模型与 category 维度的聚合接口
建一个 Book 模型,字段和前面 SQLite 表结构保持一致:
class Book(db.Model): id = db.Column(db.Integer, primary_key=True) title = db.Column(db.String(255), unique=True) price = db.Column(db.Float) category = db.Column(db.String(64))可视化大屏需要三类接口:分类数量条形图对应的聚合、价格区间分布的柱状图、以及分类平均价格的折线图。它们都建立在同一个 GROUP BY 查询上。
@app.route("/api/category_summary") def category_summary(): rows = db.session.query( Book.category, func.count(Book.id).label("count"), func.avg(Book.price).label("avg_price"), ).group_by(Book.category).all() return jsonify({ "categories": [r[0] for r in rows], "counts": [r[1] for r in rows], "avgPrices": [round(r[2], 2) for r in rows], })这里func.count和func.avg是 SQLAlchemy 对 SQL 聚合函数的封装,label()指定返回字段名,便于前端取值。注意我返回的是三个平行数组而不是对象数组,这是为了配合 ECharts 的 data 格式,省去前端二次 map 的代码。价格区间分布可以复用同一个模型,用case表达式把价格按 0-50、50-100、100+ 分组,然后同样走 GROUP BY。路由命名保持/api/<维度>_<指标>的格式,整个接口层就具有可预测性。
3.4 前端取数两条路:Jinja2 渲染与 fetch 拉取 API
主页面index.html可以通过 Flask 模板直接拿到后端数据,也可以只渲染空壳、由 JavaScript 去请求接口。两者在这类作业里都常见。
Jinja2 方式适合数据量小、不想处理跨域的场景:
<script> const INIT_DATA = {{ api_data | tojson | safe }}; </script>tojson是 Jinja2 的内置过滤器,会把 Python 字典转成合法的 JavaScript 对象。为什么还要加safe?不加上去,Jinja2 会默认转义 HTML 字符,导致 JSON 里的<、>变成<而破坏脚本。fetch 方式则让大屏页面和后端接口彻底解耦,改图表不用重启 Flask。
| 取数方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Jinja2 tojson | 首屏快、无网络请求 | 数据写死在 HTML 里 | 小数据量的作业演示 |
| fetch 拉 /api | 前端可独立调试 | 需要浏览器直连后端 | 大屏多图表、参数筛选 |
我倾向于两者结合:初始图表用 Jinja2 保证打开页面就有内容,筛选类图表用 fetch 带参数请求接口。下一节的 ECharts 部分默认采用 fetch 方式,因为它能直接对接第 3 章设计的/api路由。
4. ECharts 可视化大屏:JSON 接口对接、图表参数和响应式布局
4.1 字段与图表的映射关系
大屏不是把图表堆上去就完事,每个图表都要回答一个数据问题。分类字段用饼图展示占比,连续字段价格用柱状图看分布,时间字段用折线图看趋势,地理字段才需要地图。映射关系取决于字段类型而非图表好看程度。
| 原始字段 | 聚合方式 | 推荐图表 | ECharts type | 关键配置项 |
|---|---|---|---|---|
| category | GROUP BY 计数 | 环形饼图 | pie | roseType, radius |
| price | 区间分桶 | 柱状图 | bar | barWidth, xAxis |
| crawl_time | 按天计数 | 折线图 | line | smooth, areaStyle |
| 排名类字段 | 前 10 条 | 横向条形图 | bar | yAxis inverse |
大屏常见的错觉是把饼图放在中央撑场面,但饼图最适合表达“部分占整体”的单个问题。如果页面上同时有柱状和折线,统一色系、统一坐标轴标签的字体和留白,比增加动画更重要。
4.2 从 fetch 到 setOption:ECharts 最小接入代码
可视化章节的核心是把/api/category_summary的数据搬到图表上。最小可用代码如下,容器用固定高度是关键,ECharts 在高度为 0 或 auto 的 div 里会渲染失败。
<div id="pie" style="width:100%;height:360px;"></div> <script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script> <script> fetch("/api/category_summary") .then(res => res.json()) .then(data => { const chart = echarts.init(document.getElementById("pie")); chart.setOption({ title: { text: "图书分类数量分布", left: "center" }, tooltip: { trigger: "item" }, legend: { bottom: 0 }, series: [{ type: "pie", radius: ["40%", "70%"], data: data.categories.map((c, i) => ({ name: c, value: data.counts[i] })) }] }); window.addEventListener("resize", () => chart.resize()); }); </script>radius: ["40%", "70%"]表示环形饼图的内半径和外半径,这种配置比实心饼图更适合大屏,因为中心区域可以再放一个总数指标。trigger: "item"让鼠标悬浮时逐个显示分类的数值和占比。数据映射时,categories和counts两个平行数组通过索引合成 ECharts 需要的对象数组,这是本章节中后端返回结构设计的一个正向结果。
4.3 大屏最常用的 6 个参数组合
ECharts 配置项很多,但大屏场景高频使用的集中在以下几组。记住这组就足以撑起一个 8 块图表的大屏页面。
chart.setOption({ color: ["#409EFF", "#67C23A", "#E6A23C", "#F56C6C", "#909399"], tooltip: { trigger: "axis", axisPointer: { type: "shadow" } }, legend: { top: 0, type: "scroll" }, grid: { left: "3%", right: "4%", bottom: "3%", containLabel: true }, xAxis: { type: "category", data: data.categories, axisLabel: { rotate: 30 } }, yAxis: { type: "value" }, series: [{ type: "bar", data: data.counts, barWidth: 20, itemStyle: { borderRadius: [4, 4, 0, 0] } }] });逐项说明:color数组一次性定义多系列默认色,比逐个 series 写 color 更省事;trigger: "axis"适用于柱状图、折线图这类坐标轴图表,它会在悬浮时显示该 x 轴点的所有系列;grid.containLabel: true保证坐标轴文字不被裁切,这是初学者最容易忽略的布局问题;axisLabel.rotate用于分类名过长时避免文字重叠;barWidth固定柱子宽度,让大屏在不同缩放比例下保持协调。这些参数组合起来就是企业级数据可视化大屏里最常见的图表骨架,适配组件外观很快。
4.4 CSS Grid 网格与大屏响应式布局
大屏通常做成 1920×1080 的演示尺寸,但导师很可能用自己的笔记本打开,所以响应式不能省略。最省心的方案是用 CSS Grid 划分图表网格:
.charts-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; padding: 16px; } @media (max-width: 1024px) { .charts-grid { grid-template-columns: repeat(2, 1fr); } } @media (max-width: 640px) { .charts-grid { grid-template-columns: 1fr; } }grid-template-columns: repeat(3, 1fr)把屏幕水平三等分,窄屏时通过 media query 降低列数,让图表从三列变两列再变单列。每个图表卡片内部建议再加一个min-height: 360px,防止容器在加载瞬间高度塌缩。配合前面代码里window.addEventListener("resize", () => chart.resize()),窗口变化时图表才会跟随容器缩放。
5. 交付与复现:requirements.txt、虚拟环境和五个高频坑
5.1 venv 与 requirements.txt 锁定依赖版本
把项目交给别人,最坏的结果是对方机器上没有 requests、Flask,或者装到了系统 Python 里污染环境。标准做法是虚拟环境加依赖锁定文件。requirements.txt 里写明大版本而不是完全放任最新版:
flask==3.0.3 flask-sqlalchemy==3.1.1 requests==2.32.3 beautifulsoup4==4.12.3对方按下面的命令就可以复现环境:
python -m venv venv # Windows 下执行 venv\Scripts\activate,macOS/Linux 执行 source venv/bin/activate source venv/bin/activate pip install -r requirements.txt python app.py要注意 Python 版本与包的兼容性,Flask 3.x 要求 Python 3.8 以上。如果对方是零基础环境,先确认 Python 已经加入 PATH,否则python命令会找不到。源码、数据文件、数据库文件和 requirements.txt 放到一个文件夹里压缩交付,比 GitHub 仓库链接更符合课程设计提交场景。
5.2 高频坑:现象、原因、处理对照
| 现象 | 根因 | 处理办法 |
|---|---|---|
| Flask 启动后接口 500 | Flask-SQLAlchemy 去 instance/ 找数据库 | 用 BASE_DIR 拼接绝对路径 |
| 图表区域空白 | div 没有显式高度 | 设置 height: 360px 而非默认 auto |
| 页面上中文全部乱码 | 爬虫没设置编码 | resp.encoding = resp.apparent_encoding |
| ECharts 只有空白网格 | CDN 在演示网络不可达 | 下载 echarts.min.js 放 static/vendor/ |
| 数据量只有预期一半 | INSERT OR IGNORE 重复标题去重生效 | 检查 title 是否真的唯一 |
其中数据库路径问题占到这类作业排错的一半以上。加载页面后按下 F12 看 Network 面板里/api请求状态码,500 先看终端报错,404 检查路由前缀,数据结构不符时直接在浏览器里打开接口地址看 JSON。先把问题收敛到一层,再去改代码,往往比猜测更快。
5.3 交付前的冒烟验证
交作业前花 20 秒跑一次验证,能避免“现场翻车”的尴尬。启动 Flask 后,在另一个终端执行:
curl http://127.0.0.1:5000/api/category_summary返回包含 categories、counts、avgPrices 三个字段的 JSON,说明后端链路正常。再检查数据库文件:
sqlite3 books.db "SELECT COUNT(*) FROM book;"如果 curl 有响应而 sqlite3 命令报错,说明你正处在错误的虚拟环境或工作目录。这两条命令通过,就可以放心把项目交付出去了。
本文还有配套的精品资源,点击获取