爬虫抓景点门票这事儿,我前后折腾了差不多一个周末。起因很简单,想出门玩的时候发现各大平台票价不统一,有的还藏着各种“券后价”“会员价”,手动比价太费劲。正好那阵子在练Python,想着不如写个爬虫把景点门票数据抓下来,再用可视化把价格走势、区域分布、热度排行一次性看明白。于是就有了这个基于Python的旅游数据可视化平台,算是把爬虫、数据清洗、存储和可视化串成了一条完整的数据链路。
这篇文章算是我对这个项目的一次完整复盘,会把技术选型的理由、爬虫的细节设计、数据清洗流程、可视化方案的取舍都讲清楚,也会把实测中踩过的坑原原本本列出来。如果你是刚学完Python基础、想找一个能写在简历上的综合实战项目,或者正在做数据可视化方向的大作业、毕业设计,这篇应该能帮你少走不少弯路。
1. 项目整体设计与技术选型思路
1.1 需求拆解:这个平台到底要解决什么问题
先别急着写代码,我把需求拆成了三个层面。最表层是“看数据”,也就是把散落在各个平台的门票价格、销量、评分集中到一个页面上,用图表呈现出来。中间层是“比数据”,同一个景点在不同渠道的价格差异、不同季节的价格波动、不同城市的门票均价,这些维度要能自由切换。最底层是“存数据”,爬虫抓下来的数据不能只留在内存里,得落库,而且要支持增量更新,不然每次重跑全量爬虫既慢又容易被封。
这个需求拆解直接决定了技术栈的走向。如果只是临时看一次数据,用requests抓完直接print就行,但要做成“平台”,就绕不开存储、服务、前端展示这些环节。
1.2 技术选型:为什么是Python + Flask + ECharts
Python在这个场景里几乎是唯一选择。爬虫生态太成熟了,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, retries=3): session = requests.Session() headers = { "User-Agent": get_random_ua(), "Accept": "text/html,application/xhtml+xml", "Accept-Language": "zh-CN,zh;q=0.9" } for attempt in range(retries): try: resp = session.get(url, headers=headers, timeout=8) 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,结果某个页面卡住后整个程序挂死,排错排了半天。设置timeout=8意味着请求超过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_url=detail_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", name="uq_ticket"), ) id = Column(Integer, primary_key=True) scenic_id = Column(String(32), nullable=False, index=True) scenic_name = Column(String(100), nullable=False) city = Column(String(50)) ticket_type = Column(String(50), nullable=False) price = Column(Float, nullable=False, index=True) source = Column(String(50)) crawl_time = Column(DateTime, default=datetime.now, index=True)入库操作用pandas的to_sql方法结合SQLAlchemy引擎,一条语句就能把整个DataFrame写进表里:
from sqlalchemy import create_engine engine = create_engine("mysql+pymysql://user:pass@localhost/travel_db?charset=utf8mb4") cleaned_df.to_sql("ticket_prices", con=engine, if_exists="append", index=False)这是pandas和SQLAlchemy协同最舒服的地方——清洗和入库完全分离,写起来既简洁又不容易出错。需要提醒的是,如果你用MySQL,连接串里一定要带上charset=utf8mb4,否则后面存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(bind=engine) @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_id=scenic_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(debug=True, port=5000)一个小经验: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 id="cityChart"></div> <div id="trendChart"></div> <div id="typeChart"></div> <div id="scatterChart"></div> <script src="https://cdn.jsdelivr.net/npm/echarts@5/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列不接受NaN,pandas的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文件没声明charset="utf-8",导致从接口返回的中文在渲染时出现乱码。前者的修复办法是连接串加上charset=utf8mb4,后者的修复办法是在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编码未指定 | 连接串加charset=utf8mb4,HTML声明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画一个柱状图,然后再逐步加景点、加图表、加功能。这个过程里积累的调试经验,比看十篇教程都值钱。