简介这是一份面向携程酒店数据采集场景的Java爬虫项目源码包适合正在学习网络爬虫、Java工程实践或酒店信息管理的开发者参考。工程以Eclipse项目形式组织包含可运行的核心抓取模块、依赖库、运行参数配置和简要说明能够帮助读者理解从目标页面请求、HTML数据解析到结果落地保存的完整链路。压缩包共34个文件主体为23个Java源文件另有4个jar依赖包、2个txt说明文件以及properties、classpath、prefs等工程配置包体仅1.59MB下载与导入都非常轻量目录分区清晰便于快速定位核心代码与依赖资源。目前已有666人学习使用尤其适合想自主搭建携程酒店数据抓取工具、或基于现有工程做二次扩展的开发者通过外部配置文件可按需调整采集类型与目标字段减少重复编码工作量是一份可直接借鉴的爬虫项目范例。1. 爬取携程酒店数据CTripSpider.zip别被“爬虫”两个字骗了真正的坑在数据清洗和反爬外人眼里爬取携程酒店数据CTripSpider.zip就是把一个压缩包解压、跑一个Python脚本、Excel里自动蹦出几千行酒店数据。实际上做过的人都知道把携程酒店数据爬干净工作量的大头根本不在“爬”而在接口鉴权、字段解析和入库去重这三件事上。打包成CTripSpider.zip只是最后一步——把能复用的爬虫、清洗脚本和配置文件收拢成一个可以直接交付的工程包。这个方向适合谁做竞品比价、区域酒店供给分析、旅游行业研究的开发者或者想系统学习“怎么把一个单页爬虫扩展成可维护的数据管道”的人。如果你只是想临时拿一次数据那直接去点网页复制粘贴可能更快但如果你要的是“每个月稳定跑一轮、数据能直接进分析模型”这篇笔记就是按这个目标写的。2. 先把链路想清楚从携程页面到本地结构化数据的五个环节2.1 为什么大多数携程爬虫会“能跑但不敢用”——先解决数据源和接口的选型问题携程的酒店数据页面肉眼看到的是房价、评分、点评数、酒店名但这只是前端渲染后的结果。直接去解析HTML你会死得很惨原因有两个一是页面是服务端渲染加客户端异步混合的你拿到的HTML里往往只有骨架房价和房型是后续AJAX请求填进去的二是页面结构不定期调整今天能用的XPath下周可能就失效。所以做携程酒店数据采集第一步不是写爬虫而是打开浏览器开发者工具找到背后真正返回JSON数据的接口。我一般会在Network面板里筛选XHR请求然后按“hotel”“list”“price”这样的关键词去过滤。常见的做法是城市列表页有一个接口返回酒店摘要点进详情页后又有一个接口返回房型和价格。你只需要找到这两个核心接口把请求头、参数名和返回结构记录下后面所有工作都围绕这两个接口展开。爬虫写得再花哨数据源选错了也是白搭——你爬的是携程想让你看到的页面不是携程数据库里的原始记录。2.2 静态请求和动态渲染的取舍Requests加Selenium的混合方案比纯Selenium靠谱得多很多初学者一上来就上Selenium觉得“模拟浏览器总不会错”。但Selenium的代价是每个页面都要等完整渲染一个城市几百家酒店跑下来可能要几个小时。而且Selenium打开的浏览器指纹特征明显更容易触发风控。更合理的做法是先用Requests直接请求JSON接口拿到数据只有在接口参数被加密、或者某些字段必须依赖JS计算时才上Selenium。实际项目中我大概90%的请求是纯Requests完成的只有登录态刷新那一步偶尔需要Selenium辅助。# 核心请求逻辑先尝试纯Requests失败再降级到Selenium import requests def fetch_hotel_list(city_id, page_no, session): # 模拟携程接口的请求头Referer必须带否则多半会返回校验页 headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://hotels.ctrip.com/hotels/list, Accept: application/json, } params { cityId: city_id, pageNo: page_no, pageSize: 20, # 这个字段的具体取值要参考实际接口不同城市可能不同 sort: recommend, } try: resp session.get(https://m.ctrip.com/restapi/h5/hotel/search, headersheaders, paramsparams, timeout10) resp.raise_for_status() return resp.json() except (requests.exceptions.Timeout, requests.exceptions.ConnectionError): return None这段代码里session需要在程序启动时统一创建避免每次请求都重新握手。timeout设10秒是防止某个城市接口卡住时整个任务被拖死。注意cityId不是城市拼音也不是页面URL里的数字它是携程内部的城市编码需要单独维护一张城市映射表这个后面会专门讲。2.3 拿到JSON只是开始把接口返回的嵌套结构“捋平”携程接口返回的JSON嵌套深度大概有四五层。外层是data里面是hotelList每个酒店对象里又有baseInfo、priceInfo、commentInfo这样的子对象。直接把这个JSON存进数据库当然可以但分析的时候你会疯掉——你要统计“哪个星级酒店最多”难道每次查询都去JSON里翻半天所以第二步是写一个摊平函数把嵌套的子对象抽成扁平的列。这里要注意子对象里的字段名可能有重复比如commentInfo和baseInfo里都有score字段一个是评分一个是服务质量分摊平时必须加前缀区分。# 把嵌套JSON摊平成单层字典便于后续写进DataFrame def flatten_hotel_json(hotel): base hotel.get(baseInfo, {}) price hotel.get(priceInfo, {}) comment hotel.get(commentInfo, {}) flat { hotel_id: hotel.get(hotelId), hotel_name: base.get(hotelName), # 加前缀避免和评论里的score字段冲突 base_score: base.get(score), comment_score: comment.get(score), comment_count: comment.get(commentCount), min_price: price.get(minPrice), address: base.get(address), star: base.get(starRating), } return flat加前缀这个习惯非常重要。我在早期项目里吃过这个亏两个score字段混在一起一张报表里的评分数据全乱了排查了半天才发现是字段名冲突。另外minPrice的单位在不同接口版本里可能不一样有的是元有的是分清洗时统一转成元并标注单位来源能省掉后面无数的麻烦。3. 搭建CTripSpider的运行环境与目录结构减少“能跑”和“能维护”之间的落差3.1 项目目录的划分脚本、配置、数据、日志四个目录不能省一个典型的CTripSpider.zip解压后目录结构应该一眼能看懂。我的习惯是分四个目录spiders放爬虫脚本config放JSON或YAML配置文件output放最终导出的Excel和CSVlogs放运行日志。这看起来是小事但项目跑一个月后你要回头排查某天数据为什么少了没有日志目录你会崩溃。# config/config.yaml 核心配置都在这里不用每次改代码 city_ids: - 0 # 填真实城市ID这里只是占位 - 0 request: timeout: 10 retry_times: 3 sleep_range: [1.5, 3.5] # 请求间隔单位秒太短容易被封 output: file_prefix: ctrip_hotel_ format: xlsx配置文件和代码分离是给“非程序员”使用者留的活路。团队里做数据分析的人不一定懂Python但他们能打开YAML改城市ID和输出路径。这比让他们改代码里的硬编码靠谱得多。3.2 城市ID映射表整个爬虫的“地基”但很少有人一开始就重视携程的城市ID不是连续的也不是按拼音排序的。有的城市ID是六位数有些是五位数而且携程App和PC端用的城市ID体系还可能不一样。最头疼的是如果你用错误的城市ID去请求接口不会报错而是返回一个空列表——这时候你很难分辨是“这个城市真没酒店”还是“ID错了”。所以我的做法是在config目录下维护一个city_map.csv字段是city_name, city_id, pinyin, level。第一次建立这个表时可以用携程的搜索建议接口把热门城市跑一遍然后人工抽查。后续如果发现哪个城市的数据异常先查这张表八成是ID映射错了。# 用curl快速验证城市ID是否有效注意替换成真实ID curl -X GET https://m.ctrip.com/restapi/h5/hotel/search?cityIdxxxpageNo1pageSize5 \ -H User-Agent: Mozilla/5.0 \ -H Referer: https://hotels.ctrip.com/hotels/list \ -o test_response.json # 返回JSON里hotelList有数据说明城市ID可行返回空列表大概率ID有问题这个验证命令应该放在项目README里作为“拿到压缩包后的第一件事”。很多人在这一步省事直接用网上下载的ID表结果跑到中后期才发现数据稀疏的城市全是错的。提前花一晚上把城市ID捋清楚能让你后面省一个礼拜的排查时间。3.3 运行入口脚本把“爬取—清洗—导出”串成一条完整的流水线我不建议把爬取、清洗、导出分开手动执行因为过程一长中间任何一步出错你都得重来。更稳妥的做法是写一个run.py依次调用三个模块并且在每步之间做数据量校验——如果爬取阶段拿到了5000条原始记录清洗后不应该只剩不到3000条如果差值太大程序应该主动停下来提示异常而不是默默生成一份残缺的Excel。# run.py 主入口控制整个流程 from spiders.hotel_spider import HotelSpider from processors.cleaner import DataCleaner from exporters.excel_exporter import ExcelExporter def main(): spider HotelSpider() raw_data spider.crawl_all_cities() # 数据量校验比预期的3000条少太多就直接退出 if len(raw_data) 3000: raise SystemExit(爬取数据量异常疑似接口变动或IP被限制) cleaner DataCleaner() cleaned_data cleaner.clean(raw_data) exporter ExcelExporter() exporter.export(cleaned_data) if __name__ __main__: main()这段代码里的3000是一个预估阈值你得根据自己实际抓的城市数量和酒店密度去调。校验这步属于“被坑过才会加”的防护逻辑——我有一次就是没做校验输出了一张看起来挺完整的Excel结果三个城市的酒店数据因为接口参数错误全丢了下游分析的人拿这张表做了两周报告才发现问题。4. 把爬下来的数据清洗成“能直接分析的样子”Excel入库前的取舍与排雷4.1 字段清洗的统一规则空值、重复、单位换算一次做完爬虫拿到的是原始数据清洗才是真正体现功夫的环节。比如价格有的接口返回的minPrice是分有的是元有的返回0表示“暂无报价”而不是真的免费。再比如评分携程有些评分是4.5分制有些是5分制不统一处理后面统计一平均结果就完全失真。我习惯把所有清洗逻辑集中在一个DataCleaner类里而不是散落在各处。清洗顺序是固定的先处理缺失值再做去重最后做单位换算和类型转换。# processors/cleaner.py 统一清洗流程 import pandas as pd class DataCleaner: def clean(self, raw_df): df raw_df.copy() # 1. 删除完全重复的行基于酒店ID判断 df df.drop_duplicates(subsethotel_id, keepfirst) # 2. 价格单位统一为元原始数据如果是分就除以100 if df[min_price].max() 1000: df[min_price] df[min_price] / 100 # 3. 空评分的行不删但标记出来避免统计时被当成0 df[comment_score_missing] df[comment_score].isna() # 4. 星级字段映射成可读文本 star_map {1: 经济型, 2: 舒适型, 3: 高档型, 4: 豪华型, 5: 奢华型} df[star_level] df[star].map(star_map) return df去重时用hotel_id而不是酒店名因为重名的酒店太多了用名字去重会误删。价格单位换算那边用max() 1000来判断是一个治标不治本的办法——如果某天酒店均价真超过了1000元这个判断就会失效。更稳妥的是在爬取阶段就把单位信息带回来。4.2 存储格式的选择为什么我最终选了Excel而不是数据库很多人一上来就上MySQL或者SQLite但对于CTripSpider这种规模的项目我是反对的。原因很简单大部分下游使用者运营、分析、老板他们要的是Excel文件而不是连数据库的权限。拿几百MB的数据库文件去交付对方根本不知道怎么办。Excel的好处是即开即用还能加筛选和透视表。坏处是写入慢、单表行数有限制虽然现在最新版已经是百万级了但数据一多打开和公式计算还是会卡。我的建议是Excel只放最终结果中间过程数据落CSV这样万一Excel挂了还有备份。已经跑了几十次实践下来这个策略还没出过问题。4.3 导出Excel时的几个格式细节列宽、冻结窗格、日期格式导出一份Excel如果格式设得太随意拿到手也是要骂人的。至少要保证三点字段顺序稳定不要今天A列是价格明天A列变成评分数值列保留两位小数第一行是统一的字段名而不是爬虫里的原始字段。# exporters/excel_exporter.py import pandas as pd def export(df, output_path): # 调整列顺序把核心字段放在最前面 column_order [hotel_name, city_name, star_level, min_price, comment_score, comment_count, address, hotel_id] df df[column_order] # 如果输出文件已存在覆盖前先做备份 if os.path.exists(output_path): os.rename(output_path, output_path .bak) with pd.ExcelWriter(output_path, engineopenpyxl) as writer: df.to_excel(writer, sheet_name酒店数据, indexFalse) # 拿到工作表和列名设置列宽和冻结窗格 ws writer.sheets[酒店数据] for col_idx, col_name in enumerate(df.columns, 1): max_len max(df[col_name].astype(str).str.len().max(), len(col_name)) 2 ws.column_dimensions[chr(64 col_idx)].width min(max_len, 40) ws.freeze_panes A2备份文件那个逻辑是真踩过坑才加的——某次运行中断输出的Excel半截是旧数据半截是空的又覆盖了原本好的文件找不回来。从那以后每次导出前都先备份旧文件。冻结窗格和列宽是给非技术同事看的体面这个细节在交付时极其加分。5. 爬取携程酒店数据的避坑清单这5个坑我基本都踩过提前告诉你5.1 接口返回正常但字段全为空来源是选择了“错误的接口版本”现象请求返回的HTTP状态码是200JSON结构也对但hotelList里的酒店对象关键字段全是None或者空字符串。原因携程不同端App、H5、PC对应的接口地址不同参数体也可能不同。你很可能用H5端的参数去请求了PC端的接口或者反过来。接口没报错但它不认识你传的参数只能返回空壳数据。解决在Network面板里仔细确认你复现的是哪个端的接口把cityId、pageNo这些参数从入参到出参完整对比一遍。另外User-Agent必须和接口端匹配——用App的UA去请求PC接口行为特征太明显容易被当成伪造请求。5.2 爬到一半被断连请求频率太规律触发了风控现象前几百条数据正常然后突然连续出现超时错误信息里出现类似“连接被重置”或者“拒绝访问”的字样。原因你的循环实现里每次请求间隔都一样。携程的风控系统检测到固定间隔的请求序列会直接判定为机器操作并在某个阈值后把你的IP临时关进小黑屋。解决在每次请求之间加入随机间隔而不是固定sleep(2)。我用的是随机范围1.53.5秒取整到毫秒。顺便不要用多线程疯狂并发对反爬系统来说短时间内从同一个IP发出几十个并发请求是最高危的信号。import random import time # 随机睡眠避免请求节奏过于规律 def random_sleep(): interval random.uniform(1.5, 3.5) # 参数可调别小于1秒 time.sleep(interval)这个参数不建议再调小了。如果你真的着急出数据可以分时段跑上午跑A城市组下午跑B城市组让请求在时间上自然散开。5.3 同一个城市ID不同时间段返回的酒店数量对不上现象上午爬的时候某城市返回1500家酒店晚上再爬变成800家差挺多。原因携程接口的返回结果带着实时性和排序逻辑。晚上用户搜索高峰接口可能只返回“有房”的酒店白天房源充足返回的就会多一些。解决明确你的数据口径是“当前有房可订”还是“全部收录”。如果要做长期趋势分析固定每天同一时间段跑任务比如凌晨2点这个时间段的波动最小。5.4 JSON里出现“验证码”相关字段设定的请求频率太激进现象响应正常但JSON数据里多出verify相关的字段或者直接返回一段HTML而不是JSON。原因当前IP被风控了服务端返回了验证码页面要求你证明“你是人”。解决不要硬刚验证码。停下来等半小时到一小时再换一个代理IP把请求频率调低。如果你用的是数据中心IP换IP成本很低但如果你在住宅IP环境下可能就要等更久了。我从来没有尝试去自动过验证码因为得不偿失还会让IP被更加关注。5.5 爬到的数据比页面看到的少原因是分页参数被裁断现象页面上下拉可以一直加载到2000家酒店但接口请求到第50页就返回空数据。原因不是你的翻页代码写错了而是服务端对单次任务的页数做了上限限制。pageNo超过某个值接口就不再返回数据而不是返回空列表来暗示你“后面没有了”。解决针对这种场景换一个办法——按行政区或商圈维度去做多次搜索而不是翻到第100页。例如上海整个城市翻到底可能到第50页就没有了但拆成浦东、静安、徐汇这些区域去分别爬每个区域翻20页加起来的数据量反而更大也更接近真实总量。6. 进阶用法用断点续爬和增量更新把CTripSpider变成一套长期能用的数据管道到现在为止我们的爬虫还是一次性的跑一轮拿数据结束。但真正有长期价值的用法是“增量更新”——今天爬过的酒店明天只要更新房价和评分就行不要重复抓取所有内容。这样对服务端的压力小也稳定不容易再触发风控。实现增量更新有两个前置条件本地数据库里必须有hotel_id、update_time这两个字段爬取的时候如果hotel_id已存在就走“更新”分支只请求详情接口不再重建整个记录。# 增量更新伪代码逻辑 def upsert_hotel(hotel_id, new_data, db): old db.query(SELECT * FROM hotels WHERE hotel_id ?, (hotel_id,)) if old is None: db.insert(hotels, new_data) else: db.update(hotels, new_data, wherehotel_id ?, args(hotel_id,)) # 清除关键字段以外的旧数据让记录保持最新状态再说断点续爬。跑到一半挂了不用从第一个城市重来而是把“已完成城市”记录在一个progress.json文件里。重跑时先读这个文件跳过做过的部分。这个功能在数据量大的时候特别好使——我有一次跑全国数据中途IP断了重启后续上进度省了两个多小时的重爬时间。验证增量更新是否有效的方法也很简单今天跑完明天同一时间再跑一次对比两次导出的Excel总行数变化。如果行数只增不减说明增量逻辑是对的如果突然减少那大概率是清洗环节的去重逻辑误删了记录。我自己的习惯是CTripSpider这类爬虫项目宁可代码结构笨一点、跑得慢一点也要保证“挂了能续、跑了能查、交付能看”。这么多年下来凡是没做到这三点的爬虫项目最后基本都会变成黑匣子——跑是能跑但没人知道它跑出来的数据是怎么来的。希望这篇笔记能帮你少走我走过的弯路。希望帮到你。本文还有配套的精品资源点击获取