@mattywhite/skyscanner-api 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +407 -0
- package/package.json +33 -0
- package/src/config.js +41 -0
- package/src/devicedata.json +123 -0
- package/src/errors.js +36 -0
- package/src/index.js +20 -0
- package/src/px.js +286 -0
- package/src/skyscanner.js +634 -0
- package/src/types.js +78 -0
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
const axios = require('axios');
|
|
2
|
+
const config = require('./config');
|
|
3
|
+
const PXSolver = require('./px');
|
|
4
|
+
const {
|
|
5
|
+
Location,
|
|
6
|
+
Airport,
|
|
7
|
+
CabinClass,
|
|
8
|
+
SpecialTypes,
|
|
9
|
+
SkyscannerResponse,
|
|
10
|
+
Coordinates,
|
|
11
|
+
} = require('./types');
|
|
12
|
+
const {
|
|
13
|
+
AttemptsExhaustedIncompleteResponse,
|
|
14
|
+
BannedWithCaptcha,
|
|
15
|
+
GenericError
|
|
16
|
+
} = require('./errors');
|
|
17
|
+
const crypto = require('crypto');
|
|
18
|
+
|
|
19
|
+
class SkyScanner {
|
|
20
|
+
/**
|
|
21
|
+
* A client for interacting with the Skyscanner flight and car rental APIs.
|
|
22
|
+
*
|
|
23
|
+
* @param {Object} options - Configuration options
|
|
24
|
+
* @param {string} options.locale - Locale code for results (default: "en-US")
|
|
25
|
+
* @param {string} options.currency - Currency code for pricing (default: "USD")
|
|
26
|
+
* @param {string} options.market - Market region code (default: "US")
|
|
27
|
+
* @param {number} options.retryDelay - Seconds to wait between polling retries (default: 2)
|
|
28
|
+
* @param {number} options.maxRetries - Maximum number of polling retries (default: 15)
|
|
29
|
+
* @param {string} options.proxy - Proxy URL for HTTP requests
|
|
30
|
+
* @param {string} options.pxAuthorization - Optional pre-generated PX authorization token
|
|
31
|
+
* @param {boolean} options.verify - If set to false, SSL certificates won't be verified (default: true)
|
|
32
|
+
*/
|
|
33
|
+
constructor(options = {}) {
|
|
34
|
+
this.locale = options.locale || "en-US";
|
|
35
|
+
this.currency = options.currency || "USD";
|
|
36
|
+
this.market = options.market || "US";
|
|
37
|
+
this.retryDelay = options.retryDelay || 2;
|
|
38
|
+
this.maxRetries = options.maxRetries || 15;
|
|
39
|
+
this.proxy = options.proxy || "";
|
|
40
|
+
this.verify = options.verify !== undefined ? options.verify : true;
|
|
41
|
+
|
|
42
|
+
this.pxAuthorization = null;
|
|
43
|
+
this.UUID = null;
|
|
44
|
+
|
|
45
|
+
this.initialized = false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Initialize the client (async initialization)
|
|
50
|
+
* @private
|
|
51
|
+
*/
|
|
52
|
+
async _initialize() {
|
|
53
|
+
if (this.initialized) return;
|
|
54
|
+
|
|
55
|
+
if (!this.pxAuthorization) {
|
|
56
|
+
const solver = new PXSolver(this.proxy, this.verify);
|
|
57
|
+
[this.pxAuthorization, this.UUID] = await solver.genPxAuthorization();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
this.headers = {
|
|
61
|
+
"X-Skyscanner-ChannelId": "goandroid",
|
|
62
|
+
"X-Skyscanner-Currency": this.currency,
|
|
63
|
+
"X-Skyscanner-Locale": this.locale,
|
|
64
|
+
"X-Skyscanner-Market": this.market,
|
|
65
|
+
"X-Skyscanner-Device": "Android-phone",
|
|
66
|
+
"X-Skyscanner-Device-Class": "phone",
|
|
67
|
+
"X-Skyscanner-Client-Type": "net.skyscanner.android.main",
|
|
68
|
+
"X-Skyscanner-Client-Network-Type": "WIFI",
|
|
69
|
+
"Content-Type": "application/json; charset=UTF-8",
|
|
70
|
+
"X-Px-Authorization": this.pxAuthorization,
|
|
71
|
+
"X-PX-Os": "Android",
|
|
72
|
+
"X-Px-Uuid": this.UUID,
|
|
73
|
+
"X-Px-Mobile-Sdk-Version": "3.4.4",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
this.axiosConfig = {
|
|
77
|
+
headers: this.headers,
|
|
78
|
+
proxy: this.proxy ? {
|
|
79
|
+
host: new URL(this.proxy).hostname,
|
|
80
|
+
port: new URL(this.proxy).port
|
|
81
|
+
} : false
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
this.initialized = true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Search for flight prices between two points.
|
|
89
|
+
*
|
|
90
|
+
* @param {Object} params - Search parameters
|
|
91
|
+
* @param {Airport} params.origin - Origin airport object
|
|
92
|
+
* @param {Airport|SpecialTypes} params.destination - Destination airport or special search type
|
|
93
|
+
* @param {Date|string|null} params.departDate - Departure date or special enum (default: now)
|
|
94
|
+
* @param {Date|string|null} params.returnDate - Return date or special enum (optional)
|
|
95
|
+
* @param {string} params.cabinClass - Cabin class for travel (default: ECONOMY)
|
|
96
|
+
* @param {number} params.adults - Number of adult passengers (max 8)
|
|
97
|
+
* @param {number[]} params.childAges - List of child ages (each 0-17, max 8 children)
|
|
98
|
+
* @returns {Promise<SkyscannerResponse>} Parsed response containing pricing and itinerary data
|
|
99
|
+
*/
|
|
100
|
+
async getFlightPrices({
|
|
101
|
+
origin,
|
|
102
|
+
destination,
|
|
103
|
+
departDate = null,
|
|
104
|
+
returnDate = null,
|
|
105
|
+
cabinClass = CabinClass.ECONOMY,
|
|
106
|
+
adults = 1,
|
|
107
|
+
childAges = []
|
|
108
|
+
}) {
|
|
109
|
+
await this._initialize();
|
|
110
|
+
|
|
111
|
+
if (!departDate) {
|
|
112
|
+
departDate = new Date();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Validate child ages
|
|
116
|
+
if (!childAges.every(age => age >= 0 && age < 18)) {
|
|
117
|
+
throw new Error("Child ages must be >= 0 and < 18");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Validate dates
|
|
121
|
+
if (departDate instanceof Date && returnDate instanceof Date) {
|
|
122
|
+
if (returnDate < departDate) {
|
|
123
|
+
throw new Error("Return date cannot be past departure");
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Validate passenger counts
|
|
128
|
+
if (!(adults <= 8 && childAges.length <= 8)) {
|
|
129
|
+
throw new Error("Max 8 adults and 8 children");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Validate cabin class for special searches
|
|
133
|
+
const specialValues = [SpecialTypes.ANYTIME, SpecialTypes.EVERYWHERE];
|
|
134
|
+
if ((specialValues.includes(departDate) || specialValues.includes(returnDate) ||
|
|
135
|
+
specialValues.includes(destination)) && cabinClass !== CabinClass.ECONOMY) {
|
|
136
|
+
throw new Error("To search for cabin class that's not economy enter departDate / returnDate and destination");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Validate dates are not in the past
|
|
140
|
+
const now = new Date();
|
|
141
|
+
if ((departDate instanceof Date && departDate < now) ||
|
|
142
|
+
(returnDate instanceof Date && returnDate < now)) {
|
|
143
|
+
throw new Error("Depart date or return date cannot be in the past");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const jsonData = {
|
|
147
|
+
adults: adults,
|
|
148
|
+
childAges: childAges,
|
|
149
|
+
cabinClass: cabinClass,
|
|
150
|
+
legs: [
|
|
151
|
+
this._genLeg({ departDate, origin, destination }),
|
|
152
|
+
],
|
|
153
|
+
options: null,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
if (returnDate) {
|
|
157
|
+
const returnLeg = this._genLeg({
|
|
158
|
+
departDate: returnDate,
|
|
159
|
+
origin: destination,
|
|
160
|
+
destination: origin
|
|
161
|
+
});
|
|
162
|
+
jsonData.legs.push(returnLeg);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const customHeaders = {
|
|
166
|
+
...this.headers,
|
|
167
|
+
"X-Skyscanner-Viewid": crypto.randomUUID(),
|
|
168
|
+
"Content-Type": "application/json; charset=UTF-8",
|
|
169
|
+
"Accept-Encoding": "gzip, deflate, br",
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
let req;
|
|
173
|
+
try {
|
|
174
|
+
req = await axios.post(config.UNIFIED_SEARCH_ENDPOINT, jsonData, {
|
|
175
|
+
...this.axiosConfig,
|
|
176
|
+
headers: customHeaders
|
|
177
|
+
});
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if (error.response && error.response.status === 403) {
|
|
180
|
+
throw new BannedWithCaptcha(
|
|
181
|
+
"https://www.skyscanner.net" + error.response.data.redirect_to
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let data = req.data;
|
|
188
|
+
|
|
189
|
+
if (data.context.status === "complete") {
|
|
190
|
+
return new SkyscannerResponse(
|
|
191
|
+
data,
|
|
192
|
+
this._getSessionId(data),
|
|
193
|
+
jsonData,
|
|
194
|
+
origin,
|
|
195
|
+
destination
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let retries = 0;
|
|
200
|
+
let sessionId = data.context.sessionId;
|
|
201
|
+
|
|
202
|
+
while (retries < this.maxRetries) {
|
|
203
|
+
await this._sleep(this.retryDelay * 1000);
|
|
204
|
+
const url = config.UNIFIED_SEARCH_ENDPOINT + sessionId;
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
req = await axios.get(url, {
|
|
208
|
+
...this.axiosConfig,
|
|
209
|
+
headers: customHeaders
|
|
210
|
+
});
|
|
211
|
+
} catch (error) {
|
|
212
|
+
throw new Error(`Error while scraping flight, status_code: ${error.response?.status} response: ${error.response?.data}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
data = req.data;
|
|
216
|
+
|
|
217
|
+
if (req.status !== 200) {
|
|
218
|
+
throw new Error(`Error while scraping flight, status_code: ${req.status} response: ${req.data}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (data.context.status === "complete") {
|
|
222
|
+
return new SkyscannerResponse(
|
|
223
|
+
data,
|
|
224
|
+
this._getSessionId(data),
|
|
225
|
+
jsonData,
|
|
226
|
+
origin,
|
|
227
|
+
destination
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
sessionId = data.context.sessionId;
|
|
231
|
+
retries += 1;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
throw new AttemptsExhaustedIncompleteResponse();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Auto-suggest airports based on a query string.
|
|
239
|
+
*
|
|
240
|
+
* @param {string} query - Text to search for airport names or codes
|
|
241
|
+
* @param {Date|null} departDate - Optional outbound date for context
|
|
242
|
+
* @param {Date|null} returnDate - Optional inbound date for context
|
|
243
|
+
* @returns {Promise<Airport[]>} List of suggested Airport objects
|
|
244
|
+
*/
|
|
245
|
+
async searchAirports(query, departDate = null, returnDate = null) {
|
|
246
|
+
await this._initialize();
|
|
247
|
+
|
|
248
|
+
const params = {
|
|
249
|
+
query: query,
|
|
250
|
+
inboundDate: departDate ? this._formatDate(departDate) : "",
|
|
251
|
+
outboundDate: returnDate ? this._formatDate(returnDate) : "",
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
let req;
|
|
255
|
+
try {
|
|
256
|
+
req = await axios.get(config.SEARCH_ORIGIN_ENDPOINT, {
|
|
257
|
+
...this.axiosConfig,
|
|
258
|
+
params
|
|
259
|
+
});
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (error.response && error.response.status === 403) {
|
|
262
|
+
throw new BannedWithCaptcha(
|
|
263
|
+
"https://www.skyscanner.net" + error.response.data.redirect_to
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (req.status !== 200) {
|
|
270
|
+
throw new GenericError(
|
|
271
|
+
`Error when scraping airports, code: ${req.status} text: ${req.data}`
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const data = req.data;
|
|
276
|
+
return data.inputSuggest.map(e => new Airport(
|
|
277
|
+
e.presentation.title,
|
|
278
|
+
e.navigation.entityId,
|
|
279
|
+
e.navigation.relevantFlightParams.skyId
|
|
280
|
+
));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Auto-suggest locations based on a query.
|
|
285
|
+
*
|
|
286
|
+
* @param {string} query - Text to search for locations
|
|
287
|
+
* @returns {Promise<Location[]>} List of suggested Location objects
|
|
288
|
+
*/
|
|
289
|
+
async searchLocations(query) {
|
|
290
|
+
await this._initialize();
|
|
291
|
+
|
|
292
|
+
const url = config.LOCATION_SEARCH_ENDPOINT
|
|
293
|
+
.replace('{locale}', this.locale)
|
|
294
|
+
.replace('{market}', this.market) + query;
|
|
295
|
+
|
|
296
|
+
const params = { autosuggestExp: "neighborhood_b" };
|
|
297
|
+
|
|
298
|
+
let req;
|
|
299
|
+
try {
|
|
300
|
+
req = await axios.get(url, {
|
|
301
|
+
...this.axiosConfig,
|
|
302
|
+
params
|
|
303
|
+
});
|
|
304
|
+
} catch (error) {
|
|
305
|
+
if (error.response && error.response.status === 403) {
|
|
306
|
+
throw new BannedWithCaptcha(
|
|
307
|
+
"https://www.skyscanner.net" + error.response.data.redirect_to
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
throw error;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (req.status !== 200) {
|
|
314
|
+
throw new GenericError(
|
|
315
|
+
`Error when scraping locations, code: ${req.status} text: ${req.data}`
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return req.data.map(location => new Location(
|
|
320
|
+
location.entity_name,
|
|
321
|
+
location.entity_id,
|
|
322
|
+
location.location
|
|
323
|
+
));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Retrieve a single Airport by its IATA code.
|
|
328
|
+
*
|
|
329
|
+
* @param {string} airportCode - Three-letter IATA code to look up
|
|
330
|
+
* @returns {Promise<Airport>} Matching Airport object
|
|
331
|
+
*/
|
|
332
|
+
async getAirportByCode(airportCode) {
|
|
333
|
+
const airports = await this.searchAirports(airportCode);
|
|
334
|
+
for (const airport of airports) {
|
|
335
|
+
if (airport.skyId === airportCode) {
|
|
336
|
+
return airport;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
throw new GenericError(`IATA code not found: ${airportCode}`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Retrieve detailed information for a specific flight itinerary.
|
|
344
|
+
*
|
|
345
|
+
* @param {string} itineraryId - Unique identifier for the itinerary
|
|
346
|
+
* @param {SkyscannerResponse} response - The response object from a flight search
|
|
347
|
+
* @returns {Promise<Object>} Parsed JSON response with itinerary details
|
|
348
|
+
*/
|
|
349
|
+
async getItineraryDetails(itineraryId, response) {
|
|
350
|
+
await this._initialize();
|
|
351
|
+
|
|
352
|
+
const jsonData = {
|
|
353
|
+
itineraryId: itineraryId,
|
|
354
|
+
searchSessionId: response.sessionId,
|
|
355
|
+
featuresEnabled: ["FEATURES_ENABLED_ITINERARY_LEGACY_INFO"],
|
|
356
|
+
userPreferences: {
|
|
357
|
+
market: this.market,
|
|
358
|
+
currencyCode: this.currency,
|
|
359
|
+
locale: this.locale,
|
|
360
|
+
},
|
|
361
|
+
searchRequestDetails: {
|
|
362
|
+
adults: response.searchPayload.adults,
|
|
363
|
+
cabinClass: response.searchPayload.cabinClass,
|
|
364
|
+
legs: [],
|
|
365
|
+
},
|
|
366
|
+
options: {
|
|
367
|
+
totalCostOptions: {
|
|
368
|
+
fareAttributeFilters: [
|
|
369
|
+
"ATTRIBUTE_CABIN_BAGGAGE",
|
|
370
|
+
"ATTRIBUTE_CHECKED_BAGGAGE",
|
|
371
|
+
]
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
if (response.searchPayload.childAges) {
|
|
377
|
+
jsonData.searchRequestDetails.childAges = response.searchPayload.childAges;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
for (const leg of response.searchPayload.legs) {
|
|
381
|
+
const originId = leg.legOrigin?.entityId || leg.legOrigin;
|
|
382
|
+
const destinationId = leg.legDestination?.entityId || leg.legDestination;
|
|
383
|
+
const originIata = response.origin.entityId === originId
|
|
384
|
+
? response.origin.skyId
|
|
385
|
+
: response.destination.skyId;
|
|
386
|
+
const destinationIata = response.destination.entityId === destinationId
|
|
387
|
+
? response.destination.skyId
|
|
388
|
+
: response.origin.skyId;
|
|
389
|
+
const date = leg.dates;
|
|
390
|
+
|
|
391
|
+
const res = {
|
|
392
|
+
originIata: originIata,
|
|
393
|
+
destinationIata: destinationIata,
|
|
394
|
+
date: {
|
|
395
|
+
year: date.year,
|
|
396
|
+
month: date.month,
|
|
397
|
+
day: date.day,
|
|
398
|
+
},
|
|
399
|
+
addAlternativeOrigins: false,
|
|
400
|
+
addAlternativeDestinations: false,
|
|
401
|
+
originSkyscannerCode: originIata,
|
|
402
|
+
destinationSkyscannerCode: destinationIata,
|
|
403
|
+
originEntityId: "",
|
|
404
|
+
destinationEntityId: "",
|
|
405
|
+
};
|
|
406
|
+
jsonData.searchRequestDetails.legs.push(res);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const headers = {
|
|
410
|
+
"grpc-metadata-x-skyscanner-devicedetection-istablet": "false",
|
|
411
|
+
"grpc-metadata-x-skyscanner-devicedetection-ismobile": "true",
|
|
412
|
+
"grpc-metadata-x-skyscanner-channelid": "goandroid",
|
|
413
|
+
"grpc-metadata-x-skyscanner-viewid": crypto.randomUUID(),
|
|
414
|
+
"grpc-metadata-x-skyscanner-clientid": "skyscanner_app",
|
|
415
|
+
"grpc-metadata-x-skyscanner-client-type": "net.skyscanner.android.main",
|
|
416
|
+
"grpc-metadata-skyscanner-flights-config-session-id": crypto.randomUUID(),
|
|
417
|
+
"grpc-metadata-x-skyscanner-consent-information": "true",
|
|
418
|
+
"grpc-metadata-x-skyscanner-consent-adverts": "true",
|
|
419
|
+
"content-type": "application/json; charset=utf-8",
|
|
420
|
+
"accept-encoding": "gzip",
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
let req;
|
|
424
|
+
try {
|
|
425
|
+
req = await axios.post(config.ITINERARY_DETAILS_ENDPOINT, jsonData, {
|
|
426
|
+
...this.axiosConfig,
|
|
427
|
+
headers: { ...this.headers, ...headers }
|
|
428
|
+
});
|
|
429
|
+
} catch (error) {
|
|
430
|
+
if (error.response && error.response.status === 403) {
|
|
431
|
+
throw new BannedWithCaptcha(
|
|
432
|
+
"https://www.skyscanner.net" + error.response.data.redirect_to
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
throw error;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (req.status !== 200) {
|
|
439
|
+
throw new GenericError(
|
|
440
|
+
`Error fetching itinerary details, code: ${req.status}, text: ${req.data}`
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return req.data;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Parse a Skyscanner car rental URL and fetch rental options.
|
|
449
|
+
*
|
|
450
|
+
* @param {string} url - Skyscanner car hire URL
|
|
451
|
+
* @returns {Promise<Object>} Car rental search results
|
|
452
|
+
*/
|
|
453
|
+
async getCarRentalFromUrl(url) {
|
|
454
|
+
url = url.split("?")[0];
|
|
455
|
+
const args = url.split("/");
|
|
456
|
+
|
|
457
|
+
if (args.length < 14) {
|
|
458
|
+
throw new Error("URL not valid");
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const isDriverOver25 = parseInt(args[8]) >= 25;
|
|
462
|
+
const origin = new Location("", args[9], "");
|
|
463
|
+
const destination = new Location("", args[10], "");
|
|
464
|
+
const departTime = new Date(args[11]);
|
|
465
|
+
const returnTime = new Date(args[12]);
|
|
466
|
+
|
|
467
|
+
return this.getCarRental({
|
|
468
|
+
origin,
|
|
469
|
+
departTime,
|
|
470
|
+
returnTime,
|
|
471
|
+
isDriverOver25,
|
|
472
|
+
destination
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Search for car rental options between two locations and times.
|
|
478
|
+
*
|
|
479
|
+
* @param {Object} params - Search parameters
|
|
480
|
+
* @param {Location|Coordinates|Airport} params.origin - Pickup location
|
|
481
|
+
* @param {Date} params.departTime - Pickup datetime
|
|
482
|
+
* @param {Date} params.returnTime - Drop-off datetime
|
|
483
|
+
* @param {Location|Coordinates|Airport|null} params.destination - Drop-off location (defaults to origin)
|
|
484
|
+
* @param {boolean} params.isDriverOver25 - Flag for driver age pricing threshold
|
|
485
|
+
* @returns {Promise<Object>} JSON response containing car rental group listings and metadata
|
|
486
|
+
*/
|
|
487
|
+
async getCarRental({
|
|
488
|
+
origin,
|
|
489
|
+
departTime,
|
|
490
|
+
returnTime,
|
|
491
|
+
destination = null,
|
|
492
|
+
isDriverOver25 = true
|
|
493
|
+
}) {
|
|
494
|
+
await this._initialize();
|
|
495
|
+
|
|
496
|
+
if (!destination) {
|
|
497
|
+
destination = origin;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (returnTime < departTime) {
|
|
501
|
+
throw new Error("Return time cannot be past depart time");
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const now = new Date();
|
|
505
|
+
if (returnTime < now || departTime < now) {
|
|
506
|
+
throw new Error("Return or depart time cannot be in the past");
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const firstLocation = origin instanceof Coordinates
|
|
510
|
+
? `${origin.latitude},${origin.longitude}`
|
|
511
|
+
: origin.entityId;
|
|
512
|
+
const secondLocation = destination instanceof Coordinates
|
|
513
|
+
? `${destination.latitude},${destination.longitude}`
|
|
514
|
+
: destination.entityId;
|
|
515
|
+
|
|
516
|
+
const ageValue = isDriverOver25 ? "30" : "21";
|
|
517
|
+
const firstDate = this._formatDateTime(departTime);
|
|
518
|
+
const secondDate = this._formatDateTime(returnTime);
|
|
519
|
+
|
|
520
|
+
const url = config.CAR_RENTAL_ENDPOINT
|
|
521
|
+
.replace('{first_location}', firstLocation)
|
|
522
|
+
.replace('{second_location}', secondLocation)
|
|
523
|
+
.replace('{driver_age}', ageValue)
|
|
524
|
+
.replace('{first_date}', firstDate)
|
|
525
|
+
.replace('{second_date}', secondDate)
|
|
526
|
+
.replace('{market}', this.market)
|
|
527
|
+
.replace('{currency}', this.currency)
|
|
528
|
+
.replace('{locale}', this.locale);
|
|
529
|
+
|
|
530
|
+
const params = {
|
|
531
|
+
group: "true",
|
|
532
|
+
sipp_map: "true",
|
|
533
|
+
channel: "android",
|
|
534
|
+
vndr_img_rounded: "true",
|
|
535
|
+
ranking_enable: "false",
|
|
536
|
+
reqn: "0",
|
|
537
|
+
version: "6.9",
|
|
538
|
+
include_location: "true",
|
|
539
|
+
city_search_enable: "true",
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
let lastCount = null;
|
|
543
|
+
|
|
544
|
+
for (let i = 0; i < this.maxRetries; i++) {
|
|
545
|
+
const req = await axios.get(url, {
|
|
546
|
+
...this.axiosConfig,
|
|
547
|
+
params
|
|
548
|
+
});
|
|
549
|
+
const reqData = req.data;
|
|
550
|
+
params.reqn = String(parseInt(params.reqn) + 1);
|
|
551
|
+
|
|
552
|
+
const count = reqData.groups_count;
|
|
553
|
+
if (!lastCount) {
|
|
554
|
+
lastCount = count;
|
|
555
|
+
await this._sleep(this.retryDelay * 1000);
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (count === lastCount) {
|
|
560
|
+
return reqData;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
lastCount = count;
|
|
564
|
+
await this._sleep(this.retryDelay * 1000);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
throw new AttemptsExhaustedIncompleteResponse();
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Private helper methods
|
|
571
|
+
|
|
572
|
+
_getSessionId(data) {
|
|
573
|
+
if (!data.itineraries) {
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
return data.itineraries.context.sessionId;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
_genLeg({ departDate = null, returnDate = null, origin = null, destination = null }) {
|
|
580
|
+
const res = {};
|
|
581
|
+
const date = departDate || returnDate;
|
|
582
|
+
|
|
583
|
+
if (date instanceof Date) {
|
|
584
|
+
res.dates = {
|
|
585
|
+
"@type": "date",
|
|
586
|
+
year: date.getFullYear(),
|
|
587
|
+
month: date.getMonth() + 1,
|
|
588
|
+
day: date.getDate()
|
|
589
|
+
};
|
|
590
|
+
} else {
|
|
591
|
+
res.dates = { "@type": date };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
if (origin instanceof Airport) {
|
|
595
|
+
res.legOrigin = { "@type": "entity", entityId: origin.entityId };
|
|
596
|
+
} else {
|
|
597
|
+
res.legOrigin = { "@type": origin };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (destination instanceof Airport) {
|
|
601
|
+
res.legDestination = { "@type": "entity", entityId: destination.entityId };
|
|
602
|
+
} else {
|
|
603
|
+
res.legDestination = { "@type": destination };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
res.placeOfStay = destination instanceof Airport
|
|
607
|
+
? destination.entityId
|
|
608
|
+
: origin.entityId;
|
|
609
|
+
|
|
610
|
+
return res;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
_formatDate(date) {
|
|
614
|
+
const year = date.getFullYear();
|
|
615
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
616
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
617
|
+
return `${year}-${month}-${day}`;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
_formatDateTime(date) {
|
|
621
|
+
const year = date.getFullYear();
|
|
622
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
623
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
624
|
+
const hours = String(date.getHours()).padStart(2, '0');
|
|
625
|
+
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
626
|
+
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
_sleep(ms) {
|
|
630
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
module.exports = SkyScanner;
|
package/src/types.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
class Location {
|
|
2
|
+
/**
|
|
3
|
+
* Represents an auto-suggested location from the Skyscanner API.
|
|
4
|
+
* @param {string} entityName - Human-readable name of the location
|
|
5
|
+
* @param {string} entityId - Internal Skyscanner identifier for the location
|
|
6
|
+
* @param {string} rawLocation - Latitude and longitude as comma-separated string
|
|
7
|
+
*/
|
|
8
|
+
constructor(entityName, entityId, rawLocation) {
|
|
9
|
+
this.entityName = entityName;
|
|
10
|
+
this.entityId = entityId;
|
|
11
|
+
this.location = rawLocation.split(',');
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
class Airport {
|
|
16
|
+
/**
|
|
17
|
+
* Represents an airport in the Skyscanner system.
|
|
18
|
+
* @param {string} title - The display name of the airport (e.g. "London Heathrow")
|
|
19
|
+
* @param {string} entityId - The internal Skyscanner entity ID for the airport
|
|
20
|
+
* @param {string} skyId - The IATA-style Skyscanner identifier (used in search requests)
|
|
21
|
+
*/
|
|
22
|
+
constructor(title, entityId, skyId) {
|
|
23
|
+
this.title = title;
|
|
24
|
+
this.entityId = entityId;
|
|
25
|
+
this.skyId = skyId;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class SkyscannerResponse {
|
|
30
|
+
/**
|
|
31
|
+
* Represents the response from a Skyscanner flight search.
|
|
32
|
+
* @param {Object} json - The raw JSON response returned by Skyscanner
|
|
33
|
+
* @param {string} sessionId - The unique session identifier associated with the search
|
|
34
|
+
* @param {Object} searchPayload - The original payload used to perform the search request
|
|
35
|
+
* @param {Airport} origin - The airport from which the search was initiated
|
|
36
|
+
* @param {Airport} destination - The destination airport, if specified
|
|
37
|
+
*/
|
|
38
|
+
constructor(json, sessionId, searchPayload, origin, destination = null) {
|
|
39
|
+
this.json = json;
|
|
40
|
+
this.sessionId = sessionId;
|
|
41
|
+
this.searchPayload = searchPayload;
|
|
42
|
+
this.origin = origin;
|
|
43
|
+
this.destination = destination;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
class Coordinates {
|
|
48
|
+
/**
|
|
49
|
+
* Represents geographic coordinates.
|
|
50
|
+
* @param {number} latitude - Latitude coordinate
|
|
51
|
+
* @param {number} longitude - Longitude coordinate
|
|
52
|
+
*/
|
|
53
|
+
constructor(latitude, longitude) {
|
|
54
|
+
this.latitude = latitude;
|
|
55
|
+
this.longitude = longitude;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const CabinClass = {
|
|
60
|
+
ECONOMY: "economy",
|
|
61
|
+
PREMIUM_ECONOMY: "premium_economy",
|
|
62
|
+
BUSINESS: "business",
|
|
63
|
+
FIRST: "first"
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const SpecialTypes = {
|
|
67
|
+
ANYTIME: "anytime",
|
|
68
|
+
EVERYWHERE: "everywhere"
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
module.exports = {
|
|
72
|
+
Location,
|
|
73
|
+
Airport,
|
|
74
|
+
SkyscannerResponse,
|
|
75
|
+
Coordinates,
|
|
76
|
+
CabinClass,
|
|
77
|
+
SpecialTypes
|
|
78
|
+
};
|