Airbnb 是网络上最大的短租平台之一,其公开房源页面包含驱动市场研究、价格追踪和旅游比价所需的结构化字段:房源标题、每晚价格、评分与评论数、位置以及所提供的设施。对于研究某城市每晚房价或追踪市场走势的人来说,这些公开房源数据是原材料,而手动跨数十个房源收集数据既慢又容易出错。

本指南将向你展示如何用 Python 以可靠的方式抓取 Airbnb 房源数据。你将构建一个小型可运行的爬虫,通过 Crawling API 获取渲染后的 Airbnb 搜索页面,使用 BeautifulSoup 解析你需要的房源字段,处理分页,并导出为整洁的 JSON 和 CSV 文件。整个演示范围限定在公开房源数据:不涉及房东或房客的个人信息,也不涉及与具名人员相关联的单条评论。靠近文末的法律部分并非套话,请在将此工具指向任何实际体量之前仔细阅读。

你将构建的内容

一个 Python 脚本,它接受某地区和入住日期的 Airbnb 公开搜索 URL,收集每个结果页面上的房源卡片,并为每个房源提取一条结构化记录。本示例使用的是美国境内的住宿,但同样的方法适用于任何公开搜索 URL。我们提取以下字段:

  • 标题 卡片上显示的房源标题,例如"Cabin in Woodstock"。
  • 价格 房源上显示的每晚价格。
  • 评分 整体客人评分及旁边的评论数量。
  • 位置 从房源标题中解析出的地名。
  • 设施 卡片展示的主要设施,例如泳池、WiFi 或厨房。
  • 链接 房源页面的规范 URL。

上述所有字段均为公开且非个人信息。爬虫不会触及房东姓名、房客个人资料、私信,或任何归属于具名个人的评论。

为什么普通请求在 Airbnb 上会失败

如果你用普通的 HTTP 客户端请求 Airbnb 搜索 URL,你会收到状态码 200 的响应,但正文中几乎没有任何房源数据。有两个原因对你不利。其一,Airbnb 通过 JavaScript 在浏览器中渲染搜索结果,因此初始 HTML 是一个薄薄的外壳,只有在页面脚本运行后才会填充内容。解析第一次响应,你得到的是一个空网格,而不是房源卡片。其二,Airbnb 能快速标记自动化流量:来自数据中心的 IP 以及看起来不像真实浏览器的请求模式,在到达渲染内容之前就会被限速、IP 封锁或发起质询。

因此,一个可用的 Airbnb 爬虫需要在一次请求中满足两点:一个能真正渲染页面的浏览器,以及一个平台认为是真实访客的 IP。你可以自己组合一个无头浏览器加上一批轮换住宅代理,但将这些拼合在一起并保持其正常运转是工作的大头。Crawling API 将两者合并到一次调用中:你发送带有 JavaScript token 的 URL,它在可信 IP 后渲染页面,并返回供你解析的完整 HTML。

为什么需要 JS token

Crawlbase 提供两种 token 类型。普通 token 获取静态 HTML;JavaScript(JS)token 会先在真实浏览器中渲染页面。Airbnb 在客户端填充其搜索网格,因此你在这里需要使用 JS token。普通 token 返回的外壳与普通请求一样,其中几乎没有什么有用的内容可供解析。

前置条件

在编写任何代码之前,你需要准备好几样东西。这些都不需要花很长时间。

基础 Python 知识。你应该能够编写和运行 Python 脚本,并使用 pip 安装包。如果你对解析部分不熟悉,BeautifulSoup 指南是本教程的好伙伴。

Python 3.8 或更高版本。使用 python --version 确认你的版本。如果没有,请从 python.org 或通过 Anaconda 等发行版安装,并确保 Python 在你的 PATH 中。

