news 2026/9/19 6:59:21

Python爬虫实战:马蜂窝旅游数据采集与可视化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python爬虫实战:马蜂窝旅游数据采集与可视化

简介:面向旅游信息爬取与数据分析的应用场景,Python技术文档资源包共含1个doc文档,大小2.25MB,内容完整且结构清晰,适合Python初学者、数据分析爱好者以及需要完成课程设计或毕业设计的学生。文档以马蜂窝旅游网站为实战案例,围绕酒店、旅馆、景点等旅游信息的采集与处理展开,系统介绍了Python语言的发展背景与特色、URL与HTML基础,以及Chrome、PyCharm、Scrapy、BeautifulSoup等爬虫工具的使用。主体部分按照项目需求、设计分析、城市编号获取、旅游信息爬取与数据处理顺序展开,完整覆盖从爬虫原理到实战落地的各个环节,配有摘要、关键词和系统化目录,便于读者按章节快速查阅。目前已有315人学习下载,借助这份文档可以快速理解旅游网站数据抓取、页面解析、数据清洗与分析的实现思路,也能为同类型的爬虫项目设计或论文撰写提供参考模板。

1. 从“去哪儿玩”到“去哪儿玩的人多”:一个马蜂窝爬虫的完整拆解

打开马蜂窝,面对几百个目的地,大多数人会陷入选择困难。但如果你换个角度,把“哪里好玩”变成“哪里被讨论最多、游记最多、美食标签最密”,决策就变成了数据问题。这篇文章要拆解的,正是一个基于 Python 的旅游信息爬取与数据分析项目:它以马蜂窝为样本,先抓取城市编号,再拼接 URL 批量爬取城市旅游数据,最后用柱状图、饼图和热力图完成可视化。整个链路覆盖了 requests 伪装请求、BeautifulSoup 解析、Selenium 翻页模拟、Pandas 清洗合并、Pyecharts 绘图——基本上把聚焦爬虫的数据管线走了一遍。适合刚学完 Python 语法、想做一个完整爬虫项目练手的人,也适合需要一份可扩展的“城市维度旅游数据采集方案”的从业者参考。需要说明的是,本文讨论的是公开页面的基础信息采集,聚焦爬虫的合法边界请自行把握。

2. Python 爬虫的技术选型:为什么是 requests + BeautifulSoup + Selenium 而不是 Scrapy

写爬虫之前,先想清楚工具边界。马蜂窝的城市页面是服务端渲染的 HTML,不是 SPA 单页应用,这意味着不需要 Headless Chrome 去执行 JavaScript 才能拿到数据;页面结构相对规整,标签层级不深,BeautifulSoup 足够应对;翻页逻辑是点击“后一页”按钮而非 URL 参数变化,所以需要 Selenium 介入模拟点击。三者各司其职,比直接上 Scrapy 更轻量,也更容易调试。

2.1 requests 的伪装策略与异常处理

requests 是 Python 中最基础的 HTTP 客户端库,和 urllib 相比,它的 API 更简洁,自动处理 Cookie、重定向和连接池。但直接用 requests 访问马蜂窝,大概率会被拦,因为服务端能通过 User-Agent、Referer、访问频率等特征识别非浏览器请求。

import requests headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', 'Referer': 'https://www.mafengwo.cn/mdd/' } def fetch_html(url, timeout=10, retries=3): for attempt in range(retries): try: resp = requests.get(url, headers=headers, timeout=timeout) resp.raise_for_status() resp.encoding = 'utf-8' return resp.text except (requests.ConnectionError, requests.Timeout) as e: print(f"第 {attempt + 1} 次请求失败: {e}") time.sleep(2) return None

这里的关键参数是headers字典和timeoutUser-Agent伪装成 Chrome 浏览器,Referer设置为马蜂窝目的地首页,是为了让服务端认为你是从站内跳转过来的正常访问。timeout=10防止某个请求卡死拖慢整个爬虫,retries=3配合time.sleep(2)做简单退避。注意resp.encoding = 'utf-8'这一步不能省,马蜂窝的页面是 UTF-8 编码,如果不显式指定,requests 会根据响应头猜测编码,有时会猜错导致中文乱码。

2.2 BeautifulSoup 的解析策略:find 与 find_all 的配合

拿到 HTML 源码后,接下来是从中抽取结构化信息。BeautifulSoup 支持多种解析器,html.parser是 Python 内置的,不需要额外安装,但容错性一般;lxml解析速度快,对不规范 HTML 的容错更好,是更推荐的选择。

