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.
Files changed (434) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE +20 -0
  4. data/README.md +54 -0
  5. data/lib/recipe_scrapers/configuration.rb +119 -0
  6. data/lib/recipe_scrapers/declaration.rb +85 -0
  7. data/lib/recipe_scrapers/error_tracker.rb +14 -0
  8. data/lib/recipe_scrapers/errors.rb +16 -0
  9. data/lib/recipe_scrapers/http/adapter.rb +50 -0
  10. data/lib/recipe_scrapers/http/address_guard.rb +90 -0
  11. data/lib/recipe_scrapers/http/body_limit.rb +45 -0
  12. data/lib/recipe_scrapers/http/encoding.rb +48 -0
  13. data/lib/recipe_scrapers/http/follow_redirects.rb +75 -0
  14. data/lib/recipe_scrapers/models/ingredient.rb +20 -0
  15. data/lib/recipe_scrapers/models/ingredient_group.rb +20 -0
  16. data/lib/recipe_scrapers/models/nutrient.rb +19 -0
  17. data/lib/recipe_scrapers/models/recipe.rb +125 -0
  18. data/lib/recipe_scrapers/parsers/chain.rb +52 -0
  19. data/lib/recipe_scrapers/parsers/durations.rb +70 -0
  20. data/lib/recipe_scrapers/parsers/ingredients.rb +144 -0
  21. data/lib/recipe_scrapers/parsers/nutrients.rb +47 -0
  22. data/lib/recipe_scrapers/parsers/quantity.rb +46 -0
  23. data/lib/recipe_scrapers/parsers/ratings.rb +26 -0
  24. data/lib/recipe_scrapers/parsers/vocabulary/common.yml +7 -0
  25. data/lib/recipe_scrapers/parsers/vocabulary/de.yml +5 -0
  26. data/lib/recipe_scrapers/parsers/vocabulary/en.yml +26 -0
  27. data/lib/recipe_scrapers/parsers/vocabulary/es.yml +4 -0
  28. data/lib/recipe_scrapers/parsers/vocabulary/fr.yml +5 -0
  29. data/lib/recipe_scrapers/parsers/vocabulary/hu.yml +3 -0
  30. data/lib/recipe_scrapers/parsers/vocabulary/it.yml +4 -0
  31. data/lib/recipe_scrapers/parsers/vocabulary/ja.yml +2 -0
  32. data/lib/recipe_scrapers/parsers/vocabulary/nl.yml +4 -0
  33. data/lib/recipe_scrapers/parsers/vocabulary/pt.yml +8 -0
  34. data/lib/recipe_scrapers/parsers/vocabulary/ru.yml +7 -0
  35. data/lib/recipe_scrapers/parsers/vocabulary/scandinavian.yml +3 -0
  36. data/lib/recipe_scrapers/parsers/vocabulary/sk.yml +3 -0
  37. data/lib/recipe_scrapers/parsers/vocabulary/tr.yml +2 -0
  38. data/lib/recipe_scrapers/parsers/vocabulary.rb +120 -0
  39. data/lib/recipe_scrapers/parsers/yields.rb +30 -0
  40. data/lib/recipe_scrapers/registry.rb +88 -0
  41. data/lib/recipe_scrapers/scraper/parsed_fields.rb +69 -0
  42. data/lib/recipe_scrapers/scraper.rb +199 -0
  43. data/lib/recipe_scrapers/site_path.rb +21 -0
  44. data/lib/recipe_scrapers/sites/au/bestrecipes.rb +3 -0
  45. data/lib/recipe_scrapers/sites/be/15gram.rb +3 -0
  46. data/lib/recipe_scrapers/sites/br/panelinha.rb +3 -0
  47. data/lib/recipe_scrapers/sites/br/receitasnestle.rb +3 -0
  48. data/lib/recipe_scrapers/sites/br/receiteria.rb +3 -0
  49. data/lib/recipe_scrapers/sites/br/tudogostoso.rb +3 -0
  50. data/lib/recipe_scrapers/sites/ch/bettybossi.rb +3 -0
  51. data/lib/recipe_scrapers/sites/ch/migusto.rb +3 -0
  52. data/lib/recipe_scrapers/sites/ch/swissmilk.rb +3 -0
  53. data/lib/recipe_scrapers/sites/co/tasty.rb +3 -0
  54. data/lib/recipe_scrapers/sites/com/365daysofbakingandmore.rb +3 -0
  55. data/lib/recipe_scrapers/sites/com/40aprons.rb +3 -0
  56. data/lib/recipe_scrapers/sites/com/750g.rb +3 -0
  57. data/lib/recipe_scrapers/sites/com/abeautifulmess.rb +3 -0
  58. data/lib/recipe_scrapers/sites/com/abuelascounter.rb +3 -0
  59. data/lib/recipe_scrapers/sites/com/acozykitchen.rb +3 -0
  60. data/lib/recipe_scrapers/sites/com/addapinch.rb +3 -0
  61. data/lib/recipe_scrapers/sites/com/adozensundays.rb +3 -0
  62. data/lib/recipe_scrapers/sites/com/aflavorjournal.rb +3 -0
  63. data/lib/recipe_scrapers/sites/com/afullliving.rb +3 -0
  64. data/lib/recipe_scrapers/sites/com/ahealthysliceoflife.rb +5 -0
  65. data/lib/recipe_scrapers/sites/com/alexandracooks.rb +3 -0
  66. data/lib/recipe_scrapers/sites/com/allnutritious.rb +3 -0
  67. data/lib/recipe_scrapers/sites/com/allthehealthythings.rb +3 -0
  68. data/lib/recipe_scrapers/sites/com/altonbrown.rb +3 -0
  69. data/lib/recipe_scrapers/sites/com/amazingribs.rb +3 -0
  70. data/lib/recipe_scrapers/sites/com/amberskitchencooks.rb +3 -0
  71. data/lib/recipe_scrapers/sites/com/andy_cooks.rb +3 -0
  72. data/lib/recipe_scrapers/sites/com/anitalianinmykitchen.rb +3 -0
  73. data/lib/recipe_scrapers/sites/com/app.rb +27 -0
  74. data/lib/recipe_scrapers/sites/com/asweetpeachef.rb +3 -0
  75. data/lib/recipe_scrapers/sites/com/aubreyskitchen.rb +3 -0
  76. data/lib/recipe_scrapers/sites/com/averiecooks.rb +3 -0
  77. data/lib/recipe_scrapers/sites/com/bake_eat_repeat.rb +3 -0
  78. data/lib/recipe_scrapers/sites/com/bakerbynature.rb +3 -0
  79. data/lib/recipe_scrapers/sites/com/bakewithzoha.rb +3 -0
  80. data/lib/recipe_scrapers/sites/com/baking_sense.rb +3 -0
  81. data/lib/recipe_scrapers/sites/com/bakingmischief.rb +3 -0
  82. data/lib/recipe_scrapers/sites/com/barefeetinthekitchen.rb +3 -0
  83. data/lib/recipe_scrapers/sites/com/barefootinthepines.rb +3 -0
  84. data/lib/recipe_scrapers/sites/com/bbcgoodfood.rb +3 -0
  85. data/lib/recipe_scrapers/sites/com/belleofthekitchen.rb +3 -0
  86. data/lib/recipe_scrapers/sites/com/betterfoodguru.rb +3 -0
  87. data/lib/recipe_scrapers/sites/com/bettycrocker.rb +3 -0
  88. data/lib/recipe_scrapers/sites/com/beyondfrosting.rb +3 -0
  89. data/lib/recipe_scrapers/sites/com/biancazapatka.rb +3 -0
  90. data/lib/recipe_scrapers/sites/com/biggerbolderbaking.rb +3 -0
  91. data/lib/recipe_scrapers/sites/com/billyparisi.rb +3 -0
  92. data/lib/recipe_scrapers/sites/com/bitsofcarey.rb +3 -0
  93. data/lib/recipe_scrapers/sites/com/blogghetti.rb +3 -0
  94. data/lib/recipe_scrapers/sites/com/bluejeanchef.rb +3 -0
  95. data/lib/recipe_scrapers/sites/com/bonappetit.rb +3 -0
  96. data/lib/recipe_scrapers/sites/com/bonviveur.rb +3 -0
  97. data/lib/recipe_scrapers/sites/com/bowlofdelicious.rb +3 -0
  98. data/lib/recipe_scrapers/sites/com/breadtopia.rb +3 -0
  99. data/lib/recipe_scrapers/sites/com/brokenovenbaking.rb +3 -0
  100. data/lib/recipe_scrapers/sites/com/budgetbytes.rb +3 -0
  101. data/lib/recipe_scrapers/sites/com/cafedelites.rb +3 -0
  102. data/lib/recipe_scrapers/sites/com/cakemehometonight.rb +3 -0
  103. data/lib/recipe_scrapers/sites/com/cakewhiz.rb +3 -0
  104. data/lib/recipe_scrapers/sites/com/cambreabakes.rb +3 -0
  105. data/lib/recipe_scrapers/sites/com/carlsbadcravings.rb +3 -0
  106. data/lib/recipe_scrapers/sites/com/castironskilletcooking.rb +3 -0
  107. data/lib/recipe_scrapers/sites/com/celebratingsweets.rb +3 -0
  108. data/lib/recipe_scrapers/sites/com/chefjackovens.rb +3 -0
  109. data/lib/recipe_scrapers/sites/com/chefsavvy.rb +3 -0
  110. data/lib/recipe_scrapers/sites/com/chewoutloud.rb +3 -0
  111. data/lib/recipe_scrapers/sites/com/chocolatewithgrace.rb +3 -0
  112. data/lib/recipe_scrapers/sites/com/ciaoflorentina.rb +3 -0
  113. data/lib/recipe_scrapers/sites/com/closetcooking.rb +3 -0
  114. data/lib/recipe_scrapers/sites/com/cloudykitchen.rb +3 -0
  115. data/lib/recipe_scrapers/sites/com/coleycooks.rb +3 -0
  116. data/lib/recipe_scrapers/sites/com/cookedandloved.rb +3 -0
  117. data/lib/recipe_scrapers/sites/com/cookfastrecipes.rb +3 -0
  118. data/lib/recipe_scrapers/sites/com/cookieandkate.rb +3 -0
  119. data/lib/recipe_scrapers/sites/com/cookiesandcups.rb +3 -0
  120. data/lib/recipe_scrapers/sites/com/cooking.rb +3 -0
  121. data/lib/recipe_scrapers/sites/com/cookingclassy.rb +3 -0
  122. data/lib/recipe_scrapers/sites/com/cookinglsl.rb +3 -0
  123. data/lib/recipe_scrapers/sites/com/cookingwithjanica.rb +3 -0
  124. data/lib/recipe_scrapers/sites/com/cookpad.rb +3 -0
  125. data/lib/recipe_scrapers/sites/com/cookwell.rb +3 -0
  126. data/lib/recipe_scrapers/sites/com/cookwithdana.rb +3 -0
  127. data/lib/recipe_scrapers/sites/com/copykat.rb +3 -0
  128. data/lib/recipe_scrapers/sites/com/countryliving.rb +3 -0
  129. data/lib/recipe_scrapers/sites/com/crazyforcrust.rb +3 -0
  130. data/lib/recipe_scrapers/sites/com/creativecanning.rb +3 -0
  131. data/lib/recipe_scrapers/sites/com/cuisineaz.rb +3 -0
  132. data/lib/recipe_scrapers/sites/com/daringgourmet.rb +3 -0
  133. data/lib/recipe_scrapers/sites/com/dashfordinner.rb +3 -0
  134. data/lib/recipe_scrapers/sites/com/davidlebovitz.rb +3 -0
  135. data/lib/recipe_scrapers/sites/com/deliciouslyella.rb +3 -0
  136. data/lib/recipe_scrapers/sites/com/deliciouslysprinkled.rb +3 -0
  137. data/lib/recipe_scrapers/sites/com/delish.rb +3 -0
  138. data/lib/recipe_scrapers/sites/com/delscookingtwist.rb +3 -0
  139. data/lib/recipe_scrapers/sites/com/dinneratthezoo.rb +3 -0
  140. data/lib/recipe_scrapers/sites/com/directoalpaladar.rb +3 -0
  141. data/lib/recipe_scrapers/sites/com/domesticate_me.rb +3 -0
  142. data/lib/recipe_scrapers/sites/com/downshiftology.rb +3 -0
  143. data/lib/recipe_scrapers/sites/com/eatingbirdfood.rb +3 -0
  144. data/lib/recipe_scrapers/sites/com/eatingeuropean.rb +3 -0
  145. data/lib/recipe_scrapers/sites/com/eatingonadime.rb +3 -0
  146. data/lib/recipe_scrapers/sites/com/eatliverun.rb +3 -0
  147. data/lib/recipe_scrapers/sites/com/eatthismuch.rb +3 -0
  148. data/lib/recipe_scrapers/sites/com/elavegan.rb +3 -0
  149. data/lib/recipe_scrapers/sites/com/emilybites.rb +3 -0
  150. data/lib/recipe_scrapers/sites/com/erinliveswhole.rb +3 -0
  151. data/lib/recipe_scrapers/sites/com/erinscozykitchen.rb +3 -0
  152. data/lib/recipe_scrapers/sites/com/errenskitchen.rb +3 -0
  153. data/lib/recipe_scrapers/sites/com/everyday_delicious.rb +3 -0
  154. data/lib/recipe_scrapers/sites/com/everydaypie.rb +3 -0
  155. data/lib/recipe_scrapers/sites/com/evolvingtable.rb +3 -0
  156. data/lib/recipe_scrapers/sites/com/familyfoodonthetable.rb +3 -0
  157. data/lib/recipe_scrapers/sites/com/familyspice.rb +3 -0
  158. data/lib/recipe_scrapers/sites/com/fantabulosity.rb +3 -0
  159. data/lib/recipe_scrapers/sites/com/farmhouseonboone.rb +3 -0
  160. data/lib/recipe_scrapers/sites/com/farmtojar.rb +3 -0
  161. data/lib/recipe_scrapers/sites/com/feastingathome.rb +3 -0
  162. data/lib/recipe_scrapers/sites/com/fifteenspatulas.rb +3 -0
  163. data/lib/recipe_scrapers/sites/com/figjar.rb +3 -0
  164. data/lib/recipe_scrapers/sites/com/finedininglovers.rb +3 -0
  165. data/lib/recipe_scrapers/sites/com/fithealthymacros.rb +3 -0
  166. data/lib/recipe_scrapers/sites/com/fitslowcookerqueen.rb +3 -0
  167. data/lib/recipe_scrapers/sites/com/food.rb +3 -0
  168. data/lib/recipe_scrapers/sites/com/foodbymaria.rb +3 -0
  169. data/lib/recipe_scrapers/sites/com/foodfidelity.rb +3 -0
  170. data/lib/recipe_scrapers/sites/com/foodrepublic.rb +3 -0
  171. data/lib/recipe_scrapers/sites/com/forksoverknives.rb +3 -0
  172. data/lib/recipe_scrapers/sites/com/garlicandzest.rb +3 -0
  173. data/lib/recipe_scrapers/sites/com/garnishandglaze.rb +3 -0
  174. data/lib/recipe_scrapers/sites/com/gimmesomeoven.rb +3 -0
  175. data/lib/recipe_scrapers/sites/com/girlgonegourmet.rb +3 -0
  176. data/lib/recipe_scrapers/sites/com/girlversusdough.rb +3 -0
  177. data/lib/recipe_scrapers/sites/com/gloriousrecipes.rb +3 -0
  178. data/lib/recipe_scrapers/sites/com/glutenfreeonashoestring.rb +3 -0
  179. data/lib/recipe_scrapers/sites/com/gonnawantseconds.rb +3 -0
  180. data/lib/recipe_scrapers/sites/com/goodfooddiscoveries.rb +3 -0
  181. data/lib/recipe_scrapers/sites/com/goodhousekeeping.rb +3 -0
  182. data/lib/recipe_scrapers/sites/com/grandbaby_cakes.rb +3 -0
  183. data/lib/recipe_scrapers/sites/com/greatbritishchefs.rb +3 -0
  184. data/lib/recipe_scrapers/sites/com/halfbakedharvest.rb +3 -0
  185. data/lib/recipe_scrapers/sites/com/hassanchef.rb +3 -0
  186. data/lib/recipe_scrapers/sites/com/headbangerskitchen.rb +3 -0
  187. data/lib/recipe_scrapers/sites/com/healthywithachanceofsprinkles.rb +3 -0
  188. data/lib/recipe_scrapers/sites/com/heatherchristo.rb +3 -0
  189. data/lib/recipe_scrapers/sites/com/hilahcooking.rb +3 -0
  190. data/lib/recipe_scrapers/sites/com/hogarmania.rb +3 -0
  191. data/lib/recipe_scrapers/sites/com/homeandplate.rb +3 -0
  192. data/lib/recipe_scrapers/sites/com/hostthetoast.rb +3 -0
  193. data/lib/recipe_scrapers/sites/com/houseofnasheats.rb +3 -0
  194. data/lib/recipe_scrapers/sites/com/houseofyumm.rb +3 -0
  195. data/lib/recipe_scrapers/sites/com/iamafoodblog.rb +3 -0
  196. data/lib/recipe_scrapers/sites/com/im_worthy.rb +3 -0
  197. data/lib/recipe_scrapers/sites/com/inbloombakery.rb +3 -0
  198. data/lib/recipe_scrapers/sites/com/indianhealthyrecipes.rb +3 -0
  199. data/lib/recipe_scrapers/sites/com/ingoodflavor.rb +3 -0
  200. data/lib/recipe_scrapers/sites/com/inspiralized.rb +3 -0
  201. data/lib/recipe_scrapers/sites/com/itdoesnttastelikechicken.rb +3 -0
  202. data/lib/recipe_scrapers/sites/com/itsnotaboutnutrition.rb +3 -0
  203. data/lib/recipe_scrapers/sites/com/izzycooking.rb +3 -0
  204. data/lib/recipe_scrapers/sites/com/jennycancook.rb +3 -0
  205. data/lib/recipe_scrapers/sites/com/jimcooksfoodgood.rb +3 -0
  206. data/lib/recipe_scrapers/sites/com/jocooks.rb +3 -0
  207. data/lib/recipe_scrapers/sites/com/joyfullymad.rb +3 -0
  208. data/lib/recipe_scrapers/sites/com/joythebaker.rb +3 -0
  209. data/lib/recipe_scrapers/sites/com/juliasalbum.rb +3 -0
  210. data/lib/recipe_scrapers/sites/com/justataste.rb +3 -0
  211. data/lib/recipe_scrapers/sites/com/justinesnacks.rb +3 -0
  212. data/lib/recipe_scrapers/sites/com/kennethtemple.rb +3 -0
  213. data/lib/recipe_scrapers/sites/com/keytomylime.rb +3 -0
  214. data/lib/recipe_scrapers/sites/com/kitchendreaming.rb +3 -0
  215. data/lib/recipe_scrapers/sites/com/kitchenstories.rb +3 -0
  216. data/lib/recipe_scrapers/sites/com/kristineskitchenblog.rb +3 -0
  217. data/lib/recipe_scrapers/sites/com/krollskorner.rb +3 -0
  218. data/lib/recipe_scrapers/sites/com/lanascooking.rb +3 -0
  219. data/lib/recipe_scrapers/sites/com/laurenslatest.rb +3 -0
  220. data/lib/recipe_scrapers/sites/com/lazycatkitchen.rb +3 -0
  221. data/lib/recipe_scrapers/sites/com/lecremedelacrumb.rb +3 -0
  222. data/lib/recipe_scrapers/sites/com/leitesculinaria.rb +3 -0
  223. data/lib/recipe_scrapers/sites/com/lekkerensimpel.rb +3 -0
  224. data/lib/recipe_scrapers/sites/com/letscampsmore.rb +3 -0
  225. data/lib/recipe_scrapers/sites/com/lifestyleofafoodie.rb +3 -0
  226. data/lib/recipe_scrapers/sites/com/littleferrarokitchen.rb +3 -0
  227. data/lib/recipe_scrapers/sites/com/littlespicejar.rb +3 -0
  228. data/lib/recipe_scrapers/sites/com/littlespoonfarm.rb +3 -0
  229. data/lib/recipe_scrapers/sites/com/littlesunnykitchen.rb +3 -0
  230. data/lib/recipe_scrapers/sites/com/livelytable.rb +3 -0
  231. data/lib/recipe_scrapers/sites/com/lolascocina.rb +3 -0
  232. data/lib/recipe_scrapers/sites/com/lovingitvegan.rb +3 -0
  233. data/lib/recipe_scrapers/sites/com/madsvin.rb +3 -0
  234. data/lib/recipe_scrapers/sites/com/makeitdairyfree.rb +3 -0
  235. data/lib/recipe_scrapers/sites/com/mealprepmanual.rb +3 -0
  236. data/lib/recipe_scrapers/sites/com/meganvskitchen.rb +3 -0
  237. data/lib/recipe_scrapers/sites/com/melissaknorris.rb +3 -0
  238. data/lib/recipe_scrapers/sites/com/melskitchencafe.rb +3 -0
  239. data/lib/recipe_scrapers/sites/com/mexicanplease.rb +3 -0
  240. data/lib/recipe_scrapers/sites/com/minimalistbaker.rb +3 -0
  241. data/lib/recipe_scrapers/sites/com/ministryofcurry.rb +3 -0
  242. data/lib/recipe_scrapers/sites/com/modernhoney.rb +3 -0
  243. data/lib/recipe_scrapers/sites/com/momontimeout.rb +3 -0
  244. data/lib/recipe_scrapers/sites/com/momswithcrockpots.rb +3 -0
  245. data/lib/recipe_scrapers/sites/com/motherthyme.rb +3 -0
  246. data/lib/recipe_scrapers/sites/com/mybakingaddiction.rb +3 -0
  247. data/lib/recipe_scrapers/sites/com/myjewishlearning.rb +3 -0
  248. data/lib/recipe_scrapers/sites/com/mykidslickthebowl.rb +3 -0
  249. data/lib/recipe_scrapers/sites/com/mykoreankitchen.rb +3 -0
  250. data/lib/recipe_scrapers/sites/com/myriadrecipes.rb +3 -0
  251. data/lib/recipe_scrapers/sites/com/myvegetarianroots.rb +3 -0
  252. data/lib/recipe_scrapers/sites/com/natashaskitchen.rb +3 -0
  253. data/lib/recipe_scrapers/sites/com/naturallyella.rb +3 -0
  254. data/lib/recipe_scrapers/sites/com/noracooks.rb +3 -0
  255. data/lib/recipe_scrapers/sites/com/norecipes.rb +3 -0
  256. data/lib/recipe_scrapers/sites/com/notenoughcinnamon.rb +3 -0
  257. data/lib/recipe_scrapers/sites/com/nourishedbynutrition.rb +3 -0
  258. data/lib/recipe_scrapers/sites/com/number_2_pencil.rb +3 -0
  259. data/lib/recipe_scrapers/sites/com/onceuponachef.rb +3 -0
  260. data/lib/recipe_scrapers/sites/com/onesweetappetite.rb +3 -0
  261. data/lib/recipe_scrapers/sites/com/organicallyaddison.rb +3 -0
  262. data/lib/recipe_scrapers/sites/com/ourbestbites.rb +3 -0
  263. data/lib/recipe_scrapers/sites/com/paleorunningmomma.rb +3 -0
  264. data/lib/recipe_scrapers/sites/com/panlasangpinoy.rb +3 -0
  265. data/lib/recipe_scrapers/sites/com/peelwithzeal.rb +3 -0
  266. data/lib/recipe_scrapers/sites/com/pequerecetas.rb +3 -0
  267. data/lib/recipe_scrapers/sites/com/pilipinasrecipes.rb +3 -0
  268. data/lib/recipe_scrapers/sites/com/pinkowlkitchen.rb +3 -0
  269. data/lib/recipe_scrapers/sites/com/plantyou.rb +3 -0
  270. data/lib/recipe_scrapers/sites/com/platingpixels.rb +3 -0
  271. data/lib/recipe_scrapers/sites/com/platingsandpairings.rb +3 -0
  272. data/lib/recipe_scrapers/sites/com/plowingthroughlife.rb +3 -0
  273. data/lib/recipe_scrapers/sites/com/polishfoodies.rb +3 -0
  274. data/lib/recipe_scrapers/sites/com/practicalselfreliance.rb +3 -0
  275. data/lib/recipe_scrapers/sites/com/preppykitchen.rb +3 -0
  276. data/lib/recipe_scrapers/sites/com/pressureluckcooking.rb +3 -0
  277. data/lib/recipe_scrapers/sites/com/purelypope.rb +3 -0
  278. data/lib/recipe_scrapers/sites/com/rachlmansfield.rb +3 -0
  279. data/lib/recipe_scrapers/sites/com/rainbowplantlife.rb +3 -0
  280. data/lib/recipe_scrapers/sites/com/realfoodwell.rb +3 -0
  281. data/lib/recipe_scrapers/sites/com/realmomnutrition.rb +3 -0
  282. data/lib/recipe_scrapers/sites/com/receitas.rb +3 -0
  283. data/lib/recipe_scrapers/sites/com/recipeforperfection.rb +3 -0
  284. data/lib/recipe_scrapers/sites/com/recipegirl.rb +3 -0
  285. data/lib/recipe_scrapers/sites/com/reciperunner.rb +3 -0
  286. data/lib/recipe_scrapers/sites/com/recipetineats.rb +3 -0
  287. data/lib/recipe_scrapers/sites/com/redhousespice.rb +3 -0
  288. data/lib/recipe_scrapers/sites/com/ricardocuisine.rb +3 -0
  289. data/lib/recipe_scrapers/sites/com/sallysbakingaddiction.rb +3 -0
  290. data/lib/recipe_scrapers/sites/com/saltpepperskillet.rb +3 -0
  291. data/lib/recipe_scrapers/sites/com/sandwichtribunal.rb +3 -0
  292. data/lib/recipe_scrapers/sites/com/savoringthegood.rb +3 -0
  293. data/lib/recipe_scrapers/sites/com/savorynothings.rb +3 -0
  294. data/lib/recipe_scrapers/sites/com/savorythoughts.rb +3 -0
  295. data/lib/recipe_scrapers/sites/com/scrummylane.rb +3 -0
  296. data/lib/recipe_scrapers/sites/com/shelikesfood.rb +3 -0
  297. data/lib/recipe_scrapers/sites/com/simple_veganista.rb +3 -0
  298. data/lib/recipe_scrapers/sites/com/simplehomeedit.rb +3 -0
  299. data/lib/recipe_scrapers/sites/com/simplyquinoa.rb +3 -0
  300. data/lib/recipe_scrapers/sites/com/simplywhisked.rb +3 -0
  301. data/lib/recipe_scrapers/sites/com/sipandfeast.rb +3 -0
  302. data/lib/recipe_scrapers/sites/com/sizzlefish.rb +3 -0
  303. data/lib/recipe_scrapers/sites/com/skinnytaste.rb +3 -0
  304. data/lib/recipe_scrapers/sites/com/smalltownwoman.rb +3 -0
  305. data/lib/recipe_scrapers/sites/com/somuchfoodblog.rb +3 -0
  306. data/lib/recipe_scrapers/sites/com/southernbite.rb +3 -0
  307. data/lib/recipe_scrapers/sites/com/spainonafork.rb +3 -0
  308. data/lib/recipe_scrapers/sites/com/spendwithpennies.rb +3 -0
  309. data/lib/recipe_scrapers/sites/com/spicysouthernkitchen.rb +3 -0
  310. data/lib/recipe_scrapers/sites/com/stacyling.rb +3 -0
  311. data/lib/recipe_scrapers/sites/com/staysnatched.rb +3 -0
  312. data/lib/recipe_scrapers/sites/com/steamykitchen.rb +3 -0
  313. data/lib/recipe_scrapers/sites/com/strongrfastr.rb +3 -0
  314. data/lib/recipe_scrapers/sites/com/sudachirecipes.rb +3 -0
  315. data/lib/recipe_scrapers/sites/com/sugarhero.rb +3 -0
  316. data/lib/recipe_scrapers/sites/com/sugarmaplefarmhouse.rb +3 -0
  317. data/lib/recipe_scrapers/sites/com/sugarspunrun.rb +3 -0
  318. data/lib/recipe_scrapers/sites/com/sunset.rb +3 -0
  319. data/lib/recipe_scrapers/sites/com/sweetcsdesigns.rb +3 -0
  320. data/lib/recipe_scrapers/sites/com/tableanddish.rb +3 -0
  321. data/lib/recipe_scrapers/sites/com/tasteandtellblog.rb +3 -0
  322. data/lib/recipe_scrapers/sites/com/tasteofhome.rb +3 -0
  323. data/lib/recipe_scrapers/sites/com/tastesbetterfromscratch.rb +3 -0
  324. data/lib/recipe_scrapers/sites/com/tastesoflizzyt.rb +3 -0
  325. data/lib/recipe_scrapers/sites/com/tastyoven.rb +3 -0
  326. data/lib/recipe_scrapers/sites/com/tatyanaseverydayfood.rb +3 -0
  327. data/lib/recipe_scrapers/sites/com/teakandthyme.rb +3 -0
  328. data/lib/recipe_scrapers/sites/com/thealmondeater.rb +3 -0
  329. data/lib/recipe_scrapers/sites/com/thebigmansworld.rb +3 -0
  330. data/lib/recipe_scrapers/sites/com/theclevercarrot.rb +3 -0
  331. data/lib/recipe_scrapers/sites/com/thecookierookie.rb +3 -0
  332. data/lib/recipe_scrapers/sites/com/thefoodcharlatan.rb +3 -0
  333. data/lib/recipe_scrapers/sites/com/thefoodietakesflight.rb +3 -0
  334. data/lib/recipe_scrapers/sites/com/theglutenfreeaustrian.rb +3 -0
  335. data/lib/recipe_scrapers/sites/com/theguardian.rb +3 -0
  336. data/lib/recipe_scrapers/sites/com/theicecreamconfectionals.rb +3 -0
  337. data/lib/recipe_scrapers/sites/com/thekitchenmagpie.rb +3 -0
  338. data/lib/recipe_scrapers/sites/com/themagicalslowcooker.rb +3 -0
  339. data/lib/recipe_scrapers/sites/com/themediterraneandish.rb +3 -0
  340. data/lib/recipe_scrapers/sites/com/themodernproper.rb +3 -0
  341. data/lib/recipe_scrapers/sites/com/thepalatablelife.rb +3 -0
  342. data/lib/recipe_scrapers/sites/com/thepioneerwoman.rb +3 -0
  343. data/lib/recipe_scrapers/sites/com/theppk.rb +3 -0
  344. data/lib/recipe_scrapers/sites/com/therecipecritic.rb +3 -0
  345. data/lib/recipe_scrapers/sites/com/thesaltymarshmallow.rb +3 -0
  346. data/lib/recipe_scrapers/sites/com/thespicetrain.rb +3 -0
  347. data/lib/recipe_scrapers/sites/com/thesuburbansoapbox.rb +3 -0
  348. data/lib/recipe_scrapers/sites/com/thevintagemixer.rb +3 -0
  349. data/lib/recipe_scrapers/sites/com/thewoodenskillet.rb +3 -0
  350. data/lib/recipe_scrapers/sites/com/thinlicious.rb +3 -0
  351. data/lib/recipe_scrapers/sites/com/thishealthytable.rb +3 -0
  352. data/lib/recipe_scrapers/sites/com/toriavey.rb +3 -0
  353. data/lib/recipe_scrapers/sites/com/twopeasandtheirpod.rb +3 -0
  354. data/lib/recipe_scrapers/sites/com/unsophisticook.rb +3 -0
  355. data/lib/recipe_scrapers/sites/com/valentinascorner.rb +3 -0
  356. data/lib/recipe_scrapers/sites/com/vanillaandbean.rb +3 -0
  357. data/lib/recipe_scrapers/sites/com/veganricha.rb +3 -0
  358. data/lib/recipe_scrapers/sites/com/vegrecipesofindia.rb +3 -0
  359. data/lib/recipe_scrapers/sites/com/wearenotmartha.rb +3 -0
  360. data/lib/recipe_scrapers/sites/com/wedishitup.rb +3 -0
  361. data/lib/recipe_scrapers/sites/com/wellplated.rb +3 -0
  362. data/lib/recipe_scrapers/sites/com/whatsgabycooking.rb +3 -0
  363. data/lib/recipe_scrapers/sites/com/wyseguide.rb +3 -0
  364. data/lib/recipe_scrapers/sites/com/yemek.rb +3 -0
  365. data/lib/recipe_scrapers/sites/com/zenbelly.rb +3 -0
  366. data/lib/recipe_scrapers/sites/com/zestfulkitchen.rb +3 -0
  367. data/lib/recipe_scrapers/sites/de/bofrost.rb +3 -0
  368. data/lib/recipe_scrapers/sites/de/chefkoch.rb +26 -0
  369. data/lib/recipe_scrapers/sites/de/eattolerant.rb +3 -0
  370. data/lib/recipe_scrapers/sites/de/emmikochteinfach.rb +3 -0
  371. data/lib/recipe_scrapers/sites/de/kochbar.rb +3 -0
  372. data/lib/recipe_scrapers/sites/de/springlane.rb +3 -0
  373. data/lib/recipe_scrapers/sites/dk/madensverden.rb +3 -0
  374. data/lib/recipe_scrapers/sites/dk/sundpaabudget.rb +3 -0
  375. data/lib/recipe_scrapers/sites/fr/jow.rb +3 -0
  376. data/lib/recipe_scrapers/sites/fr/madame.rb +35 -0
  377. data/lib/recipe_scrapers/sites/hu/nosalty.rb +3 -0
  378. data/lib/recipe_scrapers/sites/hu/sobors.rb +3 -0
  379. data/lib/recipe_scrapers/sites/info/misya.rb +3 -0
  380. data/lib/recipe_scrapers/sites/it/cucchiaio.rb +3 -0
  381. data/lib/recipe_scrapers/sites/it/ricette.rb +3 -0
  382. data/lib/recipe_scrapers/sites/it/ricetteperbimby.rb +3 -0
  383. data/lib/recipe_scrapers/sites/me/leckerschmecker.rb +3 -0
  384. data/lib/recipe_scrapers/sites/menu/1000.rb +3 -0
  385. data/lib/recipe_scrapers/sites/mx/cocinafacil.rb +3 -0
  386. data/lib/recipe_scrapers/sites/mx/directoalpaladar.rb +3 -0
  387. data/lib/recipe_scrapers/sites/mx/recetasnestle.rb +3 -0
  388. data/lib/recipe_scrapers/sites/net/bellyfull.rb +3 -0
  389. data/lib/recipe_scrapers/sites/net/castironketo.rb +3 -0
  390. data/lib/recipe_scrapers/sites/net/feelgoodfoodie.rb +3 -0
  391. data/lib/recipe_scrapers/sites/net/hungryhappens.rb +3 -0
  392. data/lib/recipe_scrapers/sites/net/iambaker.rb +3 -0
  393. data/lib/recipe_scrapers/sites/net/inspiredtaste.rb +3 -0
  394. data/lib/recipe_scrapers/sites/net/piesandplots.rb +3 -0
  395. data/lib/recipe_scrapers/sites/net/tidymom.rb +3 -0
  396. data/lib/recipe_scrapers/sites/nl/leukerecepten.rb +3 -0
  397. data/lib/recipe_scrapers/sites/nl/projectgezond.rb +3 -0
  398. data/lib/recipe_scrapers/sites/no/godt.rb +3 -0
  399. data/lib/recipe_scrapers/sites/no/matprat.rb +3 -0
  400. data/lib/recipe_scrapers/sites/no/nrk.rb +3 -0
  401. data/lib/recipe_scrapers/sites/no/tine.rb +3 -0
  402. data/lib/recipe_scrapers/sites/org/lmld.rb +3 -0
  403. data/lib/recipe_scrapers/sites/org/marmiton.rb +3 -0
  404. data/lib/recipe_scrapers/sites/org/nutritionfacts.rb +3 -0
  405. data/lib/recipe_scrapers/sites/org/thekitchencommunity.rb +3 -0
  406. data/lib/recipe_scrapers/sites/plus/recette.rb +3 -0
  407. data/lib/recipe_scrapers/sites/pt/pingodoce.rb +3 -0
  408. data/lib/recipe_scrapers/sites/pt/teleculinaria.rb +3 -0
  409. data/lib/recipe_scrapers/sites/recipes/goodstuff.rb +3 -0
  410. data/lib/recipe_scrapers/sites/recipes/howtocook.rb +3 -0
  411. data/lib/recipe_scrapers/sites/ru/eda.rb +3 -0
  412. data/lib/recipe_scrapers/sites/ru/edimdoma.rb +3 -0
  413. data/lib/recipe_scrapers/sites/ru/gastronom.rb +3 -0
  414. data/lib/recipe_scrapers/sites/ru/iamcook.rb +6 -0
  415. data/lib/recipe_scrapers/sites/ru/povar.rb +3 -0
  416. data/lib/recipe_scrapers/sites/ru/russianfood.rb +8 -0
  417. data/lib/recipe_scrapers/sites/se/alltommat.rb +3 -0
  418. data/lib/recipe_scrapers/sites/se/ica.rb +3 -0
  419. data/lib/recipe_scrapers/sites/se/kiddokitchen.rb +3 -0
  420. data/lib/recipe_scrapers/sites/se/koket.rb +3 -0
  421. data/lib/recipe_scrapers/sites/se/recept.rb +3 -0
  422. data/lib/recipe_scrapers/sites/sk/receptyprevas.rb +3 -0
  423. data/lib/recipe_scrapers/sources/declared.rb +38 -0
  424. data/lib/recipe_scrapers/sources/ingredient_groups.rb +74 -0
  425. data/lib/recipe_scrapers/sources/json_ld.rb +93 -0
  426. data/lib/recipe_scrapers/sources/microdata.rb +113 -0
  427. data/lib/recipe_scrapers/sources/open_graph.rb +26 -0
  428. data/lib/recipe_scrapers/sources/schema_org/ingredient_list.rb +36 -0
  429. data/lib/recipe_scrapers/sources/schema_org/nutrition_facts.rb +21 -0
  430. data/lib/recipe_scrapers/sources/schema_org.rb +160 -0
  431. data/lib/recipe_scrapers/text.rb +59 -0
  432. data/lib/recipe_scrapers/version.rb +6 -0
  433. data/lib/recipe_scrapers.rb +137 -0
  434. metadata +560 -0
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ RecipeScrapers.register "howtocook.recipes"
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ RecipeScrapers.register "eda.ru"
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ RecipeScrapers.register "edimdoma.ru"
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ RecipeScrapers.register "gastronom.ru"
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ RecipeScrapers.register "iamcook.ru" do
4
+ ingredients rows: "div.ingredients div.ilist > div > p"
5
+ instructions rows: "div.instructions > p"
6
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ RecipeScrapers.register "povar.ru"
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ RecipeScrapers.register "russianfood.com" do
4
+ encoding "windows-1251"
5
+ title "h1"
6
+ ingredients rows: "tr.ingr_tr_0, tr.ingr_tr_1"
7
+ instructions rows: "div.step_n"
8
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ RecipeScrapers.register "alltommat.se", also: %w[alltommat.expressen.se]
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ RecipeScrapers.register "ica.se"
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ RecipeScrapers.register "kiddokitchen.se"
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ RecipeScrapers.register "koket.se"
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ RecipeScrapers.register "recept.se"
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ RecipeScrapers.register "receptyprevas.sk"
@@ -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