news 2026/10/26 8:45:53

OTA景点数据采集清洗与可视化:静态页判定与字段兜底实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
OTA景点数据采集清洗与可视化:静态页判定与字段兜底实战

简介:面向计算机与数据分析专业学生及Python爬虫初学者,这份去哪儿网旅游数据爬取与可视化分析资料以毕业设计为主线,梳理从网页结构分析、模拟登录、反爬应对到数据存储、清洗、统计与图表呈现的完整流程。压缩包内含1个PDF文件,大小约979KB,按任务分析、设计实施、作品总结与参考文献组织,便于分章节查阅。已有2770人学习,正文涉及requests、BeautifulSoup、Scrapy、MySQL/SQLite、Pandas、NumPy/SciPy以及matplotlib、seaborn、plotly等工具,并展示景点区域与等级分布、月销量同价格和评分关系等可视化案例。读者可参考其字段解析、数据导入、缺失值与异常值处理及多类型图表设计,理解旅游数据项目从采集到展示的实现思路,用于毕业设计选题、课程项目搭建和数据分析流程复盘。

1. 2264 条原始记录、1823 条有效数据:一条 OTA 景点数据链路

2264 条原始记录,清洗完只剩 1823 条,被删掉的 441 条里有相当一部分压根不在目标城市——它们只是标题里带了城市名,实际地址挂在邻省景区名下。旅游数据爬取与可视化分析这类项目最容易被低估的地方就在这里:抓取只是入口,真正决定图表能不能看的是清洗口径和取数 SQL。这套链路走的是 requests + BeautifulSoup 抓列表页与详情页,pandas 落 Excel,pymysql 进 MySQL,最后用 pyecharts 出八张图,覆盖景点区域分布、等级分布、区域与月销量、等级与月销量、销量与价格、销量与评分、名称词云。适合正在做课程设计、想把「采集 → 入库 → 清洗 → 出图」整条链跑通的人,也适合代码已经跑通、但图表结论站不住、想回头补数据质量这一课的人。

2. 拆页面与定字段:静态页判定、10 个字段映射与依赖边界

动手写代码之前,值得花半天把页面摸清楚。摸错的代价很大:明明服务端渲染的页面被判定成动态页,于是绕路上浏览器自动化,采集速度掉一个数量级,稳定性还差;该抓的字段没定好兜底规则,跑完 151 页回来发现价格列一半是空串,入库时整张表插不进去。这一章把判定方法、字段映射表和依赖版本三件事一次说清。

2.1 三步判定静态页,别急着上浏览器

第一步看源码。浏览器里按 Ctrl+U 打开原始 HTML,搜索一个你知道一定出现在列表里的景点名。源码里有,说明数据是服务端直出的;源码里没有、只有一堆 script 引用,才需要考虑动态渲染。这一步能筛掉大部分误判。

第二步看网络面板。F12 打开 Network,筛选 XHR 或 Fetch,然后点下一页。如果翻页过程中出现返回 JSON 的接口,说明列表数据走的是异步接口,直接请求那个接口比解析 HTML 省事;如果翻页只是整页文档刷新、没有新的 XHR,说明还是服务端渲染。

第三步做对照实验。用 requests 裸请求一页,打印len(resp.text),再和浏览器里「查看网页源代码」保存下来的文件大小比一比。长度接近、关键字段名能在文本里in到,基本可以确定 requests + BeautifulSoup 就够了。这个项目就是这条路:列表页的景点名、区域、热度、地址、简介、等级、最低价、月销量全部嵌在 HTML 里,详情页首屏也带评分和评论数。

提示:判定阶段别用「页面看起来会动」当依据。轮播、悬浮卡片、懒加载图片都会让页面「看起来动态」,但目标字段该在哪儿还在哪儿。

2.2 十个字段的映射表与缺失兜底

字段不是抓到就行,得先想清楚每个字段在哪个页面、用什么结构定位、抓不到的时候填什么。这张表是后面所有代码的依据,也是排查时第一个要对照的东西。

序号字段所在页定位方式缺失时填什么
1景点名称列表页h3 标签,类名 sight_item_caption必存在,取不到则整条跳过
2区域列表页span,类名 area,形如 [城市·城市·区名]空串,后续清洗会删
3热度列表页信息块内第一个 em 标签0
4地址列表页p 标签文本,去掉「地址:」前缀空串
5整体评价列表页div,类名 intro color999空串
6等级列表页span,类名 level,值为 3A/4A/5A空串,代表无等级
7最低价格列表页价格区块内 span 下的 em空串,入库转 NULL
8月销量列表页销量区块内 span 下的 em空串,入库转 NULL
9用户评分详情页span,id 为 commentscore 类结构空串
10评论个数详情页span,类名 commentCount空串

