Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a4baa94
feat(trip): add Trip.com international flight search adapter
Benjamin-eecs Jul 19, 2026
c19b283
enrich(trip): add hotel-search command
Benjamin-eecs Jul 19, 2026
1a704d4
enrich(trip): add hotel detail command
Benjamin-eecs Jul 19, 2026
ad8d59c
enrich(trip): add round-trip flight search command
Benjamin-eecs Jul 19, 2026
ddbf0c3
enrich(trip): rename parseFlightLimit to parseListLimit
Benjamin-eecs Jul 19, 2026
2662e63
enrich(trip): add attractions and experiences search command
Benjamin-eecs Jul 19, 2026
a1a067e
enrich(trip): add train route timetable command
Benjamin-eecs Jul 19, 2026
9c6549f
enrich(trip): add car-rental listing command
Benjamin-eecs Jul 19, 2026
ca0b968
enrich(trip): add airport-transfer listing command
Benjamin-eecs Jul 20, 2026
6a5aa06
enrich(trip): add tour-package search command
Benjamin-eecs Jul 20, 2026
7a0c7f6
enrich(trip): add public destination-suggest command
Benjamin-eecs Jul 20, 2026
7542d2f
enrich(trip): add flight+hotel package search command
Benjamin-eecs Jul 20, 2026
ec6d9b0
docs(trip): note eSIM plans surface via attraction search
Benjamin-eecs Jul 20, 2026
b69bb08
enrich(trip): add live-promotions deals command
Benjamin-eecs Jul 20, 2026
9b5bce3
enrich(trip): treat empty deals parse as drift, not empty result
Benjamin-eecs Jul 20, 2026
bb726e8
enrich(trip): split tour no-match (empty) from schema drift
Benjamin-eecs Jul 20, 2026
004b688
Merge remote-tracking branch 'upstream/main' into feat/trip-adapter
Benjamin-eecs Jul 20, 2026
fe72699
fix(trip): type public fetch drift failures
jackwener Jul 20, 2026
2ba7eff
fix(trip): require package flight identity
jackwener Jul 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
560 changes: 560 additions & 0 deletions cli-manifest.json

Large diffs are not rendered by default.

74 changes: 74 additions & 0 deletions clis/trip/attraction.js
Original file line number Diff line number Diff line change
@@ -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/<id>` 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,
}));
},
});
74 changes: 74 additions & 0 deletions clis/trip/car.js
Original file line number Diff line number Diff line change
@@ -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,
}));
},
});
61 changes: 61 additions & 0 deletions clis/trip/deals.js
Original file line number Diff line number Diff line change
@@ -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,
}));
},
});
88 changes: 88 additions & 0 deletions clis/trip/flight-round.js
Original file line number Diff line number Diff line change
@@ -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,
}));
},
});
83 changes: 83 additions & 0 deletions clis/trip/flight.js
Original file line number Diff line number Diff line change
@@ -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,
}));
},
});
Loading