from bs4 import BeautifulSoup soup = BeautifulSoup(html_text, 'lxml') # 定位省份 URL 所在容器 province_block = soup.find('div', class_='hot-list clearfix') if province_block: dt_tags = province_block.find_all('dt') for dt in dt_tags: a_tag = dt.find('a') if a_tag and a_tag.has_attr('href'): province_url = a_tag['href'] province_name = a_tag.get_text(strip=True) print(f"省份: {province_name}, URL: {province_url}")

find('div', class_='hot-list clearfix')中的class_带下划线,是因为class是 Python 关键字,这是 BeautifulSoup 的语法约定。find_all返回所有匹配的标签列表,find只返回第一个匹配项。这里先find到外层容器,再在容器内find_all定位所有省份名,是为了避免页面其他区域出现同名 class 干扰。get_text(strip=True)去除标签内部的空白字符,拿到干净的文本内容。

2.3 Selenium 的定位:什么时候非用不可

BeautifulSoup 只能解析静态 HTML,如果数据是 JavaScript 动态加载的,或者需要模拟点击翻页,就必须用 Selenium。马蜂窝的省份城市列表翻页,URL 不会变化,而是通过 JavaScript 替换页面内容,这种情况下 requests 拿到的永远只有第一页。

from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC import time driver = webdriver.Chrome() # 需要提前下载与浏览器版本匹配的 chromedriver driver.get('https://www.mafengwo.cn/mdd/citylist/10186.html') for page in range(1, 6): # 假设每个省份城市列表最多 5 页 WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.CLASS_NAME, 'city-list')) ) # 滚动到底部触发懒加载 driver.execute_script('window.scrollTo(0, document.body.scrollHeight);') time.sleep(2) city_items = driver.find_elements(By.CSS_SELECTOR, 'a[data-type="目的地"]') for item in city_items: city_name = item.text.strip() city_id = item.get_attribute('data-id') print(f"城市: {city_name}, ID: {city_id}") # 点击下一页 next_btn = driver.find_element(By.CLASS_NAME, 'pg-next') if next_btn: next_btn.click() time.sleep(2) else: break driver.quit()

WebDriverWait显式等待解决了页面加载时序问题,presence_of_element_located确保城市列表渲染完成后再提取数据。execute_script('window.scrollTo(0, document.body.scrollHeight)')触发页面的懒加载逻辑,因为马蜂窝的城市列表是滚动到可视区域才加载更多内容的。find_elements(By.CSS_SELECTOR, 'a[data-type="目的地"]')用 CSS 选择器按属性定位,比按 class 定位更精确,因为>province_url = "https://www.mafengwo.cn/travel-scenic-spot/mafengwo/10186.html" citylist_url = province_url.replace("travel-scenic-spot/mafengwo", "mdd/citylist") print(citylist_url) # 输出: https://www.mafengwo.cn/mdd/citylist/10186.html

replace替换规则的核心是将“景点详情路径”切换为“城市列表路径”。这一步之所以是关键,是因为同一个数字在不同 URL 模式下对应的页面功能完全不同:前者是云南省的旅游详情页,后者是云南省下辖城市的列表页。字符串替换比重新构造 URL 更稳妥,因为省去硬编码城市编号的步骤,后续如果省份编号变化,代码自动适配。

3.2 数据字段的确定与存储结构

爬取城市信息时,不能所有字段都拿,否则数据冗余且容易触发反爬。根据项目需求,聚焦三类数据:游记数(反映热门程度)、景点标签数量、美食与购物标签数量。这三类数据的爬取页面不同,最终用 city_id 和城市名做主键进行关联合并。

import pandas as pd def build_city_dataframe(city_names, city_ids, travel_counts, food_counts): df = pd.DataFrame({ 'city_name': city_names, 'city_id': city_ids, 'travel_count': travel_counts, 'food_count': food_counts }) df['city_id'] = df['city_id'].astype(str) return df

这里用pd.DataFrame构建二维表格,city_id显式转为字符串类型,因为后面合并时如果用数字类型,可能因为精度问题导致匹配失败。travel_countfood_count分别是游记数和美食标签数,这两个指标直接决定城市在排行榜中的位置。设计存储结构时,预留hotel_countshopping_count等字段位置,后续如需扩展不用改表结构。

3.3 反爬策略的设计边界

马蜂窝有一定的反爬机制,常见的是请求频率限制和验证码。项目代码里采用了四项基本策略:UA 伪装、Referer 伪装、限速、重试。更进一步的方案是维护一个 Cookie 池,登录后拿到的 Session 有效期内,请求被拦截的概率会降低,但登录涉及账号安全,适合企业级爬虫,个人学习项目不建议越界。