表里第 6 到第 8 项是坑最多的地方。无等级景区在页面上根本没有 level 节点,没有报价的景点也不渲染价格区块,如果这三个字段和名称写在同一个 try 里,一次异常就会把整条记录丢掉——151 页跑完可能少几百条。正确的做法是每个字段单独 try,取不到就返回空串,让「缺失」变成数据里的一个值,而不是一次中断。空串在入库前再统一转成 NULL,这样 INT 列不会因为收到''直接报错。

区域字段的格式要特别留意,它带着方括号和分隔符,形如[城市·城市·区名]。后续清洗和出图都要基于区名做分组,所以要么在爬取时就剥掉前后缀,要么在 SQL 或 pandas 里用正则抽。我一般倾向保留原始值入库、在聚合层再剥,理由是原始串能反查页面结构有没有变。

2.3 依赖清单与版本边界:pyecharts 1.x 和 0.5 是两套 API

这个项目的依赖不多,但有两个版本边界必须提前说清,否则代码抄过去直接跑不起来。

包用途注意点
requests发请求2.x 均可,重点是 timeout 参数别省
beautifulsoup4解析 HTML配 lxml 解析器更快,但 html.parser 无需额外安装
pandas表格处理与合并2.x 写 .xls 需额外引擎,建议统一 .xlsx
openpyxl读写 xlsxpandas 写 xlsx 的默认引擎
pymysql连 MySQL参数名用 password,旧的 passwd 仍兼容但不建议
pyecharts出图1.x 与 0.5 的类路径完全不同,本文按 1.x 写
tqdm进度条详情页循环建议加上,否则不知道跑到哪了

pyecharts 的版本问题值得单独提一句。0.5 时代Funnel、Line这些类直接从pyecharts顶层导入,图表配置靠**kwargs传;1.x 之后全部挪到pyecharts.charts下,配置项统一走opts。你在网上搜到的示例如果写着from pyecharts import Funnel,那就是 0.5 的写法,混着抄必然ImportError。判断方法很简单:pip show pyecharts看版本,1.x 的示例一律是from pyecharts.charts import Funnel加from pyecharts import options as opts。

pandas 那边也有个隐性坑。老代码习惯to_excel("comments.xls"),在 pandas 2.x 上会因为缺少 xlwt 引擎报错。改文件名后缀为.xlsx就行,代价几乎为零,还能避开 xls 单表 6 万行的老限制。至于 pymysql 写入时numpy.int64不被识别的问题,放到落库那一章处理,这里先记住「从 pandas 里拿出来的数,别直接往 SQL 里塞」。

3. 采集层三个函数:151 页翻页、详情页补字段与两表合并

整条采集链路可以压缩成三个函数加一个主循环:一个负责把页面拿回来,一个负责从列表页解析出九列,一个负责进详情页补两列,主循环负责翻页、控速、合并、落盘。函数拆得清楚,出问题时能立刻定位是「没拿到」还是「没解析出来」,不用在两百行的大函数里靠打印猜。

3.1 get_one_page:请求头、超时与编码自适应

先看取页面的部分。这段代码看着简单,但三个参数决定它在 151 页规模下稳不稳:超时、重试、编码。

import time import random import requests BASE = "https://example-ota.test" # 目标站点域名,按实际替换 def get_one_page(url, retry=3, timeout=10): """取单页 HTML,失败重试,编码自适应""" headers = { "User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/104.0.0.0 Safari/537.36"), "Accept-Language": "zh-CN,zh;q=0.9", } for i in range(retry): try: resp = requests.get(url, headers=headers, timeout=timeout) if resp.status_code == 200 and len(resp.text) > 2000: # 不写死 utf-8,也不盲信页面声明,交给 requests 推断 resp.encoding = resp.apparent_encoding return resp.text print(f"[warn] status={resp.status_code} len={len(resp.text)} url={url}") except requests.RequestException as e: print(f"[retry {i + 1}] {url} -> {e}") time.sleep(1 + i) # 退避 1s、2s、3s return ""

timeout=10是必须给的,requests 默认不超时,遇到慢响应会把整个循环卡死,151 页里卡一次你就得手动重启。retry=3配合递增退避能吃掉大部分偶发失败,比一次性失败就崩要省心。

