公交数据可视化实战:Python爬虫+Flask+SQLite+ECharts全链路解析
2026/9/11 20:29:52 网站建设 项目流程

简介:这是一套基于Python与Flask的公交数据可视化分析大作业源码及文档,是期末大作业开发中获97分的高分设计项目,适合作为课程设计、期末大作业或毕设参考。项目以公交数据为对象,覆盖爬虫数据采集、数据清洗存储、Flask后端接口和可视化大屏展示等完整环节,配有丰富的代码注释,基础薄弱也能读懂,并支持二次开发。压缩包共2000个文件,以1890个Python源码文件为主,涵盖爬虫、Flask后端与数据分析模块;另有txt说明、json数据、md文档及少量css/js前端资源,帮助理解配置与前端交互,整体大小62.36MB,结构清晰。目前已有323人学习/下载。对于想要快速搭建数据可视化项目、梳理爬虫+Flask完整开发流程的读者,这是一份可直接运行的高分设计参考。

1. 公交数据可视化大作业,真正卡进度的是数据,不是图表

公交数据可视化这个题目,听上去像是一个前端图表项目,但实际做下来你会发现另一个结论:ECharts 画图只需要几十分钟,难的是把线路、站点、车辆运行时间这些数据稳定抓下来,清洗成一张能查、能聚类的表。这套技术栈的典型分工是 Python 写爬虫抓数据,Flask 把数据暴露成 JSON 接口,SQLite 管存储,前端用 ECharts 消费接口。对准备课程设计或求职作品集的人,它是一条完整可讲的链路;对需要快速搭内部数据看板的工程师,它也是一个轻量可复制的模板。需要提醒的是,抓取数据请遵守目标站点的 robots 约定,控制请求频率,并对下载内容做合规检查。

2. 用 requests + BeautifulSoup 搭一个可重试、可限速的公交数据爬虫

2.1 数据源选型:静态 HTML、JSON 接口和文档型 PDF

公交数据一般来自三个渠道,抓取策略完全不同。城市公共交通集团官网的线路列表多是静态页面,适合 requests 直接抓 HTML 再解析;部分数据开放平台会暴露 JSON API,可以直接解析结构体;还有一类是 PDF 发布的站点时刻表,则需要先转文本再用正则做信息抽取。我一般会先把目标页面下载到本地,再做解析,而不是每次跑都打线上接口。表格里列一下常见选择和对应工具:

数据源形态典型位置解析方案适用场景
静态 HTML 页面公交集团官网线路页requests + BeautifulSoup(lxml)线路列表、站点顺序、首末班时间
JSON API市级交通开放平台requests +response.json()实时车辆位置、到站预估
PDF / 图片官网公告附件pdfplumber 转文本 + 正则站点更名、临时调整
第三方地图 Web 页地图公交详情不建议抓取数据版权和反爬成本都高

确定了数据源之后第二步才是写代码。如果站点本身有开放数据接口,优先走接口;没有接口才退回到 HTML 解析路线,这个顺序能省掉大量清洗工作。

2.2 带重试机制的 Session 封装:requests 爬虫不是一个请求函数

爬虫最常见的问题是“跑一半挂了”。一个请求超时,整个脚本崩溃,第二次跑又从第一条开始。常见做法是给 requests 封装一层 Session,把重试和连接池复用的逻辑统一收口。