import time import random def polite_sleep(base=1.5, jitter=0.5): time.sleep(base + random.uniform(0, jitter))

polite_sleep在两次请求之间加入随机延时,避免形成固定频率的访问模式。random.uniform生成的随机延时在 1.5 到 2.0 秒之间,既不会太慢影响爬取效率,也不会因为节奏太规律被识别为程序行为。

4. 核心实现:城市信息批量爬取与本地存储

设计是骨架,代码是血肉。这一章完整落地城市信息的爬取过程,包括省份 URL 的提取、城市编号的翻页获取、城市详情页的信息抽取,以及最终存成 Excel 的完整流程。

4.1 省份 URL 爬取:从目的地首页提取入口

马蜂窝的目的地首页是https://www.mafengwo.cn/mdd/,页面中有一个div容器,class 为hot-list clearfix,容器内是各省份的入口链接。这里的抓取逻辑是:先定位容器,再找所有dt标签,从dt内的a标签中提取href和文本。

import requests from bs4 import BeautifulSoup import pandas as pd def get_province_urls(): url = 'https://www.mafengwo.cn/mdd/' headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', } resp = requests.get(url, headers=headers, timeout=10) resp.encoding = 'utf-8' soup = BeautifulSoup(resp.text, 'lxml') prov_block = soup.find('div', class_='hot-list clearfix') if not prov_block: print("未找到省份容器,请检查 class 名是否变化") return [], [] provinces = [] ids = [] for dt in prov_block.find_all('dt'): a = dt.find('a') if a and a.get('href'): province_url = a['href'] province_id = province_url.rstrip('.html').split('/')[-1] provinces.append(a.get_text(strip=True)) ids.append(province_id) return provinces, ids prov_names, prov_ids = get_province_urls() print(f"共抓取 {len(prov_names)} 个省份")

rstrip('.html')去掉末尾的.html后缀,再用split('/')[-1]取路径最后一段,得到纯数字的省份 ID。这样做比正则表达式更直观,也便于读代码的人理解 URL 结构。注意.rstrip('.html')不是去除后缀的标准方式,如果文件名可能是.htm.html5会出问题,更稳妥的是province_url.split('/')[-1].split('.')[0],但这里直接用问题不大。

4.2 城市编号爬取:Selenium 翻页与懒加载处理

拿到省份 ID 后,下一步是拼接城市列表页 URL。城市列表页的分页机制是 JavaScript 点击翻页,URL 不变化,所以必须用 Selenium 模拟操作。这里有一个容易被忽略的坑:马蜂窝城市列表页的下一页按钮,当页面到达最后一页时,pg-next这个 class 会从页面中消失,因此点击前必须判断按钮是否存在。

from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC import time def crawl_city_ids(province_id, driver): base_url = f'https://www.mafengwo.cn/mdd/citylist/{province_id}.html' driver.get(base_url) city_ids = [] city_names = [] for _ in range(10): # 最多翻 10 页 try: WebDriverWait(driver, 8).until( EC.presence_of_element_located((By.CLASS_NAME, 'city-list')) ) except Exception as e: print(f"页面加载超时: {e}") break driver.execute_script('window.scrollTo(0, document.body.scrollHeight);') time.sleep(1.5) items = driver.find_elements(By.CSS_SELECTOR, 'a[data-type="目的地"]') if not items: break for item in items: cid = item.get_attribute('data-id') cname = item.text.strip() if cid and cname and cid not in city_ids: city_ids.append(cid) city_names.append(cname) # 找下一页按钮 next_btn = driver.find_elements(By.CLASS_NAME, 'pg-next') if next_btn: next_btn[0].click() time.sleep(1.5) else: break return city_names, city_ids

这个函数的关键细节有三个。第一,find_elements(By.CLASS_NAME, 'pg-next')用复数形式,返回列表,如果页面没有下一页按钮,列表为空,直接跳出循环,避免了find_element抛异常。第二,cid not in city_ids做去重,因为极少数情况下,Selenium 滚动翻页后,页面上的元素会重复渲染一次。第三,>def fetch_city_detail(city_id): url = f'https://www.mafengwo.cn/travel-scenic-spot/mafengwo/{city_id}.html' html_text = fetch_html(url) if not html_text: return None soup = BeautifulSoup(html_text, 'lxml') # 游记数:位于 class="nums" 的 span 中 travel_count = None nums_span = soup.find('span', class_='nums') if nums_span: travel_count = nums_span.get_text(strip=True) # 美食标签数:位于 class="food-tags" 的容器中 food_count = 0 food_block = soup.find('div', class_='food-tags') if food_block: food_count = len(food_block.find_all('a')) return { 'city_id': city_id, 'travel_count': travel_count, 'food_tag_count': food_count }

