Files
jackwener--opencli/clis/ctrip/utils.js
T
wehub-resource-sync 9b395f5cc3
Build Chrome Extension / build (push) Waiting to run
Trigger Website Rebuild (Docs Updated) / dispatch (push) Waiting to run
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

474 lines
19 KiB
JavaScript

/**
* Shared helpers for ctrip public destination/hotel suggestion endpoints.
*
* The single backing endpoint `https://m.ctrip.com/restapi/soa2/21881/json/gaHotelSearchEngine`
* accepts a `searchType` discriminator:
* - `D` → destination suggest (cities, scenic spots, railway stations, landmarks)
* - `H` → hotel-context suggest (cities, business areas, individual hotels)
*
* Response shape is identical; we surface every field the endpoint emits as a
* stable column so callers do not silently lose geo / English / id metadata.
*/
import { ArgumentError, CliError } from '@jackwener/opencli/errors';
const ENDPOINT = 'https://m.ctrip.com/restapi/soa2/21881/json/gaHotelSearchEngine';
const MIN_LIMIT = 1;
const MAX_LIMIT = 50;
export function parseLimit(raw, fallback = 15) {
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 async function fetchSuggest(query, searchType) {
let response;
try {
response = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
keyword: query,
searchType,
platform: 'online',
pageID: '102001',
head: {
Locale: 'zh-CN',
LocaleController: 'zh_cn',
Currency: 'CNY',
PageId: '102001',
clientID: 'opencli-ctrip',
group: 'ctrip',
Frontend: { sessionID: 1, pvid: 1 },
HotelExtension: { group: 'CTRIP', WebpSupport: false },
},
}),
});
} catch (err) {
throw new CliError(
'FETCH_ERROR',
`ctrip suggest fetch failed: ${err instanceof Error ? err.message : String(err)}`,
'Check your network connection and retry',
);
}
if (!response.ok) {
throw new CliError(
'FETCH_ERROR',
`ctrip suggest failed with status ${response.status}`,
'Retry the command or verify ctrip.com is reachable',
);
}
let payload;
try {
payload = await response.json();
} catch (err) {
throw new CliError(
'COMMAND_EXEC',
`ctrip suggest returned invalid JSON: ${err instanceof Error ? err.message : String(err)}`,
'Ctrip may have changed the endpoint response format; retry later',
);
}
if (payload && payload.Result === false) {
const code = payload.ErrorCode ?? 'unknown';
throw new CliError(
'COMMAND_EXEC',
`ctrip suggest API returned Result=false (ErrorCode=${code})`,
'Verify keyword and retry; this typically means upstream rejected the query envelope',
);
}
return Array.isArray(payload?.Response?.searchResults) ? payload.Response.searchResults : [];
}
/**
* Pick the best lat/lon pair available.
*
* Domestic Mainland China rows ship `gdLat`/`gdLon` (gaode); international rows
* ship `gLat`/`gLon` (google/wgs84). `lat`/`lon` is the legacy flat field — fall
* through to it last. Zero values are treated as "missing" since the endpoint
* uses 0.0 as a sentinel for unknown coords.
*/
export function pickCoords(item) {
const candidates = [
[item.gdLat, item.gdLon],
[item.gLat, item.gLon],
[item.lat, item.lon],
];
for (const [la, lo] of candidates) {
if (Number.isFinite(la) && Number.isFinite(lo) && (la !== 0 || lo !== 0)) {
return { lat: la, lon: lo };
}
}
return { lat: null, lon: null };
}
/**
* Build a canonical user-facing URL from the suggest item type + ids.
* Unknown types return null (do not silently fabricate URLs).
*/
export function buildUrl(item) {
const id = item?.id ? String(item.id) : '';
const cityId = item?.cityId ?? '';
const cityName = item?.cityName ? String(item.cityName) : '';
switch (item?.type) {
case 'City':
return cityId ? `https://you.ctrip.com/place/${encodeURIComponent(cityName)}${cityId}.html` : null;
case 'Markland':
return id && cityId
? `https://you.ctrip.com/sight/${encodeURIComponent(cityName)}${cityId}/${id}.html`
: null;
case 'Hotel':
return id ? `https://hotels.ctrip.com/hotels/detail/?hotelid=${id}` : null;
case 'BusinessArea':
case 'Zone':
return cityId && id
? `https://hotels.ctrip.com/hotels/list?city=${cityId}&zone=${id}`
: null;
case 'RailwayStation':
return id ? `https://trains.ctrip.com/trainstation/${id}.html` : null;
default:
return null;
}
}
function nz(v) {
return Number.isFinite(v) && v !== 0 ? v : null;
}
function firstNonZero(...values) {
for (const v of values) {
const n = Number(v);
if (Number.isFinite(n) && n !== 0) return n;
}
return null;
}
/**
* Project a raw suggest row into the stable adapter column shape.
* No silent fallbacks: every column has a deterministic value (string|number|null).
*/
export function mapSuggestRow(item, index) {
const { lat, lon } = pickCoords(item);
return {
rank: index + 1,
id: item?.id ? String(item.id) : null,
type: item?.type ? String(item.type) : null,
displayType: item?.displayType ? String(item.displayType).trim() : null,
name: String(item?.displayName || item?.word || item?.cityName || '').replace(/\s+/g, ' ').trim() || null,
eName: item?.eName ? String(item.eName).trim() : null,
cityId: Number.isFinite(item?.cityId) && item.cityId !== 0 ? item.cityId : null,
cityName: item?.cityName ? String(item.cityName).trim() : null,
provinceName: item?.provinceName ? String(item.provinceName).trim() : null,
countryName: item?.countryName ? String(item.countryName).trim() : null,
lat,
lon,
score: firstNonZero(item?.commentScore, item?.cStar),
url: buildUrl(item),
};
}
/* --------- Helpers shared by hotel-search / flight (browser-context) ---------- */
const ISO_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
/**
* Validate YYYY-MM-DD and return the canonical string. Rejects out-of-range
* month/day, malformed input, and silent NaN. Does NOT coerce or shift timezones.
*/
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;
}
/**
* Validate a 3-letter IATA airport / metro code, return uppercase.
* Ctrip URL accepts both single-airport (PEK / PVG) and metro-group (BJS / SHA) codes.
*/
export function parseIataCode(name, raw) {
if (raw === undefined || raw === null || raw === '') {
throw new ArgumentError(`--${name} is required (3-letter IATA code, e.g. PEK, SHA)`);
}
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;
}
/**
* Validate a numeric Ctrip city ID (returned by `ctrip search` / `ctrip hotel-suggest`).
*/
export function parseCityId(raw) {
if (raw === undefined || raw === null || raw === '') {
throw new ArgumentError('--city is required (numeric city ID from `ctrip search` or `ctrip hotel-suggest`)');
}
const parsed = Number(raw);
if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed <= 0) {
throw new ArgumentError(`--city must be a positive integer city ID, got ${JSON.stringify(raw)}`);
}
return parsed;
}
/**
* Pick the best lat/lon from a Ctrip hotel `positionInfo.mapCoordinate` array.
*
* Each entry has a `coordinateType` (1=WGS84, 2=GCJ02, 3=BD09 / Baidu). We prefer
* WGS84 when present (most portable), then fall through. All coordinates are
* strings in the API, so we Number() and reject NaN.
*/
export function pickHotelMapCoords(mapCoordinate) {
if (!Array.isArray(mapCoordinate) || mapCoordinate.length === 0) {
return { lat: null, lon: null };
}
// Order: WGS84 (1) → GCJ02 (2) → BD09 (3) → whatever exists
const ranking = (entry) => {
const t = Number(entry?.coordinateType);
if (t === 1) return 0;
if (t === 2) return 1;
if (t === 3) return 2;
return 3;
};
const sorted = [...mapCoordinate].sort((a, b) => ranking(a) - ranking(b));
for (const entry of sorted) {
const lat = Number(entry?.latitude);
const lon = Number(entry?.longitude);
if (Number.isFinite(lat) && Number.isFinite(lon) && (lat !== 0 || lon !== 0)) {
return { lat, lon };
}
}
return { lat: null, lon: null };
}
/**
* Project a single Ctrip hotel row from `__NEXT_DATA__.props.pageProps.initListData.hotelList[*]`
* into stable adapter column shape.
*
* No silent fallbacks — every field is `string|number|null`, never `''` masquerading
* as "no data" (see typed-errors.md §"scalar sentinels are anti-pattern").
*/
export function mapHotelRow(entry, index) {
const hotelInfo = entry?.hotelInfo ?? {};
const rooms = Array.isArray(entry?.roomInfo) ? entry.roomInfo : [];
const summary = hotelInfo.summary ?? {};
const nameInfo = hotelInfo.nameInfo ?? {};
const hotelStar = hotelInfo.hotelStar ?? {};
const commentInfo = hotelInfo.commentInfo ?? {};
const positionInfo = hotelInfo.positionInfo ?? {};
const firstRoom = rooms[0] ?? {};
const priceInfo = firstRoom.priceInfo ?? {};
const hotelId = summary.hotelId ? String(summary.hotelId) : null;
const { lat, lon } = pickHotelMapCoords(positionInfo.mapCoordinate);
// commenterNumber arrives as "13,966条点评" — strip non-digits to int, else null.
let reviewCount = null;
if (commentInfo.commenterNumber) {
const digits = String(commentInfo.commenterNumber).replace(/[^\d]/g, '');
if (digits) reviewCount = Number(digits);
}
const score = commentInfo.commentScore ? Number(commentInfo.commentScore) : null;
const star = Number.isFinite(hotelStar.star) && hotelStar.star > 0 ? hotelStar.star : null;
const price = Number.isFinite(priceInfo.price) && priceInfo.price > 0 ? priceInfo.price : null;
return {
rank: index + 1,
hotelId,
name: nameInfo.name ? String(nameInfo.name).trim() : null,
enName: nameInfo.enName ? String(nameInfo.enName).trim() : null,
star,
score: Number.isFinite(score) && score > 0 ? score : null,
scoreLabel: commentInfo.commentDescription ? String(commentInfo.commentDescription).trim() : null,
reviewCount,
cityName: positionInfo.cityName ? String(positionInfo.cityName).trim() : null,
district: positionInfo.positionDesc ? String(positionInfo.positionDesc).trim() : null,
address: positionInfo.address ? String(positionInfo.address).trim() : null,
lat,
lon,
price,
currency: priceInfo.currency ? String(priceInfo.currency).trim() : null,
url: hotelId ? `https://hotels.ctrip.com/hotels/detail/?hotelid=${hotelId}` : null,
};
}
/**
* Build the browser-context IIFE that extracts flight rows from `.flight-list`.
*
* Flights are rendered as `.flight-list > span > div` cards. Each card's innerText
* has a stable ordering (verified 2026-05-12 on bjs→sha route):
*
* [airline, flightNo, aircraft, lowPriceTag?, depTime, depAirport,
* arrTime, arrAirport, terminal?, savings?, promo?, currency, price,
* priceSuffix, cabin, cta]
*
* `lowPriceTag` (e.g. "当日低价") + `terminal` (e.g. "T2") + `savings` + `promo`
* are optional — we use position-of-first-time-match to anchor and parse around it.
*
* The host is baked in so `normalizeUrl` for booking links resolves on the calling site.
*/
export function buildFlightExtractJs() {
return `
(() => {
const cleanText = (value) => (value || '').replace(/\\s+/g, ' ').trim();
const isTime = (s) => /^([01]?\\d|2[0-3]):[0-5]\\d$/.test(s);
const isCurrency = (s) => /^[¥$€£]$/.test(s);
const isPriceDigits = (s) => /^\\d+([.,]\\d+)?$/.test(s);
const isFlightNo = (s) => /^[A-Z0-9]{2}\\d{3,4}[A-Z]?$/.test(s);
const rows = [];
document.querySelectorAll('.flight-list > span > div').forEach((card) => {
// Collect ordered text chunks (text nodes only, skip whitespace-only).
const chunks = [];
const walk = (node) => {
for (const c of node.childNodes) {
if (c.nodeType === 3) {
const t = cleanText(c.textContent);
if (t) chunks.push(t);
} else if (c.nodeType === 1) {
walk(c);
}
}
};
walk(card);
if (chunks.length < 8) return;
// Anchor on first HH:MM — that's depTime; depAirport is immediately after.
const firstTimeIdx = chunks.findIndex(isTime);
if (firstTimeIdx < 1) return;
const airline = chunks[0];
const flightNo = chunks[1] || null;
if (!airline || !isFlightNo(flightNo)) return;
const aircraft = chunks[2] && !isTime(chunks[2]) ? chunks[2] : null;
const depTime = chunks[firstTimeIdx];
const depAirport = chunks[firstTimeIdx + 1] || null;
// Second HH:MM after depTime is arrTime
const arrTimeIdx = chunks.findIndex((c, i) => i > firstTimeIdx && isTime(c));
if (arrTimeIdx < 0) return;
const arrTime = chunks[arrTimeIdx];
const arrAirport = chunks[arrTimeIdx + 1] || null;
if (!depAirport || !arrAirport) return;
// Optional terminal chunk right after arrAirport (matches /^T\\d$/ or single letter)
let terminal = null;
if (arrTimeIdx + 2 < chunks.length && /^T\\d$/.test(chunks[arrTimeIdx + 2])) {
terminal = chunks[arrTimeIdx + 2];
}
// Price: scan for currency symbol then a digit-only chunk
let price = null;
let currency = null;
for (let i = 0; i < chunks.length - 1; i++) {
if (isCurrency(chunks[i]) && isPriceDigits(chunks[i + 1])) {
currency = chunks[i];
price = Number(chunks[i + 1].replace(',', ''));
break;
}
}
// Cabin: scan from end for first non-CTA Chinese chunk ending in "舱"
let cabin = null;
for (let i = chunks.length - 1; i >= 0; i--) {
if (/舱$/.test(chunks[i])) { cabin = chunks[i]; break; }
}
rows.push({
airline,
flightNo,
aircraft,
departureTime: depTime,
departureAirport: depAirport,
arrivalTime: arrTime,
arrivalAirport: arrAirport,
terminal,
price,
currency,
cabin,
});
});
return rows;
})()
`;
}
/**
* Build a scroll-until-enough IIFE for flights/hotels DOM-card pagination.
*
* Mirrors `clis/xiaohongshu/search.js#buildScrollUntilJs` (PR #1487) — counts a
* caller-supplied row selector, scrolls until count >= target / DOM plateau /
* maxScrolls. Returns final row count so the caller can decide whether to
* surface an EmptyResultError. (xiaohongshu's helper hardcodes
* `section.note-item`; this generic version takes a selector.)
*/
export function buildScrollUntilJs(rowSelector, targetCount, maxScrolls = 8) {
if (!Number.isInteger(targetCount) || targetCount < 1 || targetCount > 100) {
throw new ArgumentError(`targetCount must be an integer between 1 and 100, got ${JSON.stringify(targetCount)}`);
}
if (!Number.isInteger(maxScrolls) || maxScrolls < 1 || maxScrolls > 30) {
throw new ArgumentError(`maxScrolls must be an integer between 1 and 30, got ${JSON.stringify(maxScrolls)}`);
}
return `
(async () => {
const sel = ${JSON.stringify(rowSelector)};
const isVisible = (el) => {
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const countItems = () => Array.from(document.querySelectorAll(sel)).filter(isVisible).length;
let lastCount = countItems();
let plateauRounds = 0;
for (let i = 0; i < ${maxScrolls}; i++) {
if (countItems() >= ${targetCount}) break;
const lastHeight = document.body.scrollHeight;
window.scrollTo(0, lastHeight);
await new Promise((resolve) => {
let to;
const ob = new MutationObserver(() => {
if (document.body.scrollHeight > lastHeight) {
clearTimeout(to);
ob.disconnect();
setTimeout(resolve, 200);
}
});
ob.observe(document.body, { childList: true, subtree: true });
to = setTimeout(() => { ob.disconnect(); resolve(null); }, 2500);
});
const newCount = countItems();
if (newCount === lastCount) {
plateauRounds++;
if (plateauRounds >= 2) break;
} else {
plateauRounds = 0;
lastCount = newCount;
}
}
return countItems();
})()
`;
}
export const __test__ = { ENDPOINT, MIN_LIMIT, MAX_LIMIT };