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,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RecipeScrapers
|
|
4
|
+
module Sources
|
|
5
|
+
class Declared
|
|
6
|
+
def initialize(document, declaration)
|
|
7
|
+
@document = document
|
|
8
|
+
@declaration = declaration
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def rule(field)
|
|
12
|
+
@declaration&.rule(field)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def text(field)
|
|
16
|
+
selector = rule(field)&.fetch(:selector, nil)
|
|
17
|
+
return nil if selector.nil?
|
|
18
|
+
|
|
19
|
+
Text.normalize(Text.content(@document.at_css(selector)))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def rows(field)
|
|
23
|
+
selector = rule(field)&.fetch(:rows, nil)
|
|
24
|
+
return nil if selector.nil?
|
|
25
|
+
|
|
26
|
+
lines = @document.css(selector).filter_map { |node| row_text(node) }
|
|
27
|
+
lines.empty? ? nil : lines
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def row_text(node)
|
|
33
|
+
parts = node.children.filter_map { |child| Text.normalize(Text.content(child)) }
|
|
34
|
+
parts.empty? ? nil : parts.join(" ")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RecipeScrapers
|
|
4
|
+
module Sources
|
|
5
|
+
module IngredientGroups
|
|
6
|
+
LAYOUTS = [
|
|
7
|
+
{ heading: ".wprm-recipe-ingredient-group-name",
|
|
8
|
+
item: ".wprm-recipe-ingredient" },
|
|
9
|
+
{ heading: ".tasty-recipes-ingredients p strong, .tasty-recipes-ingredients h4",
|
|
10
|
+
item: ".tasty-recipes-ingredients li" },
|
|
11
|
+
{ heading: ".mv-create-ingredients h4, .mv-create-ingredients h3:not(.mv-create-ingredients-title)",
|
|
12
|
+
item: ".mv-create-ingredients li" }
|
|
13
|
+
].freeze
|
|
14
|
+
SEPARATOR = /\A[^\p{L}\p{N}]*\z/
|
|
15
|
+
HEADING = /\A(?<purpose>\D+?)\s*:\z/
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def sections(lines)
|
|
19
|
+
found = lines.grep_v(SEPARATOR).each_with_object([]) do |line, sections|
|
|
20
|
+
purpose = line[HEADING, :purpose]
|
|
21
|
+
next sections << [purpose, []] if purpose
|
|
22
|
+
|
|
23
|
+
sections << [nil, []] if sections.empty?
|
|
24
|
+
sections.last.last << line
|
|
25
|
+
end
|
|
26
|
+
found.reject { |_, section_lines| section_lines.empty? }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def from_sections(sections)
|
|
30
|
+
sections.map { |purpose, lines| Models::IngredientGroup.new(purpose: purpose, ingredients: lines) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def detect(document:, ingredients:)
|
|
34
|
+
named = LAYOUTS.lazy.
|
|
35
|
+
map { |layout| call(document: document, ingredients: ingredients, **layout) }.
|
|
36
|
+
find { |groups| groups.any?(&:purpose) }
|
|
37
|
+
|
|
38
|
+
named || [ungrouped(ingredients)]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def call(document:, ingredients:, heading:, item:)
|
|
42
|
+
headings = document.css(heading)
|
|
43
|
+
return [ungrouped(ingredients)] if headings.empty?
|
|
44
|
+
return [ungrouped(ingredients)] unless document.css(item).size == ingredients.size
|
|
45
|
+
|
|
46
|
+
build(document.css("#{heading}, #{item}"), headings.to_a, ingredients)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def ungrouped(ingredients)
|
|
52
|
+
Models::IngredientGroup.new(purpose: nil, ingredients: ingredients)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def build(nodes, headings, ingredients)
|
|
56
|
+
remaining = ingredients.dup
|
|
57
|
+
sections = []
|
|
58
|
+
|
|
59
|
+
nodes.each { |node| absorb(sections, node, headings, remaining) }
|
|
60
|
+
|
|
61
|
+
sections.reject { |_, lines| lines.empty? }.
|
|
62
|
+
map { |purpose, lines| Models::IngredientGroup.new(purpose: purpose, ingredients: lines) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def absorb(sections, node, headings, remaining)
|
|
66
|
+
return sections << [Text.normalize(node.text), []] if headings.include?(node)
|
|
67
|
+
|
|
68
|
+
sections << [nil, []] if sections.empty?
|
|
69
|
+
sections.last.last << (remaining.shift || Text.normalize(node.text))
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module RecipeScrapers
|
|
6
|
+
module Sources
|
|
7
|
+
class JsonLd
|
|
8
|
+
SELECTOR = 'script[type="application/ld+json"]'
|
|
9
|
+
ESCAPED_CONTROL = { "\b" => "\\b", "\f" => "\\f", "\n" => "\\n", "\r" => "\\r", "\t" => "\\t" }.freeze
|
|
10
|
+
|
|
11
|
+
def initialize(document)
|
|
12
|
+
@nodes = flatten(document.css(SELECTOR).flat_map { |tag| parse(tag.text) })
|
|
13
|
+
@by_id = @nodes.select { |node| node["@id"] }.to_h { |node| [node["@id"], node] }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def recipe
|
|
17
|
+
@recipe ||= @nodes.find { |node| type?(node, "Recipe") } || main_entity_recipe
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def person(id)
|
|
21
|
+
entity(id, "Person")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def rating(id)
|
|
25
|
+
entity(id, "AggregateRating")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def website_name
|
|
29
|
+
site = @nodes.find { |node| type?(node, "WebSite") }
|
|
30
|
+
site && Text.normalize(site["name"])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def entity(id, schema_type)
|
|
36
|
+
node = @by_id[id]
|
|
37
|
+
node if node && type?(node, schema_type)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def main_entity_recipe
|
|
41
|
+
page = @nodes.find { |node| type?(node, "WebPage") && node["mainEntity"].is_a?(Hash) }
|
|
42
|
+
return nil unless page
|
|
43
|
+
|
|
44
|
+
candidate = page["mainEntity"]
|
|
45
|
+
candidate if type?(candidate, "Recipe")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def parse(text)
|
|
49
|
+
JSON.parse(text)
|
|
50
|
+
rescue JSON::ParserError
|
|
51
|
+
parse_leniently(text)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def parse_leniently(text)
|
|
55
|
+
JSON.parse(escape_control_characters(text))
|
|
56
|
+
rescue JSON::ParserError
|
|
57
|
+
[]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def escape_control_characters(text)
|
|
61
|
+
inside = false
|
|
62
|
+
escaped = false
|
|
63
|
+
text.each_char.map do |character|
|
|
64
|
+
next character.tap { escaped = false } if escaped
|
|
65
|
+
next ESCAPED_CONTROL.fetch(character, " ") if inside && character.ord < 0x20
|
|
66
|
+
|
|
67
|
+
escaped = inside && character == "\\"
|
|
68
|
+
inside = !inside if character == '"'
|
|
69
|
+
character
|
|
70
|
+
end.join
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def flatten(value)
|
|
74
|
+
case value
|
|
75
|
+
when Array then value.flat_map { |item| flatten(item) }
|
|
76
|
+
when Hash then [value] + flatten(graph_nodes(value)) + flatten(value["mainEntity"])
|
|
77
|
+
else []
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def graph_nodes(value)
|
|
82
|
+
graph = value["@graph"]
|
|
83
|
+
graph.is_a?(Hash) ? graph.values : graph
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def type?(node, schema_type)
|
|
87
|
+
return false unless node.is_a?(Hash)
|
|
88
|
+
|
|
89
|
+
Array(node["@type"]).any? { |name| name.to_s.casecmp?(schema_type) }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
|
|
5
|
+
module RecipeScrapers
|
|
6
|
+
module Sources
|
|
7
|
+
class Microdata
|
|
8
|
+
VALUE_ATTRIBUTES = {
|
|
9
|
+
"meta" => "content",
|
|
10
|
+
"audio" => "src", "embed" => "src", "iframe" => "src", "img" => "src",
|
|
11
|
+
"source" => "src", "track" => "src", "video" => "src",
|
|
12
|
+
"a" => "href", "area" => "href", "link" => "href",
|
|
13
|
+
"object" => "data",
|
|
14
|
+
"data" => "value", "meter" => "value",
|
|
15
|
+
"time" => "datetime"
|
|
16
|
+
}.freeze
|
|
17
|
+
INLINE = %w[
|
|
18
|
+
a abbr b bdi bdo big cite code data del dfn em font i img ins kbd label link mark meta
|
|
19
|
+
q s samp small span strong sub sup svg time tt u var wbr
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
def initialize(document)
|
|
23
|
+
@nodes = roots(document).flat_map { |element| flatten(item_for(element)) }
|
|
24
|
+
@by_id = @nodes.select { |node| node["@id"] }.to_h { |node| [node["@id"], node] }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def recipe = @recipe ||= node_of_type("Recipe")
|
|
28
|
+
def person(id) = entity(id, "Person")
|
|
29
|
+
def rating(id) = entity(id, "AggregateRating")
|
|
30
|
+
|
|
31
|
+
def website_name
|
|
32
|
+
site = node_of_type("WebSite")
|
|
33
|
+
site && Text.normalize(site["name"])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def roots(document)
|
|
39
|
+
document.css("[itemscope]").reject { |element| element.ancestors.any? { |parent| parent.key?("itemscope") } }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def flatten(node)
|
|
43
|
+
[node] + node.values.flatten.grep(Hash).flat_map { |value| flatten(value) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def node_of_type(schema_type)
|
|
47
|
+
@nodes.find { |node| node["@type"].to_s.casecmp?(schema_type) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def entity(id, schema_type)
|
|
51
|
+
node = @by_id[id]
|
|
52
|
+
node if node && node["@type"].to_s.casecmp?(schema_type)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def item_for(element)
|
|
56
|
+
node = {}
|
|
57
|
+
type = type_name(element["itemtype"])
|
|
58
|
+
node["@type"] = type if type
|
|
59
|
+
node["@id"] = element["itemid"] if element["itemid"]
|
|
60
|
+
properties_of(element).each { |name, value| assign(node, name, value) }
|
|
61
|
+
node
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def type_name(itemtype)
|
|
65
|
+
name = itemtype.to_s.split(/\s+/).first.to_s.split("/").last.to_s
|
|
66
|
+
name.empty? ? nil : name
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def properties_of(element)
|
|
70
|
+
element.element_children.flat_map { |child| properties_in(child) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def properties_in(element)
|
|
74
|
+
names = element["itemprop"].to_s.split(/\s+/)
|
|
75
|
+
return names.map { |name| [name, item_for(element)] } if element.key?("itemscope")
|
|
76
|
+
|
|
77
|
+
names.map { |name| [name, value_of(element)] } + properties_of(element)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def assign(node, name, value)
|
|
81
|
+
return node[name] = value unless node.key?(name)
|
|
82
|
+
|
|
83
|
+
existing = node[name]
|
|
84
|
+
node[name] = existing.is_a?(Array) ? existing + [value] : [existing, value]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def value_of(element)
|
|
88
|
+
attribute = VALUE_ATTRIBUTES[element.name]
|
|
89
|
+
return element[attribute] if attribute && element[attribute]
|
|
90
|
+
return element["content"] if element["content"]
|
|
91
|
+
|
|
92
|
+
lines_of(element)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def lines_of(element)
|
|
96
|
+
text_of(element).split("\n").map(&:strip).reject(&:empty?).join("\n")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def text_of(element)
|
|
100
|
+
element.children.filter_map { |child| text_from(child) }.join
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def text_from(child)
|
|
104
|
+
return nil if Text::TEXTLESS.include?(child.name)
|
|
105
|
+
return child.text if child.text? || child.cdata?
|
|
106
|
+
return nil unless child.element?
|
|
107
|
+
return text_of(child) if INLINE.include?(child.name)
|
|
108
|
+
|
|
109
|
+
"\n#{text_of(child)}\n"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RecipeScrapers
|
|
4
|
+
module Sources
|
|
5
|
+
class OpenGraph
|
|
6
|
+
def initialize(document)
|
|
7
|
+
@document = document
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def title
|
|
11
|
+
property("og:title") || Text.normalize(@document.at_css("title")&.text)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def image = property("og:image")
|
|
15
|
+
def description = property("og:description")
|
|
16
|
+
def site_name = property("og:site_name")
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def property(name)
|
|
21
|
+
tag = @document.at_css(%(meta[property="#{name}"][content]))
|
|
22
|
+
tag && Text.normalize(tag["content"])
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RecipeScrapers
|
|
4
|
+
module Sources
|
|
5
|
+
class SchemaOrg
|
|
6
|
+
module IngredientList
|
|
7
|
+
PROPERTY_VALUE = "PropertyValue"
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def parse(value)
|
|
11
|
+
lines = Array(value).flatten(1).filter_map { |item| Text.normalize(line_for(item)) }
|
|
12
|
+
lines.empty? ? nil : lines
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def line_for(item)
|
|
18
|
+
return item unless item.is_a?(Hash)
|
|
19
|
+
return item.to_s unless property_value?(item)
|
|
20
|
+
|
|
21
|
+
measured(item).join(" ")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def property_value?(item)
|
|
25
|
+
Array(item["@type"]).any? { |name| name.to_s.casecmp?(PROPERTY_VALUE) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def measured(item)
|
|
29
|
+
[item["value"], item["unitText"] || item["unitCode"], item["name"]].
|
|
30
|
+
filter_map { |part| part.to_s unless part.to_s.empty? }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RecipeScrapers
|
|
4
|
+
module Sources
|
|
5
|
+
class SchemaOrg
|
|
6
|
+
module NutritionFacts
|
|
7
|
+
class << self
|
|
8
|
+
def parse(nutrition)
|
|
9
|
+
return nil if nutrition.nil?
|
|
10
|
+
|
|
11
|
+
values = nutrition.filter_map do |name, value|
|
|
12
|
+
text = Text.normalize(value)
|
|
13
|
+
[name, text] if text && !name.empty? && !name.start_with?("@")
|
|
14
|
+
end
|
|
15
|
+
values.empty? ? nil : values.to_h
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RecipeScrapers
|
|
4
|
+
# Read what a page says. {SchemaOrg} is the one to subclass for a site.
|
|
5
|
+
module Sources
|
|
6
|
+
# Reads the fields of the schema.org Recipe a page publishes, from JSON-LD or from microdata.
|
|
7
|
+
#
|
|
8
|
+
# Every method answers the field of the same name on {Scraper}. A site that structures its markup
|
|
9
|
+
# in its own way subclasses this class and overrides a method, then names the subclass with
|
|
10
|
+
# {Scraper.schema_reader}. The steps of recipeInstructions are read by private methods that can be
|
|
11
|
+
# overridden one at a time: steps_in, section_steps, step_steps and step_heading.
|
|
12
|
+
class SchemaOrg
|
|
13
|
+
FACET = /\A[\w-]+: /
|
|
14
|
+
private_constant :FACET
|
|
15
|
+
# @param readers [Array<Sources::JsonLd, Sources::Microdata>] the first one that finds a Recipe wins
|
|
16
|
+
def initialize(*readers)
|
|
17
|
+
@source = readers.find(&:recipe) || readers.first
|
|
18
|
+
@data = @source.recipe || {}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# (see Models::Recipe#title)
|
|
22
|
+
def title = Text.normalize(@data["name"])
|
|
23
|
+
# (see Models::Recipe#description)
|
|
24
|
+
def description = Text.normalize(@data["description"])
|
|
25
|
+
# (see Models::Recipe#language)
|
|
26
|
+
def language = Text.normalize(@data["inLanguage"])
|
|
27
|
+
# (see Models::Recipe#site_name)
|
|
28
|
+
def site_name = @source.website_name
|
|
29
|
+
# (see Models::Recipe#category)
|
|
30
|
+
def category = first_text(@data["recipeCategory"])
|
|
31
|
+
# (see Models::Recipe#cuisine)
|
|
32
|
+
def cuisine = first_text(@data["recipeCuisine"])
|
|
33
|
+
# (see Models::Recipe#cooking_method)
|
|
34
|
+
def cooking_method = first_text(@data["cookingMethod"])
|
|
35
|
+
# (see Models::Recipe#yields)
|
|
36
|
+
def yields = Parsers::Yields.parse(@data["recipeYield"])
|
|
37
|
+
# (see Models::Recipe#total_time)
|
|
38
|
+
def total_time = Parsers::Durations.minutes(@data["totalTime"])
|
|
39
|
+
# (see Models::Recipe#cook_time)
|
|
40
|
+
def cook_time = Parsers::Durations.minutes(@data["cookTime"])
|
|
41
|
+
# (see Models::Recipe#prep_time)
|
|
42
|
+
def prep_time = Parsers::Durations.minutes(@data["prepTime"])
|
|
43
|
+
|
|
44
|
+
# (see Models::Recipe#author)
|
|
45
|
+
def author
|
|
46
|
+
names = [@data["author"]].flatten.filter_map { |entry| author_name(dereference(entry, :person)) }
|
|
47
|
+
names.empty? ? nil : names.join(", ")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# (see Models::Recipe#ingredients)
|
|
51
|
+
def ingredients = IngredientList.parse(@data["recipeIngredient"] || @data["ingredients"])
|
|
52
|
+
|
|
53
|
+
# (see Models::Recipe#instructions_list)
|
|
54
|
+
def instructions_list
|
|
55
|
+
steps = steps_in(@data["recipeInstructions"])
|
|
56
|
+
steps.empty? ? nil : steps
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# (see Models::Recipe#instructions)
|
|
60
|
+
def instructions = instructions_list&.join("\n")
|
|
61
|
+
# (see Models::Recipe#image)
|
|
62
|
+
def image = Text.normalize(url_in(@data["image"]))
|
|
63
|
+
|
|
64
|
+
# (see Models::Recipe#keywords)
|
|
65
|
+
def keywords
|
|
66
|
+
words = [@data["keywords"]].flatten.flat_map { |entry| entry.to_s.split(",") }
|
|
67
|
+
cleaned = words.filter_map { |word| Text.normalize(word) }.grep_v(FACET)
|
|
68
|
+
cleaned.empty? ? nil : cleaned
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# (see Models::Recipe#nutrients)
|
|
72
|
+
def nutrients = NutritionFacts.parse(first_hash(@data["nutrition"]))
|
|
73
|
+
|
|
74
|
+
# (see Models::Recipe#dietary_restrictions)
|
|
75
|
+
def dietary_restrictions
|
|
76
|
+
diets = Array(@data["suitableForDiet"]).filter_map do |diet|
|
|
77
|
+
Text.normalize(diet.is_a?(Hash) ? diet["name"] : diet.to_s.split("/").last)
|
|
78
|
+
end
|
|
79
|
+
diets.empty? ? nil : diets
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# (see Models::Recipe#ratings)
|
|
83
|
+
def ratings = Parsers::Ratings.average(rating_node)
|
|
84
|
+
# (see Models::Recipe#ratings_count)
|
|
85
|
+
def ratings_count = Parsers::Ratings.count(rating_node)
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def rating_node
|
|
90
|
+
@rating_node ||= first_hash(dereference(first_hash(@data["aggregateRating"]), :rating))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def author_name(value) = Text.normalize(value.is_a?(Hash) ? value["name"] : value)
|
|
94
|
+
|
|
95
|
+
def first_hash(value) = [value].flatten.grep(Hash).first
|
|
96
|
+
|
|
97
|
+
def dereference(value, kind)
|
|
98
|
+
return value unless value.is_a?(Hash) && value.keys == ["@id"]
|
|
99
|
+
|
|
100
|
+
@source.public_send(kind, value["@id"])
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def first_text(value) = Text.normalize(value.is_a?(Array) ? value.first : value)
|
|
104
|
+
|
|
105
|
+
def url_in(value)
|
|
106
|
+
case value
|
|
107
|
+
when Array then url_in(value.first)
|
|
108
|
+
when Hash then url_in(value["url"])
|
|
109
|
+
else value
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def steps_in(value)
|
|
114
|
+
case value
|
|
115
|
+
when String then value.split("\n").filter_map { |line| Text.normalize(line) }
|
|
116
|
+
when Array then value.flat_map { |item| listed_step(item) }
|
|
117
|
+
when Hash then steps_in_hash(value)
|
|
118
|
+
else []
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def listed_step(value) = value.is_a?(String) ? [Text.normalize(value)].compact : steps_in(value)
|
|
123
|
+
|
|
124
|
+
def steps_in_hash(value)
|
|
125
|
+
types = Array(value["@type"]).map { |name| name.to_s.downcase }
|
|
126
|
+
return section_steps(value) if types.include?("howtosection")
|
|
127
|
+
return step_steps(value) if types.include?("howtostep")
|
|
128
|
+
|
|
129
|
+
untyped_steps(value)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def section_steps(value)
|
|
133
|
+
heading = Text.normalize(value["name"] || value["Name"])
|
|
134
|
+
[heading].compact + steps_in(value["itemListElement"])
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def step_steps(value)
|
|
138
|
+
nested = value["itemListElement"]
|
|
139
|
+
return [step_heading(value)].compact + steps_in(nested) if nested.is_a?(Array)
|
|
140
|
+
|
|
141
|
+
text = nested.is_a?(Hash) ? nested["text"] : value["text"]
|
|
142
|
+
[step_heading(value), Text.normalize(text)].compact
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def step_heading(value)
|
|
146
|
+
name = value["name"]
|
|
147
|
+
return nil unless name.is_a?(String)
|
|
148
|
+
return nil if value["text"].to_s.start_with?(name.sub(/\.+\z/, ""))
|
|
149
|
+
|
|
150
|
+
Text.normalize(name)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def untyped_steps(value)
|
|
154
|
+
return steps_in(value["itemListElement"]) if value["itemListElement"]
|
|
155
|
+
|
|
156
|
+
[Text.normalize(value["text"] || value["name"])].compact
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|