Crawlbase 账户和 JS token。注册账户,打开控制台,从账户文档页面复制你的 JavaScript(JS)token。Crawlbase 提供 1,000 次免费请求作为起点,足以完成本指南的学习,且只对成功的请求计费。请将 token 视为密码:它用于验证你的请求身份,请不要将其提交到版本控制中。

搭建项目

创建一个虚拟环境以隔离项目依赖,然后安装爬虫所需的库。

bash
python --version

python -m venv airbnb_env
source airbnb_env/bin/activate

pip install crawlbase beautifulsoup4

在 Windows 上,使用 airbnb_env\Scripts\activate 代替 source 那行来激活环境。两个依赖包承担实际工作:crawlbase 是 Crawling API 的官方客户端,beautifulsoup4 解析返回的 HTML,让你可以通过 CSS 选择器提取各个字段。jsoncsv 都随标准库附带,因此导出步骤无需额外安装。

步骤 1:获取渲染后的 Airbnb 搜索页面

首先获取一个完整的页面。导入 CrawlingAPI 类,用你的 JS token 初始化它,然后请求一个 Airbnb 搜索 URL。Airbnb 异步加载结果,因此传入 ajax_waitpage_wait 来等待动态内容加载完成后再捕获页面。在解析前检查 Crawlbase 的 pc_status,可以让失败请求显式报错,而不是静默失败。

python
from crawlbase import CrawlingAPI

api = CrawlingAPI({"token": "YOUR_CRAWLBASE_TOKEN"})

OPTIONS = {
    "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/122.0",
    "ajax_wait": "true",
    "page_wait": 5000,
}

def crawl(page_url):
    response = api.get(page_url, OPTIONS)
    if response["headers"]["pc_status"] == "200":
        return response["body"].decode("utf-8")
    print(f"Request failed: {response['headers']['pc_status']}")
    return None

if __name__ == "__main__":
    search_url = "https://www.airbnb.com/s/United-States/homes?checkin=2026-07-10&checkout=2026-07-12&adults=2"
    html = crawl(search_url)
    print(html[:500] if html else "No HTML returned")

两个等待选项对于 Airbnb 这种客户端渲染目标至关重要。ajax_wait 告诉 API 等待异步内容加载完成,page_wait 则在加载后固定等待若干毫秒,让晚渲染的卡片在捕获页面前出现。五秒是一个合理的起始值;如果结果返回得很少,可以适当增大。搜索 URL 包含地点、入住和退房日期以及成人人数,与 Airbnb 自己的搜索功能一致。用 python airbnb_scraper.py 运行脚本,你应该会看到真实的 Airbnb 搜索页面标记,而不是普通请求返回的外壳。这证明渲染有效,然后再编写任何选择器。

Crawlbase Airbnb Scraper

Airbnb 需要在一次调用中同时提供渲染后的页面和可信 IP,而上面的 ajax_waitpage_wait 选项正是为此而设。Crawling API 接受 JS token,在真实浏览器中运行页面,在服务端轮换住宅 IP,并将完整 HTML 返回给你,从而省去了自己运营无头浏览器集群和代理池的麻烦。先在免费套餐上将其指向一个公开搜索页面,并且只对成功的请求付费。

步骤 2:检查房源卡片并找到选择器

拿到完整页面后,下一步是找到每个字段所在的位置。在浏览器中打开同一搜索 URL,右键点击一个房源卡片,选择"检查"以打开开发者工具。Airbnb 将每个结果包裹在其站点内容区域内的一个条目元素中,标题、评分和价格各自位于该卡片内可预测的位置。

根据历史标记,房源容器及其内部字段对应以下选择器。这些是起始模板:Airbnb 生成的类名会轮换,因此每当某个字段返回空值时,请在实时页面上重新检查。

  • 房源容器: div#site-content div[itemprop="itemListElement"]
  • 标题: div[data-testid="listing-card-title"]
  • 评分与评论: 卡片内的 span.r1dxllyb
  • 每晚价格: div._i5duul span.a8jt5op
  • 链接: 卡片锚点上的 href 属性,拼接上 Airbnb 的主机名

