Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
425 changes: 421 additions & 4 deletions cli-manifest.json

Large diffs are not rendered by default.

73 changes: 73 additions & 0 deletions clis/ctrip/attraction.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* 携程门票 / 景点: a destination's top attractions by city id.
*
* you.ctrip.com's place page renders the top-rated attractions for a city as
* `/sight/<city><cityId>/<id>.html` links carrying the name, rating, and review
* count, so this navigates the place page (routed by the numeric city id, discover
* it via `ctrip search`) and reads those links, scoped to the requested city, by
* their stable pattern (see `buildAttractionExtractJs` in utils). Per-sight ticket
* prices sit on each attraction's own detail page and are out of scope here.
*
* There is no `EmptyResultError` path: a valid you.ctrip city always lists
* attractions, so a zero city-scoped result means a stale / invalid / unrenderable
* id, which surfaces as `CommandExecutionError` ("check the city id") rather than a
* genuine-empty result.
*/
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
import { cli, Strategy } from '@jackwener/opencli/registry';
import {
buildAttractionExtractJs,
buildAttractionPlaceUrl,
buildWaitForAttractionsJs,
parseCityId,
parseListLimit,
} from './utils.js';

cli({
site: 'ctrip',
name: 'attraction',
access: 'read',
description: '列出携程某城市的热门景点(评分/点评数,城市 id 经 ctrip search 获取)',
domain: 'you.ctrip.com',
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [
{ name: 'city', required: true, positional: true, help: 'Numeric Ctrip city id (from `ctrip search`, e.g. 1 for 北京)' },
{ name: 'limit', default: 20, help: 'Number of attractions (1-50)' },
],
columns: [
'rank',
'name',
'rating', 'reviews',
'url',
],
func: async (page, kwargs) => {
const cityId = parseCityId(kwargs.city);
const limit = parseListLimit(kwargs.limit);

const placeUrl = buildAttractionPlaceUrl(cityId);
await page.goto(placeUrl);
const waitResult = await page.evaluate(buildWaitForAttractionsJs(cityId));
if (waitResult === 'captcha') {
throw new AuthRequiredError('you.ctrip.com', 'Ctrip is asking for a captcha; complete it in your browser session and retry');
}
if (waitResult !== 'content') {
throw new CommandExecutionError(`Ctrip place page did not render attraction links for city id ${cityId} (state=${String(waitResult)}); check the city id`);
}
const raw = await page.evaluate(buildAttractionExtractJs(cityId));
if (!Array.isArray(raw)) {
throw new CommandExecutionError('Ctrip attraction DOM extraction returned malformed rows');
}
if (raw.length === 0) {
throw new CommandExecutionError('Ctrip attraction links rendered but parser did not find required sight anchors');
}
return raw.slice(0, limit).map((r, i) => ({
rank: i + 1,
name: r.name,
rating: r.rating,
reviews: r.reviews,
url: r.url,
}));
},
});
85 changes: 85 additions & 0 deletions clis/ctrip/bus.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* 携程汽车票 search: intercity coach tickets by city name + date.
*
* The newbus SPA landing (`bus.ctrip.com/`) does not hydrate under the browser
* bridge, but the results route renders directly from a `?param=<json>` deep
* link (the payload the app's own search handler posts to `/list`). Schedules
* arrive via the `busListV2` XHR and render into `.list-item-parent` rows read
* by stable utility-class fields (see `buildBusExtractJs` in utils), so this
* reads by selector rather than positional innerText.
*/
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { cli, Strategy } from '@jackwener/opencli/registry';
import {
WAIT_FOR_BUS_JS,
buildBusExtractJs,
buildBusListUrl,
buildScrollUntilJs,
parseIsoDate,
parseListLimit,
parsePlaceName,
} from './utils.js';

cli({
site: 'ctrip',
name: 'bus',
access: 'read',
description: '搜索携程汽车票(按出发/到达城市名 + 日期)',
domain: 'bus.ctrip.com',
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [
{ name: 'from', required: true, positional: true, help: 'Departure city name (e.g. 北京 / 上海)' },
{ name: 'to', required: true, positional: true, help: 'Arrival city name (e.g. 天津 / 杭州)' },
{ name: 'date', required: true, help: 'Departure date (YYYY-MM-DD)' },
{ name: 'limit', default: 20, help: 'Number of departures (1-50)' },
],
columns: [
'rank',
'departureTime',
'fromStation', 'toStation',
'duration', 'price', 'status',
'url',
],
func: async (page, kwargs) => {
const fromCity = parsePlaceName('from', kwargs.from);
const toCity = parsePlaceName('to', kwargs.to);
if (fromCity === toCity) {
throw new ArgumentError(`--from and --to must differ (got ${fromCity})`);
}
const date = parseIsoDate('date', kwargs.date);
const limit = parseListLimit(kwargs.limit);

const searchUrl = buildBusListUrl(fromCity, toCity, date);
await page.goto(searchUrl);
const waitResult = await page.evaluate(WAIT_FOR_BUS_JS);
if (waitResult === 'captcha') {
throw new AuthRequiredError('bus.ctrip.com', 'Ctrip is asking for a captcha; complete it in your browser session and retry');
}
if (waitResult !== 'content') {
throw new CommandExecutionError(`Ctrip bus page did not render schedule rows (state=${String(waitResult)})`);
}
const renderedCardCount = await page.evaluate(buildScrollUntilJs('.list-item-parent', limit));
const raw = await page.evaluate(buildBusExtractJs());
if (!Array.isArray(raw)) {
throw new CommandExecutionError('Ctrip bus DOM extraction returned malformed rows');
}
if (raw.length === 0) {
if (Number(renderedCardCount) > 0) {
throw new CommandExecutionError('Ctrip bus rows rendered but parser did not find required schedule anchors');
}
throw new EmptyResultError('ctrip bus', `No coaches for ${fromCity} to ${toCity} on ${date}`);
}
return raw.slice(0, limit).map((r, i) => ({
rank: i + 1,
departureTime: r.departureTime,
fromStation: r.fromStation,
toStation: r.toStation,
duration: r.duration,
price: r.price,
status: r.status,
url: searchUrl,
}));
},
});
96 changes: 96 additions & 0 deletions clis/ctrip/cruise.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* 携程邮轮 search: cruise packages by departure port name.
*
* Ctrip's cruise search results live on the legacy `newpackage/search/sN.html`
* pages, keyed by an opaque per-port code `sN`. Those pages list every port as a
* link, so the command first navigates a stable port page (上海港, `s2`) to
* resolve the requested port name to its `sN` code, then loads that port's
* results and reads the `.route_info` cards (see helpers in utils).
*/
import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { cli, Strategy } from '@jackwener/opencli/registry';
import {
WAIT_FOR_CRUISE_JS,
buildCruiseExtractJs,
buildCruisePortLookupJs,
buildCruiseSearchUrl,
parseListLimit,
parsePlaceName,
} from './utils.js';

// 上海港 lists every departure port as a link, so it doubles as the port-code index.
const PORT_INDEX_CODE = '2';

cli({
site: 'ctrip',
name: 'cruise',
access: 'read',
description: '搜索携程邮轮线路(按出发港名)',
domain: 'cruise.ctrip.com',
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [
{ name: 'port', required: true, positional: true, help: 'Departure cruise port name (e.g. 上海 / 威尼斯 / 罗马)' },
{ name: 'limit', default: 20, help: 'Number of cruises (1-50)' },
],
columns: [
'rank',
'title', 'star',
'boarding', 'sailingDate',
'tags', 'price',
'url',
],
func: async (page, kwargs) => {
const port = parsePlaceName('port', kwargs.port);
const limit = parseListLimit(kwargs.limit);

const indexUrl = buildCruiseSearchUrl(PORT_INDEX_CODE);
await page.goto(indexUrl);
const indexWait = await page.evaluate(WAIT_FOR_CRUISE_JS);
if (indexWait === 'captcha') {
throw new AuthRequiredError('cruise.ctrip.com', 'Ctrip is asking for a captcha; complete it in your browser session and retry');
}
if (indexWait !== 'content') {
throw new CommandExecutionError(`Ctrip cruise page did not render (state=${String(indexWait)})`);
}
const portCode = await page.evaluate(buildCruisePortLookupJs(port));
if (!portCode) {
throw new EmptyResultError('ctrip cruise', `No cruise departure port matching "${port}" (try a listed sea-cruise port like 上海 / 威尼斯 / 罗马)`);
}

let searchUrl = indexUrl;
if (portCode !== PORT_INDEX_CODE) {
searchUrl = buildCruiseSearchUrl(portCode);
await page.goto(searchUrl);
const portWait = await page.evaluate(WAIT_FOR_CRUISE_JS);
if (portWait === 'captcha') {
throw new AuthRequiredError('cruise.ctrip.com', 'Ctrip is asking for a captcha; complete it in your browser session and retry');
}
if (portWait === 'empty') {
throw new EmptyResultError('ctrip cruise', `No cruises currently departing "${port}"`);
}
if (portWait !== 'content') {
throw new CommandExecutionError(`Ctrip cruise port page did not render (state=${String(portWait)})`);
}
}

const raw = await page.evaluate(buildCruiseExtractJs());
if (!Array.isArray(raw)) {
throw new CommandExecutionError('Ctrip cruise DOM extraction returned malformed rows');
}
if (raw.length === 0) {
throw new CommandExecutionError('Ctrip cruise cards rendered but parser did not find required itinerary anchors');
}
return raw.slice(0, limit).map((r, i) => ({
rank: i + 1,
title: r.title,
star: r.star,
boarding: r.boarding,
sailingDate: r.sailingDate,
tags: r.tags,
price: r.price,
url: searchUrl,
}));
},
});
Loading