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。
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 视为密码:它用于验证你的请求身份,请不要将其提交到版本控制中。
搭建项目
创建一个虚拟环境以隔离项目依赖,然后安装爬虫所需的库。
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 选择器提取各个字段。json 和 csv 都随标准库附带,因此导出步骤无需额外安装。
步骤 1:获取渲染后的 Airbnb 搜索页面
首先获取一个完整的页面。导入 CrawlingAPI 类,用你的 JS token 初始化它,然后请求一个 Airbnb 搜索 URL。Airbnb 异步加载结果,因此传入 ajax_wait 和 page_wait 来等待动态内容加载完成后再捕获页面。在解析前检查 Crawlbase 的 pc_status,可以让失败请求显式报错,而不是静默失败。
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 搜索页面标记,而不是普通请求返回的外壳。这证明渲染有效,然后再编写任何选择器。
Airbnb 需要在一次调用中同时提供渲染后的页面和可信 IP,而上面的 ajax_wait 和 page_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"之后的文本就是位置。
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 生成的类名(如 r1dxllyb 和 a8jt5op)会在没有任何通知的情况下更改。请将这里的选择器视为起始模板而非契约。当某个字段返回空值时,在浏览器的开发者工具中重新检查实时卡片并更新选择器。定期维护选择器对于任何生产爬虫来说都是正常的,而不是出了问题的迹象。
步骤 4:处理跨搜索页的分页
一个搜索页面只是结果集的一个切片。Airbnb 通过 items_offset 查询参数进行分页,每次将偏移量增加页大小(每页 18 张卡片)来逐步翻页。当分页导航存在时,从中读取下一页的偏移量,或者自己步进偏移量直到上限,防止大型市场的结果无休止地滚动。在获取函数外层加一个小型重试包装器,可以防止单个慢速页面中断整次运行。
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,放弃后返回 None。collect_all_listings 通过递增 items_offset 遍历每一页,将爬取上限设为你的 max_pages,并在某页返回零张卡片时提前停止(这是结果的自然结束)。两次翻页之间的 time.sleep(2) 控制了运行节奏,防止你对网站发起密集请求。
步骤 5:组装完整脚本
现在将各个部分拼接成一个可运行的脚本:跨页收集房源,然后将记录导出为 JSON 和 CSV。
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 在其卡片上渲染的样式。
[ { "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_wait和page_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 次请求免费,无需信用卡。
