{"record":{"id":"0859578700cd7e2c","repo":"jackwener/OpenCLI","slug":"ctrip-hotel-search-returned-malformed-ssr-hotel-li","errorCode":null,"errorMessage":"Ctrip hotel-search returned malformed SSR hotel list","messagePattern":"Ctrip hotel-search returned malformed SSR hotel list","errorType":"exception","errorClass":"CommandExecutionError","httpStatus":null,"severity":"error","filePath":"clis/ctrip/hotel-search.js","lineNumber":108,"sourceCode":"    func: async (page, kwargs) => {\n        const cityId = parseCityId(kwargs.city);\n        const checkin = parseIsoDate('checkin', kwargs.checkin);\n        const checkout = parseIsoDate('checkout', kwargs.checkout);\n        assertCheckinBeforeCheckout(checkin, checkout);\n        const limit = parseHotelLimit(kwargs.limit);\n\n        const url = `https://hotels.ctrip.com/hotels/list?city=${cityId}&checkin=${checkin}&checkout=${checkout}`;\n        await page.goto(url);\n        const waitResult = await page.evaluate(WAIT_FOR_SSR_JS);\n        if (waitResult === 'captcha') {\n            throw new AuthRequiredError('hotels.ctrip.com', 'Ctrip is asking for a captcha; complete it in your browser session and retry');\n        }\n        if (waitResult !== 'content') {\n            throw new CommandExecutionError(`Ctrip hotel-search page did not expose SSR hotel list (state=${String(waitResult)})`);\n        }\n        const raw = await page.evaluate(EXTRACT_HOTELS_JS);\n        if (!Array.isArray(raw)) {\n            throw new CommandExecutionError('Ctrip hotel-search returned malformed SSR hotel list');\n        }\n        if (raw.length === 0) {\n            throw new EmptyResultError('ctrip hotel-search', `No hotels for city=${cityId} on ${checkin} → ${checkout}`);\n        }\n        const rows = raw\n            .map((entry, i) => mapHotelRow(entry, i))\n            .filter((row) => row.hotelId && row.name)\n            .slice(0, limit);\n        if (rows.length === 0) {\n            throw new CommandExecutionError('Ctrip hotel-search SSR rows were missing required hotelId/name anchors');\n        }\n        return rows;\n    },\n});\n\nexport const __test__ = { parseHotelLimit, assertCheckinBeforeCheckout, WAIT_FOR_SSR_JS, EXTRACT_HOTELS_JS };\n","sourceCodeStart":90,"sourceCodeEnd":125,"githubUrl":"https://github.com/jackwener/OpenCLI/blob/49907e53dc3ade5c223ff0c4c2c2785687cec4e6/clis/ctrip/hotel-search.js#L90-L125","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Retry the search to rule out a transient re-render race.","Dump window.__NEXT_DATA__ keys in your browser session and update the extraction path in EXTRACT_HOTELS_JS if Ctrip moved initListData.hotelList.","Make the readiness probe and extraction use a single shared snapshot of the hotelList to eliminate the race.","Pin/verify the library against the current hotels.ctrip.com layout and report persistent breakage upstream."],"exampleFix":"// before: probe and extract read the page twice\nconst waitResult = await page.evaluate(WAIT_FOR_SSR_JS);\nconst raw = await page.evaluate(EXTRACT_HOTELS_JS);\n\n// after: extract data in the same evaluation that waits\nconst raw = await page.evaluate(`\n  new Promise((resolve) => {\n    const detect = () => {\n      const list = window.__NEXT_DATA__?.props?.pageProps?.initListData?.hotelList;\n      if (Array.isArray(list)) resolve(list);\n    };\n    detect();\n    const obs = new MutationObserver(() => detect());\n    obs.observe(document.documentElement, { childList: true, subtree: true });\n    setTimeout(() => resolve(null), 5000);\n  })\n`);","handlingStrategy":"type-guard","validationCode":"// Verify the SSR data path is present before/at extraction time\nasync function extractHotelList(page) {\n  const list = await page.evaluate(\n    'window.__NEXT_DATA__?.props?.pageProps?.initListData?.hotelList'\n  );\n  return Array.isArray(list) ? list : null;\n}","typeGuard":"function isHotelListArray(value) {\n  return Array.isArray(value) &&\n    value.every((e) => e != null && typeof e === 'object' &&\n      e.hotelInfo != null && typeof e.hotelInfo === 'object');\n}","tryCatchPattern":"try {\n  const rows = await ctripHotelSearch({ city, checkin, checkout });\n} catch (e) {\n  if (e instanceof CommandExecutionError && e.message.includes('malformed SSR hotel list')) {\n    // layout change or re-render race: retry once, then flag for scraper maintenance\n    const retried = await ctripHotelSearch({ city, checkin, checkout }).catch(() => null);\n    if (retried) return retried;\n    throw new Error('hotels.ctrip.com SSR shape changed — update EXTRACT_HOTELS_JS path');\n  }\n  throw e;\n}","preventionTips":["Retry once automatically — SPA re-renders between probe and extract cause transient races.","Pin/monitor hotels.ctrip.com layout changes that move __NEXT_DATA__ paths.","Extract the hotel list in the same page evaluation that waits for readiness to avoid races.","Log window.__NEXT_DATA__ top-level keys on failure to speed up diagnosing schema drift."],"tags":["schema-validation","ssr","scraper","race-condition"],"backgroundTag":"schema-validation-failed","analyzedSha":"49907e53dc3ade5c223ff0c4c2c2785687cec4e6","analyzedAt":"2026-08-29T08:14:47.543Z","schemaVersion":2},"datasetVersion":"2026-08-29T12:17:43.993Z"}