
简介资源是一套基于Java编写的携程酒店评论爬虫示例工程以Maven标准结构组织面向正在学习网络数据采集、希望掌握爬虫基本流程的开发者。压缩包共34个文件包含10个Java源文件及对应的10个class编译文件另有多份XML配置、properties属性和gitignore等工程文件整体仅149KB其中xiecheng_hotel.xlsx为抓取结果样例可用来核对输出格式。已有323人学习下载。源码完整覆盖URL收集、请求网页、解析HTML、数据存储等爬虫关键步骤读者可以借助Maven配置快速还原运行环境参考Java源文件理解解析逻辑并通过class文件验证编译结果。借助该示例还能厘清第三方依赖的引入方式以及如何将抓取到的评论内容写入Excel表格。开发者可在此基础上继续扩展数据清洗或可视化分析。资源体量小、目录结构清楚很适合课程设计、毕业设计或快速搭建评论抓取原型时参考。1. 携程酒店评论爬虫第一次跑通到能稳定收集数据的完整路径做酒店价格监控或者竞品分析的人大概率都想过把携程的住客评论抓下来——评分分布、关键词反馈、设施槽点这些数据比官方宣传页诚实得多。网上能搜到的携程爬虫很多但大部分要么是几年前的老代码接口早就变了要么只给了核心请求没给完整工程跑起来才发现还缺 cookie 生成、缺请求头校验、缺反爬处理。这份名为“携程酒店评论爬虫”的 Java 工程包是一个能直接编译运行的完整项目而不是零散脚本。它解决的核心问题是给定一个酒店 ID自动翻页收集评论、评分、入住时间、房型等字段并落地到本地文件后续接数据库还是接分析脚本都很顺。适合有一定 Java 基础、想快速拿到结构化酒店评论数据的开发者也适合用来作为学习 Java 爬虫工程化写法的参考项目。2. 点评分页的请求规律为什么不能直接调“下一页”接口2.1 从页面抓包到定位评论数据的真实来源第一次打开携程酒店详情页按 F12 进 Network 面板刷新页面后你会看到几十个请求酒店基本信息、房型列表、周边推荐、用户评论各占一个接口。早期爬虫会直接去解析 HTML但那是在服务端渲染时代的老思路现在携程的前端框架已经改成了前后端分离评论数据由单独的异步接口返回。这个工程的做法是先定位到那个返回评论列表的 XHR 请求再梳理它的参数规律这也是所有现代爬虫的基本功。评论数据走的接口路径通常包含review或comment关键字返回的是 JSON 格式。但需要注意直接看单个请求的 URL 并不够因为评论接口的关键参数不在 URL 里而是在 POST 请求的 body 中。用 Chrome 的 Copy as cURL 功能把整个请求导出来能更方便地看清楚请求头、请求体、cookie 这三块内容的组成。curl https://m.ctrip.com/restapi/soa2/13444/json/getCommentList \ -H authority: m.ctrip.com \ -H content-type: application/json \ -H referer: https://hotels.ctrip.com/hotel/xxx.html \ -H user-agent: Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) darwin \ --data-raw {hotelId:123456,page:1,pageSize:10,orderBy:0}这个命令展示的是一个典型的移动端评论请求结构。authority指定目标域名referer是当前酒店详情页地址user-agent用的是 iPhone 的 UA这个细节很关键——移动端接口在某些情况下比 PC 端接口风控宽松。--data-raw里是请求体包含酒店 ID、页码、每页数量、排序方式这四个字段是后续翻页的核心参数。从工程实现角度讲Java 里要模拟这个请求最常用的组合是HttpClient或OkHttp发 POST 请求配合Jackson或Gson解析 JSON 响应。确认了接口地址和参数结构之后下一步就是把登录态和 cookie 处理好否则请求会被拦在第一步。2.2 cookie 与请求头的处理Java 工程里怎么维护会话携程的评论区接口需要一个有效的M_CART或类似的 cookie 才能返回完整数据。很多人在这一步翻车用 Postman 直接测接口能通代码里跑就 403。原因一般是 cookie 没带上或者请求头缺少accept、content-type这类基础字段。更好的做法是先从浏览器复制一套 cookie 放到配置里跑通后再用代码模拟登录去换。HttpHeaders headers new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.add(User-Agent, Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) darwin); headers.add(Referer, https://hotels.ctrip.com/hotel/ hotelId .html); headers.add(Cookie, config.getCookie()); String requestBody String.format( {\hotelId\:%d,\page\:%d,\pageSize\:10,\orderBy\:0}, hotelId, pageNum );这段代码里最关键的是Cookie和Referer两个请求头。Referer如果缺失或指向其他页面服务端会判定为异常请求Cookie如果是空字符串接口会返回错误码。orderBy0表示按默认排序即热门评论优先若想按时间排序可以改成orderBy1这在分析最新住客反馈时更实用。这个工程里把 cookie 放在config.properties配置文件中而不是硬编码在类里方便后续失效后快速替换。实际运行时如果发现返回的 JSON 里errorCode不为 0优先检查 cookie 是否过期。cookie 的有效期通常只有几小时到一天长时间运行时需要在代码里加入“检测到失效自动停下并提示替换”的逻辑否则会拿到一批空数据而不自知。2.3 响应数据结构评论列表藏在哪个节点里接口返回的 JSON 结构在不同版本间有差异但这个工程抓到的结构比较稳定。外层是data对象里面有commentList数组、totalCount总数、totalPage总页数以及baseRoomInfo等辅助信息。{ errorCode: 0, data: { totalCount: 328, totalPage: 33, commentList: [ { content: 房间卫生很好前台服务热情但隔音一般, score: 4.8, checkInDate: 2024-11-02, roomName: 高级大床房, userName: M***2, tripType: 家庭亲子, hotelId: 123456 } ] } }解析时要注意content是完整评论内容score是本次入住的评分checkInDate是入住日期roomName是具体房型tripType是出行类型。这些字段已经满足大多数数据分析场景的需求。工程里的Comment.java实体类就是照着这个结构定义的属性名和 JSON 字段一一对应用 Jackson 的ObjectMapper可以一行完成反序列化。从实际使用角度看这个接口最大允许的pageSize是 10 或 20超过会直接报错。翻页时把page从 1 递增到totalPage每页之间建议休眠 2 到 5 秒。这样既能拿全数据又不会触发频控。下面这段是工程里翻页逻辑的简化版for (int page 1; page totalPage; page) { String json sendRequest(hotelId, page); CommentResponse resp objectMapper.readValue(json, CommentResponse.class); saveToFile(resp.getCommentList()); Thread.sleep(randomDelay(2000, 5000)); }randomDelay(2000, 5000)是 2 到 5 秒的随机延时这个设计能有效规避固定间隔带来的频率特征。saveToFile负责把每页的评论写入文件逐行追加而不是覆盖保证中断后能继续。totalPage是从首页响应里读出来的不要在循环里每次都重新请求首页减少不必要的流量和风控风险。3. 数据落地与断点续跑评论存成什么格式最省事3.1 JSON 还是 CSV后续要接数据库就选前者评论数据抓下来之后落盘格式直接影响后续使用的便利性。这个工程默认输出 JSON 行文件每条评论一行符合 JSON Lines 规范。相比 CSVJSONL 的好处是字段增删不需要改表头嵌套结构也能完整保留如果准备导入 MySQL 或 MongoDB用现成的工具就能直接解析。public void saveComment(Comment comment) throws IOException { ObjectMapper mapper new ObjectMapper(); try (FileWriter writer new FileWriter(OUTPUT_FILE, true)) { writer.write(mapper.writeValueAsString(comment) \n); writer.flush(); } }FileWriter的第二个参数true表示追加模式这样每次写入不会清空已有数据。mapper.writeValueAsString(comment)把对象序列化成 JSON 字符串末尾加换行区分记录。注意这里没有用JsonGenerator去创建复杂结构因为每条评论独立成行更利于断点续抓时去重。如果接 MySQL表结构可以按id、hotel_id、content、score、check_in_date、room_name、trip_type建导入时逐行解析 JSONL 再插入。如果只是做临时分析直接拖进 Pandas 读 JSONL 也没问题。工程里的输出文件默认生成在项目根目录的data文件夹下按日期命名比如comments_20250120.jsonl避免长时间运行产生超大单文件。3.2 断点续跑抓取中途挂掉如何不从头再来爬虫跑到一半断网、被限流、或者 cookie 失效是家常便饭。如果没有断点续跑机制重新执行就得从第一页重新抓既浪费时间又增加风控风险。这个工程的做法是记录已抓取的页码和评论数量下次启动时从上次失败的位置继续。Properties state new Properties(); state.load(new FileInputStream(crawl_state.properties)); int lastPage Integer.parseInt(state.getProperty(lastPage, 0)); // 每个酒店抓完后更新状态 state.setProperty(lastPage, String.valueOf(currentPage)); state.setProperty(hotelId, String.valueOf(hotelId)); state.store(new FileOutputStream(crawl_state.properties), crawl checkpoint);这段代码维护了一个简单的状态文件里面保存了当前抓到的页码和酒店 ID。启动时先读lastPage循环就从lastPage 1开始不用每次从头翻。这里的实现思路很简单但对于中小规模爬取任务已经完全够用。更复杂的场景可以升级到数据库记录抓取进度但在这个项目场景下没必要。需要明确的是评论可能有新增如果完全依赖本地去重无法发现同一酒店新增的评论。常见做法是把评论的“酒店 ID 入住日期 用户昵称”作为唯一键每次抓完做一次去重重复的跳过新增的补进去。这个逻辑简单但有效能保证最终数据在目标酒店维度上是完整且不重复的。3.3 定时更新用ScheduledExecutorService做周期抓取酒店评论是动态数据今天抓的数据三个月后就过时了。如果要做长期监控比如每周跑一次采集任务来观察某家酒店的口碑变化趋势手动执行显然不现实。工程里提供了一个基于ScheduledExecutorService的定时器可以按设定的间隔自动重新抓取。ScheduledExecutorService scheduler Executors.newScheduledThreadPool(1); scheduler.scheduleAtFixedRate(() - { try { CrawlerRunner runner new CrawlerRunner(); runner.crawl(hotelIdList); } catch (Exception e) { logger.error(scheduled crawl failed, e); } }, 0, 7, TimeUnit.DAYS);scheduleAtFixedRate的四个参数分别表示首次延迟、周期时间、时间单位。这里配置的是每周执行一次首次启动立即执行。需要注意如果任务执行时间超过了周期时间下一次会立即触发不会等待。所以如果单个酒店评论量很大抓取耗时超过一天应该把周期调成两周或更长避免任务重叠导致数据错乱。定时任务里加了异常捕获防止某次抓取失败导致整个调度线程崩溃。日志里把错误堆栈打出来方便事后排查。这个逻辑在工程里作为一个独立的SchedulerMain类存在和单次运行的Main类分开互不干扰。4. 携程反爬的典型拦截从 403、验证码到 IP 限流4.1 遇到 403 先别慌逐项排查请求头和 cookie这是最常见的拦截方式。现象是请求发出后返回 403 Forbidden或者返回{errorCode:40314}之类的业务错误码。很多人在这一步直接放弃但实际上 90% 的原因是请求头与浏览器不一致而不是 IP 被拉黑。逐项排查的顺序是先对照浏览器里的完整请求头确认User-Agent、Referer、Origin、Content-Type是否齐全且正确再检查 cookie 是否过期最后看请求体 JSON 是否符合最新接口格式。工程里提供了一个HeaderBuilder工具类把需要动态拼接的请求头集中管理换 cookie 时只需要改配置文件。public class HeaderBuilder { public static HttpHeaders build(String cookie, int hotelId) { HttpHeaders headers new HttpHeaders(); headers.add(Accept, application/json); headers.add(Accept-Language, zh-CN,zh;q0.9); headers.add(Content-Type, application/json); headers.add(Origin, https://hotels.ctrip.com); headers.add(Referer, https://hotels.ctrip.com/hotel/ hotelId .html); headers.add(User-Agent, Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) darwin); headers.add(Cookie, cookie); return headers; } }Accept和Accept-Language这两个头容易被忽略。有的服务器会根据Accept判断客户端类型缺失时可能返回非 JSON 格式的错误页。Origin头在某些跨域场景下会被校验缺失会直接报错。这些参数不是玄学而是 HTTP 协议层面的校验逻辑逐项对齐基本能解决大半问题。4.2 验证码出现时的降级策略不是死磕而是切换数据源当请求过于频繁时服务端可能返回需要滑动验证码的 HTML 页面。这种情况在携程移动端接口上通常是 IP 维度的频控触发而不是账号维度的封禁。此时继续用同一个 IP 高频率请求只会让封禁时间越来越长。这个工程里有一个现成的降级策略当检测到响应内容包含验证码特征词时自动停止该酒店的抓取把酒店 ID 写入一个failed_hotels.txt文件等过几个小时再单独重试。这种“绕开而不是硬闯”的思路比在代码里接打码平台更稳当尤其是小规模爬取场景下不值得为验证码投入额外成本。if (responseBody.contains(risk) || responseBody.contains(verify)) { logger.warn(captcha triggered for hotel {}, hotelId); failedHotelsWriter.write(hotelId \n); return; }检测关键词设为risk和verify是一种通用做法不同时期返回的字段名会微调建议抓一次错误响应看看实际返回内容再调整关键词。failedHotels.txt里的酒店可以配合定时任务在几小时后自动重试如果连续失败三次就人工介入。4.3 IP 限流与换 IP 的边界不是所有 429 都要立刻换代理携程的频控分为两个级别单 IP 短时间请求次数过多返回 429以及单 IP 长期高频请求被拉入黑名单返回 403。前者的特征是停一段时间就能恢复后者的特征是换了 User-Agent 也无效。判断逻辑很简单如果返回 429把当前请求暂停 60 秒再重试如果返回 403 且换了 cookie 仍然 403说明 IP 可能已被标记。工程里提供了一个简化版本的RetryPolicy内置了基于响应码的退避策略。public String executeWithRetry(HttpRequest request, int maxRetries) throws Exception { int retryCount 0; while (retryCount maxRetries) { HttpResponse response httpClient.execute(request); int status response.getStatusLine().getStatusCode(); if (status 429) { Thread.sleep(60000); retryCount; } else if (status 403) { throw new RuntimeException(IP blocked, need manual intervention); } else { return EntityUtils.toString(response.getEntity(), UTF-8); } } throw new Exception(max retries exceeded); }这里只对 429 做自动重试403 直接抛出异常让上层处理。原因很简单429 是临时性限流等待后能恢复403 是持久性封禁重试多少次都没用不如停下来人工检查。对于个人开发者而言使用代理池的成本和复杂度都不低先确认是不是代码层面的问题再考虑 IP 层面的方案能省不少时间。5. 常见问题与避坑这批代码最容易被改坏的地方在哪5.1 直接替换酒店 ID 后请求失败现象把工程里默认的酒店 ID 换成了自己要抓的酒店运行后返回空列表或者报参数错误。原因不同酒店的评论区接口参数不完全一致。有的酒店因为评论量少接口返回的totalPage是 0导致commentList为空这并不算报错。但有些酒店详情页的 URL 中带有特殊字符或额外参数直接拼接hotelId时把 URL 结构搞坏了。解决先访问目标酒店详情页确认是否正常显示评论区然后在浏览器中手动翻几次评论观察接口请求体的hotelId是多少。工程里的配置项hotelId要填写数字 ID而不是 URL 里那段字符串。建议先用一家评论量大的酒店测试跑通流程再去跑冷门酒店。5.2 响应内容不是 JSON 而是网页源码现象打印响应内容发现是一大段 HTML而不是结构化的 JSON前面的 JSON 解析直接抛异常。原因请求头里的Accept与content-type不匹配。服务器识别到客户端希望获取 HTML 页面于是返回了整个详情页源码过来。另一个常见原因是请求的 URL 拼接错误打到了详情页地址而不是评论接口。解决检查 URL 是否还是评论接口地址检查Accept是否包含application/json。如果都没问题再检查Content-Type有没有拼写错误——application/json和application/json;charsetUTF-8都能被接受但不能写成application/text。5.3 抓取到一半数据量突然不再增加现象日志显示每一页都正常返回但输出文件中评论数量停留在某个值不再增长。原因翻页参数page到一定数值后接口返回的仍然是最后一页的数据没有报错也没有返回空数组。这是因为某些酒店的实际评论页数小于totalPage服务端对超出范围的页码做了静默处理不会报越界错误。解决在循环中判断当前页返回的commentList是否为空为空立即终止。同时判断当前页中是否有重复记录——如果连续三次返回的第一条评论文本完全相同认为已经翻到末尾自动停止。5.4 日志里出现大量超时异常现象每个请求都要等很久才返回最后超时抛异常。项目本身没有死但速度极慢。原因网络环境不佳或者当前出口 IP 被限速。携程的服务器对异常 IP 可能会做延迟处理而不是直接拒绝表现为请求能发出去但响应迟迟不回来。解决在HttpClient配置中显式设置连接超时和读取超时分别是 5000 毫秒和 10000 毫秒。超时后快速失败而不是无限等待这样重试机制才能生效。另外确认本机网络没有走全局代理代理节点的不稳定也会导致同样的现象。5.5 同一条评论被抓了两次现象对比数据时发现某些评论内容和入住日期、用户名完全一样但存在多行。原因断点续跑逻辑不完善翻页和写入之间没有做去重。比如上次已经写到第 5 页但状态文件里记录的是第 4 页重启后就重复抓了第 5 页。解决在写入前查重以“用户名 入住日期 房型”为组合键检查该组合是否已存在。更简单的方案是抓完全部数据后做一次整体去重SQL 里用GROUP BY或者 Python 里用DataFrame.drop_duplicates()都行。这种重复数据在按评论全文匹配时会误导统计所以越早处理越好。6. 多酒店批量抓取与运行监控最后一步让这套代码变成可用的数据管道如果只有一个酒店要抓手动改配置跑一次就够了。但实际需求往往是几十上百家酒店运行一次要几个小时这时候需要批量任务管理。这个工程里有两个关键机制酒店列表统一管理和单酒店独立日志。酒店列表放在hotel_list.txt中每行一个酒店 ID。主程序启动时逐行读取依次抓取。每个酒店抓完后更新一次状态文件哪怕中途进程被 kill重启后也能跳过已经完成的酒店。单酒店独立日志的好处是某一家酒店触发验证码或报错时不会影响其他酒店的数据抓取事后排查只需要看对应酒店的日志文件。ListString hotelIds Files.readAllLines(Paths.get(hotel_list.txt)); for (String hotelIdStr : hotelIds) { int hotelId Integer.parseInt(hotelIdStr.trim()); try { CrawlerRunner runner new CrawlerRunner(); runner.crawlSingleHotel(hotelId); logger.info(hotel {} finished, comments: {}, hotelId, runner.getCount()); } catch (Exception e) { logger.error(hotel {} failed, hotelId, e); failedListWriter.write(hotelIdStr); continue; } }日志里每完成一个酒店会输出抓到的评论总数这个数字要重点看。如果某个热门酒店只抓到 5 条评论而详情页显示有几万条说明翻页提前结束了需要检查去重判断条件。批量运行时还建议把输出文件按酒店拆分而不是全部塞进同一个文件——后期分析时按酒店分组会更方便也不会因为单文件过大导致编辑器打不开。验证跑批结果有一个比较实用的方法随机选三家不同等级的酒店手动打开详情页数一遍评论总数再对比抓取结果。误差在 5% 以内说明逻辑没问题超过 10% 就要查翻页或去重逻辑。我曾经有一次全量抓完后发现某家酒店的数据只有详情页显示的五分之一最后发现问题出在commentList为空时循环没有提前终止导致后面的页码全被跳过。从那以后我每次跑批量任务都会加一个“预计总数对比”的检查环节抓完先对总数再决定要不要深入排查。这个习惯帮我省掉了好几次返工希望帮到你。本文还有配套的精品资源点击获取