Expedia 是全球最大的在线旅游交易平台之一,其展示的酒店、机票和活动数据对于价格调研、市场监控或旅游产品开发都有切实价值。问题在于 Expedia 以客户端方式渲染结果,并对机器人流量进行强力防御,因此普通 HTTP 请求只会返回一个空壳。本指南将展示如何用 JavaScript 抓取 Expedia:一个小型可运行的 Node 构建,渲染页面,用 Cheerio 解析酒店列表,处理翻页,并将结果写入 CSV。

为保持诚实和可辩护性,本文全程限定于公开数据:任何人无需登录即可在 Expedia 上看到的酒店和机票列表、每晚价格、可用性、评分和评论数量。本文不涉及用户账号、需要登录的内容、预订操作或个人数据。临近结尾的服务条款与合规部分不是套话,请在将本文内容用于生产规模前认真阅读。

为什么要抓取 Expedia 旅行数据

公开旅游定价持续波动,一次页面浏览只能告诉你当前运价的情况。抓取 Expedia 的公开列表可让你随时间追踪每晚价格和可用性,这正是价格监控、竞品对标和需求研究所依赖的基础。对于工程师来说,价值在于结构化数据:将渲染后的搜索页转化为可查询、可可视化或可输入模型的干净行记录。

这与任何电商网络爬取任务形态相同,区别在于 Expedia 的反爬技术栈比普通商店更为激进,因此方案从第一次请求起就必须将其纳入考量。

了解目标:Expedia 的酒店搜索 URL

Expedia 提供多个搜索入口(机票、租车、邮轮、体验活动),但酒店搜索是最易操作的,也是本指南所使用的入口。其结果位于一个可预测的 URL 上,查询参数直接对应搜索表单,因此你可以无需操作 UI 即可以编程方式构造任意搜索。

以下是一个具体示例:两位成人,一间客房,目的地迪拜,四晚住宿。

bash
https://www.expedia.com/Hotel-Search?adults=2&rooms=1&destination=Dubai&startDate=2026-07-10&endDate=2026-07-14

关键参数说明:

  • destination 搜索地点,需 URL 编码(城市、地区或酒店名称)。
  • adults 成人旅客人数。
  • rooms 需要定价的客房数量。
  • startDateendDate 住宿时间段,格式为 YYYY-MM-DD

用你关心的参数构建 URL,你就有了一个可重复执行的目标。在循环中改变目的地和日期,你就有了一个价格监控任务。

为什么普通请求在此失效

如果你用裸 HTTP 客户端请求该 URL,会得到状态码 200 的响应,但正文中几乎没有酒店数据。两件事在阻碍你。其一,Expedia 在浏览器中以 JavaScript 渲染列表,因此初始 HTML 只是一个骨架,页面脚本在浏览器中运行后才会填充内容。其二,Expedia 会迅速标记自动化流量:数据中心 IP 以及不像真实浏览器的请求模式,在见到任何渲染内容之前就会遭遇挑战或封锁。

因此,一个可用的 Expedia 爬虫在一次请求中需要两样东西:能真正渲染页面的浏览器,以及平台识别为真实访客的 IP。你可以自己用无头浏览器加上一个轮换住宅代理池来实现,但将两者拼接在一起并保持健康运行本身就是大部分工作量。Crawlbase Crawling API 将两者合并为一次调用:你发送带有 JavaScript token 的 URL,它在可信 IP 后渲染页面,返回完整 HTML 供你解析。

为什么使用 JS token

Crawlbase 提供两种 token 类型。普通 token 获取静态 HTML;JavaScript(JS)token 先在真实浏览器中渲染页面。Expedia 是客户端渲染的,因此这里需要 JS token。使用普通 token 返回的将是与普通请求相同的空壳。

搭建项目

你需要安装 Node.js 和 npm。确认两者均可用,然后创建项目并安装所需库。

bash
node --version
npm --version

