news 2026/9/14 2:14:56

Python旅游景点爬虫实战:去哪网景点数据采集与源码设计解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python旅游景点爬虫实战:去哪网景点数据采集与源码设计解析

简介:这是一套基于Python开发的去哪网旅游景点爬虫源码项目,适合Python爬虫初学者及旅游数据分析者。项目以去哪网为目标站点,通过get.py与app.py两个核心脚本完成景点信息的抓取、解析与整理,并输出为结构化表格数据,便于后续分析与使用。压缩包共40个文件,包含29个xlsx数据文件、5个xml配置/数据文件、2个py主程序、1个说明文档及工程文件等,整体大小约1.56MB,轻量易部署。目前已有349人学习过该资源。文件中涵盖中国各省市及日、美、法、德、澳、非、欧、亚等多地景点信息,按景区与地区分类存放,目录结构清晰。通过该源码可以了解爬虫请求、页面解析、数据存储的完整流程,并可直接模仿或扩展为自己所需的旅游景点采集工具。

1. 去哪网景点爬虫,先看页面再看接口

拿到“基于Python的旅游景点爬虫(去哪网)设计源码”这个需求,很多人第一反应是去逆向去哪儿网的Web API,恨不得从JS里捞出加密参数来。实际上我处理过的OTA站点里,去哪网属于典型的“页面直出数据重、接口数量少”的类型,景点列表页第一次请求返回的HTML里,就已经躺着景点名、评分、热度、门票价和点评数这些核心字段。真正麻烦的从来不是拿不到数据,而是字段错位、翻页丢失和连续请求被风控这三件事。

这篇文章就把一套能落地的设计和源码写法捋清楚:先拆数据流,再给最小可跑的解析代码,然后讨论并发取舍,最后收在清洗去重和真实排错上。适合刚把Python环境装好、想认真写爬虫的人,也适合已经跑过简单爬虫、但觉得去哪网这类站点总是差点意思的工程师。Python安装和requests爬虫这两项基础默认你会,中间涉及的版本选择我会顺手说明。

2. 去哪网景点爬虫的数据流拆解:页面、接口与请求头伪装

2.1 去哪网景点数据的四种载体与优先抓取顺序

写爬虫先判断数据在哪,再谈怎么写。去哪网景点频道的数据大致有四个去处:HTML直出、XHR异步接口、渲染后DOM、图片和地图瓦片。这个判断直接影响代码结构,如果一上来就盯着Network面板里那些带签名参数的XHR接口,多半会被耗掉大量时间。

我一般在抓任何页面之前会做一次快速实验:用requests直接GET目标列表页,把返回的HTML存成文件,然后grep关键词,比如景点名称、“评分”这类字样,看它们是不是已经出现在原始HTML里。出现,就走BeautifulSoup解析路线;不出现,才考虑Selenium或Playwright走浏览器渲染路线。去哪网景点列表属于前者,70%到80%的字段第一次请求就到位了。

数据载体和解析成本的关系可以按下面这张表来理解,这也是我在团队里给新人讲爬虫原理时固定会摆的一张表:

数据载体识别方式解析成本去哪网景点场景
HTML直出查看源代码即可搜到字段文本低,BeautifulSoup或lxml列表页核心字段
XHR异步返回DevTools里XHR过滤,响应是JSON中,直接json.loads翻页、分页数据
渲染后DOM源代码搜不到,页面里却有高,需无头浏览器地图控件、部分评论
图片/瓦片无法直接取文本高,需OCR或坐标换算不适合做结构化数据源

2.2 用DevTools在XHR里锁定真正的翻页接口

列表页里放不下全部景点,翻页时看URL参数的变化,是判断后端接口最直接的入口。去哪网的翻页交互有代表性:一部分是整页刷新,URL带着页码参数;另一部分在列表底部滚动加载,XHR返回JSON片段,页面JS再把片段渲染成卡片。

