用 Python 调 Airbnb 房源搜索:先拿到 placeId,再按日期查住宿总价

发布于 2026年10月15日 · 更新于2026年10月16日

旅客在框里敲「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,下一跳就传什么,并带上生成它时的行程参数。