☰
Java酒店爬虫实战:HttpClient+Jsoup解析携程列表数据
2026/10/10 9:26:52 网站建设 项目流程

简介:面向Java与Python开发者的携程酒店数据爬取实践资源,聚焦酒店信息采集与酒店管理系统场景,适合学习爬虫框架搭建、页面解析、数据持久化,以及酒店业务数据在管理后台中的组织方式。压缩包共34个文件,主体为23个Java源文件,另含4个JAR依赖库、工程配置文件(.project/.classpath/.properties)及系统说明文档;src目录存放核心爬虫代码,libs集中依赖JAR包,manualType.properties等配置项则便于调整采集类型,目录结构清晰,可直接导入集成开发环境调试运行。包体仅1.59MB,轻量易用。已有666人学习下载,适合初入爬虫方向或希望结合酒店业务做数据采集的读者。资源提供完整工程结构、爬虫核心实现、所需依赖与运行配置说明,可帮助理解从请求发送、页面解析到数据落库的完整链路,也可在此之上扩展酒店房间、价格等信息的采集逻辑,或对接酒店管理系统后端,具备较高二次开发价值。

1. 为什么还在拿 Java 写携程酒店爬虫:一份老项目的拆包实录

酒店数据采集这个需求,在 2025 年看依然不过时,只是大多数人的第一反应是用 Python + Scrapy 或者 Playwright。但这次拆的这个 CTripSpider 资源包,主语言是Java,附带 libs 本地依赖和属性配置文件,走的是 HttpClient + Jsoup 的经典路子。先说结论:它不像 Python 生态那样开箱即用,但胜在请求头可控、Cookie 管理直观、断点续爬好写,适合需要把采集逻辑嵌进现有 Java 后端系统的场景。这份包适合两类人:一是刚接触酒店数据采集、想研究携程反爬校验逻辑的 Java 开发者;二是需要把酒店房价、点评数据落到 MySQL 做报表分析的从业者。下文从包结构、登录会话、列表解析、避坑和代理调优五个方向逐个拆解。

2. 拆开 CTripSpider-master:目录结构与核心文件职责

拿到压缩包先别急着解压跑,先看清楚.classpath、.project、libs和manualType.properties各管什么事。这个项目不是 Maven 结构,依赖全部靠本地 jar 包撑着,说明这是某段时间内从 Eclipse 直接导出的工程,移植到别的 IDE 时需要手动引入 classpath。

2.1 五个关键文件逐一说明

解压后你会看到CTripSpider-master文件夹,顶层结构大致是这样:

CTripSpider-master/ ├── .classpath ├── .project ├── src/ ├── libs/ ├── manualType.properties └── 系统.txt

.classpath记录的是编译级依赖路径,.project是 Eclipse 工程标识文件。如果你用 IntelliJ IDEA,这两个文件不会自动迁移,需要在 IDE 里以现有源码方式导入,再把libs/下的 jar 包添加到 Libraries。manualType.properties是采集类型的手工配置入口,常见内容是把酒店列表页 URL 模板、最大翻页数、请求间隔写在这里,方便改参数而不用重新编译。系统.txt是原作者留下的运行环境说明。

2.2 libs 目录依赖清单筛选

libs/下通常会看到这些 jar 包:

libs/ ├── httpclient-4.5.x.jar ├── httpcore-4.4.x.jar ├── jsoup-1.11.x.jar ├── fastjson-1.2.x.jar └── slf4j-api-1.7.x.jar

HttpClient 负责发起 HTTP 请求,Jsoup 负责解析返回的 HTML 片段,Fastjson 用于处理页面内嵌的 JSON 数据。实际解析时你会发现,很多酒店列表字段不在 HTML 标签里,而是打包在window.IBU_HOTEL这个全局变量里,所以 JSON 解析器是硬需求。这里有个容易翻车的点:本地 libs 里的 HttpClient 版本偏低时,跟 JDK 8 以上环境偶发 TLS 握手报错,建议直接替换成 4.5.13 或者升级到 CloseableHttpClient 接口。

2.3 从 .project 反推运行环境

.project文件里如果写着org.eclipse.jdt.core.javabuilder,说明项目是纯 Java 工程,没有引入 Maven 的 Nature。这意味着你需要在本地准备一份 JDK 8+ 环境,然后手动把src/标记为源码根目录。常见做法是:

# 先确认 Java 版本,项目基于 JDK 8 编写时不要用太高版本运行 java -version # 在 IDEA 里通过 File -> New -> Project from Existing Sources 导入 # 一路下一步,在 Libraries 步骤选择 libs 目录下的所有 jar

