Skip to content
Open
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
33 changes: 33 additions & 0 deletions clis/ctrip/ctrip.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,39 @@ describe('ctrip buildFlightExtractJs (JSDOM)', () => {
}]);
});

it('extracts current data-testid cards via stable field ids', () => {
const html = `
<div data-testid="flight-item-1">
<span id="airlineName3U8282_1784385000000-0">四川航空</span>
<div id="comfort-3U8282_1784385000000-0">
<span>3U8282<span>空客320(中)</span></span>
</div>
<div>22:30</div>
<span id="departureFlightTrain3U8282_1784385000000-0">首都国际机场</span>
<span id="departureTerminal3U8282_1784385000000-0">T2</span>
<div>01:55<span id="crossDays3U8282_1784385000000-0">+1天</span></div>
<span id="arrivalFlightTrain3U8282_1784385000000-0">长水国际机场</span>
<span id="arrivalTerminal3U8282_1784385000000-0">T1</span>
<span id="travelPackage_price_undefined">900<dfn>¥</dfn></span>
<div>经济舱 3.3折</div>
</div>
`;
const rows = runExtract(html);
expect(rows).toEqual([{
airline: '四川航空',
flightNo: '3U8282',
aircraft: '空客320(中)',
departureTime: '22:30',
departureAirport: '首都国际机场',
arrivalTime: '01:55',
arrivalAirport: '长水国际机场',
terminal: 'T1',
price: 900,
currency: '¥',
cabin: '经济舱',
}]);
});

