//Brennan Wilkes
//Includes
const axios = require('axios');
//URL's for thecocktaildb.com API's
const ranURL = "https://www.thecocktaildb.com/api/json/v2/9973533/random.php";
const alcURL = "https://www.thecocktaildb.com/api/json/v2/9973533/filter.php?a=Alcoholic";
const drinkURL = "https://www.thecocktaildb.com/api/json/v2/9973533/lookup.php?i=";
const letURL = "https://www.thecocktaildb.com/api/json/v2/9973533/search.php?f=";
const ingrURL = "https://www.thecocktaildb.com/api/json/v2/9973533/search.php?i=";
/**
Rounds a number to the nearest quarter
@param {number} num Number to round
@returns {number} num rounded to nearest quarter
@memberof dev
*/
const roundQ = num => (Math.ceil(num * 4) / 4).toFixed(2);
/**
Class representation of a drink recipe scraped from an API
@class
@memberof dev
*/
class DrinkRecipe{
/**
Initializes a drink recipe object with a scraped JSON from the API
Performs regex validation and speculative parsing.
@constructor
@param {object} data JSON data from API
*/
constructor(data){
//Set drink ID. Note this is the API ID, not the final ID in the everyLastDrop database
this.id = data.idDrink;
//Drink name
this.name = data.strDrink.toLowerCase();
//Glass name. Converts to common desired names for uniformity.
this.glass = data.strGlass.toLowerCase()
.replace(/ [gG]lass$/, "")
.replace(/old-fashioned/g,'rocks')
.replace(/margarita\/coupette/g,'coupe')
.replace(/coupette/g,'coupe')
.replace(/margarita/g,'coupe')
.replace(/martini/g,'coupe')
.replace(/white wine/g,'wine')
.replace(/whiskey sour/g,'coupe')
.replace(/cocktail/g,'coupe');
//Auto mixmethod detection. Searches the instructions for the word shake, impling a shaken drink
this.mixMethod = (data.strInstructions.toLowerCase().includes("shake") ? "shaken" : "stirred");
//Hardcoded on ice detection based on glass type.
if(["coupe","shot","nick and nora","champagne flute"].some(el => this.glass.includes(el))){
this.onIce = false;
}
else if(["rocks","hurricane","punch bowl"].some(el => this.glass.includes(el))){
this.onIce = true;
}
//If all else fails, search instructions and ingredients for the word ice.
else{
this.onIce = data.strInstructions.toLowerCase().includes("ice") || new Array(15).map(i => data[`strIngredient${i+1}`]).join(" ").includes("ice");
}
//Generate random price and rating
this.price = 10 + Math.floor(Math.random()*5)*2;
this.rating = 2 + Math.floor(Math.random()*4)*2;
//Record img URL
this.imgURL = data.strDrinkThumb;
//Parse out ingredients
this.ingredients = [];
let ingAmt, ingName;
for(let i=1;i<16;i++){
if(data[`strIngredient${i}`]){
ingName = data[`strIngredient${i}`];
let origName = ingName;
ingAmt = data[`strMeasure${i}`];
//Check if amt field exists and is of the correct type
try{
let temp = ingAmt.replace("a","b");
}
catch{
ingAmt = "1";
}
//Check if name field exists and is of the correct type
try{
let temp = ingName.replace("a","b");
}
catch{
//If ingredient name doesn't exist, drop record of it.
continue
}
//Rounds all digits to the nearest quarter
ingAmt = ingAmt.replace(/(\d+)\/(\d+)/,(str,num,denom) => roundQ(parseFloat(num)/parseFloat(denom)));
ingAmt = ingAmt.replace(/(\d+) (\d+\.\d+)/,(str,whole,fract) => roundQ(parseFloat(whole)+parseFloat(fract)));
//Replaces ranged amounts with their maximum
ingAmt = ingAmt.replace(/(\d+.*\d*) *- *(\d+.*\d*)/,(str,low,high) => high);
//Auto unit conversion to standard OZ
ingAmt = ingAmt.replace(/(\d+.*\d*).* L .*/,(str,amt) => roundQ(parseFloat(amt)*33.814));
ingAmt = ingAmt.replace(/(\d+.*\d*).*[mM][lL].*/,(str,amt) => roundQ(parseFloat(amt)*0.033814));
ingAmt = ingAmt.replace(/(\d+.*\d*).*[cL][lL].*/,(str,amt) => roundQ(parseFloat(amt)*0.33814));
ingAmt = ingAmt.replace(/(\d+.*\d*).*tsp.*/,(str,amt) => roundQ(parseFloat(amt)*0.166667));
ingAmt = ingAmt.replace(/(\d+.*\d*).*shot.*/,(str,amt) => roundQ(parseFloat(amt)*1.5));
ingAmt = ingAmt.replace(/(\d+.*\d*).*dash.*/,(str,amt) => roundQ(parseFloat(amt)/32.0));
ingAmt = ingAmt.replace(/(\d+.*\d*).*pinch.*/,(str,amt) => roundQ(parseFloat(amt)/100.0));
ingAmt = ingAmt.replace(/(\d+.*\d*).*tbl*sp.*/,(str,amt) => roundQ(parseFloat(amt)/2.0));
ingAmt = ingAmt.replace(/(\d+.*\d*).*bottle.*/,(str,amt) => roundQ(parseFloat(amt)*75));
ingAmt = ingAmt.replace(/(\d+.*\d*).*can.*/,(str,amt) => roundQ(parseFloat(amt)*25));
ingAmt = ingAmt.replace(/(\d+.*\d*).*splash.*/,(str,amt) => roundQ(parseFloat(amt)/5));
ingAmt = ingAmt.replace(/(\d+.*\d*).*cup.*/,(str,amt) => roundQ(parseFloat(amt)*8));
ingAmt = ingAmt.replace(/(\d+.*\d*).*glass.*/,(str,amt) => roundQ(parseFloat(amt)*8));
//Minimal amounts
ingAmt = ingAmt.replace(/.*dash.*/,(str,amt) => roundQ(0.25));
ingAmt = ingAmt.replace(/.*pinch.*/,(str,amt) => roundQ(0.25));
ingAmt = ingAmt.replace(/.*splash.*/,(str,amt) => roundQ(0.25));
//Strip any remaining non digit characters
ingAmt = ingAmt.replace(/[^0-9\.]+/g, '');
//If no amount specified, assume standard 1.
if(ingAmt.length < 1){
ingAmt = 1;
}
//Force convert to floating point
ingAmt = parseFloat(ingAmt);
//Name cleanup
ingName = ingName.toLowerCase();
ingName = ingName.replace('-',' ');
ingName = ingName.replace(/[fF]resh /, "");
ingName = ingName.replace(/ [fF]resh/, "");
//Record ingredient
this.ingredients.push({
searchQuery: origName,
name: ingName,
amount: ingAmt,
})
}
}
}
/**
Queries the API for information about an ingredient, then outputs it to be caught and recorded (OS level)
@param {number} i index of ingredient. Defaults to 0
*/
getIngInfoThenOutput(i=0){
//Create a queue of promises to resolve
const axiosQueue = [];
for(let i=0;i<this.ingredients.length;i++){
//Append a new axios request
//Either lookup data from cache, or request from API
axiosQueue.push(
(ingredientCache[this.ingredients[i].name]
? new Promise((res,rej)=>{
//If ingredient has already been discovered and cached, simply lookup and resolve.
this.ingredients[i].id = ingredientCache[this.ingredients[i].name].id;
this.ingredients[i].type = ingredientCache[this.ingredients[i].name].type;
this.ingredients[i].isAlcohol = ingredientCache[this.ingredients[i].name].isAlcohol;
this.ingredients[i].percentage = ingredientCache[this.ingredients[i].name].percentage;
res(true);
})
: axios.get(`${ingrURL}${this.ingredients[i].searchQuery.replace(" ","%20")}`)
.then(res => {
if(ingredientCache[this.ingredients[i].name]){
//If ingredient has already been discovered and cached, simply lookup and resolve.
this.ingredients[i].id = ingredientCache[this.ingredients[i].name].id;
this.ingredients[i].type = ingredientCache[this.ingredients[i].name].type;
this.ingredients[i].isAlcohol = ingredientCache[this.ingredients[i].name].isAlcohol;
this.ingredients[i].percentage = ingredientCache[this.ingredients[i].name].percentage;
}
//Record and cache new data from API
else if(res.data.ingredients && res.data.ingredients.length > 0){
let details = res.data.ingredients[0];
this.ingredients[i].id = parseInt(details.idIngredient);
this.ingredients[i].type = details.strType ? details.strType : "";
this.ingredients[i].isAlcohol = details.strAlcohol==="Yes";
this.ingredients[i].percentage = details.strABV ? parseFloat(details.strABV) : 0;
//Auto alcohol percentage and type detection and parsing
if(this.ingredients[i].percentage === 0){
if(this.ingredients[i].type.toLowerCase().includes("wine")){
this.ingredients[i].isAlcohol = true;
this.ingredients[i].percentage = (this.ingredients[i].type.toLowerCase().includes("fortified")) ? 20 : 12.5;
}
else if(this.ingredients[i].type.toLowerCase().includes("whisky") ||
this.ingredients[i].type.toLowerCase().includes("spirit")||
this.ingredients[i].type.toLowerCase().includes("liquor")||
this.ingredients[i].type.toLowerCase().includes("tequila")||
this.ingredients[i].type.toLowerCase().includes("sambuca")||
this.ingredients[i].type.toLowerCase().includes("vodka")||
this.ingredients[i].type.toLowerCase().includes("whiskey")){
this.ingredients[i].isAlcohol = true;
this.ingredients[i].percentage = 40;
}
else if(this.ingredients[i].type.toLowerCase().includes("liqueur") ||
this.ingredients[i].type.toLowerCase().includes("brandy") ||
this.ingredients[i].type.toLowerCase().includes("rum") ||
this.ingredients[i].type.toLowerCase().includes("aperitif")){
this.ingredients[i].isAlcohol = true;
this.ingredients[i].percentage = 25;
}
else if(this.ingredients[i].type.toLowerCase().includes("stout")){
this.ingredients[i].isAlcohol = true;
this.ingredients[i].percentage = 17.5;
}
}
//Update cache with new discovered information.
//This is important as the API rejects many (~2%) requests with 404 or 409
ingredientCache[this.ingredients[i].name] = this.ingredients[i];
}
}).catch(err=>{
//On failure, try one more time to search the cache, incase new information has
//been discovered since previous check
if(ingredientCache[this.ingredients[i].name]){
this.ingredients[i].id = ingredientCache[this.ingredients[i].name].id;
this.ingredients[i].type = ingredientCache[this.ingredients[i].name].type;
this.ingredients[i].isAlcohol = ingredientCache[this.ingredients[i].name].isAlcohol;
this.ingredients[i].percentage = ingredientCache[this.ingredients[i].name].percentage;
}
})
)
)
}
//When all ingredient lookups have been resolved, output a JSON representation of the drink recipe to be handled at OS level
Promise.all(axiosQueue).then(res=>{
console.log(`${JSON.stringify(this,false,4)},`);
}).catch(err=>{});
}
}
//Create ingredient cache
var ingredientCache = [];
//Iterate over lettered and numbered pages of API. Weird I know.
let d;
let letters = [];
for(let l=0;l<26;l++){
letters.push(String.fromCharCode(97+l));
}
letters = [...letters,0,1,2,3,4,5,6,7,8,9];
letters.forEach((letter, i) => {
//Query API for list of drinks beginning with letter or digit.
axios.get(letURL+letter).then(res => {
//Ignore failed requests
if(!res.data.drinks){
return;
}
//Iterate over discovered drinks
res.data.drinks.forEach((drink, i) => {
//Query API for specific drink
axios.get(`${drinkURL}${drink.idDrink}`).then(res => {
//Iterate over any possible duplicates
res.data.drinks.forEach((details, i) => {
//Parse drink data
d = new DrinkRecipe(details);
//If drink parsing was successful, parse ingredients and output
if(d.glass.length > 0 ){
d.getIngInfoThenOutput();
}
});
}).catch(err=>{});
});
}).catch(err=>{});
});