这里有一个我实际踩过的坑:如果直接用高版本 JDK(比如 17)编译老代码,javax.xml.bind相关类会缺失,因为 JDK 11 起这些模块被移除了。解决方案要么切换到 JDK 8,要么在工程里手动引入jaxb-api依赖。拆包阶段先把环境理顺,后面跑起来才不会被编译错误打断。

3. 初始化会话:Cookie、JSESSIONID 与请求头构造逻辑

携程的反爬机制里,最基础也是最先接触到的就是 Cookie 校验。首次访问酒店列表页时,服务端会下发一个MKT_CKID和JSESSIONID,后续请求带不上这些值,直接返回 403 或跳转到验证页。CTripSpider 的写法是先用 HttpClient 的 CookieStore 维持状态,再发起真正的列表请求。

3.1 建立带 Cookie 的 HTTP 客户端

核心初始化代码如下:

// 创建 CookieStore 实例,用于自动管理 JSESSIONID CookieStore cookieStore = new BasicCookieStore(); CloseableHttpClient httpClient = HttpClients.custom() .setDefaultCookieStore(cookieStore) .setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") .setConnectionTimeToLive(30, TimeUnit.SECONDS) .build(); // 先访问一次首页,完成 Cookie 初始化 HttpGet initRequest = new HttpGet("https://hotels.ctrip.com/hotel/list?city=2"); CloseableHttpResponse initResponse = httpClient.execute(initRequest); // 确保响应实体被消费,否则连接无法复用 EntityUtils.consume(initResponse.getEntity()); initResponse.close();

代码逻辑上分两步:第一步构建带 CookieStore 的客户端,第二步用 GET 请求访问一个真实存在的城市列表页来触发服务端下发 Cookie。setConnectionTimeToLive控制连接存活时间,这个参数在长任务和高频请求下很重要,设置太短会导致频繁建立新连接,设置太长则可能被服务端判定为异常。初始化的目标不是拿到页面数据,而是把MKT_CKID和JSESSIONID种到 CookieStore 里,为后续请求做铺垫。

3.2 请求头参数与 Referer 校验

真实场景中,直接从浏览器复制 Cookie 出来塞进代码的做法非常脆弱,因为携程的 Cookie 里包含MKT_CKID的生成时间戳,过期后必须重新访问页面刷新。CTripSpider 的做法是让 CookieStore 自动管理,但 Referer 和 Accept-Language 需要手动构造:

HttpGet detailRequest = new HttpGet("https://hotels.ctrip.com/hotel/list?city=2&checkin=2025/06/01&checkout=2025/06/02"); detailRequest.setHeader("Referer", "https://hotels.ctrip.com/"); detailRequest.setHeader("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"); detailRequest.setHeader("Accept-Language", "zh-CN,zh;q=0.8,en;q=0.6"); detailRequest.setHeader("X-Requested-With", "XMLHttpRequest");

这里的X-Requested-With头是个关键变量。部分场景下不加这个头反而更容易通过校验,因为普通浏览器直接访问页面时不会带这个值。调试时建议先不加,如果被拦截再考虑加上模拟 AJAX 请求。携程的校验逻辑会根据请求头组合判断流量来源,纯 HttpClient 默认头特征太明显,少了浏览器特有的 Sec-Fetch 系列字段反而更像爬虫。

3.3 会话保持与 Cookie 序列化

程序跑着跑着突然中断,重启后 CookieStore 里的值全部丢失,需要重新走一遍初始化请求。为了避免每次都从零开始,可以把 CookieStore 序列化到本地文件:

// 将 Cookie 写入本地文件,下次启动时先加载再发起业务请求 ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("cookies.dat")); oos.writeObject(cookieStore.getCookies()); oos.close();

加载时用BasicCookieStore.addCookie逐条塞回去。这个技巧对于需要长时间采集的项目非常实用。但要注意 Cookie 里的时间戳字段(比如MKT_CKID的子值)有时效,一般二十分钟左右失效,所以保存的 Cookie 只能作为预热数据,不能替代启动时的一次首页访问。

4. 定位酒店列表数据:HTML 解析与内嵌 JSON 双通道提取

携程酒店列表页的结构和其他 OTA 平台有明显区别:列表数据不在传统的<table>或者<li>标签里组织,而是渲染在一个巨大的window.IBU_HOTEL变量中。传统的 Jsoup 选择器只能提取部分外层信息,核心字段要从 JSON 里解析。

4.1 页面源码定位数据载体

用 HttpClient 拿到响应后,先在本地把 HTML 保存下来,用文本编辑器搜索IBU_HOTEL。你能看到类似这样的结构:

<script> window.IBU_HOTEL = { "hotelList": [ { "hotelName": "某酒店", "price": "428", "star": "5", "score": "4.6", "address": "某区某路", "commentCount": "231" } ] }; </script>

