Skip to content

Commit

Permalink
Add points support (#47)
Browse files Browse the repository at this point in the history
* Add note for PROXY variable (#36)


* Include note about PROXY variable format

* Updates SMS module for new Plivo structure (#41)

Thanks @ribordy

* Preliminary support for points booking

* Add formatted pricing

* Fix parsing

* Tests, update dates/version, graph

* Linted

* linted

* text formatting for points

* Restore remote links

* Throw Error object

* missed text format

* Disable PN/email fields if not enabled

* Forgotten data
  • Loading branch information
samyun committed Jan 6, 2019
1 parent a0e7f92 commit f6d406a
Show file tree
Hide file tree
Showing 21 changed files with 1,464 additions and 1,024 deletions.
2 changes: 1 addition & 1 deletion LICENSE
@@ -1,7 +1,7 @@
The MIT License (MIT)

Original work Copyright (c) 2017 Scott Hardy
Modified work Copyright (c) 2017 Sam Yun
Modified work Copyright (c) 2018 Sam Yun

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
3 changes: 3 additions & 0 deletions README.md
Expand Up @@ -86,6 +86,9 @@ Instructions on deploying a proxy is outside the scope of this project. However,
To configure the Price Drop Bot to use your proxy, define a new PROXY variable within the Heroku Config. The proxy format should just be IP:port. Example: 123.123.123.123:1234

## Version history
### [3.3.0] - 2018-12-25
- Add support for award flights (points)
- Updated dependencies to latest versions
### [3.2.1] - 2018-7-23
- Merge PR from @GC-Guy to fix proxy support in checks
### [3.2.0] - 2018-7-21
Expand Down
20 changes: 10 additions & 10 deletions lib/apps/app.js
Expand Up @@ -43,7 +43,7 @@ app.post('/', async (req, res) => {
message = [
`Alert created for Southwest flight #${alert.number} from `,
`${alert.from} to ${alert.to} on ${alert.formattedDate}. `,
`We'll alert you if the price drops below $${alert.price}.`
`We'll alert you if the price drops below ${alert.formattedPrice}.`
].join('');
subject = [
`✈ Alert created for WN ${alert.number} `,
Expand All @@ -53,7 +53,7 @@ app.post('/', async (req, res) => {
message = [
`Alert created for any Southwest flight from `,
`${alert.from} to ${alert.to} on ${alert.formattedDate}. `,
`We'll alert you if the price drops below $${alert.price}.`
`We'll alert you if the price drops below ${alert.formattedPrice}.`
].join('');
subject = [
`✈ Alert created for any WN flight `,
Expand All @@ -68,8 +68,8 @@ app.post('/', async (req, res) => {
return;
}

if (mgEmail.enabled && alert.to_email) {
await mgEmail.sendEmail(alert.to_email, subject, message);
if (mgEmail.enabled && alert.toEmail) {
await mgEmail.sendEmail(alert.toEmail, subject, message);
}

if (sms.enabled && alert.phone) {
Expand All @@ -86,10 +86,10 @@ app.get('/:id/edit', async (req, res) => {
if (!data) {
const errorMsg = 'Unable to edit flight. Invalid id: ' + req.params.id;
console.warn(errorMsg);
res.send(render('error', req, {errorMsg: errorMsg}));
res.send(render('error', req, { errorMsg: errorMsg }));
} else {
const alert = new Alert(data);
res.send(render('edit', req, { alert }));
res.send(render('edit', req, { alert, mgIsEnabled: mgEmail.enabled, smsIsEnabled: sms.enabled }));
}
});

Expand All @@ -115,12 +115,12 @@ app.get('/:id/delete', async (req, res) => {

// NEW-SINGLE
app.get('/new-single', async (req, res) => {
res.send(render('new-single', req, { alertType: ALERT_TYPES.SINGLE }));
res.send(render('new-single', req, { alertType: ALERT_TYPES.SINGLE, mgIsEnabled: mgEmail.enabled, smsIsEnabled: sms.enabled }));
});

// NEW-DAY
app.get('/new-day', async (req, res) => {
res.send(render('new-day', req, { alertType: ALERT_TYPES.DAY }));
res.send(render('new-day', req, { alertType: ALERT_TYPES.DAY, mgIsEnabled: mgEmail.enabled, smsIsEnabled: sms.enabled }));
});

// SHOW
Expand All @@ -129,7 +129,7 @@ app.get('/:id', async (req, res) => {
if (!data) {
const errorMsg = 'Unable to display flight details. Invalid id: ' + req.params.id;
console.warn(errorMsg);
res.send(render('error', req, {errorMsg: errorMsg}));
res.send(render('error', req, { errorMsg: errorMsg }));
} else {
const alert = new Alert(data);
const graph = alert.data.priceHistory.length ? historyGraph(alert) : '';
Expand All @@ -143,7 +143,7 @@ app.get('/:id/change-price', async (req, res) => {
if (!data) {
const errorMsg = 'Unable to change price. Invalid id: ' + req.params.id;
console.warn(errorMsg);
res.send(render('error', req, {errorMsg: errorMsg}));
res.send(render('error', req, { errorMsg: errorMsg }));
} else {
const alert = new Alert(data);
const newPrice = parseInt(req.query.price, 10);
Expand Down
8 changes: 4 additions & 4 deletions lib/apps/email-handler.js
Expand Up @@ -122,7 +122,7 @@ async function parseEmailJSON (json) {

// Validate everything
if (flightDate && flightNumber && airportCodes.codeIsValid(airports[0]) && airportCodes.codeIsValid(airports[1])) {
flights.push({user: json.sender, to_email: json.sender, date: flightDate, number: flightNumber + ',', from: airports[0], to: airports[1]});
flights.push({ user: json.sender, toEmail: json.sender, date: flightDate, number: flightNumber + ',', from: airports[0], to: airports[1] });
} else {
console.log("Couldn't parse flight! Date: " + flightDate + ', ' + data[i + 1] + '; number: ' + flightNumber + ', ' + data[i + 2] + '; airports: ' + airports.join(', ') + ', ' + data[i + 3]);
}
Expand All @@ -146,15 +146,15 @@ async function parseEmailJSON (json) {
const message = [
`Alert created for Southwest flight #${alert.number} from `,
`${alert.from} to ${alert.to} on ${alert.formattedDate}. `,
`We'll alert you if the price drops below $${alert.price}.`
`We'll alert you if the price drops below ${alert.formattedPrice}.`
].join('');
const subject = [
`✈ Alert created for WN ${alert.number} `,
`${alert.from}${alert.to} on ${alert.formattedDate}. `
].join('');

console.log('Sending email: ' + alert.to_email + ', ' + subject + ', ' + message);
mgEmail.sendEmail(alert.to_email, subject, message);
console.log('Sending email: ' + alert.toEmail + ', ' + subject + ', ' + message);
mgEmail.sendEmail(alert.toEmail, subject, message);

alert.getLatestPrice();
redis.setAsync(alert.key(), alert.toJSON());
Expand Down
48 changes: 44 additions & 4 deletions lib/bot/alert.js
Expand Up @@ -12,6 +12,20 @@ class Alert {
this.data.id = this.data.id || shortid.generate();
this.data.from = this.data.from.toLocaleUpperCase();
this.data.to = this.data.to.toLocaleUpperCase();
if (this.data.isPointsBooking) {
this.data.isPointsBooking = this.data.isPointsBooking;
} else if (this.data.bookingType) {
if (this.data.bookingType === 'cash') {
this.data.isPointsBooking = false;
} else if (this.data.bookingType === 'points') {
this.data.isPointsBooking = true;
} else {
console.warn('Unexpected booking type, defaulting to cash: ' + this.data.bookingType);
this.data.isPointsBooking = false;
}
} else {
this.data.isPointsBooking = false;
}
try {
this.data.number = this.data.number != null && this.data.number !== 'NaN' ? this.data.number.split(',').map(n => n.trim()).filter(n => n.length).join(',') : 'NaN';
} catch (err) {
Expand All @@ -20,9 +34,9 @@ class Alert {
}
this.data.originalPrice = this.data.price ? parseInt(this.data.price, 10) : null;
this.data.phone = this.data.phone ? this.data.phone.split('').filter(d => /\d/.test(d)).join('') : null;
this.data.to_email = this.data.to_email ? this.data.to_email.split('').filter(d => /\S/.test(d)).join('') : null;
this.data.toEmail = this.data.toEmail ? this.data.toEmail.split('').filter(d => /\S/.test(d)).join('') : null;
this.data.priceHistory = Alert.compactPriceHistory(this.data.priceHistory || []);
this.data.alertType = this.data.alertType != null ? this.data.alertType : ALERT_TYPES.SINGLE; // If there's no alertType, assume Single
this.data.alertType = this.data.alertType != null ? this.data.alertType : ALERT_TYPES.SINGLE; // If there's no alertType, assume Single
this.data.fetchingPrices = this.data.fetchingPrices || false;
}

Expand All @@ -31,10 +45,11 @@ class Alert {
get date () { return new Date(this.data.date); }
get from () { return this.data.from; }
get to () { return this.data.to; }
get isPointsBooking () { return this.data.isPointsBooking; }
get number () { return this.data.number; }
get price () { return this.data.originalPrice; }
get phone () { return this.data.phone; }
get to_email () { return this.data.to_email; }
get toEmail () { return this.data.toEmail; }
get priceHistory () { return this.data.priceHistory; }
get alertType () { return this.data.alertType; }
get fetchingPrices () { return this.data.fetchingPrices; }
Expand All @@ -56,7 +71,32 @@ class Alert {
}

get formattedEmail () {
return this.data.to_email;
return this.data.toEmail;
}

get formattedPrice () {
if (this.data.isPointsBooking) {
return this.data.price + ' points';
} else {
return '$' + this.data.price;
}
}

get formattedLatestPrice () {
if (this.data.isPointsBooking) {
return this.latestPrice + ' points';
} else {
return '$' + this.latestPrice;
}
}

get formattedPriceDifference () {
const diff = this.data.price - this.latestPrice;
if (this.data.isPointsBooking) {
return diff + ' points';
} else {
return '$' + diff;
}
}

get latestPrice () {
Expand Down
56 changes: 30 additions & 26 deletions lib/bot/get-price.js
Expand Up @@ -3,11 +3,12 @@ const dateFormat = require('dateformat');
const puppeteer = require('puppeteer');
const { PROXY, ALERT_TYPES, MAX_PAGES } = require('../constants.js');

async function getPriceForFlight ({ from, to, date, number, alertType }, browser, lock) {
async function getPriceForFlight ({ from, to, date, number, isPointsBooking, alertType }, browser, lock) {
const flights = (await getFlights({
from,
to,
departDate: date,
isPointsBooking: isPointsBooking,
browser,
lock
})).outbound;
Expand All @@ -25,7 +26,7 @@ async function getPriceForFlight ({ from, to, date, number, alertType }, browser
return Math.min(...prices);
}

async function getFlights ({ from, to, departDate, returnDate, browser, lock }) {
async function getFlights ({ from, to, departDate, returnDate, isPointsBooking, browser, lock }) {
const twoWay = Boolean(departDate && returnDate);
const fares = { outbound: [] };

Expand All @@ -35,10 +36,10 @@ async function getFlights ({ from, to, departDate, returnDate, browser, lock })
let closeBrowserOnExit = false;
if (browser === undefined) {
if (PROXY === undefined) {
browser = await puppeteer.launch({args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage']});
browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'] });
closeBrowserOnExit = true;
} else {
browser = await puppeteer.launch({args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--proxy-server='+PROXY]});
browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--proxy-server=' + PROXY] });
closeBrowserOnExit = true;
}
}
Expand All @@ -48,14 +49,18 @@ async function getFlights ({ from, to, departDate, returnDate, browser, lock })
console.debug('lock has available permits: ' + lock.getPermits());
await lock.wait();
console.debug('Entered lock, available permits: ' + lock.getPermits());
html = await getPage(from, to, departDate, returnDate, browser);
html = await getPage(from, to, departDate, returnDate, isPointsBooking, browser);
await lock.signal();
console.debug('Exited lock, available permits: ' + lock.getPermits());
} else {
html = await getPage(from, to, departDate, returnDate, browser);
html = await getPage(from, to, departDate, returnDate, isPointsBooking, browser);
}
} catch (e) {
console.error(e);
if (e.message.includes('ERR_INTERNET_DISCONNECTED')) {
console.error('Bot was unable to connect to the internet while checking Southwest. Check your connection and try again later.');
} else {
console.error(e);
}
if (lock) {
const numPermits = lock.getPermits();
if (numPermits !== MAX_PAGES) { await lock.signal(); }
Expand Down Expand Up @@ -87,7 +92,7 @@ async function getFlights ({ from, to, departDate, returnDate, browser, lock })

const flights = $(e).find('.select-detail--flight-numbers').find('.actionable--text')
.text()
.substr(2) // remove "# "
.substr(2) // remove "# "
.split(' / ')
.join(',');
console.log('flights: ', flights);
Expand All @@ -96,8 +101,8 @@ async function getFlights ({ from, to, departDate, returnDate, browser, lock })
.text();
const duration = $(e).find('.flight-stops--duration-time').text();
const stops_ = durationAndStops
.split(duration)[1] // split on the duration -> eg 'Duration8h 5m1stop' -> ['Duration', '1 stop']
.split(' '); // '1 stop' -> ['1', 'stop']
.split(duration)[1] // split on the duration -> eg 'Duration8h 5m1stop' -> ['Duration', '1 stop']
.split(' '); // '1 stop' -> ['1', 'stop']

const stops = stops_[0] === '' ? 0 : parseInt(stops_[0], 10);
console.log('stops: ', stops);
Expand All @@ -111,7 +116,7 @@ async function getFlights ({ from, to, departDate, returnDate, browser, lock })
let price = Infinity;
if (Object.keys(priceStrDict).length > 0) {
for (var key in priceStrDict) {
let price_ = parseInt(priceStrDict[key], 10);
let price_ = parseInt(priceStrDict[key].replace(/,/g, ''), 10);
if (price_ < price) { price = price_; }
}
} else { console.error('There were no prices found!'); }
Expand All @@ -125,7 +130,9 @@ async function getFlights ({ from, to, departDate, returnDate, browser, lock })
}
}

function createUrl (from, to, departDate, returnDate, browser) {
function createUrl (from, to, departDate, returnDate, isPointsBooking) {
const fareType = (isPointsBooking) ? 'POINTS' : 'USD';

return 'https://www.southwest.com/air/booking/select.html' +
'?originationAirportCode=' + from +
'&destinationAirportCode=' + to +
Expand All @@ -136,7 +143,7 @@ function createUrl (from, to, departDate, returnDate, browser) {
'&returnTimeOfDay=ALL_DAY' +
'&adultPassengersCount=1' +
'&seniorPassengersCount=0' +
'&fareType=USD' +
'&fareType=' + fareType +
'&passengerType=ADULT' +
'&tripType=oneway' +
'&promoCode=' +
Expand All @@ -146,19 +153,19 @@ function createUrl (from, to, departDate, returnDate, browser) {
'&leapfrogRequest=true';
}

async function getPage (from, to, departDate, returnDate, browser) {
async function getPage (from, to, departDate, returnDate, isPointsBooking, browser) {
const page = await browser.newPage();
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36');
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36');
try {
const url = createUrl(from, to, departDate, returnDate);
const url = createUrl(from, to, departDate, returnDate, isPointsBooking);
console.log('URL: ', url);
await page.goto(url);
try {
await page.waitForSelector('.price-matrix--details-titles');
await page.waitForSelector('.flight-stops');
console.debug("Got flight page!");
console.debug('Got flight page!');
} catch (err) {
console.error("Unable to get flights - trying again");
console.error('Unable to get flights - trying again');

try {
await page.goto(url);
Expand All @@ -169,14 +176,11 @@ async function getPage (from, to, departDate, returnDate, browser) {
const errHtml = await page.evaluate(() => document.body.outerHTML);
await page.goto('about:blank');
await page.close();

if (errHtml.includes("Access Denied"))
{
throw 'ERROR! Access Denied Error! Unable to find flight information on page: ' + currentUrl + '\nhtml: ' + errHtml;
}
else
{
throw 'ERROR! Unknown error! Unable to find flight information on page: ' + currentUrl + '\nhtml: ' + errHtml;

if (errHtml.includes('Access Denied')) {
throw new Error('ERROR! Access Denied Error! Unable to find flight information on page: ' + currentUrl + '\nhtml: ' + errHtml);
} else {
throw new Error('ERROR! Unknown error! Unable to find flight information on page: ' + currentUrl + '\nhtml: ' + errHtml);
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions lib/bot/send-email.js
Expand Up @@ -8,14 +8,14 @@ async function sendEmail (to, subject, message, from = MAILGUN_EMAIL) {
if (!enabled) return false;

const mg = getMailgun();
const to_domain = to.split('@')[1];
const toDomain = to.split('@')[1];
const params = {
from: from,
to: to,
text: message
};

if (SMS_GATEWAYS.indexOf(to_domain) === -1) {
if (SMS_GATEWAYS.indexOf(toDomain) === -1) {
params.subject = subject;
}

Expand Down
2 changes: 1 addition & 1 deletion lib/bot/send-sms.js
Expand Up @@ -9,7 +9,7 @@ async function sendSms (to, message, from = PLIVO_NUMBER) {

const plivo = getPlivo();
try {
response = await plivo.messages.create(from, to, message);
const response = await plivo.messages.create(from, to, message);
console.log(response);
return response;
} catch (error) {
Expand Down
2 changes: 1 addition & 1 deletion lib/constants.js
Expand Up @@ -2,7 +2,7 @@ const ENV = process.env;

const ADMIN_NAME = ENV.ADMIN_NAME || ENV.USER_NAME;
const PASSWORD = ENV.PASSWORD || ENV.USER_PASSWORD;
const PROXY = ENV.PROXY;
const PROXY = ENV.PROXY;

const DEVELOPMENT = ENV.DEVELOPMENT === 'true';

Expand Down

0 comments on commit f6d406a

Please sign in to comment.