步骤 3:解析房源字段

将渲染后的 HTML 加载到 BeautifulSoup 中,遍历每个房源容器,并使用上述选择器提取字段。每次查找都有保护措施,当字段缺失时返回 None 而不是崩溃。标题同时作为位置的来源:Airbnb 将卡片标题写成"类型 in 地点"的格式,因此"in"之后的文本就是位置。

python
from urllib.parse import urljoin
from bs4 import BeautifulSoup

CARD = 'div#site-content div[itemprop="itemListElement"]'

def text_of(node, selector):
    el = node.select_one(selector)
    return el.get_text(strip=True) if el else None

def location_from_title(title):
    if title and " in " in title:
        return title.split(" in ", 1)[1]
    return None

def amenities_of(node):
    spans = node.select('div[data-testid="listing-card-subtitle"] span')
    items = [s.get_text(strip=True) for s in spans]
    return [a for a in items if a]

def parse_card(node):
    title = text_of(node, 'div[data-testid="listing-card-title"]')
    anchor = node.select_one("a")
    href = anchor["href"] if anchor and anchor.get("href") else None
    return {
        "title": title,
        "price": text_of(node, 'div._i5duul span.a8jt5op'),
        "rating": text_of(node, 'span.r1dxllyb'),
        "location": location_from_title(title),
        "amenities": amenities_of(node),
        "link": urljoin("https://www.airbnb.com", href) if href else None,
    }

def scrape_page(html):
    soup = BeautifulSoup(html, "html.parser")
    return [parse_card(node) for node in soup.select(CARD)]

text_of 辅助函数查询单个元素并返回其去除空白后的文本,当元素不存在时返回 None,因此某个字段缺失的卡片不会中断整个循环。评分选择器提取 Airbnb 一起渲染的综合评分和评论数,例如"4.99 (85)"。location_from_title 从卡片标题中读取地点,amenities_of 收集 Airbnb 在卡片副标题中显示的简短描述。锚点的 href 是相对路径,因此 urljoin 将其转换为完整的房源 URL。注意缺少的内容:这里没有读取任何房东姓名、房东个人资料或任何房客的评论文本。卡片仅展示公开的房源属性,解析器也只收集这些内容。

选择器会发生变化

Airbnb 生成的类名(如 r1dxllyba8jt5op)会在没有任何通知的情况下更改。请将这里的选择器视为起始模板而非契约。当某个字段返回空值时,在浏览器的开发者工具中重新检查实时卡片并更新选择器。定期维护选择器对于任何生产爬虫来说都是正常的,而不是出了问题的迹象。

步骤 4:处理跨搜索页的分页

一个搜索页面只是结果集的一个切片。Airbnb 通过 items_offset 查询参数进行分页,每次将偏移量增加页大小(每页 18 张卡片)来逐步翻页。当分页导航存在时,从中读取下一页的偏移量,或者自己步进偏移量直到上限,防止大型市场的结果无休止地滚动。在获取函数外层加一个小型重试包装器,可以防止单个慢速页面中断整次运行。

python
import time

PAGE_SIZE = 18

def fetch_html(page_url, max_retries=2):
    for attempt in range(max_retries + 1):
        html = crawl(page_url)
        if html:
            return html
        if attempt < max_retries:
            print(f"Retrying ({attempt + 1}/{max_retries})...")
            time.sleep(1)
    print(f"Unable to fetch {page_url}")
    return None

def collect_all_listings(base_url, max_pages):
    records = []
    for page in range(max_pages):
        offset = page * PAGE_SIZE
        sep = "&" if "?" in base_url else "?"
        page_url = f"{base_url}{sep}items_offset={offset}"
        html = fetch_html(page_url)
        if not html:
            break
        page_records = scrape_page(html)
        if not page_records:
            break
        records.extend(page_records)
        time.sleep(2)
    return records

