简介:这是一套基于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 itemsfetch_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-Cookie | Session保持Cookie |
| 前几页正常,翻页之后弹验证页 | 检查触发前后的请求间隔 | 加大随机延时,降低并发水位 |
| 返回200但解析结果为空 | 存HTML到本地,对比浏览器源码 | 检查选择器,确认页面结构未变 |
| 详情页字段缺失 | 看Network里该字段是否来自XHR | 判断字段是否为必需,必要时放弃 |
最后一层校验写一个断言脚本,随机抽CSV里10条记录,去浏览器无痕模式下比对页面值,任何一条不一致就停止下一轮抓取。把这个脚本挂进定时任务,数据就一直处于被验证的状态,而不是爬取成功但数据早已畸形的自欺阶段。去哪网景点爬虫做到这一步,这套设计源码才真正算能交作业。
本文还有配套的精品资源,点击获取