search.js
1 /** 2 * 携程旅行搜索 — public destination and hotel suggestion lookup. 3 */ 4 import { ArgumentError, CliError, EmptyResultError } from '@jackwener/opencli/errors'; 5 import { cli, Strategy } from '@jackwener/opencli/registry'; 6 function clampLimit(raw, fallback = 15) { 7 const parsed = Number(raw); 8 if (!Number.isFinite(parsed)) 9 return fallback; 10 return Math.max(1, Math.min(Math.floor(parsed), 50)); 11 } 12 function mapSearchResults(results, limit) { 13 return results 14 .filter((item) => !!item && typeof item === 'object') 15 .slice(0, limit) 16 .map((item, index) => ({ 17 rank: index + 1, 18 name: String(item.displayName || item.word || item.cityName || '').replace(/\s+/g, ' ').trim(), 19 type: String(item.displayType || item.type || '').replace(/\s+/g, ' ').trim(), 20 score: item.commentScore ?? item.cStar ?? '', 21 price: item.price ?? item.minPrice ?? '', 22 url: '', 23 })) 24 .filter((item) => item.name); 25 } 26 cli({ 27 site: 'ctrip', 28 name: 'search', 29 description: '搜索携程目的地、景区和酒店联想结果', 30 strategy: Strategy.PUBLIC, 31 browser: false, 32 args: [ 33 { name: 'query', required: true, positional: true, help: 'Search keyword (city or attraction)' }, 34 { name: 'limit', type: 'int', default: 15, help: 'Number of results' }, 35 ], 36 columns: ['rank', 'name', 'type', 'score', 'price', 'url'], 37 func: async (_page, kwargs) => { 38 const query = String(kwargs.query || '').trim(); 39 if (!query) { 40 throw new ArgumentError('Search keyword cannot be empty'); 41 } 42 const limit = clampLimit(kwargs.limit); 43 const response = await fetch('https://m.ctrip.com/restapi/soa2/21881/json/gaHotelSearchEngine', { 44 method: 'POST', 45 headers: { 46 'content-type': 'application/json', 47 }, 48 body: JSON.stringify({ 49 keyword: query, 50 searchType: 'D', 51 platform: 'online', 52 pageID: '102001', 53 head: { 54 Locale: 'zh-CN', 55 LocaleController: 'zh_cn', 56 Currency: 'CNY', 57 PageId: '102001', 58 clientID: 'opencli-ctrip-search', 59 group: 'ctrip', 60 Frontend: { 61 sessionID: 1, 62 pvid: 1, 63 }, 64 HotelExtension: { 65 group: 'CTRIP', 66 WebpSupport: false, 67 }, 68 }, 69 }), 70 }); 71 if (!response.ok) { 72 throw new CliError('FETCH_ERROR', `ctrip search failed with status ${response.status}`, 'Retry the command or verify ctrip.com is reachable'); 73 } 74 const payload = await response.json(); 75 const rawResults = Array.isArray(payload?.Response?.searchResults) ? payload.Response.searchResults : []; 76 const results = mapSearchResults(rawResults, limit); 77 if (!results.length) { 78 throw new EmptyResultError('ctrip search', 'Try a destination, scenic spot, or hotel keyword such as "苏州" or "朱家尖"'); 79 } 80 return results; 81 }, 82 }); 83 export const __test__ = { 84 clampLimit, 85 mapSearchResults, 86 };