Python爬虫实战:马蜂窝旅游数据采集与可视化
发布时间:2026/9/19 6:59:28来源:尧图网络
简介面向旅游信息爬取与数据分析的应用场景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/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Referer: https://www.mafengwo.cn/mdd/ } def fetch_html(url, timeout10, retries3): for attempt in range(retries): try: resp requests.get(url, headersheaders, timeouttimeout) 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字典和timeout。User-Agent伪装成 Chrome 浏览器Referer设置为马蜂窝目的地首页是为了让服务端认为你是从站内跳转过来的正常访问。timeout10防止某个请求卡死拖慢整个爬虫retries3配合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(stripTrue) 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(stripTrue)去除标签内部的空白字符拿到干净的文本内容。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.htmlreplace替换规则的核心是将“景点详情路径”切换为“城市列表路径”。这一步之所以是关键是因为同一个数字在不同 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_count和food_count分别是游记数和美食标签数这两个指标直接决定城市在排行榜中的位置。设计存储结构时预留hotel_count、shopping_count等字段位置后续如需扩展不用改表结构。3.3 反爬策略的设计边界马蜂窝有一定的反爬机制常见的是请求频率限制和验证码。项目代码里采用了四项基本策略UA 伪装、Referer 伪装、限速、重试。更进一步的方案是维护一个 Cookie 池登录后拿到的 Session 有效期内请求被拦截的概率会降低但登录涉及账号安全适合企业级爬虫个人学习项目不建议越界。import time import random def polite_sleep(base1.5, jitter0.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, headersheaders, timeout10) 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(stripTrue)) 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 fhttps://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 fhttps://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) # 游记数位于 classnums 的 span 中 travel_count None nums_span soup.find(span, class_nums) if nums_span: travel_count nums_span.get_text(stripTrue) # 美食标签数位于 classfood-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 }游记数提取中classnums的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 0re.search(r\d, str(raw))匹配字符串中第一段连续数字适合处理类似“1.2k 篇游记”这种格式提取出 1200。这里不用\d的原因是需要考虑千分位逗号所以直接用\d只能匹配数字串如果原始文本是“1,234”会被截断为 1不完美但够用。4.4 数据合并与导出Pandas 的横向连接所有城市的详情数据爬完后需要把城市 ID、城市名、游记数、美食标签数合并到一个 DataFrame 中。这里用pd.merge做内连接oncity_id指定连接键避免不同省份下同名城市互相干扰。# 假设 city_df 是包含 city_name 和 city_id 的城市基础表 # detail_df 是包含 city_id 和 travel_count 的详情表 merged_df pd.merge(city_df, detail_df, oncity_id, howinner) 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, ascendingFalse).head(10) print(top_cities[[city_name, travel_count]]) # 导出到 Excel merged_df.to_excel(mafengwo_city_data.xlsx, indexFalse, engineopenpyxl)howinner内连接保证两边都有记录的城市才保留避免空数据行进入排行。apply(clean_count)对每行数据执行清洗函数把带单位的字符串转成纯数字。sort_values(travel_count, ascendingFalse)排序后取前 10得到热门城市排行。to_excel的engineopenpyxl是 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_optsopts.InitOpts(width900px, height600px, themelight)) .add_xaxis(cities) .add_yaxis(游记数, values, label_optsopts.LabelOpts(positionright)) .reversal_axis() .set_global_opts( title_optsopts.TitleOpts(titletitle), yaxis_optsopts.AxisOpts(name城市), xaxis_optsopts.AxisOpts(name游记数), ) ) return bar.render(top_cities_bar.html)reversal_axis()将柱状图从垂直翻转成水平positionright让数值标签显示在柱条右侧避免标签被柱条遮挡。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_pairdata, radius[35%, 60%], label_optsopts.LabelOpts(formatter{b}: {d}%) ) .set_global_opts( title_optsopts.TitleOpts(titletitle), legend_optsopts.LegendOpts(orientvertical, pos_top15%, pos_left2%) ) ) 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(maptypechina, itemstyle_optsopts.ItemStyleOpts( border_color#111, area_color#f0f0f0 )) .add( series_name热度, data_pairdata, type_GeoType.EFFECT_SCATTER, symbol_size8 ) .set_series_opts(label_optsopts.LabelOpts(is_showFalse)) .set_global_opts( title_optsopts.TitleOpts(titletitle), visualmap_optsopts.VisualMapOpts(max_max([v for _, v in data])) ) ) return geo.render(city_heatmap.html)maptypechina需要下载中国地图 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;" />
网站建设高端定制企业官网