mkdir expedia-scraper && cd expedia-scraper
npm init -y
npm install cheerio crawlbase csv-writer

三个依赖完成核心工作:crawlbase 是 Crawling API 的官方客户端,cheerio 用类 jQuery 的 API 在服务端解析返回的 HTML,csv-writer 将结果序列化输出。如果你想要可查询的存储而非平面文件,可添加 sqlite3 并将行记录写入表中;下面的 CSV 路径覆盖了最常见的场景。

你还需要一个 Crawlbase 账号和 JS token,注册后在控制台获取。将其填入代码中所有 YOUR_CRAWLBASE_JS_TOKEN 的位置。

获取渲染后的 HTML

首先获取完整的页面。你需要传入两个对于 Expedia 这类站点至关重要的选项:ajax_wait 告知 API 等待异步内容加载完成,page_wait 在加载后再保持固定毫秒数,让晚渲染的列表有时间出现。五秒是合理的起点;如果结果返回内容较少,可适当增加。

javascript
const { CrawlingAPI } = require('crawlbase')

const api = new CrawlingAPI({ token: 'YOUR_CRAWLBASE_JS_TOKEN' })

const options = {
  ajax_wait: true,
  page_wait: 5000,
}

const expediaURL =
  'https://www.expedia.com/Hotel-Search?adults=2&rooms=1&destination=Paris&startDate=2026-07-10&endDate=2026-07-14'

async function fetchExpediaHTML(url) {
  const response = await api.get(url, options)
  return response.body
}

fetchExpediaHTML(expediaURL).then((html) => console.log(html))

node expedia-scraper.js 运行,你应当看到包含酒店卡片的真实标记,而不是普通请求返回的空壳。这在编写任何选择器之前就确认了渲染可用。

Crawlbase Expedia Scraper

Expedia 需要在可信 IP 后渲染页面,一次调用搞定。Crawling API 接受 JS token,在真实浏览器中运行页面,在服务端轮换住宅 IP,并交付完整 HTML,让你无需自己运行无头浏览器集群和代理池。先在免费层用它请求一个公开酒店搜索试试。

用 Cheerio 解析酒店列表

拿到 HTML 后,将其载入 Cheerio 并遍历酒店卡片。每张卡片包含你所需的字段:酒店名称、每晚价格、总价格、评分和评论数量。在浏览器开发者工具中检查实时页面,找到当前选择器,然后将每个字段映射过去。

javascript
const cheerio = require('cheerio')

function extractHotelDetails(html) {
  const $ = cheerio.load(html)
  const hotels = []

  $('div[data-stid="property-listing-results"] .uitk-card').each((i, el) => {
    hotels.push({
      name: $(el).find('h3.uitk-heading').text().trim(),
      pricePerNight: $(el).find('[data-test-id="price-summary"] .uitk-text').first().text().trim(),
      totalPrice: $(el).find('[data-test-id="price-summary"] .uitk-text').last().text().trim(),
      rating: $(el).find('.uitk-badge .uitk-badge-base-text').text().trim(),
      reviews: $(el).find('.uitk-text[aria-hidden="false"]').last().text().trim(),
    })
  })

  return hotels
}
选择器会漂移

Expedia 的类名(uitk-* 前缀和 data-stid 属性)会无通知地变更。将上面的选择器视为起始模板,而非固定契约。当提取结果返回空字符串时,重新检查实时页面并更新选择器。这对任何生产爬虫来说都是正常维护工作,而非异常迹象。

将 fetch 和 parse 串联到一个 main 函数中,这样你就有了一个可运行的完整脚本。

javascript
async function main() {
  const html = await fetchExpediaHTML(expediaURL)
  const hotels = extractHotelDetails(html)
  console.log(hotels)
}

main().catch((err) => console.error('Scrape failed:', err))

输出结果示例

运行完整脚本,你将得到一个结构化酒店对象数组。精简样本如下:

