Expedia Scraping with Python & Proxies: A Guide


Sarah Whitmore
Scraping Techniques
Expedia publishes an enormous amount of public information: hotel names, ratings, nightly prices, availability windows, and more. If you're doing legitimate market research, tracking published rates for properties you manage, or building a travel comparison feature, that public data is genuinely useful. The catch is that Expedia is a modern JavaScript-heavy site, so a plain HTML parser won't get you very far.
This guide walks through a practical, working approach to collecting public hotel pricing data from Expedia using Python and Playwright. We'll build up the script step by step, handle the "Show More Results" pattern that Expedia uses to lazy-load listings, and then route everything through proxies so a larger collection job runs reliably. Always stay within Expedia's Terms of Service and only gather publicly visible information.
Why Expedia Is Harder to Scrape Than It Looks
Like most contemporary travel sites, Expedia renders much of its content with JavaScript after the initial page load. Prices, ratings, and even the list of hotels themselves often appear only once client-side code has executed and additional API calls have resolved.
That's a problem for traditional, static-HTML tools such as Beautiful Soup. They fetch the raw markup but can't run the JavaScript that populates the page, so the data you actually want simply isn't there yet. (Beautiful Soup is still excellent for simpler, server-rendered pages — see our Beautiful Soup and proxy tips guide for those cases.)
To read the fully rendered page, you need something that behaves like a real browser.
The Right Tool: A Headless Browser
Headless browsers that you control programmatically — Playwright or Puppeteer — are the natural fit here. They execute JavaScript, wait for network requests, interact with page elements, and expose the fully rendered DOM.
Playwright is a particularly strong choice for dynamic sites. It ships official support for Python, JavaScript, Java, and C#, has a clean waiting model, and handles multiple browser engines (Chromium, Firefox, WebKit). We'll use the Python bindings throughout. If you'd rather work in Selenium, the concepts map cleanly onto our dynamic scraping with Selenium guide.
Setting Up Your Environment
You'll need Python installed first. If you don't have it, grab it from the official Python website and follow the installation instructions for your OS.
Then install Playwright and its browser binaries. Open a terminal and run:
pip install playwright
playwright install # Installs browser binaries (Firefox, Chromium, WebKit)Loading an Expedia Search Results Page
We'll instruct Playwright to open an Expedia hotel search page for a chosen city and date range. The snippet below launches Firefox in non-headless mode (so you can watch it work) and navigates to search results for Rome. Switch headless to True once you're happy with the behaviour.
import time
from playwright.sync_api import sync_playwright
# Define search parameters
destination = "Rome (and vicinity), Lazio, Italy"
checkin_date = "2024-07-10"
checkout_date = "2024-07-17"
adults = 2
rooms = 1
# Construct the URL (simplified example - real URLs might be more complex)
expedia_url = f"https://www.expedia.com/Hotel-Search?destination={destination}&startDate={checkin_date}&endDate={checkout_date}&adults={adults}&rooms={rooms}&sort=RECOMMENDED"
print(f"Navigating to: {expedia_url}")
with sync_playwright() as playwright_instance:
browser = playwright_instance.firefox.launch(
headless=False, # Set to True for background execution
)
page = browser.new_page()
page.goto(expedia_url)
# Allow time for dynamic content to load
print("Page loaded, waiting for dynamic content...")
time.sleep(5) # Increased wait time for potentially slow loads
print("Closing browser.")
browser.close()Note: We're using a pre-built URL for clarity. A more advanced version could automate filling in the search form on Expedia's homepage and clicking Search — a good exercise once you've finished this tutorial.
Locating and Extracting Hotel Cards
Once the results load, each hotel is rendered as a card. Playwright uses selectors to find elements; Expedia exposes stable data-stid attributes we can target. First, collect all the cards:
# Inside the 'with sync_playwright() as ...' block:
hotel_cards = page.locator('[data-stid="lodging-card-responsive"]').all()
print(f"Found {len(hotel_cards)} initial hotel cards.")Now loop through the cards and pull out the title, rating, and nightly price using locators scoped to each card:
# Inside the 'with sync_playwright() as ...' block, after locating cards:
extracted_hotels = []
for card in hotel_cards:
# Use locators relative to the card element
content_section = card.locator('div.uitk-card-content-section')
# Extract title
hotel_title_element = content_section.locator('h3')
hotel_title = hotel_title_element.text_content().strip() if hotel_title_element.is_visible() else "N/A"
# Extract rating (handle cases where it might be missing)
rating_element = content_section.locator('span.uitk-badge-base-text')
hotel_rating = rating_element.text_content().strip() if rating_element.is_visible() else "No Rating"
# Extract price (handle cases where it might be missing)
# More specific selector example
price_element = content_section.locator('div[data-test-id="price-summary"] .uitk-text .uitk-type-500')
# Fallback if the primary price selector fails
if not price_element.is_visible():
# Original selector as fallback
price_element = content_section.locator('div.uitk-type-500')
hotel_price = price_element.text_content().strip() if price_element.is_visible() else "Price Unavailable"
hotel_data = {
'title': hotel_title,
'rating': hotel_rating,
'price': hotel_price
}
extracted_hotels.append(hotel_data)
# Optional: Print progress for each hotel
# print(f"Extracted: {hotel_data}")Notice the .is_visible() checks. Not every hotel shows a rating (new listings) or a price (sold out for the selected dates). These guards stop the script from crashing when an element is absent and instead store a placeholder like No Rating or Price Unavailable. This defensive style is essential on real-world sites where markup varies from row to row.
Finally, print the collected data:
# Inside the 'with sync_playwright() as ...' block, after the loop:
print("\n--- Extracted Hotel Data ---")
for hotel in extracted_hotels:
print(hotel)
print("--------------------------")The Combined First Version
Here's the full script for finding and extracting the initially loaded hotels, with a longer navigation timeout for slower connections:
import time
from playwright.sync_api import sync_playwright
# Define search parameters
destination = "Rome (and vicinity), Lazio, Italy"
checkin_date = "2024-07-10"
checkout_date = "2024-07-17"
adults = 2
rooms = 1
# Construct the URL
expedia_url = f"https://www.expedia.com/Hotel-Search?destination={destination}&startDate={checkin_date}&endDate={checkout_date}&adults={adults}&rooms={rooms}&sort=RECOMMENDED"
print(f"Navigating to: {expedia_url}")
with sync_playwright() as playwright_instance:
browser = playwright_instance.firefox.launch(
headless=False, # Set to True for background execution
)
page = browser.new_page()
# Increase default timeout for navigation
page.set_default_navigation_timeout(60000) # 60 seconds
page.goto(expedia_url)
print("Page loaded, waiting for dynamic content...")
time.sleep(5) # Wait for initial load
# --- Scrape hotels ---
hotel_cards = page.locator('[data-stid="lodging-card-responsive"]').all()
print(f"Found {len(hotel_cards)} initial hotel cards.")
extracted_hotels = []
for card in hotel_cards:
content_section = card.locator('div.uitk-card-content-section')
hotel_title_element = content_section.locator('h3')
hotel_title = hotel_title_element.text_content().strip() if hotel_title_element.is_visible() else "N/A"
rating_element = content_section.locator('span.uitk-badge-base-text')
hotel_rating = rating_element.text_content().strip() if rating_element.is_visible() else "No Rating"
# Attempt primary price selector first
price_element = content_section.locator('div[data-test-id="price-summary"] .uitk-text .uitk-type-500')
if not price_element.is_visible():
# Fallback selector
price_element = content_section.locator('div.uitk-type-500')
hotel_price = price_element.text_content().strip() if price_element.is_visible() else "Price Unavailable"
hotel_data = {
'title': hotel_title,
'rating': hotel_rating,
'price': hotel_price
}
extracted_hotels.append(hotel_data)
print("\n--- Initial Extracted Hotel Data ---")
for hotel in extracted_hotels:
print(hotel)
print("------------------------------------")
print("Closing browser.")
browser.close()Running it produces something like the following (prices and availability change constantly):
[
{ 'title': 'Hotel Artemide', 'rating': '9.6', 'price': '$450' },
{ 'title': 'iQ Hotel Roma', 'rating': '9.2', 'price': '$380' },
{ 'title': 'UNAHOTELS Decò Roma', 'rating': '8.8', 'price': '$320' },
{ 'title': 'The Hive Hotel', 'rating': '8.6', 'price': '$295' },
...
]This first pass usually doesn't capture every hotel. Expedia loads additional results as you scroll or when you click a "Show More Results" button — which is exactly where a real browser earns its keep.
Loading All Results by Clicking "Show More"
To gather the complete list, we repeatedly click the "Show More Results" button until it disappears (or an error occurs), then scrape the full set of cards. Add this before the card-scraping loop:
# Inside the 'with sync_playwright() as ...' block, before card scraping:
print("Checking for 'Show More Results' button...")
show_more_button_selector = 'button[data-stid="show-more-results"]' # Verify in browser dev tools
while page.locator(show_more_button_selector).is_visible():
print("Found 'Show More Results' button, clicking...")
try:
page.locator(show_more_button_selector).click(timeout=10000) # 10 second timeout for click
print("Waiting for more results to load...")
# Wait for network activity to settle or just a fixed delay
page.wait_for_load_state('networkidle', timeout=15000) # Wait up to 15s for network to be idle
# Alternative fixed wait: time.sleep(4)
except Exception as e:
print(f"Could not click 'Show More' or timed out waiting: {e}")
break # Exit loop if button disappears or errors occur
print("'Show More Results' button no longer visible or process finished.")
# Now proceed to scrape *all* the cards that are currently loadedThe loop looks for the button, clicks it if visible, and waits for new content to settle using networkidle (or a fixed delay as a fallback). It keeps going until the button vanishes or something errors out. This is a reusable pattern for any site that paginates by lazy-loading.
Here's the complete script with the "Show More" logic folded in:
import time
from playwright.sync_api import sync_playwright
# Define search parameters
destination = "Rome (and vicinity), Lazio, Italy"
checkin_date = "2024-07-10"
checkout_date = "2024-07-17"
adults = 2
rooms = 1
# Construct the URL
expedia_url = f"https://www.expedia.com/Hotel-Search?destination={destination}&startDate={checkin_date}&endDate={checkout_date}&adults={adults}&rooms={rooms}&sort=RECOMMENDED"
print(f"Navigating to: {expedia_url}")
with sync_playwright() as playwright_instance:
browser = playwright_instance.firefox.launch(
headless=False, # Set to True for background execution
)
page = browser.new_page()
page.set_default_navigation_timeout(60000) # 60 seconds
page.goto(expedia_url)
print("Page loaded, initial wait...")
time.sleep(5) # Wait for initial load
# --- Handle "Show More Results" ---
print("Checking for 'Show More Results' button...")
# Note: Selector might change, inspect element if needed
show_more_button_selector = 'button[data-stid="show-more-results"]'
while page.locator(show_more_button_selector).is_visible():
print("Found 'Show More Results' button, clicking...")
try:
page.locator(show_more_button_selector).click(timeout=10000)
print("Waiting for more results to load...")
# Wait for network activity to settle or just a fixed delay
page.wait_for_load_state('networkidle', timeout=15000)
# time.sleep(4) # Alternative fixed wait
except Exception as e:
print(f"Could not click 'Show More' or timed out waiting: {e}")
break
print("'Show More Results' button no longer visible or process finished.")
# --- Scrape all loaded hotels ---
print("Scraping all loaded hotel cards...")
hotel_cards = page.locator('[data-stid="lodging-card-responsive"]').all()
print(f"Found {len(hotel_cards)} total hotel cards.")
extracted_hotels = []
for card in hotel_cards:
content_section = card.locator('div.uitk-card-content-section')
hotel_title_element = content_section.locator('h3')
hotel_title = hotel_title_element.text_content().strip() if hotel_title_element.is_visible() else "N/A"
rating_element = content_section.locator('span.uitk-badge-base-text')
hotel_rating = rating_element.text_content().strip() if rating_element.is_visible() else "No Rating"
price_element = content_section.locator('div[data-test-id="price-summary"] .uitk-text .uitk-type-500')
if not price_element.is_visible():
price_element = content_section.locator('div.uitk-type-500') # Fallback
hotel_price = price_element.text_content().strip() if price_element.is_visible() else "Price Unavailable"
hotel_data = {
'title': hotel_title,
'rating': hotel_rating,
'price': hotel_price
}
extracted_hotels.append(hotel_data)
print("\n--- Final Extracted Hotel Data ---")
# Print only the first few and the total count for brevity
for i, hotel in enumerate(extracted_hotels):
if i < 5: # Print first 5
print(hotel)
elif i == 5:
print("...") # Indicate more data exists
print(f"(Total: {len(extracted_hotels)} hotels)")
print("---------------------------------")
print("Closing browser.")
browser.close()Why Proxies Matter for Larger Jobs
Pulling one city and one date range now and then is unlikely to cause any friction — Expedia serves huge amounts of traffic every second. But the moment you want breadth (many cities, many date windows, price snapshots over time) all those requests originate from a single IP address. That concentration is what causes trouble: rate limits, CAPTCHAs, and eventually a temporarily blocked IP.
Web scraping at scale is fundamentally about distributing your requests so no single source hammers the server. A proxy server routes each request through a different IP, so your collection load spreads across many addresses instead of piling onto one. Residential and mobile proxies work well here because their addresses belong to real consumer connections, which is exactly what a travel site expects to see.
Evomi's proxies are ethically sourced and Swiss-based, with residential plans from $0.49/GB and free trials on residential, mobile, and datacenter pools so you can benchmark before committing. Rotating your IP frequently keeps request volume per address low and your job running smoothly.
Adding a Proxy to Playwright
Integrating a proxy is a one-line change to the browser launch. Grab your host, port, username, and password from the Evomi dashboard, then pass a proxy dict:
# Example using Evomi Residential Proxy (HTTP)
proxy_server = "rp.evomi.com:1000" # Or HTTPS on 1001, SOCKS5 on 1002
proxy_username = "YOUR_EVOMI_USERNAME"
proxy_password = "YOUR_EVOMI_PASSWORD"
browser = playwright_instance.firefox.launch(
headless=False, # Keep False for testing, True for production
proxy={
'server': proxy_server,
'username': proxy_username,
'password': proxy_password,
}
)
# ... rest of your script (new_page, goto, etc.)Every request from this browser instance now travels through the specified proxy. To confirm the exit IP is what you expect, you can point the browser at a checker such as geo.evomi.com before running the full job. If you'd rather skip managing browser infrastructure entirely, Evomi's Scraping Browser is a managed, cloud-hosted headless Chromium endpoint (Playwright- and Puppeteer-compatible over wss://browser.evomi.com) with proxying built in.
Practical Tips for Reliable Collection
Verify selectors regularly. Expedia updates its front end often. If a run returns empty titles or prices, open dev tools and re-check the
data-stidand class names.Prefer explicit waits over long sleeps.
wait_for_load_state('networkidle')andexpect(locator).to_be_visible()are more robust than fixedtime.sleep()calls.Rotate IPs and pace requests. A steady, moderate request rate paired with rotating residential IPs is far more stable than bursts from one address.
Store data incrementally. Write results to CSV or a database inside the loop so a crash mid-run doesn't cost you everything.
Respect the site. Only collect publicly visible data, follow Expedia's Terms of Service, and keep your footprint reasonable.
Where This Leads
Once you have clean, structured hotel data, the applications are broad: dynamic pricing dashboards that compare rates across dates and providers, availability trend tracking for a market you operate in, or competitive research for a hotel you own. The same Playwright-plus-proxy pattern shown here transfers to plenty of other JavaScript-heavy sites — the selectors change, but the workflow of rendering, paginating, extracting defensively, and distributing requests stays the same.
Expedia publishes an enormous amount of public information: hotel names, ratings, nightly prices, availability windows, and more. If you're doing legitimate market research, tracking published rates for properties you manage, or building a travel comparison feature, that public data is genuinely useful. The catch is that Expedia is a modern JavaScript-heavy site, so a plain HTML parser won't get you very far.
This guide walks through a practical, working approach to collecting public hotel pricing data from Expedia using Python and Playwright. We'll build up the script step by step, handle the "Show More Results" pattern that Expedia uses to lazy-load listings, and then route everything through proxies so a larger collection job runs reliably. Always stay within Expedia's Terms of Service and only gather publicly visible information.
Why Expedia Is Harder to Scrape Than It Looks
Like most contemporary travel sites, Expedia renders much of its content with JavaScript after the initial page load. Prices, ratings, and even the list of hotels themselves often appear only once client-side code has executed and additional API calls have resolved.
That's a problem for traditional, static-HTML tools such as Beautiful Soup. They fetch the raw markup but can't run the JavaScript that populates the page, so the data you actually want simply isn't there yet. (Beautiful Soup is still excellent for simpler, server-rendered pages — see our Beautiful Soup and proxy tips guide for those cases.)
To read the fully rendered page, you need something that behaves like a real browser.
The Right Tool: A Headless Browser
Headless browsers that you control programmatically — Playwright or Puppeteer — are the natural fit here. They execute JavaScript, wait for network requests, interact with page elements, and expose the fully rendered DOM.
Playwright is a particularly strong choice for dynamic sites. It ships official support for Python, JavaScript, Java, and C#, has a clean waiting model, and handles multiple browser engines (Chromium, Firefox, WebKit). We'll use the Python bindings throughout. If you'd rather work in Selenium, the concepts map cleanly onto our dynamic scraping with Selenium guide.
Setting Up Your Environment
You'll need Python installed first. If you don't have it, grab it from the official Python website and follow the installation instructions for your OS.
Then install Playwright and its browser binaries. Open a terminal and run:
pip install playwright
playwright install # Installs browser binaries (Firefox, Chromium, WebKit)Loading an Expedia Search Results Page
We'll instruct Playwright to open an Expedia hotel search page for a chosen city and date range. The snippet below launches Firefox in non-headless mode (so you can watch it work) and navigates to search results for Rome. Switch headless to True once you're happy with the behaviour.
import time
from playwright.sync_api import sync_playwright
# Define search parameters
destination = "Rome (and vicinity), Lazio, Italy"
checkin_date = "2024-07-10"
checkout_date = "2024-07-17"
adults = 2
rooms = 1
# Construct the URL (simplified example - real URLs might be more complex)
expedia_url = f"https://www.expedia.com/Hotel-Search?destination={destination}&startDate={checkin_date}&endDate={checkout_date}&adults={adults}&rooms={rooms}&sort=RECOMMENDED"
print(f"Navigating to: {expedia_url}")
with sync_playwright() as playwright_instance:
browser = playwright_instance.firefox.launch(
headless=False, # Set to True for background execution
)
page = browser.new_page()
page.goto(expedia_url)
# Allow time for dynamic content to load
print("Page loaded, waiting for dynamic content...")
time.sleep(5) # Increased wait time for potentially slow loads
print("Closing browser.")
browser.close()Note: We're using a pre-built URL for clarity. A more advanced version could automate filling in the search form on Expedia's homepage and clicking Search — a good exercise once you've finished this tutorial.
Locating and Extracting Hotel Cards
Once the results load, each hotel is rendered as a card. Playwright uses selectors to find elements; Expedia exposes stable data-stid attributes we can target. First, collect all the cards:
# Inside the 'with sync_playwright() as ...' block:
hotel_cards = page.locator('[data-stid="lodging-card-responsive"]').all()
print(f"Found {len(hotel_cards)} initial hotel cards.")Now loop through the cards and pull out the title, rating, and nightly price using locators scoped to each card:
# Inside the 'with sync_playwright() as ...' block, after locating cards:
extracted_hotels = []
for card in hotel_cards:
# Use locators relative to the card element
content_section = card.locator('div.uitk-card-content-section')
# Extract title
hotel_title_element = content_section.locator('h3')
hotel_title = hotel_title_element.text_content().strip() if hotel_title_element.is_visible() else "N/A"
# Extract rating (handle cases where it might be missing)
rating_element = content_section.locator('span.uitk-badge-base-text')
hotel_rating = rating_element.text_content().strip() if rating_element.is_visible() else "No Rating"
# Extract price (handle cases where it might be missing)
# More specific selector example
price_element = content_section.locator('div[data-test-id="price-summary"] .uitk-text .uitk-type-500')
# Fallback if the primary price selector fails
if not price_element.is_visible():
# Original selector as fallback
price_element = content_section.locator('div.uitk-type-500')
hotel_price = price_element.text_content().strip() if price_element.is_visible() else "Price Unavailable"
hotel_data = {
'title': hotel_title,
'rating': hotel_rating,
'price': hotel_price
}
extracted_hotels.append(hotel_data)
# Optional: Print progress for each hotel
# print(f"Extracted: {hotel_data}")Notice the .is_visible() checks. Not every hotel shows a rating (new listings) or a price (sold out for the selected dates). These guards stop the script from crashing when an element is absent and instead store a placeholder like No Rating or Price Unavailable. This defensive style is essential on real-world sites where markup varies from row to row.
Finally, print the collected data:
# Inside the 'with sync_playwright() as ...' block, after the loop:
print("\n--- Extracted Hotel Data ---")
for hotel in extracted_hotels:
print(hotel)
print("--------------------------")The Combined First Version
Here's the full script for finding and extracting the initially loaded hotels, with a longer navigation timeout for slower connections:
import time
from playwright.sync_api import sync_playwright
# Define search parameters
destination = "Rome (and vicinity), Lazio, Italy"
checkin_date = "2024-07-10"
checkout_date = "2024-07-17"
adults = 2
rooms = 1
# Construct the URL
expedia_url = f"https://www.expedia.com/Hotel-Search?destination={destination}&startDate={checkin_date}&endDate={checkout_date}&adults={adults}&rooms={rooms}&sort=RECOMMENDED"
print(f"Navigating to: {expedia_url}")
with sync_playwright() as playwright_instance:
browser = playwright_instance.firefox.launch(
headless=False, # Set to True for background execution
)
page = browser.new_page()
# Increase default timeout for navigation
page.set_default_navigation_timeout(60000) # 60 seconds
page.goto(expedia_url)
print("Page loaded, waiting for dynamic content...")
time.sleep(5) # Wait for initial load
# --- Scrape hotels ---
hotel_cards = page.locator('[data-stid="lodging-card-responsive"]').all()
print(f"Found {len(hotel_cards)} initial hotel cards.")
extracted_hotels = []
for card in hotel_cards:
content_section = card.locator('div.uitk-card-content-section')
hotel_title_element = content_section.locator('h3')
hotel_title = hotel_title_element.text_content().strip() if hotel_title_element.is_visible() else "N/A"
rating_element = content_section.locator('span.uitk-badge-base-text')
hotel_rating = rating_element.text_content().strip() if rating_element.is_visible() else "No Rating"
# Attempt primary price selector first
price_element = content_section.locator('div[data-test-id="price-summary"] .uitk-text .uitk-type-500')
if not price_element.is_visible():
# Fallback selector
price_element = content_section.locator('div.uitk-type-500')
hotel_price = price_element.text_content().strip() if price_element.is_visible() else "Price Unavailable"
hotel_data = {
'title': hotel_title,
'rating': hotel_rating,
'price': hotel_price
}
extracted_hotels.append(hotel_data)
print("\n--- Initial Extracted Hotel Data ---")
for hotel in extracted_hotels:
print(hotel)
print("------------------------------------")
print("Closing browser.")
browser.close()Running it produces something like the following (prices and availability change constantly):
[
{ 'title': 'Hotel Artemide', 'rating': '9.6', 'price': '$450' },
{ 'title': 'iQ Hotel Roma', 'rating': '9.2', 'price': '$380' },
{ 'title': 'UNAHOTELS Decò Roma', 'rating': '8.8', 'price': '$320' },
{ 'title': 'The Hive Hotel', 'rating': '8.6', 'price': '$295' },
...
]This first pass usually doesn't capture every hotel. Expedia loads additional results as you scroll or when you click a "Show More Results" button — which is exactly where a real browser earns its keep.
Loading All Results by Clicking "Show More"
To gather the complete list, we repeatedly click the "Show More Results" button until it disappears (or an error occurs), then scrape the full set of cards. Add this before the card-scraping loop:
# Inside the 'with sync_playwright() as ...' block, before card scraping:
print("Checking for 'Show More Results' button...")
show_more_button_selector = 'button[data-stid="show-more-results"]' # Verify in browser dev tools
while page.locator(show_more_button_selector).is_visible():
print("Found 'Show More Results' button, clicking...")
try:
page.locator(show_more_button_selector).click(timeout=10000) # 10 second timeout for click
print("Waiting for more results to load...")
# Wait for network activity to settle or just a fixed delay
page.wait_for_load_state('networkidle', timeout=15000) # Wait up to 15s for network to be idle
# Alternative fixed wait: time.sleep(4)
except Exception as e:
print(f"Could not click 'Show More' or timed out waiting: {e}")
break # Exit loop if button disappears or errors occur
print("'Show More Results' button no longer visible or process finished.")
# Now proceed to scrape *all* the cards that are currently loadedThe loop looks for the button, clicks it if visible, and waits for new content to settle using networkidle (or a fixed delay as a fallback). It keeps going until the button vanishes or something errors out. This is a reusable pattern for any site that paginates by lazy-loading.
Here's the complete script with the "Show More" logic folded in:
import time
from playwright.sync_api import sync_playwright
# Define search parameters
destination = "Rome (and vicinity), Lazio, Italy"
checkin_date = "2024-07-10"
checkout_date = "2024-07-17"
adults = 2
rooms = 1
# Construct the URL
expedia_url = f"https://www.expedia.com/Hotel-Search?destination={destination}&startDate={checkin_date}&endDate={checkout_date}&adults={adults}&rooms={rooms}&sort=RECOMMENDED"
print(f"Navigating to: {expedia_url}")
with sync_playwright() as playwright_instance:
browser = playwright_instance.firefox.launch(
headless=False, # Set to True for background execution
)
page = browser.new_page()
page.set_default_navigation_timeout(60000) # 60 seconds
page.goto(expedia_url)
print("Page loaded, initial wait...")
time.sleep(5) # Wait for initial load
# --- Handle "Show More Results" ---
print("Checking for 'Show More Results' button...")
# Note: Selector might change, inspect element if needed
show_more_button_selector = 'button[data-stid="show-more-results"]'
while page.locator(show_more_button_selector).is_visible():
print("Found 'Show More Results' button, clicking...")
try:
page.locator(show_more_button_selector).click(timeout=10000)
print("Waiting for more results to load...")
# Wait for network activity to settle or just a fixed delay
page.wait_for_load_state('networkidle', timeout=15000)
# time.sleep(4) # Alternative fixed wait
except Exception as e:
print(f"Could not click 'Show More' or timed out waiting: {e}")
break
print("'Show More Results' button no longer visible or process finished.")
# --- Scrape all loaded hotels ---
print("Scraping all loaded hotel cards...")
hotel_cards = page.locator('[data-stid="lodging-card-responsive"]').all()
print(f"Found {len(hotel_cards)} total hotel cards.")
extracted_hotels = []
for card in hotel_cards:
content_section = card.locator('div.uitk-card-content-section')
hotel_title_element = content_section.locator('h3')
hotel_title = hotel_title_element.text_content().strip() if hotel_title_element.is_visible() else "N/A"
rating_element = content_section.locator('span.uitk-badge-base-text')
hotel_rating = rating_element.text_content().strip() if rating_element.is_visible() else "No Rating"
price_element = content_section.locator('div[data-test-id="price-summary"] .uitk-text .uitk-type-500')
if not price_element.is_visible():
price_element = content_section.locator('div.uitk-type-500') # Fallback
hotel_price = price_element.text_content().strip() if price_element.is_visible() else "Price Unavailable"
hotel_data = {
'title': hotel_title,
'rating': hotel_rating,
'price': hotel_price
}
extracted_hotels.append(hotel_data)
print("\n--- Final Extracted Hotel Data ---")
# Print only the first few and the total count for brevity
for i, hotel in enumerate(extracted_hotels):
if i < 5: # Print first 5
print(hotel)
elif i == 5:
print("...") # Indicate more data exists
print(f"(Total: {len(extracted_hotels)} hotels)")
print("---------------------------------")
print("Closing browser.")
browser.close()Why Proxies Matter for Larger Jobs
Pulling one city and one date range now and then is unlikely to cause any friction — Expedia serves huge amounts of traffic every second. But the moment you want breadth (many cities, many date windows, price snapshots over time) all those requests originate from a single IP address. That concentration is what causes trouble: rate limits, CAPTCHAs, and eventually a temporarily blocked IP.
Web scraping at scale is fundamentally about distributing your requests so no single source hammers the server. A proxy server routes each request through a different IP, so your collection load spreads across many addresses instead of piling onto one. Residential and mobile proxies work well here because their addresses belong to real consumer connections, which is exactly what a travel site expects to see.
Evomi's proxies are ethically sourced and Swiss-based, with residential plans from $0.49/GB and free trials on residential, mobile, and datacenter pools so you can benchmark before committing. Rotating your IP frequently keeps request volume per address low and your job running smoothly.
Adding a Proxy to Playwright
Integrating a proxy is a one-line change to the browser launch. Grab your host, port, username, and password from the Evomi dashboard, then pass a proxy dict:
# Example using Evomi Residential Proxy (HTTP)
proxy_server = "rp.evomi.com:1000" # Or HTTPS on 1001, SOCKS5 on 1002
proxy_username = "YOUR_EVOMI_USERNAME"
proxy_password = "YOUR_EVOMI_PASSWORD"
browser = playwright_instance.firefox.launch(
headless=False, # Keep False for testing, True for production
proxy={
'server': proxy_server,
'username': proxy_username,
'password': proxy_password,
}
)
# ... rest of your script (new_page, goto, etc.)Every request from this browser instance now travels through the specified proxy. To confirm the exit IP is what you expect, you can point the browser at a checker such as geo.evomi.com before running the full job. If you'd rather skip managing browser infrastructure entirely, Evomi's Scraping Browser is a managed, cloud-hosted headless Chromium endpoint (Playwright- and Puppeteer-compatible over wss://browser.evomi.com) with proxying built in.
Practical Tips for Reliable Collection
Verify selectors regularly. Expedia updates its front end often. If a run returns empty titles or prices, open dev tools and re-check the
data-stidand class names.Prefer explicit waits over long sleeps.
wait_for_load_state('networkidle')andexpect(locator).to_be_visible()are more robust than fixedtime.sleep()calls.Rotate IPs and pace requests. A steady, moderate request rate paired with rotating residential IPs is far more stable than bursts from one address.
Store data incrementally. Write results to CSV or a database inside the loop so a crash mid-run doesn't cost you everything.
Respect the site. Only collect publicly visible data, follow Expedia's Terms of Service, and keep your footprint reasonable.
Where This Leads
Once you have clean, structured hotel data, the applications are broad: dynamic pricing dashboards that compare rates across dates and providers, availability trend tracking for a market you operate in, or competitive research for a hotel you own. The same Playwright-plus-proxy pattern shown here transfers to plenty of other JavaScript-heavy sites — the selectors change, but the workflow of rendering, paginating, extracting defensively, and distributing requests stays the same.

Author
Sarah Whitmore
Digital Privacy & Cybersecurity Consultant
About Author
Sarah is a cybersecurity strategist with a passion for online privacy and digital security. She explores how proxies, VPNs, and encryption tools protect users from tracking, cyber threats, and data breaches. With years of experience in cybersecurity consulting, she provides practical insights into safeguarding sensitive data in an increasingly digital world.