做法是打开Chrome DevTools的Network面板,勾选XHR过滤,然后手动点击下一页,观察新增请求。凡是响应里带景点名、评分数值的请求,优先看它的Headers和Payload。Page参数、offset参数、或者一个类似listId的东西,往往就是翻页游标。还要注意一个坑:这类接口经常要求请求头里带Referer,Referer必须是上一次的列表页地址,否则返回的JSON可能是空数组。

这里给一段用于基线探测的代码,目的不是直接跑出全量数据,而是验证“请求头怎么带、接口认不认”:

import requests s = requests.Session() s.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0 Safari/537.36", "Accept": "application/json, text/plain, */*", "Accept-Language": "zh-CN,zh;q=0.9", "Referer": "https://piao.qunar.com/", # 以站点当前实际路径为准 }) resp = s.get("https://piao.qunar.com/ticket/list_xxxxx.html", timeout=10) print(resp.status_code, resp.encoding, len(resp.text))

这段代码的关键点有三个:用Session而不是裸requests.get,是为了让后续的Cookie自动携带;Accept-Language里zh-CN放前面,避免服务端返回繁体或英文版;timeout给10秒而不是默认值,去哪网这类站点慢响应是常态。跑通后看status_code是不是200、text长度是不是有几十KB,基本能判断头部配置是否被接受。

2.3 请求头伪装与会话保持的最小配置

在2.2的代码里已经出现了UA和Referer,这里把整套请求头伪装逻辑补完整。去哪网对UA的校验比较严格,不带浏览器UA的Python默认UA会被直接重定向到首页,这是爬虫新手最常见的“403但浏览器没事”的原因之一。处理方式就是完整模拟浏览器的Header集合,而不是只塞一个UA。

除UA外,Accept-Encoding建议去掉或者设成较小的集合,因为一旦开启gzip压缩,返回内容在requests里会自动解压,这本身没问题,但调试时想直接看文本反而多一层转换。Cookie方面,第一次GET时服务端会下发一个标识会话的Cookie,用Session保持就好,不需要手工从浏览器复制进代码,那样反而容易过期。

一个需要明确的边界:这里只讲请求头伪装,不涉及任何绕过验证机制的方案。去哪网在检测到异常频率后,会出现访问频率限制或人机验证页面,这是站点正常的经营防御手段,业务上有合规要求,技术上也应该尊重。遇到这种响应,正确做法是停手,拉长间隔,而不是研究怎么绕。后面的章节里我给出的所有代码,都默认遵守这个底线。

3. 用requests+BeautifulSoup实现去哪网景点爬虫的最小可跑版本

3.1 先把依赖装齐,版本不要追求最新

这套源码的依赖只有三个:requests、beautifulsoup4、lxml。pandas是可选的,如果只是存CSV,标准库csv就够用。安装命令在Python 3.8及以上环境里直接执行即可:

pip install requests beautifulsoup4 lxml

装完后在代码里验证一次解析引擎是否正常:

from bs4 import BeautifulSoup soup = BeautifulSoup("<div class='sight'><a>外滩</a></div>", "lxml") print(soup.select_one(".sight a").text)

这里指定lxml作为解析器,是因为官方默认的html.parser在解析去哪网这种大量标签未闭合的页面时容易丢节点。lxml对残缺HTML的容错更好,速度也更快。如果你的环境里lxml安装失败,用html.parser也能跑,但解析结果的稳定性会差一些。

3.2 列表页解析:定位景点节点、翻页与字段抽取

去哪网景点列表页的HTML结构里,每个景点通常是一个class名中带sight的容器节点,景点名、评分、地址、门票价格都在这个节点内部,只是层级不同。完整的解析代码可以是这样:

import time import random import csv import requests from bs4 import BeautifulSoup HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml", "Accept-Language": "zh-CN,zh;q=0.9", } def fetch_page(session, url, retries=3): for attempt in range(retries): try: resp = session.get(url, headers=HEADERS, timeout=10) if resp.status_code == 200: return resp.text except requests.RequestException as e: print(f"[retry {attempt+1}] {e}") time.sleep(attempt * 2 + 1) return None def parse_list(html): soup = BeautifulSoup(html, "lxml") items = [] for block in soup.select(".sight_item"): name = block.select_one(".name") score = block.select_one(".score") grade = block.select_one(".grade") if name is None: continue items.append({ "name": name.get_text(strip=True), "score": score.get_text(strip=True) if score else "", "grade": grade.get_text(strip=True) if grade else "", }) return items

fetch_page里的退避逻辑是随手写的小细节:第一次失败等1秒,第二次等3秒,第三次等5秒,每次递增,主要用来应对瞬时网络抖动,而不是用来对抗封禁。parse_list里对每个字段都做了空值保护,因为景点列表页里并非每个卡片都有评分或等级标签,直接取属性会抛AttributeError。

翻页的写法就是循环拼接页码参数,关键是每页之间加入随机延时:

def run(session, base_url, start_page, end_page, out_path): with open(out_path, "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=["name", "score", "grade"]) writer.writeheader() for page in range(start_page, end_page + 1): url = base_url if page == 1 else f"{base_url}?page={page}" html = fetch_page(session, url) if not html: continue rows = parse_list(html) writer.writerows(rows) print(f"page={page}, rows={len(rows)}") time.sleep(random.uniform(1.5, 3.5))

CSV写入用utf-8-sig编码,这个是刻意选择:标准utf-8写出的CSV在Excel里打开中文会乱码,utf-8-sig带BOM头,Excel直接双击就能正确识别。延时区间1.5到3.5秒是一个温和的水位,既能完成中小体量数据采集,也不至于对目标站点造成压力。这个参数在后面的并发章节还会继续讨论。

3.3 详情页兜底:把列表页缺掉的描述和经纬度补齐

列表页给了名字、评分和等级,但景点的长描述、经纬度、建议游玩时长这些字段,往往只在详情页出现。去哪网的详情页URL有规律可循,一般是在列表页的景点链接基础上做拼接。因为不同城市页面结构不一致,最稳妥的方式是解析列表页里链接的href属性,把它当作详情页的相对路径。

def parse_list_with_url(html): soup = BeautifulSoup(html, "lxml") items = [] for block in soup.select(".sight_item"): link = block.select_one("a.name") if link is None: continue items.append({ "name": link.get_text(strip=True), "url": link.get("href", ""), }) return items

拿到详情页URL后,抓取逻辑和列表页是同一套思路,只是解析选择器不同。最常见的问题不是抓不到,而是详情页的某些字段通过异步加载,直接GET源码里没有,比如点评数量、周边景点推荐。遇到这种情况我一般不继续深挖接口,而是先确认业务上是否真的需要这个字段。爬虫最忌讳的是为了一个次要字段去逆向整套异步逻辑,投入产出比极低。

3.4 落盘CSV:先保证不丢数据,再考虑落库

很多人在这个阶段就想上MySQL或者MongoDB,但中小体量爬虫的最佳存储起点就是CSV或者SQLite。CSV的好处是简单、可读、后续用pandas清洗方便;坏处是并发写入时会有文件锁竞争。所以在3.2的示例里,写入逻辑是主进程单线程完成的,即便详情页抓取用了并发,最终写盘依然回到串行,这是后面章节要强调的设计思想。

def main(): session = requests.Session() session.headers.update(HEADERS) run(session, "https://piao.qunar.com/ticket/list_xxx.html", 1, 5, "sights.csv") if __name__ == "__main__": main()

main函数只有四行,但它把前面所有的组装点聚到一起了。这里有一个容易被忽略的点:请求头在Session级别更新一次,后续所有请求自动带上,就不需要在fetch_page里重复传headers。而fetch_page里又保留了一次显式的headers传参,这是为了灵活覆盖某些接口需要不同Referer的情况。

4. 去哪网景点爬虫的并发设计:线程池、限速与断点续爬的取舍

4.1 去哪网为什么不能无脑开高并发

聊到爬虫并发设计到底哪个好的时候,最常见的答案其实是“看目标站点”。去哪网这类OTA站点对爬虫的敏感度远高于普通博客站,高并发能把单机从每秒几十请求打到每秒几百请求,随之而来的就是人机验证、IP临时限制,甚至是整段IP段被拉黑。对于景点数据这种更新频率不高的冷数据,低并发慢跑反而是更专业的做法。

我的原则是第一版永远串行,等串行验证了字段和存储都没有问题,再考虑并发。串行代码的错误栈简单,字段对应关系直观,排错成本最低。而且去哪网景点列表页和详情页的关系是一对多:一个城市几十个景点,一个景点一个详情页,详情页之间相互独立,这个结构天然适合并发,但需要的并发水位其实非常低。

4.2 三种并发方案的取舍:线程池、协程与Scrapy

Python爬虫的并发方案常见的就三种:requests配合ThreadPoolExecutor、aiohttp配合asyncio协程、以及直接上Scrapy框架。三者的对比关系用一个表讲清楚:

方案写法成本适合场景去哪网适配度主要风险
requests+ThreadPoolExecutor低,改造量小中小体量、IO密集推荐,够用线程数控制不当易触发风控
aiohttp+asyncio中,全套异步大规模、高吞吐性能溢出调试复杂,库兼容性坑多
Scrapy高,框架学习成本分布式、长期维护可以用但偏重中间件配置复杂,上手慢

ThreadPoolExecutor最贴合“去哪网景点爬虫设计源码”这个场景,因为它的改造是局部性的,列表页串行逻辑不动,只需要把详情页请求丢进线程池。这也是requests爬虫从单线程升级并发的标准路径。协程方案虽然吞吐更高,但requests本身是同步库,不能直接跑在异步函数里,必须换aiohttp,属于推倒重写级别的改动,对小项目不划算。

4.3 用Semaphore压住并发水位并加指数退避重试

给详情页请求加并发之前,必须先做两件事:限流和退避。限流用Semaphore来压线程数,退避用指数增长的重试间隔来应对偶发失败。参考实现如下:

import concurrent.futures import threading import time semaphore = threading.Semaphore(4) detail_results = [] def fetch_detail(session, name, url): with semaphore: for attempt in range(3): try: resp = session.get(url, timeout=10) if resp.status_code == 200: detail_results.append((name, len(resp.text))) return except requests.RequestException: pass time.sleep(2 ** attempt) # 指数退避:1s, 2s

注意两点:Semaphore(4)的意思是最多同时有4个线程在抓详情页,这个数字不是拍脑袋定的,而是根据“串行延时2秒左右、单IP安全水位”反推出来的一个保守值;指数退避的间隔是1秒和2秒,第二次重试后如果还是失败就放弃,避免线程卡在坏URL上。

并发抓取的顺序是打乱的,所以收集结果时不能用简单的list.append然后直接写文件,因为多线程append是不安全的。正确做法是在主线程里统一汇总,或者给append加锁。上面的代码里,detail_results.append放在with semaphore的临界区内,Semaphore本身也承担了部分锁的职责,但严格来说还是应该单独加threading.Lock,这里贴的是便于理解的示意写法。

4.4 断点续爬:把进度落成文件,重启不推倒重来

并发加上了,万一程序跑了一半崩了或者被风控拦截,重新来过成本很高。断点续爬的做法很简单:把已经成功抓取的景点名或详情页URL记到一个文本文件里,每次启动时先加载这个集合,已经存在的URL直接跳过。

import os def load_done(path="done.txt"): if not os.path.exists(path): return set() with open(path, "r", encoding="utf-8") as f: return set(line.strip() for line in f) def mark_done(path, name, url): with open(path, "a", encoding="utf-8") as f: f.write(f"{url}\t{name}\n")

done.txt每行一条记录,用\t分隔URL和名称,既当进度用,也当备份用。比另外引入Redis或数据库做去重轻盈得多。这里的思路和分布式爬虫里的去重中间件完全同构,只是规模不同:小项目一个文件就够了,上了分布式才需要布隆过滤器一类的组件。对于本身就是中小体量的去哪网景点项目,这个文件方案是我最常用的。

断点续爬代码虽短,但承载了一个重要设计习惯:让爬虫本身变成可中断、可恢复的无状态任务。这个设计后续要接定时调度、要接增量更新,都不用改架构,只要把运行阶段和更新阶段分开就可以。

5. 给爬虫源码收尾:数据清洗、去重合并与连续被挡后的排查顺序

5.1 字段清洗:把“4.6分”和“¥60起”变成结构化数值

爬下来的原始字段没法直接用。评分的值是“4.6分”,价格的显示是“¥60起”,地址里混着括号注释。用pandas做一轮清洗是最快的:

import pandas as pd df = pd.read_csv("sights.csv") df["score"] = df["score"].str.replace("分", "", regex=False).astype(float) df["grade"] = df["grade"].str.extract(r"(\d+)").astype("Int64") df["price"] = df["price"].str.extract(r"(\d+(?:\.\d+)?)").astype(float)

str.extract配合正则提取数字,比replace更稳,因为原始价格字段可能带着各种前后缀。Int64而不是普通int类型,是为了容忍缺失值。这两年常有人争论AI是不是爬虫技术的更深层次运用,但一个很朴素的共识是:模型再高级,也会被“4.6分”这种半结构化文本直接绊倒,清洗这步省不掉。

5.2 数据去重与合并的兜底逻辑

翻页过程中可能遇到同一个景点出现在两个分类下,爬完需要去重。不能只按名称去重,因为“外滩”和“上海外滩”在原始数据里是两条。常见做法是用两个键做兜底:URL唯一键优先,名称做二次识别。URL去重直接沿用第四章的set方案,名称去重则可以做一个简单的别名归一化,比如去掉“景区”“公园”这类后缀再去重。这个阶段不要追求完美,保证同一个URL的记录不重复出现即可,跨名称合并留给业务侧人工判断。

5.3 请求被连续拦截的定位顺序

连续被拦的时候不要瞎调参数,按下面的顺序排查,每走一步都验证一次:

症状定位方法常见处理
第一请求就403或重定向看响应头里的Set-CookieSession保持Cookie
前几页正常,翻页之后弹验证页检查触发前后的请求间隔加大随机延时,降低并发水位
返回200但解析结果为空存HTML到本地,对比浏览器源码检查选择器,确认页面结构未变
详情页字段缺失看Network里该字段是否来自XHR判断字段是否为必需,必要时放弃

最后一层校验写一个断言脚本,随机抽CSV里10条记录,去浏览器无痕模式下比对页面值,任何一条不一致就停止下一轮抓取。把这个脚本挂进定时任务,数据就一直处于被验证的状态,而不是爬取成功但数据早已畸形的自欺阶段。去哪网景点爬虫做到这一步,这套设计源码才真正算能交作业。

本文还有配套的精品资源,点击获取

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

Claude Code 配 TaoToken:从源码看 AI Agent 的权限与上下文设计

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

作者头像 李华
网站建设 2026/9/14 2:14:22

嵌入式软件架构设计:分层事件驱动(LEDA)实战指南

1. 为什么“堆代码”是嵌入式开发最隐蔽的慢性毒药&#xff1f;你有没有过这样的经历&#xff1a;一个STM32项目&#xff0c;初期功能跑通很快&#xff0c;LED亮了、串口吐数据了、ADC采样也准——但三个月后&#xff0c;新加一个CAN总线收发功能&#xff0c;改了三处头文件&am…

作者头像 李华
网站建设 2026/9/14 2:14:04

毕业论文AI生成靠谱吗?这些坑提前知道

打开电脑&#xff0c;看到导师发来的消息&#xff1a;"初稿周三前给我看看。"再看看文档里那个写了三天的标题&#xff0c;这种感觉我太熟悉了。于是很多人把目光投向了AI——毕业论文AI生成靠谱吗&#xff1f;说实话&#xff0c;工具本身没毛病&#xff0c;但用法不…

作者头像 李华
网站建设 2026/9/14 2:12:13

动态双变异鲸鱼差分算法DLMWOADE原理与实现

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

作者头像 李华