2026艺龙酒店数据爬取实战:Python接口分析与反爬应对 2026年做艺龙酒店数据爬取和早几年最大的区别不是技术难度而是页面结构调整得更频繁、接口包装更严实如果你拿几年前的代码直接跑90%的情况会卡在登录态校验或者接口签名上。这篇文章我准备把自己在2026年实际踩过坑之后整理出来的整套Python方案完整拆开讲清楚包含页面分析、接口定位、字段清洗、翻页和处理反爬的完整链路目标是让即使只写过几十行爬虫的读者也能在本地把艺龙酒店的基础数据拉下来。先说明边界我不会写任何破解、绕过风控、恶意抓取私有数据的内容。下面用到的方法只针对酒店商圈、名称、价格、评分这类公开可见的数据并且最终用途限定为学习分析和个人低成本研究频率控制在很低的水位。如果是商用量级的数据需求请先联系平台获取合法接口或遵守对应服务条款。1. 项目整体设计与前置认知1.1 这个项目到底解决什么问题2026年艺龙的页面已经不只有传统的列表页和详情页很多城市会优先展示“促销卡片”“直播入口”“地图聚合”等模块直接用最外层HTML解析很难稳定拿到统一格式的酒店列表。更靠谱的做法是找页面内部XHR请求返回的JSON接口。这个思路适用于任何“看起来是正常网页但内容总是晚半拍出现”的站点。我做这个项目主要是为了分析几个热门旅游城市的酒店供给密度和价格分布不需要登录不需要模拟下单只需要拿到每个商圈里有哪几家酒店、最低起步价、评分、酒店星级和大致位置。这样的数据用来做区域对比、出行预算预估或者观察季节性调价已经足够了。1.2 合规意识要放在代码前面爬虫这个动作本身是中性技术但如果使用方式越界性质就完全不一样。我这里默认你也是用来做个人学习所以我建议几条硬性规则只爬公开可见的数据不碰需要账号体系才能看到的记录。不抓取任何能定位到具体用户的评论等信息不拼装个人画像。控制单次运行规模全程只取前几页数据做验证或轻量分析。请求频率要远低于正常用户浏览水平失败次数多就立即停止自查。很多新手拿到项目就想去刷全量城市这种思想要不得。轻则IP暂时受限重则给目标服务器带来额外负担没有任何好处。1.3 技术栈选择为什么还是 Python选择Python不是因为它能做惊天动地的事情而是因为在2026年它的库生态依然是对个人开发者最友好的。本项目用到的东西不多requests发HTTP请求拿JSON或HTML内容。parsel 或者 BeautifulSoup4解析HTML结构。json处理接口返回的结构化数据。sqlite3 或 csv保存数据。time、random控制请求节奏。retrying 模块或者自己写重试逻辑避免单次网络抖动导致任务中断。有人纠结“requests是否已经没落了”其实在普通爬虫场景里只要你不需要浏览器渲染requests 依然是体积最小、最容易理解的选择。Scrapy 更适合分布式爬取体系完整但针对一个城市、几个页面的中小任务灵活度反而不如直接写30行核心逻辑来得舒服。我保留的是使用函数式组织代码的思路后面你真要扩展到Scrapy抽出去也方便。2. 核心细节解析页面接口怎么找、字段怎么挖2.1 第一步不是写代码而是打开开发者工具不管你爬什么网站第一个要养成的习惯是打开 Chrome DevTools 的 Network 面板然后手动在页面上操作一次观察触发流程。以艺龙城市酒店的列表页为例你输入目的地按下搜索鼠标往下滚动时页面会分批加载酒店数据。大概率会看到几个以list、search、query这类关键词命名的XHR请求返回内容是JSON文本里面就藏着你想要的酒店信息。我习惯先勾选Network面板里的“Fetch/XHR”过滤项把这个页面发起的所有接口请求看一遍然后再逐个查看响应内容。只要看到某个请求的Response里出现hotelName或者类似字段基本就是主力接口了。不要一开始就尝试从返回的HTML源码里找正则因为2026年的前端框架迭代速度很快CSS类名带有编译后的哈希值今天分析出来的提取规则明天接口改个版本整个提取器就会失效。而围绕JSON接口做提取代码通常能撑得久一些。2.2 接口URL和参数拆解当我锁定一个返回酒店数据的XHR请求之后会在Headers面板里看它的请求URL和Query String Parameters。这类参数通常包含destinationId或cityIdcheckIn / checkOut 日期pageNo 或 pageIndexpageSizesort类型经纬度等行为参数大多数情况下你能直接修改pageNo和pageSize实现翻页。有些接口会带一个签名参数sign可能是根据城市代码、时间戳和某个固定私钥拼接后取哈希得到。遇到这种签名算法时如果只是公开的页面请求签名往往是从JS文件里取固定串生成的你可以跟进去找到生成规则的入口但如果签名是动态下发的并且需要登录态配合那就不要继续研究了说明对方明确设置了访问门槛。我自己实际碰到过一种比较头疼的情况签名规则藏在非常深的混淆JS文件里单从代码层面根本没有办法一眼看出来。后来我发现翻页按钮点击后接口签名只在URL末尾有几个时间戳字段说明签名验证只在部分入口强制生效于是改用正常浏览器里的请求复制成 cURL 初步调试。这样一个一个参数去掉找到起关键作用的那个字段。这个方法不算讨巧但排查定位速度是真快。2.3 请求头与Cookie的正确理解很多教程会让新手做一件事把浏览器的所有请求头原封不动放进requests里甚至把Cookie也硬编码在代码中。这种做法的坏处是你的脚本一旦过期就必须手动更新而且硬编码Cookie很容易让服务器看到“请求头完整但行为模式完全不像真人”的操作。我的建议是第一版调试时可以从浏览器复制完整请求先确保能够成功拿到JSON。拿到数据之后再把请求头精简到必要项。通常下面几个字段足够User-Agent标识客户端环境。Referer有些接口会校验来源页面。Accept告诉服务端你能接收什么格式。Cookie首次请求列表页会写入一些必要标记但尽量通过requests.Session自动维护。使用Session时代码如下import requests session requests.Session() session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Accept: application/json, text/plain, */*, Referer: https://hotels.elong.com/ }) session.get(https://hotels.elong.com/, timeout10)这个GET请求很重要它能让服务端认为你访问过首页并为Session种下一些必要的Cookie。后面你再请求列表接口成功率会高很多。不要直接把浏览器的Cookie硬编码进代码除非你完全理解这个字段的生命周期。2.4 解析返回的数据结构接口返回的JSON结构在不同城市不同版本下会有差异但通常有一个顶层字段包着列表。常见的是类似response.data.hotelList或者直接就是hotelInfos。这里我以实际中比较常见的结构为例。第一层可能是{ code: 0, data: { searchResult: { hotelList: [ { hotelId: 123456, hotelName: XX酒店, star: 5, score: 4.7, address: XX区XX路, minPrice: 299.0, lat: 39.92, lng: 116.45 } ] } }, message: success }你在写解析代码时不要写诸如 resp.json()[data][searchResult][hotelList] 这样一沉到底的索引因为任何一个中间节点缺失就会让脚本崩溃。最好把每个层级逐步取出来判空或者封装一个get_value的辅助函数。这是普通爬虫和稳定爬虫的区别。def safe_get(data, path, defaultNone): for key in path: if isinstance(data, dict): data data.get(key) if data is None: return default else: return default return data这个函数用起来非常顺手比如hotel_list safe_get(resp_data, [data, searchResult, hotelList], [])如果字段路径调整只要改数组里的字符串即可解析逻辑不会因为某一层为空就抛出异常。2.5 翻页与去重逻辑艺龙列表页的翻页逻辑很多情况下不是传统“下一页”按钮而是滚动分页。滚动分页对应的XHR请求一般也有page参数。你只需要找到当前页返回的total或totalCount算出大概的页数。但注意不建议为了拉全量而把自己的请求频率故意拉得很高。我在做查询时通常限制只读取前10页到20页每页20到30条。这个体量已经足够做城市层面的粗略分析。对于更底层的“整站全量”那应该是平台自己的数据团队该做的事普通个人脚本没必要也没有技术基础去做那种体量的事。去重主要依赖hotelId。有些酒店在列表里会重复出现比如带有促销卡片的酒店会在普通列表之外再出现一次。我在数据写入前会增加一层判断只保留第一次出现的hotelId。3. 实操过程与核心代码实现3.1 开发环境准备2026年了开发环境已经不复杂Python 3.11以上都可以我日常用的是3.12.x。先创建一个虚拟环境避免依赖污染系统环境python -m venv elong_env source elong_env/bin/activate # Windows下执行 elong_env\Scripts\activate然后安装依赖pip install requests parsel pandas如果你不用pandas做数据分析可以只装requests和parsel。我加装pandas主要是后期做一些价格排序和分组统计比较方便。如果你只是想要一个轻量demo跳过pandas也不影响。3.2 一个简易的接口请求样例下面是我整理过的一份可以直接跑通演示的代码框架。请把URL和参数替换成你自己电脑上通过F12确认到的真实请求这里的URL只能作为教学格式直接跑可能已经因为接口变更而失效。import requests import json import time import random def get_hotel_data(city_code, page_no1): session requests.Session() session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Accept: application/json, text/plain, */*, }) # 先访问首页让Session拿到初始Cookie try: session.get(https://hotels.elong.com/, timeout10) except requests.RequestException: pass # 这个params内容务必根据你自己的实际请求替换 params { cityId: city_code, checkIn: 2026-05-20, checkOut: 2026-05-21, pageNo: page_no, pageSize: 20, sort: default, } resp session.get(https://hotels.elong.com/search/list, paramsparams, timeout15) if resp.status_code ! 200: print(f请求失败: {resp.status_code}) return None return resp.json()这里有两点经验要交代。第一请求头不要一开始就堆得过于复杂因为很多服务器判断反爬不只依赖请求头是否完整更依赖行为轨迹。第二带了Session去维护Cookie以后代码重跑成本就低很多即便Cookie很快过期也只需要重新跑一遍首页请求。3.3 字段提取与价格单位处理价格字段是爬虫项目里经常出现歧义的地方。有人看到价格值是29900直接当成整数存进去了等分析的时候才发现价格暴高。真实原因通常是单位是“分”而不是“元”。所以清洗逻辑里必须包含一个约定def normalize_price(raw_price): 将常见的价格单位统一成元保留两位小数。 如果已经是元为单位则原样返回。 判断依据数值如果大于10000大概率是以分为单位。 但这种推断并不绝对最好还是观察真实返回字段名。 if raw_price is None: return None try: raw_price float(raw_price) except (TypeError, ValueError): return None if raw_price 10000: return round(raw_price / 100, 2) return round(raw_price, 2)这里“价格大于10000就除以100”只是我习惯性的判断不是硬性标准。你拿到数据以后一定要看字段名和真实值之间的关系如果字段名带cent字段那就一定是分如果有amount字段通常已经是元。不要指望一个通用规则解决所有平台问题。3.4 数据存储CSV还是SQLite对个人分析项目来说CSV是最方便的输出格式可以直接拖进Excel、pandas或者BI工具里。但如果单次抓取量级上千或者你想增量更新多次SQLite会更好。原因是CSV反复写入时要处理文件追加、表头重复、类型丢失等问题SQLite天然支持去重和查询。我的选择是双输出默认保存到SQLite清洗之后再导出一份便于人工翻看的CSV。核心的表结构可以这样设计CREATE TABLE IF NOT EXISTS hotel_data ( hotel_id TEXT PRIMARY KEY, hotel_name TEXT, star TEXT, score REAL, address TEXT, min_price REAL, city TEXT, check_in TEXT, check_out TEXT, crawl_time TEXT );这里hotel_id作为主键天然实现去重。后面你多次运行脚本时重复出现的酒店不会让数据表膨胀到失控。实测下来几千条数据规模下SQLite的读写速度都是毫秒级完全够用。3.5 数据处理主流程主流程我建议串起来五个子步骤城市输入、翻页循环、解析清洗、去重入库、退出打印。下面是一段完整的主逻辑示例import sqlite3 import time import random from datetime import datetime def init_db(db_pathelong.db): conn sqlite3.connect(db_path) conn.execute( CREATE TABLE IF NOT EXISTS hotel_data ( hotel_id TEXT PRIMARY KEY, hotel_name TEXT, star TEXT, score REAL, address TEXT, min_price REAL, city TEXT, check_in TEXT, check_out TEXT, crawl_time TEXT ) ) return conn def insert_hotel_data(conn, hotel, city, check_in, check_out): cursor conn.execute( INSERT OR REPLACE INTO hotel_data (hotel_id, hotel_name, star, score, address, min_price, city, check_in, check_out, crawl_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?), ( str(hotel.get(hotelId)), hotel.get(hotelName), hotel.get(star), hotel.get(score), hotel.get(address), normalize_price(hotel.get(minPrice)), city, check_in, check_out, datetime.now().strftime(%Y-%m-%d %H:%M:%S), ) ) return cursor.rowcount def crawl_pages(city_code, city_name, check_in, check_out, max_pages10): conn init_db() for page in range(1, max_pages 1): print(f开始抓取 {city_name} 第 {page} 页) data get_hotel_data(city_code, page) hotel_list safe_get(data, [data, searchResult, hotelList], []) if not hotel_list: print(f第 {page} 页无数据提前结束) break for hotel in hotel_list: insert_hotel_data(conn, hotel, city_name, check_in, check_out) conn.commit() time.sleep(random.uniform(1.5, 3.5)) conn.close()这段代码里有几个小心思值得解释。第一CONN处理放在函数内每一页提交一次事务防止中途断电丢数据。第二插入用INSERT OR REPLACE源数据代码会自动覆盖掉旧记录如果当天价格变了重跑一遍就能更新价。第三每次翻页等待时间在1.5到3.5秒之间随机这是为了模拟正常人类的浏览节奏。这个等待策略不要做得过于机械比如固定sleep(2)反而容易被识别出自动化痕迹。关于多城市扩展我的建议是不需要使用并发。老老实实写一个循环每个城市跑完后sleep个5到10秒效果比一上来就开30个线程强得多。你真的需要规模化之前先问问自己这个数据是不是有非拿不可的正当价值。3.6 日志和错误重试爬虫跑起来最难受的不是没数据而是跑到中途网络闪断脚本直接白屏退出数据丢一半。我后来在代码里增加了一个非常轻量的重试装饰器效果很好import functools import time def retry(max_attempt3, wait_seconds2): def decorator_func(func): functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(max_attempt): try: return func(*args, **kwargs) except Exception as e: print(f执行出错: {e}, 第 {attempt 1} 次重试) time.sleep(wait_seconds * random.random()) return None return wrapper return decorator_func其实系统也有现成的retrying库但针对这种小脚本自己写一个装饰器灵活度更高不需要引入额外依赖。使用方法就是在get_hotel_data上面加一行retry(max_attempt3, wait_seconds2) def get_hotel_data(city_code, page_no1): ...这样网络闪断或者接口偶发5xx脚本能自己续命而不是直接把任务挂掉。3.7 日志文件建议跑爬虫最怕没有记录。我一般会新建一个log目录每天写入一个文本日志记录成功页数、失败页数、总耗时和最后一次请求的时间。这个习惯在排障的时候能省很多时间比如你第二天发现数据只跑了一半日志里直接能看到是某页停留在长时间没有响应。4. 常见问题与排查技巧实录4.1 返回列表一直是空这种情况最常见的原因有几种。第一你请求的接口需要额外的参数没有传全。第二签名校验失败后服务端不报错只是静默返回空数组。第三第一次进入页面时页面里设置的Cookie缺失。排查步骤通常如下在浏览器Network面板里手动操作一次找到正常请求和你的脚本请求之间的差异。把浏览器里的请求复制成 curl在命令行跑一遍如果能拿到数据就说明你的代码有参数或Header漏了。用diff去对比浏览器请求和脚本请求优先检查URL查询参数里的动态时间戳。还有一种空列表情况是日期没有了房态所导致的。比如目标酒店在checkIn与checkOut日期之间满房列表接口可能直接把你当前页面变成空。解决方式是换个酒店密集的市中心城市测试或者改变日期到周末试试。4.2 收到验证码或滑块很多个人爬虫项目会栽在验证码这一关。如果你被弹验证码大概率不是因为你技术不行而是请求频率或者请求特征太像机器。验证码破解是不推荐的方向因为已经属于主动对抗平台风控技术风险和法律风险都会急剧上升。我处理验证码的态度只有一个降频等一段时间再继续。比如动态将sleep时间提高到3到6秒运行页数降到3页以内然后观察是否恢复正常。如果依然被拦截就不要再硬试了第二天再继续。做数据分析永远不差一晚上时间。4.3 页面结构变化导致字段解析不出来2026年艺龙的接口版本迭代比前几年更频繁如果今天写的解析逻辑明天跑不通了先不要慌。进入Network面板重新看一遍返回的JSON找字段名是否有变化。我遇到过多次hash字段变化比如hotelName直接改成了extendInfo.hotelName而原来的hotelId没有变。这时候修改safe_get路径列表里的表示即可五分钟内就能修复。还有一个抗结构变化的技巧不要只按照字段名映射可以写一个候选字段名列表。比如获取酒店名时依次尝试hotelName、name、hotel_name、extendInfo.namedef extract_hotel_name(hotel): for key in [hotelName, name, hotel_name]: val hotel.get(key) if val: return val # 进一步尝试嵌套字段 extend_info hotel.get(extendInfo) if isinstance(extend_info, dict): return extend_info.get(name) return None这个办法算不上高级但在真实项目中经常能让你少改不少代码。4.4 经纬度坐标和区域聚合不准酒店列表返回的经纬度通常是火星坐标系或者更偏门的地方坐标和标准地图使用的坐标系可能不一致。如果你拿原始经纬度直接扔到地图上可视化很容易出现几十到几百米的偏移看起来像酒店漂移到了马路对面。解决方案是坐标转换库Python里可以用 coord_convert 这类包来做。但如果你的分析只到商圈粒度坐标偏不偏不重要可以直接基于返回的商圈ID和商圈名称做聚合。4.5 重复数据很多重复数据的来源主要是多页请求过程中的酒店排序变化。第一页尾部出现的酒店可能因为排序实时更新到了第二页头部这样就会跨页重复出现。hotelId主键能解决入库层面的重复但如果你的CSV只是单纯append不经过数据库去重那就可能重复。所以我最终都把入库环节作为必经之路从数据库再导出CSV这样永远只保留一份。4.6 运行过程中内存占用飙高我的项目里曾经跑了一个包含大量图片URL返回的接口整个JSON文本非常大。如果每页返回的酒店对象里还包含几十张图片列表即使我不解析那些字段只要res.json()已经加载了全部内容内存一样会被撑起来。这种情况的解决办法是直接用 session.get(..., streamTrue) 读取响应流然后对较大的JSON使用逐层解析。不过更省事的方案是请求时看接口URL里能否通过参数关闭图片信息很多搜索列表接口都支持fieldsbase或者imagefalse这种裁剪参数在正式跑之前多看一眼接口文档或params定义会让数据量小很多。5. 常用扩展与后续分析建议5.1 价格区间和热度关系分析拿到酒店数据之后建议先做一轮粗粒度分析。用pandas读取数据库import sqlite3 import pandas as pd conn sqlite3.connect(elong.db) df pd.read_sql_query(SELECT * FROM hotel_data, conn) conn.close() print(df[city].value_counts()) print(df.groupby(city)[min_price].agg([median, mean, max, min]))这类结果对于判断一座城市不同区域的基础住宿成本非常直观。比如中心城区中位数价格高、郊区价格低同时有一些低价高评分酒店这才是真正的性价比选择。你不需要做高级模型几个groupby已经能说明很多问题。5.2 酒店星级与评分关系观察星级评定和用户评分在很多平台经常出现不对等现象。你拿数据画个散点看看到底哪些区域有“低星高口碑”的宝藏酒店也能帮你反过来验证页面搜索排序的逻辑。这个分析不用很复杂但是能给项目增加一些后续价值。5.3 从一次抓取演进到每周快照如果你只跑一次拿到的是一个时间截面意义很有限。更有意思的是每周跑一次相同城市组保存快照然后对比价格中位数和可订房量变化。这时候你的hotel_id主键和crawl_time字段就能派上用场可以直接通过WHERE查询上次和本周的同一批酒店的价格变化。这种增量对比是很多商旅决策工具最基础的数据来源也是个人能完成的低成本自动化。要注意的是跑每周快照之前仍然要先确认接口能正常访问因为平台接口可能随时调整。你控制不了平台变化只能控制自己每次运行都会实时确认接口最新状态并且不要把抓取结果发布成“实时权威数据”。6. 一些我踩过坑后沉淀的体会做这类爬虫项目代码其实只占很少的一部分工作量。真正耗时间的是接口分析、数据校验、异常处理和数据期限管理。我个人在实操中的经验是先拿一个城市的样例数据跑通完整链路再用日志记录输出不要一上来就全量多城市并发否则你看到的不是技术问题而是一大堆被限流后的错误日志根本无从下手。另外写代码的时候顺手把requests调用、解析函数、存储函数拆成独立函数前端以后接口万一变了你只需要修改解析部分而不会牵一发动全身。我现在回头看自己早期写的项目所有逻辑都压在一个main文件里每次排错都要满屏幕滚动体验极差。重构成模块化之后排障时间差不多缩短了一半以上。最后再分享一个小技巧在你正式跑完整项目之前先把同一页请求多试几次记录状态码和耗时看看有没有偶发失败然后设计好稳定的重试策略再挂机跑数据。爬虫不怕慢最怕无脑跑。控制好频率、保持谦逊的请求姿态这个数据工程跑下来你会比单纯照抄任何教程都更有收获。