先说一个我自己的判断:现在市面上的大数据毕设项目,十有八九都堆在“爬虫+可视化”这条路上,但这个选题其实特别容易做空——爬了一堆数据,画了几个图表,最后问答环节一问“你这个数据到底说明了什么”,就卡住了。
所以今年我做黑龙江旅游景点数据分析系统的时候,给自己定的规矩是:爬虫只是搬运工,分析才是核心,可视化只是把分析结果讲清楚的一种方式。整个项目从采集公开数据、清洗入库,到指标体系搭建、可视化大屏呈现,完整串成了一条可以跑通的链路。这篇文章就按我的实际开发顺序,把每一步的选型逻辑、踩坑记录和关键代码拆开讲。
这个项目适合谁参考?一是正在准备大数据方向毕业设计、想找一个“有真实业务场景”题目的同学;二是想练手 Python 爬虫、Pandas 数据处理和 ECharts 可视化的开发者;三是想做一个地方旅游数据分析demo、后面想接评论情感分析或客流预测的小团队。下面直接上干货。
1. 项目整体设计与技术选型
1.1 系统要解决什么问题:从需求到模块
先聊清楚这个系统到底在干什么。很多人一上来就写爬虫,爬完再说,最后发现数据乱七八糟,分析无从下手。我的习惯是反着来:先想清楚最终要在大屏上看到什么,再倒推需要哪些数据、该怎么存、怎么算。
黑龙江旅游景点的信息分散在多个OTA平台,某平台显示的是热度分,另一个平台可能有游客评价但缺价格,数据口径不一致,用户想快速知道“黑龙江哪些景点值得去”“哈尔滨周边怎么安排路线”“哪些城市旅游热度最高”,没有一个统一入口。这个系统的目标就是解决这些问题:把公开的景点基础信息、热度、评分、评论量、门票参考价等结构化数据集中起来,再做统一计算,最后用可视化大屏直观呈现。
倒推出四个功能模块:
- 数据采集模块:请求公开旅游网页,解析景点名称、所在城市、评分、评论数、门票等信息。
- 数据清洗模块:处理乱码、重复记录、缺失值、单位不统一等脏数据问题。
- 数据分析模块:计算热度指数、城市排名、类型分布、价格区间、评论量趋势等指标。
- 可视化展示模块:基于 Flask 提供数据接口,前端用 ECharts 渲染大屏。
一句话描述就是:爬虫负责找料,数据分析负责加工,可视化负责上桌。每个环节都依赖上一个环节的输出,所以开发顺序也是按这个链路来的。
1.2 技术栈选型和方案取舍
这个项目名字带“大数据”,但实际数据量也就是几万条级别,离真正的海量数据还有距离。所以我没硬上 Hadoop、Spark 这套重框架,而是选了“轻量但架构思想完整”的技术组合。
| 模块 | 备选方案 | 我的选择 | 理由 |
|---|---|---|---|
| 数据采集 | requests + BeautifulSoup、Scrapy、Playwright | requests + BeautifulSoup | 目标网站是服务端渲染的公开静态页面,不需要模拟浏览器,解析简单、上手快;Scrapy 适合大规模分布式采集,本项目数据量用不上 |
| 数据存储 | MySQL、MongoDB、HDFS + Hive | MySQL | 数据是结构化表格数据,关系型存储容易做条件查询和统计分析;HDFS 虽符合“大数据”名号,但单机部署运维成本高,且查询不如 SQL 方便 |
| 数据分析 | Pandas、Spark SQL、HiveQL | Pandas | 几万条数据用 Pandas 处理效率极高,代码可读性好,配合 matplotlib 还能快速验证明细;Spark 在这个量级是杀鸡用牛刀 |
| 可视化 | Flask + ECharts、Node.js + ECharts、PowerBI | Flask + ECharts | Flask 轻量,能同时写接口和渲染页面,前后端联调成本低;ECharts 图表类型丰富、交互流畅,做数据大屏是主流选择 |
这里说一个很多人会踩的坑:毕设答辩时“大数据技术”的字眼容易让评委期待看到分布式框架,但硬上 Hadoop 三件套(HDFS、YARN、MapReduce),如果数据量就几万条,反而会被追问“你这个数据量为什么需要分布式,性能开销怎么解释”。我的处理方式是:在系统设计文档里保留“大数据分层架构”的思想——采集层、存储层、分析层、展示层独立解耦,同时在文档里说明“后续数据规模增长时可平滑迁移到 Spark + Hive 架构”。这样既没有为了名字硬凑技术,又体现了架构扩展性。
1.3 数据源选择与合规边界
数据源这块必须谨慎。我的选择是:优先抓取公开的、不需要登录就能访问的旅游信息页,只采集景点名称、简介、评分、评论数、门票参考价这类非个人隐私信息,不碰用户账号、手机号、真实姓名等字段。
我采用了一个比较稳妥的做法:只采集列表页和详情页的静态HTML,不触发登录流程、不绕过访问限制,请求频率控制在3到5秒一条,并且遵守目标网站的robots协议。写爬虫的人要有边界感,技术本身没有错,但脱离了合规前提,后面全是麻烦。
2. 爬虫与数据采集:数据从哪来
2.1 确定采集字段和解析思路
写爬虫之前,得先把最终要入库的字段定下来。我最终定的核心字段是这样:
| 字段 | 说明 | 示例 |
|---|---|---|
| spot_name | 景点名称 | 太阳岛风景区 |
| city | 所在城市 | 哈尔滨 |
| district | 所在区县(可选) | 松北区 |
| rating | 游客评分 | 4.7 |
| comment_num | 评论数量 | 3200 |
| heat_score | 热度分(平台给出) | 92.5 |
| ticket_price | 门票参考价(元) | 30.0 |
| play_time | 建议游玩时长 | 3小时 |
| spot_type | 景点类型 | 自然风光 |
| intro | 景点简介 | 国家5A级景区…… |
字段定了,解析逻辑就清晰了:先从列表页拿到每个景点的详情页URL,再逐个请求详情页,解析出上面这些字段。列表页通常给的是“概要信息”,只有详情页才有完整介绍和参考价。
这里有个小技巧:在写解析代码之前,先用浏览器打开目标页面,按F12看Elements结构,确认字段所在的DOM层级。不要一上来就写CSS选择器,那样很容易被页面改版打乱节奏。我用的是Python自带的html.parser加BeautifulSoup,选择器写完后先print几条结果验证,再批量跑。
2.2 爬虫实现与反爬应对
爬虫模块我拆成了两个文件:spider.py负责请求页面和解析数据,main.py负责调度和落库。核心请求代码大概长这样:
import requests import time import random from bs4 import BeautifulSoup 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", } def fetch_page(url): for attempt in range(3): try: resp = requests.get(url, headers=HEADERS, timeout=10) if resp.status_code == 200: return resp else: print(f"请求失败: {resp.status_code},第{attempt + 1}次重试") except requests.exceptions.RequestException as e: print(f"请求异常: {e},第{attempt + 1}次重试") time.sleep(random.uniform(3, 5)) return None def parse_detail(html): soup = BeautifulSoup(html, "html.parser") # 假设目标字段在 .spot-info 这个容器里 info = soup.select_one(".spot-info") if not info: return None data = {} data["spot_name"] = info.select_one(".name").text.strip() data["city"] = info.select_one(".city").text.strip() rating_text = info.select_one(".rating").text.strip() data["rating"] = float(rating_text) if rating_text else None comment_text = info.select_one(".comment-num").text.strip() data["comment_num"] = int(comment_text.replace("条评价", "")) # 解析价格,默认无量纲单位是“元” price_text = info.select_one(".price").text.strip() data["ticket_price"] = float(price_text.replace("元起", "")) if "免费" not in price_text else 0.0 data["spot_type"] = info.select_one(".type").text.strip() return data关于反爬,我的经验是:不要一上来就想“怎么绕过限制”,而是先做好最基本的请求伪装。上面代码里的User-Agent、Accept、Accept-Language三个头字段是必须的,很多反爬策略第一步就是检查UA。其次是请求间隔,我用random.uniform(3, 5)模拟人工浏览节奏,既保证了采集效率,又不会给目标服务器造成压力。实测下来,这个频率下跑了200多个页面,没有触发过限制。
另外,我用了一个比较笨但有效的方法来排查解析问题:把每次请求完的HTML原始文件落盘保存一次,文件名带时间戳。这样解析逻辑写错时,不用重新请求页面,直接打开本地HTML排查就行。调试效率和友好度直接上一个台阶。
2.3 清洗入库:让脏数据变标准
爬下来的数据永远比想象的脏,这是我在这个项目里最深刻的体会之一。常见问题包括:同一个景点在不同平台名称简繁体不一致、评分字段偶尔是空值、价格有的是“20元起”有的是“免费”、评论数是带逗号的“3,200”。遇到这些情况直接入库,后面分析的时候全得炸。
我的清洗流程分四步走:
第一步,字段标准化。统一把文本里的空格、换行、全角符号去掉,把“元起”“条评价”这类后缀剥离。价格字段单独处理,“免费”转成0,“20元起”提取数字20,转成float。
第二步,去重。以spot_name + city作为唯一键,用Pandas的drop_duplicates去掉重复记录。这一步很重要,因为详情页可能被重复请求到,列表页和详情页的数据也可能交叉重复。
第三步,缺失值处理。评分缺失的,我用该城市所有景点评分的均值填充,并打一个is_fill标记字段;简介缺失的,直接用“暂无简介”填充,不删除整条数据。这里注意:不要一遇到缺失就删行,先看缺失率,如果小于5%,填充比删除更稳妥。
第四步,入库。MySQL建表语句:
CREATE TABLE IF NOT EXISTS spots ( id INT AUTO_INCREMENT PRIMARY KEY, spot_name VARCHAR(100) NOT NULL, city VARCHAR(50) NOT NULL, district VARCHAR(50), rating FLOAT, comment_num INT, heat_score FLOAT, ticket_price FLOAT, play_time VARCHAR(50), spot_type VARCHAR(50), intro TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uniq_spot_city (spot_name, city) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;注意UNIQUE KEY uniq_spot_city (spot_name, city),这条是防止重复数据的最后一道防线。即使清洗阶段漏了,入库时也会报错,程序可以捕获错误后跳过。
批量写入我用的是pymysql的executemany,一次插几百条,比逐条insert快非常多。完整流程跑完,我库里落了大概800多个黑龙江景点的结构化数据,覆盖13个地级市,数据量虽然不大,但作为分析样本足够了。
3. 数据分析与可视化大屏
3.1 分析维度与指标计算逻辑
数据入库只是开始,怎么从数据里提炼出有价值的结论才是核心。我做分析时没有只看单一指标,而是定义了一个综合的“景区热度指数”,公式如下:
热度指数 = 0.4 * 归一化评论量 + 0.3 * 归一化评分 + 0.3 * 归一化热度分为什么要归一化?因为评论量可能是几千、评分是4到5之间、热度分是0到100,不归一化直接加权,评论量会完全主导结果。我用的是最基础的min-max归一化:
import pandas as pd def minmax_norm(series): min_val = series.min() max_val = series.max() if max_val - min_val == 0: return pd.Series([1] * len(series)) return (series - min_val) / (max_val - min_val) df["comment_norm"] = minmax_norm(df["comment_num"]) df["rating_norm"] = minmax_norm(df["rating"]) df["heat_norm"] = minmax_norm(df["heat_score"]) df["heat_index"] = 0.4 * df["comment_norm"] + 0.3 * df["rating_norm"] + 0.3 * df["heat_norm"]权重为什么是0.4、0.3、0.3?这是基于业务经验调的:评论量代表景点的市场热度,反映有多少人真的去过;评分代表口碑质量;平台热度分则是一个综合值。三个指标都重要,但“有人气”在旅游推荐场景里往往比“分高但没人去”更有参考价值,所以评论量权重稍微高一点。
除了热度指数,我还做了四个分析维度:
- 城市维度:黑龙江各城市景点数量、平均热度、平均评分的排名对比,找出旅游资源的空间分布特征。
- 景区类型维度:自然风光、历史人文、主题乐园、城市公园等类型的占比和热度对比,回答“黑龙江到底以什么旅游产品见长”。
- 价格区间维度:门票价格在0、0到50、50到100、100以上区间的景区数量分布,辅助分析消费门槛。
- 评论量分层:按评论量分梯队,找出“既有口碑又有人气”的头部景区和“低口碑高人气”的潜在问题景区。
这些分析在Pandas里用groupby和agg就能完成,关键是先想清楚要回答什么问题,再写代码。分析结果导出成JSON文件,方便后端接口直接读取。
3.2 可视化大屏布局与图表实现
大屏是整个系统的门面,也是很多同学最容易翻车的地方。我的建议是:先画布局草图,再写代码。我的大屏布局是“左右两侧+中间主视觉”的结构:
- 顶部:系统标题+四个核心指标卡(景点总数、覆盖城市数、平均评分、最高热度指数)。
- 左侧从上到下:热度TOP10景点柱状图、景点类型占比玫瑰图。
- 中间:黑龙江地图散点图,展示各城市景点数量和热度分布。
- 右侧从上到下:各城市景点数量排名条形图、门票价格区间分布图。
这个布局的逻辑很清晰:指标卡先给全局概览,左侧聚焦头部景区和资源结构,中间给出空间分布,右侧补充分布特征。用户扫一眼就能形成“黑龙江旅游资源集中在哪些城市、哪些类型更有优势”的整体认知。
ECharts的图表配置有几个细节要注意。比如柱状图的tooltip要显示名称和具体数值,label放在柱子顶端避免遮挡;玫瑰图的radius要设成百分比,否则会被容器挤压;地图散点图我用的effectScatter加涟漪效果,视觉上更醒目。核心配置示例:
option = { tooltip: { trigger: 'item' }, series: [{ type: 'effectScatter', coordinateSystem: 'geo', data: mapData, symbolSize: function(val) { return Math.max(8, Math.sqrt(val[2]) * 2); }, rippleEffect: { brushType: 'stroke' }, geoIndex: 0 }] };这里有个大坑:ECharts 5 的默认地图包已经没有中国省份地图数据了,直接map: '黑龙江'是渲染不出来的。解决办法是单独引入黑龙江的GeoJSON文件,通过echarts.registerMap('黑龙江', geoJson)注册后再使用。GeoJSON可以从公开地理数据仓库下载,省界大概到地级市级别,够用了。
3.3 Flask接口与前端联调细节
可视化部分我拆成后端接口和前端页面两块。后端Flask只负责提供JSON数据,不负责拼HTML,这样前端渲染逻辑独立,后期换框架也方便。一个典型接口长这样:
from flask import Flask, jsonify import pandas as pd app = Flask(__name__) df = pd.read_csv("spot_analysis_result.csv", encoding="utf-8") @app.route("/api/overview") def overview(): overview_data = { "total_spots": int(df["spot_name"].nunique()), "total_cities": int(df["city"].nunique()), "avg_rating": round(float(df["rating"].mean()), 2), "max_heat_index": round(float(df["heat_index"].max()), 2) } return jsonify(overview_data) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000, debug=True)这里提醒一个接口返回的坑:Pandas的int64、float64类型不能直接jsonify,必须转成Python原生类型。我前面代码里已经用int()和float()包了一层,但实际写的时候经常漏,一返回就直接报TypeError: Object of type int64 is not JSON serializable。遇到这个错,检查每个字段是不是都转了原生类型。
前端页面用原生HTML + JavaScript fetch接口,数据拿到后更新ECharts的setOption。为了保证大屏展示时数据不是死板的,我加了一个30秒定时轮询:
function loadData() { fetch('/api/overview') .then(res => res.json()) .then(data => { overviewChart.setOption({ series: [{ data: [data.total_spots, data.total_cities, data.avg_rating, data.max_heat_index] }] }); }) .catch(err => console.error(err)); } setInterval(loadData, 30000);前端联调时还有一个高概率报错:图表容器div没有设置高度或宽度。ECharts初始化时容器尺寸为0,图表直接不渲染。我的习惯是给图表容器统一设置width: 100%; height: 400px;,大屏各卡片用CSS Grid布局,每个卡片内部再设固定的图表高度,实测下来显示非常稳定。
4. 踩坑记录与问题排查
4.1 爬虫阶段:403、乱码、动态页面
这个项目里我最头疼的就是403。刚开始写爬虫时只带了一个普通的UA,跑了几十页后突然开始返回403,因为请求频率和请求头特征太明显了。解决办法是:随机切换UA池、拉长请求间隔、补齐Referer和Accept头。我还做了一个异常重试机制,遇到403先停10秒再重试,如果连续3次失败就跳过当前页面记录日志,整个采集流程不中断。
中文乱码在爬虫阶段也很常见。网站页面有的是UTF-8编码,有的是GBK,直接用response.text会出现整片乱码。我的处理方式是先检查响应头里的Content-Type,拿不到就用response.apparent_encoding去猜,实测比硬编码编码方式准确得多:
resp = requests.get(url, headers=HEADERS, timeout=10) if resp.encoding is None or resp.encoding == 'ISO-8859-1': resp.encoding = resp.apparent_encoding html = resp.text另外提醒一下:有些旅游网站的列表页是动态加载的,直接用requests拿不到景点列表数据。我的分工很明确:如果页面源码里已经包含了目标数据,就优先用 requests + BeautifulSoup;如果发现数据是XHR接口动态渲染的,就打开浏览器开发者工具的Network面板,找到真正的数据接口URL,直接请求接口拿JSON。这个方法比Playwright模拟浏览器要轻量得多,而且不用担心浏览器驱动版本匹配问题。
4.2 数据阶段:重复、缺失、编码
数据清洗时遇到的坑比爬虫还多。第一个是重复数据,同一个景点在不同平台上叫法不同,或者详情页被重复请求后生成了多条记录。我建的唯一索引spot_name + city在这个阶段起到了兜底作用,但要注意:不同城市有同名景点(比如每个城市几乎都有“人民公园”),所以唯一键必须带城市,不能只用名字。
第二个坑是价格单位不一致。有的景点价格是元,有的写“免费”,有的写“暂无报价”。我在清洗时统一处理:免费转0,暂无报价转空值,空值在分析时直接用0参与统计会有偏差,所以我单独加了一个price_status字段记录原始状态,分析时按需过滤。
第三个坑是Pandas读CSV的时候中文列名和编码问题。保存中间结果时我统一用encoding="utf-8-sig"而不是utf-8,因为Excel打开UTF-8的CSV会乱码,utf-8-sig多一个BOM头,Excel能正常识别。如果后续要给别人共享数据文件,这个细节能省很多麻烦。
还有一个隐蔽的坑:MySQL建表时的排序规则。如果表用的是utf8mb4_general_ci,中文查询正常;但如果用了utf8mb4_unicode_ci或utf8mb4_bin,某些中文在查询时可能匹配不上。建议建表统一用utf8mb4_general_ci,查询和排序都够用,性能还略好。
4.3 可视化阶段:图表空白、地图不显示、性能
可视化阶段遇到的问题基本都是调试能解决的,但有几个特别容易让人抓狂。
图表完全空白,最先检查两件事:一是容器的高度,二是数据是否成功加载。我在浏览器开发者工具Console里看到Cannot read properties of undefined (reading 'data')这类报错,基本就是接口返回的数据结构和前端预期不一致。调试方法很笨但有效:console.log(data)在前端打印查看,别猜。
地图不显示,99%是GeoJSON注册问题。确认一下echarts.registerMap('黑龙江', geoJson)在setOption之前执行,而且GeoJSON里的城市名要跟数据里的城市名完全一致,差一个字就匹配不到。我数据里写的是“哈尔滨”,GeoJSON里是“哈尔滨市”,在散点图上无法显示,后来我在前端做了一层映射才解决。
至于性能优化,我遇到的情况是大屏首次加载地图加多个图表,整体白屏时间有点长。优化手段有两个:一是把分析结果提前算好存到JSON文件,接口直接读JSON而不是实时查数据库;二是Nginx缓存静态资源,浏览器第二次打开时,ECharts的JS文件直接从本地缓存加载。数据量只有几百条,完全不需要上缓存中间件,优化后秒开。
4.4 系统上线的几点建议
这个项目做完之后,我没有只停留在本地跑通,而是用Docker部署到了服务器上,让整个系统可以用浏览器直接访问。这里分享一点部署经验:MySQL用官方5.7镜像,数据目录挂载到宿主机,防止容器重建后数据丢失;Flask用Gunicorn跑,不要用自带的开发服务器;前端静态文件交给Nginx处理,接口请求走/api代理到Gunicorn。
爬虫的定时更新我用了最简单的方式:操作系统crontab每天凌晨3点跑一次采集脚本,只更新当天有变化的景点数据。如果你不想碰Linux的cron,也可以在Flask里用APScheduler写定时任务,优点是能用Python统一管理,缺点是定时任务和Web服务混在一起,进程崩溃互相影响。我的建议是:数据更新频率不高的场景,直接用crontab最省事。
日志也是必须的。爬虫脚本的执行日志、入库失败记录、接口访问日志分开写文件,出了问题先翻日志,基本上几分钟就能定位。没有日志的排查等于瞎猜。
5. 写在最后:这个项目给我的几点体会
整个项目从爬虫到可视化大屏全部跑通,前后用了一周左右。最大的体会是:数据分析项目里,爬虫和可视化都是手段,中间的“数据思维”才是核心。爬虫多写几百行代码不难,但要把“黑龙江哪些景区值得推荐”这个问题转化成评分权重、热度指数、城市分布这些可计算指标,需要的是对业务的理解。
另一个体会是,小项目不要硬堆技术。我一开始也想过要不要上Spark、Hive、HBase,后来想清楚了:一个800条数据量的分析系统,用Pandas五分钟就能出结果,何必为了“大数据”三个字把架构搞得那么重。真正的大数据项目,不是用了多少框架,而是面对海量数据时懂得如何做取舍。
最后再分享一个小技巧:做可视化大屏时,别急着堆图表。先问自己三个问题——用户是谁?他们要做什么决策?哪些指标能支撑这个决策?想清楚这三件事,再回来看ECharts的图表类型,你会发现选型非常快。这个思路不只适用于旅游景点分析,放到城市数据、电商数据、校园数据上,一样成立。