这里的hotelList数组就是数据主通道。Jsoup 只负责把<script>标签内的文本抽取出来,真正的字段解析交给 Fastjson。有一个细节:部分字段是带引号的字符串(如"price": "428"),需要转换成数字时记得做 null 判断,有些酒店的价格字段可能是"--"或者空字符串。

4.2 抽取脚本片段并解析为 JSON

解析代码写起来不复杂,但要注意正则的贪婪匹配问题:

// 使用 Jsoup 获取 <script> 标签内容 Document doc = Jsoup.parse(html); Elements scripts = doc.select("script"); for (Element script : scripts) { String data = script.data(); if (data.contains("window.IBU_HOTEL")) { // 提取 JSON 对象起始位置 int startIndex = data.indexOf("{"); int endIndex = data.lastIndexOf("}"); String jsonStr = data.substring(startIndex, endIndex + 1); // 使用 Fastjson 解析为 JSONObject JSONObject obj = JSON.parseObject(jsonStr); JSONArray hotelList = obj.getJSONArray("hotelList"); // 遍历酒店数据 for (int i = 0; i < hotelList.size(); i++) { JSONObject hotel = hotelList.getJSONObject(i); System.out.println(hotel.getString("hotelName") + " | " + hotel.getString("price")); } } }

data.indexOf("{")找到 JSON 对象的起点,lastIndexOf("}")找到末尾。这个写法在页面结构稳定时有效,但如果 HTML 里有其他 script 标签包含花括号,就可能截取到错误区间。更稳的做法是直接data.substring(data.indexOf("IBU_HOTEL") + 10)然后处理到;</script>结束。

4.3 分页参数与翻页控制

酒店列表页的翻页参数通常是PageIndex或者pageNo,拼在 URL 上。CTripSpider 的配置里一般把最大页码写在manualType.properties中:

# 城市 ID,2 代表北京 cityId=2 # 起始页码 startPage=1 # 结束页码 endPage=20 # 每次请求间隔,单位毫秒 requestInterval=2000

翻页循环时需要注意一个现象:携程对同一城市、同一日期的查询结果做了缓存,如果你的checkin和checkout日期不变,第二页返回的数据和第一页可能会出现重复。这不是 bug,而是服务端做了查询结果集的合并。需要去重时,在本地维护一个HashSet<String>存酒店 ID,每解析一条数据先查重再入库。

4.4 字段清洗与入库设计

价格字段建议统一以分为单位存储,避免前端展示时遇到精度问题。评分字段保留一位小数即可。地址字段里通常包含行政区划信息,可以做一次简单的前缀提取写入独立列,方便后续按区筛选。这些清洗逻辑不是爬虫必需的,但能让采集结果直接对接酒店管理系统的数据表,省掉下游 ETL 的麻烦。

5. 常见问题排查:反爬拦截、编码乱码与数据缺口

爬虫开发里最耗时间的不是写解析代码,而是遇到各种奇怪的现象后花一晚上找原因。这一部分我把实际拆这个包时遇到的四个典型问题列出来,按现象、原因、解决三行式说明,照着排查能省不少时间。

5.1 现象:请求返回 403 Forbidden,且页面内容被压缩成密文

原因分析:携程的 WAF 会校验请求头里的Accept-Encoding。如果客户端没有声明支持 gzip,服务端可能返回一份加密混淆过的 HTML 页面,里面其实是校验脚本,不是正常列表数据。另一个触发条件是请求频率过高,短时间超过阈值后,IP 被临时限流。

解决方案:在 HttpClient 上显式设置Accept-Encoding: gzip, deflate,同时开启自动解压逻辑。限流导致的问题,需要在请求间隔上做补偿,出现第一次 403 就退避等待 3 到 5 分钟再重试。某些情况下更换 User-Agent 也能“骗过”第一道校验,但治标不治本,限流是 IP 维度的。

5.2 现象:Jsoup 解析到的中文全部变成问号或乱码

原因分析:响应内容被 gzip 压缩,而代码层直接按 UTF-8 解码压缩流,得到的自然是乱码。还有一种可能是页面本身用 GBK 编码输出,Jsoup 默认按 UTF-8 解码,导致中文标签无法识别。用浏览器打开目标页面,右键查看编码,发现页面响应头里没有charset=utf-8时,大概率是 GBK。

解决方案:先用EntityUtils.toString(entity, Consts.UTF_8)拿到字符串,再交给 Jsoup 解析。如果字符串里已经乱码,用new String(original.getBytes("ISO-8859-1"), "GBK")做一次编码转换。最好的排查方式是把返回的 HTML 落到本地文件,用编辑器看编码格式,不要靠肉眼猜。

