旅游数据爬取与可视化毕设:从2264条到1823条的完整拆解

发布时间:2026/10/15 10:43:27
旅游数据爬取与可视化毕设:从2264条到1823条的完整拆解 简介这份PDF文档面向计算机相关专业的毕业生与数据分析初学者围绕去哪儿网旅游数据爬取与可视化分析这一毕业设计课题提供从数据采集到结果呈现的完整方案。文档共1个PDF文件压缩包约979KB内容涵盖任务分析、设计实施与作品总结等章节结构清晰便于按模块查阅。已有2766人学习下载说明该选题在毕业设计场景中具有较高的参考价值。读者可从中获取爬虫编写思路包括requests、BeautifulSoup与Scrapy的选型、模拟登录与反爬应对策略以及Pandas数据清洗、NumPy与SciPy统计分析的具体方法。可视化部分详细展示了景点区域分布、等级分布、月销量与价格、用户评分等七类分析图表并配有数据库连接、数据导入与主程序调用的实现说明适合作为毕业设计模板或数据分析入门实战参考。1. 从 2264 条到 1823 条一份旅游数据爬取与可视化毕设的完整拆解做在线旅游方向的数据分析最头疼的往往不是算法而是数据从哪来、字段全不全、清洗规则怎么定。这份毕业设计围绕某在线旅游平台的上海景点数据用 requests BeautifulSoup 抓取列表页和详情页共 10 个字段原始数据 2264 条经区域清洗后保留 1823 条再通过 pymysql 入库、pyecharts 渲染出 7 类图表。整套流程覆盖了采集、存储、清洗、分析、可视化五个环节适合正在找旅游数据爬取实战项目练手的人也适合需要一套完整毕设参考框架的在校同学。下面按实际动手顺序拆开讲重点放在能直接抄的代码和容易翻车的地方。2. 采集层拆解列表页与详情页的字段映射逻辑2.1 目标页面结构判断与请求头设置动手之前先判断页面类型。该平台景点列表页的 HTML 是服务端直出的用 requests 拿到源码后 BeautifulSoup 就能解析不需要上 Selenium 或 Playwright。列表页 URL 形如https://piao.qunar.com/ticket/list.htm?keyword上海pageN翻页参数直接拼在 query string 里一共 151 页。请求头里 User-Agent 必须带否则大概率返回空页面或验证码页。编码方面网页声明是 gbk但实际用response.apparent_encoding自动识别更稳保存文件时统一转 utf-8避免 Excel 打开乱码。import requests def get_one_page(url): header { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Safari/537.36 } response requests.get(url, headersheader, timeout10) response.encoding response.apparent_encoding return response.text这里timeout10是必须加的不加的话遇到慢响应会一直挂住。apparent_encoding比硬编码 gbk 更保险因为部分页面实际返回的是 utf-8。2.2 列表页 9 个字段的提取与容错列表页要抓的字段有景点名称、区域、热度、地址、整体评价、等级、最低价格、月销量、详情页网址。前 5 个字段基本每个景点都有后 4 个字段存在缺失——有些景点没有等级标注有些没有报价有些没有销量数据。所以每个可能缺失的字段都要包一层 try-except缺了就赋空字符串不能让一条数据的缺失导致整页解析崩掉。import re import pandas as pd from bs4 import BeautifulSoup def parse_page(html, page): scence_list [] try: soup BeautifulSoup(html, html.parser) scence soup.find_all(div, {class: result_list}) if len(scence) ! 0: for item in scence[0]: scence_name item.find_all(h3, {class: sight_item_caption})[0].get_text() scence_title item.find_all(div, {class: sight_item_info})[0] \ .find_all(span, {class: area})[0].get_text() scence_hot item.find_all(div, {class: sight_item_info})[0] \ .find_all(em)[0].get_text() scence_adress item.find_all(div, {class: sight_item_info})[0] \ .find_all(p)[0].get_text()[3:] content item.find_all(div, {class: sight_item_info})[0] \ .find_all(div, {class: intro color999})[0].get_text() try: scence_level item.find_all(span, {class: level})[0].get_text() except: scence_level try: scence_price item.find_all(div, {class: sight_item_pop})[0] \ .find_all(span, {class: sight_item_price})[0] \ .find_all(em)[0].get_text() except: scence_price try: sales item.find_all(div, {class: sight_item_pop})[0] \ .find_all(span, {class: hot_num})[0].get_text() except: sales html_scence item.find_all(div, {class: show loading})[0] html_values https://piao.qunar.com \ re.findall(href(.*?) tabindex, str(html_scence))[0] scence_list.append([scence_name, scence_title, scence_hot, scence_adress, content, scence_level, scence_price, sales, html_values]) print(第 str(page) 页已抓取完成) except: print(----网页读取错误跳过------) scence_df pd.DataFrame(datascence_list, columns[名称, 区域, 热度, 地址, 整体评价, 等级, 最低价格, 月销量, 网址]) return scence_df地址字段用[3:]截掉前三个字符是因为页面里地址前面带了“地址”前缀。详情页网址用正则从href属性里抠出来再拼上域名前缀比用 BeautifulSoup 的get(href)更直接因为那个 div 的 class 名带空格直接定位容易出问题。2.3 详情页评分与评论数的二次抓取列表页只有 9 个字段用户评分和评论个数在详情页里。所以主流程分两段先翻完 151 页列表拿到基础数据再遍历每条记录的详情页 URL 去补两个字段。详情页的解析逻辑单独封装def get_score_content(item, html_content): data [] try: soup BeautifulSoup(html_content, html.parser) content soup.find_all(div, {class: mp-description-detail}) inter_content content[0].find_all(div, {class: mp-description-comments})[0] try: over_scores inter_content.find_all(span, {id: mp-description-commentscore})[0].get_text() except: over_scores try: eval_values content[0].find_all(span, {class: mp-description-commentCount})[0].get_text() except: eval_values data.extend([item, over_scores, eval_values]) except: print(f{item} 抓取有误) return data注意详情页的评分和评论数同样存在缺失有些景点没有评分也没有评论这里用空字符串兜底。最后用pd.merge按网址列做左连接把两段数据拼成一张宽表。2.4 翻页节奏控制与主流程串联151 页列表加 2264 个详情页如果不加延时请求频率太高容易被限。代码里用time.sleep(random.random() * 3)做列表页随机间隔详情页用time.sleep(random.random())这个节奏比较稳妥。import time from tqdm import tqdm if __name__ __main__: data_result pd.DataFrame() for page in range(1, 152): url (https://piao.qunar.com/ticket/list.htm?keyword%E4%B8%8A%E6%B5%B7 region%E4%B8%8A%E6%B5%B7frommpshouye_hotcitypage str(page)) html get_one_page(url) data parse_page(html, page) time.sleep(random.random() * 3) data_result pd.concat([data_result, data]) score_conten_values [] for item in tqdm(data_result[网址].tolist()): html_content get_one_page(item) score_content_inter get_score_content(item, html_content) time.sleep(random.random()) score_conten_values.append(score_content_inter) score_content_df pd.DataFrame(score_conten_values, columns[网址, 用户评分, 评价个数]) score_content_df score_content_df.dropna() final_result pd.merge(data_result, score_content_df, howleft, on网址) final_result.to_excel(F:/comments.xlsx, indexFalse)tqdm用来显示详情页抓取进度2264 条大概要跑 40 到 60 分钟有进度条心里有底。dropna()把详情页完全没抓到的记录去掉再用左连接合并保证列表页数据不丢。3. 入库与清洗从 Excel 到 MySQL 的字段映射3.1 建表语句与字段类型选择Excel 存的是原始数据真正做分析要进 MySQL。建表时字段类型要跟数据特征匹配价格和销量用 INT因为后续要做数值计算网址用 LONGTEXT因为详情页 URL 比较长其余文本字段用 VARCHAR(255) 够用。import pymysql import pandas as pd db pymysql.connect(hostlocalhost, userroot, passwd123456, charsetutf8mb4) mycursor db.cursor() mycursor.execute(DROP TABLE IF EXISTS comments) sql CREATE TABLE comments( scence_name VARCHAR(255) COMMENT 名称, scence_title VARCHAR(255) COMMENT 区域, scence_hot VARCHAR(255) COMMENT 热度, scence_adress VARCHAR(255) COMMENT 地址, scence_content VARCHAR(255) COMMENT 整体评价, scence_level VARCHAR(255) COMMENT 等级, scence_price INT COMMENT 最低价格, scence_sales INT COMMENT 月销量, html_scence LONGTEXT COMMENT 网址, over_scores VARCHAR(255) COMMENT 用户评分, eval_values VARCHAR(255) COMMENT 评论个数 ) mycursor.execute(sql)charsetutf8mb4要显式指定否则中文区域字段入库可能变问号。DROP TABLE IF EXISTS方便反复调试每次跑都重建。3.2 逐行插入与空值处理从 Excel 读回来之后用df.where(df.notnull(), None)把 NaN 转成 None因为 pymysql 不认 pandas 的 NaN直接插会报类型错误。query INSERT INTO comments(scence_name, scence_title, scence_hot, scence_adress, scence_content, scence_level, scence_price, scence_sales, html_scence, over_scores, eval_values) VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) df pd.read_excel(F:/comments.xlsx) data df.where(df.notnull(), None) for i in range(len(data)): d data.iloc[i, :] values (d[名称], d[区域], d[热度], d[地址], d[整体评价], d[等级], d[最低价格], d[月销量], d[网址], d[用户评分], d[评价个数]) mycursor.execute(query, values) db.commit() print(共导入 str(data.shape[0]) 行 str(data.shape[1]) 列)逐行插入 2264 条大概几秒钟量不大没必要上executemany。db.commit()别忘了不提交的话数据只在事务里关掉连接就没了。3.3 区域字段清洗16 个区的白名单过滤原始数据里区域字段格式是[上海·上海·浦东新区]这种带方括号的全路径。上海实际有 16 个区但爬回来的数据里混了外地景点和重复景点需要按白名单删除。clear DELETE FROM comments WHERE scence_title NOT IN ( [上海·上海·浦东新区], [上海·上海·黄浦区], [上海·上海·徐汇区], [上海·上海·长宁区], [上海·上海·静安区], [上海·上海·普陀区], [上海·上海·虹口区], [上海·上海·杨浦区], [上海·上海·闵行区], [上海·上海·宝山区], [上海·上海·嘉定区], [上海·上海·金山区], [上海·上海·松江区], [上海·上海·青浦区], [上海·上海·奉贤区], [上海·上海·崇明县] ) mycursor.execute(clear) db.commit()注意崇明在数据里是“崇明县”不是“崇明区”写 SQL 时要跟实际数据对齐否则会把崇明的景点全删掉。清洗后从 2264 条降到 1823 条删掉了 441 条外地或重复记录。4. 可视化落地7 类图表的数据组织与渲染4.1 漏斗图与折线图的数据准备区域分布用漏斗图按区域分组统计景点数量再降序排列。区域字段入库时带了方括号前缀画图前要用正则把区名抠出来。import numpy as np import re from pyecharts import options as opts from pyecharts.charts import Funnel, Line, Bar, Scatter, WordCloud from pyecharts.globals import ThemeType sql_1 SELECT scence_title 区域, COUNT(scence_title) 景点个数 FROM comments GROUP BY scence_title ORDER BY COUNT(scence_title) DESC mycursor.execute(sql_1) p_1 mycursor.fetchall() p1 np.array(p_1) for p in p1: p[0] re.findall(r\[上海·上海·(.*)\], p[0])[0] c_1 ( Funnel(init_optsopts.InitOpts(themeThemeType.LIGHT)) .add(类别, p1.tolist(), label_optsopts.LabelOpts(positioninside)) .set_global_opts( title_optsopts.TitleOpts(title上海市旅游景点区域分布图, pos_leftcenter), legend_optsopts.LegendOpts(pos_bottom0%)) .render(漏斗图.html) )p1.tolist()是因为 pyecharts 对 numpy array 支持不稳定转成原生 list 更保险。折线图做区域与等级的交叉统计SQL 按区域和等级双字段分组Python 侧把结果拆成 x 轴和多个 y 轴序列。4.2 柱状图与散点图的分析维度区域与月销量用柱状图SQL 里对月销量取平均值再按区域分组。等级与月销量同理按等级分组求平均。散点图做月销量与价格、月销量与评分两组关系直接把两列数值传给 Scatter 的 add_xaxis 和 add_yaxis。sql_3 SELECT scence_title 区域, AVG(scence_sales) 平均月销量 FROM comments WHERE scence_sales IS NOT NULL GROUP BY scence_title ORDER BY AVG(scence_sales) DESC mycursor.execute(sql_3) p_3 mycursor.fetchall() p3 np.array(p_3) for p in p3: p[0] re.findall(r\[上海·上海·(.*)\], p[0])[0] bar ( Bar(init_optsopts.InitOpts(themeThemeType.LIGHT)) .add_xaxis([x[0] for x in p3]) .add_yaxis(平均月销量, [int(x[1]) for x in p3]) .set_global_opts( title_optsopts.TitleOpts(title各区域景点平均月销量), xaxis_optsopts.AxisOpts(axislabel_optsopts.LabelOpts(rotate45))) .render(柱状图.html) )x 轴标签旋转 45 度是因为 16 个区名横排会重叠。散点图要注意价格和销量里可能有空值SQL 里加IS NOT NULL过滤否则画图时坐标对不上。4.3 词云图的词频构造词云图按景点名称的月销量加权销量越高的景点名字越大。pyecharts 的 WordCloud 接收[(词, 权重), ...]格式的数据。sql_7 SELECT scence_name, scence_sales FROM comments WHERE scence_sales IS NOT NULL ORDER BY scence_sales DESC LIMIT 100 mycursor.execute(sql_7) p_7 mycursor.fetchall() wordcloud ( WordCloud() .add(, [(name, int(sales)) for name, sales in p_7], word_size_range[12, 60]) .set_global_opts( title_optsopts.TitleOpts(title热门景点词云图, pos_leftcenter)) .render(词云图.html) )取销量前 100 的景点做词云太多会挤在一起看不清。word_size_range控制字号范围12 到 60 在 1920 宽屏上显示效果比较均衡。5. 避坑与排查这套流程里最容易翻车的 5 个点5.1 翻页到后面返回空数据现象前几十页正常到 100 页以后result_list为空解析函数直接跳过。原因该平台对深翻页有限制或者部分页码本身就没有数据。解决在parse_page里判断len(scence) 0时打印页码并继续不要中断整个循环。同时把time.sleep的随机范围调大一点降低被限的概率。5.2 详情页评分抓回来是空字符串现象列表页数据完整但详情页的over_scores和eval_values大量为空。原因部分景点详情页结构跟标准模板不一样mp-description-detail这个 div 可能不存在。解决get_score_content里已经用 try-except 兜底但更稳妥的做法是先判断content列表是否为空为空直接返回[item, , ]避免索引越界。5.3 入库后中文区域字段变成问号现象MySQL 里scence_title列显示[??·??·???]。原因连接时没指定 charset或者数据库/表的默认字符集不是 utf8mb4。解决pymysql.connect加charsetutf8mb4建表时也显式指定DEFAULT CHARSETutf8mb4。如果表已经建好了用ALTER TABLE comments CONVERT TO CHARACTER SET utf8mb4改。5.4 清洗 SQL 把崇明景点全删了现象清洗后崇明区景点数为 0。原因白名单里写的是[上海·上海·崇明区]但实际数据里是[上海·上海·崇明县]。解决写清洗 SQL 之前先SELECT DISTINCT scence_title FROM comments看一眼实际值按真实数据写白名单不要凭常识猜。5.5 pyecharts 渲染的 HTML 打开是空白现象.render()生成的 HTML 文件用浏览器打开只有标题没有图。原因pyecharts 默认从 CDN 加载 echarts.min.js本地网络访问不到 CDN 时就白屏。解决在InitOpts里指定js_host为本地路径或者提前把 echarts.min.js 下载到同目录用CurrentConfig.ONLINE_HOST改成相对路径。6. 进阶技巧用 SQL 聚合替代 Python 循环计算这套流程跑通之后最值得优化的地方是把 Python 里的循环计算尽量下沉到 SQL 层。原始代码里区域分布、等级分布、月销量均值这些统计都是在 Python 里用 pandas 做的数据量小的时候没问题但如果换成全国景点数据几万条记录在 Python 里循环会明显变慢。我一般会这样改所有分组统计直接用 SQL 的GROUP BYAVG/COUNT完成Python 只负责拿结果和渲染图表。比如区域与月销量的柱状图一条 SQL 就能出结果不需要先把全量数据拉到 DataFrame 再 groupby。这样做的另一个好处是清洗规则和统计口径都落在 SQL 里换人接手时看 SQL 就能明白数据是怎么算的不用去翻 Python 代码里的中间变量。还有一个细节是月销量字段的类型。原始数据里月销量是字符串入库时转成 INT 之后AVG才能正确计算。如果入库时没转类型SQL 里的AVG会按字符串排序算结果完全不对。我一般会在建表时就把数值字段定成 INT 或 DECIMAL导入前用pd.to_numeric(errorscoerce)做一次强制转换转不了的置为 NaN入库时再转 None。验证数据质量有个笨办法但很管用清洗前后各跑一次SELECT COUNT(*)再对区域字段跑SELECT scence_title, COUNT(*) FROM comments GROUP BY scence_title看 16 个区的数量分布是否合理。如果某个区数量特别少或者特别多大概率是清洗规则写错了。从那以后我每次做完区域清洗都会强制走一遍这个分布检查确认没有误删再往下做可视化。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询