Domain skill
expedia
Markdown synced from browser-harness domain skills.
- Host
- expedia
- Files
- 3
Agent prompt
Use this skill
Copy this prompt into your coding agent to make it enable browser-harness domain skills and read this exact domain folder before automating.
Set up https://github.com/browser-use/browser-harness for me if it is not already installed. If setup is needed, read `install.md` first to install and connect it to my real browser. Then read `SKILL.md` for normal usage and always read `helpers.py` because that is where the browser-harness functions are. Enable domain skills if they are not already enabled by setting `BH_DOMAIN_SKILLS=1` for browser-harness. Use the `expedia` domain skill from `agent-workspace/domain-skills/expedia/`. Read every markdown file for this domain before inventing an approach: - agent-workspace/domain-skills/expedia/api.md - agent-workspace/domain-skills/expedia/automation.md - agent-workspace/domain-skills/expedia/flights-search.md Use those domain-skill notes to complete my task for `expedia` in my real browser. When you open a setup, verification, or task tab, activate it so I can see the active browser tab.
Skill contents
What the agent will read
typeahead API, property URLs, affiliate shortlink chain
api.md
- Field-tested 2026-06 against www.expedia.mx (applies to other POS domains — www.expedia.com, etc. — with the same paths).
- Works with plain curlcffi chrome impersonation (impersonate="chrome120")
- — no session, no PerimeterX/Akamai challenge observed at low volume.
- Response sr[] items of "@type": "gaiaHotelResult" carry exactly what you
Show full markdown
Field-tested 2026-06 against www.expedia.mx (applies to other POS domains —
www.expedia.com, etc. — with the same paths).
Hotel typeahead API (no auth, no cookies)
GET https://www.expedia.mx/api/v4/typeahead/{url-encoded query}
?client=SearchForm&lob=HOTELS&personalize=false®iontype=2047
&dest=true&features=ta_hierarchy&maxresults=10
- Works with plain
curl_cffichrome impersonation (impersonate="chrome120") — no session, no PerimeterX/Akamai challenge observed at low volume. - Response
sr[]items of"@type": "gaiaHotelResult"carry exactly what you need to identify a property:hotelId(the.h{id}.in property URLs),regionNames.shortName/fullName, andcoordinates.lat/long. - Querying
"{hotel name} {city}"reliably puts the right property first; retry with the bare hotel name if the combined query returns nothing. - The contributor reports a 10× speed improvement over search-page extraction. The report includes no paired timings.
Property page URLs
- Canonical shape:
https://www.expedia.mx/{slug}.h{hotelId}.Informacion-Hotel(.Hotel-Informationon the English POS). Routing is by.h{id}.only — the slug is cosmetic and Expedia 301s to the canonical slug (it even rewroteRoma-…→Rome-…in tests). - Useful query params:
startDate=YYYY-MM-DD,endDate=YYYY-MM-DD,rm1=a{adults}[:c{age}...](e.g.rm1=a2:c8:c5),expediaPropertyId={id}. These pre-fill dates and occupancy on the page. - Search fallback:
https://www.expedia.mx/Hotel-Search?destination={name}, {city}&startDate=…&endDate=…&adults=N&rooms=1.
Trap: property pages 429 datacenter-ish IPs
GET on property pages returned HTTP 429 from a residential-but-curl client
while the typeahead kept returning 200. If you only need to construct
URLs for a human to open, never fetch the property page at all; if you must
read one, do it through the user's real browser session (coordinate clicks),
not http_get.
Affiliate shortlink chain (expedia.tpx.lv)
expedia.tpx.lv/<code> links are Travelpayouts partner shortlinks. The
redirect chain (observed):
expedia.tpx.lv/<code> → deals.vio.com?label=…-<TP marker>&ofd=book_uri=destination=<expedia URL> → r.vio.com (same params) → prf.hn/click/camref:…/pubref:…/destination:<expedia URL> (Partnerize) → r.bttn.io (Button attribution) → www.expedia.mx/<City>-Hoteles-<Name>.h<id>.Informacion-Hotel?…&affcid=…PHG…
- The Travelpayouts marker is recoverable from the
label=/utm_content=params on the vio.com hop. - To mint these programmatically, don't reconstruct the chain — use the
Travelpayouts links API:
POST https://api.travelpayouts.com/links/v1/createwith{"trs": <project id>, "marker": <partner id>, "shorten": true, "links": [{"url": "<full expedia URL>", "sub_id": "…"}]}and the account's API token (headerX-Access-Token). Limits: 100 req/min/marker, ≤10 links per request. Returnspartner_urlshortlinks. Full-length brand URLs only (no shortened input). - HEAD requests die mid-chain (some hops 405 HEAD); use GET with
allow_redirectsor walkLocationheaders manually.
Browser Automation
automation.md
- Field-tested against expedia.co.in on 2026-04-27 using browser-harness CDP helpers (goto, js, click, typetext, screenshot).
- ---
- Build your search via URL parameters, not the UI. The date picker, destination autocomplete, and traveller widgets are fragile—coordinate clicks frequently dismiss them or mis-target. Encode everything you can into a...
- ---
Show full markdown
Field-tested against expedia.co.in on 2026-04-27 using browser-harness CDP
helpers (goto, js, click, type_text, screenshot).
TL;DR
Build your search via URL parameters, not the UI. The date picker, destination
autocomplete, and traveller widgets are fragile—coordinate clicks frequently
dismiss them or mis-target. Encode everything you can into a goto() URL, then
use JS clicks only for what the URL can't express (child ages, price filters).
Hotel Search URL Template
https://www.expedia.co.in/Hotel-Search?destination={DEST}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}&rooms={N}&adults={N}&children={N}&childrenAges={age1,age2,...}
Example — 2 adults, 2 children (ages 5 and 7), Tokyo, June 2026:
goto("https://www.expedia.co.in/Hotel-Search?"
"destination=Central+Tokyo,+Tokyo+Prefecture&"
"startDate=2026-06-01&endDate=2026-06-07&"
"rooms=1&adults=2&children=2&childrenAges=5,7")
Note: childrenAges in the URL may not always populate the age dropdowns on
the results page. Verify with a screenshot and set them via JS if needed.
Date Picker — DO NOT USE
The calendar widget is extremely unreliable with coordinate-based clicks:
- Clicking a date cell frequently closes the entire picker instead of selecting the date.
- The picker has month-navigation arrows that are tiny targets.
- "Flexible dates" mode has a different DOM structure with pill-shaped month selectors that also mis-fire.
- Dozens of retry attempts across multiple strategies all failed.
Workaround: Always pass dates via URL parameters (startDate, endDate).
Travellers Widget
The travellers stepper panel works with JS .click() on the increment/decrement
buttons.
Opening the panel
js("""
(()=>{
let btn = document.querySelector('button[data-testid="travelers-field-trigger"]')
|| [...document.querySelectorAll('button')].find(b => b.textContent.includes('traveller'));
if(btn){ btn.click(); return 'opened'; }
return 'not found';
})()
""")
Incrementing children count
js("""
(()=>{
let span = [...document.querySelectorAll('span')].find(s => s.textContent.trim() === 'Children');
if(!span) return 'no Children label';
let container = span.closest('div').parentElement;
let buttons = container.querySelectorAll('button');
let plus = buttons[buttons.length - 1]; // last button is "+"
plus.click();
return 'incremented';
})()
""")
Setting child ages
Child age dropdowns are <select> elements with aria-label like
"Child 1 age", "Child 2 age", etc.
js("""
(()=>{
let selects = document.querySelectorAll('select');
// selects[0] = Child 1 age, selects[1] = Child 2 age, etc.
let setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
selects[0].value = '5';
selects[0].dispatchEvent(new Event('change', {bubbles:true}));
selects[1].value = '7';
selects[1].dispatchEvent(new Event('change', {bubbles:true}));
return 'ages set';
})()
""")
Closing the panel
js("""
(()=>{
let btn = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === 'Done');
if(btn){ btn.click(); return 'done'; }
return 'no Done button';
})()
""")
Price Filter
On the results page, the nightly-price filter has two text inputs and two range sliders.
| Element | Selector |
|---|---|
| Min text input | #price-min |
| Max text input | #price-max |
| Min range slider | input[type="range"][aria-label*="Minimum"] |
| Max range slider | input[type="range"][aria-label*="Maximum"] |
Setting max price
The most reliable method is to click the input, select all, type the value, and press Enter:
click(x, y) # coordinates of #price-max
js("document.getElementById('price-max').select()")
type_text("20000")
press_key("Enter")
Setting the value purely via JS (dispatchEvent) does trigger a re-search but
coordinate-click + type is more reliable for actually applying the filter.
Key Lessons
-
URL-first strategy — Encode destination, dates, room count, adults, children, and child ages in the URL. Only use UI interaction for things the URL cannot express.
-
JS
.click()over coordinate clicks — For buttons inside panels (traveller stepper, Done), find elements by text/attribute and call.click()in JS. Coordinate clicks on overlay panels are unreliable. -
dispatchEventwith{bubbles: true}— Required for React-controlled inputs (selects, text fields). Without bubbling, React state won't update. -
Wait after navigation — After
goto()or pressing Search, callwait_for_load()+time.sleep(3)before interacting. Expedia loads results asynchronously. -
Indian locale —
expedia.co.inshows prices in ₹ (INR). The price filter values include the ₹ symbol and commas in the text input but the underlying range slider uses plain integers.
Flight Search Automation
flights-search.md
- Field-tested 2026-05-22 using browser-harness + Mac Chrome CDP with residential Canadian ISP IP.
- ---
- Expedia uses DataDome (or similar behavior-based anti-bot). Key facts:
- Blocks datacenter IPs with HTTP 429 ("Too Many Requests") and captcha
Show full markdown
Field-tested 2026-05-22 using browser-harness + Mac Chrome CDP with residential Canadian ISP IP.
Anti-Bot Protection
Expedia uses DataDome (or similar behavior-based anti-bot). Key facts:
- Blocks datacenter IPs with HTTP 429 ("Too Many Requests") and captcha pages ("Show us your human side…")
- Even headless Chromium with stealth init scripts gets blocked after 1-2 requests from the same IP
- With residential IP + real Chrome: Works reliably — Canadian ISP IP through real Mac Chrome (not headless) passes DataDome checks without captchas or rate limiting
- From datacenter IPs: Use a cloud browser with residential proxies (Browser Use cloud, ScrapingBee, BrightData)
Search URL Format
Expedia flight search can be initiated via a direct URL — this is the primary recommended approach. It avoids the fragile datepicker, airport autocomplete, and traveller widget entirely:
https://www.expedia.ca/Flights-Search?flight-type=roundtrip&mode=search&trip=roundtrip&leg1=from:YYZ,to:ICN,departure:2027/07/03TANYT&leg2=from:ICN,to:YYZ,departure:2027/07/25TANYT&passengers=adults:1&options=cabin:economy&sort=price%3Aa
URL parameters:
leg1— outbound:from:{IATA},to:{IATA},departure:{YYYY/MM/DD}TANYTleg2— return: same formatpassengers—adults:N,children:Noptions—cabin:economy|premium|business|firstsort—price:a(ascending),price:d(descending),duration:a,departure:a,arrival:a
Navigation in browser-harness
Open the search in a task tab. The pre-filled URL loads results directly; no Search button click is needed:
search_url = "https://www.expedia.ca/Flights-Search?flight-type=roundtrip&mode=search&trip=roundtrip&leg1=from:YYZ,to:ICN,departure:2027/07/03TANYT&leg2=from:ICN,to:YYZ,departure:2027/07/25TANYT&passengers=adults:1&options=cabin:economy&sort=price%3Aa"
new_tab(search_url)
wait_for_load(timeout=25)
if not js('!!document.querySelector("[data-stid*=listing]")'):
raise RuntimeError("Flight listings are absent; inspect the search tab before continuing")
CRITICAL: Do NOT click the Search button. The pre-filled URL already returns search results. Clicking Search just reloads the same page, which can reset applied filters and clear extracted results entirely.
UI Search Flow (when URL approach fails)
When navigating to https://www.expedia.ca/Flights directly, the page has:
Step 1: Select trip type
Radio buttons: "Roundtrip" / "One-way" / "Multi-city"
Selector: button[data-testid*="trip-type"] or a[data-stid*="trip-type"]
Step 2: Origin (Leaving from)
Selector: input[aria-label="Leaving from"] or #origin-airport
Fill: clear first, then type YYZ, wait for autocomplete dropdown,
press Enter (or click first result).
origin = page.locator("#origin-airport")
origin.fill("")
origin.type("YYZ", delay=100)
page.wait_for_timeout(1000)
page.keyboard.press("ArrowDown")
page.keyboard.press("Enter")
Step 3: Destination (Going to)
Same pattern, field #destination-airport or input[aria-label="Going to"].
Step 4: Dates
Dates are a date-picker widget. Do NOT use the date picker via clicks (highly unreliable, see lessons below). Instead:
- Set date fields via JavaScript to bypass the calendar widget:
page.evaluate("""() => {
const depart = document.querySelector('input[aria-label*="Depart"]');
const ret = document.querySelector('input[aria-label*="Return"]');
if (!depart) return false;
// React controlled input — need native setter
const nativeSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype, 'value'
).set;
nativeSetter.call(depart, '2026-07-01');
depart.dispatchEvent(new Event('input', { bubbles: true }));
depart.dispatchEvent(new Event('change', { bubbles: true }));
if (ret) {
nativeSetter.call(ret, '2026-07-23');
ret.dispatchEvent(new Event('input', { bubbles: true }));
ret.dispatchEvent(new Event('change', { bubbles: true }));
}
return true;
}""")
Step 5: Travellers & Cabin
Toggle button: button[data-testid="travelers-field-trigger"]
Inside the panel:
- Adults stepper: +/- buttons within a container
- Cabin class: select element with options (Economy, Premium, Business, First)
- Close with "Done" button: find
buttoncontaining text "Done"
Step 6: Click Search
Search button locators:
button[data-testid="search-button"]button:has-text("Search")- Any
buttoninside the search form
After clicking, wait for results:
page.wait_for_url("**/Flights-Search**", timeout=15000)
page.wait_for_selector('div[data-testid="listing"], div[role="listitem"]', timeout=30000)
Results Page
After search, the page shows a list of flight combinations.
Flight cards
Each flight option is a card. Selectors in the URL-preload results page:
div[data-stid*="listing"]— primary working selector with browser-harness
Selectors for the form-submitted results page:
div[data-testid="listing"]div[role="listitem"]
Extracting flight data from cards
Using browser-harness js() with the URL-preload approach (up to 200
listings):
result = js("""Array.from(document.querySelectorAll('[data-stid*="listing"]'))
.slice(0, 200).map(card => card.innerText).filter(Boolean)""")
if not result:
raise RuntimeError("No flight cards appeared; inspect the page before reporting results")
This example captures at most 200 rendered cards. Verify additional pages or lazy loading before claiming complete results.
Using Playwright for structured extraction (form-submit result page):
results = page.evaluate("""() => {
const cards = document.querySelectorAll('div[data-testid="listing"], div[role="listitem"]');
return Array.from(cards).slice(0, 20).map(card => {
const priceEl = card.querySelector('[data-testid="price"], .uitk-type-500');
const durationEl = card.querySelector('[data-testid="duration"]');
const stopsEl = card.querySelector('[data-testid="stops"]');
const airlineEl = card.querySelector('[data-testid="airline"]');
const times = card.querySelectorAll('[data-testid="time"]');
return {
price: priceEl?.innerText?.trim(),
duration: durationEl?.innerText?.trim(),
stops: stopsEl?.innerText?.trim(),
airline: airlineEl?.innerText?.trim(),
depTime: times[0]?.innerText?.trim(),
arrTime: times[1]?.innerText?.trim(),
};
}).filter(c => c.price);
}""")
Sorting
Sort options: dropdown or button group — look for "Sort by" trigger, then options: "Cheapest" / "Fastest" / "Best" / "Departure" / "Arrival".
Stop Filters — Critical Quirk
Expedia's stop checkboxes are exclusive — not inclusive. Each checkbox shows exactly that many stops, not "up to that many":
| Checkbox | Shows |
|---|---|
| Nonstop | 0 stops only (not "0 or 1") |
| 1 stop | Exactly 1 stop only |
| 2+ stops | 2+ stops only |
To search for 0 OR 1 stop:
- Click "Nonstop" checkbox
- Wait 5+ seconds for page refresh
- Click "1 stop" checkbox
- Wait 5+ seconds for page refresh
Each click triggers a full page reload with new results. Do NOT assume they'll stack — wait for the reload to complete between clicks.
Finding stop filter checkboxes
Filter element IDs are dynamic (e.g., NUM_OF_STOPS-0-:rs:) with random
suffixes. Find by aria-label match:
stops = js("""
JSON.stringify(
Array.from(document.querySelectorAll('input[type=checkbox]'))
.filter(i => (i.getAttribute('aria-label')||'').match(/stop/i))
.map(i => ({id: i.id, label: i.getAttribute('aria-label'), checked: i.checked}))
)
""")
Other Filters
Common filter panels:
- Price range: min/max inputs or slider
- Airlines: Checkbox list
- Times: Checkbox groups for morning/afternoon/evening
Click-through to Fare Selection
To get the REAL total price (not the listing estimate):
- Click a flight card/button → opens fare details or booking page
- Look for "Continue" or "Select" button:
button:has-text("Select") - On the fare selection page, extract the total:
code
div[data-testid="price-summary"] div[data-testid="fare-total"] h4:has-text("Total") - Take screenshot to verify price
Key Lessons
-
URL-first strategy — encode everything you can into the search URL parameter format. Avoid interacting with the datepicker, airport autocomplete, and traveller widget (all fragile).
-
Direct URL loading skips the landing page bot check entirely in many cases. Navigate to
Flights-Search?...URL directly rather than clicking through from the homepage. -
Do NOT click Search with URL pre-load — the pre-filled URL already returns results. Clicking Search reloads the same page and resets any applied filters.
-
Residential IP + real Chrome works — from a home ISP connection with a non-headless Chrome instance, Expedia's DataDome passes without captchas. Cloud browser with residential proxies only needed from datacenter IPs.
-
Stop filters are exclusive — each checkbox toggles a specific stop count. To show 0 AND 1 stop, click both sequentially with 5+ second waits for the page refresh between clicks.
-
Filter IDs are dynamic — use
aria-labelmatching instead of hard-coded IDs for stop checkboxes and other filter elements. -
goto_url()+new_tab()fallback — navigate the existing tab silently first; if results don't load, open a new tab as fallback. This avoids unnecessary macOS window popups. -
Fresh browser context per search — don't reuse cookies/session across repeated searches from the same IP, as bot score accumulates.
-
Use non-headless mode with xvfb on headless servers — headless mode is heavily fingerprintable. The
--disable-blink-features=AutomationControlledflag +navigator.webdriveroverride is essential. -
Wait for async loads — after search or filter application, wait for the URL to change to
/Flights-Search*and for flight elements to appear. 15-30s timeout recommended. Filter changes take 5+ seconds.