it('omits terminal when not present after arrAirport', () => {
const html = `
<div class="flight-list"><span>
Expand Down
12 changes: 6 additions & 6 deletions clis/ctrip/flight.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@
* Unlike `hotel-search`, the flight rows are NOT in `__NEXT_DATA__` — they
* arrive via a post-load XHR that the daemon network buffer currently can't
* capture (see MEMORY `daemon_capture_pipeline_bug_2026_05_07`). We instead
* extract from the rendered `.flight-list > span > div` cards using a
* position-anchored innerText parser (see `buildFlightExtractJs` in utils).
* extract from the rendered flight cards using stable visible-UI anchors
* (see `FLIGHT_CARD_SELECTOR` and `buildFlightExtractJs` in utils).
*
* Round-trip + advanced filters (airline whitelist, cabin selection beyond
* 全舱位) are out of scope for v1 — track in #1481 follow-up if requested.
*/
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { cli, Strategy } from '@jackwener/opencli/registry';
import { buildFlightExtractJs, buildScrollUntilJs, parseIataCode, parseIsoDate } from './utils.js';
import { FLIGHT_CARD_SELECTOR, buildFlightExtractJs, buildScrollUntilJs, parseIataCode, parseIsoDate } from './utils.js';

const MIN_LIMIT = 1;
const MAX_LIMIT = 50;
Expand All @@ -31,14 +31,14 @@ function parseFlightLimit(raw) {
}

/**
* Wait for `.flight-list > span > div` to render (the post-load XHR settles
* Wait for a flight card to render (the post-load XHR settles
* 1-3s after navigation), or detect a captcha/login redirect.
*/
const WAIT_FOR_FLIGHTS_JS = `
new Promise((resolve) => {
const detect = () => {
if (location.pathname.includes('captcha') || /验证码|verify the human/i.test(document.body?.innerText || '')) return 'captcha';
if (document.querySelector('.flight-list > span > div')) return 'content';
if (document.querySelector(${JSON.stringify(FLIGHT_CARD_SELECTOR)})) return 'content';
return null;
};
const found = detect();
Expand Down Expand Up @@ -96,7 +96,7 @@ cli({
throw new CommandExecutionError(`Ctrip flight page did not render flight cards (state=${String(waitResult)})`);
}
// Scroll until enough flight cards rendered (Ctrip lazy-loads beyond ~8).
const renderedCardCount = await page.evaluate(buildScrollUntilJs('.flight-list > span > div', limit));
const renderedCardCount = await page.evaluate(buildScrollUntilJs(FLIGHT_CARD_SELECTOR, limit));
const raw = await page.evaluate(buildFlightExtractJs());
if (!Array.isArray(raw)) {
throw new CommandExecutionError('Ctrip flight DOM extraction returned malformed rows');
Expand Down
53 changes: 48 additions & 5 deletions clis/ctrip/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -314,11 +314,13 @@ export function mapHotelRow(entry, index) {
};
}

export const FLIGHT_CARD_SELECTOR = '.flight-list > span > div, [data-testid^="flight-item-"]';

/**
* Build the browser-context IIFE that extracts flight rows from `.flight-list`.
* Build the browser-context IIFE that extracts flight rows from Ctrip's flight cards.
*
* 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):
* Legacy cards use `.flight-list > span > div` and have a stable innerText ordering
* (verified 2026-05-12 on bjs→sha route):
*
* [airline, flightNo, aircraft, lowPriceTag?, depTime, depAirport,
* arrTime, arrAirport, terminal?, savings?, promo?, currency, price,
Expand All @@ -327,7 +329,8 @@ export function mapHotelRow(entry, index) {
* `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.
* Current cards use `data-testid="flight-item-*"` plus stable field-id prefixes.
* Keep both paths so the adapter remains compatible while Ctrip rolls the DOM out.
*/
export function buildFlightExtractJs() {
return `
Expand All @@ -339,7 +342,7 @@ export function buildFlightExtractJs() {
const isFlightNo = (s) => /^[A-Z0-9]{2}\\d{3,4}[A-Z]?$/.test(s);

const rows = [];
document.querySelectorAll('.flight-list > span > div').forEach((card) => {
document.querySelectorAll(${JSON.stringify(FLIGHT_CARD_SELECTOR)}).forEach((card) => {
// Collect ordered text chunks (text nodes only, skip whitespace-only).
const chunks = [];
const walk = (node) => {
Expand All @@ -353,6 +356,46 @@ export function buildFlightExtractJs() {
}
};
walk(card);

if (card.matches('[data-testid^="flight-item-"]')) {
const airlineNode = card.querySelector('[id^="airlineName"]');
const airline = cleanText(airlineNode?.textContent);
const idFlightNo = /^airlineName([A-Z0-9]{2}\\d{3,4}[A-Z]?)(?:_|$)/.exec(airlineNode?.id || '')?.[1] || null;
const flightNoIdx = chunks.findIndex(isFlightNo);
const flightNo = idFlightNo || (flightNoIdx >= 0 ? chunks[flightNoIdx] : null);
const aircraftCandidate = flightNoIdx >= 0 ? chunks[flightNoIdx + 1] : null;
const aircraft = aircraftCandidate && !isTime(aircraftCandidate) && !/^(共享|经停|直飞)$/.test(aircraftCandidate)
? aircraftCandidate
: null;
const times = chunks.filter(isTime);
const departureAirport = cleanText(card.querySelector('[id^="departureFlightTrain"]')?.textContent) || null;
const arrivalAirport = cleanText(card.querySelector('[id^="arrivalFlightTrain"]')?.textContent) || null;

if (!airline || !isFlightNo(flightNo) || times.length < 2 || !departureAirport || !arrivalAirport) return;

const priceNode = card.querySelector('[id^="travelPackage_price_"]');
const priceText = cleanText(priceNode?.textContent);
const priceMatch = /(\\d[\\d,]*(?:\\.\\d+)?)/.exec(priceText);
const currency = cleanText(priceNode?.querySelector('dfn')?.textContent) ||
((priceText.match(/[¥$€£]/) || [])[0] ?? null);
const cabinMatch = cleanText(card.textContent).match(/(超级经济舱|经济舱|公务\\/头等舱|公务舱|商务舱|头等舱)/);

rows.push({
airline,
flightNo,
aircraft,
departureTime: times[0],
departureAirport,
arrivalTime: times[1],
arrivalAirport,
terminal: cleanText(card.querySelector('[id^="arrivalTerminal"]')?.textContent) || null,
price: priceMatch ? Number(priceMatch[1].replace(/,/g, '')) : null,
currency,
cabin: cabinMatch?.[1] || null,
});
return;
}

if (chunks.length < 8) return;

// Anchor on first HH:MM — that's depTime; depAirport is immediately after.
Expand Down