import random import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry UA_POOL = [ "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 build_retry_session(retries: int = 3, backoff_factor: float = 0.6): retry = Retry( total=retries, read=retries, connect=retries, status_forcelist=[500, 502, 503, 504], backoff_factor=backoff_factor, ) session = requests.Session() session.headers["User-Agent"] = random.choice(UA_POOL) session.headers["Referer"] = "https://bus.example.cn/" adapter = HTTPAdapter(max_retries=retry, pool_connections=10, pool_maxsize=20) session.mount("http://", adapter) session.mount("https://", adapter) return session

这里status_forcelist指定哪些 HTTP 状态码触发重试,500 和 502 这类服务端错误重试才有意义;backoff_factor让每次重试等待时间递增,0.6 表示第一次等待 0.6 秒、第二次 1.2 秒,避免重试风暴。UA 池随机切换是为了减少同一 User-Agent 在日志里出现频率过高的问题,但这不是反爬的万能药,真正更有效的方案是控制抓取节奏。

2.3 解析与入库:先落 HTML 再清洗,避免反复抓站

建议把每次抓到的原始 HTML 存成文件,文件名带抓取时间,之后再从本地文件解析进数据库。这样做有两个好处:同一个页面调试解析逻辑时不产生二次请求;原始报文留存方便回溯字段问题。解析代码直接操作 BeautifulSoup 对象:

from bs4 import BeautifulSoup from pathlib import Path def parse_line_page(html_path: str): html = Path(html_path).read_text(encoding="utf-8") soup = BeautifulSoup(html, "lxml") line_list = [] for item in soup.select("ul.line-list li"): name = item.select_one(".line-name").get_text(strip=True) href = item.select_one("a").get("href") line_list.append({"name": name, "detail_url": href}) return line_list

lxml解析速度明显快于标准库html.parser,在列表页成百上千个节点的情况下差距能到十倍级别。抓取循环里加随机 sleep:

import time for page in range(1, total_pages + 1): url = f"https://bus.example.cn/list?page={page}" resp = session.get(url, timeout=10) if resp.status_code == 200: raw_dir = Path("data/raw") raw_dir.mkdir(parents=True, exist_ok=True) (raw_dir / f"list_{page}.html").write_text(resp.text, encoding="utf-8") time.sleep(random.uniform(1.0, 2.5))

timeout=10必须写,否则 DNS 解析卡住会占用整个 worker;random.uniform(1.0, 2.5)让请求间隔在 1 到 2.5 秒之间抖动,比固定 sleep 更贴近人工浏览行为。数据抓回来看一眼 HTML 再写解析规则,这个顺序不要颠倒。

3. Flask 提供数据接口,SQLite 建模公交线路与到站数据

3.1 为什么选 SQLite 而不是 MySQL 或 JSON 文件

公交可视化的数据规模一般是万级线路、十万级站点记录,SQLite 完全撑得住,而且交付时不用部署数据库服务。对比用 JSON 文件直接交给前端,SQLite 的好处是可以在 SQL 层做聚合排序,比如“某条线路 8 点到 9 点的平均到站间隔”,一条 COUNT 加 GROUP BY 就完成。相比之下,如果爬虫需要频繁增量更新数据,JSON 文件很难做并发写入,SQLite 的事务机制能保证同一时间只有一份版本在生效。

选择 SQLite 需要把一个点提前想清楚:它的强项是本地读写,不适合多个服务进程同时高并发写。大作业这种单机爬虫加单机 Flask 的架构,远没有触到它的上限。如果后续要支撑更大并发,可以把sqlite3.connect(..., check_same_thread=False)替换成 SQLAlchemy 的连接池做平滑过渡,但初期没必要引入额外依赖。

3.2 表设计与建表语句:线路、站点、GPS 记录分三张

这三张表是公交可视化最常见的模型。第一张bus_lines存线路基本信息,第二张bus_stops存站点排序,第三张bus_gps存车辆位置或到站记录。站点和线路是多对多关系,所以第三张表也承担关联查询的职责。

CREATE TABLE bus_lines ( line_id TEXT PRIMARY KEY, line_name TEXT NOT NULL, start_stop TEXT, end_stop TEXT, first_bus TEXT, last_bus TEXT ); CREATE TABLE bus_stops ( stop_id INTEGER PRIMARY KEY AUTOINCREMENT, line_id TEXT NOT NULL REFERENCES bus_lines(line_id), seq_no INTEGER NOT NULL, stop_name TEXT NOT NULL, lng REAL, lat REAL ); CREATE TABLE bus_gps ( id INTEGER PRIMARY KEY AUTOINCREMENT, line_id TEXT NOT NULL, run_time TEXT NOT NULL, lng REAL, lat REAL, status TEXT ); CREATE INDEX idx_stops_line ON bus_stops(line_id, seq_no); CREATE INDEX idx_gps_line_time ON bus_gps(line_id, run_time);

seq_no表示站点在线路中的顺序,可视化画路线时必须按它排序,不然线段会乱跳。run_time建议统一存成YYYY-MM-DD HH:MM:SS的字符串,浏览数据时不用转换就能看懂,Python 侧构造也方便。索引加在查询频率最高的联合字段上,一张百万行级别的 GPS 表没有索引可以慢到几百毫秒,加了之后基本稳定在 10 毫秒内。

3.3 按版本暴露 JSON API:Flask 只做接口层

Flask 在这个项目里的角色是纯接口层,不做模板渲染。路由设计成带版本号的结构,前端升级时后端可以平行兼容。

from flask import Flask, jsonify, request from flask_cors import CORS import sqlite3 app = Flask(__name__) CORS(app) def query_db(sql: str, args: tuple = ()): conn = sqlite3.connect("bus_data.db") conn.row_factory = sqlite3.Row rows = conn.execute(sql, args).fetchall() conn.close() return [dict(row) for row in rows] @app.get("/api/v1/lines") def list_lines(): keyword = request.args.get("keyword", "").strip() if keyword: rows = query_db( "SELECT * FROM bus_lines WHERE line_name LIKE ? LIMIT 20", (f"%{keyword}%",), ) else: rows = query_db("SELECT * FROM bus_lines LIMIT 100") return jsonify({"code": 0, "data": rows})

CORS(app)解决前端本地调试时的跨域问题,否则从 5500 端口打开页面访问 Flask 的 5000 端口会被浏览器拦截。LIKE模糊查询用于线路名搜索,注意参数化查询用?占位符,不要把字符串直接拼进 SQL,Flask 不会自动处理这种拼接风险。row_factory = sqlite3.Row让查询结果可以按列名取值,序列化成 JSON 时中文比较整洁。

3.4 前端消费 API:ECharts 画时段折线与线路热度

接口准备好之后,前端用原生 fetch 拿 JSON,图表用 ECharts 渲染。为了离线演示稳定,建议把echarts.min.js下载到本地 static 目录,而不是引用 CDN,避免答辩现场没有外网导致图表空白。下面是画一个线路全天到站次数折线图的最小示例:

fetch("/api/v1/gps_histogram?line_id=822") .then((res) => res.json()) .then((json) => { const data = json.data; const chart = echarts.init(document.getElementById("histogram")); chart.setOption({ xAxis: { type: "category", data: data.hours }, yAxis: { type: "value" }, series: [{ type: "line", data: data.counts, areaStyle: {} }], }); });

接口返回的字段名需要在后端定义时保持稳定,前端所有图表都依赖这个契约。如果后期改了字段名,图表会静默失败,控制台只报 undefined,排查起来比较费时间。

4. 源码工程化与文档说明:从单脚本改成分层项目

4.1 分层结构 models / services / api / spiders 各管什么

大多数大作业源码是从爬虫脚本一路叠加写出来的,一个 main.py 里混了 requests、数据库、Flask 路由和 HTML 字符串。跑起来没问题,改一个参数要翻遍全部代码。一般我会把项目拆成下面这种结构,拆完以后每一层都可以单独测试。

bus-vis/ ├── app.py ├── config.py ├── requirements.txt ├── spiders/ │ ├── __init__.py │ └── crawl_bus.py ├── services/ │ ├── __init__.py │ └── line_service.py ├── models/ │ ├── __init__.py │ └── db.py ├── api/ │ ├── __init__.py │ └── routes.py ├── static/ │ └── echarts.min.js ├── templates/ │ └── index.html └── data/ └── raw/

spiders只负责请求网页和解析结构,返回 Python 字典或列表;models封装数据库的初始化、连接和底层查询,业务逻辑不写 SQL;services是中间层,从爬虫拿原始数据,调用 models 写入并做清洗;api只注册路由,把 services 的返回结果转成 JSON。Flask 的入口app.py里只做三件事:创建 Flask 实例、注册蓝图、调用create_all建表。

4.2 配置外置与依赖锁定:改参数不用动业务代码

数据库路径、爬虫请求间隔、UA 池这些应放到 config.py 里,这样改配置不用碰逻辑代码,也让文档说明和实际运行参数对得上。

class Config: DATABASE_PATH = "bus_data.db" REQUEST_TIMEOUT = 10 CRAWL_SLEEP_RANGE = (1.0, 2.5) MAX_PAGES = 50

运行入口读配置:

app = Flask(__name__) app.config.from_object(Config)

requirements.txt锁定主要依赖版本,尽量写“>=”而不是“==”,这样既能保持兼容,又不会把已有环境锁死在一个过旧版本上。依赖列表里除了 Flask 和 requests,还需要lxml(HTML 解析引擎)和flask-cors(跨域支持)。

4.3 README 文档说明的五个要素

文档说明不写成了使用手册,把下面五件事写清楚就够了:项目是干什么的、目录结构是什么、环境要求是什么、运行顺序是什么、爬虫抓的是什么数据合法吗。README 里贴出运行顺序是关键,按下面的最小模板来:

python -m venv .venv source .venv/bin/activate pip install -r requirements.txt python app.py

先执行爬虫,再启动 Flask;或者直接做成 Flask CLI 命令,这一步在第五章展开。文档里还要写清楚数据更新方式,是手动重跑爬虫还是定时任务,大作业阶段手动重跑已经足够,不要加 cron 把线上站点抓出问题。

4.4 从大作业源码到企业级数据可视化还差哪几步

这部分是对有经验的读者说的。大作业代码能跑,但距离可交付还有四条明显的差距。第一是缺少日志:写个简单装饰器把所有关键步骤打到 logs 目录,比后期靠 print 排查靠谱得多。第二是没有限流:前后两次抓取如果并发数超过网站阈值,IP 会被封,requests 只能帮你重试不能帮你预防。第三是没有监控指标:加一个/api/v1/health路由返回最后抓取时间、数据库连接状态和记录数。第四是配置外部化:把密钥、数据库路径放进环境变量,而不是写死在 config.py 里。做项目展示时把这几点主动讲出来,比跑通 20 张图表更能体现工程能力。

5. 用 Flask CLI 一键完成抓取、入库与可视化刷新

5.1 自定义 flask crawl-update 命令,串起整条链路

Flask 的 CLI 体系可以挂自定义命令,把爬虫、清洗、入库、缓存清理串成一条命令。这样文档说明里就不用写“先运行 python crawl.py,再运行 python app.py”,而是flask crawl-update一条路径走完,也方便部署脚本调用。

import click from flask.cli import with_appcontext def register_commands(app): @app.cli.command("crawl-update") @with_appcontext def crawl_update_command(): """抓取最新公交数据并刷新接口缓存。""" from spiders.crawl_bus import run_spider from services.line_service import load_lines_into_db raw_data = run_spider() load_lines_into_db(raw_data) app.config["LAST_UPDATE"] = datetime.now().isoformat() click.echo("update finished")

@with_appcontext保证命令执行时可以访问 app 配置和数据库连接;click.echo输出统一格式的成功信息,配合日志文件可以快速确认执行状态。Flask 2.3 及以上版本用flask --app app crawl-update触发命令。

5.2 用 run_time 目录做数据快照,可视化随时回溯

每次抓取的数据先落到data/raw/20250311/这种按日期命名的目录,再在bus_gps表里用run_time字段记录抓取批次时间。这样做的好处是,如果某天抓到的数据明显异常,可以直接从旧目录恢复到上一个批次,不用重新请求站点。数据快照配合接口里的时间参数,还能做“不同日期的线路运力对比”,这是可视化看图时最加分的功能。

5.3 一条命令启动 Flask 并验证接口

开发阶段用 127.0.0.1 启动方便调试,演示时可以绑定局域网 IP 让其他设备访问:

flask --app app run --host=0.0.0.0 --port=5000 curl "http://127.0.0.1:5000/api/v1/lines?keyword=822"

curl 返回 JSON 数组后,再用浏览器打开http://127.0.0.1:5000看可视化页面。如果接口返回空列表,优先检查bus_data.db是否生成、run_spider()是否正常完成,而不是先怀疑图表代码。整条链路从抓取到展示都跑通之后,再回头看 README 里的运行说明,把命令替换成 Flask CLI 方式,文档和代码就不会出现两套不一致的启动路径。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询