fetch_html 最多重试两次失败的获取,并在两次尝试之间短暂暂停,成功时返回 HTML,放弃后返回 Nonecollect_all_listings 通过递增 items_offset 遍历每一页,将爬取上限设为你的 max_pages,并在某页返回零张卡片时提前停止(这是结果的自然结束)。两次翻页之间的 time.sleep(2) 控制了运行节奏,防止你对网站发起密集请求。

步骤 5:组装完整脚本

现在将各个部分拼接成一个可运行的脚本:跨页收集房源,然后将记录导出为 JSON 和 CSV。

python
import csv
import json
import time
from urllib.parse import urljoin
from crawlbase import CrawlingAPI
from bs4 import BeautifulSoup

api = CrawlingAPI({"token": "YOUR_CRAWLBASE_TOKEN"})

OPTIONS = {
    "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/122.0",
    "ajax_wait": "true",
    "page_wait": 5000,
}

CARD = 'div#site-content div[itemprop="itemListElement"]'
PAGE_SIZE = 18

def crawl(page_url):
    response = api.get(page_url, OPTIONS)
    if response["headers"]["pc_status"] == "200":
        return response["body"].decode("utf-8")
    print(f"Request failed: {response['headers']['pc_status']}")
    return None

def fetch_html(page_url, max_retries=2):
    for attempt in range(max_retries + 1):
        html = crawl(page_url)
        if html:
            return html
        if attempt < max_retries:
            time.sleep(1)
    return None

def text_of(node, selector):
    el = node.select_one(selector)
    return el.get_text(strip=True) if el else None

def location_from_title(title):
    if title and " in " in title:
        return title.split(" in ", 1)[1]
    return None

def amenities_of(node):
    spans = node.select('div[data-testid="listing-card-subtitle"] span')
    items = [s.get_text(strip=True) for s in spans]
    return [a for a in items if a]

def parse_card(node):
    title = text_of(node, 'div[data-testid="listing-card-title"]')
    anchor = node.select_one("a")
    href = anchor["href"] if anchor and anchor.get("href") else None
    return {
        "title": title,
        "price": text_of(node, 'div._i5duul span.a8jt5op'),
        "rating": text_of(node, 'span.r1dxllyb'),
        "location": location_from_title(title),
        "amenities": amenities_of(node),
        "link": urljoin("https://www.airbnb.com", href) if href else None,
    }

def scrape_page(html):
    soup = BeautifulSoup(html, "html.parser")
    return [parse_card(node) for node in soup.select(CARD)]

def collect_all_listings(base_url, max_pages):
    records = []
    for page in range(max_pages):
        offset = page * PAGE_SIZE
        sep = "&" if "?" in base_url else "?"
        html = fetch_html(f"{base_url}{sep}items_offset={offset}")
        if not html:
            break
        page_records = scrape_page(html)
        if not page_records:
            break
        records.extend(page_records)
        time.sleep(2)
    return records

def save_outputs(records):
    with open("airbnb_listings.json", "w") as f:
        json.dump(records, f, indent=2)
    if not records:
        return
    with open("airbnb_listings.csv", "w", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=records[0].keys())
        writer.writeheader()
        for r in records:
            row = {**r, "amenities": ", ".join(r["amenities"])}
            writer.writerow(row)

def main():
    search_url = "https://www.airbnb.com/s/United-States/homes?checkin=2026-07-10&checkout=2026-07-12&adults=2"
    records = collect_all_listings(search_url, max_pages=2)
    save_outputs(records)
    print(f"Saved {len(records)} listings")

if __name__ == "__main__":
    main()

该脚本跨最多两个搜索页面收集房源,将每张卡片解析为一条记录,并通过两秒的睡眠控制循环节奏。save_outputs 同时写入 JSON 文件和 CSV 文件;对于 CSV,它将设施列表展平为逗号分隔字符串,使该列保持可读性。根据你的目标地点和日期调整 max_pages 和搜索 URL。

