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
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module RecipeScrapers
|
|
6
|
+
module Parsers
|
|
7
|
+
# The words {Ingredients} reads: units, pinch words, size adjectives, qualifiers and number words.
|
|
8
|
+
# The gem bundles one vocabulary per language, plus a common one with the metric units.
|
|
9
|
+
#
|
|
10
|
+
# @api private
|
|
11
|
+
class Vocabulary
|
|
12
|
+
WORD_LISTS = %i[units pinches sizes qualifiers].freeze
|
|
13
|
+
BUNDLED = File.expand_path("vocabulary/*.yml", __dir__)
|
|
14
|
+
NEVER = "(?!)"
|
|
15
|
+
private_constant :WORD_LISTS, :BUNDLED, :NEVER
|
|
16
|
+
|
|
17
|
+
# @return [Array<String>] units, where a space matches any run of spaces or none
|
|
18
|
+
attr_reader :units
|
|
19
|
+
|
|
20
|
+
# @return [Array<String>] words that are a unit and an amount of one, such as "pinch"
|
|
21
|
+
attr_reader :pinches
|
|
22
|
+
|
|
23
|
+
# @return [Array<String>] adjectives that can come before a unit, such as "large"
|
|
24
|
+
attr_reader :sizes
|
|
25
|
+
|
|
26
|
+
# @return [Array<String>] words before an amount that are not part of it, such as "about"
|
|
27
|
+
attr_reader :qualifiers
|
|
28
|
+
|
|
29
|
+
# @return [Hash{String => Numeric}] amounts written as words, such as "two" => 2
|
|
30
|
+
attr_reader :number_words
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
# Adds a vocabulary to the catalog, next to the bundled ones.
|
|
34
|
+
#
|
|
35
|
+
# @param name [Symbol]
|
|
36
|
+
# @param languages [Array<String>] the language codes it is used for, such as %w[pl]
|
|
37
|
+
# @param words [Hash] the word lists, as {#initialize} takes them
|
|
38
|
+
# @return [Hash] the catalog entry
|
|
39
|
+
def define(name, languages: [], **words)
|
|
40
|
+
catalog[name] = entry(languages, words)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @param language [String, nil] a language tag, such as "en-US"
|
|
44
|
+
# @return [Array<Symbol>] the common vocabulary plus those of the language, or every vocabulary
|
|
45
|
+
# when no file declares the language
|
|
46
|
+
#
|
|
47
|
+
# @api private
|
|
48
|
+
def names_for(language)
|
|
49
|
+
code = language.to_s.downcase[/\A[a-z]+/]
|
|
50
|
+
spoken = catalog.filter_map { |name, entry| name if entry[:languages].include?(code) }
|
|
51
|
+
spoken.empty? ? catalog.keys.sort : [:common, *spoken.sort]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @param language [String, nil]
|
|
55
|
+
# @return [Vocabulary] the vocabularies of the language combined
|
|
56
|
+
def for(language)
|
|
57
|
+
combine(names_for(language))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @param names [Array<Symbol>]
|
|
61
|
+
# @return [Vocabulary] the named vocabularies added together
|
|
62
|
+
#
|
|
63
|
+
# @api private
|
|
64
|
+
def combine(names)
|
|
65
|
+
names.map { |name| catalog.fetch(name)[:vocabulary] }.reduce(:+)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @return [Hash{Symbol => Hash}] every vocabulary by name, the bundled ones loaded on first use
|
|
69
|
+
#
|
|
70
|
+
# @api private
|
|
71
|
+
def catalog
|
|
72
|
+
@catalog ||= load_bundled
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @param words [Array<String>]
|
|
76
|
+
# @return [String] a regexp alternation of the words, longest first, that never matches when empty
|
|
77
|
+
#
|
|
78
|
+
# @api private
|
|
79
|
+
def alternation(words)
|
|
80
|
+
return NEVER if words.empty?
|
|
81
|
+
|
|
82
|
+
words.sort_by { |word| -word.length }.map { |word| Regexp.escape(word).gsub("\\ ", "\\s*") }.join("|")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def load_bundled
|
|
88
|
+
Dir[BUNDLED].to_h do |path|
|
|
89
|
+
words = YAML.safe_load_file(path).transform_keys(&:to_sym)
|
|
90
|
+
[File.basename(path, ".yml").to_sym, entry(words.delete(:languages), words)]
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def entry(languages, words)
|
|
95
|
+
{ languages: Array(languages), vocabulary: new(**words) }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @param units [Array<String>]
|
|
100
|
+
# @param pinches [Array<String>]
|
|
101
|
+
# @param sizes [Array<String>]
|
|
102
|
+
# @param qualifiers [Array<String>]
|
|
103
|
+
# @param number_words [Hash{String => Numeric}]
|
|
104
|
+
def initialize(units: [], pinches: [], sizes: [], qualifiers: [], number_words: {})
|
|
105
|
+
@units = units.uniq.freeze
|
|
106
|
+
@pinches = pinches.uniq.freeze
|
|
107
|
+
@sizes = sizes.uniq.freeze
|
|
108
|
+
@qualifiers = qualifiers.uniq.freeze
|
|
109
|
+
@number_words = number_words.freeze
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @param other [Vocabulary]
|
|
113
|
+
# @return [Vocabulary] both word lists without repeats. The other one wins for a number word in both
|
|
114
|
+
def +(other)
|
|
115
|
+
lists = WORD_LISTS.to_h { |list| [list, public_send(list) + other.public_send(list)] }
|
|
116
|
+
Vocabulary.new(**lists, number_words: number_words.merge(other.number_words))
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RecipeScrapers
|
|
4
|
+
module Parsers
|
|
5
|
+
module Yields
|
|
6
|
+
SERVING_WORDS = /serv|portion|порц|person|people/i
|
|
7
|
+
NUMBER = /(\d+)/
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def parse(value)
|
|
11
|
+
return nil if value.nil?
|
|
12
|
+
|
|
13
|
+
text = Text.normalize(value.is_a?(Array) ? value.first : value)
|
|
14
|
+
return nil if text.nil?
|
|
15
|
+
|
|
16
|
+
count = text[NUMBER, 1]
|
|
17
|
+
return nil if count.nil?
|
|
18
|
+
|
|
19
|
+
"#{count} #{noun_for(text, count)}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def noun_for(text, count)
|
|
25
|
+
text.match?(SERVING_WORDS) || text == count ? "servings" : "items"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module RecipeScrapers
|
|
6
|
+
# Maps hosts to the sites the gem supports. A host maps to a {Declaration}, or to a {Scraper}
|
|
7
|
+
# subclass for a site that needs code. A leading "www." is ignored.
|
|
8
|
+
module Registry
|
|
9
|
+
class << self
|
|
10
|
+
# @return [Hash{String => Declaration, Class}] every registered host and what reads it
|
|
11
|
+
#
|
|
12
|
+
# @api private
|
|
13
|
+
def entries
|
|
14
|
+
@entries ||= {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Registers a site by declaration.
|
|
18
|
+
#
|
|
19
|
+
# @param host [String] the host of the site
|
|
20
|
+
# @param also [Array<String>] more hosts read the same way, such as a regional domain
|
|
21
|
+
# @yield the declaration body, evaluated on the {Declaration}
|
|
22
|
+
# @return [Declaration]
|
|
23
|
+
def register(host, also: [], &)
|
|
24
|
+
declaration = Declaration.build(host, &)
|
|
25
|
+
([host] + Array(also)).each { |name| entries[normalize(name)] = declaration }
|
|
26
|
+
declaration
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Registers a {Scraper} subclass under the host it declares with {Scraper.host}.
|
|
30
|
+
#
|
|
31
|
+
# @param klass [Class<Scraper>]
|
|
32
|
+
# @return [Class<Scraper>] the class it was given
|
|
33
|
+
def register_class(klass)
|
|
34
|
+
entries[normalize(klass.declared_host)] = klass
|
|
35
|
+
klass
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Finds what reads a URL.
|
|
39
|
+
#
|
|
40
|
+
# @param url [String]
|
|
41
|
+
# @return [Declaration, Class<Scraper>, nil] nil when the host is not registered or the URL does not parse
|
|
42
|
+
def for(url)
|
|
43
|
+
entries[normalize(URI.parse(url).host.to_s)]
|
|
44
|
+
rescue URI::Error
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [Array<String>] every supported host
|
|
49
|
+
def hosts
|
|
50
|
+
entries.keys
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Forgets every registered site.
|
|
54
|
+
#
|
|
55
|
+
# @return [void]
|
|
56
|
+
#
|
|
57
|
+
# @api private
|
|
58
|
+
def clear!
|
|
59
|
+
@entries = {}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A copy of the entries, to put back with {restore}. Specs use it to register a site for one example.
|
|
63
|
+
#
|
|
64
|
+
# @return [Hash{String => Declaration, Class}]
|
|
65
|
+
#
|
|
66
|
+
# @api private
|
|
67
|
+
def snapshot
|
|
68
|
+
entries.dup
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Puts back entries a {snapshot} took.
|
|
72
|
+
#
|
|
73
|
+
# @param saved [Hash{String => Declaration, Class}]
|
|
74
|
+
# @return [void]
|
|
75
|
+
#
|
|
76
|
+
# @api private
|
|
77
|
+
def restore(saved)
|
|
78
|
+
@entries = saved
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def normalize(host)
|
|
84
|
+
host.to_s.downcase.delete_prefix("www.")
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RecipeScrapers
|
|
4
|
+
class Scraper
|
|
5
|
+
# The fields that split text into value objects, with the parsers {Configuration#parsers} holds.
|
|
6
|
+
module ParsedFields
|
|
7
|
+
# @param scraper [Class<Scraper>] gets {ClassMethods}
|
|
8
|
+
# @return [void]
|
|
9
|
+
#
|
|
10
|
+
# @api private
|
|
11
|
+
def self.included(scraper)
|
|
12
|
+
scraper.extend(ClassMethods)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# The class methods a scraper class gets from {ParsedFields}.
|
|
16
|
+
module ClassMethods
|
|
17
|
+
# Sets the parsers of a field for every page of this scraper class. It wins over the site
|
|
18
|
+
# declaration and the configuration.
|
|
19
|
+
#
|
|
20
|
+
# @param field [Symbol] :ingredients or :nutrients
|
|
21
|
+
# @param parsers [Array<#call>] tried in order, the first result that is not nil wins
|
|
22
|
+
# @raise [ArgumentError] for a field that takes no parser
|
|
23
|
+
def parser(field, *parsers)
|
|
24
|
+
own_parsers[Parsers::Chain.field!(field)] = parsers
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Hash{Symbol => Array<#call>}] the parsers this class sets with {#parser}
|
|
28
|
+
#
|
|
29
|
+
# @api private
|
|
30
|
+
def own_parsers
|
|
31
|
+
@own_parsers ||= {}
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# (see Models::Recipe#parsed_ingredients)
|
|
36
|
+
def parsed_ingredients
|
|
37
|
+
spoken = language
|
|
38
|
+
ingredients&.map { |line| parse(:ingredients, line, spoken) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# (see Models::Recipe#parsed_nutrients)
|
|
42
|
+
def parsed_nutrients
|
|
43
|
+
spoken = language
|
|
44
|
+
parsed = nutrients.to_h.filter_map do |name, text|
|
|
45
|
+
nutrient = parse(:nutrients, text, spoken)
|
|
46
|
+
nutrient && (nutrient.name ? nutrient : nutrient.with(name: name))
|
|
47
|
+
end
|
|
48
|
+
parsed.empty? ? nil : parsed
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def with_parsed_ingredients(groups)
|
|
54
|
+
spoken = language
|
|
55
|
+
groups.map do |group|
|
|
56
|
+
group.with(parsed_ingredients: group.ingredients.map { |line| parse(:ingredients, line, spoken) })
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def parse(field, text, spoken)
|
|
61
|
+
Parsers::Chain.run(field, parsers_for(field), text, language: spoken)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def parsers_for(field)
|
|
65
|
+
self.class.own_parsers[field] || @declaration&.parsers_for(field) || RecipeScrapers.config.parsers.fetch(field)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module RecipeScrapers
|
|
7
|
+
# Reads the recipe fields of one page. {RecipeScrapers.parse} builds one and returns its
|
|
8
|
+
# {#to_recipe}, so an application gets a {Models::Recipe} and never the page itself. A field the
|
|
9
|
+
# page does not publish is nil.
|
|
10
|
+
#
|
|
11
|
+
# Every field reads the site declaration first, then the schema.org recipe, then OpenGraph where
|
|
12
|
+
# that applies. A site that needs code subclasses Scraper, names its host with {.host} and
|
|
13
|
+
# registers with {Registry.register_class}.
|
|
14
|
+
#
|
|
15
|
+
# @example A site with its own schema.org reader
|
|
16
|
+
# class Chefkoch < RecipeScrapers::Scraper
|
|
17
|
+
# class SchemaReader < RecipeScrapers::Sources::SchemaOrg
|
|
18
|
+
# def ingredients = super&.map(&:strip)
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# host "chefkoch.de"
|
|
22
|
+
# schema_reader SchemaReader
|
|
23
|
+
# end
|
|
24
|
+
# RecipeScrapers::Registry.register_class(Chefkoch)
|
|
25
|
+
class Scraper
|
|
26
|
+
include ParsedFields
|
|
27
|
+
|
|
28
|
+
# Every field a scraper answers, in the order {#to_h} returns them.
|
|
29
|
+
CONTRACT = (Models::Recipe.members - %i[url]).freeze
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
# Names the host a subclass reads, or returns it.
|
|
33
|
+
#
|
|
34
|
+
# @param value [String, nil]
|
|
35
|
+
# @return [String, nil]
|
|
36
|
+
def host(value = nil)
|
|
37
|
+
@declared_host = value if value
|
|
38
|
+
@declared_host
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
alias declared_host host
|
|
42
|
+
|
|
43
|
+
# Sets the class that reads the schema.org recipe for a subclass, or returns it.
|
|
44
|
+
#
|
|
45
|
+
# @param value [Class<Sources::SchemaOrg>, nil]
|
|
46
|
+
# @return [Class<Sources::SchemaOrg>] {Sources::SchemaOrg} unless a subclass sets its own
|
|
47
|
+
def schema_reader(value = nil)
|
|
48
|
+
@schema_reader = value if value
|
|
49
|
+
@schema_reader || Sources::SchemaOrg
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @return [String] the address the page was read from
|
|
54
|
+
attr_reader :url
|
|
55
|
+
|
|
56
|
+
# @return [Nokogiri::HTML5::Document] the parsed page, for a subclass that reads it directly
|
|
57
|
+
attr_reader :document
|
|
58
|
+
|
|
59
|
+
# @param html [String] the page source
|
|
60
|
+
# @param url [String] the address of the page
|
|
61
|
+
# @param declaration [Declaration, nil] the declaration of the site
|
|
62
|
+
def initialize(html, url:, declaration: nil)
|
|
63
|
+
@url = url
|
|
64
|
+
@declaration = declaration
|
|
65
|
+
@document = Nokogiri::HTML5(Text.recode(html, declaration&.encoding_name))
|
|
66
|
+
@schema = self.class.schema_reader.new(Sources::JsonLd.new(@document), Sources::Microdata.new(@document))
|
|
67
|
+
@open_graph = Sources::OpenGraph.new(@document)
|
|
68
|
+
@declared = Sources::Declared.new(@document, declaration)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# (see Models::Recipe#host)
|
|
72
|
+
def host
|
|
73
|
+
URI.parse(url).host.to_s.delete_prefix("www.")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# (see Models::Recipe#canonical_url)
|
|
77
|
+
def canonical_url
|
|
78
|
+
link = document.at_css('link[rel="canonical"][href]')
|
|
79
|
+
return url unless link
|
|
80
|
+
|
|
81
|
+
URI.join(url, link["href"]).to_s
|
|
82
|
+
rescue URI::Error
|
|
83
|
+
url
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# (see Models::Recipe#title)
|
|
87
|
+
def title = @declared.text(:title) || @schema.title || @open_graph.title
|
|
88
|
+
# (see Models::Recipe#author)
|
|
89
|
+
def author = @declared.text(:author) || @schema.author
|
|
90
|
+
# (see Models::Recipe#site_name)
|
|
91
|
+
def site_name = @declared.text(:site_name) || @schema.site_name || @open_graph.site_name
|
|
92
|
+
# (see Models::Recipe#language)
|
|
93
|
+
def language = @declared.text(:language) || @schema.language || document_language
|
|
94
|
+
# (see Models::Recipe#description)
|
|
95
|
+
def description = @declared.text(:description) || @schema.description || @open_graph.description
|
|
96
|
+
# (see Models::Recipe#category)
|
|
97
|
+
def category = @declared.text(:category) || @schema.category
|
|
98
|
+
# (see Models::Recipe#cuisine)
|
|
99
|
+
def cuisine = @declared.text(:cuisine) || @schema.cuisine
|
|
100
|
+
# (see Models::Recipe#cooking_method)
|
|
101
|
+
def cooking_method = @declared.text(:cooking_method) || @schema.cooking_method
|
|
102
|
+
# (see Models::Recipe#keywords)
|
|
103
|
+
def keywords = @declared.rows(:keywords) || @schema.keywords
|
|
104
|
+
# (see Models::Recipe#equipment)
|
|
105
|
+
def equipment = @declared.rows(:equipment)
|
|
106
|
+
# (see Models::Recipe#nutrients)
|
|
107
|
+
def nutrients = @schema.nutrients
|
|
108
|
+
# (see Models::Recipe#dietary_restrictions)
|
|
109
|
+
def dietary_restrictions = @schema.dietary_restrictions
|
|
110
|
+
# (see Models::Recipe#ratings)
|
|
111
|
+
def ratings = @schema.ratings
|
|
112
|
+
# (see Models::Recipe#ratings_count)
|
|
113
|
+
def ratings_count = @schema.ratings_count
|
|
114
|
+
|
|
115
|
+
# (see Models::Recipe#yields)
|
|
116
|
+
def yields = Parsers::Yields.parse(@declared.text(:yields)) || @schema.yields
|
|
117
|
+
# (see Models::Recipe#total_time)
|
|
118
|
+
def total_time = Parsers::Durations.minutes(@declared.text(:total_time)) || @schema.total_time
|
|
119
|
+
# (see Models::Recipe#cook_time)
|
|
120
|
+
def cook_time = Parsers::Durations.minutes(@declared.text(:cook_time)) || @schema.cook_time
|
|
121
|
+
# (see Models::Recipe#prep_time)
|
|
122
|
+
def prep_time = Parsers::Durations.minutes(@declared.text(:prep_time)) || @schema.prep_time
|
|
123
|
+
|
|
124
|
+
# (see Models::Recipe#ingredients)
|
|
125
|
+
def ingredients
|
|
126
|
+
lines = ingredient_sections.flat_map(&:last)
|
|
127
|
+
lines.empty? ? nil : lines
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# (see Models::Recipe#instructions_list)
|
|
131
|
+
def instructions_list = @declared.rows(:instructions) || @schema.instructions_list
|
|
132
|
+
# (see Models::Recipe#instructions)
|
|
133
|
+
def instructions = instructions_list&.join("\n")
|
|
134
|
+
|
|
135
|
+
# (see Models::Recipe#ingredient_groups)
|
|
136
|
+
def ingredient_groups
|
|
137
|
+
lines = ingredients
|
|
138
|
+
return nil if lines.nil?
|
|
139
|
+
|
|
140
|
+
marked = marked_groups(lines)
|
|
141
|
+
groups = marked.any?(&:purpose) ? marked : Sources::IngredientGroups.from_sections(ingredient_sections)
|
|
142
|
+
with_parsed_ingredients(groups)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# (see Models::Recipe#image)
|
|
146
|
+
def image
|
|
147
|
+
address = @declared.text(:image) || @schema.image || @open_graph.image
|
|
148
|
+
address && URI.join(url, address).to_s
|
|
149
|
+
rescue URI::Error
|
|
150
|
+
address
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# (see Models::Recipe#links)
|
|
154
|
+
def links
|
|
155
|
+
document.css("a[href]").map { |anchor| anchor["href"] }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# @return [Hash{Symbol => Object}] every field of {CONTRACT} with its value
|
|
159
|
+
#
|
|
160
|
+
# @api private
|
|
161
|
+
def to_h
|
|
162
|
+
CONTRACT.to_h { |field| [field, public_send(field)] }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Reads every field once and returns them without the page.
|
|
166
|
+
#
|
|
167
|
+
# @return [Models::Recipe]
|
|
168
|
+
#
|
|
169
|
+
# @api private
|
|
170
|
+
def to_recipe
|
|
171
|
+
Models::Recipe.new(url: url, **to_h)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# @return [Boolean] whether the page has a title and ingredients
|
|
175
|
+
#
|
|
176
|
+
# @api private
|
|
177
|
+
def recipe?
|
|
178
|
+
!title.nil? && !ingredients.nil?
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
private
|
|
182
|
+
|
|
183
|
+
def ingredient_sections
|
|
184
|
+
@ingredient_sections ||=
|
|
185
|
+
Sources::IngredientGroups.sections(@declared.rows(:ingredients) || @schema.ingredients || [])
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def marked_groups(lines)
|
|
189
|
+
rule = @declared.rule(:ingredient_groups)
|
|
190
|
+
return Sources::IngredientGroups.detect(document: document, ingredients: lines) if rule.nil?
|
|
191
|
+
|
|
192
|
+
Sources::IngredientGroups.call(document: document, ingredients: lines, heading: rule[:heading], item: rule[:item])
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def document_language
|
|
196
|
+
Text.normalize(document.at_css("html")&.[]("lang"))
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RecipeScrapers
|
|
4
|
+
module SitePath
|
|
5
|
+
class << self
|
|
6
|
+
def for(host)
|
|
7
|
+
"#{directory(host)}/#{slug(host)}"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def directory(host)
|
|
13
|
+
host.split(".").last
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def slug(host)
|
|
17
|
+
host.split(".").first.tr("-", "_")
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|