/ clis / ctrip / search.js
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  };