scripts/recipes/recipe-front-generator.mjs (341 lines of code) (raw):

#!/usr/bin/env node //A preset list of containers that we can select from import crypto from 'crypto'; const usage = `This script (aka Kitchen Ipsum Generator :-D) builds kinda sensible-looking fronts based on a set list of titles and semantic search. Example usage is ./recipe-front-generator.mjs --stage LOCAL --fronts-issue-id 9d58078c-f9d8-4c27-8949-5c1dc8d2bfe5 --front-name 'All Recipes' --cookie "$AUTH_COOKIE" --collection-count 7 [--filter vegetarian] stage - set this to LOCAL or CODE. Don't run against PROD. fronts-issue-id - the issue containing the fronts to populate. Get this from the browser address bar in Fronts tool (e.g., https://fronts.local.dev-gutools.co.uk/v2/issues/9d58078c-f9d8-4c27-8949-5c1dc8d2bfe5) front-name - either 'All Recipes' or 'Meat-Free'. Note that it needs to exist already. cookie - set of cookies containing authorization. Get this by going into the Network tab, reloading your Front, finding a network request and copying the headers. filter - can be set to 'veg' or 'vegetarian' to restrict to only vegetarian recipes. I tend to then set this into an environment variable to make by console buffer more readable collection-count - number of collections to generate. Defaults to 1 if not specified. `; const containerNames = [ "Flavors of the World: A Culinary Adventure", "Quick & Tasty: Meals in 30 Minutes or Less", "Cozy Comfort: Recipes for Rainy Days", "Fresh & Light: A Taste of Summer", "Farm to Table: Fresh, Seasonal Delights", "Spice It Up: Bold Dishes for Adventurous Eaters", "Soul-Warming Stews for Cold Nights", "From the Oven: Bakes to Satisfy Any Craving", "Simply Delicious: 5-Ingredient Wonders", "One-Pot Magic: Easy Dishes, Minimal Cleanup", "Plant-Powered: Vibrant Vegan Creations", "Sweet Tooth Heaven: Desserts to Indulge In", "Family Favorites: Meals to Make Everyone Smile", "Weekend Brunch Goals: Recipes to Impress", "The Italian Kitchen: Pasta, Pizza, and More", "Healthy, Wholesome, & Hearty Bowls", "Sizzle & Sear: Grilled Goodness All Year Long", "Bringing the Heat: Fiery Flavors You'll Love", "Sweet & Savory Fusion: Unique Flavor Combos", "Global Comfort Foods: Your Favorite Dishes Reimagined", "Deliciously Decadent: Indulge in Every Bite", "Street Food Staples from Around the World", "Feast Your Eyes: Gourmet Meals Made Easy", "Healthy Habits: Nutritious Meals That Satisfy", "Hearty & Homestyle: Classic Comfort Dishes", "Elevated Everyday: Simple Meals, Sophisticated Taste", "Dinner Party Perfection: Dishes to Impress Guests", "Mediterranean Marvels: Fresh and Flavorful", "Master the Grill: Recipes for BBQ Lovers", "Crispy, Crunchy, & Full of Flavor", "Sweet Beginnings: Breakfast & Brunch Treats", "Summer BBQ Essentials: Flame-Kissed Goodness", "Flavors of Fall: Seasonal Recipes to Savor", "Aromatic & Rich: Perfect Curry Recipes", "Simple Snacks: Tasty Bites for Every Occasion", "Coastal Cooking: Seafood Recipes to Dive Into", "Warming Soups & Stews for Every Season", "Quick Bites: Appetizers for Any Occasion", "Baked to Perfection: Savory & Sweet Delights", "Wrap It Up: Easy and Delicious Wrap Recipes", "Delicious Detox: Clean Eating Recipes", "The Sweetest Treats: Baking Bliss Awaits", "Bold Flavors, Simple Prep: Quick Gourmet Meals", "Ultimate Game Day Grub: Crowd-Pleasing Snacks", "Feel-Good Foods: Healthy and Hearty", "Satisfy Your Cravings: Comfort Foods Redefined", "Light & Lovely: Perfect Salads for Any Meal", "Gluten-Free Goodies Everyone Will Love", "Breakfast in Bed: Recipes to Start the Day Right", "Ultimate Meat Lover’s Menu", "Under 500 Calories: Guilt-Free Gourmet", "For the Love of Chocolate: Irresistible Desserts", "Easy Entertaining: No-Fuss Party Foods", "Satisfying Sides: Perfect Complements to Any Meal", "Asian Fusion Feasts: Bold, Unique Flavors", "Lighter Fare: Meals That Won't Weigh You Down", "Sundays Made Simple: Slow Cooker Comfort", "Finger Food Fun: Deliciously Dippable Recipes", "Quick Fix: Weeknight Meals in a Flash", "Savory Sensations: Satisfying Soups to Savor", "Rustic Elegance: Country-Inspired Recipes", "Savory & Sweet: Perfect Pairings for Every Palate", "Tacos & Tequila: Mexican-Inspired Meals", "Lunchbox Love: Easy Meals to Take On-the-Go", "A Taste of the Tropics: Exotic Island Flavors", "Superfoods for Super You: Power-Packed Plates", "Midnight Munchies: Late Night Snacks You’ll Love", "Guilt-Free Desserts You Can’t Resist", "Pizza Party: Creative and Fun Toppings", "Fiesta Flavors: Mexican Favorites You’ll Adore", "Savory Bites: Delicious Dinner Ideas", "One-Pan Wonders: Fuss-Free Cooking", "Hearty Breakfasts to Fuel Your Day", "Tapas & Small Plates: Bite-Sized Bliss", "Picnic Perfection: Easy, Portable Recipes", "On a Roll: Perfect Sandwiches and Wraps", "Cheesy Comforts: Melty, Gooey Delights", "Heavenly Homestyle Baking: Recipes to Cherish", "Fresh From the Garden: Herb & Veggie-Packed Dishes", "A Taste of Italy: Recipes for Italian Food Lovers", "Fiery & Flavorful: Spicy Dishes to Heat Things Up", "Indulgent & Irresistible: Rich Dishes to Savor", "Refreshing & Light: Drinks and Smoothies to Sip", "Quick, Easy, & Delicious Breakfast Ideas", "Creamy & Dreamy: Comforting Pasta Dishes", "Flourless Feasts: Gluten-Free Wonders", "Slow-Cooked Success: Low & Slow, Big Flavor", "Bite-Sized Bliss: Perfect Hors d'Oeuvres", "A Dash of Citrus: Zesty Recipes That Shine", "Hearty Grain Bowls for Everyday Energy", "Sizzle & Spice: Southeast Asian Sensations", "Savory Pies: Perfect for Every Meal", "Breakfast Boost: Start Your Day Right", "Farmhouse Flavors: Rustic Recipes That Warm the Heart", "Satisfying Smoothies for Anytime", "Decadent Dinners: Treat Yourself Tonight", "Savory Brunch Ideas for Lazy Mornings", "Flour Power: Master the Art of Baking", "Festive Feasts: Holiday Recipes for Celebration", "Healthy Starts: Energizing Breakfast Recipes", "Global Grains: A World of Delicious Grains", "Perfect for Sharing: Family-Style Meals", "Sweet & Savory Creations for Any Mood" ] const recipeBase = "https://recipes.code.dev-guardianapis.com"; const getArg = (flag, optional = false) => { const argIdx = process.argv.indexOf(flag); const arg = argIdx !== -1 ? process.argv[argIdx + 1] : undefined; if (!arg && !optional) { console.error(`No argument for ${flag} given. ${usage}`); process.exit(2); } return arg; }; class ContinueOnError extends Error { } const getFrontsUri = () => { switch(stage.toLocaleUpperCase()) { case "PROD": throw new Error("Don't run this against PROD") case "CODE": return "https://fronts.code.dev-gutools.co.uk"; case "LOCAL": return "https://fronts.local.dev-gutools.co.uk"; default: throw new Error("--stage must be one of CODE or LOCAL") } } const stage = getArg("--stage"); const frontsBaseUrl = getFrontsUri(); const frontsIssueId = getArg("--fronts-issue-id"); const collectionCount = parseInt(getArg("--collection-count", true) ?? "1"); const minRecipes = 2; const maxRecipes = 8; const frontName = getArg("--front-name"); const cookie = getArg("--cookie"); const filter = getArg("--filter", true); const frontsHeaders = { "Content-Type": "application/json", Cookie: cookie, }; /** * Returns a batch of recipes from the search backend, in this format: * { * "hits": 61, * "maxScore": 0.8703575, * "results": [ * { * "score": 0.8703575, * "title": "Courgette and samphire", * "href": "/content/hSase0evm9VrxXxt9SP8_HWpDmpFQ9_I_rK_mC8S1aw", * "composerId": "57864e17e4b02d747b53cae5" * }, * .... * } * @param searchString * @param count * @return {Promise<any>} */ async function findRecipes(searchString, count, meatFree) { console.debug(`search term is '${searchString}'`); // const baseUrl = `${recipeBase}/search?q=${encodeURIComponent(searchString)}&format=Full&limit=${count}`; // const url = meatFree ? baseUrl + '&dietFilter=vegetarian' : baseUrl; const searchReq = { queryText: searchString, format: "Full", limit: count, searchType: "Embedded" } if(meatFree) { searchReq.filters = { diets: ["vegetarian"], filterType: "Post" } } const response = await fetch(`${recipeBase}/search`, { method: "POST", body: JSON.stringify(searchReq), headers: { ...frontsHeaders, "Content-Type": 'application/json' }, }); if(response.status !== 200) { const content = await response.text(); console.error(`Server error ${response.status}: ${content}`); throw new Error("Unable to search for matching recipes") } return response.json(); } /** * Takes in a recipe index structure and turns it into a recipe card * @param recipeIndexEntry */ function buildCard(recipeIndexEntry) { if(!recipeIndexEntry.id) throw new Error("Can't build card as recipeIndexEntry has no id"); return { uuid: crypto.randomUUID(), frontPublicationDate: Date.now(), cardType: 'recipe', id: recipeIndexEntry.id } } /** * Creates a new collection in the given front * @param collectionName * @param frontId * @return {Promise<*>} the ID of the collection that was just created */ async function makeNewCollection(collectionName, frontId) { const newCollectionResponse = await fetch( `${frontsBaseUrl}/editions-api/fronts/${frontId}/collection?name=${encodeURIComponent(collectionName)}`, { method: "PUT", headers: frontsHeaders, } ); if (newCollectionResponse.status !== 200) { console.error( `Error creating new collection: ${ newCollectionResponse.status } ${ newCollectionResponse.statusText } ${await newCollectionResponse.text()}` ); throw new Error("Unable to create collection") } else { console.log( `Collection with title '${collectionName}' added to front ${frontId}` ); } const responseBody = await newCollectionResponse.json(); //We always insert a new collection at the top return responseBody[0].id; } async function updateCollectionContents(collectionId, collectionName, cards) { const body = JSON.stringify({ collection: { id: collectionId, isHidden: false, lastUpdated: Date.now(), updatedBy: "autofill script", updatedEmail: "andy.gallagher@guardian.co.uk", displayName: collectionName, items: cards }, id: collectionId }) const response = await fetch( `${frontsBaseUrl}/editions-api/collections/${collectionId}`, { method: "PUT", headers: frontsHeaders, body, } ); if(response.status!==200) { const contentText = await response.text(); throw new Error(`Unable to update collection with ID ${collectionId}, server said ${response.status} ${contentText}`) } return response.json(); } function searchTermFromCollectionName(collectionName) { const indexOfColon = collectionName.lastIndexOf(':'); if(indexOfColon>0) { return collectionName.substring(indexOfColon+1).trim(); } else { return collectionName; } } async function buildCollection(collectionName, frontId, count) { const recipes = await findRecipes(searchTermFromCollectionName(collectionName), count, filter==='veg' || filter==='vegetarian'); //use the collectionName as a search string if(recipes.maxScore < 0.7) { throw new ContinueOnError(`No reliable results for '${collectionName} as a search string`); } console.log(`Selected ${recipes.results.length} recipes with max confidence of ${recipes.maxScore}`); recipes.results.forEach((r)=>console.log(`\t${r.title} ${r.contributors}`)); const newCollectionId = await makeNewCollection(collectionName, frontId); const recipeCards = recipes.results.map(buildCard); await updateCollectionContents(newCollectionId, collectionName, recipeCards); } async function frontNameToId(issueId, frontName) { const response = await fetch( `${frontsBaseUrl}/editions-api/issues/${issueId}`, { method: "GET", headers: frontsHeaders, } ); if(response.status!==200) { const contentText = await response.text(); throw new Error(`Unable to map name to ID, server said ${response.status} ${contentText}`); } const content = await response.json(); const matches = content.fronts.filter((_)=>_.displayName===frontName); if(matches.length>0) { return matches[0].id; } else { return undefined; } } // START MAIN const frontId = await frontNameToId(frontsIssueId, frontName); console.log(`ID of front ${frontName} is ${frontId}. Looking to generate ${collectionCount} collections`); for(let i=0; i<collectionCount; i++) { const titleSelector = crypto.randomInt(containerNames.length - 1); const targetRecipeCount = crypto.randomInt(minRecipes, maxRecipes); console.log(`Generating a collection of ${targetRecipeCount} for '${containerNames[titleSelector]}`); try { await buildCollection(containerNames[titleSelector], frontId, targetRecipeCount); } catch(err) { if(err instanceof ContinueOnError) { console.warn(err); } else { console.error(err); break; } } }