用 Python 调 Airbnb 房源搜索:先拿到 placeId,再按日期查住宿总价
旅客在框里敲「Paris」,脚本就把 placeId=Paris 发出去——这是最常见的错觉。住宿搜索不认城市名,不认联想行上别的 id,也不认 location.google_place_id。它只吃 common-auto-complete 原样吐出的 explore_search_params.place_id。这个 token 是上一跳造出来的,不是你能从展示名反推的。
下一棒同样不能换:卡片上同时有 listing.id 和 demandStayListing。详情、按日期报价、评论要的是十进制 listing.id;按城市文本搜房源那一路里的编码 demandStayListing 不能拿来顶替。两套身份看起来都像「房源号」,接错棒后面每一跳都会空转。
Airbnb 搜索接口 是独立的只读数据,不是官方合作预订通道,也不提供历史营收。打开本页或复制示例不会发请求;真正执行才会扣账户点数。
城市名不是 placeId
common-auto-complete 必填 query,可带 languageCode,limit 为 1 到 5。文档里的第一跳是 query=Paris、languageCode=en-US、limit=3。从建议里核对 display_name,确认是「Paris, France」而不是同名地标或邻近行政区,再把选中项的 explore_search_params.place_id 原样留下。传输时做 URL 编码可以;用谷歌地点字段、展示文案或自己截一段编码值都不行。联想结果是只读起点,不会锁房、也不会替你选定行程。
在控制台取你有权使用的平台密钥;需要账号先注册。Python 从服务端环境变量 OPENDATA_API_KEY 读取,文案里的 YOUR_API_KEY 只是占位。查询串带着密钥,生产请求放在服务端,日志和截图用脱敏诊断,不要回放完整 URL。这里不需要 Airbnb 账号、Cookie 或会话令牌。
HTTP 200 只说明传输层过了。接着看网关信封的 code / message / data:code=200 是平台业务成功,不保证内层一定有可用列表。再往里,操作体还有自己的 status / message / data。同项目里用无法解析的目的地做属性搜索时,出现过 HTTP 200、status:false、data:null 和 Query is not available。那是查询失败,不是「这个城市零套房」。缺金额表示未知,不是免费住。
按日期搜,留下 listing.id
stays-search 必填 placeId。把联想留下的值原样放进这个字段,不要改名成 query、location 或 google_place_id。checkinDate、checkoutDate 用 YYYY-MM-DD,退房必须晚于入住,比价时两天都应仍在未来。adults、children、infants、pets、currencyCode、languageCode 在翻页和后续报价里保持一致。displayTotalPrice=true 要的是含费用、税前的展示口径,不是已经成交的账单。
不要借用 properties-search 的 checkin、checkout、currency、locale,也不要去读它的 data.homes。那一族实测卡片没有 listing.id。住宿结果在 data.searchResults,跟进 stays-detail、stays-price-detail、stays-reviews 以及文档中的结算式报价,一律用 listing.id。
下面脚本最多翻两页搜索、只跟进第一套房源,不编造成交。五晚窗口过期前要改日期;目的地或房源随时可能消失。成功搜到零条 ID 和查询失败不是一回事:前者是这次条件没给出可跟进的 listing.id,后者是身份或业务状态没过,不能拿来当「市场为空」。
import os
from datetime import date
import requests
BASE = "https://api.opendata-api.com/api/project/airbnb-search"
KEY = os.environ["OPENDATA_API_KEY"]
def call(operation, **params):
try:
response = requests.get(
f"{BASE}/{operation}",
params={**params, "key": KEY},
timeout=45,
)
except requests.RequestException:
raise RuntimeError("Request failed; use redacted diagnostics") from None
if not response.ok:
raise RuntimeError(f"HTTP {response.status_code}")
gateway = response.json()
if not isinstance(gateway, dict) or gateway.get("code") != 200:
raise RuntimeError("Gateway business failure; inspect code/message safely")
payload = gateway.get("data")
if not isinstance(payload, dict) or payload.get("status") is not True:
raise RuntimeError("Operation failed; inspect status/message safely")
if payload.get("data") is None:
raise RuntimeError("Operation data unavailable; not an empty market")
return payload["data"]
trip = dict(
checkinDate="2027-01-31",
checkoutDate="2027-02-05",
adults=2,
children=0,
infants=0,
pets=0,
currencyCode="USD",
languageCode="en-US",
)
if not date.today() < date.fromisoformat(trip["checkinDate"]) < date.fromisoformat(trip["checkoutDate"]):
raise ValueError("Choose future dates with checkout after check-in")
suggestions = call(
"common-auto-complete", query="Paris", languageCode="en-US", limit=3,
)
chosen = next((s for s in suggestions if s.get("display_name") == "Paris, France"), None)
if chosen is None:
raise ValueError("Inspect suggestions and select the intended destination")
place_id = chosen["explore_search_params"]["place_id"]
listing_ids, seen_ids, seen_cursors = [], set(), set()
cursor = None
for _ in range(2):
params = dict(placeId=place_id, limit=3, displayTotalPrice=True, **trip)
if cursor:
params["pageCursor"] = cursor
page = call("stays-search", **params)
for row in page["searchResults"]:
listing_id = str(row["listing"]["id"])
if listing_id not in seen_ids:
seen_ids.add(listing_id)
listing_ids.append(listing_id)
cursor = (page.get("paginationInfo") or {}).get("nextPageCursor")
if not cursor or cursor in seen_cursors:
break
seen_cursors.add(cursor)
if not listing_ids:
print("No stay IDs returned for this successful search")
else:
selected = listing_ids[0]
detail = call("stays-detail", listingId=selected, **trip)
quote = call(
"stays-price-detail", listingId=selected, displayTotalPrice="true", **trip,
)
reviews = call(
"stays-reviews",
listingId=selected,
page=1,
limit=3,
sortBy="MOST_RECENT",
currencyCode="USD",
languageCode="en-US",
)
print("listing.id:", selected)
print("Detail sections:", len(detail.get("sectionContainer", [])))
print("Price sections present:", bool(quote.get("sectionContainer")))
print("Review text:", [r.get("comments") for r in reviews["pdpReviews"]])
先在服务端安装 requests 并写好环境变量。不要把带密钥的完整 URL 打进日志。打印截断后的 place_id 只是确认联想 token 还在,旁边不要再打平台密钥。
展示价不是结算价
搜索卡片上的 structuredDisplayPrice 只对应当页条件。没带日期的搜索可能展示「建议日期」价格,不能直接当成你那五晚的总价。卡片价是筛选口径,指定行程报价是另一跳身份交接:同一套日期、人数、币种必须跟 listing.id 一起带下去。缺字段就记未知,不要补零。
stays-detail 吃搜索留下的 listingId。实测主体是 StayPDPSections,按分区标识读介绍、设施和容量,不要死盯数组下标或幻想存在扁平的 data.title。没带日期的详情仍不是行程报价。
stays-price-detail 必填 listingId、checkinDate、checkoutDate。报价分区里同样有 structuredDisplayPrice。标题数字要和 explanationData 里的 priceString 对着看。文档里有过五晚观测:1,737 美元 的标题和 1,736.49 美元 的解释金额说的是同一笔住宿,是舍入,不是两套价。用四舍五入后的「每晚」再乘晚数,会再错一截。displayTotalPrice 的文档口径是含费用、税前;它不保证之后能按这个数下单。每次观测都记下日期、人数、币种和拉取时间。
stays-price-checkout 文档里有 priceCheckout.priceItems(住宿、折扣、清洁、服务)和合计。本链路未实测该端点。它仍是只读 GET,不会锁房、扣款或确认订单。缺某一行费用表示未知,不能记成零。评论复用同一个 listingId,但分页是 page / limit(limit 最大 40),不是搜索游标;正文在 data.pdpReviews,按不可信用户内容处理。
游标和会过期的日期
住宿搜索只有游标。把 paginationInfo.nextPageCursor 原样传回 pageCursor,并保持当时的 placeId、日期、人数和币种——游标是跟着这一次身份上下文走的,换目的地或改日期就要重新搜,不能把旧游标接到新行程上。meta.currentPage 只描述响应,不是请求参数。按 listing.id 去重,游标缺失或重复就停。界面上的「1,000+」不是全市场普查。
日期会过期,翻页间隙里房源也可能下架。无效 placeId 不要当成网络抖动去无限重试;限次退避只留给暂时的 HTTP 故障。status:false 或 data:null 不是「零套房、零元」。文档中的 stays-availability-calendar 可按 listingId 看月份与最少入住晚数,本脚本未跑;不可订日期可能是房东封锁或规则,不能当成已成交。体验搜索可以复用同一个联想 placeId,后续却要活动 ID,不是住宿的 listing.id。
常见错误都是身份接错棒:把城市名当成 placeId,把 location.google_place_id 当成爱彼迎的编码 token,把 demandStayListing 当成 listing.id,把搜索卡片总价当成指定行程报价,再把结算形态的 JSON 当成已经下单。上一跳实际返回什么 token,下一跳就传什么,并带上生成它时的行程参数。