Skip to content

enrich(ctrip): expand the adapter across Ctrip's travel verticals#2156

Merged
jackwener merged 16 commits into
jackwener:mainfrom
Benjamin-eecs:enrich/ctrip-train
Jul 20, 2026
Merged

enrich(ctrip): expand the adapter across Ctrip's travel verticals#2156
jackwener merged 16 commits into
jackwener:mainfrom
Benjamin-eecs:enrich/ctrip-train

Conversation

@Benjamin-eecs

@Benjamin-eecs Benjamin-eecs commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Description

Rounds out the ctrip adapter across the travel verticals Ctrip exposes with a stable extraction surface, tracked by #2155. The adapter already covered flights, hotels, and destination suggest; this PR adds seven verticals in one place rather than one command per PR. Each command follows the shape the adapter already uses (browser mode plus Strategy.COOKIE; the public search / hotel-suggest commands resolve the ids the search commands take), reads rows from stable SSR or class-keyed anchors, drops incomplete rows rather than surfacing blanks, and raises AuthRequiredError on a captcha / login gate.

Verticals shipped in this PR (each live-verified, output below):

  • Trains (火车票): train
  • Hotels (酒店): hotel single-hotel detail (hotel-search already shipped)
  • Bus (汽车票): bus
  • Ferry (船票): ferry
  • Cruise (邮轮): cruise
  • Tours (旅游): tour
  • Flight and Hotel (机+酒): package
  • Attractions (门票): attraction (a destination's top attractions by city id)
  • Round-trip flights (往返机票): flight-round

While finalizing this PR, Ctrip migrated its flight list from .flight-list > span > div to .flight-item cards (which omit a text flight number), which had silently broken the existing one-way ctrip flight. Both flight and flight-round now read the new cards through the shared parser, with flightNo returned as null when a card omits it.

Closes #2155.

Type of Change

  • 🐛 Bug fix
  • ✨ New feature
  • 🌐 New site adapter
  • 📝 Documentation
  • ♻️ Refactor
  • 🔧 CI / build / tooling

Checklist

  • I ran the checks relevant to this PR
  • I updated tests or docs if needed
  • I included output or screenshots when useful

Documentation (if adding/modifying an adapter)

  • Added doc page under docs/adapters/ (if new adapter)
  • Updated docs/adapters/index.md table (if new adapter)
  • Updated sidebar in docs/.vitepress/config.mts (if new adapter)
  • Updated README.md / README.zh-CN.md when command discoverability changed
  • Used positional args for the command's primary subject unless a named flag is clearly better
  • Normalized expected adapter failures to CliError subclasses instead of raw Error

Screenshots / Output

Trains (火车票): train

Live run against trains.ctrip.com (logged-in session, no captcha):

$ opencli ctrip train 北京 上海 --date 2026-08-01 --limit 3 -f json
[
  { "rank": 1, "trainNo": "G531", "departureTime": "06:08", "departureStation": "北京南", "arrivalTime": "12:04", "arrivalStation": "上海虹桥", "duration": "5时56分", "fromPrice": 626, "seats": "二等座有票 / 一等座(抢) / 商务座(抢)" },
  { "rank": 2, "trainNo": "G547", "fromPrice": 626, "seats": "二等座有票 / 一等座有票 / 商务座(抢)" },
  { "rank": 3, "trainNo": "G1",   "fromPrice": 661, "seats": "二等座有票 / 一等座17张 / 商务座13张" }
]

Also verified on a normal-speed K-type route (杭州 to 上海) so high-speed and normal trains both parse. Rows missing the train number or endpoint times are dropped, never surfaced with sentinel values; fromPrice stays null when the price node is non-numeric. Captcha redirect raises AuthRequiredError; render timeout / malformed extraction raise CommandExecutionError; a genuinely empty route raises EmptyResultError; --limit (1-50) is validated up front with no silent clamp. Train helpers are appended to clis/ctrip/utils.js after the existing flight helpers, so this does not touch the flight-card extractor that PR #2142 is revising.

Hotels (酒店): hotel

Live run against hotels.ctrip.com (logged-in session, no captcha):

$ opencli ctrip hotel 375539 -f json   # 上海和平饭店
[
  { "hotelId": "375539", "name": "上海和平饭店", "enName": null, "star": 5, "score": 4.8, "scoreLabel": "超棒", "reviewCount": 5920, "ratingBreakdown": "卫生 4.8 / 设施 4.8 / 环境 4.8 / 服务 4.8", "facilities": "接机服务 / 无线WIFI免费 / 行李寄存 / 会议厅 / 电梯 / 餐厅", "checkInOut": "入住时间: 15:00后 / 退房时间: 12:00前", "cityName": "上海", "address": "上海黄浦区南京东路20号", "lat": 31.244714, "lon": 121.496056, "url": "https://hotels.ctrip.com/hotels/detail/?hotelid=375539" }
]

The profile is read from __NEXT_DATA__.props.pageProps.hotelDetailResponse, the same SSR source style as hotel-search, and surfaces the four rating sub-scores, hot facilities, and the check-in/out policy that the listing row does not carry. Room-level nightly prices load via a post-SSR XHR into hashed CSS-module cards, so they are out of scope here the same way flight's post-load price XHR is.

Bus (汽车票): bus

Live run against bus.ctrip.com (logged-in session, no captcha):

$ opencli ctrip bus 北京 天津 --date 2026-08-01 --limit 3 -f json
[
  { "rank": 1, "departureTime": "07:05", "fromStation": "四惠客运站", "toStation": "马伸桥", "duration": "约2时30分", "price": 50, "status": "暂停网售" },
  { "rank": 2, "departureTime": "07:05", "fromStation": "四惠客运站", "toStation": "仓上屯", "duration": "约2小时", "price": 45, "status": "暂停网售" },
  { "rank": 3, "departureTime": "07:40", "fromStation": "四惠客运站", "toStation": "马伸桥", "duration": "约2时30分", "price": 50, "status": "暂停网售" }
]

The bus.ctrip.com/ landing SPA does not hydrate under the browser bridge, so the command navigates the results route directly through its ?param=<json> deep link (the payload the app's own search handler posts). Coach rows arrive via the busListV2 XHR and are read from .list-item-parent cards by stable utility-class fields; rows missing the departure time or either station are dropped rather than surfaced with blanks.

Ferry (船票): ferry

Live run against ship.ctrip.com (logged-in session, no captcha):

$ opencli ctrip ferry 大连 烟台 --date 2026-08-01 --limit 3 -f json
[
  { "rank": 1, "shipName": "渤海晶珠", "departureTime": "09:00", "fromPort": "辽渔大连湾航运中心", "arrivalTime": "15:30", "toPort": "烟台港客运站", "duration": "6时30分", "price": 220, "status": "选择舱位" },
  { "rank": 2, "shipName": "渤海晶珠", "departureTime": "09:00", "fromPort": "辽渔大连湾航运中心", "arrivalTime": "16:00", "toPort": "烟台港客运站", "duration": "7时", "price": 260, "status": "选择舱位" },
  { "rank": 3, "shipName": "渤海玉珠", "departureTime": "09:30", "fromPort": "旅顺新港", "arrivalTime": "15:50", "toPort": "蓬莱港客运站", "duration": "6时20分", "price": 200, "status": "选择舱位" }
]

Sibling of bus on ship.ctrip.com: same non-hydrating landing, same ?param=<json> results deep link. Sailings arrive via the getShipLineV2 XHR and are read from .list-item-parent cards; rows missing the departure time or either port are dropped rather than surfaced with blanks.

Cruise (邮轮): cruise

Live run against cruise.ctrip.com (logged-in session, no captcha):

$ opencli ctrip cruise 上海 --limit 2 -f json
[
  { "rank": 1, "title": "MSC地中海邮轮·荣耀号·上海-那霸(冲绳)-上海·5天4晚", "star": 4, "boarding": "上海登船/离船", "sailingDate": "2026 09-30", "tags": "免签 / 码头接送巴士 / 船上服务", "price": 3980 },
  { "rank": 2, "title": "皇家加勒比国际游轮·海洋光谱号·上海-西归浦市(济州岛)-上海·4天3晚", "star": 4, "boarding": "上海登船/离船", "sailingDate": "2026 08-21", "tags": "免签 / 岸上游 / WIFI租赁 / 船上餐饮", "price": 5474 }
]

Cruise results live on the legacy newpackage/search/sN.html pages keyed by an opaque per-port code, so the command loads the 上海 page (which lists every port as a link) to resolve the requested port name to its code, then loads that port's results and reads the .route_info cards. A listed port with no current sailings (verified against 天津, currently seasonal) raises EmptyResultError. River cruises are a separate product and out of scope.

Tours (旅游): tour

Live run against vacations.ctrip.com (logged-in session, no captcha):

$ opencli ctrip tour 北京 --limit 2 -f json
[
  { "rank": 1, "title": "北京5日4晚跟团游", "subtitle": "封顶12人小团『指定二环品牌美居酒店』+赠热门博物馆+全精华景点深度漫游", "tags": "0购物 / 成团保障 / 赠取消险 / 升旗仪式 / 住宿超赞", "score": 4.7, "sold": 1968, "reviews": 567, "price": 3726 },
  { "rank": 2, "title": "北京5日4晚拼小团", "subtitle": "精选『指定住2环5钻赛特酒店 距离长安街4公里』赠1个热门博物馆门票 并加赠博物馆讲解", "tags": "0购物 / 成团保障 / 宫殿 / 博物馆 / 饺子宴", "score": 4.8, "sold": 155, "reviews": 43, "price": 4253 }
]

Results render server-side into .list_product_item cards read by stable class fields; the price / score / sold fields lazy-load a moment after the titles, so the command waits until every rendered card carries a price before reading. A destination with no packages raises EmptyResultError.

Flight and Hotel (机+酒): package

Live run against vacations.ctrip.com (logged-in session, no captcha):

$ opencli ctrip package 三亚 --limit 2 -f json
[
  { "rank": 1, "title": "海南三亚+亚特兰蒂斯5日4晚私家团", "tags": "成团保障 / 亲子甄选 / 深潜体验 / 车型可选", "score": 5, "sold": 6, "reviews": 1, "price": 3460 },
  { "rank": 2, "title": "三亚5日自由行", "tags": "自选酒店 / 冲浪体验 / 随行旅拍", "score": 4.8, "sold": 71000, "reviews": 4375, "price": 2124 }
]

Reuses the shared vacations product extractor against the freetravel (自由行 / flight-plus-hotel) search section rather than the whole section tour uses; sold / reviews counts written as N万 are folded to their integer value.

Attractions (门票): attraction

Live run against you.ctrip.com (logged-in session, no captcha):

$ opencli ctrip attraction 1 --limit 4 -f json
[
  { "rank": 1, "name": "北京环球度假区", "rating": 4.6, "reviews": 72000, "url": "https://you.ctrip.com/sight/beijing1/2045497.html?poiType=3" },
  { "rank": 2, "name": "故宫博物院", "rating": 4.8, "reviews": 197000, "url": "https://you.ctrip.com/sight/beijing1/229.html?poiType=3" },
  { "rank": 3, "name": "八达岭长城", "rating": 4.7, "reviews": 61000, "url": "https://you.ctrip.com/sight/beijing1/230.html?poiType=3" },
  { "rank": 4, "name": "中国国家博物馆", "rating": null, "reviews": 13000, "url": "https://you.ctrip.com/sight/beijing1/62722.html?poiType=3" }
]

The you.ctrip.com place page renders through hashed CSS-module class names, so rows anchor on the one stable handle each attraction exposes, the /sight/<city>/<id>.html detail link (name is the text before the rating, url its href), and read rating / review count from the link text by data-format pattern (19.7w / 1.3万 expanded to thousands). The place page routes by the trailing numeric city id (discover it via ctrip search) and redirects any slug to the canonical one; rating stays null when a card lists only a review count. Per-attraction ticket prices sit on each sight's own detail page and are out of scope here.

Round-trip flights (往返机票): flight-round

Live run against flights.ctrip.com (logged-in session, no captcha):

$ opencli ctrip flight-round SHA BJS --depart 2026-08-15 --return 2026-08-22 --limit 3 -f json
[
  { "rank": 1, "airline": "新海航|海南航空", "flightNo": null, "departureTime": "19:00", "departureAirport": "虹桥国际机场", "arrivalTime": "21:15", "arrivalAirport": "首都国际机场", "terminal": "T2", "price": 817, "currency": "¥", "cabin": null },
  { "rank": 2, "airline": "新海航|海南航空", "flightNo": null, "departureTime": "20:15", "departureAirport": "虹桥国际机场", "arrivalTime": "22:30", "arrivalAirport": "首都国际机场", "terminal": "T2", "price": 817, "currency": "¥", "cabin": null },
  { "rank": 3, "airline": "吉祥航空", "flightNo": null, "departureTime": "18:05", "departureAirport": "浦东国际机场", "arrivalTime": "20:20", "arrivalAirport": "大兴国际机场", "terminal": null, "price": 846, "currency": "¥", "cabin": null }
]

The round-trip search deep-links to the same online/list app under a round-<from>-<to>?depdate=<depart>_<return> URL that renders the outbound (去程) leg priced for the whole round trip (往返总价). The cards are .flight-item rows carrying the same ordered text shape as the one-way list, so this reuses the one-way position-anchored parser with that selector; the round-trip cards omit the flight number, so flightNo / aircraft come back null (never a sentinel) and the parser treats them as optional there while keeping them required for the one-way list. Picking the return leg is a second step and is out of scope here.

All nine shipped verticals above are live-verified against a logged-in session; clis/ctrip/ctrip.test.js covers each command's argument validation, captcha / timeout / empty / malformed ladder, and JSDOM extraction. The out-of-scope verticals are noted with their reasons in the Description.

@Benjamin-eecs
Benjamin-eecs marked this pull request as ready for review July 19, 2026 14:47
Copilot AI review requested due to automatic review settings July 19, 2026 14:47

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@Benjamin-eecs
Benjamin-eecs marked this pull request as draft July 19, 2026 15:30
@Benjamin-eecs Benjamin-eecs changed the title enrich(ctrip): add train ticket search command enrich(ctrip): expand the adapter across Ctrip's travel verticals Jul 19, 2026
@Benjamin-eecs
Benjamin-eecs marked this pull request as ready for review July 19, 2026 22:38
@Benjamin-eecs
Benjamin-eecs marked this pull request as draft July 20, 2026 02:27
@Benjamin-eecs
Benjamin-eecs marked this pull request as ready for review July 20, 2026 11:19
Benjamin-eecs and others added 16 commits July 21, 2026 01:42
ctrip search already suggests railway stations but there was no way to query the
actual departures. ctrip train <from> <to> --date fills that gap on the public
trains.ctrip.com list page, browser-mode + cookie like flight/hotel-search. Rows
are read by stable class-keyed fields rather than positional innerText;
incomplete cards are dropped, not sentinel-filled.
Single-hotel profile from the detail-page SSR: rating sub-scores, hot facilities, check-in/out policy.
Intercity coach search via the newbus results deep link (landing SPA does not hydrate under the bridge).
Passenger ferry sailings via the ship.ctrip.com results deep link, sibling of bus.
Resolves a departure port name to its legacy per-port code, then reads the .route_info cards.
Group and self-guided tour search via the vacations sv=<destination> deep link, stable-class cards.
Shares the vacations product extractor with tour (freetravel section); folds a 万 count multiplier into the shared parser.
…esults

Matches the drift handling bus/ferry/train use, so genuine-empty stays EmptyResultError.
parseListLimit / parsePlaceName replace the train-named helpers now reused across bus/ferry/cruise/tour/package with neutral hints; ferry ship-name/duration read by pattern, not position.
@jackwener
jackwener force-pushed the enrich/ctrip-train branch from a55fd5a to 8a819b3 Compare July 20, 2026 17:49
@jackwener
jackwener merged commit 5256711 into jackwener:main Jul 20, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Expand the ctrip adapter across Ctrip's remaining travel verticals

3 participants