编码那行是重点。很多教程直接写resp.encoding = "utf-8",而目标站点实际返回的是 gbk 编码,写死 utf-8 的结果是中文全变问号或乱码,看着像解析失败其实是编码错了。apparent_encoding会从响应内容里猜编码,比写死可靠得多。另外我加了len(resp.text) > 2000这个长度判断,是为了挡住「状态码 200 但返回的是验证页或空壳」的情况——这种响应拿去解析只会得到零条数据,还不会报错,比直接失败更难查。

3.2 parse_page:把选择器抽出来,逐字段 try

解析函数是整个采集层最容易写崩的地方。一个常见写法是for item in soup.find_all("div", {"class": "result_list"})[0]直接遍历容器子节点,这行代码在页面结构稳定时能用,但只要中间多插一个装饰性 div,遍历出来的东西就全乱了。更稳的做法是先定位到每个景点卡片,再在卡片内部找字段。

import re import pandas as pd from bs4 import BeautifulSoup from urllib.parse import urljoin COLUMNS = ["名称", "区域", "热度", "地址", "整体评价", "等级", "最低价格", "月销量", "网址"] def parse_page(html, page): """解析列表页,返回本页 DataFrame""" rows = [] soup = BeautifulSoup(html, "html.parser") box = soup.find("div", {"class": "result_list"}) if box is None: print(f"[warn] 第 {page} 页未找到结果容器,跳过") return pd.DataFrame(columns=COLUMNS) for item in box.find_all("div", {"class": "sight_item"}): info = item.find("div", {"class": "sight_item_info"}) if info is None: continue name_node = item.find("h3", {"class": "sight_item_caption"}) if name_node is None: # 名称是主键,取不到直接丢 continue name = name_node.get_text(strip=True) area = info.find("span", {"class": "area"}) area = area.get_text(strip=True) if area else "" hot = info.find("em") hot = hot.get_text(strip=True) if hot else "0" addr = info.find("p") addr = addr.get_text(strip=True).replace("地址:", "") if addr else "" intro = info.find("div", {"class": "intro color999"}) intro = intro.get_text(strip=True) if intro else "" level = item.find("span", {"class": "level"}) level = level.get_text(strip=True) if level else "" # 无等级留空 price_node = item.select_one("span.sight_item_price em") price = price_node.get_text(strip=True) if price_node else "" sales_node = item.select_one("span.hot_num") sales = sales_node.get_text(strip=True) if sales_node else "" link = item.select_one("a[href]") url = urljoin(BASE, link["href"]) if link else "" if not url: continue rows.append([name, area, hot, addr, intro, level, price, sales, url]) print(f"第 {page} 页解析到 {len(rows)} 条") return pd.DataFrame(rows, columns=COLUMNS)

这段代码有几个刻意的设计。select_one和find都做了 None 判断,取不到就返回空字符串,让这条记录仍然能留住——无等级、无报价的景点是很常见的,不是脏数据。名称是唯一不能缺的字段,缺了这条记录没有任何意义,所以直接continue。网址同理。

urljoin(BASE, link["href"])比字符串拼接可靠。详情页链接在页面里可能是相对路径/ticket/detail/xxx.htm,也可能是//域名/...这种协议相对写法,直接加号拼接会拼出畸形 URL,而urljoin会按标准解析。后面详情页解析失败的锅,有一部分就是拼接拼出来的。

select_one("span.sight_item_price em")用的是 CSS 选择器,比嵌套find_all加下标干净。价格和销量藏在卡片右下角的一个浮层里,如果用find_all("div", {"class": "sight_item_pop"})[0]这种写法,一旦某条记录没有浮层就抛索引越界,整页解析直接中断。

3.3 get_score_content:详情页两层嵌套与空值返回

详情页只需要补两个字段,但它的结构比列表页深一层:外层一个描述容器,内层一个评论块,评分和评论数各在一层。写的时候要保证「哪怕两个字段都拿不到,也把网址返回来」,否则后面的 merge 会因为缺 key 对不齐。

def get_score_content(url, html): """从详情页取评分与评论数,失败返回空串而不是抛异常""" result = [url, "", ""] if not html: return result try: soup = BeautifulSoup(html, "html.parser") block = soup.find("div", {"class": "mp-description-detail"}) if block is None: return result comment = block.find("div", {"class": "mp-description-comments"}) score_node = comment.find("span", {"id": "mp-description-commentscore"}) if comment else None if score_node: result[1] = score_node.get_text(strip=True) count_node = block.find("span", {"class": "mp-description-commentCount"}) if count_node: # 页面上常带「条评论」后缀,只留数字 txt = count_node.get_text(strip=True) m = re.search(r"\d+", txt) result[2] = m.group() if m else "" except Exception as e: print(f"[warn] 详情页解析失败 {url} -> {e}") return result