json
[
  {
    "name": "OKKO Hotels Paris Rueil-Malmaison",
    "pricePerNight": "$118",
    "totalPrice": "$542 total",
    "rating": "9.0",
    "reviews": "286 reviews"
  },
  {
    "name": "Grand Hotel Leveque",
    "pricePerNight": "$174",
    "totalPrice": "$782 total",
    "rating": "8.4",
    "reviews": "1,004 reviews"
  },
  {
    "name": "citizenM Paris Opera",
    "pricePerNight": "$192",
    "totalPrice": "$871 total",
    "rating": "9.6",
    "reviews": "76 reviews"
  }
]

处理分页

Expedia 先加载第一批结果,然后将更多内容藏在"显示更多结果"按钮后,而非数字翻页列表。Crawling API 可以通过 css_click_selector 选项为你点击该按钮:传入有效的、经 URL 编码的 CSS 选择器,API 会在页面渲染后点击该按钮,让额外列表在 HTML 返回前加载完毕。

javascript
const options = {
  ajax_wait: true,
  page_wait: 10000,
  css_click_selector: encodeURIComponent('button[data-stid="show-more-results"]'),
}

将其替换到 options 对象中,下次运行将返回比之前更多的酒店。点击操作后需要给 page_wait 多一点时间,因为额外结果在点击触发后需要时间渲染完成。

将结果存储为 CSV

在迭代调试时打印控制台输出是可以的,但你需要将数据保存到磁盘。csv-writer 库将每个对象键映射到一列,只需几行代码即可追加写入你的行记录。

javascript
const createCsvWriter = require('csv-writer').createObjectCsvWriter

function saveToCSV(data) {
  const writer = createCsvWriter({
    path: 'hotels.csv',
    header: [
      { id: 'name', title: 'Name' },
      { id: 'pricePerNight', title: 'Price Per Night' },
      { id: 'totalPrice', title: 'Total Price' },
      { id: 'rating', title: 'Rating' },
      { id: 'reviews', title: 'Reviews' },
    ],
  })

  return writer.writeRecords(data).then(() => console.log('Saved hotels.csv'))
}

main 中用 saveToCSV(hotels) 替换打印日志,每次运行都会写出一个整洁的 hotels.csv,可在任何电子表格中打开或加载到数据管道中。如果你更倾向于用 SQL 查询数据,可用 sqlite3 将同样的行记录写入 SQLite 表;解析步骤完全相同。

保持不被封锁

即使有渲染加持,Expedia 仍会监测爬虫特征流量。养成几个习惯有助于保持运行健康,这些习惯同样适用于任何有强力防护的商业目标。

  • 控制请求节奏。 在紧密循环中频繁请求同一搜索是最快触发限速的方式。将请求分散,并变化搜索参数。
  • 依赖 IP 轮换。 住宅代理池将请求分散到众多真实用户 IP 上,使单个地址不会触发速率限制。Crawling API 已为你处理这一切;如果你自己搭建技术栈,这部分是关键所在。
  • 关注状态码。 运行开始返回挑战或错误,表明当前速率或 IP 层级已不足够。将代理状态错误码视为信号,而非噪声。

完整策略手册请参见如何在不被封锁的情况下抓取网站。如果你想将本方案与无头浏览器构建进行对比,用 Python 和 Selenium 进行网络爬取详细介绍了那套技术栈。

诚实的部分:服务条款与合规

抓取大型商业旅游网站处于法律灰色地带,"是否被允许"的答案取决于平台的服务条款、你所在的司法管辖区以及数据的用途。Expedia 的条款限制自动化访问,因此无论工具多么谨慎,爬取都可能违反这些条款。本文中的代码并不改变这一点,它只是让技术部分可行。

