How to Scrape Airbnb Listings and Prices
Learn how to scrape Airbnb listings and prices: extract fields from Airbnb's embedded JSON, handle date-based pricing, map pagination, and anti-bot defenses.

If you try to scrape Airbnb the way you'd scrape a plain HTML page, you get an empty shell and no prices. Airbnb is a React app that ships its real data as a JSON blob buried in the page, and the nightly rate you actually want doesn't exist until you tell it your dates and guest count. This guide is hands-on: which fields to pull, how to read the price the right way, how to walk the map past Airbnb's result cap, how to get past its anti-bot, and working Python against SparkProxy's Scraping API. It's the short-term-rental companion to our guide on scraping Booking.com hotel prices, and Airbnb is a different animal, so we cover what makes it different.
What you'll build
- A field map for title, price per night, total with fees, rating, reviews, host, room type, location, and availability
- A parser that reads Airbnb's embedded JSON instead of fighting CSS selectors
- Priced requests that pass check-in, check-out, and guest count so the price is real
- A map-tiling paginator that beats the ~270-result search cap
- An anti-bot escalation ladder so a 403 doesn't kill the run
Scrape responsibly: public data, ToS, and robots
Set the boundary before any code. Scraping data that any anonymous visitor can see on a public listing page sits on defensible ground, and US courts have repeatedly declined to treat access to public pages as unauthorized access. That's not blanket permission. A few rules keep you on the right side:
- Public pages only. Don't scrape anything behind a login, and don't touch host or guest personal data, private messages, or exact addresses (Airbnb only reveals the precise location after a booking anyway).
- Read robots.txt and the ToS as signals. Airbnb's terms prohibit automated collection. That rarely creates criminal exposure for public data, but it tells you the site will fight you technically, so behave: go slow, keep your footprint small, and don't degrade their service.
- Don't rebuild their marketplace. Using pricing and occupancy data internally for market analysis is a different risk profile from republishing scraped listings as a competing site.
- Rate-limit yourself. Hammering the servers is both a detection signal and a bad-neighbor move. A polite crawl outlasts a fast one that gets banned in an hour.
The rest of this article assumes you're collecting public listing data for internal analysis, the same footing as our ethical framing for scraping e-commerce prices. For anything you plan to publish or resell, get legal sign-off.
What data an Airbnb listing exposes
Decide your schema up front, because retrofitting a field after you've scraped 100,000 pages means re-scraping. Here's the field reference most short-term-rental pipelines converge on, with the type and the gotcha that bites people.
| Field | Data type | Example value | Extraction notes |
|---|---|---|---|
| `room_id` | string | `48291756` | The stable join key. It's in the listing URL (`/rooms/48291756`). |
| `title` | string | `Sunny loft near the Mission` | Host-written, changes often. Not a reliable identity key. |
| `price_per_night` | integer | `149` | The nightly rate before fees. Changes with your dates and guest count. |
| `total_price` | integer | `555` | Nights x rate plus cleaning and service fees for a specific stay. Only appears with dates set. |
| `currency` | string | `USD` | Depends on the exit IP and locale. Store it with every price. |
| `rating` | float | `4.92` | Out of 5. Brand-new listings show `New` instead of a number. |
| `review_count` | integer | `218` | Pairs with rating. Zero on new listings. |
| `room_type` | string | `Entire home` | Entire home/apt, Private room, Shared room, or Hotel room. Normalize the vocabulary. |
| `host_name` | string | `Maria` | First name only on public pages. Don't try to resolve real identities. |
| `is_superhost` | boolean | `true` | A flag in the JSON, not always visible as text. |
| `capacity` | integer | `4` | Max guests. Drives which searches the listing appears in. |
| `bedrooms` / `beds` / `baths` | int / int / float | `1` / `2` / `1` | Baths can be `1.5`. Keep the decimal. |
| `lat` / `lng` | float | `37.759, -122.414` | Approximate (jittered) until booked. Lives in the JSON, not visible text. |
| `min_nights` | integer | `2` | Minimum stay. In the availability/calendar data. |
| `amenities` | array | `["Wifi", "Kitchen"]` | Useful for filtering and comps. |
Two of these carry more weight than the rest. Price per night is meaningless on its own, because it's a function of dates and guests, and total price adds fees that can swing the real cost 25 to 40%. A listing at "$149/night" can land at $200 all-in for a 3-night stay once cleaning and service fees hit. If you store the headline rate and skip the fee-inclusive total, your comps are wrong from day one.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
Where Airbnb hides its data: embedded JSON, not HTML
Here's the thing that trips up most first-timers. Airbnb's listing page is a React single-page app. The visible HTML is a skeleton, and the actual listing fields (price, rating, host, amenities, coordinates) arrive as a large JSON state object embedded in a tag, hydrated client-side. Under the hood the app talks to Airbnb's internal GraphQL API, but that API rotates operation hashes and signs requests, so chasing it directly is fragile. The stable target is the JSON that Airbnb itself bakes into the initial page render.
That changes your extraction strategy completely. Instead of writing brittle CSS selectors against a DOM that may not even contain the price, you parse the embedded JSON. The script tag's id changes between redesigns, so don't hard-code it. Grab every JSON-looking script blob and keep the ones that parse:
import json
from bs4 import BeautifulSoup
def extract_embedded_json(html):
"""Airbnb ships listing data as JSON inside <script> tags. The IDs
change between redesigns, so collect every blob that parses cleanly."""
soup = BeautifulSoup(html, "html.parser")
blobs = []
for tag in soup.find_all("script"):
text = (tag.string or "").strip()
if not text or text[0] not in "{[":
continue
try:
blobs.append(json.loads(text))
except json.JSONDecodeError:
continue
return blobs
The data is nested deep inside those blobs. Rather than memorize a path that breaks on the next release, walk the tree for the keys you need:
def deep_find(obj, key):
"""Yield every value stored under `key`, at any depth."""
if isinstance(obj, dict):
for k, v in obj.items():
if k == key:
yield v
yield from deep_find(v, key)
elif isinstance(obj, list):
for item in obj:
yield from deep_find(item, key)
# Example: find the price display object wherever it lives
blobs = extract_embedded_json(html)
prices = [p for blob in blobs for p in deep_find(blob, "structuredDisplayPrice")]
This approach survives Airbnb reshuffling its component tree. When the exact key names change (and they do), you inspect one saved blob, find the new key, and update a string. That's a five-minute fix instead of a rewrite. Keep a raw blob archived during development so you can diff it when a field goes missing.
Quickstart: one listing through the Scraping API
Airbnb combines three hard problems: it's JavaScript-rendered, it fingerprints browsers, and it rate-limits per IP. A managed Scraping API handles proxy rotation, headless Chromium, and anti-bot behind one call, so you send a URL and get back rendered HTML. If you want the build-vs-buy math, see web scraping API vs self-managed proxies.
The base endpoint is https://scrape.sparkproxy.io/api/v1 and it authenticates with an X-API-Key header. The simplest call, rendering a room page through a US IP:
curl -G "https://scrape.sparkproxy.io/api/v1" \
-H "X-API-Key: YOUR_API_KEY" \
--data-urlencode "url=https://www.airbnb.com/rooms/48291756" \
--data-urlencode "render_js=true" \
--data-urlencode "country_code=US"
The same thing in Python, which is where the rest of the guide lives:
import requests
API = "https://scrape.sparkproxy.io/api/v1"
API_KEY = "YOUR_API_KEY" # from your SparkProxy dashboard
resp = requests.get(
API,
headers={"X-API-Key": API_KEY},
params={
"url": "https://www.airbnb.com/rooms/48291756",
"render_js": "true", # Airbnb hydrates content via JS
"country_code": "US", # pins currency and regional pricing
"wait_for": "script#data-deferred-state-0", # wait for the JSON blob
},
timeout=90,
)
resp.raise_for_status()
html = resp.text
render_js=true is non-negotiable here because the JSON blob is written during hydration. The wait_for selector tells the API to hold until the embedded state script is in the DOM before capturing, so you don't grab the page a beat too early and lose the data. If Airbnb renames that script id, swap the selector for main or a container you know renders late.
Reading the price correctly: dates, guests, and fees
This is the section that separates a working Airbnb data scraper from one that quietly produces garbage. A listing has no single price. The nightly rate depends on the check-in and check-out dates (weekends, seasons, and length-of-stay discounts all move it) and on the guest count (extra-guest fees kick in above the base occupancy). Scrape a room with no dates and you get a rough "from" rate that no guest ever actually pays.
To get a real, bookable price, put the stay into the URL as query parameters:
from urllib.parse import urlencode
def priced_room_url(room_id, checkin, checkout, adults=1):
params = {
"check_in": checkin, # "2026-09-12"
"check_out": checkout, # "2026-09-15"
"adults": adults,
}
return f"https://www.airbnb.com/rooms/{room_id}?" + urlencode(params)
url = priced_room_url("48291756", "2026-09-12", "2026-09-15", adults=2)
With dates present, the embedded JSON gains a price breakdown. The shape is roughly this (illustrative, and the exact keys shift between releases, so use deep_find rather than a fixed path):
{
"structuredDisplayPrice": {
"primaryLine": { "price": "$149", "qualifier": "per night" },
"explanationData": {
"priceDetails": [
{ "description": "$149 x 3 nights", "priceString": "$447" },
{ "description": "Cleaning fee", "priceString": "$45" },
{ "description": "Service fee", "priceString": "$63" }
]
},
"secondaryLine": { "price": "$555 total" }
}
}
Parse both the nightly line and the fee breakdown, and compute the all-in nightly cost yourself so listings with different fee structures compare fairly:
import re
def money_to_int(text):
"""'$1,299' -> 1299. None if there are no digits."""
digits = re.sub(r"[^\d]", "", text or "")
return int(digits) if digits else None
def parse_price(display):
nightly = money_to_int(display.get("primaryLine", {}).get("price"))
total = money_to_int(display.get("secondaryLine", {}).get("price"))
fees = {}
for row in display.get("explanationData", {}).get("priceDetails", []):
fees[row["description"]] = money_to_int(row.get("priceString"))
return {"nightly": nightly, "total": total, "fees": fees}
Store the stay dates and guest count alongside every price row. A price is only a fact as (room_id, checkin, checkout, guests, currency, total). Drop any of those and you can't compare two observations honestly, the same discipline our price comparison infrastructure guide applies to retail.
Walking search results and the map
Extracting one room is the easy 5%. The real job is finding every listing in an area, and Airbnb makes that deliberately hard with a result cap. A single search returns roughly 15 pages of about 18 results, so you top out near 270 listings no matter how many actually exist in the city. Paginating harder does nothing once you hit the wall.
The search URL is map-driven. It accepts a bounding box (ne_lat, ne_lng, sw_lat, sw_lng), a search_by_map flag, an items_offset for paging, and the same date and guest params as a room page:
def search_url(location, bbox, checkin=None, checkout=None, adults=1, offset=0):
params = {
"search_by_map": "true",
"ne_lat": bbox["ne_lat"], "ne_lng": bbox["ne_lng"],
"sw_lat": bbox["sw_lat"], "sw_lng": bbox["sw_lng"],
"adults": adults, "items_offset": offset,
}
if checkin: params["check_in"] = checkin
if checkout: params["check_out"] = checkout
return f"https://www.airbnb.com/s/{location}/homes?" + urlencode(params)
The fix for the 270 cap is not more pages, it's a smaller box. Tile the map: if a bounding box returns the cap, it's "full," so split it into four quadrants and search each one. Recurse until every tile comes back under the cap, and you've covered the whole area without ever asking Airbnb for page 16.
CAP = 270 # Airbnb's approximate per-search ceiling
def split_bbox(b):
mid_lat = (b["ne_lat"] + b["sw_lat"]) / 2
mid_lng = (b["ne_lng"] + b["sw_lng"]) / 2
return [
{"ne_lat": b["ne_lat"], "ne_lng": b["ne_lng"], "sw_lat": mid_lat, "sw_lng": mid_lng},
{"ne_lat": b["ne_lat"], "ne_lng": mid_lng, "sw_lat": mid_lat, "sw_lng": b["sw_lng"]},
{"ne_lat": mid_lat, "ne_lng": b["ne_lng"], "sw_lat": b["sw_lat"], "sw_lng": mid_lng},
{"ne_lat": mid_lat, "ne_lng": mid_lng, "sw_lat": b["sw_lat"], "sw_lng": b["sw_lng"]},
]
def tile_search(location, bbox, country="US", depth=0, max_depth=5):
"""Subdivide the map until each tile is under the cap. Returns room ids."""
ids = collect_ids(location, bbox, country) # defined in the full script
if len(ids) >= CAP and depth < max_depth:
found = set()
for quad in split_bbox(bbox):
found |= tile_search(location, quad, country, depth + 1, max_depth)
return found
return set(ids)
Dense city centers subdivide a few levels deep; rural areas resolve in one pass. This quadtree pattern is the map-based cousin of the "narrow the query" trick we use for scraping real estate listings, and it's the single technique that turns a partial city sample into full coverage.
Geo-targeting: currency and regional pricing
Airbnb localizes by where the request appears to come from. A request from a German IP shows prices in EUR with German fee formatting; a US IP shows USD. If you're building a comps dataset, mismatched currencies are a silent corruption bug, so pin the exit country to the market you're studying with the country_code parameter (an ISO 3166-1 alpha-2 code):
MARKETS = [
{"location": "San-Francisco--CA", "country": "US"},
{"location": "London--UK", "country": "GB"},
{"location": "Berlin--Germany", "country": "DE"},
{"location": "Barcelona--Spain", "country": "ES"},
]
for market in MARKETS:
ids = tile_search(market["location"], CITY_BBOX[market["location"]],
country=market["country"])
# ... scrape each room with the same country_code so currency is consistent
Two practical notes. First, always persist the currency you observed, never assume it from the country, because Airbnb occasionally serves a traveler's home currency. Second, some cities apply regulatory caps (registration numbers, night limits) that only surface on in-country requests, so matching the IP to the market gets you the accurate regulatory badges too. City-level bounding boxes are easy to source from any geocoding service once, then cache.
Getting past Airbnb's anti-bot
Airbnb runs real bot mitigation: browser fingerprinting, behavioral signals, and per-IP rate limits that tighten fast. The mistake is reaching for the most expensive proxy tier on every request. The right move is an escalation ladder that starts cheap and only upgrades what gets blocked.
Cheapest to strongest:
- Rotating datacenter with JS render.
render_js=truealone clears a lot of search and room pages. Start here. - Add
stealth=true. Layers a homepage pre-warm, a forced Google referrer, and longer idle delays for pages that sniff headless browsers. - Escalate to
premium_proxy=true. Residential exit IPs for the requests that hard-block datacenter ranges, which tends to be dense-city searches and repeat hits. - Vary
device. Rotatedesktop,mobile, andtabletso repeated requests don't share one fingerprint.
The core discipline is rate, not just IP quality. Even with rotation, 200 requests a minute at one host looks nothing like a person browsing. Spread the load, add jitter, and cap per-IP throughput. Our full playbook is how to avoid getting your proxy blocked. Map symptoms to fixes so you're not guessing mid-run:
| Symptom | Likely cause | Fix |
|---|---|---|
| `403` on the first request | Datacenter IP flagged, or thin headers | Add `stealth=true`; escalate to `premium_proxy=true` |
| `429` after N requests | Per-IP rate too high | Slow down, widen delays, lean on rotation |
| Empty price in the JSON | No dates in the URL, or captured too early | Add `check_in`/`check_out`; `wait_for` the state script |
| Blank listing fields | Page grabbed before hydration | `render_js=true` plus a `wait_for` selector |
| Wrong-currency prices | Geo mismatch | Set `country_code` to the market you want |
The insight most guides skip: let the block response decide when to escalate, instead of paying for residential up front. Send datacenter first, and only flip premium_proxy on for the exact URLs that return a 403. On a typical city crawl that keeps most requests on the cheap tier and reserves the expensive one for pages that genuinely need it.
A complete production scraper
Here's the whole thing wired together: a session, a request function with retry, backoff, and automatic datacenter-to-residential escalation, an embedded-JSON extractor, the map tiler, and a runner that writes clean rows to CSV.
import requests, json, time, csv, random, re
from bs4 import BeautifulSoup
from urllib.parse import urlencode
API = "https://scrape.sparkproxy.io/api/v1"
API_KEY = "YOUR_API_KEY" # from your SparkProxy dashboard
CAP = 270
session = requests.Session()
session.headers.update({"X-API-Key": API_KEY})
def api_get(url, country="US", premium=False, wait_for=None):
"""One call with retry, backoff, and datacenter->residential escalation."""
params = {"url": url, "render_js": "true", "country_code": country, "stealth": "true"}
if wait_for:
params["wait_for"] = wait_for
if premium:
params["premium_proxy"] = "true"
for attempt in range(4):
try:
r = session.get(API, params=params, timeout=90)
except requests.RequestException:
time.sleep(2 ** attempt); continue
if r.status_code == 200:
return r.text
if r.status_code == 403 and not params.get("premium_proxy"):
params["premium_proxy"] = "true" # escalate this URL only
if r.status_code in (403, 429, 500, 502, 503):
time.sleep((2 ** attempt) + random.uniform(0, 1.5)); continue
r.raise_for_status()
return None
def extract_embedded_json(html):
soup = BeautifulSoup(html, "html.parser")
blobs = []
for tag in soup.find_all("script"):
text = (tag.string or "").strip()
if text and text[0] in "{[":
try: blobs.append(json.loads(text))
except json.JSONDecodeError: pass
return blobs
def deep_find(obj, key):
if isinstance(obj, dict):
for k, v in obj.items():
if k == key: yield v
yield from deep_find(v, key)
elif isinstance(obj, list):
for item in obj: yield from deep_find(item, key)
def money_to_int(text):
d = re.sub(r"[^\d]", "", text or "")
return int(d) if d else None
def search_url(location, bbox, checkin, checkout, adults=2, offset=0):
params = {
"search_by_map": "true",
"ne_lat": bbox["ne_lat"], "ne_lng": bbox["ne_lng"],
"sw_lat": bbox["sw_lat"], "sw_lng": bbox["sw_lng"],
"adults": adults, "items_offset": offset,
"check_in": checkin, "check_out": checkout,
}
return f"https://www.airbnb.com/s/{location}/homes?" + urlencode(params)
def collect_ids(location, bbox, country, checkin, checkout):
"""Page a single bounding box, pulling room ids from the embedded JSON."""
ids = set()
for offset in range(0, CAP, 18):
html = api_get(search_url(location, bbox, checkin, checkout, offset=offset),
country=country)
if not html: break
blobs = extract_embedded_json(html)
page_ids = {str(v) for blob in blobs for v in deep_find(blob, "listingId")}
if not page_ids: break
ids |= page_ids
time.sleep(random.uniform(1.5, 3.5))
return ids
def split_bbox(b):
mlat = (b["ne_lat"] + b["sw_lat"]) / 2
mlng = (b["ne_lng"] + b["sw_lng"]) / 2
return [
{"ne_lat": b["ne_lat"], "ne_lng": b["ne_lng"], "sw_lat": mlat, "sw_lng": mlng},
{"ne_lat": b["ne_lat"], "ne_lng": mlng, "sw_lat": mlat, "sw_lng": b["sw_lng"]},
{"ne_lat": mlat, "ne_lng": b["ne_lng"], "sw_lat": b["sw_lat"], "sw_lng": mlng},
{"ne_lat": mlat, "ne_lng": mlng, "sw_lat": b["sw_lat"], "sw_lng": b["sw_lng"]},
]
def tile_search(location, bbox, country, checkin, checkout, depth=0, max_depth=5):
ids = collect_ids(location, bbox, country, checkin, checkout)
if len(ids) >= CAP and depth < max_depth:
out = set()
for quad in split_bbox(bbox):
out |= tile_search(location, quad, country, checkin, checkout, depth + 1, max_depth)
return out
return ids
def scrape_room(room_id, country, checkin, checkout, adults=2):
url = f"https://www.airbnb.com/rooms/{room_id}?" + urlencode(
{"check_in": checkin, "check_out": checkout, "adults": adults})
html = api_get(url, country=country, wait_for="script#data-deferred-state-0")
if not html: return None
blobs = extract_embedded_json(html)
display = next(iter(v for blob in blobs for v in deep_find(blob, "structuredDisplayPrice")), {})
title = next(iter(deep_find(blobs, "name")), "")
return {
"room_id": room_id,
"title": (title or "").strip(),
"nightly": money_to_int((display.get("primaryLine") or {}).get("price")),
"total": money_to_int((display.get("secondaryLine") or {}).get("price")),
"checkin": checkin,
"checkout": checkout,
"country": country,
}
def run(location, bbox, country, checkin, checkout, out="airbnb.csv"):
ids = tile_search(location, bbox, country, checkin, checkout)
print(f"Found {len(ids)} listings in {location}")
cols = ["room_id", "title", "nightly", "total", "checkin", "checkout", "country"]
with open(out, "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=cols); w.writeheader()
for i, rid in enumerate(sorted(ids), 1):
row = scrape_room(rid, country, checkin, checkout)
if row: w.writerow(row)
if i % 25 == 0: print(f" {i}/{len(ids)} scraped")
time.sleep(random.uniform(1.0, 3.0)) # polite pacing
if __name__ == "__main__":
SF = {"ne_lat": 37.812, "ne_lng": -122.355, "sw_lat": 37.705, "sw_lng": -122.514}
run("San-Francisco--CA", SF, "US", "2026-09-12", "2026-09-15", out="sf.csv")
This survives the failure modes that stop naive scrapers: it renders JS and waits for the state script, backs off on 429s, escalates only blocked URLs to residential, tiles the map past the cap, and carries dates and currency into every row. Swap the deep_find keys for your target's current names and adjust the bounding box for your city.
Cost per request: pick the cheapest mode that works
The Scraping API bills in credits, and the mode you pick per request is the biggest lever on cost. Rendering JavaScript and routing through residential IPs cost more, so match the mode to what Airbnb actually forces rather than defaulting to the strongest option.
| Request mode | Credits | Use it for |
|---|---|---|
| Rotating datacenter, HTTP only | 1 | Rarely usable on Airbnb (content is JS-rendered) |
| Rotating datacenter + JS render | 5 | Most searches and room pages; your default |
| Premium (residential), no JS | 10 | Not typical here, since Airbnb needs rendering |
| Premium (residential) + JS render | 25 | Dense-city searches and pages that hard-block datacenter |
| Add-on: `stealth`, `country_code` | +5 each | Layer only when a target requires it |
The math at scale: a run of 50,000 room pages costs 250,000 credits at the datacenter-plus-JS rate (5 each), but 1.25 million if you blindly send premium-plus-JS (25 each) on every page. The escalation ladder is what keeps you near the low end. Send datacenter first, escalate the blocked minority to residential, and you pay the 25-credit rate only where the page won't yield any other way. If you're weighing this against running your own pool, our breakdown of web scraping API vs self-managed proxies has the full comparison.
Frequently asked questions
FAQ
Scraping publicly displayed listing data that any anonymous visitor can view is generally defensible under current US case law, which has declined to treat access to public web pages as unauthorized access. The limits come from elsewhere: don't log in or bypass authentication, don't collect personal data about hosts or guests, and don't republish scraped listings as a competing marketplace. Airbnb's terms prohibit automated collection, so the safest footing is internal analysis of public data. Consult counsel before publishing or reselling anything you collect.
Almost always one of two reasons. Either you didn't pass check-in and check-out dates, so Airbnb has nothing to price and returns only a vague "from" rate, or you captured the page before it finished hydrating, so the JSON price object wasn't in the DOM yet. Fix both by adding check_in and check_out query parameters to the room URL and setting wait_for to the embedded state script so the API waits for the data before capturing.
Airbnb is a React app that embeds its listing data as a JSON state object inside a tag in the initial HTML, then hydrates the page from it. The visible DOM is a skeleton, so a reliable airbnb data scraper parses that embedded JSON rather than scraping CSS selectors. The script tag's id changes between redesigns, so collect every script blob that parses as JSON and walk the tree for the keys you need instead of hard-coding a path.
A single Airbnb search caps at roughly 270 results, about 15 pages of 18, no matter how many listings actually exist in the area. Paging past that returns nothing. The fix for full airbnb listings scraping is map tiling: search a bounding box, and if it returns the cap, split it into four quadrants and search each recursively until every tile comes back under the cap. That turns a partial city sample into complete coverage.
Not for everything. Many searches and room pages return correctly on rotating datacenter IPs with JavaScript rendering and stealth mode enabled. Reserve residential (premium) IPs for the requests that hard-block datacenter ranges, which tend to be dense-city searches and repeated hits on the same pages. The cost-efficient pattern for scraping airbnb prices is to send datacenter first and escalate to residential only on the specific URLs that return a 403.
The biggest difference is where the data lives and how price works. Airbnb hides its fields in an embedded JSON state blob and prices per stay based on your dates and guest count, with cleaning and service fees layered on top, while Booking.com exposes more in server-rendered HTML. Airbnb also caps searches at about 270 results and forces map tiling for full coverage. Our Booking.com scraping guide covers that side; the anti-bot and geo-targeting tactics carry across both.
Get 50% off your first purchase
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Offer ends soon โ claim it before it's gone
Related articles

How to Scrape GraphQL APIs
Learn to scrape GraphQL API data: find the /graphql endpoint, read the query and variables in DevTools, then replay your own queries with cursor pagination.

How to Bypass reCAPTCHA When Web Scraping
How to bypass reCAPTCHA when web scraping the ethical way: how v2 and v3 scoring work, how to raise your reCAPTCHA score, and solvers as a last resort.

Elixir Web Scraping With Proxies: A Practical Guide
Elixir web scraping with proxies: real code for Req, Finch, and HTTPoison proxy config, Floki parsing, Crawly, Task.async_stream, and the SparkProxy API.
