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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a7ef3e9e75436df3b37c84f48c98c7d09586560a9c1d90b567ee990b5ea9d288
4
+ data.tar.gz: c1fab2685faf5fbb236a74b8c9b4b5130b3f0c0c351c9fedfec919768e6dc260
5
+ SHA512:
6
+ metadata.gz: 06cf44e4b1e4bc58c93d4176bd9bb8140ddcc9db43e6593f8685eea72557f709e8488245e82b8fb6289547a39d91072e6ee17084bab3a3d65a6927644eb35200
7
+ data.tar.gz: 11b3a22c61673e7232411f915e575aad8ef2f8f3b6ac838e54f63e03a21b023b2817c03aa4a415213530d6d8c7f515fae2befa0b31834ba79d2f742429f586ca
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ The first release.
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 David Khotelov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
6
+ this software and associated documentation files (the "Software"), to deal in
7
+ the Software without restriction, including without limitation the rights to
8
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9
+ the Software, and to permit persons to whom the Software is furnished to do so,
10
+ subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # recipe_scrapers
2
+
3
+ A Ruby gem that reads recipes from cooking websites: the title, ingredients, instructions, times,
4
+ yields and nutrition. It uses the [schema.org](https://schema.org/Recipe) markup most recipe sites
5
+ publish, falls back to [OpenGraph](https://ogp.me/), and reads the HTML directly for sites that
6
+ publish neither. It does not get around bot protection.
7
+
8
+ ## Installation
9
+
10
+ ```console
11
+ bundle add recipe_scrapers
12
+ ```
13
+
14
+ The gem runs on every Ruby version that has not reached its end of life.
15
+
16
+ ## Usage
17
+
18
+ ```ruby
19
+ require "recipe_scrapers"
20
+
21
+ recipe = RecipeScrapers.scrape("https://www.recipetineats.com/crispy-potato-straws-pommes-paille/")
22
+ recipe.title # => "Crispy potato straws (Pommes Paille)"
23
+ recipe.ingredients[1] # => "1 1/2 - 2 cups vegetable oil (canola, sunflower or peanut oil)"
24
+ recipe.parsed_ingredients[1].to_h # => { amount: 1.5, unit: "cups", name: "vegetable oil" }
25
+ recipe.cook_time # => 10
26
+ recipe.to_h # every field as plain data, ready for JSON
27
+ ```
28
+
29
+ To read HTML you already have, without a request:
30
+
31
+ ```ruby
32
+ recipe = RecipeScrapers.parse(html, url: url)
33
+ ```
34
+
35
+ ## Documentation
36
+
37
+ - [Usage](wiki/Usage.md): fetching, other HTTP clients, unsupported sites, errors
38
+ - [Recipe Fields](wiki/Recipe-Fields.md): every field with its type and an example
39
+ - [Configuration](wiki/Configuration.md): timeouts, size limits, your own connection
40
+ - [Parsers](wiki/Parsers.md): your own ingredient and nutrient parsers
41
+ - [API reference](https://rubydoc.info/gems/recipe_scrapers)
42
+ - [Changelog](CHANGELOG.md)
43
+ - [Copyright and Usage](wiki/Copyright-and-Usage.md): what you are responsible for
44
+
45
+ ## Contributing
46
+
47
+ A site stopped working, or you need a new one? [Open an issue](https://github.com/davqasd/recipe_scrapers/issues)
48
+ with the URL of a recipe page. To add it yourself, see [CONTRIBUTING](CONTRIBUTING.md). Report a
49
+ security problem privately, see [SECURITY](SECURITY.md).
50
+
51
+ ## License
52
+
53
+ MIT, see [LICENSE](LICENSE). The pages recorded for the tests are not covered by it, see
54
+ [License](wiki/License.md).
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/retry"
5
+
6
+ module RecipeScrapers
7
+ # The settings for fetching pages and parsing fields. Read them through {RecipeScrapers.config}
8
+ # and change them with {RecipeScrapers.configure}. Changing a fetch setting rebuilds the connection
9
+ # on its next use, unless an application assigned its own with {#connection=}.
10
+ #
11
+ # @!attribute [rw] user_agent
12
+ # @return [String] the User-Agent header every request sends
13
+ # @!attribute [rw] timeout
14
+ # @return [Integer] seconds to wait for a whole response, 15 by default
15
+ # @!attribute [rw] open_timeout
16
+ # @return [Integer] seconds to wait for the connection to open, 5 by default
17
+ # @!attribute [rw] max_redirects
18
+ # @return [Integer] redirects to follow before {TooManyRedirects}, 3 by default
19
+ # @!attribute [rw] max_body_bytes
20
+ # @return [Integer] the largest body accepted before {ResponseTooLarge}, 5 MiB by default
21
+ # @!attribute [rw] allow_private_addresses
22
+ # @return [Boolean] fetch hosts that resolve to private or loopback addresses, false by default
23
+ # @!attribute [rw] error_tracker
24
+ # @return [#call] gets the error of a failing parser, {ErrorTracker} by default
25
+ # @!attribute [rw] retry_options
26
+ # @return [Hash] options for faraday-retry, used on timeouts, refused connections and 5xx
27
+ # @!attribute [rw] adapter
28
+ # @return [Symbol, Class] the Faraday adapter, `:recipe_scrapers_net_http` ({Http::Adapter}) by default
29
+ # @!attribute [rw] resolver
30
+ # @return [#call, nil] turns a host into IPAddr addresses, such as a DNS cache. Nil uses the
31
+ # system resolver
32
+ class Configuration
33
+ DEFAULT_ADAPTER = :recipe_scrapers_net_http
34
+ RETRIED = [Faraday::TimeoutError, Faraday::ConnectionFailed, Faraday::ServerError].freeze
35
+
36
+ SETTINGS = %i[
37
+ user_agent timeout open_timeout max_redirects max_body_bytes
38
+ allow_private_addresses error_tracker retry_options adapter resolver
39
+ ].freeze
40
+
41
+ SETTINGS.each do |setting|
42
+ attr_reader setting
43
+
44
+ define_method(:"#{setting}=") do |value|
45
+ instance_variable_set(:"@#{setting}", value)
46
+ @connection = nil unless @connection_assigned
47
+ value
48
+ end
49
+ end
50
+
51
+ DEFAULTS = {
52
+ user_agent: "recipe_scrapers/#{VERSION} (+https://github.com/davqasd/recipe_scrapers)",
53
+ timeout: 15,
54
+ open_timeout: 5,
55
+ max_redirects: 3,
56
+ max_body_bytes: 5 * 1024 * 1024,
57
+ allow_private_addresses: false,
58
+ error_tracker: ErrorTracker,
59
+ retry_options: { max: 2, interval: 1, backoff_factor: 2 },
60
+ adapter: DEFAULT_ADAPTER,
61
+ resolver: nil
62
+ }.freeze
63
+
64
+ private_constant :DEFAULT_ADAPTER, :RETRIED, :SETTINGS, :DEFAULTS
65
+
66
+ # The parsers of every field that takes them, tried in order. A parser answers
67
+ # `call(text, language:)` with a hash of the model's attributes, or nil for text it cannot read.
68
+ #
69
+ # @example Put a parser in front of the bundled one
70
+ # RecipeScrapers.config.parsers[:ingredients].unshift(GramsOnly)
71
+ # @return [Hash{Symbol => Array<#call>}]
72
+ attr_reader :parsers
73
+
74
+ def initialize
75
+ DEFAULTS.each { |setting, value| instance_variable_set(:"@#{setting}", value.dup) }
76
+ @parsers = Parsers::Chain.defaults
77
+ @connection = nil
78
+ @connection_assigned = false
79
+ end
80
+
81
+ # The Faraday connection {RecipeScrapers.scrape} fetches with, built from these settings.
82
+ #
83
+ # @return [Faraday::Connection]
84
+ def connection
85
+ @connection ||= build_connection
86
+ end
87
+
88
+ # Replaces the connection with one the application builds. Nil goes back to the built one.
89
+ #
90
+ # @param value [Faraday::Connection, nil]
91
+ def connection=(value)
92
+ @connection_assigned = !value.nil?
93
+ @connection = value
94
+ end
95
+
96
+ # Builds a new connection with the retry, redirect, address guard, body limit and encoding
97
+ # middleware, in that order.
98
+ #
99
+ # @return [Faraday::Connection]
100
+ def build_connection
101
+ Faraday.new(headers: { "User-Agent" => user_agent }) do |faraday|
102
+ install_middleware(faraday)
103
+ faraday.options.timeout = timeout
104
+ faraday.options.open_timeout = open_timeout
105
+ faraday.adapter(adapter)
106
+ end
107
+ end
108
+
109
+ private
110
+
111
+ def install_middleware(faraday)
112
+ faraday.request(:retry, methods: [:get], exceptions: RETRIED, **retry_options)
113
+ faraday.use(:recipe_scrapers_follow_redirects, limit: max_redirects)
114
+ faraday.use(:recipe_scrapers_address_guard, allow_private: allow_private_addresses, resolver: resolver)
115
+ faraday.use(:recipe_scrapers_body_limit, bytes: max_body_bytes)
116
+ faraday.response(:recipe_scrapers_encoding)
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RecipeScrapers
4
+ # Tells the gem where the recipe fields are on a site whose markup is missing or wrong. A
5
+ # declaration is built by {RecipeScrapers.register}. Inside its block, each recipe field is a
6
+ # method that takes a CSS selector:
7
+ #
8
+ # - `title "h1.recipe-title"` reads the text of the first match.
9
+ # - `ingredients rows: "ul.ingredients li"` reads one line per match, for ingredients,
10
+ # instructions, keywords and equipment.
11
+ # - `ingredient_groups heading: "h3", item: "li"` splits the ingredients under their headings.
12
+ #
13
+ # A declared field wins over the schema.org value. Every field left out falls back to it.
14
+ #
15
+ # @example
16
+ # RecipeScrapers.register "example.com" do
17
+ # title "h1.recipe-title"
18
+ # ingredients rows: ".ingredients li"
19
+ # instructions rows: ".steps li"
20
+ # encoding "windows-1251"
21
+ # end
22
+ class Declaration
23
+ DECLARABLE = (Scraper::CONTRACT - %i[host]).freeze
24
+ private_constant :DECLARABLE
25
+
26
+ # @return [String] the host the declaration was registered for
27
+ attr_reader :host
28
+
29
+ # @return [String, nil] the encoding the site sends without saying so, set with {#encoding}
30
+ attr_reader :encoding_name
31
+
32
+ # @param host [String]
33
+ # @yield the declaration body, evaluated on the new declaration
34
+ # @return [Declaration]
35
+ def self.build(host, &block)
36
+ new(host).tap { |declaration| declaration.instance_eval(&block) if block }
37
+ end
38
+
39
+ def initialize(host)
40
+ @host = host
41
+ @rules = {}
42
+ @parsers = {}
43
+ @encoding_name = nil
44
+ end
45
+
46
+ # @param field [Symbol] a field of {Scraper::CONTRACT}
47
+ # @return [Hash, nil] what the declaration says about the field: `{selector:}`, `{rows:}`, or
48
+ # `{heading:, item:}` for ingredient groups
49
+ #
50
+ # @api private
51
+ def rule(field)
52
+ @rules[field]
53
+ end
54
+
55
+ # Declares the encoding of pages that send no charset, or the wrong one.
56
+ #
57
+ # @param name [String] an encoding Ruby knows, such as "windows-1251"
58
+ def encoding(name)
59
+ @encoding_name = name
60
+ end
61
+
62
+ # Sets the parsers of a field for this site, in the order they are tried.
63
+ #
64
+ # @param field [Symbol] :ingredients or :nutrients
65
+ # @param parsers [Array<#call>]
66
+ # @raise [ArgumentError] for a field that takes no parser
67
+ def parser(field, *parsers)
68
+ @parsers[Parsers::Chain.field!(field)] = parsers
69
+ end
70
+
71
+ # @param field [Symbol]
72
+ # @return [Array<#call>, nil] the parsers this site sets for the field
73
+ #
74
+ # @api private
75
+ def parsers_for(field)
76
+ @parsers[field]
77
+ end
78
+
79
+ DECLARABLE.each do |field|
80
+ define_method(field) do |selector = nil, **options|
81
+ @rules[field] = selector ? { selector: selector } : options
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RecipeScrapers
4
+ # The default {Configuration#error_tracker}. It raises the error again, so a failing parser is
5
+ # never swallowed. An application replaces it with anything that answers call(error), such as a
6
+ # lambda that reports to its error service and returns.
7
+ module ErrorTracker
8
+ # @param exception [StandardError] the error a parser raised
9
+ # @raise [StandardError] always, the error it was given
10
+ def self.call(exception)
11
+ raise exception
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RecipeScrapers
4
+ # The ancestor of every error the gem raises.
5
+ class Error < StandardError; end
6
+ # Raised when the gem does not support the host and supported_only is on.
7
+ class UnsupportedSite < Error; end
8
+ # Raised when a page has no recipe title or no ingredients.
9
+ class RecipeNotFound < Error; end
10
+ # Raised when a response body is larger than {Configuration#max_body_bytes}.
11
+ class ResponseTooLarge < Error; end
12
+ # Raised when a host resolves to no address, or to a private, loopback or other non-public one.
13
+ class BlockedAddress < Error; end
14
+ # Raised when a redirect chain is longer than {Configuration#max_redirects}.
15
+ class TooManyRedirects < Error; end
16
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/net_http"
5
+ require_relative "address_guard"
6
+
7
+ module RecipeScrapers
8
+ # The Faraday adapter and middleware that fetch a page safely.
9
+ module Http
10
+ # Faraday's net/http adapter, connecting to the addresses {AddressGuard} checked instead of
11
+ # resolving the host again. It tries the pinned addresses in order and moves on when one does not
12
+ # answer. The host name still goes into the TLS handshake and the Host header. Registered as
13
+ # `:recipe_scrapers_net_http`, the default {Configuration#adapter}.
14
+ class Adapter < Faraday::Adapter::NetHttp
15
+ PIN_KEY = AddressGuard::PIN_KEY
16
+ ATTEMPT_KEY = :recipe_scrapers_address
17
+ private_constant :PIN_KEY, :ATTEMPT_KEY
18
+
19
+ # @param env [Faraday::Env]
20
+ # @return [Faraday::Response]
21
+ # @raise [Faraday::ConnectionFailed] when no pinned address answers
22
+ #
23
+ # @api private
24
+ def call(env)
25
+ addresses = Array(env[:request]&.context&.dig(PIN_KEY))
26
+ return super if addresses.empty?
27
+
28
+ addresses.each_with_index do |address, index|
29
+ env[ATTEMPT_KEY] = address
30
+ return super(env)
31
+ rescue Faraday::ConnectionFailed
32
+ raise if index == addresses.size - 1
33
+ end
34
+ end
35
+
36
+ # @param env [Faraday::Env]
37
+ # @return [Net::HTTP] the connection, set to the pinned address unless a proxy is in use
38
+ #
39
+ # @api private
40
+ def net_http_connection(env)
41
+ super.tap do |http|
42
+ address = env[ATTEMPT_KEY] || Array(env[:request]&.context&.dig(PIN_KEY)).first
43
+ http.ipaddr = address if address && !http.proxy?
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+
50
+ Faraday::Adapter.register_middleware(recipe_scrapers_net_http: RecipeScrapers::Http::Adapter)
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "ipaddr"
5
+ require "resolv"
6
+
7
+ module RecipeScrapers
8
+ module Http
9
+ # Refuses a request whose host resolves to no address, or to a loopback, private, link-local,
10
+ # multicast, CGNAT or unspecified one, so a URL from a user cannot reach the internal network. It
11
+ # pins the addresses it checked for {Adapter}, which closes the window where DNS could answer
12
+ # differently the second time. Registered as `:recipe_scrapers_address_guard`.
13
+ class AddressGuard < Faraday::Middleware
14
+ CGNAT = IPAddr.new("100.64.0.0/10")
15
+ MULTICAST_V4 = IPAddr.new("224.0.0.0/4")
16
+ MULTICAST_V6 = IPAddr.new("ff00::/8")
17
+ UNSPECIFIED = [IPAddr.new("0.0.0.0"), IPAddr.new("::")].freeze
18
+ private_constant :CGNAT, :MULTICAST_V4, :MULTICAST_V6, :UNSPECIFIED
19
+
20
+ # The key of the request context where the guard pins the addresses it checked.
21
+ #
22
+ # @api private
23
+ PIN_KEY = :recipe_scrapers_resolve
24
+
25
+ # @param app [#call]
26
+ # @param allow_private [Boolean] let every address through, for development
27
+ # @param resolver [#call, nil] takes a host and returns IPAddr objects, the system resolver when nil
28
+ def initialize(app, allow_private: false, resolver: nil)
29
+ super(app)
30
+ @allow_private = allow_private
31
+ @resolver = resolver || method(:resolve)
32
+ end
33
+
34
+ # @param env [Faraday::Env]
35
+ # @raise [BlockedAddress]
36
+ #
37
+ # @api private
38
+ def on_request(env)
39
+ host = env.url.host.to_s
40
+ addresses = @resolver.call(host)
41
+ raise BlockedAddress, "#{host} did not resolve to any address" if addresses.empty?
42
+
43
+ addresses.each { |address| check(host, address) }
44
+ pin(env, addresses)
45
+ end
46
+
47
+ private
48
+
49
+ def check(host, address)
50
+ return if @allow_private
51
+ return unless blocked?(unwrap(address))
52
+
53
+ raise BlockedAddress, "#{host} resolved to #{address}, which is not a public address"
54
+ end
55
+
56
+ def blocked?(address)
57
+ return true if address.loopback? || address.private? || address.link_local?
58
+ return true if multicast?(address) || UNSPECIFIED.include?(address)
59
+
60
+ address.ipv4? && CGNAT.include?(address)
61
+ end
62
+
63
+ def multicast?(address)
64
+ address.ipv4? ? MULTICAST_V4.include?(address) : MULTICAST_V6.include?(address)
65
+ end
66
+
67
+ def unwrap(address)
68
+ address.ipv6? && address.ipv4_mapped? ? address.native : address
69
+ end
70
+
71
+ def pin(env, addresses)
72
+ env.request.context = (env.request.context || {}).merge(PIN_KEY => addresses.map(&:to_s))
73
+ end
74
+
75
+ def resolve(host)
76
+ Resolv.getaddresses(host).filter_map { |address| to_ip(address) }
77
+ end
78
+
79
+ def to_ip(address)
80
+ IPAddr.new(address)
81
+ rescue IPAddr::InvalidAddressError
82
+ nil
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ Faraday::Middleware.register_middleware(
89
+ recipe_scrapers_address_guard: RecipeScrapers::Http::AddressGuard
90
+ )
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+
5
+ module RecipeScrapers
6
+ module Http
7
+ # Refuses a response larger than a limit, checked against Content-Length and against the body
8
+ # itself. Registered as `:recipe_scrapers_body_limit`.
9
+ class BodyLimit < Faraday::Middleware
10
+ # @param app [#call]
11
+ # @param bytes [Integer] the largest body accepted
12
+ def initialize(app, bytes:)
13
+ super(app)
14
+ @bytes = bytes
15
+ end
16
+
17
+ # @param env [Faraday::Env]
18
+ # @raise [ResponseTooLarge]
19
+ #
20
+ # @api private
21
+ def on_complete(env)
22
+ refuse(declared_size(env).to_i) if over_limit?(declared_size(env))
23
+
24
+ actual = env[:body].to_s.bytesize
25
+ refuse(actual) if actual > @bytes
26
+ end
27
+
28
+ private
29
+
30
+ def declared_size(env)
31
+ env[:response_headers].to_h.find { |name, _| name.to_s.casecmp?("content-length") }&.last
32
+ end
33
+
34
+ def over_limit?(declared)
35
+ !declared.nil? && declared.to_i > @bytes
36
+ end
37
+
38
+ def refuse(size)
39
+ raise ResponseTooLarge, "response of #{size} bytes exceeds the limit of #{@bytes}"
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ Faraday::Middleware.register_middleware(recipe_scrapers_body_limit: RecipeScrapers::Http::BodyLimit)
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+
5
+ module RecipeScrapers
6
+ module Http
7
+ # Converts a response body to UTF-8, using the charset of the Content-Type header or of the meta
8
+ # tag in the first 4 KiB. Invalid bytes are replaced. Registered as `:recipe_scrapers_encoding`.
9
+ class Encoding < Faraday::Middleware
10
+ HEADER_CHARSET = /charset=([\w-]+)/i
11
+ META_CONTENT = /<meta[^>]+charset=["']?([\w-]+)/i
12
+ SNIFF_BYTES = 4096
13
+ private_constant :HEADER_CHARSET, :META_CONTENT, :SNIFF_BYTES
14
+
15
+ # @param env [Faraday::Env]
16
+ # @return [void]
17
+ #
18
+ # @api private
19
+ def on_complete(env)
20
+ body = env[:body]
21
+ return if body.nil? || body.empty?
22
+
23
+ env[:body] = to_utf8(body, charset_from(env[:response_headers]) || charset_from_meta(body))
24
+ end
25
+
26
+ private
27
+
28
+ def charset_from(headers)
29
+ header = headers.to_h.find { |name, _| name.to_s.casecmp?("content-type") }&.last
30
+ header&.[](HEADER_CHARSET, 1)
31
+ end
32
+
33
+ def charset_from_meta(body)
34
+ head = body.byteslice(0, SNIFF_BYTES).to_s.force_encoding(::Encoding::BINARY)
35
+ head[META_CONTENT, 1]
36
+ end
37
+
38
+ def to_utf8(body, name)
39
+ source = ::Encoding.find(name || "UTF-8")
40
+ body.dup.force_encoding(source).encode(::Encoding::UTF_8, invalid: :replace, undef: :replace)
41
+ rescue ArgumentError
42
+ body.dup.force_encoding(::Encoding::UTF_8).scrub
43
+ end
44
+ end
45
+ end
46
+ end
47
+
48
+ Faraday::Response.register_middleware(recipe_scrapers_encoding: RecipeScrapers::Http::Encoding)
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "uri"
5
+
6
+ module RecipeScrapers
7
+ module Http
8
+ # Follows 301, 302, 303, 307 and 308 redirects as GET requests. Every hop goes through the
9
+ # middleware after it again, so {AddressGuard} checks each host. The Authorization header is dropped
10
+ # when a redirect leaves the origin. Registered as `:recipe_scrapers_follow_redirects`.
11
+ class FollowRedirects < Faraday::Middleware
12
+ REDIRECTS = [301, 302, 303, 307, 308].freeze
13
+ CLEARED_ON_HOP = %i[status response response_headers].freeze
14
+ UNSAFE_IN_LOCATION = %r{[^\-_.!~*'()a-zA-Z\d;/?:@&=+$,\[\]%]}
15
+ AUTHORIZATION = "Authorization"
16
+ private_constant :REDIRECTS, :CLEARED_ON_HOP, :UNSAFE_IN_LOCATION, :AUTHORIZATION
17
+
18
+ # @param app [#call]
19
+ # @param limit [Integer] redirects to follow
20
+ def initialize(app, limit: 3)
21
+ super(app)
22
+ @limit = limit
23
+ end
24
+
25
+ # @param env [Faraday::Env]
26
+ # @return [Faraday::Response] the response of the last hop
27
+ # @raise [TooManyRedirects]
28
+ #
29
+ # @api private
30
+ def call(env)
31
+ follow(env, @limit)
32
+ end
33
+
34
+ private
35
+
36
+ def follow(env, hops_left)
37
+ response = @app.call(env)
38
+ location = redirect_location(response)
39
+ return response if location.nil?
40
+ raise TooManyRedirects, "gave up after #{@limit} redirects at #{env.url}" if hops_left.zero?
41
+
42
+ follow(next_hop(response.env.dup, location), hops_left - 1)
43
+ end
44
+
45
+ def redirect_location(response)
46
+ return nil unless REDIRECTS.include?(response.status)
47
+
48
+ location = response.headers["Location"].to_s.split("#").first.to_s
49
+ location.empty? ? nil : location.gsub(UNSAFE_IN_LOCATION) { |raw| escape(raw) }
50
+ end
51
+
52
+ def next_hop(env, location)
53
+ from = env.url
54
+ env.url = from + location
55
+ CLEARED_ON_HOP.each { |key| env.delete(key) }
56
+ env.method = :get
57
+ env.body = nil
58
+ env.request_headers.delete(AUTHORIZATION) unless same_origin?(from, env.url)
59
+ env
60
+ end
61
+
62
+ def same_origin?(from, to)
63
+ [from.scheme, from.host, from.port] == [to.scheme, to.host, to.port]
64
+ end
65
+
66
+ def escape(raw)
67
+ raw.unpack("C*").map { |byte| format("%%%02X", byte) }.join
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ Faraday::Middleware.register_middleware(
74
+ recipe_scrapers_follow_redirects: RecipeScrapers::Http::FollowRedirects
75
+ )
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RecipeScrapers
4
+ # The value objects the gem returns.
5
+ module Models
6
+ # One ingredient line split into its parts. {Models::Recipe#parsed_ingredients} holds these.
7
+ #
8
+ # @!attribute [r] amount
9
+ # @return [Float, nil] the quantity, rounded to two places. A range keeps its lower bound
10
+ # @!attribute [r] unit
11
+ # @return [String, nil] the unit as the page writes it, such as "cups" or "g"
12
+ # @!attribute [r] name
13
+ # @return [String, nil] the ingredient, without text in parentheses
14
+ Ingredient = Data.define(:amount, :unit, :name) do
15
+ def initialize(amount: nil, unit: nil, name: nil)
16
+ super
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RecipeScrapers
4
+ module Models
5
+ # The ingredients under one heading of the page. {Models::Recipe#ingredient_groups} holds these.
6
+ #
7
+ # @!attribute [r] purpose
8
+ # @return [String, nil] the heading, such as "For the sauce", or nil for ungrouped ingredients
9
+ # @!attribute [r] ingredients
10
+ # @return [Array<String>] the ingredient lines of the group
11
+ # @!attribute [r] parsed_ingredients
12
+ # @return [Array<Models::Ingredient>, nil] the same lines split into an amount, a unit and a
13
+ # name, in the same order
14
+ IngredientGroup = Data.define(:purpose, :ingredients, :parsed_ingredients) do
15
+ def initialize(purpose:, ingredients:, parsed_ingredients: nil)
16
+ super
17
+ end
18
+ end
19
+ end
20
+ end