recipe_scrapers 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE +20 -0
- data/README.md +54 -0
- data/lib/recipe_scrapers/configuration.rb +119 -0
- data/lib/recipe_scrapers/declaration.rb +85 -0
- data/lib/recipe_scrapers/error_tracker.rb +14 -0
- data/lib/recipe_scrapers/errors.rb +16 -0
- data/lib/recipe_scrapers/http/adapter.rb +50 -0
- data/lib/recipe_scrapers/http/address_guard.rb +90 -0
- data/lib/recipe_scrapers/http/body_limit.rb +45 -0
- data/lib/recipe_scrapers/http/encoding.rb +48 -0
- data/lib/recipe_scrapers/http/follow_redirects.rb +75 -0
- data/lib/recipe_scrapers/models/ingredient.rb +20 -0
- data/lib/recipe_scrapers/models/ingredient_group.rb +20 -0
- data/lib/recipe_scrapers/models/nutrient.rb +19 -0
- data/lib/recipe_scrapers/models/recipe.rb +125 -0
- data/lib/recipe_scrapers/parsers/chain.rb +52 -0
- data/lib/recipe_scrapers/parsers/durations.rb +70 -0
- data/lib/recipe_scrapers/parsers/ingredients.rb +144 -0
- data/lib/recipe_scrapers/parsers/nutrients.rb +47 -0
- data/lib/recipe_scrapers/parsers/quantity.rb +46 -0
- data/lib/recipe_scrapers/parsers/ratings.rb +26 -0
- data/lib/recipe_scrapers/parsers/vocabulary/common.yml +7 -0
- data/lib/recipe_scrapers/parsers/vocabulary/de.yml +5 -0
- data/lib/recipe_scrapers/parsers/vocabulary/en.yml +26 -0
- data/lib/recipe_scrapers/parsers/vocabulary/es.yml +4 -0
- data/lib/recipe_scrapers/parsers/vocabulary/fr.yml +5 -0
- data/lib/recipe_scrapers/parsers/vocabulary/hu.yml +3 -0
- data/lib/recipe_scrapers/parsers/vocabulary/it.yml +4 -0
- data/lib/recipe_scrapers/parsers/vocabulary/ja.yml +2 -0
- data/lib/recipe_scrapers/parsers/vocabulary/nl.yml +4 -0
- data/lib/recipe_scrapers/parsers/vocabulary/pt.yml +8 -0
- data/lib/recipe_scrapers/parsers/vocabulary/ru.yml +7 -0
- data/lib/recipe_scrapers/parsers/vocabulary/scandinavian.yml +3 -0
- data/lib/recipe_scrapers/parsers/vocabulary/sk.yml +3 -0
- data/lib/recipe_scrapers/parsers/vocabulary/tr.yml +2 -0
- data/lib/recipe_scrapers/parsers/vocabulary.rb +120 -0
- data/lib/recipe_scrapers/parsers/yields.rb +30 -0
- data/lib/recipe_scrapers/registry.rb +88 -0
- data/lib/recipe_scrapers/scraper/parsed_fields.rb +69 -0
- data/lib/recipe_scrapers/scraper.rb +199 -0
- data/lib/recipe_scrapers/site_path.rb +21 -0
- data/lib/recipe_scrapers/sites/au/bestrecipes.rb +3 -0
- data/lib/recipe_scrapers/sites/be/15gram.rb +3 -0
- data/lib/recipe_scrapers/sites/br/panelinha.rb +3 -0
- data/lib/recipe_scrapers/sites/br/receitasnestle.rb +3 -0
- data/lib/recipe_scrapers/sites/br/receiteria.rb +3 -0
- data/lib/recipe_scrapers/sites/br/tudogostoso.rb +3 -0
- data/lib/recipe_scrapers/sites/ch/bettybossi.rb +3 -0
- data/lib/recipe_scrapers/sites/ch/migusto.rb +3 -0
- data/lib/recipe_scrapers/sites/ch/swissmilk.rb +3 -0
- data/lib/recipe_scrapers/sites/co/tasty.rb +3 -0
- data/lib/recipe_scrapers/sites/com/365daysofbakingandmore.rb +3 -0
- data/lib/recipe_scrapers/sites/com/40aprons.rb +3 -0
- data/lib/recipe_scrapers/sites/com/750g.rb +3 -0
- data/lib/recipe_scrapers/sites/com/abeautifulmess.rb +3 -0
- data/lib/recipe_scrapers/sites/com/abuelascounter.rb +3 -0
- data/lib/recipe_scrapers/sites/com/acozykitchen.rb +3 -0
- data/lib/recipe_scrapers/sites/com/addapinch.rb +3 -0
- data/lib/recipe_scrapers/sites/com/adozensundays.rb +3 -0
- data/lib/recipe_scrapers/sites/com/aflavorjournal.rb +3 -0
- data/lib/recipe_scrapers/sites/com/afullliving.rb +3 -0
- data/lib/recipe_scrapers/sites/com/ahealthysliceoflife.rb +5 -0
- data/lib/recipe_scrapers/sites/com/alexandracooks.rb +3 -0
- data/lib/recipe_scrapers/sites/com/allnutritious.rb +3 -0
- data/lib/recipe_scrapers/sites/com/allthehealthythings.rb +3 -0
- data/lib/recipe_scrapers/sites/com/altonbrown.rb +3 -0
- data/lib/recipe_scrapers/sites/com/amazingribs.rb +3 -0
- data/lib/recipe_scrapers/sites/com/amberskitchencooks.rb +3 -0
- data/lib/recipe_scrapers/sites/com/andy_cooks.rb +3 -0
- data/lib/recipe_scrapers/sites/com/anitalianinmykitchen.rb +3 -0
- data/lib/recipe_scrapers/sites/com/app.rb +27 -0
- data/lib/recipe_scrapers/sites/com/asweetpeachef.rb +3 -0
- data/lib/recipe_scrapers/sites/com/aubreyskitchen.rb +3 -0
- data/lib/recipe_scrapers/sites/com/averiecooks.rb +3 -0
- data/lib/recipe_scrapers/sites/com/bake_eat_repeat.rb +3 -0
- data/lib/recipe_scrapers/sites/com/bakerbynature.rb +3 -0
- data/lib/recipe_scrapers/sites/com/bakewithzoha.rb +3 -0
- data/lib/recipe_scrapers/sites/com/baking_sense.rb +3 -0
- data/lib/recipe_scrapers/sites/com/bakingmischief.rb +3 -0
- data/lib/recipe_scrapers/sites/com/barefeetinthekitchen.rb +3 -0
- data/lib/recipe_scrapers/sites/com/barefootinthepines.rb +3 -0
- data/lib/recipe_scrapers/sites/com/bbcgoodfood.rb +3 -0
- data/lib/recipe_scrapers/sites/com/belleofthekitchen.rb +3 -0
- data/lib/recipe_scrapers/sites/com/betterfoodguru.rb +3 -0
- data/lib/recipe_scrapers/sites/com/bettycrocker.rb +3 -0
- data/lib/recipe_scrapers/sites/com/beyondfrosting.rb +3 -0
- data/lib/recipe_scrapers/sites/com/biancazapatka.rb +3 -0
- data/lib/recipe_scrapers/sites/com/biggerbolderbaking.rb +3 -0
- data/lib/recipe_scrapers/sites/com/billyparisi.rb +3 -0
- data/lib/recipe_scrapers/sites/com/bitsofcarey.rb +3 -0
- data/lib/recipe_scrapers/sites/com/blogghetti.rb +3 -0
- data/lib/recipe_scrapers/sites/com/bluejeanchef.rb +3 -0
- data/lib/recipe_scrapers/sites/com/bonappetit.rb +3 -0
- data/lib/recipe_scrapers/sites/com/bonviveur.rb +3 -0
- data/lib/recipe_scrapers/sites/com/bowlofdelicious.rb +3 -0
- data/lib/recipe_scrapers/sites/com/breadtopia.rb +3 -0
- data/lib/recipe_scrapers/sites/com/brokenovenbaking.rb +3 -0
- data/lib/recipe_scrapers/sites/com/budgetbytes.rb +3 -0
- data/lib/recipe_scrapers/sites/com/cafedelites.rb +3 -0
- data/lib/recipe_scrapers/sites/com/cakemehometonight.rb +3 -0
- data/lib/recipe_scrapers/sites/com/cakewhiz.rb +3 -0
- data/lib/recipe_scrapers/sites/com/cambreabakes.rb +3 -0
- data/lib/recipe_scrapers/sites/com/carlsbadcravings.rb +3 -0
- data/lib/recipe_scrapers/sites/com/castironskilletcooking.rb +3 -0
- data/lib/recipe_scrapers/sites/com/celebratingsweets.rb +3 -0
- data/lib/recipe_scrapers/sites/com/chefjackovens.rb +3 -0
- data/lib/recipe_scrapers/sites/com/chefsavvy.rb +3 -0
- data/lib/recipe_scrapers/sites/com/chewoutloud.rb +3 -0
- data/lib/recipe_scrapers/sites/com/chocolatewithgrace.rb +3 -0
- data/lib/recipe_scrapers/sites/com/ciaoflorentina.rb +3 -0
- data/lib/recipe_scrapers/sites/com/closetcooking.rb +3 -0
- data/lib/recipe_scrapers/sites/com/cloudykitchen.rb +3 -0
- data/lib/recipe_scrapers/sites/com/coleycooks.rb +3 -0
- data/lib/recipe_scrapers/sites/com/cookedandloved.rb +3 -0
- data/lib/recipe_scrapers/sites/com/cookfastrecipes.rb +3 -0
- data/lib/recipe_scrapers/sites/com/cookieandkate.rb +3 -0
- data/lib/recipe_scrapers/sites/com/cookiesandcups.rb +3 -0
- data/lib/recipe_scrapers/sites/com/cooking.rb +3 -0
- data/lib/recipe_scrapers/sites/com/cookingclassy.rb +3 -0
- data/lib/recipe_scrapers/sites/com/cookinglsl.rb +3 -0
- data/lib/recipe_scrapers/sites/com/cookingwithjanica.rb +3 -0
- data/lib/recipe_scrapers/sites/com/cookpad.rb +3 -0
- data/lib/recipe_scrapers/sites/com/cookwell.rb +3 -0
- data/lib/recipe_scrapers/sites/com/cookwithdana.rb +3 -0
- data/lib/recipe_scrapers/sites/com/copykat.rb +3 -0
- data/lib/recipe_scrapers/sites/com/countryliving.rb +3 -0
- data/lib/recipe_scrapers/sites/com/crazyforcrust.rb +3 -0
- data/lib/recipe_scrapers/sites/com/creativecanning.rb +3 -0
- data/lib/recipe_scrapers/sites/com/cuisineaz.rb +3 -0
- data/lib/recipe_scrapers/sites/com/daringgourmet.rb +3 -0
- data/lib/recipe_scrapers/sites/com/dashfordinner.rb +3 -0
- data/lib/recipe_scrapers/sites/com/davidlebovitz.rb +3 -0
- data/lib/recipe_scrapers/sites/com/deliciouslyella.rb +3 -0
- data/lib/recipe_scrapers/sites/com/deliciouslysprinkled.rb +3 -0
- data/lib/recipe_scrapers/sites/com/delish.rb +3 -0
- data/lib/recipe_scrapers/sites/com/delscookingtwist.rb +3 -0
- data/lib/recipe_scrapers/sites/com/dinneratthezoo.rb +3 -0
- data/lib/recipe_scrapers/sites/com/directoalpaladar.rb +3 -0
- data/lib/recipe_scrapers/sites/com/domesticate_me.rb +3 -0
- data/lib/recipe_scrapers/sites/com/downshiftology.rb +3 -0
- data/lib/recipe_scrapers/sites/com/eatingbirdfood.rb +3 -0
- data/lib/recipe_scrapers/sites/com/eatingeuropean.rb +3 -0
- data/lib/recipe_scrapers/sites/com/eatingonadime.rb +3 -0
- data/lib/recipe_scrapers/sites/com/eatliverun.rb +3 -0
- data/lib/recipe_scrapers/sites/com/eatthismuch.rb +3 -0
- data/lib/recipe_scrapers/sites/com/elavegan.rb +3 -0
- data/lib/recipe_scrapers/sites/com/emilybites.rb +3 -0
- data/lib/recipe_scrapers/sites/com/erinliveswhole.rb +3 -0
- data/lib/recipe_scrapers/sites/com/erinscozykitchen.rb +3 -0
- data/lib/recipe_scrapers/sites/com/errenskitchen.rb +3 -0
- data/lib/recipe_scrapers/sites/com/everyday_delicious.rb +3 -0
- data/lib/recipe_scrapers/sites/com/everydaypie.rb +3 -0
- data/lib/recipe_scrapers/sites/com/evolvingtable.rb +3 -0
- data/lib/recipe_scrapers/sites/com/familyfoodonthetable.rb +3 -0
- data/lib/recipe_scrapers/sites/com/familyspice.rb +3 -0
- data/lib/recipe_scrapers/sites/com/fantabulosity.rb +3 -0
- data/lib/recipe_scrapers/sites/com/farmhouseonboone.rb +3 -0
- data/lib/recipe_scrapers/sites/com/farmtojar.rb +3 -0
- data/lib/recipe_scrapers/sites/com/feastingathome.rb +3 -0
- data/lib/recipe_scrapers/sites/com/fifteenspatulas.rb +3 -0
- data/lib/recipe_scrapers/sites/com/figjar.rb +3 -0
- data/lib/recipe_scrapers/sites/com/finedininglovers.rb +3 -0
- data/lib/recipe_scrapers/sites/com/fithealthymacros.rb +3 -0
- data/lib/recipe_scrapers/sites/com/fitslowcookerqueen.rb +3 -0
- data/lib/recipe_scrapers/sites/com/food.rb +3 -0
- data/lib/recipe_scrapers/sites/com/foodbymaria.rb +3 -0
- data/lib/recipe_scrapers/sites/com/foodfidelity.rb +3 -0
- data/lib/recipe_scrapers/sites/com/foodrepublic.rb +3 -0
- data/lib/recipe_scrapers/sites/com/forksoverknives.rb +3 -0
- data/lib/recipe_scrapers/sites/com/garlicandzest.rb +3 -0
- data/lib/recipe_scrapers/sites/com/garnishandglaze.rb +3 -0
- data/lib/recipe_scrapers/sites/com/gimmesomeoven.rb +3 -0
- data/lib/recipe_scrapers/sites/com/girlgonegourmet.rb +3 -0
- data/lib/recipe_scrapers/sites/com/girlversusdough.rb +3 -0
- data/lib/recipe_scrapers/sites/com/gloriousrecipes.rb +3 -0
- data/lib/recipe_scrapers/sites/com/glutenfreeonashoestring.rb +3 -0
- data/lib/recipe_scrapers/sites/com/gonnawantseconds.rb +3 -0
- data/lib/recipe_scrapers/sites/com/goodfooddiscoveries.rb +3 -0
- data/lib/recipe_scrapers/sites/com/goodhousekeeping.rb +3 -0
- data/lib/recipe_scrapers/sites/com/grandbaby_cakes.rb +3 -0
- data/lib/recipe_scrapers/sites/com/greatbritishchefs.rb +3 -0
- data/lib/recipe_scrapers/sites/com/halfbakedharvest.rb +3 -0
- data/lib/recipe_scrapers/sites/com/hassanchef.rb +3 -0
- data/lib/recipe_scrapers/sites/com/headbangerskitchen.rb +3 -0
- data/lib/recipe_scrapers/sites/com/healthywithachanceofsprinkles.rb +3 -0
- data/lib/recipe_scrapers/sites/com/heatherchristo.rb +3 -0
- data/lib/recipe_scrapers/sites/com/hilahcooking.rb +3 -0
- data/lib/recipe_scrapers/sites/com/hogarmania.rb +3 -0
- data/lib/recipe_scrapers/sites/com/homeandplate.rb +3 -0
- data/lib/recipe_scrapers/sites/com/hostthetoast.rb +3 -0
- data/lib/recipe_scrapers/sites/com/houseofnasheats.rb +3 -0
- data/lib/recipe_scrapers/sites/com/houseofyumm.rb +3 -0
- data/lib/recipe_scrapers/sites/com/iamafoodblog.rb +3 -0
- data/lib/recipe_scrapers/sites/com/im_worthy.rb +3 -0
- data/lib/recipe_scrapers/sites/com/inbloombakery.rb +3 -0
- data/lib/recipe_scrapers/sites/com/indianhealthyrecipes.rb +3 -0
- data/lib/recipe_scrapers/sites/com/ingoodflavor.rb +3 -0
- data/lib/recipe_scrapers/sites/com/inspiralized.rb +3 -0
- data/lib/recipe_scrapers/sites/com/itdoesnttastelikechicken.rb +3 -0
- data/lib/recipe_scrapers/sites/com/itsnotaboutnutrition.rb +3 -0
- data/lib/recipe_scrapers/sites/com/izzycooking.rb +3 -0
- data/lib/recipe_scrapers/sites/com/jennycancook.rb +3 -0
- data/lib/recipe_scrapers/sites/com/jimcooksfoodgood.rb +3 -0
- data/lib/recipe_scrapers/sites/com/jocooks.rb +3 -0
- data/lib/recipe_scrapers/sites/com/joyfullymad.rb +3 -0
- data/lib/recipe_scrapers/sites/com/joythebaker.rb +3 -0
- data/lib/recipe_scrapers/sites/com/juliasalbum.rb +3 -0
- data/lib/recipe_scrapers/sites/com/justataste.rb +3 -0
- data/lib/recipe_scrapers/sites/com/justinesnacks.rb +3 -0
- data/lib/recipe_scrapers/sites/com/kennethtemple.rb +3 -0
- data/lib/recipe_scrapers/sites/com/keytomylime.rb +3 -0
- data/lib/recipe_scrapers/sites/com/kitchendreaming.rb +3 -0
- data/lib/recipe_scrapers/sites/com/kitchenstories.rb +3 -0
- data/lib/recipe_scrapers/sites/com/kristineskitchenblog.rb +3 -0
- data/lib/recipe_scrapers/sites/com/krollskorner.rb +3 -0
- data/lib/recipe_scrapers/sites/com/lanascooking.rb +3 -0
- data/lib/recipe_scrapers/sites/com/laurenslatest.rb +3 -0
- data/lib/recipe_scrapers/sites/com/lazycatkitchen.rb +3 -0
- data/lib/recipe_scrapers/sites/com/lecremedelacrumb.rb +3 -0
- data/lib/recipe_scrapers/sites/com/leitesculinaria.rb +3 -0
- data/lib/recipe_scrapers/sites/com/lekkerensimpel.rb +3 -0
- data/lib/recipe_scrapers/sites/com/letscampsmore.rb +3 -0
- data/lib/recipe_scrapers/sites/com/lifestyleofafoodie.rb +3 -0
- data/lib/recipe_scrapers/sites/com/littleferrarokitchen.rb +3 -0
- data/lib/recipe_scrapers/sites/com/littlespicejar.rb +3 -0
- data/lib/recipe_scrapers/sites/com/littlespoonfarm.rb +3 -0
- data/lib/recipe_scrapers/sites/com/littlesunnykitchen.rb +3 -0
- data/lib/recipe_scrapers/sites/com/livelytable.rb +3 -0
- data/lib/recipe_scrapers/sites/com/lolascocina.rb +3 -0
- data/lib/recipe_scrapers/sites/com/lovingitvegan.rb +3 -0
- data/lib/recipe_scrapers/sites/com/madsvin.rb +3 -0
- data/lib/recipe_scrapers/sites/com/makeitdairyfree.rb +3 -0
- data/lib/recipe_scrapers/sites/com/mealprepmanual.rb +3 -0
- data/lib/recipe_scrapers/sites/com/meganvskitchen.rb +3 -0
- data/lib/recipe_scrapers/sites/com/melissaknorris.rb +3 -0
- data/lib/recipe_scrapers/sites/com/melskitchencafe.rb +3 -0
- data/lib/recipe_scrapers/sites/com/mexicanplease.rb +3 -0
- data/lib/recipe_scrapers/sites/com/minimalistbaker.rb +3 -0
- data/lib/recipe_scrapers/sites/com/ministryofcurry.rb +3 -0
- data/lib/recipe_scrapers/sites/com/modernhoney.rb +3 -0
- data/lib/recipe_scrapers/sites/com/momontimeout.rb +3 -0
- data/lib/recipe_scrapers/sites/com/momswithcrockpots.rb +3 -0
- data/lib/recipe_scrapers/sites/com/motherthyme.rb +3 -0
- data/lib/recipe_scrapers/sites/com/mybakingaddiction.rb +3 -0
- data/lib/recipe_scrapers/sites/com/myjewishlearning.rb +3 -0
- data/lib/recipe_scrapers/sites/com/mykidslickthebowl.rb +3 -0
- data/lib/recipe_scrapers/sites/com/mykoreankitchen.rb +3 -0
- data/lib/recipe_scrapers/sites/com/myriadrecipes.rb +3 -0
- data/lib/recipe_scrapers/sites/com/myvegetarianroots.rb +3 -0
- data/lib/recipe_scrapers/sites/com/natashaskitchen.rb +3 -0
- data/lib/recipe_scrapers/sites/com/naturallyella.rb +3 -0
- data/lib/recipe_scrapers/sites/com/noracooks.rb +3 -0
- data/lib/recipe_scrapers/sites/com/norecipes.rb +3 -0
- data/lib/recipe_scrapers/sites/com/notenoughcinnamon.rb +3 -0
- data/lib/recipe_scrapers/sites/com/nourishedbynutrition.rb +3 -0
- data/lib/recipe_scrapers/sites/com/number_2_pencil.rb +3 -0
- data/lib/recipe_scrapers/sites/com/onceuponachef.rb +3 -0
- data/lib/recipe_scrapers/sites/com/onesweetappetite.rb +3 -0
- data/lib/recipe_scrapers/sites/com/organicallyaddison.rb +3 -0
- data/lib/recipe_scrapers/sites/com/ourbestbites.rb +3 -0
- data/lib/recipe_scrapers/sites/com/paleorunningmomma.rb +3 -0
- data/lib/recipe_scrapers/sites/com/panlasangpinoy.rb +3 -0
- data/lib/recipe_scrapers/sites/com/peelwithzeal.rb +3 -0
- data/lib/recipe_scrapers/sites/com/pequerecetas.rb +3 -0
- data/lib/recipe_scrapers/sites/com/pilipinasrecipes.rb +3 -0
- data/lib/recipe_scrapers/sites/com/pinkowlkitchen.rb +3 -0
- data/lib/recipe_scrapers/sites/com/plantyou.rb +3 -0
- data/lib/recipe_scrapers/sites/com/platingpixels.rb +3 -0
- data/lib/recipe_scrapers/sites/com/platingsandpairings.rb +3 -0
- data/lib/recipe_scrapers/sites/com/plowingthroughlife.rb +3 -0
- data/lib/recipe_scrapers/sites/com/polishfoodies.rb +3 -0
- data/lib/recipe_scrapers/sites/com/practicalselfreliance.rb +3 -0
- data/lib/recipe_scrapers/sites/com/preppykitchen.rb +3 -0
- data/lib/recipe_scrapers/sites/com/pressureluckcooking.rb +3 -0
- data/lib/recipe_scrapers/sites/com/purelypope.rb +3 -0
- data/lib/recipe_scrapers/sites/com/rachlmansfield.rb +3 -0
- data/lib/recipe_scrapers/sites/com/rainbowplantlife.rb +3 -0
- data/lib/recipe_scrapers/sites/com/realfoodwell.rb +3 -0
- data/lib/recipe_scrapers/sites/com/realmomnutrition.rb +3 -0
- data/lib/recipe_scrapers/sites/com/receitas.rb +3 -0
- data/lib/recipe_scrapers/sites/com/recipeforperfection.rb +3 -0
- data/lib/recipe_scrapers/sites/com/recipegirl.rb +3 -0
- data/lib/recipe_scrapers/sites/com/reciperunner.rb +3 -0
- data/lib/recipe_scrapers/sites/com/recipetineats.rb +3 -0
- data/lib/recipe_scrapers/sites/com/redhousespice.rb +3 -0
- data/lib/recipe_scrapers/sites/com/ricardocuisine.rb +3 -0
- data/lib/recipe_scrapers/sites/com/sallysbakingaddiction.rb +3 -0
- data/lib/recipe_scrapers/sites/com/saltpepperskillet.rb +3 -0
- data/lib/recipe_scrapers/sites/com/sandwichtribunal.rb +3 -0
- data/lib/recipe_scrapers/sites/com/savoringthegood.rb +3 -0
- data/lib/recipe_scrapers/sites/com/savorynothings.rb +3 -0
- data/lib/recipe_scrapers/sites/com/savorythoughts.rb +3 -0
- data/lib/recipe_scrapers/sites/com/scrummylane.rb +3 -0
- data/lib/recipe_scrapers/sites/com/shelikesfood.rb +3 -0
- data/lib/recipe_scrapers/sites/com/simple_veganista.rb +3 -0
- data/lib/recipe_scrapers/sites/com/simplehomeedit.rb +3 -0
- data/lib/recipe_scrapers/sites/com/simplyquinoa.rb +3 -0
- data/lib/recipe_scrapers/sites/com/simplywhisked.rb +3 -0
- data/lib/recipe_scrapers/sites/com/sipandfeast.rb +3 -0
- data/lib/recipe_scrapers/sites/com/sizzlefish.rb +3 -0
- data/lib/recipe_scrapers/sites/com/skinnytaste.rb +3 -0
- data/lib/recipe_scrapers/sites/com/smalltownwoman.rb +3 -0
- data/lib/recipe_scrapers/sites/com/somuchfoodblog.rb +3 -0
- data/lib/recipe_scrapers/sites/com/southernbite.rb +3 -0
- data/lib/recipe_scrapers/sites/com/spainonafork.rb +3 -0
- data/lib/recipe_scrapers/sites/com/spendwithpennies.rb +3 -0
- data/lib/recipe_scrapers/sites/com/spicysouthernkitchen.rb +3 -0
- data/lib/recipe_scrapers/sites/com/stacyling.rb +3 -0
- data/lib/recipe_scrapers/sites/com/staysnatched.rb +3 -0
- data/lib/recipe_scrapers/sites/com/steamykitchen.rb +3 -0
- data/lib/recipe_scrapers/sites/com/strongrfastr.rb +3 -0
- data/lib/recipe_scrapers/sites/com/sudachirecipes.rb +3 -0
- data/lib/recipe_scrapers/sites/com/sugarhero.rb +3 -0
- data/lib/recipe_scrapers/sites/com/sugarmaplefarmhouse.rb +3 -0
- data/lib/recipe_scrapers/sites/com/sugarspunrun.rb +3 -0
- data/lib/recipe_scrapers/sites/com/sunset.rb +3 -0
- data/lib/recipe_scrapers/sites/com/sweetcsdesigns.rb +3 -0
- data/lib/recipe_scrapers/sites/com/tableanddish.rb +3 -0
- data/lib/recipe_scrapers/sites/com/tasteandtellblog.rb +3 -0
- data/lib/recipe_scrapers/sites/com/tasteofhome.rb +3 -0
- data/lib/recipe_scrapers/sites/com/tastesbetterfromscratch.rb +3 -0
- data/lib/recipe_scrapers/sites/com/tastesoflizzyt.rb +3 -0
- data/lib/recipe_scrapers/sites/com/tastyoven.rb +3 -0
- data/lib/recipe_scrapers/sites/com/tatyanaseverydayfood.rb +3 -0
- data/lib/recipe_scrapers/sites/com/teakandthyme.rb +3 -0
- data/lib/recipe_scrapers/sites/com/thealmondeater.rb +3 -0
- data/lib/recipe_scrapers/sites/com/thebigmansworld.rb +3 -0
- data/lib/recipe_scrapers/sites/com/theclevercarrot.rb +3 -0
- data/lib/recipe_scrapers/sites/com/thecookierookie.rb +3 -0
- data/lib/recipe_scrapers/sites/com/thefoodcharlatan.rb +3 -0
- data/lib/recipe_scrapers/sites/com/thefoodietakesflight.rb +3 -0
- data/lib/recipe_scrapers/sites/com/theglutenfreeaustrian.rb +3 -0
- data/lib/recipe_scrapers/sites/com/theguardian.rb +3 -0
- data/lib/recipe_scrapers/sites/com/theicecreamconfectionals.rb +3 -0
- data/lib/recipe_scrapers/sites/com/thekitchenmagpie.rb +3 -0
- data/lib/recipe_scrapers/sites/com/themagicalslowcooker.rb +3 -0
- data/lib/recipe_scrapers/sites/com/themediterraneandish.rb +3 -0
- data/lib/recipe_scrapers/sites/com/themodernproper.rb +3 -0
- data/lib/recipe_scrapers/sites/com/thepalatablelife.rb +3 -0
- data/lib/recipe_scrapers/sites/com/thepioneerwoman.rb +3 -0
- data/lib/recipe_scrapers/sites/com/theppk.rb +3 -0
- data/lib/recipe_scrapers/sites/com/therecipecritic.rb +3 -0
- data/lib/recipe_scrapers/sites/com/thesaltymarshmallow.rb +3 -0
- data/lib/recipe_scrapers/sites/com/thespicetrain.rb +3 -0
- data/lib/recipe_scrapers/sites/com/thesuburbansoapbox.rb +3 -0
- data/lib/recipe_scrapers/sites/com/thevintagemixer.rb +3 -0
- data/lib/recipe_scrapers/sites/com/thewoodenskillet.rb +3 -0
- data/lib/recipe_scrapers/sites/com/thinlicious.rb +3 -0
- data/lib/recipe_scrapers/sites/com/thishealthytable.rb +3 -0
- data/lib/recipe_scrapers/sites/com/toriavey.rb +3 -0
- data/lib/recipe_scrapers/sites/com/twopeasandtheirpod.rb +3 -0
- data/lib/recipe_scrapers/sites/com/unsophisticook.rb +3 -0
- data/lib/recipe_scrapers/sites/com/valentinascorner.rb +3 -0
- data/lib/recipe_scrapers/sites/com/vanillaandbean.rb +3 -0
- data/lib/recipe_scrapers/sites/com/veganricha.rb +3 -0
- data/lib/recipe_scrapers/sites/com/vegrecipesofindia.rb +3 -0
- data/lib/recipe_scrapers/sites/com/wearenotmartha.rb +3 -0
- data/lib/recipe_scrapers/sites/com/wedishitup.rb +3 -0
- data/lib/recipe_scrapers/sites/com/wellplated.rb +3 -0
- data/lib/recipe_scrapers/sites/com/whatsgabycooking.rb +3 -0
- data/lib/recipe_scrapers/sites/com/wyseguide.rb +3 -0
- data/lib/recipe_scrapers/sites/com/yemek.rb +3 -0
- data/lib/recipe_scrapers/sites/com/zenbelly.rb +3 -0
- data/lib/recipe_scrapers/sites/com/zestfulkitchen.rb +3 -0
- data/lib/recipe_scrapers/sites/de/bofrost.rb +3 -0
- data/lib/recipe_scrapers/sites/de/chefkoch.rb +26 -0
- data/lib/recipe_scrapers/sites/de/eattolerant.rb +3 -0
- data/lib/recipe_scrapers/sites/de/emmikochteinfach.rb +3 -0
- data/lib/recipe_scrapers/sites/de/kochbar.rb +3 -0
- data/lib/recipe_scrapers/sites/de/springlane.rb +3 -0
- data/lib/recipe_scrapers/sites/dk/madensverden.rb +3 -0
- data/lib/recipe_scrapers/sites/dk/sundpaabudget.rb +3 -0
- data/lib/recipe_scrapers/sites/fr/jow.rb +3 -0
- data/lib/recipe_scrapers/sites/fr/madame.rb +35 -0
- data/lib/recipe_scrapers/sites/hu/nosalty.rb +3 -0
- data/lib/recipe_scrapers/sites/hu/sobors.rb +3 -0
- data/lib/recipe_scrapers/sites/info/misya.rb +3 -0
- data/lib/recipe_scrapers/sites/it/cucchiaio.rb +3 -0
- data/lib/recipe_scrapers/sites/it/ricette.rb +3 -0
- data/lib/recipe_scrapers/sites/it/ricetteperbimby.rb +3 -0
- data/lib/recipe_scrapers/sites/me/leckerschmecker.rb +3 -0
- data/lib/recipe_scrapers/sites/menu/1000.rb +3 -0
- data/lib/recipe_scrapers/sites/mx/cocinafacil.rb +3 -0
- data/lib/recipe_scrapers/sites/mx/directoalpaladar.rb +3 -0
- data/lib/recipe_scrapers/sites/mx/recetasnestle.rb +3 -0
- data/lib/recipe_scrapers/sites/net/bellyfull.rb +3 -0
- data/lib/recipe_scrapers/sites/net/castironketo.rb +3 -0
- data/lib/recipe_scrapers/sites/net/feelgoodfoodie.rb +3 -0
- data/lib/recipe_scrapers/sites/net/hungryhappens.rb +3 -0
- data/lib/recipe_scrapers/sites/net/iambaker.rb +3 -0
- data/lib/recipe_scrapers/sites/net/inspiredtaste.rb +3 -0
- data/lib/recipe_scrapers/sites/net/piesandplots.rb +3 -0
- data/lib/recipe_scrapers/sites/net/tidymom.rb +3 -0
- data/lib/recipe_scrapers/sites/nl/leukerecepten.rb +3 -0
- data/lib/recipe_scrapers/sites/nl/projectgezond.rb +3 -0
- data/lib/recipe_scrapers/sites/no/godt.rb +3 -0
- data/lib/recipe_scrapers/sites/no/matprat.rb +3 -0
- data/lib/recipe_scrapers/sites/no/nrk.rb +3 -0
- data/lib/recipe_scrapers/sites/no/tine.rb +3 -0
- data/lib/recipe_scrapers/sites/org/lmld.rb +3 -0
- data/lib/recipe_scrapers/sites/org/marmiton.rb +3 -0
- data/lib/recipe_scrapers/sites/org/nutritionfacts.rb +3 -0
- data/lib/recipe_scrapers/sites/org/thekitchencommunity.rb +3 -0
- data/lib/recipe_scrapers/sites/plus/recette.rb +3 -0
- data/lib/recipe_scrapers/sites/pt/pingodoce.rb +3 -0
- data/lib/recipe_scrapers/sites/pt/teleculinaria.rb +3 -0
- data/lib/recipe_scrapers/sites/recipes/goodstuff.rb +3 -0
- data/lib/recipe_scrapers/sites/recipes/howtocook.rb +3 -0
- data/lib/recipe_scrapers/sites/ru/eda.rb +3 -0
- data/lib/recipe_scrapers/sites/ru/edimdoma.rb +3 -0
- data/lib/recipe_scrapers/sites/ru/gastronom.rb +3 -0
- data/lib/recipe_scrapers/sites/ru/iamcook.rb +6 -0
- data/lib/recipe_scrapers/sites/ru/povar.rb +3 -0
- data/lib/recipe_scrapers/sites/ru/russianfood.rb +8 -0
- data/lib/recipe_scrapers/sites/se/alltommat.rb +3 -0
- data/lib/recipe_scrapers/sites/se/ica.rb +3 -0
- data/lib/recipe_scrapers/sites/se/kiddokitchen.rb +3 -0
- data/lib/recipe_scrapers/sites/se/koket.rb +3 -0
- data/lib/recipe_scrapers/sites/se/recept.rb +3 -0
- data/lib/recipe_scrapers/sites/sk/receptyprevas.rb +3 -0
- data/lib/recipe_scrapers/sources/declared.rb +38 -0
- data/lib/recipe_scrapers/sources/ingredient_groups.rb +74 -0
- data/lib/recipe_scrapers/sources/json_ld.rb +93 -0
- data/lib/recipe_scrapers/sources/microdata.rb +113 -0
- data/lib/recipe_scrapers/sources/open_graph.rb +26 -0
- data/lib/recipe_scrapers/sources/schema_org/ingredient_list.rb +36 -0
- data/lib/recipe_scrapers/sources/schema_org/nutrition_facts.rb +21 -0
- data/lib/recipe_scrapers/sources/schema_org.rb +160 -0
- data/lib/recipe_scrapers/text.rb +59 -0
- data/lib/recipe_scrapers/version.rb +6 -0
- data/lib/recipe_scrapers.rb +137 -0
- metadata +560 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a7ef3e9e75436df3b37c84f48c98c7d09586560a9c1d90b567ee990b5ea9d288
|
|
4
|
+
data.tar.gz: c1fab2685faf5fbb236a74b8c9b4b5130b3f0c0c351c9fedfec919768e6dc260
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 06cf44e4b1e4bc58c93d4176bd9bb8140ddcc9db43e6593f8685eea72557f709e8488245e82b8fb6289547a39d91072e6ee17084bab3a3d65a6927644eb35200
|
|
7
|
+
data.tar.gz: 11b3a22c61673e7232411f915e575aad8ef2f8f3b6ac838e54f63e03a21b023b2817c03aa4a415213530d6d8c7f515fae2befa0b31834ba79d2f742429f586ca
|
data/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David Khotelov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
6
|
+
this software and associated documentation files (the "Software"), to deal in
|
|
7
|
+
the Software without restriction, including without limitation the rights to
|
|
8
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
|
9
|
+
the Software, and to permit persons to whom the Software is furnished to do so,
|
|
10
|
+
subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
17
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
18
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
19
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
20
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# recipe_scrapers
|
|
2
|
+
|
|
3
|
+
A Ruby gem that reads recipes from cooking websites: the title, ingredients, instructions, times,
|
|
4
|
+
yields and nutrition. It uses the [schema.org](https://schema.org/Recipe) markup most recipe sites
|
|
5
|
+
publish, falls back to [OpenGraph](https://ogp.me/), and reads the HTML directly for sites that
|
|
6
|
+
publish neither. It does not get around bot protection.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```console
|
|
11
|
+
bundle add recipe_scrapers
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The gem runs on every Ruby version that has not reached its end of life.
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
require "recipe_scrapers"
|
|
20
|
+
|
|
21
|
+
recipe = RecipeScrapers.scrape("https://www.recipetineats.com/crispy-potato-straws-pommes-paille/")
|
|
22
|
+
recipe.title # => "Crispy potato straws (Pommes Paille)"
|
|
23
|
+
recipe.ingredients[1] # => "1 1/2 - 2 cups vegetable oil (canola, sunflower or peanut oil)"
|
|
24
|
+
recipe.parsed_ingredients[1].to_h # => { amount: 1.5, unit: "cups", name: "vegetable oil" }
|
|
25
|
+
recipe.cook_time # => 10
|
|
26
|
+
recipe.to_h # every field as plain data, ready for JSON
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
To read HTML you already have, without a request:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
recipe = RecipeScrapers.parse(html, url: url)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Documentation
|
|
36
|
+
|
|
37
|
+
- [Usage](wiki/Usage.md): fetching, other HTTP clients, unsupported sites, errors
|
|
38
|
+
- [Recipe Fields](wiki/Recipe-Fields.md): every field with its type and an example
|
|
39
|
+
- [Configuration](wiki/Configuration.md): timeouts, size limits, your own connection
|
|
40
|
+
- [Parsers](wiki/Parsers.md): your own ingredient and nutrient parsers
|
|
41
|
+
- [API reference](https://rubydoc.info/gems/recipe_scrapers)
|
|
42
|
+
- [Changelog](CHANGELOG.md)
|
|
43
|
+
- [Copyright and Usage](wiki/Copyright-and-Usage.md): what you are responsible for
|
|
44
|
+
|
|
45
|
+
## Contributing
|
|
46
|
+
|
|
47
|
+
A site stopped working, or you need a new one? [Open an issue](https://github.com/davqasd/recipe_scrapers/issues)
|
|
48
|
+
with the URL of a recipe page. To add it yourself, see [CONTRIBUTING](CONTRIBUTING.md). Report a
|
|
49
|
+
security problem privately, see [SECURITY](SECURITY.md).
|
|
50
|
+
|
|
51
|
+
## License
|
|
52
|
+
|
|
53
|
+
MIT, see [LICENSE](LICENSE). The pages recorded for the tests are not covered by it, see
|
|
54
|
+
[License](wiki/License.md).
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "faraday/retry"
|
|
5
|
+
|
|
6
|
+
module RecipeScrapers
|
|
7
|
+
# The settings for fetching pages and parsing fields. Read them through {RecipeScrapers.config}
|
|
8
|
+
# and change them with {RecipeScrapers.configure}. Changing a fetch setting rebuilds the connection
|
|
9
|
+
# on its next use, unless an application assigned its own with {#connection=}.
|
|
10
|
+
#
|
|
11
|
+
# @!attribute [rw] user_agent
|
|
12
|
+
# @return [String] the User-Agent header every request sends
|
|
13
|
+
# @!attribute [rw] timeout
|
|
14
|
+
# @return [Integer] seconds to wait for a whole response, 15 by default
|
|
15
|
+
# @!attribute [rw] open_timeout
|
|
16
|
+
# @return [Integer] seconds to wait for the connection to open, 5 by default
|
|
17
|
+
# @!attribute [rw] max_redirects
|
|
18
|
+
# @return [Integer] redirects to follow before {TooManyRedirects}, 3 by default
|
|
19
|
+
# @!attribute [rw] max_body_bytes
|
|
20
|
+
# @return [Integer] the largest body accepted before {ResponseTooLarge}, 5 MiB by default
|
|
21
|
+
# @!attribute [rw] allow_private_addresses
|
|
22
|
+
# @return [Boolean] fetch hosts that resolve to private or loopback addresses, false by default
|
|
23
|
+
# @!attribute [rw] error_tracker
|
|
24
|
+
# @return [#call] gets the error of a failing parser, {ErrorTracker} by default
|
|
25
|
+
# @!attribute [rw] retry_options
|
|
26
|
+
# @return [Hash] options for faraday-retry, used on timeouts, refused connections and 5xx
|
|
27
|
+
# @!attribute [rw] adapter
|
|
28
|
+
# @return [Symbol, Class] the Faraday adapter, `:recipe_scrapers_net_http` ({Http::Adapter}) by default
|
|
29
|
+
# @!attribute [rw] resolver
|
|
30
|
+
# @return [#call, nil] turns a host into IPAddr addresses, such as a DNS cache. Nil uses the
|
|
31
|
+
# system resolver
|
|
32
|
+
class Configuration
|
|
33
|
+
DEFAULT_ADAPTER = :recipe_scrapers_net_http
|
|
34
|
+
RETRIED = [Faraday::TimeoutError, Faraday::ConnectionFailed, Faraday::ServerError].freeze
|
|
35
|
+
|
|
36
|
+
SETTINGS = %i[
|
|
37
|
+
user_agent timeout open_timeout max_redirects max_body_bytes
|
|
38
|
+
allow_private_addresses error_tracker retry_options adapter resolver
|
|
39
|
+
].freeze
|
|
40
|
+
|
|
41
|
+
SETTINGS.each do |setting|
|
|
42
|
+
attr_reader setting
|
|
43
|
+
|
|
44
|
+
define_method(:"#{setting}=") do |value|
|
|
45
|
+
instance_variable_set(:"@#{setting}", value)
|
|
46
|
+
@connection = nil unless @connection_assigned
|
|
47
|
+
value
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
DEFAULTS = {
|
|
52
|
+
user_agent: "recipe_scrapers/#{VERSION} (+https://github.com/davqasd/recipe_scrapers)",
|
|
53
|
+
timeout: 15,
|
|
54
|
+
open_timeout: 5,
|
|
55
|
+
max_redirects: 3,
|
|
56
|
+
max_body_bytes: 5 * 1024 * 1024,
|
|
57
|
+
allow_private_addresses: false,
|
|
58
|
+
error_tracker: ErrorTracker,
|
|
59
|
+
retry_options: { max: 2, interval: 1, backoff_factor: 2 },
|
|
60
|
+
adapter: DEFAULT_ADAPTER,
|
|
61
|
+
resolver: nil
|
|
62
|
+
}.freeze
|
|
63
|
+
|
|
64
|
+
private_constant :DEFAULT_ADAPTER, :RETRIED, :SETTINGS, :DEFAULTS
|
|
65
|
+
|
|
66
|
+
# The parsers of every field that takes them, tried in order. A parser answers
|
|
67
|
+
# `call(text, language:)` with a hash of the model's attributes, or nil for text it cannot read.
|
|
68
|
+
#
|
|
69
|
+
# @example Put a parser in front of the bundled one
|
|
70
|
+
# RecipeScrapers.config.parsers[:ingredients].unshift(GramsOnly)
|
|
71
|
+
# @return [Hash{Symbol => Array<#call>}]
|
|
72
|
+
attr_reader :parsers
|
|
73
|
+
|
|
74
|
+
def initialize
|
|
75
|
+
DEFAULTS.each { |setting, value| instance_variable_set(:"@#{setting}", value.dup) }
|
|
76
|
+
@parsers = Parsers::Chain.defaults
|
|
77
|
+
@connection = nil
|
|
78
|
+
@connection_assigned = false
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The Faraday connection {RecipeScrapers.scrape} fetches with, built from these settings.
|
|
82
|
+
#
|
|
83
|
+
# @return [Faraday::Connection]
|
|
84
|
+
def connection
|
|
85
|
+
@connection ||= build_connection
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Replaces the connection with one the application builds. Nil goes back to the built one.
|
|
89
|
+
#
|
|
90
|
+
# @param value [Faraday::Connection, nil]
|
|
91
|
+
def connection=(value)
|
|
92
|
+
@connection_assigned = !value.nil?
|
|
93
|
+
@connection = value
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Builds a new connection with the retry, redirect, address guard, body limit and encoding
|
|
97
|
+
# middleware, in that order.
|
|
98
|
+
#
|
|
99
|
+
# @return [Faraday::Connection]
|
|
100
|
+
def build_connection
|
|
101
|
+
Faraday.new(headers: { "User-Agent" => user_agent }) do |faraday|
|
|
102
|
+
install_middleware(faraday)
|
|
103
|
+
faraday.options.timeout = timeout
|
|
104
|
+
faraday.options.open_timeout = open_timeout
|
|
105
|
+
faraday.adapter(adapter)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def install_middleware(faraday)
|
|
112
|
+
faraday.request(:retry, methods: [:get], exceptions: RETRIED, **retry_options)
|
|
113
|
+
faraday.use(:recipe_scrapers_follow_redirects, limit: max_redirects)
|
|
114
|
+
faraday.use(:recipe_scrapers_address_guard, allow_private: allow_private_addresses, resolver: resolver)
|
|
115
|
+
faraday.use(:recipe_scrapers_body_limit, bytes: max_body_bytes)
|
|
116
|
+
faraday.response(:recipe_scrapers_encoding)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RecipeScrapers
|
|
4
|
+
# Tells the gem where the recipe fields are on a site whose markup is missing or wrong. A
|
|
5
|
+
# declaration is built by {RecipeScrapers.register}. Inside its block, each recipe field is a
|
|
6
|
+
# method that takes a CSS selector:
|
|
7
|
+
#
|
|
8
|
+
# - `title "h1.recipe-title"` reads the text of the first match.
|
|
9
|
+
# - `ingredients rows: "ul.ingredients li"` reads one line per match, for ingredients,
|
|
10
|
+
# instructions, keywords and equipment.
|
|
11
|
+
# - `ingredient_groups heading: "h3", item: "li"` splits the ingredients under their headings.
|
|
12
|
+
#
|
|
13
|
+
# A declared field wins over the schema.org value. Every field left out falls back to it.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# RecipeScrapers.register "example.com" do
|
|
17
|
+
# title "h1.recipe-title"
|
|
18
|
+
# ingredients rows: ".ingredients li"
|
|
19
|
+
# instructions rows: ".steps li"
|
|
20
|
+
# encoding "windows-1251"
|
|
21
|
+
# end
|
|
22
|
+
class Declaration
|
|
23
|
+
DECLARABLE = (Scraper::CONTRACT - %i[host]).freeze
|
|
24
|
+
private_constant :DECLARABLE
|
|
25
|
+
|
|
26
|
+
# @return [String] the host the declaration was registered for
|
|
27
|
+
attr_reader :host
|
|
28
|
+
|
|
29
|
+
# @return [String, nil] the encoding the site sends without saying so, set with {#encoding}
|
|
30
|
+
attr_reader :encoding_name
|
|
31
|
+
|
|
32
|
+
# @param host [String]
|
|
33
|
+
# @yield the declaration body, evaluated on the new declaration
|
|
34
|
+
# @return [Declaration]
|
|
35
|
+
def self.build(host, &block)
|
|
36
|
+
new(host).tap { |declaration| declaration.instance_eval(&block) if block }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(host)
|
|
40
|
+
@host = host
|
|
41
|
+
@rules = {}
|
|
42
|
+
@parsers = {}
|
|
43
|
+
@encoding_name = nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @param field [Symbol] a field of {Scraper::CONTRACT}
|
|
47
|
+
# @return [Hash, nil] what the declaration says about the field: `{selector:}`, `{rows:}`, or
|
|
48
|
+
# `{heading:, item:}` for ingredient groups
|
|
49
|
+
#
|
|
50
|
+
# @api private
|
|
51
|
+
def rule(field)
|
|
52
|
+
@rules[field]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Declares the encoding of pages that send no charset, or the wrong one.
|
|
56
|
+
#
|
|
57
|
+
# @param name [String] an encoding Ruby knows, such as "windows-1251"
|
|
58
|
+
def encoding(name)
|
|
59
|
+
@encoding_name = name
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Sets the parsers of a field for this site, in the order they are tried.
|
|
63
|
+
#
|
|
64
|
+
# @param field [Symbol] :ingredients or :nutrients
|
|
65
|
+
# @param parsers [Array<#call>]
|
|
66
|
+
# @raise [ArgumentError] for a field that takes no parser
|
|
67
|
+
def parser(field, *parsers)
|
|
68
|
+
@parsers[Parsers::Chain.field!(field)] = parsers
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @param field [Symbol]
|
|
72
|
+
# @return [Array<#call>, nil] the parsers this site sets for the field
|
|
73
|
+
#
|
|
74
|
+
# @api private
|
|
75
|
+
def parsers_for(field)
|
|
76
|
+
@parsers[field]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
DECLARABLE.each do |field|
|
|
80
|
+
define_method(field) do |selector = nil, **options|
|
|
81
|
+
@rules[field] = selector ? { selector: selector } : options
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RecipeScrapers
|
|
4
|
+
# The default {Configuration#error_tracker}. It raises the error again, so a failing parser is
|
|
5
|
+
# never swallowed. An application replaces it with anything that answers call(error), such as a
|
|
6
|
+
# lambda that reports to its error service and returns.
|
|
7
|
+
module ErrorTracker
|
|
8
|
+
# @param exception [StandardError] the error a parser raised
|
|
9
|
+
# @raise [StandardError] always, the error it was given
|
|
10
|
+
def self.call(exception)
|
|
11
|
+
raise exception
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RecipeScrapers
|
|
4
|
+
# The ancestor of every error the gem raises.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
# Raised when the gem does not support the host and supported_only is on.
|
|
7
|
+
class UnsupportedSite < Error; end
|
|
8
|
+
# Raised when a page has no recipe title or no ingredients.
|
|
9
|
+
class RecipeNotFound < Error; end
|
|
10
|
+
# Raised when a response body is larger than {Configuration#max_body_bytes}.
|
|
11
|
+
class ResponseTooLarge < Error; end
|
|
12
|
+
# Raised when a host resolves to no address, or to a private, loopback or other non-public one.
|
|
13
|
+
class BlockedAddress < Error; end
|
|
14
|
+
# Raised when a redirect chain is longer than {Configuration#max_redirects}.
|
|
15
|
+
class TooManyRedirects < Error; end
|
|
16
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "faraday/net_http"
|
|
5
|
+
require_relative "address_guard"
|
|
6
|
+
|
|
7
|
+
module RecipeScrapers
|
|
8
|
+
# The Faraday adapter and middleware that fetch a page safely.
|
|
9
|
+
module Http
|
|
10
|
+
# Faraday's net/http adapter, connecting to the addresses {AddressGuard} checked instead of
|
|
11
|
+
# resolving the host again. It tries the pinned addresses in order and moves on when one does not
|
|
12
|
+
# answer. The host name still goes into the TLS handshake and the Host header. Registered as
|
|
13
|
+
# `:recipe_scrapers_net_http`, the default {Configuration#adapter}.
|
|
14
|
+
class Adapter < Faraday::Adapter::NetHttp
|
|
15
|
+
PIN_KEY = AddressGuard::PIN_KEY
|
|
16
|
+
ATTEMPT_KEY = :recipe_scrapers_address
|
|
17
|
+
private_constant :PIN_KEY, :ATTEMPT_KEY
|
|
18
|
+
|
|
19
|
+
# @param env [Faraday::Env]
|
|
20
|
+
# @return [Faraday::Response]
|
|
21
|
+
# @raise [Faraday::ConnectionFailed] when no pinned address answers
|
|
22
|
+
#
|
|
23
|
+
# @api private
|
|
24
|
+
def call(env)
|
|
25
|
+
addresses = Array(env[:request]&.context&.dig(PIN_KEY))
|
|
26
|
+
return super if addresses.empty?
|
|
27
|
+
|
|
28
|
+
addresses.each_with_index do |address, index|
|
|
29
|
+
env[ATTEMPT_KEY] = address
|
|
30
|
+
return super(env)
|
|
31
|
+
rescue Faraday::ConnectionFailed
|
|
32
|
+
raise if index == addresses.size - 1
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param env [Faraday::Env]
|
|
37
|
+
# @return [Net::HTTP] the connection, set to the pinned address unless a proxy is in use
|
|
38
|
+
#
|
|
39
|
+
# @api private
|
|
40
|
+
def net_http_connection(env)
|
|
41
|
+
super.tap do |http|
|
|
42
|
+
address = env[ATTEMPT_KEY] || Array(env[:request]&.context&.dig(PIN_KEY)).first
|
|
43
|
+
http.ipaddr = address if address && !http.proxy?
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
Faraday::Adapter.register_middleware(recipe_scrapers_net_http: RecipeScrapers::Http::Adapter)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "ipaddr"
|
|
5
|
+
require "resolv"
|
|
6
|
+
|
|
7
|
+
module RecipeScrapers
|
|
8
|
+
module Http
|
|
9
|
+
# Refuses a request whose host resolves to no address, or to a loopback, private, link-local,
|
|
10
|
+
# multicast, CGNAT or unspecified one, so a URL from a user cannot reach the internal network. It
|
|
11
|
+
# pins the addresses it checked for {Adapter}, which closes the window where DNS could answer
|
|
12
|
+
# differently the second time. Registered as `:recipe_scrapers_address_guard`.
|
|
13
|
+
class AddressGuard < Faraday::Middleware
|
|
14
|
+
CGNAT = IPAddr.new("100.64.0.0/10")
|
|
15
|
+
MULTICAST_V4 = IPAddr.new("224.0.0.0/4")
|
|
16
|
+
MULTICAST_V6 = IPAddr.new("ff00::/8")
|
|
17
|
+
UNSPECIFIED = [IPAddr.new("0.0.0.0"), IPAddr.new("::")].freeze
|
|
18
|
+
private_constant :CGNAT, :MULTICAST_V4, :MULTICAST_V6, :UNSPECIFIED
|
|
19
|
+
|
|
20
|
+
# The key of the request context where the guard pins the addresses it checked.
|
|
21
|
+
#
|
|
22
|
+
# @api private
|
|
23
|
+
PIN_KEY = :recipe_scrapers_resolve
|
|
24
|
+
|
|
25
|
+
# @param app [#call]
|
|
26
|
+
# @param allow_private [Boolean] let every address through, for development
|
|
27
|
+
# @param resolver [#call, nil] takes a host and returns IPAddr objects, the system resolver when nil
|
|
28
|
+
def initialize(app, allow_private: false, resolver: nil)
|
|
29
|
+
super(app)
|
|
30
|
+
@allow_private = allow_private
|
|
31
|
+
@resolver = resolver || method(:resolve)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param env [Faraday::Env]
|
|
35
|
+
# @raise [BlockedAddress]
|
|
36
|
+
#
|
|
37
|
+
# @api private
|
|
38
|
+
def on_request(env)
|
|
39
|
+
host = env.url.host.to_s
|
|
40
|
+
addresses = @resolver.call(host)
|
|
41
|
+
raise BlockedAddress, "#{host} did not resolve to any address" if addresses.empty?
|
|
42
|
+
|
|
43
|
+
addresses.each { |address| check(host, address) }
|
|
44
|
+
pin(env, addresses)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def check(host, address)
|
|
50
|
+
return if @allow_private
|
|
51
|
+
return unless blocked?(unwrap(address))
|
|
52
|
+
|
|
53
|
+
raise BlockedAddress, "#{host} resolved to #{address}, which is not a public address"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def blocked?(address)
|
|
57
|
+
return true if address.loopback? || address.private? || address.link_local?
|
|
58
|
+
return true if multicast?(address) || UNSPECIFIED.include?(address)
|
|
59
|
+
|
|
60
|
+
address.ipv4? && CGNAT.include?(address)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def multicast?(address)
|
|
64
|
+
address.ipv4? ? MULTICAST_V4.include?(address) : MULTICAST_V6.include?(address)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def unwrap(address)
|
|
68
|
+
address.ipv6? && address.ipv4_mapped? ? address.native : address
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def pin(env, addresses)
|
|
72
|
+
env.request.context = (env.request.context || {}).merge(PIN_KEY => addresses.map(&:to_s))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def resolve(host)
|
|
76
|
+
Resolv.getaddresses(host).filter_map { |address| to_ip(address) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def to_ip(address)
|
|
80
|
+
IPAddr.new(address)
|
|
81
|
+
rescue IPAddr::InvalidAddressError
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
Faraday::Middleware.register_middleware(
|
|
89
|
+
recipe_scrapers_address_guard: RecipeScrapers::Http::AddressGuard
|
|
90
|
+
)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
|
|
5
|
+
module RecipeScrapers
|
|
6
|
+
module Http
|
|
7
|
+
# Refuses a response larger than a limit, checked against Content-Length and against the body
|
|
8
|
+
# itself. Registered as `:recipe_scrapers_body_limit`.
|
|
9
|
+
class BodyLimit < Faraday::Middleware
|
|
10
|
+
# @param app [#call]
|
|
11
|
+
# @param bytes [Integer] the largest body accepted
|
|
12
|
+
def initialize(app, bytes:)
|
|
13
|
+
super(app)
|
|
14
|
+
@bytes = bytes
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @param env [Faraday::Env]
|
|
18
|
+
# @raise [ResponseTooLarge]
|
|
19
|
+
#
|
|
20
|
+
# @api private
|
|
21
|
+
def on_complete(env)
|
|
22
|
+
refuse(declared_size(env).to_i) if over_limit?(declared_size(env))
|
|
23
|
+
|
|
24
|
+
actual = env[:body].to_s.bytesize
|
|
25
|
+
refuse(actual) if actual > @bytes
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def declared_size(env)
|
|
31
|
+
env[:response_headers].to_h.find { |name, _| name.to_s.casecmp?("content-length") }&.last
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def over_limit?(declared)
|
|
35
|
+
!declared.nil? && declared.to_i > @bytes
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def refuse(size)
|
|
39
|
+
raise ResponseTooLarge, "response of #{size} bytes exceeds the limit of #{@bytes}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
Faraday::Middleware.register_middleware(recipe_scrapers_body_limit: RecipeScrapers::Http::BodyLimit)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
|
|
5
|
+
module RecipeScrapers
|
|
6
|
+
module Http
|
|
7
|
+
# Converts a response body to UTF-8, using the charset of the Content-Type header or of the meta
|
|
8
|
+
# tag in the first 4 KiB. Invalid bytes are replaced. Registered as `:recipe_scrapers_encoding`.
|
|
9
|
+
class Encoding < Faraday::Middleware
|
|
10
|
+
HEADER_CHARSET = /charset=([\w-]+)/i
|
|
11
|
+
META_CONTENT = /<meta[^>]+charset=["']?([\w-]+)/i
|
|
12
|
+
SNIFF_BYTES = 4096
|
|
13
|
+
private_constant :HEADER_CHARSET, :META_CONTENT, :SNIFF_BYTES
|
|
14
|
+
|
|
15
|
+
# @param env [Faraday::Env]
|
|
16
|
+
# @return [void]
|
|
17
|
+
#
|
|
18
|
+
# @api private
|
|
19
|
+
def on_complete(env)
|
|
20
|
+
body = env[:body]
|
|
21
|
+
return if body.nil? || body.empty?
|
|
22
|
+
|
|
23
|
+
env[:body] = to_utf8(body, charset_from(env[:response_headers]) || charset_from_meta(body))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def charset_from(headers)
|
|
29
|
+
header = headers.to_h.find { |name, _| name.to_s.casecmp?("content-type") }&.last
|
|
30
|
+
header&.[](HEADER_CHARSET, 1)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def charset_from_meta(body)
|
|
34
|
+
head = body.byteslice(0, SNIFF_BYTES).to_s.force_encoding(::Encoding::BINARY)
|
|
35
|
+
head[META_CONTENT, 1]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_utf8(body, name)
|
|
39
|
+
source = ::Encoding.find(name || "UTF-8")
|
|
40
|
+
body.dup.force_encoding(source).encode(::Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
41
|
+
rescue ArgumentError
|
|
42
|
+
body.dup.force_encoding(::Encoding::UTF_8).scrub
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
Faraday::Response.register_middleware(recipe_scrapers_encoding: RecipeScrapers::Http::Encoding)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module RecipeScrapers
|
|
7
|
+
module Http
|
|
8
|
+
# Follows 301, 302, 303, 307 and 308 redirects as GET requests. Every hop goes through the
|
|
9
|
+
# middleware after it again, so {AddressGuard} checks each host. The Authorization header is dropped
|
|
10
|
+
# when a redirect leaves the origin. Registered as `:recipe_scrapers_follow_redirects`.
|
|
11
|
+
class FollowRedirects < Faraday::Middleware
|
|
12
|
+
REDIRECTS = [301, 302, 303, 307, 308].freeze
|
|
13
|
+
CLEARED_ON_HOP = %i[status response response_headers].freeze
|
|
14
|
+
UNSAFE_IN_LOCATION = %r{[^\-_.!~*'()a-zA-Z\d;/?:@&=+$,\[\]%]}
|
|
15
|
+
AUTHORIZATION = "Authorization"
|
|
16
|
+
private_constant :REDIRECTS, :CLEARED_ON_HOP, :UNSAFE_IN_LOCATION, :AUTHORIZATION
|
|
17
|
+
|
|
18
|
+
# @param app [#call]
|
|
19
|
+
# @param limit [Integer] redirects to follow
|
|
20
|
+
def initialize(app, limit: 3)
|
|
21
|
+
super(app)
|
|
22
|
+
@limit = limit
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @param env [Faraday::Env]
|
|
26
|
+
# @return [Faraday::Response] the response of the last hop
|
|
27
|
+
# @raise [TooManyRedirects]
|
|
28
|
+
#
|
|
29
|
+
# @api private
|
|
30
|
+
def call(env)
|
|
31
|
+
follow(env, @limit)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def follow(env, hops_left)
|
|
37
|
+
response = @app.call(env)
|
|
38
|
+
location = redirect_location(response)
|
|
39
|
+
return response if location.nil?
|
|
40
|
+
raise TooManyRedirects, "gave up after #{@limit} redirects at #{env.url}" if hops_left.zero?
|
|
41
|
+
|
|
42
|
+
follow(next_hop(response.env.dup, location), hops_left - 1)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def redirect_location(response)
|
|
46
|
+
return nil unless REDIRECTS.include?(response.status)
|
|
47
|
+
|
|
48
|
+
location = response.headers["Location"].to_s.split("#").first.to_s
|
|
49
|
+
location.empty? ? nil : location.gsub(UNSAFE_IN_LOCATION) { |raw| escape(raw) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def next_hop(env, location)
|
|
53
|
+
from = env.url
|
|
54
|
+
env.url = from + location
|
|
55
|
+
CLEARED_ON_HOP.each { |key| env.delete(key) }
|
|
56
|
+
env.method = :get
|
|
57
|
+
env.body = nil
|
|
58
|
+
env.request_headers.delete(AUTHORIZATION) unless same_origin?(from, env.url)
|
|
59
|
+
env
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def same_origin?(from, to)
|
|
63
|
+
[from.scheme, from.host, from.port] == [to.scheme, to.host, to.port]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def escape(raw)
|
|
67
|
+
raw.unpack("C*").map { |byte| format("%%%02X", byte) }.join
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
Faraday::Middleware.register_middleware(
|
|
74
|
+
recipe_scrapers_follow_redirects: RecipeScrapers::Http::FollowRedirects
|
|
75
|
+
)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RecipeScrapers
|
|
4
|
+
# The value objects the gem returns.
|
|
5
|
+
module Models
|
|
6
|
+
# One ingredient line split into its parts. {Models::Recipe#parsed_ingredients} holds these.
|
|
7
|
+
#
|
|
8
|
+
# @!attribute [r] amount
|
|
9
|
+
# @return [Float, nil] the quantity, rounded to two places. A range keeps its lower bound
|
|
10
|
+
# @!attribute [r] unit
|
|
11
|
+
# @return [String, nil] the unit as the page writes it, such as "cups" or "g"
|
|
12
|
+
# @!attribute [r] name
|
|
13
|
+
# @return [String, nil] the ingredient, without text in parentheses
|
|
14
|
+
Ingredient = Data.define(:amount, :unit, :name) do
|
|
15
|
+
def initialize(amount: nil, unit: nil, name: nil)
|
|
16
|
+
super
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RecipeScrapers
|
|
4
|
+
module Models
|
|
5
|
+
# The ingredients under one heading of the page. {Models::Recipe#ingredient_groups} holds these.
|
|
6
|
+
#
|
|
7
|
+
# @!attribute [r] purpose
|
|
8
|
+
# @return [String, nil] the heading, such as "For the sauce", or nil for ungrouped ingredients
|
|
9
|
+
# @!attribute [r] ingredients
|
|
10
|
+
# @return [Array<String>] the ingredient lines of the group
|
|
11
|
+
# @!attribute [r] parsed_ingredients
|
|
12
|
+
# @return [Array<Models::Ingredient>, nil] the same lines split into an amount, a unit and a
|
|
13
|
+
# name, in the same order
|
|
14
|
+
IngredientGroup = Data.define(:purpose, :ingredients, :parsed_ingredients) do
|
|
15
|
+
def initialize(purpose:, ingredients:, parsed_ingredients: nil)
|
|
16
|
+
super
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|