输出结果示例

python airbnb_scraper.py 运行完整脚本,你将获得每个房源一条整洁的结构化记录,可直接用于分析、存入数据库或导入电子表格。下方的标题、评分和价格反映了 Airbnb 在其卡片上渲染的样式。

json
[
  {
    "title": "Cabin in Woodstock",
    "price": "$70 per night",
    "rating": "4.9 (41)",
    "location": "Woodstock",
    "amenities": ["Wifi", "Kitchen", "Free parking"],
    "link": "https://www.airbnb.com/rooms/12345678"
  },
  {
    "title": "Farm stay in Kalispell",
    "price": "$199 per night",
    "rating": "5.0 (161)",
    "location": "Kalispell",
    "amenities": ["Wifi", "Pool", "Kitchen"],
    "link": "https://www.airbnb.com/rooms/23456789"
  }
]

对应的 CSV 包含相同的列,每行一个房源,可直接导入 pandas 或任意电子表格,按价格区间、评分或位置进行筛选。如果你的目标是专门追踪价格,关于使用 Python 抓取 Airbnb 价格的配套指南对价格字段有更深入的介绍,而网络爬取用于价格情报则讲解了拿到数据后如何使用。

在大规模抓取中保持不被封锁

即使渲染问题已经解决,Airbnb 仍然会监视爬虫形态的流量。一些良好习惯能让更长时间的运行保持健康,这些习惯同样适用于任何防御严密的商业目标。

  • 控制请求速率。在紧密循环中密集请求搜索页面是被限速或发起质询的最快方式。上面的两秒睡眠是下限而非上限;对于较大的任务,适当增大间隔,并在不同目标之间轮换,而不是以全速爬取同一路径。
  • 依赖 IP 轮换。一批住宅 IP 将请求分散到众多真实用户地址上,这样单个地址就不会触发速率限制。Crawling API 会为你处理这个问题;如果你自己搭建技术栈,这是最需要做好的部分。
  • 关注状态码。一次运行开始返回非 200 的 pc_status 值,说明当前速率或 IP 级别已不够用。将这视为退后的信号,而不是可以忽略的噪声。

对于更大规模的爬取,异步 Crawler 可以将请求排队并将结果推送到 webhook,适合在不保持持久连接的情况下运行大量搜索页面。更广泛的操作手册可参考如何在不被封锁的情况下爬取网站。同样的两层方法也适用于其他房源平台,例如抓取 Apartments.com

抓取 Airbnb 是否合法?

抓取 Airbnb 是否被允许,取决于 Airbnb 的服务条款、你所在的司法管辖区以及你对数据的使用方式。Airbnb 的服务条款限制自动化访问、爬取以及从平台采集内容,因此无论你的工具多么谨慎,爬取行为都可能违反这些条款。本文中的代码没有改变这一点,它只是让技术部分得以实现。请阅读 Airbnb 的服务条款和 robots.txt,尊重其中所隐含的速率限制,并将两者视为你采集内容的边界。保持请求量足够低,不要给 Airbnb 的服务器造成压力。

更重要的边界是个人数据。Airbnb 房源是用户发布的内容,这意味着一个页面可能包含真实人物的信息:房东和房客。本指南刻意将范围限定在公开的、非个人性质的房源字段,即标题、每晚价格、评分、评论数、位置、设施和房源链接,因为这是让工作保持在可辩护范围内的边界线。不要采集房东或房客的姓名、个人照片、联系方式或任何其他个人信息,也不要抓取归属于具名房客的单条评论或汇编房东的个人档案。这些都是个人数据,涉及这些数据时,欧盟的 GDPR 和加利福尼亚州的 CCPA 等隐私法律均适用,有其各自的要求和处罚措施。某个字段公开可见并不意味着在它指向某个人时就可以自由采集。