三层判空是必要的:容器层缺失、评论块层缺失、字段节点层缺失,任何一层没兜住都会抛AttributeError。最后那个re.search(r"\d+")是为了把「1234 条评论」洗成纯数字,否则入库到 INT 列会失败——这类「页面上看着是数字、实际带单位」的字段,在清洗阶段是最常见的报错来源之一。

评分和评论数不一定同时有。有些景点有评论但没显示平均分,有些反过来。允许单字段为空,比要求两个都齐再落库要合理。

3.4 主循环:range(1, 152)、随机间隔与 merge 对齐

主循环把上面三块串起来,做两件事:翻页抓列表,再对每条的详情页 URL 补字段,最后按 URL 左右合并。

def main(): all_pages = [] for page in range(1, 152): # 站点共 151 页 url = f"{BASE}/ticket/list.htm?keyword=%E4%B8%8A%E6%B5%B7&page={page}" html = get_one_page(url) if not html: continue df = parse_page(html, page) if not df.empty: all_pages.append(df) time.sleep(random.uniform(1.0, 3.0)) # 页间隔,避免请求过于规律 base_df = pd.concat(all_pages, ignore_index=True) base_df = base_df <p> <a href="https://download.csdn.net/download/Cherry_JJ/86858890" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 8:45:13

金融AI大模型安全测试:自动化越狱与防御策略

1. 项目概述&#xff1a;大模型安全测试的攻防博弈在金融行业全面拥抱AI大模型的当下&#xff0c;安全团队面临一个严峻挑战&#xff1a;如何系统性地评估大模型的防御脆弱性&#xff1f;传统渗透测试方法在面对具有复杂推理能力的AI系统时显得力不从心。我们开发的这套自动化越…

作者头像 李华
网站建设 2026/10/26 8:44:51

T3 Stack实战:打造类型安全的代码片段管理工具

最近在做一个团队内部的代码资产沉淀工具&#xff0c;需要把平时散落在各处&#xff08;聊天记录、临时文件、个人笔记&#xff09;的常用代码片段统一管理起来&#xff0c;既要登录、又要分类、还要能一键复制。为了快速出成果&#xff0c;我选了当下很成熟的一套全栈技术组合…

作者头像 李华
网站建设 2026/10/26 8:44:37

为对话式AI搭建持久化记忆系统:claude-mem设计与实现

如果你经常和对话式AI打交道&#xff0c;一定会遇到一个很磨人的场景&#xff1a;昨天刚聊完的项目背景、用户画像、技术选型&#xff0c;今天新开一个会话&#xff0c;它全忘了&#xff0c;你得把同样的背景再科普一遍。聊得越多&#xff0c;这种“AI健忘症”就越让人抓狂。cl…

作者头像 李华
网站建设 2026/10/26 8:44:26

从零搭建T3 Stack全栈项目:t3code代码片段管理工具实战

最近折腾了一阵子&#xff0c;总算把一个代号叫t3code的全栈项目从零跑通了。t3code 这个名字&#xff0c;拆开看其实有两层意思&#xff1a;“T3”对应的是 T3 Stack&#xff0c;也就是目前 TypeScript 社区里非常流行的 Next.js TypeScript tRPC Prisma Tailwind 技术组合…

作者头像 李华
网站建设 2026/10/26 8:44:25

C#+HALCON视觉检测系统源码拆解:配置化工位与采集检测链路

简介&#xff1a;这是一套面向机器视觉开发者与学习者的 C# 结合 HALCON 完整检测软件源代码&#xff0c;围绕工件表面质量检测展开&#xff0c;可用于理解工业视觉系统从图像采集到结果判定的实现路径&#xff0c;也适合作为课程设计、项目原型与二次开发的基础参考。压缩包共…

作者头像 李华
网站建设 2026/10/26 8:43:51

YOLO目标检测工程全流程实战指南

1. YOLO工程生命周期全景解析在计算机视觉领域&#xff0c;YOLO(You Only Look Once)目标检测算法因其出色的实时性能而广受欢迎。但一个完整的YOLO工程从构思到落地&#xff0c;远不止训练模型那么简单。根据我多年实战经验&#xff0c;完整的生命周期包含以下关键环节&#x…

作者头像 李华