简介:这份PDF文档面向计算机相关专业的毕业生与数据分析初学者,围绕去哪儿网旅游数据爬取与可视化分析这一毕业设计课题,提供从数据采集到结果呈现的完整方案。文档共1个PDF文件,压缩包约979KB,内容涵盖任务分析、设计实施与作品总结等章节,结构清晰便于按模块查阅。已有2766人学习下载,说明该选题在毕业设计场景中具有较高的参考价值。读者可从中获取爬虫编写思路,包括requests、BeautifulSoup、Scrapy等工具的运用,以及模拟登录与反爬应对策略;同时能了解Pandas数据清洗、NumPy与SciPy统计分析的方法,并借助matplotlib、seaborn、plotly完成景点区域分布、等级分布、月销量与价格、用户评分等多维度可视化图表。文档还涉及MySQL、SQLite数据库连接与数据导入流程,适合需要完整项目流程参考、快速搭建毕设框架的读者。
1. 旅游数据爬取与可视化:从标题到落地,一个工程师的拆解笔记
旅游数据爬取加可视化分析,这个标题背后其实藏着一条完整的数据链路:从目标站点拿到结构化的景点、票价、评论、评分数据,清洗成能用的表,再把它变成能讲故事的地图、热力图和趋势曲线。我做过几个类似的数据采集与分析项目,踩过的坑比想象中多——反爬策略、字段缺失、坐标偏移、可视化时中文乱码,每一个都能卡住半天。这篇文章面向两类人:想入门数据采集但不知道从哪下手的开发者,以及已经能写爬虫但可视化环节总是翻车的从业者。我会把选型理由、可复现的代码、参数怎么调、失败时看什么,一层层拆开讲。读完你至少能跑通一条从采集到出图的完整链路,并且知道哪些地方最容易翻车。
2. 采集方案怎么选:静态页面、动态接口与合规边界
2.1 先判断目标页面是静态渲染还是动态加载
很多旅游类站点,景点列表和详情页并不是直接写在 HTML 里的。你右键查看网页源代码,如果能看到完整的景点名称、价格、评分,那大概率是服务端渲染,用 requests 加解析库就能拿到。如果源代码里只有一堆空的 div 和 script 标签,数据是浏览器执行 JavaScript 之后才填充进去的,那就得换思路。
常见做法有三种:一是直接抓 XHR 接口,打开浏览器开发者工具的 Network 面板,筛选 XHR 或 Fetch 请求,刷新页面,找到返回 JSON 的那个请求,把 URL 和参数复制出来用代码模拟;二是用自动化浏览器工具驱动真实浏览器渲染后取 DOM;三是看页面有没有内嵌的初始化数据,比如window.__INITIAL_STATE__这种全局变量,直接从 script 标签里正则提取。
我一般优先找 XHR 接口,因为返回的是干净 JSON,解析成本最低,速度也快。找不到再考虑自动化浏览器。下面是一个抓取 JSON 接口的最小示例,以某虚构旅游信息站点为例:
import requests import json import time # 模拟浏览器请求头,缺少 User-Agent 很多站点直接返回 403 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", "Referer": "https://example-travel-site.com/list", "Accept": "application/json, text/plain, */*" } # 分页参数:page 从 1 开始,page_size 控制每页条数 params = { "page": 1, "page_size": 20, "city": "某市", "category": "景点" } url = "https://example-travel-site.com/api/poi/list" resp = requests.get(url, headers=headers, params=params, timeout=10) # 检查状态码,非 200 直接打印出来排查 if resp.status_code != 200: print("请求失败,状态码:", resp.status_code) else: data = resp.json() # 通常业务数据在 data 字段下,具体层级要看接口返回结构 items = data.get("data", {}).get("list", []) for item in items: print(item.get("name"), item.get("price"), item.get("score")) # 控制请求频率,避免触发风控 time.sleep(1.5)这段代码的关键点有三个。第一是请求头,User-Agent 和 Referer 是最基本的,有些站点还会校验 Cookie 或自定义 token,那就需要从浏览器里复制完整的请求头。第二是分页逻辑,page 和 page_size 的具体命名每个站点不一样,有的叫 offset 和 limit,有的叫 start 和 count,必须对着 Network 面板里的真实请求改。第三是延时,time.sleep 的数值没有标准答案,我一般从 1 秒起步,如果遇到 429 状态码就翻倍,稳定后再逐步降低。
2.2 自动化浏览器方案与接口方案的取舍
当接口有加密签名、参数无法直接构造时,自动化浏览器是兜底方案。它的优势是所见即所得,页面渲染成什么样就能拿到什么;代价是速度慢、资源占用高、稳定性受网络和页面改版影响大。
用自动化浏览器时,核心是等待策略。不要用固定 sleep,而是显式等待某个元素出现。下面是一个用 Playwright 抓取列表页的骨架:
from playwright.sync_api import sync_playwright import json results = [] with sync_playwright() as p: # headless=False 可以肉眼观察浏览器行为,调试阶段建议打开 browser = p.chromium.launch(headless=True) page = browser.new_page() page.goto("https://example-travel-site.com/list", timeout=30000) # 等待景点卡片元素出现,最多等 15 秒 page.wait_for_selector(".poi-card", timeout=15000) cards = page.query_selector_all(".poi-card") for card in cards: name = card.query_selector(".poi-name") price = card.query_selector(".poi-price") score = card.query_selector(".poi-score") results.append({ "name": name.inner_text() if name else "", "price": price.inner_text() if price else "", "score": score.inner_text() if score else "" }) browser.close() # 保存为 JSON 文件,方便后续清洗 with open("poi_list.json", "w", encoding="utf-8") as f: json.dump(results, f, ensure_ascii=False, indent=2)参数方面,timeout 设多少取决于目标站点的响应速度,我一般设 15000 到 30000 毫秒。wait_for_selector 的选择器要尽量精确,用 class 比用标签名稳,用 data 属性比用 class 更稳。如果页面有懒加载,还需要模拟滚动到底部再等待新元素出现。
接口方案和浏览器方案的取舍标准很简单:接口能通就用接口,接口走不通再上浏览器。不要一上来就开浏览器,那是杀鸡用牛刀,而且后期维护成本高得多。
2.3 合规边界与采集频率控制
做数据采集,第一条原则是只采集公开可见的数据,不碰需要登录才能访问的内容,不碰个人隐私字段。第二条原则是控制频率,不给目标站点造成压力。我一般会把并发数控制在 1 到 3,单域名请求间隔不低于 1 秒,遇到 403 或 429 立即停止并延长等待。
另外,采集前看一眼目标站点的 robots.txt,虽然它不具备法律强制力,但它是站点运营者表达意愿的方式。如果 robots.txt 明确禁止抓取某个路径,我建议换数据源或者走官方开放接口。数据量大的话,优先考虑有没有公开数据集可以用,很多旅游相关的学术数据集质量不差,省去采集环节的麻烦。
3. 数据清洗与存储:从原始 JSON 到可分析的表格
3.1 字段提取与缺失值处理
采集回来的原始数据往往很脏。价格字段可能是“¥120起”这种字符串,评分可能是“4.5分”或者“暂无评分”,地址可能混在描述文本里。清洗的第一步是把这些字段规范化。
import pandas as pd import re df = pd.read_json("poi_list.json") # 提取价格中的数字,无法提取的置为 NaN def extract_price(text): if not isinstance(text, str): return None match = re.search(r"(\d+(\.\d+)?)", text) return float(match.group(1)) if match else None df["price_clean"] = df["price"].apply(extract_price) # 评分同理,处理“暂无评分”这类非数值 def extract_score(text): if not isinstance(text, str): return None match = re.search(r"(\d+(\.\d+)?)", text) return float(match.group(1)) if match else None df["score_clean"] = df["score"].apply(extract_score) # 查看缺失情况 print(df[["price_clean", "score_clean"]].isna().sum()) # 缺失值处理策略:价格缺失用中位数填充,评分缺失直接剔除 df["price_clean"] = df["price_clean"].fillna(df["price_clean"].median()) df = df.dropna(subset=["score_clean"]) print("清洗后数据量:", len(df))这段代码的逻辑是:先用正则把数值从混合文本里抠出来,再分别处理缺失。价格缺失用中位数填充是常见做法,因为价格分布通常有偏,均值容易被极端值拉偏。评分缺失我倾向于直接剔除,因为评分是后续分析的核心字段,填充会引入偏差。
参数方面,正则(\d+(\.\d+)?)能匹配整数和小数,如果你的数据里有“1,200”这种带千分位的,需要先把逗号去掉再匹配。缺失值填充策略没有万能解,关键是想清楚填充后的数据会不会影响你的分析结论。
3.2 去重与坐标数据获取
旅游数据里重复记录很常见,同一个景点可能因为不同来源或不同分页出现多次。去重不能只看名称,因为名称可能有细微差异,比如“某某公园”和“某某公园(东门)”。我一般用名称加地址的组合做去重键,或者用名称的编辑距离做模糊匹配。
# 简单去重:名称和价格完全一致视为重复 df = df.drop_duplicates(subset=["name", "price_clean"], keep="first") # 如果要做地理可视化,需要经纬度 # 常见做法是调用公开的地理编码服务,把地址转成经纬度 # 这里用模拟数据演示字段结构 import numpy as np np.random.seed(42) df["lng"] = np.random.uniform(116.0, 116.8, len(df)) df["lat"] = np.random.uniform(39.6, 40.2, len(df)) df.to_csv("poi_clean.csv", index=False, encoding="utf-8-sig") print("已保存清洗后数据,共", len(df), "条")坐标获取是旅游数据可视化的关键环节。真实场景下,你需要调用地理编码接口把地址转成经纬度,或者从目标站点直接抓取坐标字段。如果站点没有提供坐标,可以用公开的地理编码服务,但要注意调用频率限制和坐标系的差异。国内常见的是 GCJ-02 坐标系,而一些国际服务返回的是 WGS-84,混用会导致地图上位置偏移几百米。
保存 CSV 时用utf-8-sig编码,这样用 Excel 打开不会中文乱码。这个坑我踩过不止一次,血泪经验。
4. 可视化落地:地图、热力图与趋势图的实现细节
4.1 用 pyecharts 做地理散点图和热力图
可视化的第一步是选工具。静态图表用 matplotlib 或 seaborn,交互式地图用 pyecharts 或 folium。旅游数据天然带地理属性,所以我一般用 pyecharts,它对中文支持好,导出 HTML 后可以直接在浏览器里交互。
from pyecharts import options as opts from pyecharts.charts import Geo, HeatMap, Line import pandas as pd df = pd.read_csv("poi_clean.csv") # 地理散点图:每个景点一个点,点大小反映评分 geo = ( Geo() .add_schema(maptype="某市") # maptype 要和你使用的地图包匹配 .add( "景点分布", [list(z) for z in zip(df["name"], df["lng"], df["lat"])], type_="scatter", label_opts=opts.LabelOpts(is_show=False) ) .set_global_opts( title_opts=opts.TitleOpts(title="景点地理分布"), visualmap_opts=opts.VisualMapOpts( min_=df["score_clean"].min(), max_=df["score_clean"].max(), is_show=True ) ) ) geo.render("geo_scatter.html")这段代码的核心是add_schema里的 maptype,它决定了底图用哪个城市或区域。pyecharts 内置了一部分地图包,如果没有你需要的城市,需要额外安装对应的地图数据包。add方法里的数据格式是[名称, 经度, 纬度]的列表,type_ 设为 scatter 就是散点图。
热力图适合展示密度分布,比如哪些区域景点扎堆:
# 热力图:用经纬度网格统计景点密度 heatmap = ( HeatMap() .add_xaxis([str(round(x, 2)) for x in df["lng"].unique()]) .add_yaxis( "密度", [str(round(y, 2)) for y in df["lat"].unique()], [[round(x, 2), round(y, 2), 1] for x, y in zip(df["lng"], df["lat"])] ) .set_global_opts( title_opts=opts.TitleOpts(title="景点密度热力图"), visualmap_opts=opts.VisualMapOpts(is_show=True) ) ) heatmap.render("heatmap.html")热力图的参数里,x 轴和 y 轴的粒度决定了图的精细程度。粒度太细图会很稀疏,太粗又会糊成一团。我一般先把经纬度四舍五入到小数点后两位,大约对应一公里左右的精度,然后根据数据量调整。
4.2 评分分布与价格区间的趋势呈现
除了地理图,评分分布和价格区间也是旅游数据分析的常见需求。评分分布用直方图看集中趋势,价格区间用箱线图看离散程度。
from pyecharts.charts import Bar, Boxplot import numpy as np # 评分分布直方图:手动分箱 bins = [0, 3, 3.5, 4, 4.5, 5] labels = ["3分以下", "3-3.5", "3.5-4", "4-4.5", "4.5-5"] df["score_bin"] = pd.cut(df["score_clean"], bins=bins, labels=labels) score_counts = df["score_bin"].value_counts().reindex(labels) bar = ( Bar() .add_xaxis(labels) .add_yaxis("景点数量", score_counts.tolist()) .set_global_opts( title_opts=opts.TitleOpts(title="景点评分分布"), xaxis_opts=opts.AxisOpts(name="评分区间"), yaxis_opts=opts.AxisOpts(name="数量") ) ) bar.render("score_dist.html")分箱的边界值要根据实际数据分布来定。如果大部分评分集中在 4 到 5 之间,那 0 到 3 的区间就会很空,可以合并。pd.cut的 labels 参数要和 bins 的数量匹配,少一个多一个都会报错。
价格箱线图可以按区域或景点类型分组,看不同组的价格中位数和离散程度。这里不展开代码,思路和上面一致,把分组字段传给 x 轴即可。
4.3 中文乱码与地图包缺失的排查
可视化环节最常见的两个问题:中文显示成方块,地图渲染出来是空白。
中文乱码的根因是字体缺失。matplotlib 需要手动指定中文字体,pyecharts 导出 HTML 一般不会有这个问题,但如果你用 snapshot 功能截图,底层还是依赖浏览器字体。解决办法是在代码里显式设置字体:
import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["SimHei"] # 指定默认字体 plt.rcParams["axes.unicode_minus"] = False # 解决负号显示问题地图空白通常是 maptype 对应的地图包没安装。pyecharts 的地图数据是独立的包,需要单独安装。另外,如果数据里的经纬度超出了底图范围,点也不会显示。排查方法是先打印经纬度的最小最大值,确认落在底图覆盖的区域内。
5. 避坑与常见问题:那些让我加班到凌晨的坑
5.1 请求返回 200 但数据为空
现象:代码没报错,状态码是 200,但解析出来的列表是空的。
原因通常有三种:一是接口需要特定的 Cookie 或 token,你只带了 User-Agent;二是参数名写错了,比如把 page 写成了 pageNum,服务端忽略了未知参数返回了默认第一页但结构不同;三是返回的 JSON 层级和你以为的不一样,数据藏在更深的一层。
解决:先把 resp.text 完整打印出来看前 500 个字符,确认返回的到底是什么。再用浏览器开发者工具对比你的请求和真实请求的差异,重点看请求头和参数。层级问题就逐层打印 keys,直到找到列表所在的位置。
5.2 采集到一半突然全部失败
现象:前几百条正常,之后全部返回 403 或超时。
原因:触发了站点的频率限制或 IP 封禁。很多站点的风控是滑动窗口,短时间内请求过多就会临时封禁。
解决:立即停止采集,把延时调大,比如从 1 秒调到 5 秒,等几分钟再试。如果还是不行,可能需要更换出口地址或降低并发。我的习惯是在采集脚本里加一个计数器,每请求 50 次就强制休息 30 秒,这样能大幅降低被封的概率。
5.3 经纬度偏移导致点位漂移
现象:地图上景点位置明显不对,偏移了几百米甚至几公里。
原因:坐标系不统一。不同来源的经纬度可能使用不同的坐标系,直接混用就会偏移。
解决:确认所有坐标的来源和坐标系类型,统一转换成同一种坐标系后再可视化。如果无法确认,可以用已知地标做校准,比如选一个你确定位置的景点,看它在图上的位置和实际位置的偏差,反推偏移量。
5.4 可视化图表在浏览器里打不开
现象:render 生成的 HTML 文件双击打开是空白页。
原因:pyecharts 生成的 HTML 依赖在线 CDN 加载 JavaScript 库,如果网络环境访问不了 CDN,图表就渲染不出来。
解决:把 CDN 资源换成本地文件,或者用 pyecharts 的离线模式。另一个办法是用 snapshot 功能直接生成图片,不依赖浏览器渲染。
5.5 数据量大了之后内存爆掉
现象:采集几万条数据后,脚本运行越来越慢,最后内存不足被系统杀掉。
原因:把所有数据堆在内存里的列表里,没有及时落盘。
解决:边采集边写入文件,用 JSONL 格式每行一条记录,或者用 SQLite 做本地存储。这样即使中途中断,已采集的数据也不会丢。我一般用 SQLite,因为后续清洗和分析可以直接用 SQL 查询,比反复读写 CSV 方便。
6. 进阶技巧:把采集和分析串成可复用的流水线
走到这里,你已经能跑通单次采集和可视化了。但真实项目里,数据需要定期更新,分析维度会不断增加,手动跑脚本很快就会乱。我的做法是把整条链路拆成三个独立的模块:采集器、清洗器、可视化器,每个模块只负责一件事,通过文件或数据库传递数据。
采集器只负责发请求和落盘,输出原始 JSONL。清洗器读取原始数据,做字段规范化和去重,输出干净的 CSV 或写入数据库。可视化器只读干净数据,生成图表。这样做的好处是,任何一环出问题都可以单独重跑,不用从头再来。
验证数据质量的一个实用技巧是抽样人工核对。随机抽 20 条记录,把采集到的名称、价格、评分和页面上显示的内容逐一对比。如果准确率低于 95%,说明解析逻辑有问题,需要回去检查选择器或正则。这个步骤看起来笨,但能帮你提前发现系统性错误,避免在错误数据上做出一堆漂亮但没用的图。
另一个技巧是给采集脚本加日志。不要只用 print,用 logging 模块把请求 URL、状态码、耗时、解析出的记录数都记下来。出问题的时候,日志就是你的黑匣子。我习惯把日志按天切分,保留最近 7 天,这样既不会占太多空间,又能追溯近期的问题。
最后说一个我自己的教训:不要等到采集了几万条数据才想起来检查数据质量。每采集 500 条就停下来看一眼样本,确认字段完整、格式正确、没有明显的乱码或缺失。这个习惯帮我省下了大量返工时间。数据采集这件事,后悔药是没有的,只能在过程中不断验证。希望帮到你。
本文还有配套的精品资源,点击获取