diff --git a/cli-manifest.json b/cli-manifest.json index c74c6ea27..4286d5551 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -36531,6 +36531,566 @@ "modulePath": "trae-solo/workspaces-fs.js", "sourceFile": "trae-solo/workspaces-fs.js" }, + { + "site": "trip", + "name": "attraction", + "description": "Search Trip.com attractions and experiences by destination keyword", + "access": "read", + "domain": "trip.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "query", + "type": "str", + "required": true, + "positional": true, + "help": "Destination or attraction keyword (e.g. Tokyo / Paris / Louvre)" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of results (1-50)" + } + ], + "columns": [ + "rank", + "name", + "rating", + "reviews", + "booked", + "price", + "currency", + "url" + ], + "type": "js", + "modulePath": "trip/attraction.js", + "sourceFile": "trip/attraction.js", + "navigateBefore": false + }, + { + "site": "trip", + "name": "car", + "description": "List Trip.com car-rental vehicles for a city (category, model, seats, daily price)", + "access": "read", + "domain": "trip.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "city", + "type": "str", + "required": true, + "positional": true, + "help": "Numeric Trip.com carhire city id (discover via the carhire search box)" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of vehicles (1-50)" + } + ], + "columns": [ + "rank", + "category", + "vehicle", + "seats", + "price", + "currency", + "url" + ], + "type": "js", + "modulePath": "trip/car.js", + "sourceFile": "trip/car.js", + "navigateBefore": false + }, + { + "site": "trip", + "name": "deals", + "description": "List Trip.com live promotions from the Top Deals hub: campaign title, offer, discount, and link", + "access": "read", + "domain": "trip.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of deals (1-50)" + } + ], + "columns": [ + "rank", + "title", + "offer", + "discount", + "url" + ], + "type": "js", + "modulePath": "trip/deals.js", + "sourceFile": "trip/deals.js", + "navigateBefore": false + }, + { + "site": "trip", + "name": "flight", + "description": "Search Trip.com one-way flights by IATA route + departure date", + "access": "read", + "domain": "trip.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "from", + "type": "str", + "required": true, + "positional": true, + "help": "Departure IATA code (e.g. LON / LHR)" + }, + { + "name": "to", + "type": "str", + "required": true, + "positional": true, + "help": "Arrival IATA code (e.g. NYC / JFK)" + }, + { + "name": "date", + "type": "str", + "required": true, + "help": "Departure date (YYYY-MM-DD)" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of flights (1-50)" + } + ], + "columns": [ + "rank", + "airline", + "departureTime", + "departureAirport", + "arrivalTime", + "arrivalAirport", + "duration", + "stops", + "price", + "currency", + "url" + ], + "type": "js", + "modulePath": "trip/flight.js", + "sourceFile": "trip/flight.js", + "navigateBefore": false + }, + { + "site": "trip", + "name": "flight-round", + "description": "Search Trip.com round-trip flights by IATA route + depart/return dates", + "access": "read", + "domain": "trip.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "from", + "type": "str", + "required": true, + "positional": true, + "help": "Departure IATA code (e.g. LON / LHR)" + }, + { + "name": "to", + "type": "str", + "required": true, + "positional": true, + "help": "Arrival IATA code (e.g. NYC / JFK)" + }, + { + "name": "depart", + "type": "str", + "required": true, + "help": "Outbound date (YYYY-MM-DD)" + }, + { + "name": "return", + "type": "str", + "required": true, + "help": "Return date (YYYY-MM-DD)" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of flights (1-50)" + } + ], + "columns": [ + "rank", + "airline", + "departureTime", + "departureAirport", + "arrivalTime", + "arrivalAirport", + "duration", + "stops", + "price", + "currency", + "url" + ], + "type": "js", + "modulePath": "trip/flight-round.js", + "sourceFile": "trip/flight-round.js", + "navigateBefore": false + }, + { + "site": "trip", + "name": "hotel", + "description": "Show a Trip.com hotel detail by id (rating breakdown, amenities, check-in/out policy)", + "access": "read", + "domain": "trip.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "id", + "type": "str", + "required": true, + "positional": true, + "help": "Numeric Trip.com hotel id (discover via the hotels list; e.g. 715233)" + } + ], + "columns": [ + "hotelId", + "name", + "enName", + "star", + "score", + "scoreLabel", + "reviewCount", + "ratingBreakdown", + "facilities", + "checkInOut", + "cityName", + "address", + "lat", + "lon", + "url" + ], + "type": "js", + "modulePath": "trip/hotel.js", + "sourceFile": "trip/hotel.js", + "navigateBefore": false + }, + { + "site": "trip", + "name": "hotel-search", + "description": "List Trip.com hotels for a city id + check-in/out date range", + "access": "read", + "domain": "trip.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "city", + "type": "str", + "required": true, + "positional": true, + "help": "Numeric Trip.com city id (discover via the hotels search box; e.g. 338 for London)" + }, + { + "name": "checkin", + "type": "str", + "required": true, + "help": "Check-in date (YYYY-MM-DD)" + }, + { + "name": "checkout", + "type": "str", + "required": true, + "help": "Check-out date (YYYY-MM-DD)" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of hotels (1-50)" + } + ], + "columns": [ + "rank", + "name", + "score", + "reviewLabel", + "reviews", + "location", + "room", + "price", + "currency", + "url" + ], + "type": "js", + "modulePath": "trip/hotel-search.js", + "sourceFile": "trip/hotel-search.js", + "navigateBefore": false + }, + { + "site": "trip", + "name": "package", + "description": "Search Trip.com flight+hotel packages by route + dates; lists the package flight options priced at the bundle rate", + "access": "read", + "domain": "trip.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "from", + "type": "str", + "required": true, + "positional": true, + "help": "Origin city keyword (e.g. Seoul / London / Bangkok)" + }, + { + "name": "to", + "type": "str", + "required": true, + "positional": true, + "help": "Destination city keyword (e.g. Tokyo / Paris / Singapore)" + }, + { + "name": "depart", + "type": "str", + "required": true, + "help": "Outbound date (YYYY-MM-DD)" + }, + { + "name": "return", + "type": "str", + "required": true, + "help": "Return date (YYYY-MM-DD)" + }, + { + "name": "adults", + "type": "int", + "default": 2, + "required": false, + "help": "Number of adults (1-9, default 2)" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of packages (1-50)" + } + ], + "columns": [ + "rank", + "airline", + "flightNo", + "from", + "to", + "departure", + "arrival", + "stops", + "price", + "currency" + ], + "type": "js", + "modulePath": "trip/package.js", + "sourceFile": "trip/package.js" + }, + { + "site": "trip", + "name": "search", + "description": "Suggest Trip.com destinations (cities, airports) for a keyword; resolves the ids the other commands take", + "access": "read", + "domain": "trip.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "query", + "type": "str", + "required": true, + "positional": true, + "help": "Destination keyword (e.g. Tokyo / Bali / London)" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of suggestions (1-50)" + } + ], + "columns": [ + "rank", + "name", + "type", + "cityId", + "airportCode", + "province", + "country" + ], + "type": "js", + "modulePath": "trip/search.js", + "sourceFile": "trip/search.js" + }, + { + "site": "trip", + "name": "tour", + "description": "Search Trip.com tour packages by destination keyword (private or group tours)", + "access": "read", + "domain": "trip.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "query", + "type": "str", + "required": true, + "positional": true, + "help": "Destination or tour keyword (e.g. Tokyo / Kyoto / Bali)" + }, + { + "name": "type", + "type": "str", + "default": "private", + "required": false, + "help": "Tour line: private or group (default private)" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of tours (1-50)" + } + ], + "columns": [ + "rank", + "name", + "type", + "rating", + "reviews", + "price", + "currency", + "url" + ], + "type": "js", + "modulePath": "trip/tour.js", + "sourceFile": "trip/tour.js", + "navigateBefore": false + }, + { + "site": "trip", + "name": "train", + "description": "Show a Trip.com train route timetable (departure/arrival times, duration, changes)", + "access": "read", + "domain": "trip.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "from", + "type": "str", + "required": true, + "positional": true, + "help": "Departure city (e.g. London / Paris / Shanghai)" + }, + { + "name": "to", + "type": "str", + "required": true, + "positional": true, + "help": "Arrival city (e.g. Manchester / Lyon / Beijing)" + }, + { + "name": "country", + "type": "str", + "required": true, + "help": "Route country slug (e.g. uk / france / italy / spain / germany / china)" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of journeys (1-50)" + } + ], + "columns": [ + "rank", + "departureTime", + "fromStation", + "arrivalTime", + "toStation", + "duration", + "changes", + "url" + ], + "type": "js", + "modulePath": "trip/train.js", + "sourceFile": "trip/train.js", + "navigateBefore": false + }, + { + "site": "trip", + "name": "transfer", + "description": "List Trip.com airport-transfer vehicles for a city + airport (type, seats, from-price)", + "access": "read", + "domain": "trip.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "city", + "type": "str", + "required": true, + "positional": true, + "help": "Airport city (e.g. Bangkok / Beijing / Da Nang)" + }, + { + "name": "airport", + "type": "str", + "required": true, + "positional": true, + "help": "3-letter airport IATA code (e.g. DMK / PKX / DAD)" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of vehicles (1-50)" + } + ], + "columns": [ + "rank", + "type", + "passengers", + "luggage", + "price", + "currency", + "url" + ], + "type": "js", + "modulePath": "trip/transfer.js", + "sourceFile": "trip/transfer.js", + "navigateBefore": false + }, { "site": "tvmaze", "name": "search", diff --git a/clis/trip/attraction.js b/clis/trip/attraction.js new file mode 100644 index 000000000..c51fef4f1 --- /dev/null +++ b/clis/trip/attraction.js @@ -0,0 +1,74 @@ +/** + * Trip.com (international) attractions and experiences search by destination + * keyword. + * + * The things-to-do products load client-side into hashed CSS-module cards, so + * this anchors on each card's stable `things-to-do/detail/` link (which also + * gives a real per-row `url`) and reads rating / reviews / booked / price from + * the card text by data-format pattern (see `buildAttractionExtractJs` in utils). + */ +import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { + WAIT_FOR_ATTRACTIONS_JS, + buildAttractionExtractJs, + buildAttractionSearchUrl, + parseKeyword, + parseListLimit, +} from './utils.js'; + +cli({ + site: 'trip', + name: 'attraction', + access: 'read', + description: 'Search Trip.com attractions and experiences by destination keyword', + domain: 'trip.com', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { name: 'query', required: true, positional: true, help: 'Destination or attraction keyword (e.g. Tokyo / Paris / Louvre)' }, + { name: 'limit', type: 'int', default: 20, help: 'Number of results (1-50)' }, + ], + columns: [ + 'rank', + 'name', + 'rating', 'reviews', 'booked', + 'price', 'currency', + 'url', + ], + func: async (page, kwargs) => { + const query = parseKeyword('query', kwargs.query); + const limit = parseListLimit(kwargs.limit); + + const searchUrl = buildAttractionSearchUrl(query); + await page.goto(searchUrl); + const waitResult = await page.evaluate(WAIT_FOR_ATTRACTIONS_JS); + if (waitResult === 'captcha') { + throw new AuthRequiredError('trip.com', 'Trip.com is asking for a verification; complete it in your browser session and retry'); + } + if (waitResult === 'empty') { + throw new EmptyResultError('trip attraction', `No attractions for "${query}"`); + } + if (waitResult !== 'content') { + throw new CommandExecutionError(`Trip.com things-to-do page did not render product cards (state=${String(waitResult)})`); + } + const raw = await page.evaluate(buildAttractionExtractJs()); + if (!Array.isArray(raw)) { + throw new CommandExecutionError('Trip.com attraction DOM extraction returned malformed rows'); + } + if (raw.length === 0) { + throw new CommandExecutionError('Trip.com attraction cards rendered but parser did not find required detail-link anchors'); + } + return raw.slice(0, limit).map((r, i) => ({ + rank: i + 1, + name: r.name, + rating: r.rating, + reviews: r.reviews, + booked: r.booked, + price: r.price, + currency: 'USD', + url: r.url, + })); + }, +}); diff --git a/clis/trip/car.js b/clis/trip/car.js new file mode 100644 index 000000000..dd575943b --- /dev/null +++ b/clis/trip/car.js @@ -0,0 +1,74 @@ +/** + * Trip.com (international) car-rental listing by city. + * + * Trip.com files car-rental listings under an SEO path whose text slugs are + * cosmetic; only the numeric carhire city id routes the page, so this takes that + * id (discover it via the carhire search box) and reads the rendered `.card-item` + * cards by stable class fields (see `buildCarExtractJs` in utils). The listing + * carries the site's near-term representative daily price; a dated pickup / + * drop-off quote sits behind the booking step and is out of scope here. + */ +import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { + WAIT_FOR_CARS_JS, + buildCarExtractJs, + buildCarListUrl, + parseCityId, + parseListLimit, +} from './utils.js'; + +cli({ + site: 'trip', + name: 'car', + access: 'read', + description: 'List Trip.com car-rental vehicles for a city (category, model, seats, daily price)', + domain: 'trip.com', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { name: 'city', required: true, positional: true, help: 'Numeric Trip.com carhire city id (discover via the carhire search box)' }, + { name: 'limit', type: 'int', default: 20, help: 'Number of vehicles (1-50)' }, + ], + columns: [ + 'rank', + 'category', 'vehicle', + 'seats', + 'price', 'currency', + 'url', + ], + func: async (page, kwargs) => { + const cityId = parseCityId('city', kwargs.city); + const limit = parseListLimit(kwargs.limit); + + const listUrl = buildCarListUrl(cityId); + await page.goto(listUrl); + const waitResult = await page.evaluate(WAIT_FOR_CARS_JS); + if (waitResult === 'captcha') { + throw new AuthRequiredError('trip.com', 'Trip.com is asking for a verification; complete it in your browser session and retry'); + } + if (waitResult === 'empty') { + throw new EmptyResultError('trip car', `No car rentals for city id ${cityId}`); + } + if (waitResult !== 'content') { + throw new CommandExecutionError(`Trip.com car listing did not render (state=${String(waitResult)}); check the carhire city id`); + } + const raw = await page.evaluate(buildCarExtractJs()); + if (!Array.isArray(raw)) { + throw new CommandExecutionError('Trip.com car DOM extraction returned malformed rows'); + } + if (raw.length === 0) { + throw new CommandExecutionError('Trip.com car cards rendered but parser did not find required price anchors'); + } + return raw.slice(0, limit).map((r, i) => ({ + rank: i + 1, + category: r.category, + vehicle: r.vehicle, + seats: r.seats, + price: r.price, + currency: r.currency, + url: listUrl, + })); + }, +}); diff --git a/clis/trip/deals.js b/clis/trip/deals.js new file mode 100644 index 000000000..cc5d83a38 --- /dev/null +++ b/clis/trip/deals.js @@ -0,0 +1,61 @@ +/** + * Trip.com (international) running-promotions listing. + * + * Trip.com curates its live campaigns on a "Today's Top Deals" hub + * (`/sale/deals/`), each a promo tile linking to a dedicated campaign page. The + * hub renders client-side, so this reads the rendered `.top-deals_link-item` + * tiles (title, offer line, parsed discount, campaign URL) the same way the other + * browser-mode trip commands read their result cards. The per-campaign terms and + * bookable inventory live on the linked page and are out of scope here. + */ +import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { WAIT_FOR_DEALS_JS, buildDealsExtractJs, buildDealsUrl, parseListLimit } from './utils.js'; + +cli({ + site: 'trip', + name: 'deals', + access: 'read', + description: 'List Trip.com live promotions from the Top Deals hub: campaign title, offer, discount, and link', + domain: 'trip.com', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of deals (1-50)' }, + ], + columns: [ + 'rank', + 'title', 'offer', + 'discount', + 'url', + ], + func: async (page, kwargs) => { + const limit = parseListLimit(kwargs.limit); + + await page.goto(buildDealsUrl()); + const waitResult = await page.evaluate(WAIT_FOR_DEALS_JS); + if (waitResult === 'captcha') { + throw new AuthRequiredError('trip.com', 'Trip.com is asking for a verification; complete it in your browser session and retry'); + } + if (waitResult !== 'content') { + throw new CommandExecutionError(`Trip.com deals page did not render deal tiles (state=${String(waitResult)})`); + } + const raw = await page.evaluate(buildDealsExtractJs()); + if (!Array.isArray(raw)) { + throw new CommandExecutionError('Trip.com deals DOM extraction returned malformed rows'); + } + // The Top Deals hub is a permanent curated page, so once the wait confirms it + // rendered, zero parsed tiles means the tile markup drifted, not an empty hub. + if (raw.length === 0) { + throw new CommandExecutionError('Trip.com deals hub rendered but no promotion tiles parsed (the tile markup may have changed)'); + } + return raw.slice(0, limit).map((r, i) => ({ + rank: i + 1, + title: r.title, + offer: r.offer, + discount: r.discount, + url: r.url, + })); + }, +}); diff --git a/clis/trip/flight-round.js b/clis/trip/flight-round.js new file mode 100644 index 000000000..cc921eb29 --- /dev/null +++ b/clis/trip/flight-round.js @@ -0,0 +1,88 @@ +/** + * Trip.com (international) round-trip flight search by IATA route + dates. + * + * Complements one-way `flight`: the round-trip results page renders the same + * `.result-item` outbound cards (priced for the round trip), so this reuses the + * shared flight extractor and wait helper against a `triptype=rt` search URL. + * Rows missing the airline, both airports, or both times are dropped. + */ +import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { + WAIT_FOR_FLIGHTS_JS, + buildFlightExtractJs, + buildFlightRoundSearchUrl, + parseIataCode, + parseIsoDate, + parseListLimit, +} from './utils.js'; + +cli({ + site: 'trip', + name: 'flight-round', + access: 'read', + description: 'Search Trip.com round-trip flights by IATA route + depart/return dates', + domain: 'trip.com', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { name: 'from', required: true, positional: true, help: 'Departure IATA code (e.g. LON / LHR)' }, + { name: 'to', required: true, positional: true, help: 'Arrival IATA code (e.g. NYC / JFK)' }, + { name: 'depart', required: true, help: 'Outbound date (YYYY-MM-DD)' }, + { name: 'return', required: true, help: 'Return date (YYYY-MM-DD)' }, + { name: 'limit', type: 'int', default: 20, help: 'Number of flights (1-50)' }, + ], + columns: [ + 'rank', + 'airline', + 'departureTime', 'departureAirport', + 'arrivalTime', 'arrivalAirport', + 'duration', 'stops', + 'price', 'currency', + 'url', + ], + func: async (page, kwargs) => { + const fromCode = parseIataCode('from', kwargs.from); + const toCode = parseIataCode('to', kwargs.to); + if (fromCode === toCode) { + throw new ArgumentError(`--from and --to must differ (got ${fromCode})`); + } + const depart = parseIsoDate('depart', kwargs.depart); + const ret = parseIsoDate('return', kwargs.return); + if (depart >= ret) { + throw new ArgumentError(`--depart must be before --return (got ${depart} .. ${ret})`); + } + const limit = parseListLimit(kwargs.limit); + + const searchUrl = buildFlightRoundSearchUrl(fromCode, toCode, depart, ret); + await page.goto(searchUrl); + const waitResult = await page.evaluate(WAIT_FOR_FLIGHTS_JS); + if (waitResult === 'captcha') { + throw new AuthRequiredError('trip.com', 'Trip.com is asking for a verification; complete it in your browser session and retry'); + } + if (waitResult !== 'content') { + throw new CommandExecutionError(`Trip.com flight page did not render flight cards (state=${String(waitResult)})`); + } + const raw = await page.evaluate(buildFlightExtractJs()); + if (!Array.isArray(raw)) { + throw new CommandExecutionError('Trip.com flight DOM extraction returned malformed rows'); + } + if (raw.length === 0) { + throw new EmptyResultError('trip flight-round', `No round-trip flights for ${fromCode} to ${toCode} on ${depart} .. ${ret}`); + } + return raw.slice(0, limit).map((r, i) => ({ + rank: i + 1, + airline: r.airline, + departureTime: r.departureTime, + departureAirport: r.departureAirport, + arrivalTime: r.arrivalTime, + arrivalAirport: r.arrivalAirport, + duration: r.duration, + stops: r.stops, + price: r.price, + currency: r.currency, + url: searchUrl, + })); + }, +}); diff --git a/clis/trip/flight.js b/clis/trip/flight.js new file mode 100644 index 000000000..0e70c986d --- /dev/null +++ b/clis/trip/flight.js @@ -0,0 +1,83 @@ +/** + * Trip.com (international) one-way flight search by IATA route + date. + * + * Trip.com is the English-facing sibling of Ctrip. Results render client-side + * into `.result-item` cards keyed by stable `data-testid` anchors, so this + * reads by selector (see `buildFlightExtractJs` in utils) rather than by + * position. Rows missing the airline, both airports, or both times are dropped. + */ +import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { + WAIT_FOR_FLIGHTS_JS, + buildFlightExtractJs, + buildFlightSearchUrl, + parseIataCode, + parseIsoDate, + parseListLimit, +} from './utils.js'; + +cli({ + site: 'trip', + name: 'flight', + access: 'read', + description: 'Search Trip.com one-way flights by IATA route + departure date', + domain: 'trip.com', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { name: 'from', required: true, positional: true, help: 'Departure IATA code (e.g. LON / LHR)' }, + { name: 'to', required: true, positional: true, help: 'Arrival IATA code (e.g. NYC / JFK)' }, + { name: 'date', required: true, help: 'Departure date (YYYY-MM-DD)' }, + { name: 'limit', type: 'int', default: 20, help: 'Number of flights (1-50)' }, + ], + columns: [ + 'rank', + 'airline', + 'departureTime', 'departureAirport', + 'arrivalTime', 'arrivalAirport', + 'duration', 'stops', + 'price', 'currency', + 'url', + ], + func: async (page, kwargs) => { + const fromCode = parseIataCode('from', kwargs.from); + const toCode = parseIataCode('to', kwargs.to); + if (fromCode === toCode) { + throw new ArgumentError(`--from and --to must differ (got ${fromCode})`); + } + const date = parseIsoDate('date', kwargs.date); + const limit = parseListLimit(kwargs.limit); + + const searchUrl = buildFlightSearchUrl(fromCode, toCode, date); + await page.goto(searchUrl); + const waitResult = await page.evaluate(WAIT_FOR_FLIGHTS_JS); + if (waitResult === 'captcha') { + throw new AuthRequiredError('trip.com', 'Trip.com is asking for a verification; complete it in your browser session and retry'); + } + if (waitResult !== 'content') { + throw new CommandExecutionError(`Trip.com flight page did not render flight cards (state=${String(waitResult)})`); + } + const raw = await page.evaluate(buildFlightExtractJs()); + if (!Array.isArray(raw)) { + throw new CommandExecutionError('Trip.com flight DOM extraction returned malformed rows'); + } + if (raw.length === 0) { + throw new EmptyResultError('trip flight', `No flights for ${fromCode} to ${toCode} on ${date}`); + } + return raw.slice(0, limit).map((r, i) => ({ + rank: i + 1, + airline: r.airline, + departureTime: r.departureTime, + departureAirport: r.departureAirport, + arrivalTime: r.arrivalTime, + arrivalAirport: r.arrivalAirport, + duration: r.duration, + stops: r.stops, + price: r.price, + currency: r.currency, + url: searchUrl, + })); + }, +}); diff --git a/clis/trip/hotel-search.js b/clis/trip/hotel-search.js new file mode 100644 index 000000000..baf8decd3 --- /dev/null +++ b/clis/trip/hotel-search.js @@ -0,0 +1,80 @@ +/** + * Trip.com (international) hotel listing by city id + check-in/out date range. + * + * Trip.com renders hotel results client-side into `.hotel-card` cards keyed by + * stable class fields, so this reads by selector (see `buildHotelExtractJs` in + * utils) rather than positional innerText. Cards without a hotel name are + * dropped rather than surfaced with blanks. + */ +import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { + WAIT_FOR_HOTELS_JS, + buildHotelExtractJs, + buildHotelSearchUrl, + parseCityId, + parseIsoDate, + parseListLimit, +} from './utils.js'; + +cli({ + site: 'trip', + name: 'hotel-search', + access: 'read', + description: 'List Trip.com hotels for a city id + check-in/out date range', + domain: 'trip.com', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { name: 'city', required: true, positional: true, help: 'Numeric Trip.com city id (discover via the hotels search box; e.g. 338 for London)' }, + { name: 'checkin', required: true, help: 'Check-in date (YYYY-MM-DD)' }, + { name: 'checkout', required: true, help: 'Check-out date (YYYY-MM-DD)' }, + { name: 'limit', type: 'int', default: 20, help: 'Number of hotels (1-50)' }, + ], + columns: [ + 'rank', + 'name', 'score', 'reviewLabel', 'reviews', + 'location', 'room', + 'price', 'currency', + 'url', + ], + func: async (page, kwargs) => { + const cityId = parseCityId('city', kwargs.city); + const checkin = parseIsoDate('checkin', kwargs.checkin); + const checkout = parseIsoDate('checkout', kwargs.checkout); + if (checkin >= checkout) { + throw new ArgumentError(`--checkin must be before --checkout (got ${checkin} .. ${checkout})`); + } + const limit = parseListLimit(kwargs.limit); + + const searchUrl = buildHotelSearchUrl(cityId, checkin, checkout); + await page.goto(searchUrl); + const waitResult = await page.evaluate(WAIT_FOR_HOTELS_JS); + if (waitResult === 'captcha') { + throw new AuthRequiredError('trip.com', 'Trip.com is asking for a verification; complete it in your browser session and retry'); + } + if (waitResult !== 'content') { + throw new CommandExecutionError(`Trip.com hotel page did not render hotel cards (state=${String(waitResult)})`); + } + const raw = await page.evaluate(buildHotelExtractJs()); + if (!Array.isArray(raw)) { + throw new CommandExecutionError('Trip.com hotel DOM extraction returned malformed rows'); + } + if (raw.length === 0) { + throw new EmptyResultError('trip hotel-search', `No hotels for city ${cityId} on ${checkin} .. ${checkout}`); + } + return raw.slice(0, limit).map((r, i) => ({ + rank: i + 1, + name: r.name, + score: r.score, + reviewLabel: r.reviewLabel, + reviews: r.reviews, + location: r.location, + room: r.room, + price: r.price, + currency: r.currency, + url: searchUrl, + })); + }, +}); diff --git a/clis/trip/hotel.js b/clis/trip/hotel.js new file mode 100644 index 000000000..a7f6a50bd --- /dev/null +++ b/clis/trip/hotel.js @@ -0,0 +1,54 @@ +/** + * Trip.com (international) single-hotel detail by id: rating breakdown, popular + * amenities, check-in/out policy, address, and coordinates. + * + * Reads `__NEXT_DATA__.props.pageProps.hotelDetailResponse` from the SSR detail + * page, the same SSR shape the mainland `ctrip hotel` detail uses. This surfaces + * the fields the `hotel-search` list row does not carry. Room-level nightly + * prices load via a post-SSR XHR and are out of scope here; `hotel-search` + * already reports a representative nightly price per hotel. + */ +import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { WAIT_FOR_HOTEL_DETAIL_JS, buildHotelDetailExtractJs, buildHotelDetailUrl, parseHotelId } from './utils.js'; + +cli({ + site: 'trip', + name: 'hotel', + access: 'read', + description: 'Show a Trip.com hotel detail by id (rating breakdown, amenities, check-in/out policy)', + domain: 'trip.com', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { name: 'id', required: true, positional: true, help: 'Numeric Trip.com hotel id (discover via the hotels list; e.g. 715233)' }, + ], + columns: [ + 'hotelId', 'name', 'enName', + 'star', 'score', 'scoreLabel', 'reviewCount', 'ratingBreakdown', + 'facilities', 'checkInOut', + 'cityName', 'address', 'lat', 'lon', + 'url', + ], + func: async (page, kwargs) => { + const hotelId = parseHotelId('id', kwargs.id); + const url = buildHotelDetailUrl(hotelId); + await page.goto(url); + const waitResult = await page.evaluate(WAIT_FOR_HOTEL_DETAIL_JS); + if (waitResult === 'captcha') { + throw new AuthRequiredError('trip.com', 'Trip.com is asking for a verification; complete it in your browser session and retry'); + } + if (waitResult !== 'content') { + throw new CommandExecutionError(`Trip.com hotel detail page did not expose SSR hotel data (state=${String(waitResult)})`); + } + const detail = await page.evaluate(buildHotelDetailExtractJs()); + if (!detail || typeof detail !== 'object') { + throw new CommandExecutionError('Trip.com hotel detail SSR extraction returned malformed data'); + } + if (!detail.hotelId || !detail.name) { + throw new EmptyResultError('trip hotel', `No detail exposed for hotel id ${hotelId}`); + } + return [{ ...detail, url }]; + }, +}); diff --git a/clis/trip/package.js b/clis/trip/package.js new file mode 100644 index 000000000..287d42316 --- /dev/null +++ b/clis/trip/package.js @@ -0,0 +1,93 @@ +/** + * Trip.com (international) flight+hotel package search by route + dates. + * + * Trip.com prices its packages through the flight-selection step of the booking + * flow: a public, unsigned POST keyed on the metro city codes plus the + * destination hotel city id returns the outbound flight options priced at the + * bundle rate (the specific hotel is picked in a later step). So this is a plain + * public fetch (no browser) that resolves both endpoints through the same POI + * search `trip search` uses, then lists the package flights (see + * `fetchPackageSearch` in utils). Per-person package fares only; the return leg + * rides on the hotel checkout date. + */ +import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { fetchPackageSearch, mapPackageRow, parseIsoDate, parseKeyword, parseListLimit, resolvePackageCity } from './utils.js'; + +function parseAdults(raw) { + if (raw === undefined || raw === null || raw === '') return 2; + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 9) { + throw new ArgumentError(`--adults must be an integer between 1 and 9, got ${JSON.stringify(raw)}`); + } + return parsed; +} + +cli({ + site: 'trip', + name: 'package', + access: 'read', + description: 'Search Trip.com flight+hotel packages by route + dates; lists the package flight options priced at the bundle rate', + domain: 'trip.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'from', required: true, positional: true, help: 'Origin city keyword (e.g. Seoul / London / Bangkok)' }, + { name: 'to', required: true, positional: true, help: 'Destination city keyword (e.g. Tokyo / Paris / Singapore)' }, + { name: 'depart', required: true, help: 'Outbound date (YYYY-MM-DD)' }, + { name: 'return', required: true, help: 'Return date (YYYY-MM-DD)' }, + { name: 'adults', type: 'int', default: 2, help: 'Number of adults (1-9, default 2)' }, + { name: 'limit', type: 'int', default: 20, help: 'Number of packages (1-50)' }, + ], + columns: [ + 'rank', + 'airline', 'flightNo', + 'from', 'to', + 'departure', 'arrival', + 'stops', + 'price', 'currency', + ], + func: async (kwargs) => { + const from = parseKeyword('from', kwargs.from); + const to = parseKeyword('to', kwargs.to); + const depart = parseIsoDate('depart', kwargs.depart); + const ret = parseIsoDate('return', kwargs.return); + if (depart >= ret) { + throw new ArgumentError(`--depart must be before --return (got ${depart} .. ${ret})`); + } + const adults = parseAdults(kwargs.adults); + const limit = parseListLimit(kwargs.limit); + + const origin = await resolvePackageCity(from); + if (!origin) { + throw new ArgumentError(`Could not resolve origin "${from}" to a Trip.com city; run 'trip search ${from}' to find the name`); + } + const dest = await resolvePackageCity(to); + if (!dest) { + throw new ArgumentError(`Could not resolve destination "${to}" to a Trip.com city; run 'trip search ${to}' to find the name`); + } + if (origin.cityId === dest.cityId) { + throw new ArgumentError(`--from and --to must differ (both resolved to ${dest.name})`); + } + + const groups = await fetchPackageSearch({ + dcode: origin.cityCode, + acode: dest.cityCode, + hcityid: String(dest.cityId), + depart, + ret, + adults, + }); + if (groups.length === 0) { + throw new EmptyResultError('trip package', `No flight+hotel packages for ${origin.name} to ${dest.name} on ${depart} .. ${ret}`); + } + const rows = groups + .filter((g) => g && Array.isArray(g.flightlist) && g.flightlist.length) + .map((g) => mapPackageRow(g, 0)) + .filter((row) => row.flightNo && row.from && row.to && row.departure && row.arrival); + if (rows.length === 0) { + throw new CommandExecutionError(`Trip.com package search returned ${groups.length} group(s) but none carried a parseable flight identity, route, and time`); + } + return rows.slice(0, limit).map((row, i) => ({ ...row, rank: i + 1 })); + }, +}); diff --git a/clis/trip/search.js b/clis/trip/search.js new file mode 100644 index 000000000..22a9fe66a --- /dev/null +++ b/clis/trip/search.js @@ -0,0 +1,43 @@ +/** + * Trip.com (international) destination suggest. + * + * Trip.com's flight / hotel search boxes resolve a keyword through a public, + * unsigned POI-search endpoint, so this is a plain public fetch (no browser) that + * returns the city / airport matches. The `cityId` feeds `hotel-search` / `car` / + * `tour`, and the `airportCode` feeds `flight` / `transfer`, so `search` is the + * discovery step for the id-based commands (see `fetchPoiSearch` in utils). + */ +import { EmptyResultError } from '@jackwener/opencli/errors'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { fetchPoiSearch, flattenPoiResults, mapSearchRow, parseKeyword, parseListLimit } from './utils.js'; + +cli({ + site: 'trip', + name: 'search', + access: 'read', + description: 'Suggest Trip.com destinations (cities, airports) for a keyword; resolves the ids the other commands take', + domain: 'trip.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'query', required: true, positional: true, help: 'Destination keyword (e.g. Tokyo / Bali / London)' }, + { name: 'limit', type: 'int', default: 20, help: 'Number of suggestions (1-50)' }, + ], + columns: [ + 'rank', + 'name', 'type', + 'cityId', 'airportCode', + 'province', 'country', + ], + func: async (kwargs) => { + const query = parseKeyword('query', kwargs.query); + const limit = parseListLimit(kwargs.limit); + + const results = await fetchPoiSearch(query); + const items = flattenPoiResults(results).filter((item) => item && item.name); + if (items.length === 0) { + throw new EmptyResultError('trip search', `No destinations for "${query}"`); + } + return items.slice(0, limit).map((item, i) => mapSearchRow(item, i)); + }, +}); diff --git a/clis/trip/tour.js b/clis/trip/tour.js new file mode 100644 index 000000000..c31fcc10e --- /dev/null +++ b/clis/trip/tour.js @@ -0,0 +1,84 @@ +/** + * Trip.com (international) tour-package search by destination keyword. + * + * Trip.com's tour results (`package-tours/list?kwd=`) load through a + * signed POST that only fires on a search submit, so this navigates the results + * page and lets the page issue its own signed request while a fetch hook captures + * the `products` response, rather than replaying the signature (see + * `buildTourSearchJs` in utils). Per-departure pricing and availability sit behind + * the booking step; the row `price` is the starting per-person estimate shown. + */ +import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { buildTourSearchJs, buildTourSearchUrl, parseKeyword, parseListLimit } from './utils.js'; + +const TOUR_TABS = { private: 'privateTours', group: 'groupTours' }; + +function parseTourType(raw) { + if (raw === undefined || raw === null || raw === '') return TOUR_TABS.private; + const value = String(raw).trim().toLowerCase(); + if (!TOUR_TABS[value]) { + throw new ArgumentError(`--type must be private or group, got ${JSON.stringify(raw)}`); + } + return TOUR_TABS[value]; +} + +cli({ + site: 'trip', + name: 'tour', + access: 'read', + description: 'Search Trip.com tour packages by destination keyword (private or group tours)', + domain: 'trip.com', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { name: 'query', required: true, positional: true, help: 'Destination or tour keyword (e.g. Tokyo / Kyoto / Bali)' }, + { name: 'type', default: 'private', help: 'Tour line: private or group (default private)' }, + { name: 'limit', type: 'int', default: 20, help: 'Number of tours (1-50)' }, + ], + columns: [ + 'rank', + 'name', 'type', + 'rating', 'reviews', + 'price', 'currency', + 'url', + ], + func: async (page, kwargs) => { + const query = parseKeyword('query', kwargs.query); + const tourType = parseTourType(kwargs.type); + const limit = parseListLimit(kwargs.limit); + + const searchUrl = buildTourSearchUrl(query, tourType); + await page.goto(searchUrl); + const result = await page.evaluate(buildTourSearchJs(query)); + if (!result || typeof result !== 'object') { + throw new CommandExecutionError('Trip.com tour search returned malformed data'); + } + if (result.status === 'captcha') { + throw new AuthRequiredError('trip.com', 'Trip.com is asking for a verification; complete it in your browser session and retry'); + } + if (result.status === 'empty') { + throw new EmptyResultError('trip tour', `No ${kwargs.type || 'private'} tours for "${query}"`); + } + if (result.status !== 'content') { + throw new CommandExecutionError(`Trip.com tour search did not return results (state=${String(result.status)})`); + } + // Products captured but none carry a name is drift (schema moved), not an empty search; + // a genuine no-match resolves as status 'empty' above off the page's "0 routes found". + const rows = Array.isArray(result.rows) ? result.rows.filter((r) => r.name) : []; + if (rows.length === 0) { + throw new CommandExecutionError('Trip.com tour search captured products but none carried a name (the product markup may have changed)'); + } + return rows.slice(0, limit).map((r, i) => ({ + rank: i + 1, + name: r.name, + type: r.type, + rating: r.rating, + reviews: r.reviews, + price: r.price, + currency: 'USD', + url: r.url, + })); + }, +}); diff --git a/clis/trip/train.js b/clis/trip/train.js new file mode 100644 index 000000000..9739f77d6 --- /dev/null +++ b/clis/trip/train.js @@ -0,0 +1,76 @@ +/** + * Trip.com (international) train timetable by route. + * + * Trip.com exposes per-route SEO timetables at + * `trains//route/-to-/`, so `--country` is required and the + * city names are slugified into the URL. The page lists journeys as timetable + * rows (departure / arrival times, stations, duration, changes) read by stable + * class fields (see `buildTrainExtractJs` in utils); per-journey fares sit behind + * the booking step and are out of scope here. + */ +import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { + WAIT_FOR_TRAINS_JS, + buildTrainExtractJs, + buildTrainRouteUrl, + parseKeyword, + parseListLimit, +} from './utils.js'; + +cli({ + site: 'trip', + name: 'train', + access: 'read', + description: 'Show a Trip.com train route timetable (departure/arrival times, duration, changes)', + domain: 'trip.com', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { name: 'from', required: true, positional: true, help: 'Departure city (e.g. London / Paris / Shanghai)' }, + { name: 'to', required: true, positional: true, help: 'Arrival city (e.g. Manchester / Lyon / Beijing)' }, + { name: 'country', required: true, help: 'Route country slug (e.g. uk / france / italy / spain / germany / china)' }, + { name: 'limit', type: 'int', default: 20, help: 'Number of journeys (1-50)' }, + ], + columns: [ + 'rank', + 'departureTime', 'fromStation', + 'arrivalTime', 'toStation', + 'duration', 'changes', + 'url', + ], + func: async (page, kwargs) => { + const from = parseKeyword('from', kwargs.from); + const to = parseKeyword('to', kwargs.to); + const country = parseKeyword('country', kwargs.country); + const limit = parseListLimit(kwargs.limit); + + const searchUrl = buildTrainRouteUrl(country, from, to); + await page.goto(searchUrl); + const waitResult = await page.evaluate(WAIT_FOR_TRAINS_JS); + if (waitResult === 'captcha') { + throw new AuthRequiredError('trip.com', 'Trip.com is asking for a verification; complete it in your browser session and retry'); + } + if (waitResult !== 'content') { + throw new CommandExecutionError(`Trip.com train timetable did not render (state=${String(waitResult)}); check the city names and --country`); + } + const raw = await page.evaluate(buildTrainExtractJs()); + if (!Array.isArray(raw)) { + throw new CommandExecutionError('Trip.com train DOM extraction returned malformed rows'); + } + if (raw.length === 0) { + throw new EmptyResultError('trip train', `No timetable for ${from} to ${to} (${country})`); + } + return raw.slice(0, limit).map((r, i) => ({ + rank: i + 1, + departureTime: r.departureTime, + fromStation: r.fromStation, + arrivalTime: r.arrivalTime, + toStation: r.toStation, + duration: r.duration, + changes: r.changes, + url: searchUrl, + })); + }, +}); diff --git a/clis/trip/transfer.js b/clis/trip/transfer.js new file mode 100644 index 000000000..08295215a --- /dev/null +++ b/clis/trip/transfer.js @@ -0,0 +1,82 @@ +/** + * Trip.com (international) airport-transfer listing. + * + * Trip.com files airport transfers under an SEO path keyed on the city slug plus + * the airport IATA code (`airport-transfers//airport-/`). A city that + * does not match the airport bounces to the transfer landing page, so this checks + * the landed path before extracting to avoid returning the generic landing list. + * Rows come from the rendered `.vehicle-card` cards (see `buildTransferExtractJs` + * in utils); the from-price is the site's representative fare, with the dated + * pickup quote behind the booking step. + */ +import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { + WAIT_FOR_TRANSFERS_JS, + buildTransferExtractJs, + buildTransferListUrl, + parseIataCode, + parseKeyword, + parseListLimit, +} from './utils.js'; + +cli({ + site: 'trip', + name: 'transfer', + access: 'read', + description: 'List Trip.com airport-transfer vehicles for a city + airport (type, seats, from-price)', + domain: 'trip.com', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { name: 'city', required: true, positional: true, help: 'Airport city (e.g. Bangkok / Beijing / Da Nang)' }, + { name: 'airport', required: true, positional: true, help: '3-letter airport IATA code (e.g. DMK / PKX / DAD)' }, + { name: 'limit', type: 'int', default: 20, help: 'Number of vehicles (1-50)' }, + ], + columns: [ + 'rank', + 'type', + 'passengers', 'luggage', + 'price', 'currency', + 'url', + ], + func: async (page, kwargs) => { + const city = parseKeyword('city', kwargs.city); + const airport = parseIataCode('airport', kwargs.airport); + const limit = parseListLimit(kwargs.limit); + + const listUrl = buildTransferListUrl(city, airport); + await page.goto(listUrl); + const waitResult = await page.evaluate(WAIT_FOR_TRANSFERS_JS); + if (waitResult === 'captcha') { + throw new AuthRequiredError('trip.com', 'Trip.com is asking for a verification; complete it in your browser session and retry'); + } + if (waitResult === 'empty') { + throw new EmptyResultError('trip transfer', `No airport transfers for ${city} (${airport})`); + } + if (waitResult !== 'content') { + throw new CommandExecutionError(`Trip.com transfer listing did not render (state=${String(waitResult)}); check the city and airport code`); + } + const landedPath = await page.evaluate('location.pathname'); + if (!/\/airport-transfers\/[^/]+\/airport-[^/]+/i.test(String(landedPath))) { + throw new CommandExecutionError(`Trip.com bounced ${city} / ${airport} to the transfer landing; check the city name matches the airport IATA code`); + } + const raw = await page.evaluate(buildTransferExtractJs()); + if (!Array.isArray(raw)) { + throw new CommandExecutionError('Trip.com transfer DOM extraction returned malformed rows'); + } + if (raw.length === 0) { + throw new CommandExecutionError('Trip.com transfer cards rendered but parser did not find required price anchors'); + } + return raw.slice(0, limit).map((r, i) => ({ + rank: i + 1, + type: r.type, + passengers: r.passengers, + luggage: r.luggage, + price: r.price, + currency: r.currency, + url: listUrl, + })); + }, +}); diff --git a/clis/trip/trip.test.js b/clis/trip/trip.test.js new file mode 100644 index 000000000..2b74a298e --- /dev/null +++ b/clis/trip/trip.test.js @@ -0,0 +1,1420 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { JSDOM } from 'jsdom'; +import { getRegistry } from '@jackwener/opencli/registry'; +import './flight.js'; +import './flight-round.js'; +import './hotel-search.js'; +import './hotel.js'; +import './attraction.js'; +import './train.js'; +import './car.js'; +import './transfer.js'; +import './tour.js'; +import './search.js'; +import './package.js'; +import './deals.js'; +import { + WAIT_FOR_ATTRACTIONS_JS, + WAIT_FOR_CARS_JS, + WAIT_FOR_DEALS_JS, + WAIT_FOR_HOTEL_DETAIL_JS, + WAIT_FOR_TRAINS_JS, + WAIT_FOR_TRANSFERS_JS, + buildAttractionExtractJs, + buildAttractionSearchUrl, + buildCarExtractJs, + buildCarListUrl, + buildDealsExtractJs, + buildDealsUrl, + buildFlightExtractJs, + buildFlightRoundSearchUrl, + buildFlightSearchUrl, + buildHotelDetailExtractJs, + buildHotelDetailUrl, + buildHotelExtractJs, + buildHotelSearchUrl, + buildTourSearchJs, + buildTourSearchUrl, + buildTrainExtractJs, + buildTrainRouteUrl, + buildTransferExtractJs, + buildTransferListUrl, + fetchPoiSearch, + flattenPoiResults, + mapPackageRow, + mapSearchRow, + parseCityId, + parseHotelId, + parseIataCode, + parseIsoDate, + parseKeyword, + parseListLimit, + resolvePackageCity, +} from './utils.js'; + +function createPageMock(evaluateResults) { + const evaluate = vi.fn(); + for (const result of evaluateResults) { + evaluate.mockResolvedValueOnce(result); + } + return { + goto: vi.fn().mockResolvedValue(undefined), + evaluate, + wait: vi.fn().mockResolvedValue(undefined), + }; +} + +describe('trip parseIataCode', () => { + it('uppercases valid 3-letter codes', () => { + expect(parseIataCode('from', 'lon')).toBe('LON'); + expect(parseIataCode('to', 'JFK')).toBe('JFK'); + }); + it('rejects empty / malformed codes', () => { + expect(() => parseIataCode('from', '')).toThrow('required'); + expect(() => parseIataCode('from', 'LO')).toThrow('3-letter IATA'); + expect(() => parseIataCode('from', 'LOND')).toThrow('3-letter IATA'); + }); +}); + +describe('trip parseIsoDate', () => { + it('accepts real calendar dates', () => { + expect(parseIsoDate('date', '2026-08-15')).toBe('2026-08-15'); + }); + it('rejects malformed / impossible dates', () => { + expect(() => parseIsoDate('date', '08/15')).toThrow('YYYY-MM-DD'); + expect(() => parseIsoDate('date', '2026-02-30')).toThrow('not a real calendar date'); + expect(() => parseIsoDate('date', '')).toThrow('required'); + }); +}); + +describe('trip parseListLimit', () => { + it('falls back for empty / undefined', () => { + expect(parseListLimit(undefined)).toBe(20); + expect(parseListLimit('')).toBe(20); + expect(parseListLimit(undefined, 5)).toBe(5); + }); + it('rejects out-of-range / non-integer (no silent clamp)', () => { + expect(() => parseListLimit(0)).toThrow('--limit'); + expect(() => parseListLimit(51)).toThrow('--limit'); + expect(() => parseListLimit('abc')).toThrow('--limit'); + }); +}); + +describe('trip buildFlightSearchUrl', () => { + it('lowercases codes and pins one-way English/USD params', () => { + const url = buildFlightSearchUrl('LON', 'NYC', '2026-08-15'); + const qs = new URL(url).searchParams; + expect(url).toContain('https://www.trip.com/flights/showfarefirst?'); + expect(qs.get('dcity')).toBe('lon'); + expect(qs.get('acity')).toBe('nyc'); + expect(qs.get('ddate')).toBe('2026-08-15'); + expect(qs.get('triptype')).toBe('ow'); + expect(qs.get('locale')).toBe('en_US'); + expect(qs.get('curr')).toBe('USD'); + }); +}); + +describe('trip flight command (registry-level)', () => { + const cmd = getRegistry().get('trip/flight'); + + const FLIGHT_RAW = { + airline: 'Norse Atlantic Airways', + departureTime: '1:05 PM', + departureAirport: 'LGW', + arrivalTime: '3:55 PM', + arrivalAirport: 'JFK', + duration: '7h 50m', + stops: 'Nonstop', + price: 662, + currency: 'USD', + }; + + it('declares Strategy.COOKIE + browser:true + navigateBefore:false + access:read', () => { + expect(cmd.access).toBe('read'); + expect(cmd.browser).toBe(true); + expect(String(cmd.strategy)).toContain('cookie'); + expect(cmd.navigateBefore).toBe(false); + expect(cmd.domain).toBe('trip.com'); + }); + + it('rejects invalid IATA / date / from==to / limit before navigation', async () => { + const page = createPageMock([]); + await expect(cmd.func(page, { from: 'LO', to: 'NYC', date: '2026-08-15', limit: 5 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('IATA') }); + await expect(cmd.func(page, { from: 'LON', to: 'LON', date: '2026-08-15', limit: 5 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('must differ') }); + await expect(cmd.func(page, { from: 'LON', to: 'NYC', date: '08/15', limit: 5 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--date') }); + await expect(cmd.func(page, { from: 'LON', to: 'NYC', date: '2026-08-15', limit: 0 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--limit') }); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('throws AuthRequired when a verification gate is detected', async () => { + const page = createPageMock(['captcha']); + await expect(cmd.func(page, { from: 'LON', to: 'NYC', date: '2026-08-15', limit: 5 })) + .rejects.toThrow('Trip.com is asking for a verification'); + expect(page.evaluate).toHaveBeenCalledTimes(1); + }); + + it('throws CommandExecutionError on render timeout and on malformed extraction', async () => { + await expect(cmd.func(createPageMock(['timeout']), { from: 'LON', to: 'NYC', date: '2026-08-15', limit: 5 })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('did not render flight cards') }); + await expect(cmd.func(createPageMock(['content', { rows: [] }]), { from: 'LON', to: 'NYC', date: '2026-08-15', limit: 5 })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('malformed rows') }); + }); + + it('throws EmptyResultError when extraction returns no flights', async () => { + await expect(cmd.func(createPageMock(['content', []]), { from: 'LON', to: 'NYC', date: '2026-08-15', limit: 5 })) + .rejects.toMatchObject({ code: 'EMPTY_RESULT' }); + }); + + it('maps DOM-extracted rows and respects --limit', async () => { + const page = createPageMock(['content', [FLIGHT_RAW, { ...FLIGHT_RAW, airline: 'Jetblue Airways', price: 837 }]]); + const rows = await cmd.func(page, { from: 'LON', to: 'NYC', date: '2026-08-15', limit: 1 }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + rank: 1, + airline: 'Norse Atlantic Airways', + departureTime: '1:05 PM', + departureAirport: 'LGW', + arrivalTime: '3:55 PM', + arrivalAirport: 'JFK', + price: 662, + currency: 'USD', + }); + for (const row of rows) { + for (const col of cmd.columns) expect(row).toHaveProperty(col); + } + }); +}); + +describe('trip buildFlightExtractJs (JSDOM)', () => { + function runExtract(html) { + const dom = new JSDOM(`${html}`, { url: 'https://www.trip.com/' }); + const js = buildFlightExtractJs(); + return Function('document', `return (${js})`)(dom.window.document); + } + + const CARD = ` +
+
Norse Atlantic Airways
+
LGW
+
JFK
+ 1:05PM + 3:55PM + 7h 50m +
Nonstop
+
$662
+
`; + + it('extracts a flight card via data-testid + time/code anchors', () => { + expect(runExtract(CARD)).toEqual([{ + airline: 'Norse Atlantic Airways', + departureTime: '1:05 PM', + departureAirport: 'LGW', + arrivalTime: '3:55 PM', + arrivalAirport: 'JFK', + duration: '7h 50m', + stops: 'Nonstop', + price: 662, + currency: 'USD', + }]); + }); + + it('keeps price null when the price node is missing/non-numeric', () => { + const noPrice = CARD.replace('
$662
', '
--
'); + expect(runExtract(noPrice)[0].price).toBeNull(); + }); + + it('drops cards missing airline or an airport (no sentinel rows)', () => { + const noAirline = CARD.replace('
Norse Atlantic Airways
', ''); + expect(runExtract(noAirline)).toEqual([]); + expect(runExtract('
')).toEqual([]); + }); +}); + +describe('trip parseCityId', () => { + it('accepts numeric city ids', () => { + expect(parseCityId('city', '338')).toBe('338'); + expect(parseCityId('city', 338)).toBe('338'); + }); + it('rejects empty / non-numeric ids', () => { + expect(() => parseCityId('city', '')).toThrow('required'); + expect(() => parseCityId('city', 'London')).toThrow('numeric'); + }); +}); + +describe('trip buildHotelSearchUrl', () => { + it('pins city / dates / English / USD params', () => { + const url = buildHotelSearchUrl('338', '2026-08-15', '2026-08-16'); + const qs = new URL(url).searchParams; + expect(url).toContain('https://www.trip.com/hotels/list?'); + expect(qs.get('city')).toBe('338'); + expect(qs.get('checkin')).toBe('2026-08-15'); + expect(qs.get('checkout')).toBe('2026-08-16'); + expect(qs.get('locale')).toBe('en_US'); + expect(qs.get('curr')).toBe('USD'); + }); +}); + +describe('trip hotel-search command (registry-level)', () => { + const cmd = getRegistry().get('trip/hotel-search'); + + const HOTEL_RAW = { + name: 'Royal National Hotel', + score: 8.2, + reviewLabel: 'Very good', + reviews: 2918, + location: 'Bloomsbury, Near The British Museum', + room: 'Standard Plus Twin Room', + price: 205, + currency: 'USD', + }; + + it('declares Strategy.COOKIE + browser:true + access:read', () => { + expect(cmd.access).toBe('read'); + expect(cmd.browser).toBe(true); + expect(String(cmd.strategy)).toContain('cookie'); + expect(cmd.domain).toBe('trip.com'); + }); + + it('rejects invalid city / dates / limit before navigation', async () => { + const page = createPageMock([]); + await expect(cmd.func(page, { city: 'London', checkin: '2026-08-15', checkout: '2026-08-16', limit: 5 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('numeric') }); + await expect(cmd.func(page, { city: '338', checkin: '08/15', checkout: '2026-08-16', limit: 5 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--checkin') }); + await expect(cmd.func(page, { city: '338', checkin: '2026-08-16', checkout: '2026-08-15', limit: 5 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('before --checkout') }); + await expect(cmd.func(page, { city: '338', checkin: '2026-08-15', checkout: '2026-08-16', limit: 0 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--limit') }); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('throws AuthRequired on verification, CommandExec on timeout, EmptyResult on no hotels', async () => { + await expect(cmd.func(createPageMock(['captcha']), { city: '338', checkin: '2026-08-15', checkout: '2026-08-16', limit: 5 })) + .rejects.toThrow('Trip.com is asking for a verification'); + await expect(cmd.func(createPageMock(['timeout']), { city: '338', checkin: '2026-08-15', checkout: '2026-08-16', limit: 5 })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('did not render hotel cards') }); + await expect(cmd.func(createPageMock(['content', []]), { city: '338', checkin: '2026-08-15', checkout: '2026-08-16', limit: 5 })) + .rejects.toMatchObject({ code: 'EMPTY_RESULT' }); + }); + + it('maps DOM-extracted rows and respects --limit', async () => { + const page = createPageMock(['content', [HOTEL_RAW, { ...HOTEL_RAW, name: 'LSE Rosebery Hall', price: 116 }]]); + const rows = await cmd.func(page, { city: '338', checkin: '2026-08-15', checkout: '2026-08-16', limit: 1 }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ rank: 1, name: 'Royal National Hotel', score: 8.2, reviews: 2918, price: 205, currency: 'USD' }); + for (const row of rows) { + for (const col of cmd.columns) expect(row).toHaveProperty(col); + } + }); +}); + +describe('trip buildHotelExtractJs (JSDOM)', () => { + function runExtract(html) { + const dom = new JSDOM(`${html}`, { url: 'https://www.trip.com/' }); + const js = buildHotelExtractJs(); + return Function('document', `return (${js})`)(dom.window.document); + } + + const CARD = ` +
+
Royal National Hotel
+
8.2
+
Very good
+
2,918 reviews
+
Bloomsbury
+
Near The British Museum
+
Standard Plus Twin Room
+
$205
+
`; + + it('extracts a hotel card with numeric score / reviews / price', () => { + expect(runExtract(CARD)).toEqual([{ + name: 'Royal National Hotel', + score: 8.2, + reviewLabel: 'Very good', + reviews: 2918, + location: 'Bloomsbury, Near The British Museum', + room: 'Standard Plus Twin Room', + price: 205, + currency: 'USD', + }]); + }); + + it('keeps price null when non-numeric and drops cards without a name', () => { + const noPrice = CARD.replace('
$205
', '
Sold out
'); + expect(runExtract(noPrice)[0].price).toBeNull(); + const noName = CARD.replace('
Royal National Hotel
', ''); + expect(runExtract(noName)).toEqual([]); + }); +}); + +const HOTEL_DETAIL_SSR = { + hotelBaseInfo: { + masterHotelId: 715233, + cityName: 'London', + nameInfo: { name: 'LSE Rosebery Hall', nameEn: '' }, + starInfo: { level: 2 }, + }, + hotelPositionInfo: { address: '90 Rosebery Ave, Islington, London, EC1R 4TY, United Kingdom', lat: '51.527561', lng: '-0.107065' }, + hotelComment: { + comment: { + score: '8.3', + scoreDescription: 'Very good', + totalComment: 159, + scoreDetail: [ + { showName: 'Cleanliness', showScore: '8.7' }, + { showName: 'Amenities', showScore: '7.7' }, + { showName: 'Location', showScore: '8.5' }, + { showName: 'Service', showScore: '8.3' }, + ], + }, + }, + hotelFacilityPopV2: { + hotelPopularFacility: { + list: [ + { facilityDesc: 'Luggage storage' }, + { facilityDesc: 'Wi-Fi in public areas' }, + ], + }, + }, + hotelPolicyInfo: { + checkInAndOut: { + content: [ + { title: 'Check-in: ', description: 'After 15:00' }, + { title: 'Check-out: ', description: 'Before 10:30' }, + { description: 'Front desk hours: 24/7' }, + ], + }, + }, +}; + +// Shape as projected by buildHotelDetailExtractJs (what page.evaluate returns). +const HOTEL_DETAIL_ROW = { + hotelId: '715233', + name: 'LSE Rosebery Hall', + enName: null, + star: 2, + score: 8.3, + scoreLabel: 'Very good', + reviewCount: 159, + ratingBreakdown: 'Cleanliness 8.7 / Amenities 7.7 / Location 8.5 / Service 8.3', + facilities: 'Luggage storage / Wi-Fi in public areas', + checkInOut: 'Check-in: After 15:00 / Check-out: Before 10:30 / Front desk hours: 24/7', + cityName: 'London', + address: '90 Rosebery Ave, Islington, London, EC1R 4TY, United Kingdom', + lat: 51.527561, + lon: -0.107065, +}; + +describe('trip parseHotelId', () => { + it('accepts a numeric id as string', () => { + expect(parseHotelId('id', '715233')).toBe('715233'); + }); + it('rejects blank / non-numeric ids', () => { + expect(() => parseHotelId('id', '')).toThrow('required'); + expect(() => parseHotelId('id', 'abc')).toThrow('numeric Trip.com hotel id'); + }); +}); + +describe('trip buildHotelDetailUrl', () => { + it('builds the detail URL with the hotel id', () => { + const url = buildHotelDetailUrl('715233'); + expect(url.startsWith('https://www.trip.com/hotels/detail/?')).toBe(true); + expect(url).toContain('hotelId=715233'); + expect(url).toContain('curr=USD'); + }); +}); + +describe('trip hotel command (registry-level)', () => { + const cmd = getRegistry().get('trip/hotel'); + + it('declares Strategy.COOKIE + browser:true + access:read', () => { + expect(cmd.access).toBe('read'); + expect(cmd.browser).toBe(true); + expect(String(cmd.strategy)).toContain('cookie'); + expect(cmd.domain).toBe('trip.com'); + }); + + it('rejects a non-numeric id before navigation', async () => { + const page = createPageMock([]); + await expect(cmd.func(page, { id: 'shoreditch' })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('numeric Trip.com hotel id') }); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('throws AuthRequired on verification, CommandExec on timeout / malformed, EmptyResult on no profile', async () => { + await expect(cmd.func(createPageMock(['captcha']), { id: '715233' })) + .rejects.toThrow('Trip.com is asking for a verification'); + await expect(cmd.func(createPageMock(['timeout']), { id: '715233' })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('did not expose SSR hotel data') }); + await expect(cmd.func(createPageMock(['content', null]), { id: '715233' })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('malformed data') }); + await expect(cmd.func(createPageMock(['content', { hotelId: null, name: null }]), { id: '715233' })) + .rejects.toMatchObject({ code: 'EMPTY_RESULT' }); + }); + + it('maps the SSR profile into a single row carrying every declared column', async () => { + const page = createPageMock(['content', HOTEL_DETAIL_ROW]); + const rows = await cmd.func(page, { id: '715233' }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + hotelId: '715233', + name: 'LSE Rosebery Hall', + star: 2, + score: 8.3, + ratingBreakdown: 'Cleanliness 8.7 / Amenities 7.7 / Location 8.5 / Service 8.3', + facilities: 'Luggage storage / Wi-Fi in public areas', + url: expect.stringContaining('hotelId=715233'), + }); + for (const row of rows) { + for (const col of cmd.columns) expect(row).toHaveProperty(col); + } + expect(page.goto).toHaveBeenCalledTimes(1); + }); +}); + +describe('trip buildHotelDetailExtractJs (JSDOM)', () => { + function runExtract(nextData) { + const dom = new JSDOM('', { + url: 'https://www.trip.com/hotels/detail/', + runScripts: 'outside-only', + }); + dom.window.__NEXT_DATA__ = nextData; + return dom.window.Function(`return (${buildHotelDetailExtractJs()})`)(); + } + + it('projects the hotel profile, joining sub-scores / amenities / policy', () => { + const out = runExtract({ props: { pageProps: { hotelDetailResponse: HOTEL_DETAIL_SSR } } }); + expect(out).toEqual(HOTEL_DETAIL_ROW); + }); + + it('returns null when the SSR detail block is absent', () => { + expect(runExtract({ props: { pageProps: {} } })).toBeNull(); + }); + + it('detects the rendered SSR block as content via WAIT_FOR_HOTEL_DETAIL_JS', async () => { + const dom = new JSDOM('', { + url: 'https://www.trip.com/hotels/detail/', + runScripts: 'outside-only', + }); + dom.window.__NEXT_DATA__ = { props: { pageProps: { hotelDetailResponse: HOTEL_DETAIL_SSR } } }; + await expect(dom.window.Function(`return (${WAIT_FOR_HOTEL_DETAIL_JS})`)()) + .resolves.toBe('content'); + }); +}); + +describe('trip buildFlightRoundSearchUrl', () => { + it('lowercases codes and pins round-trip English/USD params', () => { + const url = buildFlightRoundSearchUrl('LON', 'NYC', '2026-08-15', '2026-08-22'); + const qs = new URL(url).searchParams; + expect(url).toContain('https://www.trip.com/flights/showfarefirst?'); + expect(qs.get('dcity')).toBe('lon'); + expect(qs.get('acity')).toBe('nyc'); + expect(qs.get('ddate')).toBe('2026-08-15'); + expect(qs.get('rdate')).toBe('2026-08-22'); + expect(qs.get('triptype')).toBe('rt'); + expect(qs.get('curr')).toBe('USD'); + }); +}); + +describe('trip flight-round command (registry-level)', () => { + const cmd = getRegistry().get('trip/flight-round'); + + const FLIGHT_RAW = { + airline: 'British Airways', + departureTime: '6:05 PM', + departureAirport: 'LHR', + arrivalTime: '9:05 PM', + arrivalAirport: 'JFK', + duration: '8h', + stops: 'Nonstop', + price: 758, + currency: 'USD', + }; + + it('declares Strategy.COOKIE + browser:true + navigateBefore:false + access:read', () => { + expect(cmd.access).toBe('read'); + expect(cmd.browser).toBe(true); + expect(String(cmd.strategy)).toContain('cookie'); + expect(cmd.navigateBefore).toBe(false); + expect(cmd.domain).toBe('trip.com'); + }); + + it('rejects invalid IATA / dates / from==to / depart>=return / limit before navigation', async () => { + const page = createPageMock([]); + await expect(cmd.func(page, { from: 'LO', to: 'NYC', depart: '2026-08-15', return: '2026-08-22', limit: 5 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('IATA') }); + await expect(cmd.func(page, { from: 'LON', to: 'LON', depart: '2026-08-15', return: '2026-08-22', limit: 5 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('must differ') }); + await expect(cmd.func(page, { from: 'LON', to: 'NYC', depart: '08/15', return: '2026-08-22', limit: 5 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--depart') }); + await expect(cmd.func(page, { from: 'LON', to: 'NYC', depart: '2026-08-22', return: '2026-08-15', limit: 5 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--depart must be before --return') }); + await expect(cmd.func(page, { from: 'LON', to: 'NYC', depart: '2026-08-15', return: '2026-08-22', limit: 0 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--limit') }); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('throws AuthRequired on verification, CommandExec on timeout, EmptyResult on no flights', async () => { + await expect(cmd.func(createPageMock(['captcha']), { from: 'LON', to: 'NYC', depart: '2026-08-15', return: '2026-08-22', limit: 5 })) + .rejects.toThrow('Trip.com is asking for a verification'); + await expect(cmd.func(createPageMock(['timeout']), { from: 'LON', to: 'NYC', depart: '2026-08-15', return: '2026-08-22', limit: 5 })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('did not render flight cards') }); + await expect(cmd.func(createPageMock(['content', []]), { from: 'LON', to: 'NYC', depart: '2026-08-15', return: '2026-08-22', limit: 5 })) + .rejects.toMatchObject({ code: 'EMPTY_RESULT' }); + }); + + it('maps DOM-extracted rows against the round-trip URL and respects --limit', async () => { + const page = createPageMock(['content', [FLIGHT_RAW, { ...FLIGHT_RAW, airline: 'American Airlines', price: 767 }]]); + const rows = await cmd.func(page, { from: 'LON', to: 'NYC', depart: '2026-08-15', return: '2026-08-22', limit: 1 }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ rank: 1, airline: 'British Airways', departureAirport: 'LHR', price: 758, currency: 'USD' }); + for (const row of rows) { + for (const col of cmd.columns) expect(row).toHaveProperty(col); + } + expect(page.goto).toHaveBeenCalledTimes(1); + expect(page.goto.mock.calls[0][0]).toContain('triptype=rt'); + }); +}); + +const ATTR_CARD = ` +
+ Tokyo Metro 24/48/72 Hour Pass + 4.8 /5 4.9k reviews 109.5k booked + Up to $3 off $5.54 $6.16 +
`; + +const ATTR_RAW = { + name: 'Tokyo Metro 24/48/72 Hour Pass', + rating: 4.8, + reviews: 4900, + booked: 109500, + price: 5.54, + url: 'https://www.trip.com/things-to-do/detail/24465457/', +}; + +describe('trip parseKeyword', () => { + it('accepts a non-empty keyword', () => { + expect(parseKeyword('query', 'Tokyo')).toBe('Tokyo'); + expect(parseKeyword('query', ' Paris ')).toBe('Paris'); + }); + it('rejects blank / over-long keywords', () => { + expect(() => parseKeyword('query', '')).toThrow('required'); + expect(() => parseKeyword('query', 'x'.repeat(61))).toThrow('too long'); + }); +}); + +describe('trip buildAttractionSearchUrl', () => { + it('builds the things-to-do search URL with the keyword', () => { + const url = buildAttractionSearchUrl('Tokyo'); + expect(url.startsWith('https://www.trip.com/things-to-do/list?')).toBe(true); + expect(url).toContain('keyword=Tokyo'); + expect(url).toContain('curr=USD'); + }); +}); + +describe('trip attraction command (registry-level)', () => { + const cmd = getRegistry().get('trip/attraction'); + + it('declares Strategy.COOKIE + browser:true + access:read', () => { + expect(cmd.access).toBe('read'); + expect(cmd.browser).toBe(true); + expect(String(cmd.strategy)).toContain('cookie'); + expect(cmd.domain).toBe('trip.com'); + }); + + it('rejects a blank query and invalid limit before navigation', async () => { + const page = createPageMock([]); + await expect(cmd.func(page, { query: '', limit: 5 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('required') }); + await expect(cmd.func(page, { query: 'Tokyo', limit: 0 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--limit') }); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('throws AuthRequired on verification, EmptyResult on empty, CommandExec on timeout / drift', async () => { + await expect(cmd.func(createPageMock(['captcha']), { query: 'Tokyo', limit: 5 })) + .rejects.toThrow('Trip.com is asking for a verification'); + await expect(cmd.func(createPageMock(['empty']), { query: 'Nowherexyz', limit: 5 })) + .rejects.toMatchObject({ code: 'EMPTY_RESULT' }); + await expect(cmd.func(createPageMock(['timeout']), { query: 'Tokyo', limit: 5 })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('did not render product cards') }); + await expect(cmd.func(createPageMock(['content', []]), { query: 'Tokyo', limit: 5 })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('did not find required detail-link anchors') }); + }); + + it('maps rows with a per-row detail url and respects --limit', async () => { + const page = createPageMock(['content', [ATTR_RAW, { ...ATTR_RAW, name: 'Mount Fuji Day Trip', price: 36.29 }]]); + const rows = await cmd.func(page, { query: 'Tokyo', limit: 1 }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ rank: 1, name: 'Tokyo Metro 24/48/72 Hour Pass', rating: 4.8, reviews: 4900, price: 5.54, currency: 'USD', url: ATTR_RAW.url }); + for (const row of rows) { + for (const col of cmd.columns) expect(row).toHaveProperty(col); + } + expect(page.goto).toHaveBeenCalledTimes(1); + expect(page.goto.mock.calls[0][0]).toContain('keyword=Tokyo'); + }); +}); + +describe('trip buildAttractionExtractJs (JSDOM)', () => { + function runExtract(html) { + const dom = new JSDOM(`${html}`, { url: 'https://www.trip.com/things-to-do/list' }); + return Function('document', `return (${buildAttractionExtractJs()})`)(dom.window.document); + } + + it('extracts a product by its stable detail link, excluding the promo price', () => { + expect(runExtract(ATTR_CARD)).toEqual([ATTR_RAW]); + }); + + it('dedupes repeated detail links and drops links without a numeric id', () => { + expect(runExtract(ATTR_CARD + ATTR_CARD)).toHaveLength(1); + expect(runExtract('')).toEqual([]); + }); +}); + +const TRAIN_TABLE = ` + + + + + + + + + + +
DepartureArrival
05:273h 38m, 1 changeLondon St. Pancras International09:05Manchester Piccadilly
06:082h 17m, DirectLondon Euston08:25Manchester Piccadilly
`; + +const TRAIN_RAW = { + departureTime: '05:27', + fromStation: 'London St. Pancras International', + arrivalTime: '09:05', + toStation: 'Manchester Piccadilly', + duration: '3h 38m', + changes: 1, +}; + +describe('trip buildTrainRouteUrl', () => { + it('slugifies the cities under the country route path', () => { + expect(buildTrainRouteUrl('uk', 'London', 'Manchester')) + .toBe('https://www.trip.com/trains/uk/route/london-to-manchester/'); + expect(buildTrainRouteUrl('France', 'Paris', 'Lyon')) + .toBe('https://www.trip.com/trains/france/route/paris-to-lyon/'); + }); +}); + +describe('trip train command (registry-level)', () => { + const cmd = getRegistry().get('trip/train'); + + it('declares Strategy.COOKIE + browser:true + access:read', () => { + expect(cmd.access).toBe('read'); + expect(cmd.browser).toBe(true); + expect(String(cmd.strategy)).toContain('cookie'); + expect(cmd.domain).toBe('trip.com'); + }); + + it('rejects a blank city / country and invalid limit before navigation', async () => { + const page = createPageMock([]); + await expect(cmd.func(page, { from: '', to: 'Manchester', country: 'uk', limit: 5 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('required') }); + await expect(cmd.func(page, { from: 'London', to: 'Manchester', country: '', limit: 5 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('required') }); + await expect(cmd.func(page, { from: 'London', to: 'Manchester', country: 'uk', limit: 0 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--limit') }); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('throws AuthRequired on verification, CommandExec on timeout, EmptyResult on no timetable', async () => { + await expect(cmd.func(createPageMock(['captcha']), { from: 'London', to: 'Manchester', country: 'uk', limit: 5 })) + .rejects.toThrow('Trip.com is asking for a verification'); + await expect(cmd.func(createPageMock(['timeout']), { from: 'London', to: 'Manchester', country: 'uk', limit: 5 })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('did not render') }); + await expect(cmd.func(createPageMock(['content', []]), { from: 'London', to: 'Manchester', country: 'uk', limit: 5 })) + .rejects.toMatchObject({ code: 'EMPTY_RESULT' }); + }); + + it('maps timetable rows against the route URL and respects --limit', async () => { + const page = createPageMock(['content', [TRAIN_RAW, { ...TRAIN_RAW, departureTime: '06:08', changes: 0 }]]); + const rows = await cmd.func(page, { from: 'London', to: 'Manchester', country: 'uk', limit: 1 }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ rank: 1, departureTime: '05:27', fromStation: 'London St. Pancras International', arrivalTime: '09:05', duration: '3h 38m', changes: 1 }); + for (const row of rows) { + for (const col of cmd.columns) expect(row).toHaveProperty(col); + } + expect(page.goto).toHaveBeenCalledTimes(1); + expect(page.goto.mock.calls[0][0]).toContain('/trains/uk/route/london-to-manchester/'); + }); +}); + +describe('trip buildTrainExtractJs (JSDOM)', () => { + function runExtract(html) { + const dom = new JSDOM(`${html}`, { url: 'https://www.trip.com/trains/uk/route/london-to-manchester/' }); + return Function('document', `return (${buildTrainExtractJs()})`)(dom.window.document); + } + + it('extracts timetable journeys, parsing duration and change count', () => { + const rows = runExtract(TRAIN_TABLE); + expect(rows).toHaveLength(2); + expect(rows[0]).toEqual(TRAIN_RAW); + expect(rows[1].changes).toBe(0); + expect(rows[1].fromStation).toBe('London Euston'); + }); + + it('drops the header row and rows missing a time or station', () => { + expect(runExtract('
DepartureArrival
')).toEqual([]); + }); +}); + +const CAR_CARDS = ` +
+
Mid-sized car
Toyota Camry or Similar
+
5 3 4
+
From $50 /day
+
+
+
Compact car
Toyota Corolla or Similar
+
5 3 4
+
From $47 /day
+
`; + +const CAR_RAW = { + category: 'Mid-sized car', + vehicle: 'Toyota Camry or Similar', + seats: 5, + price: 50, + currency: 'USD', +}; + +describe('trip buildCarListUrl', () => { + it('routes by the numeric city id with cosmetic slugs', () => { + expect(buildCarListUrl('313')).toBe('https://www.trip.com/carhire/to-city-1/city-313/'); + }); +}); + +describe('trip car command (registry-level)', () => { + const cmd = getRegistry().get('trip/car'); + + it('declares Strategy.COOKIE + browser:true + access:read', () => { + expect(cmd.access).toBe('read'); + expect(cmd.browser).toBe(true); + expect(String(cmd.strategy)).toContain('cookie'); + expect(cmd.domain).toBe('trip.com'); + }); + + it('rejects a non-numeric city and invalid limit before navigation', async () => { + const page = createPageMock([]); + await expect(cmd.func(page, { city: 'san francisco', limit: 5 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('numeric') }); + await expect(cmd.func(page, { city: '313', limit: 0 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--limit') }); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('throws AuthRequired on verification, EmptyResult on an empty listing, CommandExec on timeout or drift', async () => { + await expect(cmd.func(createPageMock(['captcha']), { city: '313', limit: 5 })) + .rejects.toThrow('Trip.com is asking for a verification'); + await expect(cmd.func(createPageMock(['empty']), { city: '313', limit: 5 })) + .rejects.toMatchObject({ code: 'EMPTY_RESULT' }); + await expect(cmd.func(createPageMock(['timeout']), { city: '313', limit: 5 })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('did not render') }); + await expect(cmd.func(createPageMock(['content', []]), { city: '313', limit: 5 })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('did not find') }); + }); + + it('maps vehicle rows against the listing URL and respects --limit', async () => { + const page = createPageMock(['content', [CAR_RAW, { ...CAR_RAW, category: 'Compact car', price: 47 }]]); + const rows = await cmd.func(page, { city: '313', limit: 1 }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ rank: 1, category: 'Mid-sized car', vehicle: 'Toyota Camry or Similar', seats: 5, price: 50, currency: 'USD' }); + expect(rows[0].url).toBe('https://www.trip.com/carhire/to-city-1/city-313/'); + for (const row of rows) { + for (const col of cmd.columns) expect(row).toHaveProperty(col); + } + expect(page.goto).toHaveBeenCalledTimes(1); + }); +}); + +describe('trip buildCarExtractJs (JSDOM)', () => { + function runExtract(html) { + const dom = new JSDOM(`${html}`, { url: 'https://www.trip.com/carhire/to-city-1/city-313/' }); + return Function('document', `return (${buildCarExtractJs()})`)(dom.window.document); + } + + it('splits the category from the example model and reads seats + price', () => { + const rows = runExtract(CAR_CARDS); + expect(rows).toHaveLength(2); + expect(rows[0]).toEqual(CAR_RAW); + expect(rows[1]).toMatchObject({ category: 'Compact car', vehicle: 'Toyota Corolla or Similar', price: 47 }); + }); + + it('drops cards without a rendered price', () => { + expect(runExtract('
No Price Car
')).toEqual([]); + }); +}); + +describe('trip WAIT_FOR_CARS_JS (JSDOM)', () => { + it('detects a rendered price anchor as content', async () => { + const dom = new JSDOM('
From $50 /day
', { + url: 'https://www.trip.com/carhire/to-city-1/city-313/', + runScripts: 'outside-only', + }); + await expect(dom.window.Function(`return (${WAIT_FOR_CARS_JS})`)()) + .resolves.toBe('content'); + }); +}); + +const TRANSFER_CARDS = ` +
+
+
Standard Car
+ Max 4 + 1 +
$ 4.21 off
From $15.73 Incl. taxes & fees
+
+
+
Minibus
+ Max 9 + 3 +
From $30.81 Incl. taxes & fees
+
+
`; + +const TRANSFER_RAW = { + type: 'Standard Car', + passengers: 4, + luggage: 1, + price: 15.73, + currency: 'USD', +}; + +describe('trip buildTransferListUrl', () => { + it('slugifies the city and airport code into the SEO path', () => { + expect(buildTransferListUrl('Bangkok', 'DMK')) + .toBe('https://www.trip.com/airport-transfers/bangkok/airport-dmk/'); + expect(buildTransferListUrl('Ho Chi Minh City', 'SGN')) + .toBe('https://www.trip.com/airport-transfers/ho-chi-minh-city/airport-sgn/'); + }); +}); + +describe('trip transfer command (registry-level)', () => { + const cmd = getRegistry().get('trip/transfer'); + + it('declares Strategy.COOKIE + browser:true + access:read', () => { + expect(cmd.access).toBe('read'); + expect(cmd.browser).toBe(true); + expect(String(cmd.strategy)).toContain('cookie'); + expect(cmd.domain).toBe('trip.com'); + }); + + it('rejects a blank city / non-IATA airport and invalid limit before navigation', async () => { + const page = createPageMock([]); + await expect(cmd.func(page, { city: '', airport: 'DMK', limit: 5 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('required') }); + await expect(cmd.func(page, { city: 'Bangkok', airport: 'BANGKOK', limit: 5 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('IATA') }); + await expect(cmd.func(page, { city: 'Bangkok', airport: 'DMK', limit: 99 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--limit') }); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('throws AuthRequired on verification, EmptyResult on no listing, CommandExec on timeout', async () => { + await expect(cmd.func(createPageMock(['captcha']), { city: 'Bangkok', airport: 'DMK', limit: 5 })) + .rejects.toThrow('Trip.com is asking for a verification'); + await expect(cmd.func(createPageMock(['empty']), { city: 'Bangkok', airport: 'DMK', limit: 5 })) + .rejects.toMatchObject({ code: 'EMPTY_RESULT' }); + await expect(cmd.func(createPageMock(['timeout']), { city: 'Bangkok', airport: 'DMK', limit: 5 })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('did not render') }); + }); + + it('flags a landing-page bounce when the city does not match the airport', async () => { + await expect(cmd.func(createPageMock(['content', '/airport-transfers/']), { city: 'Paris', airport: 'DMK', limit: 5 })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('bounced') }); + }); + + it('maps vehicle rows against the listing URL and respects --limit', async () => { + const page = createPageMock(['content', '/airport-transfers/bangkok/airport-dmk/', [TRANSFER_RAW, { ...TRANSFER_RAW, type: 'Minibus', passengers: 9, price: 30.81 }]]); + const rows = await cmd.func(page, { city: 'Bangkok', airport: 'DMK', limit: 1 }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ rank: 1, type: 'Standard Car', passengers: 4, luggage: 1, price: 15.73, currency: 'USD' }); + expect(rows[0].url).toBe('https://www.trip.com/airport-transfers/bangkok/airport-dmk/'); + for (const row of rows) { + for (const col of cmd.columns) expect(row).toHaveProperty(col); + } + expect(page.goto).toHaveBeenCalledTimes(1); + }); +}); + +describe('trip buildTransferExtractJs (JSDOM)', () => { + function runExtract(html) { + const dom = new JSDOM(`${html}`, { url: 'https://www.trip.com/airport-transfers/bangkok/airport-dmk/' }); + return Function('document', `return (${buildTransferExtractJs()})`)(dom.window.document); + } + + it('reads type / passengers / luggage / price and excludes the discount tag', () => { + const rows = runExtract(TRANSFER_CARDS); + expect(rows).toHaveLength(2); + expect(rows[0]).toEqual(TRANSFER_RAW); + expect(rows[1]).toMatchObject({ type: 'Minibus', passengers: 9, luggage: 3, price: 30.81 }); + }); + + it('drops cards without a type or price', () => { + expect(runExtract('
From $15 Incl.
')).toEqual([]); + }); +}); + +describe('trip WAIT_FOR_TRANSFERS_JS (JSDOM)', () => { + it('detects a rendered price row as content', async () => { + const dom = new JSDOM('
From $15.73
', { + url: 'https://www.trip.com/airport-transfers/bangkok/airport-dmk/', + runScripts: 'outside-only', + }); + await expect(dom.window.Function(`return (${WAIT_FOR_TRANSFERS_JS})`)()) + .resolves.toBe('content'); + }); +}); + +const TOUR_RAW = { + name: '2D1N ยท Private Tours ยท Japan Osaka + Kyoto + Nara Kansai Three-City Travel Route', + type: 'Private Tours', + rating: 4.9, + reviews: 48, + price: 88, + url: 'https://us.trip.com/package-tours/detail/70457661?city=219&locale=en-US&curr=USD', +}; + +describe('trip buildTourSearchUrl', () => { + it('builds the package-tours list URL with kwd + tabType', () => { + expect(buildTourSearchUrl('Kyoto', 'privateTours')) + .toBe('https://www.trip.com/package-tours/list?kwd=Kyoto&tabType=privateTours&locale=en-US&curr=USD'); + }); +}); + +describe('trip buildTourSearchJs', () => { + it('embeds the keyword, the products-content capture guard, and the empty-result guard', () => { + const js = buildTourSearchJs('Bali'); + expect(js).toContain('"Bali"'); + expect(js).toContain('"products":['); + expect(js).toContain('routes?'); + }); +}); + +describe('trip tour command (registry-level)', () => { + const cmd = getRegistry().get('trip/tour'); + + it('declares Strategy.COOKIE + browser:true + access:read', () => { + expect(cmd.access).toBe('read'); + expect(cmd.browser).toBe(true); + expect(String(cmd.strategy)).toContain('cookie'); + expect(cmd.domain).toBe('trip.com'); + }); + + it('rejects a blank query, invalid --type, and invalid limit before navigation', async () => { + const page = createPageMock([]); + await expect(cmd.func(page, { query: '', type: 'private', limit: 5 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('required') }); + await expect(cmd.func(page, { query: 'Kyoto', type: 'luxury', limit: 5 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--type') }); + await expect(cmd.func(page, { query: 'Kyoto', type: 'private', limit: 0 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--limit') }); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('throws AuthRequired on verification, EmptyResult on a genuine no-match, CommandExec on timeout / malformed / drift', async () => { + await expect(cmd.func(createPageMock([{ status: 'captcha' }]), { query: 'Kyoto', type: 'private', limit: 5 })) + .rejects.toThrow('Trip.com is asking for a verification'); + await expect(cmd.func(createPageMock([{ status: 'empty' }]), { query: 'Nowherexyz', type: 'private', limit: 5 })) + .rejects.toMatchObject({ code: 'EMPTY_RESULT' }); + await expect(cmd.func(createPageMock([{ status: 'timeout' }]), { query: 'Kyoto', type: 'private', limit: 5 })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('did not return results') }); + await expect(cmd.func(createPageMock([null]), { query: 'Kyoto', type: 'private', limit: 5 })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('malformed') }); + await expect(cmd.func(createPageMock([{ status: 'content', rows: [] }]), { query: 'Kyoto', type: 'private', limit: 5 })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('none carried a name') }); + }); + + it('maps captured products, drops nameless rows, and respects --type / --limit', async () => { + const page = createPageMock([{ status: 'content', rows: [TOUR_RAW, { name: null }, { ...TOUR_RAW, name: 'Osaka Group Tour', price: 119 }] }]); + const rows = await cmd.func(page, { query: 'Kyoto', type: 'group', limit: 5 }); + expect(rows).toHaveLength(2); + expect(rows[0]).toMatchObject({ rank: 1, name: TOUR_RAW.name, type: 'Private Tours', rating: 4.9, reviews: 48, price: 88, currency: 'USD', url: TOUR_RAW.url }); + for (const row of rows) { + for (const col of cmd.columns) expect(row).toHaveProperty(col); + } + expect(page.goto).toHaveBeenCalledTimes(1); + expect(page.goto.mock.calls[0][0]).toContain('tabType=groupTours'); + }); +}); + +const POI_CITY = { + name: 'Bali', + cityId: 723, + provinceName: 'Bali Province', + countryName: 'Indonesia', + airportCode: '', + childResults: [ + { name: 'Ngurah Rai International Airport', cityId: 723, airportCode: 'DPS', provinceName: 'Bali Province', countryName: 'Indonesia' }, + ], +}; + +function poiResponse(payload) { + return new Response(JSON.stringify(payload), { status: 200 }); +} + +describe('trip mapSearchRow', () => { + it('classifies a city vs an airport and preserves ids (no silent drop)', () => { + expect(mapSearchRow(POI_CITY, 0)).toEqual({ + rank: 1, name: 'Bali', type: 'city', cityId: 723, airportCode: null, + province: 'Bali Province', country: 'Indonesia', + }); + expect(mapSearchRow(POI_CITY.childResults[0], 1)).toMatchObject({ + rank: 2, name: 'Ngurah Rai International Airport', type: 'airport', cityId: 723, airportCode: 'DPS', + }); + }); + it('keeps cityId null when the entry has none (e.g. a province)', () => { + expect(mapSearchRow({ name: 'Bali Province', provinceName: 'Bali Province', countryName: 'Indonesia' }, 0).cityId).toBeNull(); + }); +}); + +describe('trip flattenPoiResults', () => { + it('flattens each city plus its child airports in order', () => { + const rows = flattenPoiResults([POI_CITY]); + expect(rows).toHaveLength(2); + expect(rows[0].name).toBe('Bali'); + expect(rows[1].airportCode).toBe('DPS'); + }); + it('skips non-object entries', () => { + expect(flattenPoiResults([null, POI_CITY, 'x'])).toHaveLength(2); + }); +}); + +describe('trip search command (registry-level)', () => { + const cmd = getRegistry().get('trip/search'); + beforeEach(() => vi.unstubAllGlobals()); + + it('declares Strategy.PUBLIC + browser:false + access:read', () => { + expect(cmd.access).toBe('read'); + expect(cmd.browser).toBe(false); + expect(String(cmd.strategy)).toContain('public'); + }); + + it('rejects a blank query and invalid limit', async () => { + await expect(cmd.func({ query: '', limit: 5 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('required') }); + await expect(cmd.func({ query: 'Bali', limit: 0 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--limit') }); + }); + + it('maps flattened suggestions and respects --limit', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(poiResponse({ results: [POI_CITY] })))); + const rows = await cmd.func({ query: 'Bali', limit: 5 }); + expect(rows).toHaveLength(2); + expect(rows[0]).toMatchObject({ rank: 1, name: 'Bali', type: 'city', cityId: 723 }); + expect(rows[1]).toMatchObject({ rank: 2, type: 'airport', airportCode: 'DPS' }); + for (const row of rows) { + for (const col of cmd.columns) expect(row).toHaveProperty(col); + } + }); + + it('throws EmptyResult when the endpoint returns no results', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(poiResponse({ results: [] })))); + await expect(cmd.func({ query: 'Nowherexyz', limit: 5 })).rejects.toMatchObject({ code: 'EMPTY_RESULT' }); + }); + + it('surfaces HTTP + network failures as typed COMMAND_EXEC', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response('{}', { status: 503 })))); + await expect(cmd.func({ query: 'Bali', limit: 5 })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('status 503') }); + vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('socket hang up')))); + await expect(cmd.func({ query: 'Bali', limit: 5 })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('socket hang up') }); + }); + + it('surfaces a 200 with an unparseable body as typed COMMAND_EXEC', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response('not json', { status: 200 })))); + await expect(cmd.func({ query: 'Bali', limit: 5 })).rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('invalid JSON') }); + }); + + it('surfaces a 200 POI body missing the results array as typed COMMAND_EXEC', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(poiResponse({ data: [] })))); + await expect(cmd.func({ query: 'Bali', limit: 5 })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('missing results array') }); + }); +}); + +const PKG_GROUP = { + flightlist: [{ + binfo: { flightno: 'TW247', airlineName: "T'Way Air" }, + dportinfo: { aport: 'ICN' }, + aportinfo: { aport: 'NRT' }, + dateinfo: { dtime: '2026-08-05 18:15:00', atime: '2026-08-05 20:45:00' }, + }], + policylist: [{ price: { price: 53.4 } }], +}; + +const PKG_ROW = { + rank: 1, + airline: "T'Way Air", + flightNo: 'TW247', + from: 'ICN', + to: 'NRT', + departure: '2026-08-05 18:15:00', + arrival: '2026-08-05 20:45:00', + stops: 0, + price: 53.4, + currency: 'USD', +}; + +const PKG_POIS = { + Seoul: [{ name: 'Seoul', cityId: 274, cityCode: 'SEL', airportCode: '' }], + Tokyo: [{ name: 'Tokyo', cityId: 228, cityCode: 'TYO', airportCode: '' }], +}; + +function stubPackageFetch({ pois = PKG_POIS, pkg = { grouplist: [PKG_GROUP] }, pkgStatus = 200 } = {}) { + vi.stubGlobal('fetch', vi.fn((url, opts) => { + if (String(url).includes('poiSearch')) { + const key = JSON.parse(opts.body).key; + return Promise.resolve(new Response(JSON.stringify({ results: pois[key] || [] }), { status: 200 })); + } + return Promise.resolve(new Response(JSON.stringify(pkg), { status: pkgStatus })); + })); +} + +describe('trip mapPackageRow', () => { + it('projects a nonstop group into the route summary row', () => { + expect(mapPackageRow(PKG_GROUP, 0)).toEqual(PKG_ROW); + }); + it('reads the arrival off the last leg, counts stops, and keeps a missing price null', () => { + const connection = { + flightlist: [ + { binfo: { flightno: 'KE1', airlineName: 'Korean Air' }, dportinfo: { aport: 'ICN' }, aportinfo: { aport: 'PVG' }, dateinfo: { dtime: '2026-08-05 08:00:00', atime: '2026-08-05 10:00:00' } }, + { binfo: { flightno: 'KE2', airlineName: 'Korean Air' }, dportinfo: { aport: 'PVG' }, aportinfo: { aport: 'NRT' }, dateinfo: { dtime: '2026-08-05 12:00:00', atime: '2026-08-05 15:00:00' } }, + ], + policylist: [], + }; + expect(mapPackageRow(connection, 1)).toMatchObject({ + rank: 2, airline: 'Korean Air', flightNo: 'KE1', from: 'ICN', to: 'NRT', + departure: '2026-08-05 08:00:00', arrival: '2026-08-05 15:00:00', stops: 1, price: null, + }); + }); +}); + +describe('trip resolvePackageCity', () => { + beforeEach(() => vi.unstubAllGlobals()); + + it('resolves the first city carrying both a metro code and id', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response(JSON.stringify({ results: [{ name: 'Tokyo', cityId: 228, cityCode: 'tyo', airportCode: '' }] }), { status: 200 })))); + await expect(resolvePackageCity('Tokyo')).resolves.toEqual({ name: 'Tokyo', cityCode: 'TYO', cityId: 228 }); + }); + + it('skips airport-only children and returns null when no city has a code', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response(JSON.stringify({ results: [ + { name: 'Narita International Airport', cityId: 228, cityCode: 'TYO', airportCode: 'NRT' }, + { name: 'Some Province', cityId: 0, cityCode: '' }, + ] }), { status: 200 })))); + await expect(resolvePackageCity('nowhere')).resolves.toBeNull(); + }); +}); + +describe('trip package command (registry-level)', () => { + const cmd = getRegistry().get('trip/package'); + beforeEach(() => vi.unstubAllGlobals()); + + it('declares Strategy.PUBLIC + browser:false + access:read', () => { + expect(cmd.access).toBe('read'); + expect(cmd.browser).toBe(false); + expect(String(cmd.strategy)).toContain('public'); + expect(cmd.domain).toBe('trip.com'); + }); + + it('rejects a blank keyword, malformed dates, depart>=return, and invalid adults / limit', async () => { + await expect(cmd.func({ from: '', to: 'Tokyo', depart: '2026-08-05', return: '2026-08-08', adults: 2, limit: 5 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('required') }); + await expect(cmd.func({ from: 'Seoul', to: 'Tokyo', depart: '08/05', return: '2026-08-08', adults: 2, limit: 5 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--depart') }); + await expect(cmd.func({ from: 'Seoul', to: 'Tokyo', depart: '2026-08-08', return: '2026-08-05', adults: 2, limit: 5 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--depart must be before --return') }); + await expect(cmd.func({ from: 'Seoul', to: 'Tokyo', depart: '2026-08-05', return: '2026-08-08', adults: 0, limit: 5 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--adults') }); + await expect(cmd.func({ from: 'Seoul', to: 'Tokyo', depart: '2026-08-05', return: '2026-08-08', adults: 2, limit: 0 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--limit') }); + }); + + it('rejects an unresolvable keyword and a from==to that resolves to one city', async () => { + stubPackageFetch({ pois: { Tokyo: PKG_POIS.Tokyo } }); + await expect(cmd.func({ from: 'Nowherexyz', to: 'Tokyo', depart: '2026-08-05', return: '2026-08-08', adults: 2, limit: 5 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('Could not resolve origin') }); + await expect(cmd.func({ from: 'Tokyo', to: 'Tokyo', depart: '2026-08-05', return: '2026-08-08', adults: 2, limit: 5 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('must differ') }); + }); + + it('resolves both endpoints, maps package rows, and respects --limit', async () => { + stubPackageFetch({ pkg: { grouplist: [PKG_GROUP, { ...PKG_GROUP, policylist: [{ price: { price: 61 } }] }] } }); + const rows = await cmd.func({ from: 'Seoul', to: 'Tokyo', depart: '2026-08-05', return: '2026-08-08', adults: 2, limit: 1 }); + expect(rows).toHaveLength(1); + expect(rows[0]).toEqual(PKG_ROW); + for (const row of rows) { + for (const col of cmd.columns) expect(row).toHaveProperty(col); + } + }); + + it('throws EmptyResult when the endpoint returns no package groups', async () => { + stubPackageFetch({ pkg: { grouplist: [] } }); + await expect(cmd.func({ from: 'Seoul', to: 'Tokyo', depart: '2026-08-05', return: '2026-08-08', adults: 2, limit: 5 })) + .rejects.toMatchObject({ code: 'EMPTY_RESULT' }); + }); + + it('throws CommandExec (drift) when groups return but none carry an itinerary', async () => { + stubPackageFetch({ pkg: { grouplist: [{ policylist: [{ price: { price: 53.4 } }] }] } }); + await expect(cmd.func({ from: 'Seoul', to: 'Tokyo', depart: '2026-08-05', return: '2026-08-08', adults: 2, limit: 5 })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('parseable flight identity') }); + }); + + it('throws CommandExec (drift) when package legs lack flight identity, route, or times', async () => { + stubPackageFetch({ pkg: { grouplist: [{ flightlist: [{}], policylist: [{ price: { price: 53.4 } }] }] } }); + await expect(cmd.func({ from: 'Seoul', to: 'Tokyo', depart: '2026-08-05', return: '2026-08-08', adults: 2, limit: 5 })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('parseable flight identity') }); + }); + + it('drops malformed package groups and reranks parseable rows', async () => { + stubPackageFetch({ pkg: { grouplist: [{ flightlist: [{}] }, { ...PKG_GROUP, policylist: [] }] } }); + const rows = await cmd.func({ from: 'Seoul', to: 'Tokyo', depart: '2026-08-05', return: '2026-08-08', adults: 2, limit: 5 }); + expect(rows).toEqual([{ ...PKG_ROW, rank: 1, price: null }]); + }); + + it('surfaces a package endpoint failure as typed COMMAND_EXEC', async () => { + stubPackageFetch({ pkgStatus: 503 }); + await expect(cmd.func({ from: 'Seoul', to: 'Tokyo', depart: '2026-08-05', return: '2026-08-08', adults: 2, limit: 5 })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('status 503') }); + }); + + it('surfaces a 200 package body that will not parse as typed COMMAND_EXEC', async () => { + vi.stubGlobal('fetch', vi.fn((url, opts) => { + if (String(url).includes('poiSearch')) { + const key = JSON.parse(opts.body).key; + return Promise.resolve(new Response(JSON.stringify({ results: PKG_POIS[key] || [] }), { status: 200 })); + } + return Promise.resolve(new Response('not json', { status: 200 })); + })); + await expect(cmd.func({ from: 'Seoul', to: 'Tokyo', depart: '2026-08-05', return: '2026-08-08', adults: 2, limit: 5 })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('invalid JSON') }); + }); + + it('surfaces a 200 package body missing grouplist as typed COMMAND_EXEC', async () => { + stubPackageFetch({ pkg: { data: [] } }); + await expect(cmd.func({ from: 'Seoul', to: 'Tokyo', depart: '2026-08-05', return: '2026-08-08', adults: 2, limit: 5 })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('missing grouplist array') }); + }); +}); + +const DEALS_HTML = ` + `; + +const DEALS_RAW = { + title: 'Go Japan', + offer: 'Hotel up to 50% off', + discount: '50%', + url: 'https://us.trip.com/sale/w/37676/gojapan.html', +}; + +describe('trip buildDealsUrl', () => { + it('pins the Top Deals hub URL with English / USD', () => { + const url = buildDealsUrl(); + expect(url.startsWith('https://www.trip.com/sale/deals/?')).toBe(true); + expect(url).toContain('locale=en-US'); + expect(url).toContain('curr=USD'); + }); +}); + +describe('trip deals command (registry-level)', () => { + const cmd = getRegistry().get('trip/deals'); + + it('declares Strategy.COOKIE + browser:true + access:read', () => { + expect(cmd.access).toBe('read'); + expect(cmd.browser).toBe(true); + expect(String(cmd.strategy)).toContain('cookie'); + expect(cmd.domain).toBe('trip.com'); + }); + + it('rejects an invalid limit before navigation', async () => { + const page = createPageMock([]); + await expect(cmd.func(page, { limit: 0 })) + .rejects.toMatchObject({ code: 'ARGUMENT', message: expect.stringContaining('--limit') }); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('throws AuthRequired on verification, CommandExec on timeout / malformed / no parsed tiles', async () => { + await expect(cmd.func(createPageMock(['captcha']), { limit: 5 })) + .rejects.toThrow('Trip.com is asking for a verification'); + await expect(cmd.func(createPageMock(['timeout']), { limit: 5 })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('did not render deal tiles') }); + await expect(cmd.func(createPageMock(['content', null]), { limit: 5 })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('malformed rows') }); + // The hub is never legitimately empty, so rendered-but-nothing-parsed is drift, not empty. + await expect(cmd.func(createPageMock(['content', []]), { limit: 5 })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('no promotion tiles parsed') }); + }); + + it('maps deal rows against the hub URL and respects --limit', async () => { + const page = createPageMock(['content', [DEALS_RAW, { ...DEALS_RAW, title: 'Go China', offer: 'Exclusive Summer Deals', discount: null }]]); + const rows = await cmd.func(page, { limit: 1 }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ rank: 1, title: 'Go Japan', offer: 'Hotel up to 50% off', discount: '50%', url: DEALS_RAW.url }); + for (const row of rows) { + for (const col of cmd.columns) expect(row).toHaveProperty(col); + } + expect(page.goto).toHaveBeenCalledTimes(1); + expect(page.goto.mock.calls[0][0]).toContain('/sale/deals/'); + }); +}); + +describe('trip buildDealsExtractJs (JSDOM)', () => { + function runExtract(html) { + const dom = new JSDOM(`${html}`, { url: 'https://www.trip.com/sale/deals/' }); + return Function('document', `return (${buildDealsExtractJs()})`)(dom.window.document); + } + + it('extracts tiles with title / offer / parsed discount and dedupes by campaign URL', () => { + const rows = runExtract(DEALS_HTML + DEALS_HTML); + expect(rows).toHaveLength(2); + expect(rows[0]).toEqual(DEALS_RAW); + expect(rows[1]).toMatchObject({ title: 'Go China', offer: 'Exclusive Summer Deals', discount: null }); + }); + + it('parses a $N off discount and prefixes a relative campaign href', () => { + const rows = runExtract('
Flights
Get $15 off
'); + expect(rows[0]).toMatchObject({ discount: '$15 off', url: 'https://www.trip.com/sale/w/9/z.html' }); + }); + + it('drops tiles without a title or offer', () => { + expect(runExtract('')).toEqual([]); + }); +}); + +describe('trip WAIT_FOR_DEALS_JS (JSDOM)', () => { + it('detects a rendered deal tile as content', async () => { + const dom = new JSDOM('', { + url: 'https://www.trip.com/sale/deals/', + runScripts: 'outside-only', + }); + await expect(dom.window.Function(`return (${WAIT_FOR_DEALS_JS})`)()) + .resolves.toBe('content'); + }); +}); diff --git a/clis/trip/utils.js b/clis/trip/utils.js new file mode 100644 index 000000000..10c4df3e8 --- /dev/null +++ b/clis/trip/utils.js @@ -0,0 +1,911 @@ +/** + * Shared helpers for the Trip.com (international) adapter. + * + * Trip.com is the English-facing sibling of Ctrip; its search pages render + * results client-side, so the browser-mode commands read the rendered DOM. + * Flight rows are `.result-item` cards keyed by stable `data-testid` anchors + * (`flights-name`, `stopInfoText`, `flight_price_*`). + */ +import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors'; + +const MIN_LIMIT = 1; +const MAX_LIMIT = 50; +const ISO_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/; +const POI_SEARCH_ENDPOINT = 'https://www.trip.com/restapi/soa2/14427/poiSearch'; +const PACKAGE_SEARCH_ENDPOINT = 'https://www.trip.com/restapi/soa2/19866/FlightSelectSearch'; + +export function parseIataCode(name, raw) { + if (raw === undefined || raw === null || raw === '') { + throw new ArgumentError(`--${name} is required (3-letter IATA code, e.g. LON, NYC)`); + } + const value = String(raw).trim().toUpperCase(); + if (!/^[A-Z]{3}$/.test(value)) { + throw new ArgumentError(`--${name} must be a 3-letter IATA code, got ${JSON.stringify(raw)}`); + } + return value; +} + +export function parseIsoDate(name, raw) { + if (raw === undefined || raw === null || raw === '') { + throw new ArgumentError(`--${name} is required (YYYY-MM-DD)`); + } + const value = String(raw).trim(); + const m = ISO_DATE_RE.exec(value); + if (!m) { + throw new ArgumentError(`--${name} must be YYYY-MM-DD, got ${JSON.stringify(raw)}`); + } + const year = Number(m[1]); + const month = Number(m[2]); + const day = Number(m[3]); + if (month < 1 || month > 12 || day < 1 || day > 31) { + throw new ArgumentError(`--${name} has invalid month/day: ${value}`); + } + // Cross-check via UTC date math so 2026-02-30 doesn't pass. + const parsed = new Date(Date.UTC(year, month - 1, day)); + if (parsed.getUTCFullYear() !== year || parsed.getUTCMonth() !== month - 1 || parsed.getUTCDate() !== day) { + throw new ArgumentError(`--${name} is not a real calendar date: ${value}`); + } + return value; +} + +export function parseListLimit(raw, fallback = 20) { + if (raw === undefined || raw === null || raw === '') return fallback; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) { + throw new ArgumentError(`--limit must be an integer between ${MIN_LIMIT} and ${MAX_LIMIT}, got ${JSON.stringify(raw)}`); + } + if (parsed < MIN_LIMIT || parsed > MAX_LIMIT) { + throw new ArgumentError(`--limit must be between ${MIN_LIMIT} and ${MAX_LIMIT}, got ${parsed}`); + } + return parsed; +} + +export function buildFlightSearchUrl(fromCode, toCode, date) { + const params = new URLSearchParams({ + dcity: fromCode.toLowerCase(), + acity: toCode.toLowerCase(), + ddate: date, + triptype: 'ow', + class: 'y', + quantity: '1', + locale: 'en_US', + curr: 'USD', + }); + return `https://www.trip.com/flights/showfarefirst?${params.toString()}`; +} + +export function buildFlightRoundSearchUrl(fromCode, toCode, depart, ret) { + const params = new URLSearchParams({ + dcity: fromCode.toLowerCase(), + acity: toCode.toLowerCase(), + ddate: depart, + rdate: ret, + triptype: 'rt', + class: 'y', + quantity: '1', + locale: 'en_US', + curr: 'USD', + }); + return `https://www.trip.com/flights/showfarefirst?${params.toString()}`; +} + +/** + * Browser-context IIFE that extracts flight rows from Trip.com's rendered + * `.result-item` cards. Fields are read from stable `data-testid` anchors plus + * the `HH:MM` / `AM-PM` / `IATA` leaf-node pattern. Cards missing the airline, + * both airports, or both times are dropped rather than surfaced with blanks. + */ +export function buildFlightExtractJs() { + return ` + (() => { + const clean = (el) => el ? (el.textContent || '').replace(/\\s+/g, ' ').trim() : ''; + const rows = []; + document.querySelectorAll('.result-item').forEach((card) => { + const airline = clean(card.querySelector('[data-testid="flights-name"]')); + const codes = Array.from(card.querySelectorAll('[class*="font-black"]')) + .map((el) => clean(el)).filter((t) => /^[A-Z]{3}$/.test(t)); + const leaves = Array.from(card.querySelectorAll('*')) + .filter((el) => !el.children.length).map((el) => clean(el)); + const times = leaves.filter((t) => /^\\d{1,2}:\\d{2}$/.test(t)); + const meridiems = leaves.filter((t) => /^(AM|PM)$/.test(t)); + const duration = leaves.find((t) => /^\\d+h(\\s\\d+m)?$/.test(t)) || null; + if (!airline || codes.length < 2 || times.length < 2) return; + const withMeridiem = (i) => times[i] + (meridiems[i] ? ' ' + meridiems[i] : ''); + const priceEl = card.querySelector('[data-testid^="flight_price"]'); + const priceText = clean(priceEl); + const priceNum = priceText.replace(/[^0-9.]/g, ''); + rows.push({ + airline, + departureTime: withMeridiem(0), + departureAirport: codes[0], + arrivalTime: withMeridiem(1), + arrivalAirport: codes[1], + duration, + stops: clean(card.querySelector('[data-testid="stopInfoText"]')) || null, + price: priceNum ? Number(priceNum) : null, + currency: priceText.startsWith('$') ? 'USD' : (priceText.replace(/[0-9.,\\s]/g, '') || null), + }); + }); + return rows; + })() + `; +} + +/** Wait for the flight list to render, or detect a captcha / verification wall. */ +export const WAIT_FOR_FLIGHTS_JS = ` + new Promise((resolve) => { + const detect = () => { + if (/captcha|verify you are human|security check/i.test(document.body?.innerText || '')) return 'captcha'; + if (document.querySelector('.result-item')) return 'content'; + return null; + }; + const found = detect(); + if (found) return resolve(found); + const observer = new MutationObserver(() => { + const result = detect(); + if (result) { observer.disconnect(); resolve(result); } + }); + observer.observe(document.documentElement, { childList: true, subtree: true }); + setTimeout(() => { observer.disconnect(); resolve('timeout'); }, 12000); + }) +`; + +export function parseCityId(name, raw) { + if (raw === undefined || raw === null || String(raw).trim() === '') { + throw new ArgumentError(`--${name} is required (numeric Trip.com city id, e.g. 338 for London)`); + } + const value = String(raw).trim(); + if (!/^\d+$/.test(value)) { + throw new ArgumentError(`--${name} must be a numeric Trip.com city id, got ${JSON.stringify(raw)}`); + } + return value; +} + +export function buildHotelSearchUrl(cityId, checkin, checkout) { + const params = new URLSearchParams({ + city: cityId, + checkin, + checkout, + locale: 'en_US', + curr: 'USD', + }); + return `https://www.trip.com/hotels/list?${params.toString()}`; +} + +/** + * Browser-context IIFE that extracts hotel rows from Trip.com's rendered + * `.hotel-card` cards, read by stable class-keyed fields + * (`.hotelName/.score/.comment-num/.position-desc/.price-highlight`). Cards + * without a hotel name are dropped rather than surfaced with blanks. + */ +export function buildHotelExtractJs() { + return ` + (() => { + const clean = (el) => el ? (el.textContent || '').replace(/\\s+/g, ' ').trim() : ''; + const toNum = (t) => { const m = String(t).replace(/[^0-9.]/g, ''); return m ? Number(m) : null; }; + const rows = []; + document.querySelectorAll('.hotel-card').forEach((card) => { + const name = clean(card.querySelector('.hotelName')); + if (!name) return; + const locations = Array.from(card.querySelectorAll('.position-desc')) + .map((el) => clean(el)).filter(Boolean); + const priceText = clean(card.querySelector('.price-highlight')); + rows.push({ + name, + score: toNum(clean(card.querySelector('.score'))), + reviewLabel: clean(card.querySelector('.comment-desc')) || null, + reviews: toNum(clean(card.querySelector('.comment-num'))), + location: locations.join(', ') || null, + room: clean(card.querySelector('.room-name')) || null, + price: toNum(priceText), + currency: priceText.startsWith('$') ? 'USD' : (priceText.replace(/[0-9.,\\s]/g, '') || null), + }); + }); + return rows; + })() + `; +} + +/** Wait for the hotel list to render, or detect a verification wall. */ +export const WAIT_FOR_HOTELS_JS = ` + new Promise((resolve) => { + const detect = () => { + if (/captcha|verify you are human|security check/i.test(document.body?.innerText || '')) return 'captcha'; + if (document.querySelector('.hotel-card')) return 'content'; + return null; + }; + const found = detect(); + if (found) return resolve(found); + const observer = new MutationObserver(() => { + const result = detect(); + if (result) { observer.disconnect(); resolve(result); } + }); + observer.observe(document.documentElement, { childList: true, subtree: true }); + setTimeout(() => { observer.disconnect(); resolve('timeout'); }, 12000); + }) +`; + +export function parseHotelId(name, raw) { + if (raw === undefined || raw === null || String(raw).trim() === '') { + throw new ArgumentError(`--${name} is required (numeric Trip.com hotel id, discover via the hotels list)`); + } + const value = String(raw).trim(); + if (!/^\d+$/.test(value)) { + throw new ArgumentError(`--${name} must be a numeric Trip.com hotel id, got ${JSON.stringify(raw)}`); + } + return value; +} + +export function buildHotelDetailUrl(hotelId) { + const params = new URLSearchParams({ hotelId, locale: 'en_US', curr: 'USD' }); + return `https://www.trip.com/hotels/detail/?${params.toString()}`; +} + +/** + * Browser-context IIFE that projects the single-hotel profile from + * `__NEXT_DATA__.props.pageProps.hotelDetailResponse` (the same SSR shape the + * mainland `ctrip hotel` detail uses). Rating sub-scores, popular amenities, and + * the check-in/out policy are each joined into one string so the profile stays a + * single flat row. Returns `null` when the SSR block is absent, so the caller + * raises a typed error instead of surfacing blanks. Room-level nightly prices + * load via a post-SSR XHR and are out of scope here. + */ +export function buildHotelDetailExtractJs() { + return ` + (() => { + const pp = window.__NEXT_DATA__?.props?.pageProps; + const dr = pp && pp.hotelDetailResponse; + if (!dr || typeof dr !== 'object') return null; + const clean = (s) => (s == null ? null : String(s).replace(/\\s+/g, ' ').trim() || null); + const num = (s) => { const n = Number(s); return Number.isFinite(n) && n !== 0 ? n : null; }; + const bi = dr.hotelBaseInfo || {}; + const nameInfo = bi.nameInfo || {}; + const starInfo = bi.starInfo || {}; + const pos = dr.hotelPositionInfo || {}; + const comment = (dr.hotelComment && dr.hotelComment.comment) || {}; + const scoreDetail = Array.isArray(comment.scoreDetail) ? comment.scoreDetail : []; + const popList = (((dr.hotelFacilityPopV2 || {}).hotelPopularFacility || {}).list) || []; + const cio = (dr.hotelPolicyInfo && dr.hotelPolicyInfo.checkInAndOut) || {}; + const cioContent = Array.isArray(cio.content) ? cio.content : []; + return { + hotelId: bi.masterHotelId != null ? String(bi.masterHotelId) : null, + name: clean(nameInfo.name), + enName: clean(nameInfo.nameEn), + star: (Number.isFinite(starInfo.level) && starInfo.level > 0) ? starInfo.level : null, + score: num(comment.score), + scoreLabel: clean(comment.scoreDescription), + reviewCount: (Number.isFinite(comment.totalComment) && comment.totalComment > 0) ? comment.totalComment : null, + ratingBreakdown: scoreDetail.map((s) => (s && s.showName && s.showScore) ? clean(s.showName) + ' ' + clean(s.showScore) : null).filter(Boolean).join(' / ') || null, + facilities: popList.map((f) => f && clean(f.facilityDesc)).filter(Boolean).join(' / ') || null, + checkInOut: cioContent.map((c) => c && clean((c.title || '') + (c.description || ''))).filter(Boolean).join(' / ') || null, + cityName: clean(bi.cityName), + address: clean(pos.address), + lat: num(pos.lat), + lon: num(pos.lng), + }; + })() + `; +} + +/** Wait for the hotel detail SSR block, or detect a verification wall. */ +export const WAIT_FOR_HOTEL_DETAIL_JS = ` + new Promise((resolve) => { + const detect = () => { + if (/captcha|verify you are human|security check/i.test(document.body?.innerText || '')) return 'captcha'; + const dr = window.__NEXT_DATA__?.props?.pageProps?.hotelDetailResponse; + if (dr && dr.hotelBaseInfo && dr.hotelBaseInfo.nameInfo) return 'content'; + return null; + }; + const found = detect(); + if (found) return resolve(found); + const observer = new MutationObserver(() => { + const result = detect(); + if (result) { observer.disconnect(); resolve(result); } + }); + observer.observe(document.documentElement, { childList: true, subtree: true }); + setTimeout(() => { observer.disconnect(); resolve('timeout'); }, 12000); + }) +`; + +export function parseKeyword(name, raw) { + if (raw === undefined || raw === null || String(raw).trim() === '') { + throw new ArgumentError(`--${name} is required (a destination or attraction keyword)`); + } + const value = String(raw).trim(); + if (value.length > 60) { + throw new ArgumentError(`--${name} is too long (max 60 chars): ${JSON.stringify(raw)}`); + } + return value; +} + +export function buildAttractionSearchUrl(keyword) { + const params = new URLSearchParams({ keyword, locale: 'en_US', curr: 'USD' }); + return `https://www.trip.com/things-to-do/list?${params.toString()}`; +} + +/** + * Browser-context IIFE that extracts attraction / experience rows from Trip.com's + * things-to-do results. The product cards use hashed CSS-module class names, so + * this anchors on the one stable handle each card exposes, the + * `things-to-do/detail/` link (name is its text, `url` its href), and reads + * rating / reviews / booked / price from the card's text by data-format pattern + * rather than by hashed class. The price excludes the "$N off" promo tag and + * takes the current (lowest non-promo) fare. Cards without a name or id are + * dropped rather than surfaced with blanks. + */ +export function buildAttractionExtractJs() { + return ` + (() => { + const clean = (s) => (s || '').replace(/\\s+/g, ' ').trim(); + const kNum = (s) => { if (!s) return null; const n = Number(String(s).replace(/k/i, '').replace(/,/g, '')); return /k/i.test(s) ? Math.round(n * 1000) : n; }; + const rows = []; + const seen = new Set(); + document.querySelectorAll('a[href*="/things-to-do/detail/"]').forEach((link) => { + const name = clean(link.textContent); + if (!name || name.length < 4) return; + const href = link.getAttribute('href') || ''; + const idMatch = href.match(/\\/detail\\/(\\d+)/); + const id = idMatch ? idMatch[1] : null; + if (!id || seen.has(id)) return; + seen.add(id); + let card = link; + const txt = (el) => (el && (el.innerText || el.textContent)) || ''; + for (let i = 0; i < 6; i++) { if (card.parentElement) { card = card.parentElement; if (/\\$\\s?\\d/.test(txt(card))) break; } } + const t = txt(card); + const ratingM = t.match(/(\\d(?:\\.\\d)?)\\s*\\/\\s*5/); + const reviewsM = t.match(/([\\d.]+k?)\\s+reviews/i); + const bookedM = t.match(/([\\d.]+k?)\\s+booked/i); + const prices = []; + const re = /\\$\\s?([\\d,]+(?:\\.\\d+)?)/g; + let m; + while ((m = re.exec(t)) !== null) { + if (!/off/i.test(t.slice(m.index, m.index + m[0].length + 5))) prices.push(Number(m[1].replace(/,/g, ''))); + } + rows.push({ + name, + rating: ratingM ? Number(ratingM[1]) : null, + reviews: kNum(reviewsM && reviewsM[1]), + booked: kNum(bookedM && bookedM[1]), + price: prices.length ? Math.min.apply(null, prices) : null, + url: href.startsWith('http') ? href : ('https://www.trip.com' + href), + }); + }); + return rows; + })() + `; +} + +/** Wait for the attraction results to render (products lazy-load), or detect a verification wall. */ +export const WAIT_FOR_ATTRACTIONS_JS = ` + new Promise((resolve) => { + const detect = () => { + if (/captcha|verify you are human|security check/i.test(document.body?.innerText || '')) return 'captcha'; + if (document.querySelectorAll('a[href*="/things-to-do/detail/"]').length > 2 && /\\$\\s?\\d/.test(document.body?.innerText || '')) return 'content'; + if (/no results|no matching|couldn.t find/i.test(document.body?.innerText || '')) return 'empty'; + return null; + }; + const found = detect(); + if (found) return resolve(found); + const observer = new MutationObserver(() => { + const result = detect(); + if (result) { observer.disconnect(); resolve(result); } + }); + observer.observe(document.documentElement, { childList: true, subtree: true }); + setTimeout(() => { observer.disconnect(); resolve('timeout'); }, 15000); + }) +`; + +/** + * Build the timetable URL for a train route. Trip.com organises route pages under + * a country segment (`trains//route/-to-/`), so the country is + * required; the city names are slugified. + */ +export function buildTrainRouteUrl(country, from, to) { + const slug = (s) => String(s).trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); + return `https://www.trip.com/trains/${slug(country)}/route/${slug(from)}-to-${slug(to)}/`; +} + +/** + * Browser-context IIFE that extracts train journeys from a Trip.com route + * timetable. Rows are `` entries with a `.item-departure` / `.item-arrival` + * cell (time in `.item-time-text`, station in `.item-name`); duration and change + * count are parsed from the departure cell text after the time. The SEO + * timetable carries no per-journey fare (it sits behind the "Find Tickets" + * booking step). Rows without both times and stations are dropped. + */ +export function buildTrainExtractJs() { + return ` + (() => { + const clean = (el) => el ? (el.textContent || '').replace(/\\s+/g, ' ').trim() : ''; + const rows = []; + document.querySelectorAll('tr').forEach((tr) => { + const dep = tr.querySelector('.item-departure'); + const arr = tr.querySelector('.item-arrival'); + if (!dep || !arr) return; + const departureTime = (clean(dep.querySelector('.item-time-text')).match(/\\d{1,2}:\\d{2}/) || [])[0] || ''; + const arrivalTime = (clean(arr.querySelector('.item-time-text')).match(/\\d{1,2}:\\d{2}/) || [])[0] || ''; + const fromStation = clean(dep.querySelector('.item-name')); + const toStation = clean(arr.querySelector('.item-name')); + if (!departureTime || !arrivalTime || !fromStation || !toStation) return; + const rest = clean(dep).replace(departureTime, ''); + const durMatch = rest.match(/(\\d+\\s*h(?:\\s*\\d+\\s*m)?|\\d+\\s*min)/i); + const changeMatch = rest.match(/(\\d+)\\s*changes?/i); + rows.push({ + departureTime, + fromStation, + arrivalTime, + toStation, + duration: durMatch ? durMatch[1].replace(/\\s+/g, ' ').trim() : null, + changes: changeMatch ? Number(changeMatch[1]) : (/direct|non-?stop/i.test(rest) ? 0 : null), + }); + }); + return rows; + })() + `; +} + +/** Wait for a train timetable to render, or detect a verification wall. */ +export const WAIT_FOR_TRAINS_JS = ` + new Promise((resolve) => { + const detect = () => { + if (/captcha|verify you are human|security check/i.test(document.body?.innerText || '')) return 'captcha'; + if (document.querySelector('.item-departure .item-time-text')) return 'content'; + return null; + }; + const found = detect(); + if (found) return resolve(found); + const observer = new MutationObserver(() => { + const result = detect(); + if (result) { observer.disconnect(); resolve(result); } + }); + observer.observe(document.documentElement, { childList: true, subtree: true }); + setTimeout(() => { observer.disconnect(); resolve('timeout'); }, 12000); + }) +`; + +/** + * Build the car-rental listing URL for a Trip.com carhire city. Trip.com files + * these listings under an SEO path whose text slugs are cosmetic; only the + * numeric city id in the trailing segment routes, so a placeholder country/city + * slug is used and the id drives the page. + */ +export function buildCarListUrl(cityId) { + return `https://www.trip.com/carhire/to-city-1/city-${cityId}/`; +} + +/** + * Browser-context IIFE that extracts car-rental rows from a Trip.com carhire + * listing. Each `.card-item` carries the vehicle category and example model in + * `.card-item-title`, the passenger count as the first number in + * `.card-item-vehicle-info`, and the daily price in `.car-daily-price`. Cards + * without a price or any name are dropped rather than surfaced with blanks. + */ +export function buildCarExtractJs() { + return ` + (() => { + const clean = (el) => el ? (el.textContent || '').replace(/\\s+/g, ' ').trim() : ''; + const txt = (el) => (el && (el.innerText || el.textContent)) || ''; + const rows = []; + document.querySelectorAll('.card-item').forEach((card) => { + const priceText = clean(card.querySelector('.car-daily-price')); + const priceM = priceText.match(/\\$\\s?([\\d,]+(?:\\.\\d+)?)/); + if (!priceM) return; + const titleText = txt(card.querySelector('.card-item-title')); + const vehicle = clean(card.querySelector('.title-info')) || null; + const category = (vehicle ? titleText.replace(vehicle, '') : titleText).replace(/\\s+/g, ' ').trim() || null; + if (!category && !vehicle) return; + const seatsM = txt(card.querySelector('.card-item-vehicle-info')).match(/\\d+/); + rows.push({ + category, + vehicle, + seats: seatsM ? Number(seatsM[0]) : null, + price: Number(priceM[1].replace(/,/g, '')), + currency: /\\$/.test(priceText) ? 'USD' : null, + }); + }); + return rows; + })() + `; +} + +/** Wait for the car listing to render, or detect a verification wall. */ +export const WAIT_FOR_CARS_JS = ` + new Promise((resolve) => { + const detect = () => { + if (/captcha|verify you are human|security check/i.test(document.body?.innerText || '')) return 'captcha'; + if (document.querySelector('.card-item .car-daily-price')) return 'content'; + if (/no results|no matching|couldn.t find|not available/i.test(document.body?.innerText || '')) return 'empty'; + return null; + }; + const found = detect(); + if (found) return resolve(found); + const observer = new MutationObserver(() => { + const result = detect(); + if (result) { observer.disconnect(); resolve(result); } + }); + observer.observe(document.documentElement, { childList: true, subtree: true }); + setTimeout(() => { observer.disconnect(); resolve('timeout'); }, 15000); + }) +`; + +/** + * Build the airport-transfer listing URL. Trip.com files transfers under an SEO + * path keyed on the city slug plus the airport IATA code + * (`airport-transfers//airport-/`); a mismatched city bounces to the + * landing page, so both are required and slugified here. + */ +export function buildTransferListUrl(city, airport) { + const slug = (s) => String(s).trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); + return `https://www.trip.com/airport-transfers/${slug(city)}/airport-${slug(airport)}/`; +} + +/** + * Browser-context IIFE that extracts airport-transfer rows from a Trip.com + * transfer listing. Each `.vehicle-card` carries the vehicle type in + * `.vehicle-card__title-text`, passenger and luggage capacity in + * `.vehicle-card__capacity-text` / `.vehicle-card__luggage-text`, and the + * from-price in `.vehicle-card__price-row` (the `$N off` promo sits in a + * separate discount tag and is excluded). Cards without a type or price are + * dropped rather than surfaced with blanks. + */ +export function buildTransferExtractJs() { + return ` + (() => { + const clean = (el) => el ? (el.textContent || '').replace(/\\s+/g, ' ').trim() : ''; + const intOf = (el) => { const m = clean(el).match(/\\d+/); return m ? Number(m[0]) : null; }; + const rows = []; + document.querySelectorAll('.vehicle-card').forEach((card) => { + const type = clean(card.querySelector('.vehicle-card__title-text')); + const priceText = clean(card.querySelector('.vehicle-card__price-row')); + const priceM = priceText.match(/\\$\\s?([\\d,]+(?:\\.\\d+)?)/); + if (!type || !priceM) return; + rows.push({ + type, + passengers: intOf(card.querySelector('.vehicle-card__capacity-text')), + luggage: intOf(card.querySelector('.vehicle-card__luggage-text')), + price: Number(priceM[1].replace(/,/g, '')), + currency: /\\$/.test(priceText) ? 'USD' : null, + }); + }); + return rows; + })() + `; +} + +/** Wait for the transfer listing to render, or detect a verification wall. */ +export const WAIT_FOR_TRANSFERS_JS = ` + new Promise((resolve) => { + const detect = () => { + if (/captcha|verify you are human|security check/i.test(document.body?.innerText || '')) return 'captcha'; + if (document.querySelector('.vehicle-card .vehicle-card__price-row')) return 'content'; + if (/no results|no matching|couldn.t find|not available/i.test(document.body?.innerText || '')) return 'empty'; + return null; + }; + const found = detect(); + if (found) return resolve(found); + const observer = new MutationObserver(() => { + const result = detect(); + if (result) { observer.disconnect(); resolve(result); } + }); + observer.observe(document.documentElement, { childList: true, subtree: true }); + setTimeout(() => { observer.disconnect(); resolve('timeout'); }, 15000); + }) +`; + +/** + * Build the tour search URL. Trip.com files tour packages under + * `package-tours/list?kwd=` with a `tabType` selecting the product line + * (`privateTours` / `groupTours`). + */ +export function buildTourSearchUrl(keyword, tourType) { + const params = new URLSearchParams({ kwd: keyword, tabType: tourType, locale: 'en-US', curr: 'USD' }); + return `https://www.trip.com/package-tours/list?${params.toString()}`; +} + +/** + * Browser-context Promise that reads a tour search off the results page. The + * product list is served by a signed POST that only fires on a search submit, so + * rather than replaying the signed request this installs a fetch hook that + * captures the response body carrying the `"products"` array, drives the page's + * own search box (re-submitting the keyword) so the page issues its own signed + * request, then resolves once the response lands. Returns `{ status, rows }`, + * where `status` is `content` / `captcha` / `empty` / `noinput` / `timeout` + * (`empty` when the results page reports `0 routes found` for a genuine no-match). + */ +export function buildTourSearchJs(keyword) { + return ` + new Promise((resolve) => { + const captured = []; + const origFetch = window.fetch; + window.fetch = function (url) { + const promise = origFetch.apply(this, arguments); + try { + promise.then((res) => { res.clone().text().then((text) => { + if (text.indexOf('"products":[') !== -1) { + try { const data = JSON.parse(text); if (Array.isArray(data.products) && data.products.length) captured.push(data.products); } catch (e) {} + } + }).catch(() => {}); }).catch(() => {}); + } catch (e) {} + return promise; + }; + const input = document.querySelector('input[placeholder]') || document.querySelector('input'); + if (input) { + const setValue = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; + input.focus(); + setValue.call(input, ''); input.dispatchEvent(new Event('input', { bubbles: true })); + setValue.call(input, ${JSON.stringify(keyword)}); input.dispatchEvent(new Event('input', { bubbles: true })); + ['keydown', 'keyup'].forEach((type) => input.dispatchEvent(new KeyboardEvent(type, { bubbles: true, key: 'Enter', keyCode: 13 }))); + } + const num = (v) => (typeof v === 'number' && isFinite(v)) ? v : null; + let elapsed = 0; + const timer = setInterval(() => { + elapsed += 300; + if (/captcha|verify you are human|security check/i.test(document.body?.innerText || '')) { clearInterval(timer); window.fetch = origFetch; return resolve({ status: 'captcha' }); } + if (captured.length) { + clearInterval(timer); window.fetch = origFetch; + const products = captured[captured.length - 1]; + const rows = products.map((p) => { + const basic = p.basicInfo || {}; + const comment = (p.statistics && p.statistics.commentInfo) || {}; + return { + name: basic.name || null, + type: basic.productTypeName || null, + rating: num(comment.score), + reviews: num(comment.count), + price: num(p.priceInfo && p.priceInfo.price), + url: (basic.detailUrl && basic.detailUrl.ONLINE) || null, + }; + }); + return resolve({ status: 'content', rows }); + } + if (!input) { clearInterval(timer); window.fetch = origFetch; return resolve({ status: 'noinput' }); } + if (!captured.length && elapsed >= 6000 && /0\\s+routes?\\s+found/i.test((document.body && document.body.innerText) || '')) { clearInterval(timer); window.fetch = origFetch; return resolve({ status: 'empty' }); } + if (elapsed >= 16000) { clearInterval(timer); window.fetch = origFetch; return resolve({ status: 'timeout' }); } + }, 300); + }) + `; +} + +/** Build the "Today's Top Deals" hub URL (English / USD). */ +export function buildDealsUrl() { + const params = new URLSearchParams({ locale: 'en-US', curr: 'USD' }); + return `https://www.trip.com/sale/deals/?${params.toString()}`; +} + +/** + * Browser-context IIFE that extracts running-promotion rows from Trip.com's + * "Today's Top Deals" hub. Each promotion is an `.top-deals_link-item` tile whose + * title sits in `.top-deals_item-tit`, offer line in `.top-deals_item-desc`, and + * campaign page in the tile `href`; the `discount` is the percentage or `$N off` + * parsed out of the title + offer text (`null` when the campaign states none). + * The campaign `url` is normalised to its canonical path, dropping the per-session + * promo tracking query. Tiles are deduped by that URL, and those without a title + * or offer are dropped rather than surfaced with blanks. + */ +export function buildDealsExtractJs() { + return ` + (() => { + const clean = (el) => el ? (el.textContent || '').replace(/\\s+/g, ' ').trim() : ''; + const rows = []; + const seen = new Set(); + document.querySelectorAll('a[class*="top-deals_link-item"]').forEach((a) => { + const raw = a.getAttribute('href') || ''; + if (!raw) return; + const url = (raw.startsWith('http') ? raw : ('https://www.trip.com' + raw)).split('?')[0]; + if (seen.has(url)) return; + const title = clean(a.querySelector('[class*="top-deals_item-tit"]')); + const offer = clean(a.querySelector('[class*="top-deals_item-desc"]')); + if (!title && !offer) return; + seen.add(url); + const discM = [title, offer].filter(Boolean).join(' ').match(/\\d+(?:\\.\\d+)?%|\\$\\s?\\d+(?:\\.\\d+)?\\s*off/i); + rows.push({ + title: title || null, + offer: offer || null, + discount: discM ? discM[0].replace(/\\s+/g, ' ').trim() : null, + url, + }); + }); + return rows; + })() + `; +} + +/** Wait for the deals hub to render, or detect a verification wall. */ +export const WAIT_FOR_DEALS_JS = ` + new Promise((resolve) => { + const detect = () => { + if (/captcha|verify you are human|security check/i.test(document.body?.innerText || '')) return 'captcha'; + if (document.querySelector('a[class*="top-deals_link-item"]')) return 'content'; + return null; + }; + const found = detect(); + if (found) return resolve(found); + const observer = new MutationObserver(() => { + const result = detect(); + if (result) { observer.disconnect(); resolve(result); } + }); + observer.observe(document.documentElement, { childList: true, subtree: true }); + setTimeout(() => { observer.disconnect(); resolve('timeout'); }, 15000); + }) +`; + +/** + * Query Trip.com's public destination-suggest endpoint (the same POI search the + * flight / hotel boxes call). It takes an unsigned JSON POST and returns city / + * airport / place matches, so this needs no browser session. Returns the raw + * `results` array. Missing/non-array `results` means schema drift; an explicit + * empty array is the only valid empty-result shape. + */ +export async function fetchPoiSearch(keyword) { + let response; + try { + response = await fetch(POI_SEARCH_ENDPOINT, { + method: 'POST', + headers: { 'content-type': 'application/json', currency: 'USD' }, + body: JSON.stringify({ + key: keyword, + mode: '0', + tripType: 'RT', + Head: { Currency: 'USD', Locale: 'en-US', Source: 'ONLINE', Channel: 'EnglishSite', ClientID: 'opencli-trip' }, + }), + }); + } catch (err) { + throw new CommandExecutionError(`Trip.com poiSearch fetch failed: ${err instanceof Error ? err.message : String(err)}`); + } + if (!response.ok) { + throw new CommandExecutionError(`Trip.com poiSearch failed with status ${response.status}`); + } + let payload; + try { + payload = await response.json(); + } catch (err) { + throw new CommandExecutionError(`Trip.com poiSearch returned invalid JSON: ${err instanceof Error ? err.message : String(err)}`); + } + if (!Array.isArray(payload?.results)) { + throw new CommandExecutionError('Trip.com poiSearch returned malformed payload: missing results array'); + } + return payload.results; +} + +/** + * Flatten POI results into a flat suggestion list: each top-level city keeps its + * own row, and its `childResults` (nearby airports) follow, so a single search + * surfaces both the city id and the airport codes. + */ +export function flattenPoiResults(results) { + const rows = []; + for (const result of results) { + if (!result || typeof result !== 'object') continue; + rows.push(result); + if (Array.isArray(result.childResults)) { + for (const child of result.childResults) { + if (child && typeof child === 'object') rows.push(child); + } + } + } + return rows; +} + +/** + * Project a POI suggestion into the stable adapter column shape. A row carrying + * an airport code is an `airport` (feeds `flight` / `transfer`); otherwise it is a + * `city` (feeds `hotel-search` / `car` / `tour`). Missing values stay `null`. + */ +export function mapSearchRow(item, index) { + const airportCode = item?.airportCode ? String(item.airportCode).trim() : ''; + return { + rank: index + 1, + name: item?.name ? String(item.name).replace(/\s+/g, ' ').trim() : null, + type: airportCode ? 'airport' : 'city', + cityId: Number.isFinite(item?.cityId) && item.cityId !== 0 ? item.cityId : null, + airportCode: airportCode || null, + province: item?.provinceName ? String(item.provinceName).trim() : null, + country: item?.countryName ? String(item.countryName).trim() : null, + }; +} + +/** + * Resolve a destination keyword to the Trip.com city its flight+hotel package + * search needs: the metro `cityCode` (e.g. `SEL`) drives the flight leg and the + * numeric `cityId` drives the hotel leg, both of which the public POI search + * returns for a city match. Picks the first result carrying both (an airport + * child only carries an `airportCode`), so one keyword resolves both endpoints. + * Returns `null` when no city matches. + */ +export async function resolvePackageCity(keyword) { + const results = await fetchPoiSearch(keyword); + for (const item of results) { + if (!item || typeof item !== 'object' || item.airportCode) continue; + const cityCode = item.cityCode ? String(item.cityCode).trim().toUpperCase() : ''; + const cityId = Number.isFinite(item.cityId) && item.cityId !== 0 ? item.cityId : null; + if (cityCode && cityId) { + return { name: item.name ? String(item.name).replace(/\s+/g, ' ').trim() : keyword, cityCode, cityId }; + } + } + return null; +} + +/** + * Query Trip.com's flight+hotel package search (the flight-selection step of the + * package booking flow). It takes an unsigned JSON POST keyed on the metro city + * codes plus the destination hotel city id and returns the package's flight + * options priced at the bundle rate, so this needs no browser session. `fmap` 19 + * is the flight+hotel product map and `sgrade` 4 economy; the return date rides on + * the hotel checkout, so the flight criteria carries just the outbound segment, + * the same shape the results page submits. Returns the raw `grouplist` array; + * missing/non-array `grouplist` means schema drift, not "no packages". + */ +export async function fetchPackageSearch({ dcode, acode, hcityid, depart, ret, adults }) { + const body = { + head: { + cid: '', ctok: '', cver: '1.0', lang: '01', sid: '8888', syscode: '09', auth: '', xsid: '', + extension: [ + { name: 'locale', value: 'en-US' }, + { name: 'currency', value: 'USD' }, + { name: 'productLine', value: 'FlightHotel' }, + { name: 'source', value: 'ONLINE' }, + ], + Locale: 'en-US', Language: 'en', Currency: 'USD', ClientID: '', + }, + platform: { src: 'PC', lang: 'en-US', currency: 'USD', sitesrc: 'trip' }, + flightcriteria: { + osource: 1, triptype: 1, fmap: 19, sflag: 0, rtype: 2, + seglist: [{ segno: 1, ddate: depart, sgrade: 4, dcode, acode }], + pinfo: { adults, children: 0, babys: 0 }, + }, + hotelcriteria: { chin: depart, chout: ret, hcityid, rnum: 1 }, + }; + let response; + try { + response = await fetch(PACKAGE_SEARCH_ENDPOINT, { + method: 'POST', + headers: { 'content-type': 'application/json', currency: 'USD' }, + body: JSON.stringify(body), + }); + } catch (err) { + throw new CommandExecutionError(`Trip.com package search fetch failed: ${err instanceof Error ? err.message : String(err)}`); + } + if (!response.ok) { + throw new CommandExecutionError(`Trip.com package search failed with status ${response.status}`); + } + let payload; + try { + payload = await response.json(); + } catch (err) { + throw new CommandExecutionError(`Trip.com package search returned invalid JSON: ${err instanceof Error ? err.message : String(err)}`); + } + if (!Array.isArray(payload?.grouplist)) { + throw new CommandExecutionError('Trip.com package search returned malformed payload: missing grouplist array'); + } + return payload.grouplist; +} + +/** + * Project a package flight group into the stable adapter column shape. A group's + * `flightlist` is the itinerary legs (one for a nonstop), so the route summary + * reads the departure off the first leg and the arrival off the last, with the + * leg count minus one as the stop count. `price` is the per-person package + * starting fare (`policylist[0].price.price`); missing values stay `null`. + */ +export function mapPackageRow(group, index) { + const legs = Array.isArray(group?.flightlist) ? group.flightlist : []; + const first = legs[0] || {}; + const last = legs[legs.length - 1] || {}; + const binfo = first.binfo || {}; + const price = group?.policylist?.[0]?.price?.price; + const str = (v) => (v == null || v === '') ? null : String(v).replace(/\s+/g, ' ').trim(); + return { + rank: index + 1, + airline: str(binfo.airlineName), + flightNo: str(binfo.flightno), + from: str(first.dportinfo && first.dportinfo.aport), + to: str(last.aportinfo && last.aportinfo.aport), + departure: str(first.dateinfo && first.dateinfo.dtime), + arrival: str(last.dateinfo && last.dateinfo.atime), + stops: legs.length ? legs.length - 1 : null, + price: (typeof price === 'number' && isFinite(price)) ? price : null, + currency: 'USD', + }; +} + +export const __test__ = { MIN_LIMIT, MAX_LIMIT, POI_SEARCH_ENDPOINT, PACKAGE_SEARCH_ENDPOINT }; diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index fac6e79fa..77a57d07d 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -70,6 +70,7 @@ export default defineConfig({ { text: '51job', link: '/adapters/browser/51job' }, { text: 'PowerChina', link: '/adapters/browser/powerchina' }, { text: 'Ctrip', link: '/adapters/browser/ctrip' }, + { text: 'Trip.com', link: '/adapters/browser/trip' }, { text: 'Booking.com', link: '/adapters/browser/booking' }, { text: 'GeoGebra', link: '/adapters/browser/geogebra' }, { text: 'Reuters', link: '/adapters/browser/reuters' }, diff --git a/docs/adapters/browser/trip.md b/docs/adapters/browser/trip.md new file mode 100644 index 000000000..69d74a5b4 --- /dev/null +++ b/docs/adapters/browser/trip.md @@ -0,0 +1,350 @@ +# Trip.com + +**Mode**: ๐ŸŒ Public (`search`, `package`) ยท ๐Ÿ–ฅ๏ธ Browser + Cookie (`flight`, `flight-round`, `hotel-search`, `hotel`, `attraction`, `train`, `car`, `transfer`, `tour`, `deals`) +**Domain**: `trip.com` + +Trip.com is the international (English) sibling of the `ctrip` adapter, run by +the same company. These commands search worldwide flights and hotels on +`trip.com` in English / USD, browser-mode + cookie like their `ctrip` peers. + +## Commands + +| Command | Mode | Description | +|---------|------|-------------| +| `opencli trip search` | Public | Suggest destinations (cities + airports) for a keyword, resolving the ids other commands take | +| `opencli trip flight` | Browser (cookie) | One-way flight search by IATA route + departure date | +| `opencli trip flight-round` | Browser (cookie) | Round-trip flight search by IATA route + depart/return dates | +| `opencli trip hotel-search` | Browser (cookie) | List hotels for a city id + check-in/out date range | +| `opencli trip hotel` | Browser (cookie) | Single-hotel detail by id: rating breakdown, amenities, check-in/out policy | +| `opencli trip attraction` | Browser (cookie) | Attractions and experiences (tickets + tours) search by destination keyword | +| `opencli trip train` | Browser (cookie) | Train route timetable (departure/arrival times, duration, changes) | +| `opencli trip car` | Browser (cookie) | Car-rental listing for a city (category, model, seats, daily price) | +| `opencli trip transfer` | Browser (cookie) | Airport-transfer listing for a city + airport (type, seats, from-price) | +| `opencli trip tour` | Browser (cookie) | Tour-package search by destination keyword (private or group tours) | +| `opencli trip package` | Public | Flight+hotel package search by route + dates (package flight options at the bundle rate) | +| `opencli trip deals` | Browser (cookie) | List Trip.com live promotions (Top Deals hub): campaign title, offer, discount, link | + +## Usage Examples + +```bash +# Destination suggest (resolves city ids + airport codes for the commands below) +opencli trip search Tokyo --limit 10 + +# One-way flight search (English, USD) +opencli trip flight LON NYC --date 2026-08-15 --limit 20 +opencli trip flight LHR JFK --date 2026-08-15 -f json + +# Round-trip flight search +opencli trip flight-round LON NYC --depart 2026-08-15 --return 2026-08-22 --limit 20 + +# Hotel listing (numeric city id, e.g. 338 for London) +opencli trip hotel-search 338 --checkin 2026-08-15 --checkout 2026-08-16 --limit 10 + +# Single-hotel detail (hotel id from the hotels list) +opencli trip hotel 715233 + +# Attractions and experiences search (destination keyword) +opencli trip attraction Tokyo --limit 20 + +# Train route timetable (country slug + cities) +opencli trip train London Manchester --country uk --limit 20 + +# Car-rental listing (numeric carhire city id, e.g. 313 for San Francisco) +opencli trip car 313 --limit 10 + +# Airport-transfer listing (airport city + IATA code) +opencli trip transfer Bangkok DMK --limit 10 + +# Tour-package search (destination keyword; private tours by default) +opencli trip tour Kyoto --limit 20 +opencli trip tour Bangkok --type group --limit 10 + +# Flight+hotel package search (city keywords + dates) +opencli trip package Seoul Tokyo --depart 2026-08-05 --return 2026-08-08 --limit 10 + +# Live promotions (Top Deals hub) +opencli trip deals --limit 10 +``` + +## Search Columns (`search`) + +| Column | Notes | +|--------|-------| +| `rank` | 1-based position in the suggest list | +| `name` | Destination name (city, airport, province, or place) | +| `type` | `airport` when the row carries an airport code, otherwise `city` | +| `cityId` | Numeric Trip.com city id (feeds `hotel-search` / `car` / `tour`); `null` for non-city rows | +| `airportCode` | 3-letter IATA code (feeds `flight` / `transfer`); `null` for non-airport rows | +| `province`, `country` | Geo context | + +Args: +- `` (positional, required): a destination keyword (e.g. `Tokyo` / `Bali` / `London`). +- `--limit` (1-50, default 20). + +`search` is a public, unsigned POST to Trip.com's POI-suggest endpoint (no browser +session needed), the same lookup the flight / hotel search boxes use. Each matched +city keeps its own row and its nearby airports follow, so one call surfaces both +the `cityId` and the `airportCode` the id-based commands take. Rows without an id +keep `cityId` / `airportCode` as `null` rather than a sentinel. + +## Flight Columns (`flight`) + +| Column | Notes | +|--------|-------| +| `rank` | 1-based position after filtering incomplete rows | +| `airline` | Operating airline name | +| `departureTime`, `arrivalTime` | Local `H:MM AM/PM` strings as rendered | +| `departureAirport`, `arrivalAirport` | 3-letter IATA airport codes | +| `duration` | Trip length as shown (e.g. `7h 50m`); `null` if absent | +| `stops` | Stop summary (e.g. `Nonstop`, `1 stop`); `null` if absent | +| `price` | Lowest fare shown as a number; `null` if non-numeric | +| `currency` | `USD` (the search pins `curr=USD`) | +| `url` | The search URL (Trip.com flight cards share a booking handoff, no per-row deeplink) | + +Args: +- ``, `` (positional, required): 3-letter IATA codes (`LON`/`NYC` metro codes work alongside single-airport codes like `LHR`/`JFK`). +- `--date` (required): `YYYY-MM-DD`. +- `--limit` (1-50, default 20). + +Rows come from `.result-item` cards, read by stable `data-testid` anchors +(`flights-name`, `stopInfoText`, `flight_price_*`) plus the `HH:MM` / `AM-PM` / +IATA leaf pattern, rather than positional innerText. Cards missing the airline, +both airports, or both times are dropped rather than emitted with sentinel values. + +## Round-Trip Flight Columns (`flight-round`) + +`flight-round` returns the outbound leg of a round-trip search (priced for the +round trip) with the same column shape as `flight` (`rank`, `airline`, +`departureTime`, `departureAirport`, `arrivalTime`, `arrivalAirport`, `duration`, +`stops`, `price`, `currency`, `url`) and reuses the same `.result-item` extractor. + +Args: +- ``, `` (positional, required): 3-letter IATA codes. +- `--depart`, `--return` (required): `YYYY-MM-DD`, with `depart` before `return`. +- `--limit` (1-50, default 20). + +## Hotel Listing Columns (`hotel-search`) + +| Column | Notes | +|--------|-------| +| `rank` | 1-based position in the rendered list | +| `name` | Hotel name | +| `score`, `reviewLabel` | Guest score (out of 10) and its label (e.g. `Very good`); `null` if unrated | +| `reviews` | Review count as an integer; `null` if absent | +| `location` | Location / landmark descriptions joined by `, ` | +| `room` | Lead room name shown on the card; `null` if absent | +| `price`, `currency` | Nightly price and `USD`; `price` is `null` when non-numeric | +| `url` | The search results URL (cards share the list page) | + +Args: +- `` (positional, required): numeric Trip.com city id (discover via the hotels search box; e.g. `338` for London). +- `--checkin`, `--checkout` (required): `YYYY-MM-DD`, validated as real calendar dates with `checkin < checkout`. +- `--limit` (1-50, default 20). + +Rows come from `.hotel-card` cards, read by stable class-keyed fields +(`.hotelName` / `.score` / `.comment-num` / `.position-desc` / `.price-highlight`). +Cards without a hotel name are dropped rather than surfaced with blanks. + +## Hotel Detail Columns (`hotel`) + +| Column | Notes | +|--------|-------| +| `hotelId` | Echoes the requested id | +| `name`, `enName` | Localised + English name (English may be `null`) | +| `star` | Star rating (`1`-`5`); `null` for unrated entries | +| `score`, `scoreLabel` | Guest score out of 10 and its label (e.g. `8.3` / `Very good`); both `null` if unrated | +| `reviewCount` | Total review count as an integer | +| `ratingBreakdown` | The sub-scores joined by ` / ` (e.g. `Cleanliness 8.7 / Location 8.5`) | +| `facilities` | Most-popular amenities joined by ` / ` (e.g. `Luggage storage / Restaurant`) | +| `checkInOut` | Check-in / check-out policy lines joined by ` / ` | +| `cityName`, `address` | Geo context | +| `lat`, `lon` | Coordinates from the detail page; `null` if absent | +| `url` | The detail URL | + +Args: +- `` (positional, required): numeric Trip.com hotel id (discover via the hotels list; e.g. `715233`). + +The profile is read from `__NEXT_DATA__.props.pageProps.hotelDetailResponse` (the +same SSR shape the mainland `ctrip hotel` detail uses), surfacing the fields the +listing row does not carry. Room-level nightly prices load via a post-SSR XHR and +are out of scope here. + +## Attraction Columns (`attraction`) + +| Column | Notes | +|--------|-------| +| `rank` | 1-based position in the rendered list | +| `name` | Product name (ticket, tour, or experience title) | +| `rating` | Guest rating out of 5; `null` if unrated | +| `reviews` | Review count (`4.9k` expanded to `4900`); `null` if absent | +| `booked` | Booking count (`109.5k` expanded to `109500`); `null` if absent | +| `price`, `currency` | Current fare (the promo `$N off` tag is excluded) and `USD`; `price` is `null` if absent | +| `url` | Per-product detail URL (`things-to-do/detail/`) | + +Args: +- `` (positional, required): a destination or attraction keyword (e.g. `Tokyo` / `Paris` / `Louvre`). +- `--limit` (1-50, default 20). + +The products load client-side into hashed CSS-module cards, so rows anchor on +each card's stable `things-to-do/detail/` link (name is its text, `url` its +href) and read rating / reviews / booked / price from the card text by +data-format pattern. Trip.com's "Attractions & Tours" combines tickets, tours, +and experiences into this one result set. Travel eSIM / SIM data plans are also +sold as things-to-do products, so `trip attraction eSIM` (or a destination +keyword) surfaces them with the same columns. + +## Train Columns (`train`) + +| Column | Notes | +|--------|-------| +| `rank` | 1-based position in the timetable | +| `departureTime`, `arrivalTime` | `HH:MM` strings | +| `fromStation`, `toStation` | Departure and arrival station names | +| `duration` | Journey length as shown (e.g. `3h 38m`); `null` if absent | +| `changes` | Number of changes as an integer (`0` for direct); `null` if not stated | +| `url` | The route timetable URL (journeys share the route page) | + +Args: +- ``, `` (positional, required): city names (e.g. `London` / `Manchester`), slugified into the route URL. +- `--country` (required): the route country slug Trip.com files the route under (e.g. `uk` / `france` / `italy` / `spain` / `germany` / `china`). +- `--limit` (1-50, default 20). + +Trip.com organises train routes as per-country SEO timetable pages +(`trains//route/-to-/`), so `--country` is required. The page +lists journeys by departure / arrival times, stations, duration, and changes; +per-journey fares sit behind the booking step and are out of scope here. + +## Car Columns (`car`) + +| Column | Notes | +|--------|-------| +| `rank` | 1-based position in the rendered listing | +| `category` | Vehicle class (e.g. `Mid-sized car`, `Compact SUV`) | +| `vehicle` | Example model shown for the class (e.g. `Toyota Camry or Similar`) | +| `seats` | Passenger capacity as an integer; `null` if absent | +| `price`, `currency` | Representative daily price and `USD`; `price` is `null` when non-numeric | +| `url` | The listing URL (vehicles share the city page) | + +Args: +- `` (positional, required): numeric Trip.com carhire city id (discover via the carhire search box; e.g. `313` for San Francisco). +- `--limit` (1-50, default 20). + +Trip.com files car-rental listings under an SEO path whose text slugs are +cosmetic, so only the numeric carhire city id routes the page. Rows come from +`.card-item` cards, read by stable class fields (`.card-item-title` / +`.card-item-vehicle-info` / `.car-daily-price`); the daily price is the site's +near-term representative rate, while a dated pickup / drop-off quote sits behind +the booking step and is out of scope here. Cards without a price are dropped +rather than surfaced with blanks. + +## Transfer Columns (`transfer`) + +| Column | Notes | +|--------|-------| +| `rank` | 1-based position in the rendered listing | +| `type` | Vehicle type (e.g. `Standard Car`, `Minibus`, `Business 7 seater`) | +| `passengers` | Maximum passengers as an integer; `null` if absent | +| `luggage` | Maximum luggage pieces as an integer; `null` if absent | +| `price`, `currency` | Representative from-price and `USD`; `price` is `null` when non-numeric | +| `url` | The listing URL (vehicles share the airport page) | + +Args: +- `` (positional, required): the airport city (e.g. `Bangkok` / `Beijing` / `Da Nang`), slugified into the URL. +- `` (positional, required): the 3-letter airport IATA code (e.g. `DMK` / `PKX` / `DAD`). +- `--limit` (1-50, default 20). + +Trip.com files airport transfers under an SEO path keyed on the city slug plus +the airport IATA code (`airport-transfers//airport-/`). A city that +does not match the airport bounces to the transfer landing, so the command checks +the landed path and raises `CommandExecutionError` rather than returning the +generic landing list. Rows come from `.vehicle-card` cards, read by stable class +fields (`.vehicle-card__title-text` / `.vehicle-card__capacity-text` / +`.vehicle-card__luggage-text` / `.vehicle-card__price-row`); the `$N off` promo +sits in a separate discount tag and is excluded, and the dated pickup quote sits +behind the booking step. + +## Tour Columns (`tour`) + +| Column | Notes | +|--------|-------| +| `rank` | 1-based position in the rendered result set | +| `name` | Tour title (e.g. `4D3N ยท Private Tours ยท Japan + Osaka + Kyoto ...`) | +| `type` | Product line as shown (`Private Tours` / `Group Tours`) | +| `rating` | Guest rating out of 5; `null` when the tour has no reviews yet | +| `reviews` | Review count as an integer; `null` when absent | +| `price`, `currency` | Starting per-person estimate and `USD`; `price` is `null` when non-numeric | +| `url` | Per-tour detail URL (`package-tours/detail/`) | + +Args: +- `` (positional, required): a destination or tour keyword (e.g. `Tokyo` / `Kyoto` / `Bali`). +- `--type` (`private` or `group`, default `private`): the tour product line. +- `--limit` (1-50, default 20). + +Trip.com serves tour results (`package-tours/list?kwd=`) through a signed +POST that only fires on a search submit, so rather than replaying the signature +the command opens the results page and re-submits the keyword in the page's own +search box, letting the page issue its own signed request while a fetch hook +captures the `products` response. Rows read the product `name`, `type`, rating, +review count, starting price, and per-tour detail URL from that JSON; per-departure +pricing and availability sit behind the booking step. + +## Package Columns (`package`) + +| Column | Notes | +|--------|-------| +| `rank` | 1-based position in the returned package groups | +| `airline` | Operating airline of the outbound flight | +| `flightNo` | Outbound flight number | +| `from`, `to` | Outbound departure / arrival IATA airport codes (arrival read off the last leg for a connection) | +| `departure`, `arrival` | Outbound `YYYY-MM-DD HH:MM:SS` local times | +| `stops` | Number of stops on the outbound (`0` for a nonstop) | +| `price`, `currency` | Per-person package starting fare and `USD`; `price` is `null` when absent | + +Args: +- ``, `` (positional, required): origin and destination city keywords (e.g. `Seoul` / `Tokyo`), resolved through the same POI search `search` uses. +- `--depart`, `--return` (required): `YYYY-MM-DD`, with `depart` before `return`. +- `--adults` (1-9, default 2). +- `--limit` (1-50, default 20). + +Trip.com prices flight+hotel packages through the flight-selection step of the +booking flow: a public, unsigned POST keyed on the metro city codes plus the +destination hotel city id returns the outbound flight options priced at the bundle +rate. So the command resolves both keywords to their city code + id, posts the +package search (no browser session needed), and lists those flights. The specific +hotel is chosen in a later booking step, so per-row hotel detail and the return leg +(which rides on the hotel checkout date) are out of scope here. + +## Deals Columns (`deals`) + +| Column | Notes | +|--------|-------| +| `rank` | 1-based position in the Top Deals hub | +| `title` | Campaign title (e.g. `Go Japan`, `Explore Taiwan`) | +| `offer` | Offer line as shown (e.g. `Hotel up to 50% off`); `null` if the tile carries none | +| `discount` | Discount parsed from the title / offer (e.g. `50%`, `$15 off`); `null` when the campaign states none | +| `url` | Canonical campaign page URL (per-session promo tracking stripped) | + +Args: +- `--limit` (1-50, default 20). + +Trip.com curates its running campaigns on a "Today's Top Deals" hub +(`/sale/deals/`), each a promo tile linking to a dedicated campaign page. Rows come +from the rendered `.top-deals_link-item` tiles (title in `.top-deals_item-tit`, +offer in `.top-deals_item-desc`), deduped by the canonical campaign URL; tiles +without a title or offer are dropped. The per-campaign terms and the bookable +inventory behind each campaign are out of scope here. + +## Prerequisites + +- Chrome running with the [Browser Bridge extension](/guide/browser-bridge) installed. +- A `trip.com` session in that Chrome profile. Flight search works without login, + but a verification gate (suspected bot) raises `AuthRequiredError`; complete it + in your live session and retry. + +## Notes + +- Trip.com is English/USD-facing. For the mainland Chinese site (Chinese UI, CNY, + domestic rail), use the `ctrip` adapter instead. +- This adapter covers Trip.com's travel verticals end to end: flights, hotels, + attractions, train timetables, car rentals, airport transfers, tour packages, + flight+hotel packages, and the live-promotions hub. diff --git a/docs/adapters/index.md b/docs/adapters/index.md index eafae551e..4e7995617 100644 --- a/docs/adapters/index.md +++ b/docs/adapters/index.md @@ -31,6 +31,7 @@ Run `opencli list` for the live registry. | **[51job](./browser/51job.md)** | `search` `hot` `detail` `company` | ๐Ÿ” Browser | | **[powerchina](./browser/powerchina.md)** | `search` | ๐Ÿ” Browser | | **[ctrip](./browser/ctrip.md)** | `search` `hotel-suggest` | ๐ŸŒ Public | +| **[trip](./browser/trip.md)** | `search` `flight` `flight-round` `hotel-search` `hotel` `attraction` `train` `car` `transfer` `tour` `package` `deals` | ๐Ÿ” Browser | | **[booking](./browser/booking.md)** | `search` | ๐ŸŒ Public | | **[reuters](./browser/reuters.md)** | `search` `article-detail` | ๐Ÿ” Browser | | **[hltv](./browser/hltv.md)** | `search` `player-summary` `player-matches` `player-form` `player-map-pool` `player-vs-team` `player-teammate-impact` `player-duel` `match-map` `match-series` `team-matches` `team-map-pool` `event-matches` | ๐ŸŒ Browser |