以下几条值得坚守。只采集公开数据:任何人无需账号即可看到的列表、价格、可用性和评分。遵守网站的 robots.txt 及其声明的速率预期,并将请求量保持在不给服务器造成压力的水平。如果你计划商业化使用数据,请获得许可或签订正式数据协议,而不是默认沉默即同意。永远不要采集个人数据,包括任何与个人用户账号相关的信息。

本指南刻意将范围限定在公开列表数据,因为这是让工作具备可辩护性的边界。本文不涵盖登录墙后的任何内容、账号或个人资料数据、可识别个人的评论,以及任何种类的预订操作。如果你的项目需要超出公开列表的内容,正确路径是 Expedia 的官方 API 或数据协议,而不是更巧妙的爬虫。关于托管访问与原始爬取的差异,什么是 API 代理是一篇有益的延伸阅读。

回顾

核心要点

  • Expedia 是客户端渲染的。 普通请求返回空壳,因此在解析之前必须先渲染页面。
  • 你需要同时具备渲染能力和可信 IP。 带 JS token 的 Crawling API 在一次调用中完成两者;ajax_waitpage_wait 控制等待内容的时间。
  • Cheerio 完成提取工作。 将名称、价格、评分和评论数映射到当前选择器,并预期这些选择器会随时间漂移。
  • 分页通过点击实现。 使用 css_click_selector 在 HTML 返回前触发"显示更多结果"。
  • 坚守公开数据。 遵守 Expedia 的服务条款和 robots.txt;不触碰账号、个人数据或预订操作。

常见问题

为什么普通请求从 Expedia 返回不了酒店数据?

因为 Expedia 以 JavaScript 在客户端渲染其列表。初始 HTML 只是一个骨架,只有在页面脚本于浏览器中运行后才会填充字段,因此原始 HTTP 请求返回状态码 200,但酒店字段为空。要获得真实数据,必须先渲染页面,这正是 Crawling API JS token 为你处理的事情。

Expedia 需要普通 token 还是 JS token?

需要 JS token。普通 token 获取静态 HTML,在 Expedia 上这与普通请求返回的空壳相同。JS token 先在真实浏览器中渲染页面,再交付 HTML,因此 Cheerio 解析时列表已存在。

我的 Cheerio 选择器返回空字符串,是什么变了?

几乎可以确定是 Expedia 的标记发生了变化。其 uitk-* 类名和 data-stid 属性会无通知地更改,上个月还能用的选择器现在可能已失效。在浏览器开发者工具中重新检查实时搜索页并更新选择器。定期进行选择器维护对任何生产爬虫来说都是正常的。

如何获取超过第一批的结果?

Expedia 将额外酒店隐藏在"显示更多结果"按钮后,而非数字翻页。将该按钮的 CSS 选择器经 URL 编码后传给 Crawling API 的 css_click_selector 选项,API 会在渲染后点击它,让额外列表在 HTML 返回前加载完毕。使用此选项时给 page_wait 稍多一点时间。

如何在抓取 Expedia 时避免被封锁?

保持每个 IP 的请求速率较低,变化搜索参数而不是循环同一 URL,并通过轮换住宅 IP 路由,使单个地址不会触发速率限制。Crawling API 为你管理轮换和可信 IP 池;如果你自己搭建技术栈,那部分是值得投入的关键。关注状态码,当开始看到挑战时及时降速。

抓取 Expedia 是否合法?

这取决于 Expedia 的服务条款、你所在的司法管辖区以及你的目的,而他们的条款限制自动化访问。严格限于公开列表数据,遵守 robots.txt 和速率预期,不触碰账号、个人数据或预订操作。如需商业化使用,请获得许可或签订正式数据协议,而不是依赖爬虫。

开始构建

大规模爬取任何站点,无需与基础设施对抗。

Crawlbase 负责处理代理、指纹和 CAPTCHA,让你的团队专注于交付数据流水线,而非维护爬取管道。1,000 次请求免费,无需信用卡。

自助开通 · 无需销售通话 · 提供企业级爬取量