Python旅游景点爬虫实战:去哪网景点数据采集与源码设计解析 📅 发布时间:2026/9/14 2:14:59 👁 浏览次数: 简介这是一套基于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的东西往往就是翻页游标。还要注意一个坑这类接口经常要求请求头里带RefererReferer必须是上一次的列表页地址否则返回的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;q0.9, Referer: https://piao.qunar.com/, # 以站点当前实际路径为准 }) resp s.get(https://piao.qunar.com/ticket/list_xxxxx.html, timeout10) 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. 用requestsBeautifulSoup实现去哪网景点爬虫的最小可跑版本3.1 先把依赖装齐版本不要追求最新这套源码的依赖只有三个requests、beautifulsoup4、lxml。pandas是可选的如果只是存CSV标准库csv就够用。安装命令在Python 3.8及以上环境里直接执行即可pip install requests beautifulsoup4 lxml装完后在代码里验证一次解析引擎是否正常from bs4 import BeautifulSoup soup BeautifulSoup(div classsighta外滩/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/xhtmlxml, Accept-Language: zh-CN,zh;q0.9, } def fetch_page(session, url, retries3): for attempt in range(retries): try: resp session.get(url, headersHEADERS, timeout10) if resp.status_code 200: return resp.text except requests.RequestException as e: print(f[retry {attempt1}] {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(stripTrue), score: score.get_text(stripTrue) if score else , grade: grade.get_text(stripTrue) 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, encodingutf-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(fpage{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(stripTrue), 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 三种并发方案的取舍线程池、协程与ScrapyPython爬虫的并发方案常见的就三种requests配合ThreadPoolExecutor、aiohttp配合asyncio协程、以及直接上Scrapy框架。三者的对比关系用一个表讲清楚方案写法成本适合场景去哪网适配度主要风险requestsThreadPoolExecutor低改造量小中小体量、IO密集推荐够用线程数控制不当易触发风控aiohttpasyncio中全套异步大规模、高吞吐性能溢出调试复杂库兼容性坑多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, timeout10) 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(pathdone.txt): if not os.path.exists(path): return set() with open(path, r, encodingutf-8) as f: return set(line.strip() for line in f) def mark_done(path, name, url): with open(path, a, encodingutf-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(分, , regexFalse).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条记录去浏览器无痕模式下比对页面值任何一条不一致就停止下一轮抓取。把这个脚本挂进定时任务数据就一直处于被验证的状态而不是爬取成功但数据早已畸形的自欺阶段。去哪网景点爬虫做到这一步这套设计源码才真正算能交作业。本文还有配套的精品资源点击获取