jackwener/OpenCLI · error · CommandExecutionError

Ctrip hotel-search returned malformed SSR hotel list

Error message

Ctrip hotel-search returned malformed SSR hotel list

What it means

A CommandExecutionError raised when the EXTRACT_HOTELS_JS snippet returns null instead of an array — i.e. even after the readiness probe reported 'content', reading window.__NEXT_DATA__.props.pageProps.initListData.hotelList did not yield an array. This is a consistency check: readiness and extraction read the same path, so a mismatch indicates the SSR state changed between probes or the probe/extract paths diverged after a Ctrip layout change.

Source

Thrown at clis/ctrip/hotel-search.js:108

    func: async (page, kwargs) => {
        const cityId = parseCityId(kwargs.city);
        const checkin = parseIsoDate('checkin', kwargs.checkin);
        const checkout = parseIsoDate('checkout', kwargs.checkout);
        assertCheckinBeforeCheckout(checkin, checkout);
        const limit = parseHotelLimit(kwargs.limit);

        const url = `https://hotels.ctrip.com/hotels/list?city=${cityId}&checkin=${checkin}&checkout=${checkout}`;
        await page.goto(url);
        const waitResult = await page.evaluate(WAIT_FOR_SSR_JS);
        if (waitResult === 'captcha') {
            throw new AuthRequiredError('hotels.ctrip.com', 'Ctrip is asking for a captcha; complete it in your browser session and retry');
        }
        if (waitResult !== 'content') {
            throw new CommandExecutionError(`Ctrip hotel-search page did not expose SSR hotel list (state=${String(waitResult)})`);
        }
        const raw = await page.evaluate(EXTRACT_HOTELS_JS);
        if (!Array.isArray(raw)) {
            throw new CommandExecutionError('Ctrip hotel-search returned malformed SSR hotel list');
        }
        if (raw.length === 0) {
            throw new EmptyResultError('ctrip hotel-search', `No hotels for city=${cityId} on ${checkin} → ${checkout}`);
        }
        const rows = raw
            .map((entry, i) => mapHotelRow(entry, i))
            .filter((row) => row.hotelId && row.name)
            .slice(0, limit);
        if (rows.length === 0) {
            throw new CommandExecutionError('Ctrip hotel-search SSR rows were missing required hotelId/name anchors');
        }
        return rows;
    },
});

export const __test__ = { parseHotelLimit, assertCheckinBeforeCheckout, WAIT_FOR_SSR_JS, EXTRACT_HOTELS_JS };

View on GitHub (pinned to 49907e53dc)

Solutions

  1. Retry the search to rule out a transient re-render race.
  2. Dump window.__NEXT_DATA__ keys in your browser session and update the extraction path in EXTRACT_HOTELS_JS if Ctrip moved initListData.hotelList.
  3. Make the readiness probe and extraction use a single shared snapshot of the hotelList to eliminate the race.
  4. Pin/verify the library against the current hotels.ctrip.com layout and report persistent breakage upstream.

Example fix

// before: probe and extract read the page twice
const waitResult = await page.evaluate(WAIT_FOR_SSR_JS);
const raw = await page.evaluate(EXTRACT_HOTELS_JS);

// after: extract data in the same evaluation that waits
const raw = await page.evaluate(`
  new Promise((resolve) => {
    const detect = () => {
      const list = window.__NEXT_DATA__?.props?.pageProps?.initListData?.hotelList;
      if (Array.isArray(list)) resolve(list);
    };
    detect();
    const obs = new MutationObserver(() => detect());
    obs.observe(document.documentElement, { childList: true, subtree: true });
    setTimeout(() => resolve(null), 5000);
  })
`);
Defensive patterns

Strategy: type-guard

Validate before calling

// Verify the SSR data path is present before/at extraction time
async function extractHotelList(page) {
  const list = await page.evaluate(
    'window.__NEXT_DATA__?.props?.pageProps?.initListData?.hotelList'
  );
  return Array.isArray(list) ? list : null;
}

Type guard

function isHotelListArray(value) {
  return Array.isArray(value) &&
    value.every((e) => e != null && typeof e === 'object' &&
      e.hotelInfo != null && typeof e.hotelInfo === 'object');
}

Try / catch

try {
  const rows = await ctripHotelSearch({ city, checkin, checkout });
} catch (e) {
  if (e instanceof CommandExecutionError && e.message.includes('malformed SSR hotel list')) {
    // layout change or re-render race: retry once, then flag for scraper maintenance
    const retried = await ctripHotelSearch({ city, checkin, checkout }).catch(() => null);
    if (retried) return retried;
    throw new Error('hotels.ctrip.com SSR shape changed — update EXTRACT_HOTELS_JS path');
  }
  throw e;
}

Prevention

When it happens

Trigger: The page's __NEXT_DATA__ was replaced or restructured between the wait probe and the extraction (SPA re-render); a Ctrip deploy renamed/moved initListData.hotelList while the old readiness heuristics still matched via a cached state; page navigation or frame reset wiped window.__NEXT_DATA__ before evaluate ran.

Common situations: Ctrip rolling out a new hotel-list page version; race where client-side routing re-renders the page and resets __NEXT_DATA__; partially loaded hydration payload; regional variants of the list page with a different data shape.

Understand the failure class

Background: Schema validation failed / invalid input schema: payload rejected because its shape doesn't match the expected schema — this error's family across 28 libraries.

Related errors


AI-assisted analysis of jackwener/OpenCLI@49907e53dc (2026-08-29). Data as JSON: /api/errors/0859578700cd7e2c. Report an issue: GitHub.