游记数提取中,class='nums'span里直接是数字文本,比如“1234”,但也有可能包含“篇”等汉字,所以后处理时需要正则只保留数字。美食标签数是统计food-tags容器下a标签的数量,这个方法假设每个美食标签渲染为一个a链接,如果页面改版,数量统计逻辑会失效,所以代码里要加一个兜底判断。

import re def clean_count(raw): if raw is None: return 0 match = re.search(r'\d+', str(raw)) return int(match.group()) if match else 0

re.search(r'\d+', str(raw))匹配字符串中第一段连续数字,适合处理类似“1.2k 篇游记”这种格式,提取出 1200。这里不用\d+的原因是需要考虑千分位逗号,所以直接用\d+只能匹配数字串,如果原始文本是“1,234”,会被截断为 1,不完美但够用。

4.4 数据合并与导出:Pandas 的横向连接

所有城市的详情数据爬完后,需要把城市 ID、城市名、游记数、美食标签数合并到一个 DataFrame 中。这里用pd.merge做内连接,on='city_id'指定连接键,避免不同省份下同名城市互相干扰。

# 假设 city_df 是包含 city_name 和 city_id 的城市基础表 # detail_df 是包含 city_id 和 travel_count 的详情表 merged_df = pd.merge(city_df, detail_df, on='city_id', how='inner') merged_df['travel_count'] = merged_df['travel_count'].apply(clean_count) merged_df['food_tag_count'] = merged_df['food_tag_count'].apply(clean_count) # 按游记数降序排列 top_cities = merged_df.sort_values('travel_count', ascending=False).head(10) print(top_cities[['city_name', 'travel_count']]) # 导出到 Excel merged_df.to_excel('mafengwo_city_data.xlsx', index=False, engine='openpyxl')

how='inner'内连接保证两边都有记录的城市才保留,避免空数据行进入排行。apply(clean_count)对每行数据执行清洗函数,把带单位的字符串转成纯数字。sort_values('travel_count', ascending=False)排序后取前 10,得到热门城市排行。to_excelengine='openpyxl'是 Pandas 2.x 写 Excel 的默认引擎,如果没装 openpyxl 会报 ModuleNotFoundError,需要先pip install openpyxl

5. 数据可视化:用 Pyecharts 让排行榜“看得见”

数据爬下来只是第一步,能让人一眼看出“哪个城市值得去”,才算完成闭环。可视化采用 Pyecharts 生成柱状图、饼图和热力图,三张图分别对应三个层次的旅游决策。

5.1 柱状图:热门城市 Top10 与景点 Top15

柱状图是最直观的排行呈现方式。用 Pyecharts 的Bar类,横向表示城市名称,纵向表示游记数。游记数差距很大的情况下,横向柱状图比纵向柱状图更容易阅读,因为城市名在左侧不会因为过长而换行。

from pyecharts.charts import Bar from pyecharts import options as opts def render_top_bar(data, title): cities = [item[0] for item in data] values = [item[1] for item in data] bar = ( Bar(init_opts=opts.InitOpts(width='900px', height='600px', theme='light')) .add_xaxis(cities) .add_yaxis('游记数', values, label_opts=opts.LabelOpts(position='right')) .reversal_axis() .set_global_opts( title_opts=opts.TitleOpts(title=title), yaxis_opts=opts.AxisOpts(name='城市'), xaxis_opts=opts.AxisOpts(name='游记数'), ) ) return bar.render('top_cities_bar.html')

reversal_axis()将柱状图从垂直翻转成水平,position='right'让数值标签显示在柱条右侧,避免标签被柱条遮挡。set_global_opts里的title_opts和坐标轴name参数,能直接生成带标题和图表的完整 HTML 文件,不需要额外写前端代码。

5.2 饼状图:美食标签占比与分布

查看一个城市的美食标签占比,能判断这个城市的“美食多样性”。如果某个城市的餐饮标签集中在少数几个品类,说明美食结构比较单一,反之则说明选择更多元。

