Imported from lesterppo/hermes-travel-cli (
AGENTS.md). Install upstream withnpx skills add lesterppo/hermes-travel-cli. Copyright stays with the author.
AGENTS.md — for AI coding agents working in this repo
What this repo is
travel — token-efficient, AI-agent-native CLI for online travel agents: Agoda, Trip.com, Airbnb, Booking.com. Pure HTTP where possible; headed CDP browser only for Booking.com search results (Akamai bot wall). Expedia and Priceline are hard bot walls and return structured errors. No API keys.
This repo is the successor to hermes-agoda-cli and hermes-ota-cli
(both archived). Everything from those repos lives here under one CLI.
Files
travel.py— unified CLI entry (dispatcher). Parsetravel <platform> <cmd> ....agoda.py— Agoda implementation (~38KB, GraphQL + REST, pure HTTP).ota.py— Trip.com / Airbnb / Booking.com implementation (~24KB, pure HTTP + CDP for booking search).agoda_city_template.json— captured citySearch GraphQL request (deep-patch source).agoda_property_template.json— captured propertyDetailsSearch request.booking_autocomplete_template.json— captured Booking AutoComplete GraphQL request.travel_tool.py— optional native Hermes tool wrapper (platform + action dispatch).
CLI contract (must never change)
- Output pointer JSON on stdout:
{"ok":true,"f":"/tmp/<prefix>_<name>.json","s":N};--jsonprints full response inline. - Errors:
{"ok":false,"err":CODE,"msg":"..."}— never Python tracebacks. - Compact keys:
nname,id,pprice,rrating,rcreview count,ststars,uURL,posneighborhood. - Global flags (
--json,--currency) go before the platform name.
Dispatcher mechanics (travel.py)
PLATFORMS = {"agoda": "agoda", "trip": "ota", "airbnb": "ota",
"booking": "ota", "expedia": "ota", "priceline": "ota"}
travel --json agoda search 5085 ...→ forwards toagoda.main()with argv["travel","--json","search",...](agoda parser has commands at TOP level — no platform token).travel --json trip suggest "Tokyo"→ forwards toota.main()with argv["travel","--json","trip","suggest","Tokyo"](ota parser needs the platform token as first sub-arg).- PITFALL: the two parsers have different shapes (agoda: no platform prefix;
ota: platform prefix). The dispatcher branches on
module_name == "ota". Adding a new platform module requires updatingPLATFORMSand the argv shape branch.
Platform endpoint map (reverse-engineered Aug 2026)
Agoda (agoda.py)
- suggest:
GET /api/cronos/search/GetUnifiedSuggestResult/3/1/1/0/en-us/?searchText=X&guid=<uuid>&origin=US&cid=-1&pageTypeId=1(plain headers). Type map: 0=city, 1=area, 2=hotel, 3=poi, 4=landmark. Tokyo=5085. - search:
POST /graphql/searchop=citySearch— requires ag-* headers (ag-request-id,ag-correlation-id,ag-page-type-id: 103, ...) AND the full captured template (agoda_city_template.json); hand-minimized payloads → HTTP 400. Deep-patch via path keys (_patch_dict). - hotel:
POST /graphql/propertyop=propertyDetailsSearch— ag-* headers (ag-page-type-id: 7), templateagoda_property_template.json. Address keys areaddress1/address2/postalCode. - reviews:
POST /api/cronos/property/review/HotelReviews(plain headers). Score fromscore.demographics[]id=0/providerId=332. Flaky: ~50% of first calls return empty comments — retry once. - trend:
POST /graphql/searchop=priceTrendSearch— city:propertyId:null; property: BOTH cityId + propertyId. - fsearch:
POST /api/gw/flight/TextSearch(plain). - flight:
POST /api/flights-bff/search/v1/flights— exact body shape required (slices+itineraryFilter+whitelabelContext). - act:
POST /api/activities/graphql— activities-specific headers (ag-platform-id:-999,ag-activities-client-id:cart-cart-js). - Header strategy is critical: ag-* headers BREAK
/api/*endpoints (silent HTML 200). Three header sets: ag-* (graphql), plain (/api/*), activities. - Burst rate-limit:
_postretries 429/5xx/bad-JSON with 1.5s/3s backoff. Search--pages Ndedupes by id (ranking shifts between page requests).
Trip.com (ota.py)
- suggest:
POST us.trip.com/restapi/soa2/14975/homepageSuggest{"keyword":X,"locale":"en-us","platform":"online","head":{"cver":"6.0","extension":[{"name":"locale",...},{"name":"platform",...},{"name":"currency",...}]}}. Returnsresult[].{districtId,districtName,districtType,url}; the "Hotels in X" result's URL carries?city=N= the hotelCityId (differs from districtId: Tokyo 294→228, Bangkok 191→359, Seoul 234→274). The CLI exposeshotelCityIdat top level of suggest output. - search:
GET trip.com/hotels/list?city=<hotelCityId>&checkin=&checkout=&adult=&crn=&curr=→ SSR cards split ondata-offline-hotelId(nameclass=hotelName, stars aria"N out of 5 stars|rating", score aria"N out of 10", priceclass=price...>US$N, positionclass=position-desc). - hotel:
GET hotels/detail/?hotelId=&cityId=→__next_f.pushRSC payloads, join + unicode_escape; extracthotelBaseInfo/address/score/totalComment/facilityName/landmark/trafficDesc. getCityList(soa2/34951) returns only POPULAR cities — useless for lookup.- Room-list API (
soa2/33269/getHotelRoomListOversea) spider-blocked (4030).
Airbnb (ota.py)
- API key
d306zoyjsyarp7ifhu67rjxn52tv0t20(public, embedded in HTML) →x-airbnb-api-keyheader. - suggest:
GET api/v3/AutoSuggestionsQuery/<hash>— rawParams query filter is IGNORED server-side (geo-IP suggestions). Don't build lookup on it. - search:
GET airbnb.com/s/<Query--Region>/homes?checkin=&checkout=&adults=→ parse<script id="data-deferred-state-0">:niobeClientData[0][1].data.presentation.staysSearch.results.searchResults[]. Fields: propertyId, subtitle, title, avgRatingA11yLabel (rating+count; empty for new listings), price fromstructuredDisplayPrice.primaryLine.orderedComponents(DiscountedPrice component). - DEMAND-STAY listings: propertyId is synthetic; real room id = base64-decode
demandStayListing.id("DemandStayListing:" → /rooms/). The room URL MUST use the decoded id. - matched flag:
seo.pageMetadata.locationQuery— false = query didn't resolve, listings are geo-fallback. - hotel: room pages return 2.6KB 404 stub to curl; classic rooms load in a browser. 404-guard on title.
Booking.com (ota.py)
- suggest (pure HTTP):
POST booking.com/dml/graphql?lang=en-usop=AutoComplete(template file). Patchvariables.input.prefixQuery. Responsedata.autoCompleteSuggestions.results[].{displayInfo.title, displayInfo.label, destination.{destId,destType,countryCode}}. Tokyo destId=-246227, destType CITY. Oldaccommodations.booking.com/autocomplete.jsonis dead ({"results":[]}). - search (CDP browser ONLY): curl → 202 Akamai challenge; headed CDP renders cards (headless shows only header nav). Selectors:
[data-testid="property-card"], title,price-and-discounted-price(per-night;price-for-x-nightsis the stay caption — WRONG),review-score,a[data-testid="title-link"].
Expedia / Priceline (ota.py)
Akamai "Bot or Not?" (Expedia) and PerimeterX press-hold (Priceline) never
auto-solve, even in persistent-profile headed Chrome. blocked_platform()
returns {"ok":false,"err":"bot-walled"}. Never retry-loop.
Testing (live, network required)
Known-good fixtures (2026-08-14, all verified live):
- Agoda: Tokyo cityId 5085; Hotel Gracery Shinjuku 706347 score 8.7, ~$135/night
- Trip: Tokyo hotelCityId 228; Hotel Musse Ginza Meitetsu 12255760 score 9.0, $193
- Airbnb: "Tokyo, Japan" → 18 listings; Hotel Shu room 35488738, $139, 4.69/1350
- Booking: "Tokyo" → destId -246227; search needs CDP; Shinjuku Washington Annex $123
python3 travel.py agoda suggest "Tokyo"
python3 travel.py --json agoda search 5085 --in 2026-09-01 --out 2026-09-03
python3 travel.py trip search 228 --in 2026-09-01 --out 2026-09-03
python3 travel.py airbnb search "Tokyo, Japan" --in 2026-09-01 --out 2026-09-03
python3 travel.py booking suggest "Tokyo"
python3 travel.py expedia search "Tokyo" # expect bot-walled
Expected: {"ok": true, ...} for all except expedia/priceline.
Reliability audit suites: /tmp/deep_agoda*.py, /tmp/deep_trip.py,
/tmp/deep_airbnb.py, /tmp/deep_booking.py, /tmp/edge_battery.py
(not in repo — recreated per session).
Extending to a new OTA
- Curl homepage: 200+full HTML = SSR-parseable; 202/403/429 = bot-walled.
- SSR sites: grep for embedded state (
data-deferred-state-0,__next_f.push,data-offline-hotelId) and parse JSON from HTML. - Autocomplete: load homepage in CDP, type into destination box, capture
pg.on("request")XHR/fetch. - Replay captured POST with curl to confirm pure-HTTP viability.
- Bot-walled search: headed CDP + DOM testid extraction.
- Add the module to
travel.pyPLATFORMS + argv-shape branch; add a_<name>_actionintravel_tool.pyfor the native Hermes tool.
Conventions
- Output keys compact (
n,p,r,rc,st,u). - Errors
{"ok":false,"err":CODE,"msg":...}exit 1 — never tracebacks. - Query inputs capped at 80 chars (APIs match best with short queries).
- Privacy-safe: no personal paths, emails, or secrets. Run
python3 privacy_sweep.pybefore pushing. - Keep
travel.pya thin dispatcher; platform logic stays in agoda.py/ota.py.