import { Constants } from './lib/constants'; import { IFlightArray, IFlight, ITrip, ITripFull, IFee, AVAILABLE_IN, FEE_TYPES, FEE_GROUPS, } from './model/flights'; import { FlightsNotFound } from './lib/errors/FlightsNotFound'; import { load } from 'cheerio'; import * as moment from 'moment'; import crypto = require('crypto'); type Values = { price: number, miles: number, }; export class Parser { private static readonly OUTBOUND_STR = 'outbound'; private static readonly INBOUND_STR = 'inbound'; private static readonly OUTBOUND_IDX = 0; private static readonly INBOUND_IDX = 1; public static readonly AIRLINE = 'avianca'; public static readonly FORMAT_CONTENT = 'MMM D, YYYY h:mm:ss A'; protected $: any; protected cube: any; protected recommendationList: any; protected proposedBounds: any; public parse( content: string, tripType: string, cabin: string, adults: number, children: number, infants: number, toCountry: string, ): IFlightArray { this.$ = load(content); const { cube, recommendationList, proposedBounds } = this.extractJSON(content); this.cube = cube; this.recommendationList = recommendationList; this.proposedBounds = proposedBounds; const outboundFlights = this.getFligths( Parser.OUTBOUND_STR, Parser.OUTBOUND_IDX, tripType, cabin, adults, children, infants, toCountry, ); let flights = [].concat(outboundFlights); if (tripType === Constants.ROUND_TRIP) { const inboundFlights = this.getFligths( Parser.INBOUND_STR, Parser.INBOUND_IDX, tripType, cabin, adults, children, infants, toCountry, ); flights = flights.concat(inboundFlights); } return flights; } public static parseDateTime(dateTime: string): string { return moment.utc(dateTime, Parser.FORMAT_CONTENT).format(); } public static getFlightNumber(airlineCode: string, flightNumber: string): string { return `${airlineCode}${flightNumber}`; } private getCabin(segment: any, cabin: string): string { if (segment.beginLocation.countryCode === 'BR' && segment.endLocation.countryCode === 'BR') { return Constants.ECONOMIC; } return cabin === Constants.EXECUTIVE ? Constants.EXECUTIVE : Constants.ECONOMIC; } private getTrips(segments: any, cabin: string): ITripFull[] { let prevArrivalDate: string = ''; const trips: ITripFull[] = segments.map((segment: any, i: number) => { const departureDate = Parser.parseDateTime(segment.beginDate); const arrivalDate = Parser.parseDateTime(segment.endDate); const trip: ITripFull = { departureDate, arrivalDate, from: segment.beginLocation.locationCode, to: segment.endLocation.locationCode, carrier: segment.opAirline.name || segment.airline.name, flightNumber: Parser.getFlightNumber(segment.airline.code, segment.flightNumber), aircraft: segment.equipment.name, duration: moment(arrivalDate).diff(departureDate, 'minutes'), flightTime: segment.flightTime, layover: (i === 0) ? 0 : moment(departureDate).diff(prevArrivalDate, 'minutes'), stops: segment.listLegs ? segment.listLegs.length - 1 : 0, cabin: this.getCabin(segment, cabin), fromCountry: segment.beginLocation.countryCode, toCountry: segment.endLocation.countryCode, }; prevArrivalDate = trip.arrivalDate; return trip; }); return trips; } private castTrips(tripsFull: ITripFull[]): ITrip[] { return tripsFull.map((elem: ITripFull) => { return { from: elem.from, to: elem.to, departureDate: elem.departureDate, arrivalDate: elem.arrivalDate, carrier: elem.carrier, flightNumber: elem.flightNumber, aircraft: elem.aircraft, duration: elem.duration, layover: elem.layover, stops: elem.stops, cabin: elem.cabin, }; }); } private getProposedBoundIndex(directionIdx: number, flightId: number) { const proposedBounds = this.proposedBounds[directionIdx]; let proposedBoundIndex = 0; while (proposedBounds.proposedFlightsGroup[proposedBoundIndex].proposedBoundId !== flightId) { proposedBoundIndex = proposedBoundIndex + 1; } return proposedBoundIndex; } private calculateValuesPerChild(bound: any, children: number): Values { let price = 0; let miles = 0; if (children > 0) { if (bound.travellerPrices['CHD']) { const travellerPrice = bound.travellerPrices['CHD']; const { tax, serviceFee } = bound.boundAmount; const priceWithouTaxAndFee = (travellerPrice - tax - serviceFee); price = Math.trunc(priceWithouTaxAndFee * 100) / 100; miles = ((Math.round(travellerPrice / 10) * 10) * 40); } } return { price, miles }; } private calculateValuesPerInfant( bound: any, infants: number, pricePerAdult: number, milesPerAdult: number, toCountry: string, ): { valuesPerInfant: Values, boardingTaxInfant: number, } { let price = 0; let miles = 0; let boardingTaxInfant = 0; if (infants > 0) { if (bound.travellerPrices['INF']) { const travellerPrice = Math.trunc(bound.travellerPrices['INF'] * 100) / 100; if (toCountry.toUpperCase() === 'BR') { price = travellerPrice; miles = ((Math.ceil(price / 10) * 10) * 60); } else { price = (Math.round((pricePerAdult * 0.1) * 100) / 100); miles = (Math.round((milesPerAdult * 0.1) / 100) * 100); boardingTaxInfant = Number((travellerPrice - price).toFixed(2)); } } } return { boardingTaxInfant, valuesPerInfant: { price, miles }, }; } private getValuesPerAdult( bestRecommendationIndex: number, directionIdx: number, ): { valuesPerAdult: Values, familyCode: string, boardingTax: number, convenienceFee: number, } { const recommendation = this.recommendationList[bestRecommendationIndex]; const bounds = recommendation.bounds; const boundIndex = (bounds.length === 1) ? 0 : directionIdx; const bound = bounds[boundIndex]; return { valuesPerAdult: { price: bound.boundAmount.amountWithoutTax, miles: bound.boundAmount.milesCost, }, familyCode: (bounds.length === 1) ? recommendation.ffCode : bound.ffCode, boardingTax: bound.boundAmount.tax, convenienceFee: bound.boundAmount.serviceFee, }; } public static calculateValuesTotal( valuesPerAdult: Values, valuesPerChild: Values, valuesPerInfant: Values, adults: number, children: number, infants: number, ): Values { const pricePerAdult = this.getTotal(valuesPerAdult.price, adults); const pricePerChild = this.getTotal(valuesPerChild.price, children); const pricePerInfant = this.getTotal(valuesPerInfant.price, infants); const price = Number((pricePerAdult + pricePerChild + pricePerInfant).toFixed(2)); const milesPerAdult = this.getTotal(valuesPerAdult.miles, adults); const milesPerChild = this.getTotal(valuesPerChild.miles, children); const milesPerInfant = this.getTotal(valuesPerInfant.miles, infants); const miles = Number((milesPerAdult + milesPerChild + milesPerInfant).toFixed(0)); return { price, miles }; } public static getTotal(value: number = 0, quantity: number = 0): number { return Math.round((value * quantity) * 100) / 100; } private calculateFlightDuration(tripsFull: ITripFull[]): number { const flightTime = tripsFull[tripsFull.length - 1].flightTime; return flightTime ? moment.duration(flightTime).asMinutes() : tripsFull.reduce((sum, e) => sum + e.duration + e.layover, 0); } public static getFlightId(airline: string, flightNumber: string, trips: ITrip[]) { const tripsHash = trips.reduce((hash, t) => `${hash};${t.flightNumber}_${t.cabin}`, ''); const hash = `${airline}_${flightNumber}_${tripsHash}`; return crypto.createHash('md5').update(hash).digest('hex'); } public static getFees( adults: number = 0, children: number = 0, infants: number = 0, boardingTaxAdult: number = 0, boardingTaxInfant: number = 0, convenienceFee: number = 0, ): IFee[] { const quantityAdults: number = (adults * 1) + (children * 1); const fees: IFee[] = []; if ((boardingTaxAdult > 0) || (boardingTaxInfant > 0)) { const boardingTaxTotal = ((boardingTaxAdult * quantityAdults) + (boardingTaxInfant * infants)); fees.push({ type: FEE_TYPES.BOARDING_TAX, group: FEE_GROUPS.PRICE, value: Number(boardingTaxTotal.toFixed(2)), }); } if (convenienceFee > 0) { const convenienceFeeTotal = (convenienceFee * quantityAdults); fees.push({ type: FEE_TYPES.CONVENIENCE_FEE, group: FEE_GROUPS.PRICE, value: Number(convenienceFeeTotal.toFixed(2)), }); } return fees; } private getFlight( directionStr: string, directionIdx: number, tripTypeFlight: string, cabin: string, recommendationIndex: number, flightId: number, adults: number, children: number, infants: number, toCountry: string, ): IFlight { /* O relacionamento entre os arrays é feito da seguinte maneira: - O array "cube" aponta para o array "recommendationList" através do campo bestRecommendationIndex, que é o index do array, ou seja, recommendationList[bestRecomendationIndex]; - O array "recommendationList" aponta para o array "proposedBounds" através do campo bounds[].flightGroupList[].flightId, ou seja, recommendationList[recommendationIndex].bounds[0].flightGroupList[0].flightId; - O array "proposedBounds" contém o array "proposedFlightsGroup" que contém o "proposedBoundId", que é o relacionamento com o "flightId" do recommendationList, ou seja, "flightId === proposedBoundId"; */ const proposedBoundIndex = this.getProposedBoundIndex(directionIdx, flightId); const proposedFlightsGroup = this.proposedBounds[directionIdx] .proposedFlightsGroup[proposedBoundIndex]; const tripsFull = this.getTrips(proposedFlightsGroup.segments, cabin); const tripsFlight = this.castTrips(tripsFull); const { valuesPerAdult, familyCode, boardingTax, convenienceFee } = this .getValuesPerAdult(recommendationIndex, directionIdx); const recommendation = this.recommendationList[recommendationIndex]; const bounds = recommendation.bounds; const boundIndex = (bounds.length === 1) ? 0 : directionIdx; const bound = bounds[boundIndex]; const valuesPerChild = this.calculateValuesPerChild(bound, children); const { valuesPerInfant, boardingTaxInfant } = this.calculateValuesPerInfant( bound, infants, valuesPerAdult.price, valuesPerAdult.miles, toCountry, ); const valuesTotal = Parser.calculateValuesTotal( valuesPerAdult, valuesPerChild, valuesPerInfant, adults, children, infants, ); const airline = Parser.AIRLINE; const emptyLuggage = { carryOn: { weight: null, prices: [], }, checked: { weight: null, prices: [], }, }; const ret: IFlight = { airline, familyCode, from: tripsFlight[0].from, to: tripsFlight[tripsFlight.length - 1].to, flightNumber: tripsFlight[0].flightNumber, airlinePricePerAdult: valuesPerAdult.price, airlinePricePerChild: valuesPerChild.price, airlinePricePerInfant: valuesPerInfant.price, airlinePriceTotal: valuesTotal.price, milesPerAdult: valuesPerAdult.miles, milesPerChild: valuesPerChild.miles, milesPerInfant: valuesPerInfant.miles, milesTotal: valuesTotal.miles, cabin: tripsFlight[0].cabin, carrier: tripsFlight[0].carrier, duration: this.calculateFlightDuration(tripsFull), departureDate: tripsFlight[0].departureDate, arrivalDate: tripsFlight[tripsFlight.length - 1].arrivalDate, fromCountry: tripsFull[0].fromCountry, toCountry: tripsFull[tripsFlight.length - 1].toCountry, direction: directionStr, stops: tripsFlight.reduce((stops, trip) => stops + trip.stops + 1, -1), tripType: tripTypeFlight, availableIn: AVAILABLE_IN.BOTH, airlineTarget: airline, providerMiles: airline, flightId: Parser.getFlightId(airline, tripsFlight[0].flightNumber, tripsFlight), fees: Parser.getFees( adults, children, infants, boardingTax, boardingTaxInfant, convenienceFee, ), trips: tripsFlight, luggage: emptyLuggage, milesLuggage: emptyLuggage, }; return ret; } private concatFields(flight: never): string { return (flight['direction'] === 'outbound' ? '1' : '2') + flight['flightNumber'] + (`000000000000000${flight['airlinePricePerAdult']}`).slice(-15); } private getFligths( directionStr: string, directionIdx: number, tripType: string, cabin: string, adults: number, children: number, infants: number, toCountry: string, ): any { const fareFamilyList = this.cube.bounds[directionIdx].fareFamilyList; const flights = fareFamilyList.map((fareFamily: any) => { const keys = Object.keys(fareFamily.flights); return keys.map((key: any) => { return this.getFlight( directionStr, directionIdx, tripType, cabin, fareFamily.flights[key].bestRecommendationIndex, fareFamily.flights[key].flight.flightId, adults, children, infants, toCountry, ); }); }); return [].concat(...flights) .sort((a, b) => { const compareA = this.concatFields(a); const compareB = this.concatFields(b); return (compareA > compareB) ? 1 : (compareA < compareB) ? -1 : 0; }) .filter((e, i, arr) => (i === 0) || (e['flightNumber'] !== arr[i - 1]['flightNumber'])); } public extractJSON(content: string): { cube: any, recommendationList: any, proposedBounds: any, airportRoutes: any, multiAirportCity: any, } { let cube: any = {}; let recommendationList: any = {}; let proposedBounds: any = {}; let airportRoutes: any = {}; let multiAirportCity: any = {}; const matches: null | string[] = content .match(/PlnextPageProvider\.init\(((.|\n)+)(\)\s+?\}\);)/); if (!Array.isArray(matches) || matches.length < 3) { throw new FlightsNotFound('A consulta não retornou dados'); } const str = matches[1] .replace('config :', '"config":') .replace('pageEngine : pageEngine', '"pageEngine":"pageEngine"') .replace('sessionId :', '"sessionId":') .replace('onePageNavEnabled :', '"onePageNavEnabled":'); const json = JSON.parse(str); if ( typeof json.config !== 'object' || typeof json.config.pageDefinitionConfig !== 'object' || typeof json.config.pageDefinitionConfig.pageData !== 'object' || typeof json.config.pageDefinitionConfig.pageData.business !== 'object' || typeof json.config.pageDefinitionConfig.pageData.business.Availability !== 'object' || typeof json.config.pageDefinitionConfig.pageData.business.Availability.cube !== 'object' || typeof json.config.pageDefinitionConfig.pageData.business .Availability.recommendationList !== 'object' || typeof json.config.pageDefinitionConfig.pageData.business .Availability.proposedBounds !== 'object' ) { if ( typeof json.config !== 'undefined' && typeof json.config.pageDefinitionConfig !== 'undefined' && typeof json.config.pageDefinitionConfig.pageData !== 'undefined' && typeof json.config.pageDefinitionConfig.pageData.errorList !== 'undefined' && typeof json.config.pageDefinitionConfig.pageData.errorList.globalErrors !== 'undefined' && typeof json.config.pageDefinitionConfig.pageData.errorList.globalErrors.E !== 'undefined' && json.config.pageDefinitionConfig.pageData.errorList.globalErrors.E.length > 0 ) { const E = json.config.pageDefinitionConfig.pageData.errorList.globalErrors.E; if (E[0].code === '66002') { /** A Avianca retorna links redirecionando para a o site da própria Avianca * * N�o foi poss�vel encontrar voos para as datas desejadas. Por favor, * \u003ca href\u003d\"http://www.avianca.com.br\"\u003eclique aqui\u003c/a\u003e * para alterar sua busca ou entre em contato com nossa * \u003ca href\u003d\"http://www.avianca.com.br/fale_conosco\"\u003eCentral de * Vendas\u003c/a\u003e. (66002 [977]) * */ throw new FlightsNotFound('Não foi possível encontrar voos para as datas desejadas'); } if (E[0].code === '10031') { throw new FlightsNotFound('A data da partida solicitada não está correta'); } } throw new FlightsNotFound('Sem disponibilidade para o período informado'); } cube = json.config.pageDefinitionConfig.pageData.business.Availability.cube; recommendationList = json.config.pageDefinitionConfig.pageData .business.Availability.recommendationList; proposedBounds = json.config.pageDefinitionConfig.pageData.business.Availability.proposedBounds; airportRoutes = json.config.pageDefinitionConfig.pageData.globalLists.SITE_AIRPORT_ROUTES; multiAirportCity = json.config.pageDefinitionConfig.pageData .globalLists.NO_SL_MULTI_AIRPORT_CITY; return { cube, recommendationList, proposedBounds, airportRoutes, multiAirportCity }; } }