diff --git a/client/package-lock.json b/client/package-lock.json index daff5006..e5c98c90 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,6 +1,6 @@ { "name": "maxplorateur-client", - "version": "1.8.0", + "version": "1.9.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/client/package.json b/client/package.json index 08c704b5..28347483 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "maxplorateur-client", - "version": "1.8.0", + "version": "1.9.0", "private": true, "scripts": { "serve": "node_modules/.bin/vue-cli-service serve", diff --git a/server/package-lock.json b/server/package-lock.json index bac3f2a9..4e2ec148 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,6 +1,6 @@ { "name": "maxplorateur-server", - "version": "1.8.0", + "version": "1.9.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/server/package.json b/server/package.json index a5648196..e3a03f3e 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "maxplorateur-server", - "version": "1.8.0", + "version": "1.9.0", "description": "find a tgvmax seat", "scripts": { "clean": "rm -fR ./node_modules ./.nyc_output ./coverage ./dist", diff --git a/server/src/Config.ts b/server/src/Config.ts index ec6e9170..27743fbb 100644 --- a/server/src/Config.ts +++ b/server/src/Config.ts @@ -86,9 +86,14 @@ export class Config { public disableCronCheck: boolean; /** - * disable trainline calls + * zeit username */ - public disableTrainline: number; + public zeitUsername: string | undefined; + + /** + * zeit username + */ + public zeitPassword: string | undefined; constructor() { /* tslint:disable */ @@ -111,7 +116,8 @@ export class Config { this.disableCronCheck = isNil(process.env.DISABLE_CRON_CHECK) ? config.get('disableCronCheck') : process.env.DISABLE_CRON_CHECK === 'true'; - this.disableTrainline = 2; + this.zeitUsername = process.env.ZEIT_USERNAME; + this.zeitPassword = process.env.ZEIT_PASSWORD; } private getWhitelist = (): string => { diff --git a/server/src/core/CronChecks.ts b/server/src/core/CronChecks.ts index 4c7bd46e..44a058c0 100644 --- a/server/src/core/CronChecks.ts +++ b/server/src/core/CronChecks.ts @@ -5,9 +5,9 @@ import * as cron from 'node-cron'; import Config from '../Config'; import Notification from '../core/Notification'; import Database from '../database/database'; -import { IAvailability, ITravelAlert, IUser } from '../types'; -import { SncfMobile } from './SncfMobile'; -import { Trainline } from './Trainline'; +import { IAvailability, IConnector, IConnectorParams, ITravelAlert, IUser } from '../types'; +import Sncf from './connectors/Sncf'; +import Zeit from './connectors/Zeit'; /** * Periodically check Tgvmax availability @@ -18,6 +18,39 @@ import { Trainline } from './Trainline'; * if NO -> update lastCheck to current time and continue */ class CronChecks { + + /** + * connectors + */ + private readonly connectors: IConnector[]; + + constructor() { + this.connectors = [ + { + name: 'Zeit', + weight: 30, + async isTgvmaxAvailable({ + origin, destination, fromTime, toTime, tgvmaxNumber, + }: IConnectorParams): Promise { + console.log(`${moment(new Date()).tz('Europe/Paris').format('DD-MM-YYYY HH:mm:ss')} - using zeit connector`); // tslint:disable-line + + return Zeit.isTgvmaxAvailable({ origin, destination, fromTime, toTime, tgvmaxNumber }); + }, + }, + { + name: 'Sncf', + weight: 70, + async isTgvmaxAvailable({ + origin, destination, fromTime, toTime, tgvmaxNumber, + }: IConnectorParams): Promise { + console.log(`${moment(new Date()).tz('Europe/Paris').format('DD-MM-YYYY HH:mm:ss')} - using sncf connector`); // tslint:disable-line + + return Sncf.isTgvmaxAvailable({ origin, destination, fromTime, toTime, tgvmaxNumber }); + }, + }, + ]; + } + /** * init CronJob */ @@ -35,35 +68,20 @@ class CronChecks { * Send notification if tgvmax seat is available */ for (const travelAlert of travelAlerts) { - let availability: IAvailability; - /** - * split load on trainline and sncf mobile APIs - */ - if (random(0, 1) === Config.disableTrainline) { - console.log(`${moment(new Date()).tz('Europe/Paris').format('DD-MM-YYYY HH:mm:ss')} - processing travelAlert ${travelAlert._id} - trainline API`); // tslint:disable-line - const trainline: Trainline = new Trainline( - travelAlert.origin.trainlineId, - travelAlert.destination.trainlineId, - travelAlert.fromTime, - travelAlert.toTime, - travelAlert.tgvmaxNumber, - ); - availability = await trainline.isTgvmaxAvailable(); - } else { - console.log(`${moment(new Date()).tz('Europe/Paris').format('DD-MM-YYYY HH:mm:ss')} - processing travelAlert ${travelAlert._id} - sncf API`); // tslint:disable-line - const sncfMobile: SncfMobile = new SncfMobile( - travelAlert.origin.sncfId, - travelAlert.destination.sncfId, - travelAlert.fromTime, - travelAlert.toTime, - travelAlert.tgvmaxNumber, - ); - availability = await sncfMobile.isTgvmaxAvailable(); - } + const selectedConnector: IConnector = this.getConnector(); + const availability: IAvailability = await selectedConnector.isTgvmaxAvailable({ // tslint:disable-line + origin: travelAlert.origin, + destination: travelAlert.destination, + fromTime: travelAlert.fromTime, + toTime: travelAlert.toTime, + tgvmaxNumber: travelAlert.tgvmaxNumber, + }); if (!availability.isTgvmaxAvailable) { - await Database.updateOne('alerts', {_id: new ObjectId(travelAlert._id)}, {$set: {lastCheck: new Date()}}); + await Database.updateOne( + 'alerts', { _id: new ObjectId(travelAlert._id)}, { $set: { lastCheck: new Date() }, + }); await this.delay(Config.delay); continue; } @@ -95,6 +113,20 @@ class CronChecks { }); } + /** + * select the connector that will process tgvmax availability + */ + private readonly getConnector = (): IConnector => { + const MAX_WEIGHT: number = 100; + let rand: number = random(0, MAX_WEIGHT); + for (const connector of this.connectors) { + rand = rand - connector.weight; + if (rand <= 0) { return connector; } + } + + return this.connectors[this.connectors.length - 1]; + } + /** * fetch all pending travelAlert in database */ diff --git a/server/src/core/connectors/Sncf.ts b/server/src/core/connectors/Sncf.ts new file mode 100644 index 00000000..b8011eb2 --- /dev/null +++ b/server/src/core/connectors/Sncf.ts @@ -0,0 +1,157 @@ +import Axios, { AxiosRequestConfig, AxiosResponse } from 'axios'; +import * as httpsProxyAgent from 'https-proxy-agent'; +import { filter, get, isEmpty, isNil, map, random, uniq } from 'lodash'; +import * as moment from 'moment-timezone'; +import Config from '../../Config'; +import { IAvailability, IConnectorParams, ISncfMobileTrain } from '../../types'; + +/** + * Sncf connector + */ +class Sncf { + /** + * connector generic function + */ + public async isTgvmaxAvailable({ + origin, destination, fromTime, toTime, tgvmaxNumber, + }: IConnectorParams): Promise { + const tgvmaxHours: string[] = await this.getTgvmaxHours({ + origin, destination, fromTime, toTime, tgvmaxNumber, + }); + + /** + * If previous call returns an empty array, there is no TGVmax available + */ + return isEmpty(tgvmaxHours) + ? { isTgvmaxAvailable: false, hours: [] } + : { isTgvmaxAvailable: true, hours: uniq(tgvmaxHours) }; + } + + /** + * get data from sncf api + */ + private readonly getTgvmaxHours = async({ + origin, destination, fromTime, toTime, tgvmaxNumber, + }: IConnectorParams): Promise => { + const results: ISncfMobileTrain[] = []; + let keepSearching: boolean = true; + let departureMinTime: string = moment(fromTime).tz('Europe/Paris').format('YYYY-MM-DD[T]HH:mm:ss'); + const departureMaxTime: string = moment(toTime).tz('Europe/Paris').format('YYYY-MM-DD[T]HH:mm:ss'); + + try { + while (keepSearching) { + const config: AxiosRequestConfig = { + url: `${Config.baseSncfMobileUrl}/m700/vmd/maq/v3/proposals/train`, + method: 'POST', + headers: { + Accept: 'application/json', + 'User-Agent': 'OUI.sncf/65.1.1 CFNetwork/1107.1 Darwin/19.0.0', + 'Accept-Language': 'fr-FR ', + 'Content-Type': 'application/json;charset=UTF8', + Host: 'wshoraires.oui.sncf', + 'x-vsc-locale': 'fr_FR', + 'X-Device-Type': 'IOS', + }, + data: { + departureTown: { + codes: { + resarail: origin.sncfId, + }, + }, + destinationTown: { + codes: { + resarail: destination.sncfId, + }, + }, + features: [ + 'TRAIN_AND_BUS', + 'DIRECT_TRAVEL', + ], + outwardDate: moment(departureMinTime).format('YYYY-MM-DD[T]HH:mm:ss.SSSZ'), + passengers: [ + { + age: 25, // random + ageRank: 'YOUNG', + birthday: '1995-03-06', // random + commercialCard: { + number: tgvmaxNumber, + type: 'HAPPY_CARD', + }, + type: 'HUMAN', + }, + ], + travelClass: 'SECOND', + }, + }; + + /** + * split load between multiple servers + */ + if (process.env.NODE_ENV === 'production' && !isNil(Config.proxyUrl) && random(0, 1) === 0) { + config.httpsAgent = new httpsProxyAgent(Config.proxyUrl); + } + + /** + * interceptor for handling sncf 200 ok that should be 500 or 301 + */ + Axios.interceptors.response.use(async(res: AxiosResponse) => { + const data: {exceptionType?: string} = res.data as {exceptionType?: string}; + if (!isNil(data.exceptionType)) { + return Promise.reject({ + response: { + status: 500, + statusText: data.exceptionType, + }, + }); + } + + return res; + }); + + /** + * get data from oui.sncf + */ + const response: AxiosResponse = await Axios.request(config); + + const pageResults: {journeys: ISncfMobileTrain[]} = response.data as {journeys: ISncfMobileTrain[]}; + const pageJourneys: ISncfMobileTrain[] = pageResults.journeys; + + results.push(...pageJourneys); + + const pageLastTripDeparture: string = moment(pageJourneys[pageJourneys.length - 1].departureDate) + .tz('Europe/Paris').format('YYYY-MM-DD[T]HH:mm:ss'); + + if (moment(departureMaxTime).isSameOrBefore(pageLastTripDeparture) + || moment(departureMinTime).isSame(pageLastTripDeparture)) { + keepSearching = false; + } + + departureMinTime = pageLastTripDeparture; + } + } catch (error) { + const status: number = get(error, 'response.status', ''); // tslint:disable-line + const statusText: string = get(error, 'response.statusText', ''); // tslint:disable-line + const label: string = get(error, 'response.data.label', ''); // tslint:disable-line + console.log(`SNCF API ERROR : ${status} ${statusText} ${label}`); // tslint:disable-line + } + + /** + * 1/ filter out trains with no TGVmax seat available + * 2/ filter out trains leaving after toTime + */ + const tgvmaxTravels: ISncfMobileTrain[] = filter(results, (item: ISncfMobileTrain) => { + const departureDate: string = moment(item.departureDate).tz('Europe/Paris').format('YYYY-MM-DD[T]HH:mm:ss'); + + return isNil(item.unsellableReason) + && item.price.value === 0 + && moment(departureDate).isSameOrBefore(departureMaxTime); + }); + + return map(tgvmaxTravels, (tgvmaxTravel: ISncfMobileTrain) => { + return moment(tgvmaxTravel.departureDate).tz('Europe/Paris').format('HH:mm'); + }); + } + +} + +export default new Sncf(); diff --git a/server/src/core/connectors/Trainline.ts b/server/src/core/connectors/Trainline.ts new file mode 100644 index 00000000..9224bd06 --- /dev/null +++ b/server/src/core/connectors/Trainline.ts @@ -0,0 +1,172 @@ +import Axios, { AxiosRequestConfig, AxiosResponse } from 'axios'; +import * as httpsProxyAgent from 'https-proxy-agent'; +import { filter, isEmpty, isNil, map, random, uniq } from 'lodash'; +import * as moment from 'moment-timezone'; +import * as uuidv4 from 'uuid/v4'; +import Config from '../../Config'; +import { IAvailability, ITrainlineTrain } from '../../types'; +/** + * Trainline + * Fetch Tgvmax availabilities from trainline.fr + */ +export class Trainline { + /** + * departure station + */ + private readonly origin: string; + + /** + * destination station + */ + private readonly destination: string; + + /** + * earliest train on which we want to set up an alert + */ + private readonly fromTime: string; + + /** + * latest train on which we want to set up an alert + */ + private readonly toTime: string; + + /** + * TGVmax number + */ + private readonly tgvmaxNumber: string; + + constructor(origin: string, destination: string, fromTime: Date, toTime: Date, tgvmaxNumber: string) { + this.origin = origin; + this.destination = destination; + this.fromTime = moment(fromTime).tz('Europe/Paris').format('YYYY-MM-DD[T]HH:mm:ss'); + this.toTime = moment(toTime).tz('Europe/Paris').format('YYYY-MM-DD[T]HH:mm:ss'); + this.tgvmaxNumber = tgvmaxNumber; + } + + /** + * Check if there is a tgvmax seat available on a train : + * - leaving train station : origin + * - going to train station : destination + * - leaving between fromTime and toTime + */ + public async isTgvmaxAvailable(): Promise { + const tgvmaxHours: string[] = await this.getTgvmaxHours(); + /** + * If previous call returns an empty array, there is no TGVmax available + */ + if (isEmpty(tgvmaxHours)) { + return { + isTgvmaxAvailable: false, + hours: [], + }; + } + + return { + isTgvmaxAvailable: true, + hours: uniq(tgvmaxHours), + }; + } + + /** + * Get Train with a TGVmax seat available from Trainline API + */ + private async getTgvmaxHours(): Promise { + + const results: ITrainlineTrain[] = []; + let keepSearching: boolean = true; + let fromTime: string = this.fromTime; + + try { + while (keepSearching) { + const config: AxiosRequestConfig = { + url: `${Config.baseTrainlineUrl}/api/v5_1/search`, + method: 'POST', + headers: { + Accept: 'application/json', + 'User-Agent': 'CaptainTrain/5221(d109181b0) (iPhone8,4; iOS 13.1.2; Scale/2.00)', + 'Accept-Language': 'fr', + 'Content-Type': 'application/json; charset=UTF-8', + Host: 'www.trainline.eu', + }, + data: { + local_currency: 'EUR', + search: { + passengers: [ + { + age: 25, // random + id: uuidv4(), // random uuid + label: uuidv4(), // random uuid + cards: [{ + reference: 'SNCF.HappyCard', + number: this.tgvmaxNumber, + }], + }, + ], + departure_station_id: this.origin, + arrival_station_id: this.destination, + departure_date: this.getTrainlineDate(fromTime), + systems: [ + 'sncf', + ], + }, + }, + }; + + /** + * split load between multiple servers + */ + if (process.env.NODE_ENV === 'production' && !isNil(Config.proxyUrl) && random(0, 1) === 0) { + config.httpsAgent = new httpsProxyAgent(Config.proxyUrl); + } + + /** + * get data from trainline + */ + const response: AxiosResponse = await Axios.request(config); + + const pageResults: {trips: ITrainlineTrain[]} = response.data as {trips: ITrainlineTrain[]}; + const pageTrips: ITrainlineTrain[] = pageResults.trips; + + results.push(...pageTrips); + + const pageLastTripDeparture: string = moment(pageTrips[pageTrips.length - 1].departure_date) + .tz('Europe/Paris').format('YYYY-MM-DD[T]HH:mm:ss'); + + if (moment(this.toTime).isSameOrBefore(pageLastTripDeparture) + || moment(fromTime).isSame(pageLastTripDeparture)) { + keepSearching = false; + } + + fromTime = pageLastTripDeparture; + } + } catch (error) { + console.log(`TRAINLINE API ERROR : ${error.response.status} ${error.response.statusText} | ${JSON.stringify(error.response.data.error, null, 2)}`); // tslint:disable-line + } + + /** + * 1/ filter out trains with no TGVmax seat available + * 2/ filter out trains leaving after toTime + */ + const tgvmaxTravels: ITrainlineTrain[] = filter(results, (item: ITrainlineTrain) => { + const departureDate: string = moment(item.departure_date).tz('Europe/Paris').format('YYYY-MM-DD[T]HH:mm:ss'); + + return item.cents === 0 + && moment(departureDate).isSameOrBefore(this.toTime) + && isNil(item.short_unsellable_reason); + }); + + return map(tgvmaxTravels, (tgvmaxTravel: ITrainlineTrain) => { + return moment(tgvmaxTravel.departure_date).tz('Europe/Paris').format('HH:mm'); + }); + } + + /** + * trainline input date is GMT + current UTC/GMT offset + */ + private readonly getTrainlineDate = (time: string): string => { + const minInHour: number = 60; + const utcOffset: number = moment(new Date()).tz('Europe/Paris').utcOffset() / minInHour; + + return moment(time).add(utcOffset, 'hours').format('YYYY-MM-DD[T]HH:mm:ss'); + } +} diff --git a/server/src/core/connectors/Zeit.ts b/server/src/core/connectors/Zeit.ts new file mode 100644 index 00000000..2ed153a7 --- /dev/null +++ b/server/src/core/connectors/Zeit.ts @@ -0,0 +1,43 @@ +import Axios, { AxiosRequestConfig, AxiosResponse } from 'axios'; +import Config from '../../Config'; +import { IAvailability, IConnectorParams } from '../../types'; + +/** + * Zeit connector + */ +class Zeit { + /** + * connector generic function + */ + public async isTgvmaxAvailable({ origin, destination, fromTime, toTime, tgvmaxNumber }: IConnectorParams): Promise { + + const config: AxiosRequestConfig = { + url: 'https://maxplorateur.now.sh/api/travels', + method: 'POST', + auth: { + username: Config.zeitUsername as string, + password: Config.zeitPassword as string, + }, + data: { + origin: origin.sncfId, + destination: destination.sncfId, + fromTime, + toTime, + tgvmaxNumber + }, + }; + + try { + const response: AxiosResponse = await Axios.request(config); + return response.data; + } catch(error) { + console.log(error); + return { + isTgvmaxAvailable: false, + hours: [], + }; + } + } +} + +export default new Zeit(); \ No newline at end of file diff --git a/server/src/types.ts b/server/src/types.ts index c61fbc76..1b49c13a 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -108,3 +108,25 @@ export interface IStation { sncfId: string; trainlineId: string; } + +export interface IConnector { + name: string; + isTgvmaxAvailable: any; // tslint:disable-line + weight: number; +} + +export interface IConnectorParams { + origin: { + name: string; + sncfId: string; + trainlineId: string; + }; + destination: { + name: string; + sncfId: string; + trainlineId: string; + }; + fromTime: string; + toTime: string; + tgvmaxNumber: string; +}