from pyecharts.charts import Pie def render_pie(data, title): pie = ( Pie() .add( series_name='美食标签分布', data_pair=data, radius=['35%', '60%'], label_opts=opts.LabelOpts(formatter='{b}: {d}%') ) .set_global_opts( title_opts=opts.TitleOpts(title=title), legend_opts=opts.LegendOpts(orient='vertical', pos_top='15%', pos_left='2%') ) ) return pie.render('food_tag_pie.html')

radius=['35%', '60%']生成环形饼图,内半径 35%、外半径 60%,比实心饼图更美观。{b}: {d}%是 Pyecharts 的占位符语法,{b}表示数据项名称,{d}表示占比百分比。图例放在左侧垂直排列,避免数据项多时图例遮挡扇形区域。

5.3 热力图:热门城市的区域分布

热力图展示城市的地理分布,能直观看出“哪些区域是旅游聚集地”。Pyecharts 的Map结合百度地图 API 实现省级热力渲染,但更轻量的是用Geo类在地图上打点,点的大小和颜色深浅对应游记数。

from pyecharts.charts import Geo from pyecharts.globals import GeoType def render_heatmap(data, title): geo = ( Geo() .add_schema(maptype='china', itemstyle_opts=opts.ItemStyleOpts( border_color='#111', area_color='#f0f0f0' )) .add( series_name='热度', data_pair=data, type_=GeoType.EFFECT_SCATTER, symbol_size=8 ) .set_series_opts(label_opts=opts.LabelOpts(is_show=False)) .set_global_opts( title_opts=opts.TitleOpts(title=title), visualmap_opts=opts.VisualMapOpts(max_=max([v for _, v in data])) ) ) return geo.render('city_heatmap.html')

maptype='china'需要下载中国地图 GeoJSON 数据文件,Pyecharts 2.x 会在首次使用时自动下载,但国内网络可能拉取失败。解决方法是手动下载 echarts 地图包放在项目目录下,或者使用pyecharts-snapshot离线渲染。GeoType.EFFECT_SCATTER是带涟漪效果的点状图,视觉效果比普通散点更突出,点的大小固定为 8,颜色深浅由VisualMapOpts控制。

5.4 可视化结果的验证与异常排查

图表生成后,需要做两层验证:一是数据本身是否准确,二是图表配置是否和数据类型匹配。

# 验证热门城市 Top10 是否有明显异常 print(top_cities.head(10).to_string()) # 检查数据分布 print(merged_df['travel_count'].describe())

describe()输出游记数的均值、标准差、最小值、最大值,如果最大值比其他城市高出几个数量级,需要回头核对一下这个城市的style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/19 6:59:18

Foundry Solidity测试原理与实战:EVM状态机驱动的合约验证

1. 为什么 Solidity 测试不能照搬 Java 或 Python 那套逻辑?刚从 Java 接口自动化测试框架或 pytest 测试框架转过来的朋友,第一眼看到forge test命令时,大概率会下意识敲出pytest tests/或mvn test—— 然后发现报错:command not…

作者头像 李华
网站建设 2026/9/19 6:59:05

CMSIS-4不是标准而是遗产协议:嵌入式静态工程深度评测指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/19 6:58:31

GD32H759+RT-Thread工控项目点灯实战:从环境搭建到可靠性验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/19 6:58:09

51单片机闭环控制实战:从传感器采样到电机调速全链路解析

简介:本资源是一份面向电子类专业本科生及单片机初学者的毕业设计级技术文档,聚焦基于51单片机的智能小车控制系统完整实现方案。内容涵盖智能循迹原理、STC89C52最小系统设计、红外循迹与避障模块、电机驱动、无线遥控及语音控制等核心功能模块的硬件选…

作者头像 李华
网站建设 2026/9/19 6:57:34

UE5内置XR模拟器:零硬件启动VR开发全流程

1. 项目概述:为什么“不用买VR设备”这件事值得认真对待我第一次在团队里提出“先别急着采购HTC Vive或Quest 3,咱们用UE5内置模拟器跑通整套VR交互逻辑”时,被两位资深美术当场质疑:“连头显都不戴,怎么调手柄追踪精度…

作者头像 李华
网站建设 2026/9/19 6:55:40

用几秒参考音频做语音编辑与零样本TTS:VoiceCraft上手

用几秒参考音频做语音编辑与零样本TTS:VoiceCraft上手 【免费下载链接】VoiceCraft Zero-Shot Speech Editing and Text-to-Speech in the Wild 项目地址: https://gitcode.com/GitHub_Trending/vo/VoiceCraft VoiceCraft 是一个零样本语音编辑与文本转语音&…

作者头像 李华