const fs = require("fs").promises, fetch = require("node-fetch"), FormData = require("form-data"), { JSDOM } = require("jsdom"), HttpsProxyAgent = require('http-proxy-agent'), { request, GraphQLClient, gql } = require("graphql-request"), moment = require("moment"); /* Any search URL to oregonliquorsearch that has an expired cookie * will take you back to the main "verify you are 21" page * * If you hit the server too frequently, or perhaps too many times * with the same cookie, it will ban your IP for an indeterminate * amount of time. It might be a scaled value; I've been blocked * for an hour before or several days other times. */ const cookies = []; const client = new GraphQLClient("http://localhost:4000/graphql", { headers: {} }); const getWhiskyInfo = async (whisky) => { const cacheFile = `cache/${whisky.code}`; return fs.stat(cacheFile).then(async stat => { const last = moment(stat.mtime), yesterday = moment().subtract(1, 'day');; if (last.isBefore(yesterday)) { console.log(`${cacheFile} ${last.calendar()} is older than yesterday at this time: ${yesterday.calendar()}`); await fs.unlink(cacheFile); } else { console.log(`${cacheFile} ${last.calendar()} is newer than yesterday at this time: ${yesterday.calendar()}`); } return fs.readFile(cacheFile); }) .catch(async (error) => { if (criticalFailure) throw error; console.log(`Loading new location data for ${whisky.code}`); const url = `http://oregonliquorsearch.com/servlet/FrontController` + `?radiusSearchParam=10` + `&productSearchParam=${whisky.code}` + `&locationSearchParam=97225` + `&btnSearch=Search` + `&view=global` + `&action=search`; await rateLimit(); return fetch(url, { headers: { cookie: cookies } }) .then(response => response.text()) .then(dom => { try { fs.writeFile(cacheFile, dom); } catch (_error) { if (criticalFailure) throw _error; console.log(`Write failed: ${_error}`); } return dom; }); }).then(dom => { dom = new JSDOM(dom); const headers = []; const rows = dom.window.document.querySelectorAll('table.list tr'); const olccLocations = []; rows.forEach(row => { const th = row.querySelectorAll('table.list th'); if (th.length) { th.forEach(header => headers.push(header.textContent.trim())); return; } const tds = row.querySelectorAll('td'), location = {}; tds.forEach((td, index) => { const span = td.querySelector('span.link'); let value = (span ? span.textContent : td.textContent).trim(); if (parseFloat(value) == value) { value = parseFloat(value); } location[headers[index]] = value; }); olccLocations.push(location); }); return olccLocations; }) .then(async (olccLocations) => { console.log(`Updating locations for ${whisky.code}`); const whiskyInventories = []; /* Locations OLCC reports for this whisky */ await Promise.all(olccLocations.map(async (olccLocation) => { let location = locations.find(item => item.code == olccLocation['Store No']); let operation; if (location == null) { /* New location */ console.log(`NEW location: ${olccLocation['Store No']} ${olccLocation['Address']} ${olccLocation['Location']}`); location = { code: olccLocation['Store No'].toString(), address: olccLocation['Address'], city: olccLocation['Location'], phone: olccLocation['Telephone'], longitude: -110.5, latitude: 45 }; operation = gql` mutation CreateLocation($code: String!, $address: String!, $city: String!, $phone: String!, $longitude: Float!, $latitude: Float!) { createLocation(code: $code, address: $address, city: $city, phone: $phone, longitude: $longitude, latitude: $latitude) { code } }`; } else { operation = gql` mutation UpdateLocation($code: String!, $address: String!, $city: String!, $phone: String!, $longitude: Float!, $latitude: Float!) { updateLocation(code: $code, address: $address, city: $city, phone: $phone, longitude: $longitude, latitude: $latitude) }`; } try { const data = await client.request(operation, location); if (data.createLocation && data.createLocation.code) { locations.push(location); } whiskyInventories.push({ location, updated: moment().format("YYYY-MM-DD"), quantity: olccLocation['Qty'] }); } catch (error) { console.error(JSON.stringify(error, null, 2)); console.log(location); process.exit(1); } })); /* Manage Inventories table */ /* Delete entries on whisky which do not exist in the new OLCC locations for this whisky, or update quantity for those that do */ await Promise.all(whisky.inventories.map(async (inventory) => { const update = whiskyInventories.find(item => item.location.code == inventory.location.code); let operation, variables; if (update) { Object.assign(inventory, update); operation = gql` mutation UpdateInventory($location: String!, $whisky: String!, $quantity: Int!, $updated: String!) { updateInventory(location: $location, whisky: $whisky, quantity: $quantity, updated: $updated) }`; //console.log("update", JSON.stringify(inventory)); variables = { whisky: whisky.code, location: update.location.code, quantity: update.quantity, updated: moment().format("YYYY-MM-DD") }; } else { operation = gql` mutation DeleteInventory($location: String!, $whisky: String!) { deleteInventory(location: $location, whisky: $whisky) }`; console.log("delete", JSON.stringify(inventory)); variables = { whisky: whisky.code, location: inventory.location.code, }; } try { await client.request(operation, variables); } catch (error) { console.error(JSON.stringify(error, null, 2)); console.log(JSON.stringify(variables, null, 2), update); process.exit(1); } })); /* If an entry exists in the OLCC locations for this whisky, and not on the whisky, then add a new Inventory entry */ await Promise.all(whiskyInventories.map(async (olccInventory) => { const update = whisky.inventories.find(item => item.location.code == olccInventory.location.code); if (!update) { const operation = gql` mutation CreateInventory($location: String!, $whisky: String!, $quantity: Int!, $updated: String!) { createInventory(location: $location, whisky: $whisky, quantity: $quantity, updated: $updated) { quantity } }`; try { console.log(JSON.stringify(olccInventory, null, 2)); const data = await client.request(operation, { whisky: whisky.code, location: olccInventory.location.code, quantity: olccInventory.quantity, updated: moment().format("YYYY-MM-DD") }); //console.log(`New inventory: ${JSON.stringify(data, null, 2)}`); whisky.inventories.push(data.createInventory.inventory); } catch (error) { console.log('there'); console.error(JSON.stringify(error, null, 2)); console.log(operation); process.exit(1); } } })); whisky.inventories = whiskyInventories; return whisky.inventories; }); }; console.log(`Trying from "cache"...`); let lastFetch = undefined; const rateLimit = async () => { if (!lastFetch) { lastFetch = Date.now(); return; } const delay = (lastFetch + (Math.random() * 5000 + 2500)) - Date.now(); if (delay > 0) { await wait(delay); } lastFetch = Date.now(); } const wait = async (timeout) => { return new Promise((resolve, reject) => { setTimeout(resolve, timeout); }); }; let criticalFailure = false; let whiskies = [], locations = []; fs.stat("cache") .catch((error) => { if (criticalFailure) throw error; fs.mkdir("cache"); return fs.stat("cache"); }) .then(async (stats) => { if (!stats.isDirectory()) { criticalFailure = true; throw new Error("cache is not a directory or can not be created"); } let query = gql` { Whiskies { code description price size lastSeen inventories { location { code address city longitude latitude } quantity updated } } }`; try { whiskies = (await client.request(query)).Whiskies; } catch (error) { console.error(error); process.exit(-1); } query = gql` { Locations { code address city phone longitude latitude } }`; try { locations = (await client.request(query)).Locations; } catch (error) { console.error(error); process.exit(-1); } }) .catch(error => console.error(error)) .then(async () => { /* Attempt to read the whisky cache data. * If it is older than 1 day, fetch new data */ const cacheFile = "cache/whiskies", stat = await fs.stat(cacheFile), last = moment(stat.mtime), yesterday = moment().subtract(1, 'day'); if (last.isBefore(yesterday)) { console.log(`${cacheFile} ${last.calendar()} is older than yesterday at this time: ${yesterday.calendar()}`); await fs.unlink(cacheFile); } else { console.log(`${cacheFile} ${last.calendar()} is newer than yesterday at this time: ${yesterday.calendar()}`); } const data = await fs.readFile(cacheFile); return fs.readFile("cache/cookies") .then(cookieData => { JSON.parse(cookieData).forEach(cookie => cookies.push(cookie)) }) .then(() => { return data; }); }).catch(async (error) => { if (criticalFailure) throw error; console.error(error); console.log(`Cache or cookies not found--loading fresh whisky list and cookies.`); await rateLimit(); return fetch("http://oregonliquorsearch.com/", { // agent: new HttpsProxyAgent('http://3.22.0.212:8080') }).then((response) => { response.headers.raw()['set-cookie'] .forEach((header) => { header .split(';') .forEach(cookie => { cookies.push(cookie); }); }); fs.writeFile("cache/cookies", JSON.stringify(cookies)); }) .then(async () => { /* if (!cookies.JSESSIONID) { throw "JSESSIONID not found"; }*/ console.log(`Submitting age verification...`); const formData = new FormData(); formData.append("btnSubmit", "I'm 21 or older"); await rateLimit(); return fetch("http://oregonliquorsearch.com/servlet/WelcomeController", { method: "POST", body: formData, headers: { cookie: cookies } }) .then((response) => { if (response.status != 200) { throw new Error("Error fetching resource from oregonliquorsearch"); } }); }).then(async () => { /* Search for all WHISKY types */ console.log(`Searching for all whisky types...`); await rateLimit(); return fetch("http://oregonliquorsearch.com/servlet/FrontController" + "?view=browsecategoriesallsubcategories" + "&action=select" + "&category=DOMESTIC%20WHISKEY", { headers: { cookie: cookies } }) .then(response => response.text()); }) .then(async (dom) => { try { console.log(`Writing to "cache"...`); await fs.writeFile("cache/whiskies", dom); } catch(_error) { if (criticalFailure) throw _error; console.log(`Write failed: ${_error}`); } return dom; }); }).then(dom => { dom = new JSDOM(dom); const headers = []; const rows = dom.window.document.querySelectorAll('table.list tr'); const olccWhiskies = []; const processRow = (row) => { const th = row.querySelectorAll('table.list th a'); if (th.length) { th.forEach(header => headers.push(header.textContent.trim())); return; } const tds = row.querySelectorAll('td'), whisky = {}; tds.forEach((td, index) => { let value = td.textContent.trim(); switch (headers[index]) { case 'Size': if (value.match(/^.* ML/)) { value = parseFloat(value) / 1000.; } else if (value.match(/^.* L/)) { value = parseFloat(value); } else if (value == 'LITER') { value = 1.; } break; case 'Proof': if (parseFloat(value) == value) { value = parseFloat(value); } break; case 'Case Price': case 'Bottle Price': if (value.match(/^\$/)) { value = parseFloat(value.replace(/^\$/, "")); } break; case 'Item Code': const anchor = td.querySelector('a'); if (anchor) { whisky.url = `http://oregonliquorsearch.com${anchor.href}`; } value = td.querySelector('span.link').textContent.trim(); break; case 'Description': case 'Age': default: break; } whisky[headers[index]] = value; }); olccWhiskies.push(whisky); }; rows.forEach(processRow); return olccWhiskies; }).then(async (olccWhiskies) => { await Promise.all(olccWhiskies.map(async (olccWhisky) => { let whisky = whiskies.find(item => item.code == olccWhisky['Item Code']); let operation; if (whisky == null) { /* New whisky */ console.log(`NEW whisky: ${olccWhisky['Item Code']} ${olccWhisky['Description']}`); whisky = { description: olccWhisky['Description'], code: olccWhisky['Item Code'].toString(), size: Math.floor(1000 * olccWhisky['Size']), proof: olccWhisky['Proof'], age: olccWhisky['Age'], price: olccWhisky['Bottle Price'], }; operation = gql` mutation CreateWhisky($description: String!, $code: String!, $size: Int!, $price: Float!, $lastSeen: String!) { createWhisky(description: $description, code: $code, size: $size, price: $price, lastSeen: $lastSeen) { code } }`; } else { operation = gql` mutation UpdateWhisky($description: String!, $code: String!, $size: Int!, $price: Float!, $lastSeen: String!) { updateWhisky(description: $description, code: $code, size: $size, price: $price, lastSeen: $lastSeen) }`; } whisky.lastSeen = moment().format("YYYY-MM-DD"); try { const data = await client.request(operation, whisky); if (whisky.code === undefined) { whisky.code = data; } } catch (error) { console.error(JSON.stringify(error, null, 2)); console.error(operation); console.error(variables); process.exit(1); } })); console.log(`OLCC currently carries ${whiskies.length} domestic whiskies.`); whiskies = whiskies .filter(a => a.code == '2170B') .filter(a => a.price >= 10.)// && a['Bottle Price'] <= 100.) .sort((a, b) => b.price - a.price); const max = Math.min(5, whiskies.length); console.log(`${max} whiskies > $10:`); for (let i = 0; i < max; i++) { console.log(`${i+1}. \$${whiskies[i].price} - ${whiskies[i].description}`); const _inventories = await getWhiskyInfo(whiskies[i]); _inventories.sort((a, b) => b.quantity - a.quantity); /* Create or Update location */ /* Update location inventory */ _inventories.forEach((inventory) => { console.log(` ${inventory.quantity} at ${inventory.location.address} in ${inventory.location.city}, OR`); }); } });