ARTICLE DETAIL

资讯详情

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

Python爬虫+Flask+ECharts:打造景点门票数据可视化平台

Python爬虫+Flask+ECharts:打造景点门票数据可视化平台 爬虫抓景点门票这事儿我前后折腾了差不多一个周末。起因很简单想出门玩的时候发现各大平台票价不统一有的还藏着各种“券后价”“会员价”手动比价太费劲。正好那阵子在练Python想着不如写个爬虫把景点门票数据抓下来再用可视化把价格走势、区域分布、热度排行一次性看明白。于是就有了这个基于Python的旅游数据可视化平台算是把爬虫、数据清洗、存储和可视化串成了一条完整的数据链路。这篇文章算是我对这个项目的一次完整复盘会把技术选型的理由、爬虫的细节设计、数据清洗流程、可视化方案的取舍都讲清楚也会把实测中踩过的坑原原本本列出来。如果你是刚学完Python基础、想找一个能写在简历上的综合实战项目或者正在做数据可视化方向的大作业、毕业设计这篇应该能帮你少走不少弯路。1. 项目整体设计与技术选型思路1.1 需求拆解这个平台到底要解决什么问题先别急着写代码我把需求拆成了三个层面。最表层是“看数据”也就是把散落在各个平台的门票价格、销量、评分集中到一个页面上用图表呈现出来。中间层是“比数据”同一个景点在不同渠道的价格差异、不同季节的价格波动、不同城市的门票均价这些维度要能自由切换。最底层是“存数据”爬虫抓下来的数据不能只留在内存里得落库而且要支持增量更新不然每次重跑全量爬虫既慢又容易被封。这个需求拆解直接决定了技术栈的走向。如果只是临时看一次数据用requests抓完直接print就行但要做成“平台”就绕不开存储、服务、前端展示这些环节。1.2 技术选型为什么是Python Flask EChartsPython在这个场景里几乎是唯一选择。爬虫生态太成熟了requests发请求、BeautifulSoup解析HTML、pandas做清洗一条龙下来代码量比Java少一大截。数据可视化层面我选了ECharts而不是Matplotlib核心原因是交互性。Matplotlib画出来是静态图片看不了tooltip、拉不了时间轴、点不了图例而ECharts本身是纯前端库配合Flask提供数据接口页面端完全不需要写复杂的前端逻辑灌数据就能出图。存储层用了SQLAlchemy这套ORM。有人说小题大做直接pymysql写SQL不就行了我的理由是项目涉及的表结构会频繁调整ORM改模型类比改SQL语句直观得多而且写进代码里可读性强后面维护时不用翻着建表语句猜字段含义。这算是把一个用得上、又不至于复杂到劝退新手的“企业级”概念融了进来。整体架构可以概况为爬虫采集pandas中转SQLAlchemy落库Flask提供接口ECharts前端渲染。各环节边界清晰任何一个模块都能拆出来单独测试。1.3 项目目录结构与模块划分代码组织上我按照数据流向分了五个模块travel_spider/ ├── spider/ │ ├── crawl.py # 爬虫主逻辑 │ ├── parser.py # HTML解析 │ └── user_agents.py # 随机UA池 ├── database/ │ ├── models.py # SQLAlchemy模型 │ └── engine.py # 数据库连接 ├── analyzer/ │ └── clean.py # 数据清洗 ├── web/ │ ├── app.py # Flask应用 │ └── templates/ │ └── index.html # 可视化页面 └── requirements.txt这个结构的好处是职责单一爬虫只管抓清洗只做预处理web层不碰任何脏数据。实际开发中我发现模块化最大的价值不是代码好看而是排错效率——页面图表不显示了先查web层数据对不上先查analyzer数据没抓到只查spider基本不会互相甩锅。2. 爬虫模块设计与门票数据采集细节2.1 数据源选择和抓取策略景点门票数据的来源我做了三类选择。第一类是景区官网数据最准但结构差异极大有的用动态加载、有的在页面源码里直接内嵌JSON维护成本高。第二类是第三方OTA平台数据聚合度高但反爬力度参差不齐。第三类是本地旅游资讯站结构相对固定很多还保留着服务端渲染对新手最友好。我最终主抓一个中型的旅游资讯类站点练手同时用另一个平台做交叉验证。抓取策略上采用广度优先加深度限制先爬景点列表页拿到每个景点的详情页URL再逐条进详情页抓票价和开放时间。这里有个经验不要一上来就追求“全网爬取”先把一个数据源吃透、把流程跑通再横向扩展源否则你会被各种网站的奇葩结构活活耗死。2.2 请求头伪装和频率控制的实操方案反爬是躲不开的坎。我最初直接裸写requests.get()结果没爬到50个页面就被拒了。排查后发现对方检查了User-Agent和访问频率。解决方案分三步走第一准备一个UA池每次请求随机取一个模拟不同浏览器和操作系统。第二请求间隔用随机数控制在2到5秒之间浮动避免规律性请求。第三在最外层加重试机制遇到状态码429或503就sleep十秒再重试一次。# spider/crawl.py 核心请求逻辑 import time import random import requests from spider.user_agents import get_random_ua def fetch_url(url, retries3): session requests.Session() headers { User-Agent: get_random_ua(), Accept: text/html,application/xhtmlxml, Accept-Language: zh-CN,zh;q0.9 } for attempt in range(retries): try: resp session.get(url, headersheaders, timeout8) if resp.status_code 200: return resp.text if resp.status_code in (429, 503): time.sleep(10 attempt * 5) continue except requests.exceptions.ConnectionError: time.sleep(15) return None这里要专门强调一下timeout参数。第一次写爬虫的时候没加timeout结果某个页面卡住后整个程序挂死排错排了半天。设置timeout8意味着请求超过8秒直接抛异常配合重试机制哪怕遇到慢响应也不至于卡死整个任务。2.3 页面解析从HTML中精准提取价格和票种解析这块我用的BeautifulSoup加CSS选择器。核心思路是先定位到详情页的票价区域再逐条提取票种名称和对应价格。这个步骤最烦的是各家网站的HTML结构不统一有的票价在table里有的在div里有的直接写在一段带class的p标签里。我的通用策略是先找class名里包含price、ticket、sale这些关键词的节点再在节点内做文本清洗。# spider/parser.py 解析门票信息 from bs4 import BeautifulSoup def parse_ticket_html(html_text): soup BeautifulSoup(html_text, html.parser) ticket_items [] price_blocks soup.select(.ticket-item, .price-item, [class*ticket]) for block in price_blocks[:10]: # 只取前10个票种防止抓取到无关内容 name_tag block.select_one(.ticket-name, .item-title) price_tag block.select_one(.price, .sale-price, .price-num) if not name_tag or not price_tag: continue name name_tag.get_text().strip() price_text price_tag.get_text().strip() # 清洗价格文本转成数值 price clean_price(price_text) if price and name: ticket_items.append({ticket_type: name, price: price}) return ticket_itemsclean_price是个容易被忽视的小函数。原网页里的价格可能是“¥120起”“成人票 120.00”“120元/人”这种五花八门的格式直接存字符串后面没法做大小比较。我的处理方式是用正则把数字和小数点提取出来再float()转数值无法提取的置为None等数据清洗阶段统一处理。这个细节直接决定了后续可视化中“价格中位数”“价格区间分布”这类图表能不能画出来。2.4 断点续爬与异常兜底机制写爬虫最怕什么爬到一半挂了前功尽弃。所以我在设计里特意加了断点续爬已经成功抓取的详情页URL会实时写入一个txt文件每次启动前先加载这个文件遇到重复URL直接跳过。这个方案相比用数据库记录更轻量适合中小型爬取任务。# 断点续爬逻辑 visited set() try: with open(visited.txt, r) as f: visited set(line.strip() for line in f) except FileNotFoundError: pass for detail_url in detail_urls: if detail_url in visited: continue html_text fetch_url(detail_url) if html_text: data parse_ticket_html(html_text) save_to_database(data, source_urldetail_url) visited.add(detail_url) with open(visited.txt, a) as f: f.write(detail_url \n)另外在解析环节我加了try/except的粒度控制整个页面解析失败不影响循环继续但每条门票字段的提取失败要记录日志方便后续排查是哪类页面结构没覆盖到。实测中这个日志帮了大忙有个景点的页面用了懒加载票价区域初始为空日志直接定位到了这个问题。3. 数据清洗与SQLAlchemy存储方案3.1 为什么数据不能抓下来直接入库如果你做过爬虫就会知道网页上扒下来的原始数据基本都是“半成品”。价格里带文字描述、景点名称前后有空格或换行、不同数据源的票价类型命名不一致比如“成人票”“全价票”“成人门票”指的是同一个东西。如果不管这些直接入库后面做可视化的数据质量会非常难看柱状图上可能出现“¥120起”这种无法参与聚合的字符串。所以要有一个独立的清洗层用pandas的DataFrame统一处理。3.2 清洗流程去重、补全、规范化清洗逻辑我拆成四个步骤第一步去空格和不可见字符。调用DataFrame的str.strip()批量处理所有文本列。第二步价格字段规范化。前面提到的clean_price在这里作为向量化函数应用到整列提取失败的值记为NaN。第三步去重。同一景点同一天同一个票种可能会被多个数据源抓到通过景点ID加票种名称加价格三个字段联合去重。第四步缺失值处理。票价缺失的记录直接删除因为价格是本项目可视化的核心维度景点名称缺失则用详情页URL反查补全实在查不到就标记为“未知景点”。# analyzer/clean.py 清洗核心代码 import pandas as pd def clean_ticket_df(raw_df): df raw_df.copy() # 文本清洗 df[scenic_name] df[scenic_name].str.strip() df[ticket_type] df[ticket_type].str.strip() # 价格规范化 df[price] df[price].apply(clean_price) # 去重 df df.drop_duplicates(subset[scenic_id, ticket_type, price]) # 删除价格缺失的记录 df df.dropna(subset[price]) return df这里有个值得说的细节去重字段为什么要带上价格因为同一个景点同一种票在不同平台价格可能不同我希望保留这种价格差异所以同景点同票种但价格不同的记录要保留只有三个字段完全一致的才删。3.3 SQLAlchemy建表与数据入库实战存储层用的是SQLAlchemy ORM。建表时我重点考虑了三个维度唯一约束、索引和时间戳。唯一约束对应上面的去重逻辑数据库层面再加一道保险索引加在scenic_id和price上因为后续查询基本都是按景点分组或按价格筛选时间戳字段用于支持“票价走势”这个可视化需求。# database/models.py from sqlalchemy import Column, Integer, String, Float, DateTime, UniqueConstraint from sqlalchemy.ext.declarative import declarative_base from datetime import datetime Base declarative_base() class TicketPrice(Base): __tablename__ ticket_prices __table_args__ ( UniqueConstraint(scenic_id, ticket_type, price, nameuq_ticket), ) id Column(Integer, primary_keyTrue) scenic_id Column(String(32), nullableFalse, indexTrue) scenic_name Column(String(100), nullableFalse) city Column(String(50)) ticket_type Column(String(50), nullableFalse) price Column(Float, nullableFalse, indexTrue) source Column(String(50)) crawl_time Column(DateTime, defaultdatetime.now, indexTrue)入库操作用pandas的to_sql方法结合SQLAlchemy引擎一条语句就能把整个DataFrame写进表里from sqlalchemy import create_engine engine create_engine(mysqlpymysql://user:passlocalhost/travel_db?charsetutf8mb4) cleaned_df.to_sql(ticket_prices, conengine, if_existsappend, indexFalse)这是pandas和SQLAlchemy协同最舒服的地方——清洗和入库完全分离写起来既简洁又不容易出错。需要提醒的是如果你用MySQL连接串里一定要带上charsetutf8mb4否则后面存emoji或特殊符号会报错。本项目数据里暂时没有emoji但这个习惯要养成不是只有表情包才算特殊字符一些生僻字在utf8下同样会出问题。4. 基于Flask和ECharts的可视化设计与交互实现4.1 前后端分离思路Flask只做数据接口可视化部分我没用Django那种重框架而是选了Flask。原因很直接这个项目的核心是数据展示不是处理复杂业务逻辑Flask轻量、上手快、一个文件就能起服务。前后端分离的思路是Flask只提供JSON格式的数据接口页面上的图表全部由ECharts在前端渲染。这样一来好处很明显如果后面想换图表库前端改就行后端完全不用动。实际开发中我定义了两类接口一类是汇总型接口比如返回全部景点的平均票价、各城市的平均票价另一类是明细型接口比如返回某个景点全部票种的历史价格记录。接口设计得越贴合图表需求前端画图就越省事。# web/app.py from flask import Flask, jsonify, render_template from sqlalchemy.orm import sessionmaker from database.models import TicketPrice, engine app Flask(__name__) Session sessionmaker(bindengine) app.route(/) def index(): return render_template(index.html) app.route(/api/avg_price_by_city) def avg_price_by_city(): session Session() rows session.query(TicketPrice.city, func.avg(TicketPrice.price)).group_by(TicketPrice.city).all() data [{city: city, avg_price: round(avg, 2)} for city, avg in rows] session.close() return jsonify(data) app.route(/api/price_trend/scenic_id) def price_trend(scenic_id): session Session() rows session.query(TicketPrice.crawl_time, TicketPrice.price).filter_by(scenic_idscenic_id).order_by(TicketPrice.crawl_time).all() data [{date: str(dt.date()), price: price} for dt, price in rows] session.close() return jsonify(data) if __name__ __main__: app.run(debugTrue, port5000)一个小经验session.close()一定要放在return之前而且要习惯用try/finally包裹。我刚开始偷懒没关session跑几个小时后MySQL连接数直接打满数据库拒绝新连接整个服务挂了。这类连接池泄漏问题在Flask里特别阴间因为不是立即报错而是延迟爆发。4.2 图表选型逻辑按数据特征选图表类型ECharts的图表类型很丰富但不能为了炫技乱用。我对数据做了分析后决定用四种图表柱状图展示各城市平均票价对比因为城市数量有限、数据是离散的柱状图能直观体现城市间差距。折线图展示热门景点近一个月的价格波动时间序列数据天然适合折线图且ECharts自带的dataZoom组件可以拖拽缩放能看长期趋势也能聚焦局部。饼图展示各票种成人票、学生票、儿童票、家庭套票在所有景点中的占比占比关系用饼图最直观。散点图展示门票价格与评分的关系用来观察“贵是否等于好”这个图能清晰地看到是否存在高评分低价格的神仙景点。图表之间还做了联动点击柱状图中的某个城市折线图自动切换为该城市热门景点的价格走势点击饼图中的某个票种散点图高亮对应数据。联动逻辑是用ECharts的events.on(click)监听配合setOption更新数据这部分前端代码有点绕但写完一次之后复用性很高。4.3 数据接口与ECharts组件的对接细节前端页面结构很简单一个HTML文件里放了四个div容器分别放四张图表。对接的核心方法是fetch请求后端接口拿到JSON后组装成ECharts需要的option结构。这里有个坑ECharts的option结构对字段名称和数据类型非常敏感比如坐标轴数据是字符串数组系列数据必须是一组数值直接传字符串会导致图表空白或坐标轴显示异常。!-- web/templates/index.html 核心加载逻辑 -- div idcityChart/div div idtrendChart/div div idtypeChart/div div idscatterChart/div script srchttps://cdn.jsdelivr.net/npm/echarts5/dist/echarts.min.js/script script // 柱状图初始化 fetch(/api/avg_price_by_city) .then(res res.json()) .then(data { const chart echarts.init(document.getElementById(cityChart)); const option { title: { text: 各城市平均门票价格 }, tooltip: {}, grid: { left: 3%, bottom: 10% }, xAxis: { data: data.map(item item.city) }, yAxis: {}, series: [{ type: bar, name: 平均票价, data: data.map(item item.avg_price), label: { show: true, position: top } }] }; chart.setOption(option); }); /script实战中的一个小经验ECharts的初始化一定要等DOM渲染完成再执行如果div被隐藏或还没挂载init会拿到一个宽度为0的容器图表画不出来但也不报错调试起来非常痛苦。我的解决办法是把初始化代码放在window.onload的回调里或者使用setTimeout延迟100毫秒确保容器已经渲染。5. 实操中遇到的坑与排查技巧5.1 反爬机制的升级应对项目做到中后期某个数据源突然返回了一个验证码页面。排查后发现对方检测到了高频访问。我的处理策略是降低爬取频率到每10秒一次同时把UA池扩充到30个另外加上了简单的代理切换逻辑。这里要强调的是爬虫要克制频率越低越不容易触发反爬。为了演示效果把频率拉到每秒三五次最后只会换来IP封禁得不偿失。另外还遇到过一次数据错乱问题某景点的门票价格突然全部变成了0。排查后发现不是反爬而是页面改版后解析逻辑失灵价格标签的class名变了解析模块把页面里的背景图URL当成价格抓了进来。这类问题的排查思路是先看原始HTML结构是否变化再看解析规则是否还匹配不要一上来怀疑反爬。我在parser里加了输出原始区块的调试函数遇到异常数据时先dump片段节省了大量猜测时间。5.2 数据清洗中的坑NaN的隐形陷阱pandas的NaN是最容易埋雷的地方。我在做“各城市平均票价”分组聚合时输出结果里某些城市的数据凭空消失了。找了好久才发现是这些城市的票价数据里有NaN而SQLAlchemy模型里的Float列不接受NaNpandas的to_sql在遇到NaN时会把整行静默跳过。解决办法是在清洗阶段对NaN做显式处理——要么删除要么填充不能让NaN流入数据库。# 显式处理NaN避免to_sql静默丢行 df df.dropna(subset[price]) df[city] df[city].fillna(未知城市)这类问题的隐蔽性在于它不是报错只是数据变少。如果你不做数据完整性校验根本意识不到丢了数据。所以我在清洗后加了一个简单的体检报告统计原始行数、清洗后行数、丢弃行数以及删除原因分布每次跑完都打印出来。这个习惯让数据质量变得可感知、可追溯。5.3 ECharts中文乱码和异步加载问题乱码问题出现在两个层面。一是MySQL侧建表时不指定utf8mb4浏览器端显示的汉字成了问号二是前端JS文件的编码问题HTML文件没声明charsetutf-8导致从接口返回的中文在渲染时出现乱码。前者的修复办法是连接串加上charsetutf8mb4后者的修复办法是在HTML的meta标签里显式声明编码。异步问题主要出现在图表绘制顺序上。四张图表同时发请求数据返回的先后顺序不确定。如果用户在数据还没加载完时点击图例或缩放页面会出现白屏或报“Cannot read property data of undefined”。解决方法是封装一个renderChart函数内部统一处理数据为空的情况并且用Promise.all保证四张图都拿到数据后再初始化而不是边请求边初始化。5.4 常见问题速查表问题现象可能原因排查与解决办法爬虫请求被拒绝请求头缺少UA或频率过高随机UA池请求间隔加随机延迟抓到的价格全是0页面改版导致解析class不匹配dump原始HTML片段更新解析规则数据库连接数爆满Flask未关闭session用try/finally包裹session.close()生成的图表空白DOM宽度为0或数据格式不对初始化前检查容器宽高校验option字段类型中文显示乱码数据库编码或HTML编码未指定连接串加charsetutf8mb4HTML声明utf-8分组聚合结果缺失NaN静默丢弃整行清洗阶段显式dropna或fillna页面卡死无响应请求未设置timeout或重试过多timeout设8秒限制重试次数6. 项目复盘技术链路的收获与后续扩展方向6.1 从零到一跑通数据全链路的感悟这个项目真正教会我的不是某个单独的库怎么用而是**把数据从“浏览器里看到的东西”变成“数据库里的表”再变成“屏幕上会动会点的图表”**这一整条链路的协作方式。爬虫解决“数据从哪来”清洗解决“数据能不能用”存储解决“数据怎么组织”可视化解决“数据怎么表达”。四个环节任何一个掉链子最终结果都不对。做这个项目之前我可能会说“我会requests”“我会pandas”“我会ECharts”但做完之后我能说“我能独立做一个数据产品”。这两者的差别就是碎片知识与会用系统方法解决问题的差别。对想走数据分析或Python开发方向的人来说这种综合性项目比刷一百道语法题都管用。6.2 代码健壮性的细节改良项目跑通后我做了一轮代码健壮性优化。核心改动有三个给爬虫加配置文件把目标URL、请求间隔、重试次数等参数全部抽离到config.py里避免改参数时在代码里到处找给清洗模块加单元测试用几个构造的脏数据样本验证清洗逻辑比如带单位的价格、缺失城市名、重复记录给Flask接口加缓存用functools.lru_cache缓存热门查询的结果减少数据库压力。# config.py 爬虫配置 SPIDER_CONFIG { base_url: https://example-travel-site.com/, page_size: 50, max_pages: 20, request_interval: [2, 5], retry_times: 3, timeout: 8, ua_pool_size: 30, }这些看起来不起眼的改动让项目的可维护性提升了一个档次。特别是配置文件抽离后续我想要增加第二个数据源只需要在配置里加一个source块不需要动爬虫主逻辑。6.3 后续可以怎么扩展做完了这个基础版我列了几条扩展方向也是我个人比较想继续折腾的方向第一抓取频率响应时间扩展为自动化监控系统每天定时抓取一次数据在价格波动超过阈值时发邮件或推送通知这样能在“降价”时第一时间知道。第二把景点区域信息补全用地图做区域热度可视化ECharts的地图组件在地市级维度上支持非常友好加上热力图模式之后整个平台的表达力会强很多。第三把数据接口升级为RESTful风格引入JWT身份验证做成一个多用户可访问的SaaS化服务。这个扩展会把项目从个人工具推向产品化技术深度也会上一个台阶。第四引入Scrapy框架替代requests。当前项目的并发是串行的页面量级到几万时效率会很难看Scrapy的异步引擎能显著提速且内置了去重、中间件等机制。我个人实际做这个项目的最大体会是别看单个环节都很简单真正把它们串起来时坑比想象中多得多。每个模块单独跑都没问题一旦连起来编码、超时、数据库连接、前端渲染各种问题轮番冒出来。建议即将动手的朋友不要急着把四大组件全部写完再联调而是先把最小闭环跑通——比如先抓一个景点、清洗后存库、用Flask输出JSON、ECharts画一个柱状图然后再逐步加景点、加图表、加功能。这个过程里积累的调试经验比看十篇教程都值钱。
返回列表