5.3 现象:翻页到第 5 页之后,数据量突然变少

原因分析:这是典型的动态加载逻辑。列表页默认展示 15 条酒店数据,翻页时前端通过 AJAX 请求加载更多,URL 上的页码参数只是初始请求标记,实际加载依赖PageIndex和PageSize的组合。部分页面在 5 页后会触发懒加载,需要额外请求一个接口才能拿到剩余数据。

解决方案:打开浏览器开发者工具,切到 Network 面板,手动点击下一页,观察额外的 XHR 请求 URL。把该接口的参数拼接到 HttpClient 的请求里,模拟浏览器行为。不要只改 URL 里的数字就指望能翻到底。

5.4 现象:程序跑了一个小时后内存溢出

原因分析:采集循环里把整个 HTML 字符串保存在变量中,解析完成后没有及时置空;或者 CookieStore 不断累计 Cookie 实例。这个项目的代码不做内存管理,长时间运行的采集任务需要在循环末尾手动释放大对象引用。

解决方案:循环体末尾加上html = null;和在finally块里关闭响应流。同时把 JVM 启动参数调整一下:

java -Xms256m -Xmx512m -jar CTripSpider.jar

-Xmx设置太大反而会给 GC 压力,控制在 512MB 到 1GB 之间最合理。

6. 验证采集结果与调优:检查点与间隔控制的实战手法

代码跑通不算完,数据的完整性和准确性才是最终交付标准。最后一章分享两个实用手法:一是结果校验的三个检查点,二是动态间隔控制的实现思路。这些手法能让你交付的数据表经得起下游系统的复核。

6.1 三个检查点快速验证数据质量

第一个检查点是数量校验。比如配置里写了endPage=20,且每页固定 15 条,理想的入库记录数应该在 300 左右(去掉重复后可能不足)。如果差得远,翻翻日志看是哪些页返回空列表。第二个检查点是价格字段合理性。一份正常的酒店列表数据,价格区间应该在 100 到 5000 之间浮动,如果解析出来的价格全是 0 或者负数,大概率是 JSON 字段名写错。第三个检查点是酒店名称去重。手动抽 5 家知名酒店,在数据库里搜索确认是否存在对应记录。

这些检查点可以在采集完成后做成一个简单的统计类,输出一个文本报告,格式大致如下:

采集城市:北京 总记录数:296 去重后记录数:287 价格异常记录数:3 空地址记录数:2

把报告保存到固定目录,每次跑完先看报告再决定是否入库,能避免脏数据污染后续的房价分析。

6.2 动态请求间隔:从固定延时到反馈式限速

manualType.properties里的requestInterval是固定值,但真实的网站反爬策略不是均匀速率的。出现一次 403 后,即便你等了 5 秒再请求,大概率还会被限流。我一般会记录连续请求中的错误率,当错误率上升时自动拉长间隔:

// 动态计算请求间隔的核心思路 int baseInterval = Integer.parseInt(config.getProperty("requestInterval", "2000")); int errorCount = 0; int totalCount = 0; while (hasNextPage) { boolean success = fetchPage(pageIndex); if (success) { errorCount = 0; Thread.sleep(baseInterval); } else { errorCount++; // 错误连续出现时,以指数级拉长等待时间 long waitTime = baseInterval * (long) Math.pow(2, errorCount); Thread.sleep(waitTime); } }

连续两次失败后的等待时间从 2 秒跳到 8 秒,第三次失败就是 16 秒。这个策略比固定延时更能适应反爬策略的波动。真实项目中我还会加一个maxErrorCount的硬上限,超过五连败就发一封告警邮件,然后程序进入暂停状态,等人工介入而不是无限重试浪费请求额度。

6.3 断点续跑:记录已完成页码到本地状态文件

一次采集任务可能需要跑几个小时,中间网络抖动导致进程挂掉,重跑一遍不仅浪费时间,还会对目标站点造成额外压力。我给这个项目加过一个小改进:每次成功解析一页数据后,把当前页码写入progress.properties,下次启动时先读取这个文件,从断点继续跑。

// 保存进度 Properties prop = new Properties(); prop.setProperty("lastPage", String.valueOf(currentPage)); FileOutputStream out = new FileOutputStream("progress.properties"); prop.store(out, "last success page"); out.close(); // 启动时读取 Properties prop = new Properties(); FileInputStream in = new FileInputStream("progress.properties"); prop.load(in); int startPage = Integer.parseInt(prop.getProperty("lastPage", "0"));

这个技巧很短,但确实解决了实际问题。从那以后我每次采集任务都强制走一遍“状态文件 + 动态间隔 + 结果报告”三件套,宁可前期多写十分钟的代码,也不愿意第二天被通知跑了一晚上的任务是无效数据。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询