ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

黑龙江旅游景点数据分析系统:爬虫清洗分析可视化全流程解析

黑龙江旅游景点数据分析系统:爬虫清洗分析可视化全流程解析 先说一个我自己的判断现在市面上的大数据毕设项目十有八九都堆在“爬虫可视化”这条路上但这个选题其实特别容易做空——爬了一堆数据画了几个图表最后问答环节一问“你这个数据到底说明了什么”就卡住了。所以今年我做黑龙江旅游景点数据分析系统的时候给自己定的规矩是爬虫只是搬运工分析才是核心可视化只是把分析结果讲清楚的一种方式。整个项目从采集公开数据、清洗入库到指标体系搭建、可视化大屏呈现完整串成了一条可以跑通的链路。这篇文章就按我的实际开发顺序把每一步的选型逻辑、踩坑记录和关键代码拆开讲。这个项目适合谁参考一是正在准备大数据方向毕业设计、想找一个“有真实业务场景”题目的同学二是想练手 Python 爬虫、Pandas 数据处理和 ECharts 可视化的开发者三是想做一个地方旅游数据分析demo、后面想接评论情感分析或客流预测的小团队。下面直接上干货。1. 项目整体设计与技术选型1.1 系统要解决什么问题从需求到模块先聊清楚这个系统到底在干什么。很多人一上来就写爬虫爬完再说最后发现数据乱七八糟分析无从下手。我的习惯是反着来先想清楚最终要在大屏上看到什么再倒推需要哪些数据、该怎么存、怎么算。黑龙江旅游景点的信息分散在多个OTA平台某平台显示的是热度分另一个平台可能有游客评价但缺价格数据口径不一致用户想快速知道“黑龙江哪些景点值得去”“哈尔滨周边怎么安排路线”“哪些城市旅游热度最高”没有一个统一入口。这个系统的目标就是解决这些问题把公开的景点基础信息、热度、评分、评论量、门票参考价等结构化数据集中起来再做统一计算最后用可视化大屏直观呈现。倒推出四个功能模块数据采集模块请求公开旅游网页解析景点名称、所在城市、评分、评论数、门票等信息。数据清洗模块处理乱码、重复记录、缺失值、单位不统一等脏数据问题。数据分析模块计算热度指数、城市排名、类型分布、价格区间、评论量趋势等指标。可视化展示模块基于 Flask 提供数据接口前端用 ECharts 渲染大屏。一句话描述就是爬虫负责找料数据分析负责加工可视化负责上桌。每个环节都依赖上一个环节的输出所以开发顺序也是按这个链路来的。1.2 技术栈选型和方案取舍这个项目名字带“大数据”但实际数据量也就是几万条级别离真正的海量数据还有距离。所以我没硬上 Hadoop、Spark 这套重框架而是选了“轻量但架构思想完整”的技术组合。模块备选方案我的选择理由数据采集requests BeautifulSoup、Scrapy、Playwrightrequests BeautifulSoup目标网站是服务端渲染的公开静态页面不需要模拟浏览器解析简单、上手快Scrapy 适合大规模分布式采集本项目数据量用不上数据存储MySQL、MongoDB、HDFS HiveMySQL数据是结构化表格数据关系型存储容易做条件查询和统计分析HDFS 虽符合“大数据”名号但单机部署运维成本高且查询不如 SQL 方便数据分析Pandas、Spark SQL、HiveQLPandas几万条数据用 Pandas 处理效率极高代码可读性好配合 matplotlib 还能快速验证明细Spark 在这个量级是杀鸡用牛刀可视化Flask ECharts、Node.js ECharts、PowerBIFlask EChartsFlask 轻量能同时写接口和渲染页面前后端联调成本低ECharts 图表类型丰富、交互流畅做数据大屏是主流选择这里说一个很多人会踩的坑毕设答辩时“大数据技术”的字眼容易让评委期待看到分布式框架但硬上 Hadoop 三件套HDFS、YARN、MapReduce如果数据量就几万条反而会被追问“你这个数据量为什么需要分布式性能开销怎么解释”。我的处理方式是在系统设计文档里保留“大数据分层架构”的思想——采集层、存储层、分析层、展示层独立解耦同时在文档里说明“后续数据规模增长时可平滑迁移到 Spark Hive 架构”。这样既没有为了名字硬凑技术又体现了架构扩展性。1.3 数据源选择与合规边界数据源这块必须谨慎。我的选择是优先抓取公开的、不需要登录就能访问的旅游信息页只采集景点名称、简介、评分、评论数、门票参考价这类非个人隐私信息不碰用户账号、手机号、真实姓名等字段。我采用了一个比较稳妥的做法只采集列表页和详情页的静态HTML不触发登录流程、不绕过访问限制请求频率控制在3到5秒一条并且遵守目标网站的robots协议。写爬虫的人要有边界感技术本身没有错但脱离了合规前提后面全是麻烦。2. 爬虫与数据采集数据从哪来2.1 确定采集字段和解析思路写爬虫之前得先把最终要入库的字段定下来。我最终定的核心字段是这样字段说明示例spot_name景点名称太阳岛风景区city所在城市哈尔滨district所在区县可选松北区rating游客评分4.7comment_num评论数量3200heat_score热度分平台给出92.5ticket_price门票参考价元30.0play_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/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9, } def fetch_page(url): for attempt in range(3): try: resp requests.get(url, headersHEADERS, timeout10) 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意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, encodingutf-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(host0.0.0.0, port5000, debugTrue)这里提醒一个接口返回的坑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, headersHEADERS, timeout10) 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的时候中文列名和编码问题。保存中间结果时我统一用encodingutf-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的图表类型你会发现选型非常快。这个思路不只适用于旅游景点分析放到城市数据、电商数据、校园数据上一样成立。
返回列表