对于超出小规模、公开、非个人样本的任何需求,正确的路径是官方渠道,而不是更巧妙的爬虫。Airbnb 为获批的集成运营合作伙伴和 API 项目,这才是商业或批量使用的正确途径。当对某个具体使用场景有疑虑时,在基于数据构建产品之前,请先寻求法律建议。上述技术演示是一种在公开数据上学习技术机制的方式,而不是大规模采集或触碰任何与个人相关数据的许可。

回顾

核心要点

  • Airbnb 是客户端渲染的。普通请求返回一个带有空网格的薄外壳,因此必须先渲染页面再进行解析。
  • 渲染和可信 IP 缺一不可。带 JS token 的 Crawling API 在一次调用中同时完成两者;ajax_waitpage_wait 控制等待内容加载的时长。
  • 解析卡片,而非个人信息。遍历 itemListElement 容器,读取标题、价格、附评论数的评分、位置、设施和链接,全部都是公开的非个人字段。
  • 分页与导出。步进 Airbnb 的 items_offset 参数直到上限,通过短暂睡眠控制运行节奏,并将记录写入 JSON 和 CSV。
  • 坚守公开数据范围。尊重 Airbnb 的服务条款和 robots.txt,绝不采集房东或房客的个人数据或具名评论,记住 GDPR 和 CCPA 对任何个人数据均适用,并在正式生产环境中使用 Airbnb 的官方或合作伙伴 API。

常见问题

为什么普通请求返回的 Airbnb 网格是空的?

因为 Airbnb 通过 JavaScript 在客户端加载其搜索结果。初始 HTML 是一个外壳,只有在浏览器中运行页面脚本后才会填充内容,因此原始 HTTP 请求返回状态码 200 但没有任何房源卡片。要获取完整的内容集,必须先渲染页面,这正是 Crawling API 的 JS token 为你处理的事情。

Airbnb 需要普通 token 还是 JS token?

需要 JS token。普通 token 获取静态 HTML,而 Airbnb 上的静态 HTML 与普通请求返回的薄外壳相同。JS token 在返回 HTML 之前先在真实浏览器中渲染页面,因此当 BeautifulSoup 解析时,房源卡片及其字段都已经存在。普通请求和 JavaScript 请求消耗的点数不同,请查看你的控制台确认。

我可以从 Airbnb 房源中抓取哪些字段?

公开的、非个人性质的房源字段:房源标题、每晚价格、评分与评论数、位置、主要设施以及房源链接。请坚守那些不需要账户登录、任何访客均可见的数据,绝不采集房东或房客的姓名、个人资料、联系方式,或与具名人员关联的单条评论。这些是个人数据,超出了本指南所涵盖的公开房源范围。

我的选择器返回 None,发生了什么?

几乎可以肯定是 Airbnb 的标记发生了变化。其生成的类名(评分用的 r1dxllyb、价格用的 a8jt5op,以及 listing-card-title 测试 id)会在没有通知的情况下更改,因此上个月有效的选择器可能已经失效。在浏览器开发者工具中重新检查实时卡片并更新选择器。定期维护选择器对于任何生产爬虫来说都是正常的。

如何处理某个地点房源的分页?

Airbnb 通过 items_offset 查询参数步进结果,步长为每页 18 张卡片。上面的 collect_all_listings 函数逐页递增偏移量,将爬取上限设为 max_pages,并在某页返回零张卡片时停止。在两次翻页之间保持短暂的睡眠,让运行节奏保持礼貌。

我可以将抓取的 Airbnb 数据用于商业用途吗?

请将这视为一个法律问题,而非技术问题。Airbnb 的服务条款限制爬取和数据再利用,而且房源可能包含 GDPR 和 CCPA 等法律保护的个人数据,因此商业或批量使用通常需要获得许可。请审阅相关条款,在正式生产环境中使用 Airbnb 的官方或合作伙伴 API,并在基于数据构建产品之前寻求法律建议。

开始构建

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

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

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