packages feed

chez-grater 0.1.1 → 0.1.2

raw patch · 11 files changed

+164/−101 lines, 11 files

Files

app/main.hs view
@@ -2,16 +2,18 @@  import Chez.Grater (scrapeAndParseUrl, scrapeUrl) import Chez.Grater.Manager (createManager)-import Chez.Grater.Parser (mkIngredients)-import Chez.Grater.Readable.Types (mkReadableIngredient, showReadableIngredient)+import Chez.Grater.Parser (mkIngredients, quantityP, runParser)+import Chez.Grater.Parser.Types (ParsedQuantity(..))+import Chez.Grater.Readable.Types (mkReadableIngredient, mkReadableQuantity, showReadableIngredient, showReadableQuantity) import Chez.Grater.Scraper.Site (allScrapers) import Chez.Grater.Scraper.Types (ScrapedIngredient(..), ScrapedRecipeName(..), ScrapedStep(..))-import Chez.Grater.Types (Ingredient(..), RecipeName(..), Step(..))+import Chez.Grater.Types (unIngredientName, Ingredient(..), IngredientName(..), Quantity(..), RecipeName(..), Step(..)) import Data.Aeson ((.=), object) import Data.Aeson.Encode.Pretty (encodePretty) import Network.URI (parseURI) import Options.Applicative ((<**>)) import qualified Data.ByteString.Lazy.Char8 as LC8+import qualified Data.CaseInsensitive as CI import qualified Data.Text as Text import qualified Options.Applicative as Opt @@ -26,6 +28,7 @@ data Opts = Opts   { optsUrl :: String   , optsNoParse :: Bool+  , optsScale :: Maybe Double   , optsOutput :: Output   } @@ -40,6 +43,13 @@         ( Opt.long "no-parse"             <> Opt.help "Don't parse the recipe"         )+      <*> Opt.option readScale+        ( Opt.short 's'+            <> Opt.long "scale"+            <> Opt.value Nothing+            <> Opt.showDefault+            <> Opt.help "Adjust ingredient quantities"+        )       <*> Opt.option readOutput         ( Opt.short 'o'             <> Opt.long "output"@@ -51,7 +61,28 @@       "text" -> Just OutputText       "json" -> Just OutputJson       _ -> Nothing+    readScale = Opt.eitherReader $ \str ->+      case (runParser quantityP . Text.pack) str of+        Right (ParsedQuantity 1) -> Right Nothing+        Right (ParsedQuantity q) -> Right (Just q)+        Right _ -> Left "Expected numeric quantity"+        Left err -> Left err +scaleIngredientBy :: Double -> Ingredient -> Ingredient+scaleIngredientBy scale (Ingredient name quantity unit) =+  case quantity of+    Quantity q ->+      Ingredient name (Quantity (q*scale)) unit+    QuantityMissing ->+      Ingredient+        (IngredientName . (prefix <>) . unIngredientName $ name)+        quantity+        unit+  where+    prefix = case (showReadableQuantity . mkReadableQuantity . Quantity) scale of+      Just str -> CI.mk $ "(" <> str <> "x) "  -- FIXME: should be "×" but unicode 😵‍💫+      Nothing -> ""+ showRecipe :: RecipeName -> [Ingredient] -> [Step] -> IO () showRecipe (RecipeName name) ingredients steps = do   let width = length (Text.unpack name)@@ -88,4 +119,5 @@         (fmap (Step . unScrapedStep) steps)     False -> do       (name, ingredients, steps, _) <- scrapeAndParseUrl allScrapers manager url-      renderRecipe optsOutput name ingredients steps+      let scaledIngredients :: [Ingredient] = (maybe id (fmap . scaleIngredientBy) optsScale) ingredients+      renderRecipe optsOutput name scaledIngredients steps
chez-grater.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name:          chez-grater-version:       0.1.1+version:       0.1.2 author:        Dan Fithian maintainer:    Dan Fithian copyright:     2022 Dan Fithian@@ -109,6 +109,7 @@     , aeson-pretty < 0.9     , base < 5.0     , bytestring < 0.12+    , case-insensitive < 1.3     , network-uri < 2.7     , optparse-applicative < 0.18     , text < 1.3@@ -120,6 +121,7 @@   main-is: main.hs   other-modules:       Chez.Grater.ParserSpec+      Chez.Grater.ReadableSpec       Chez.Grater.TestEnv       Chez.GraterSpec       Paths_chez_grater
src/Chez/Grater/Parser.hs view
@@ -140,9 +140,7 @@      quantityExpression = ParsedQuantity <$> (strictQuantityParser quantitySimple <|> quantityImproper)     quantityWord = ParsedQuantityWord . CI.mk <$> ((\str -> if CI.mk str `elem` Map.keys quantityAliasTable then pure str else fail $ Text.unpack str <> " is not a quantity") =<< spaced (Atto.takeWhile1 isAlpha))-    quantityMissing = quantityParser $ \str -> case Text.null str of-      True -> pure ParsedQuantityMissing-      False -> fail $ Text.unpack str <> " is a quantity, but thought it was missing"+    quantityMissing = pure ParsedQuantityMissing  spaced :: Atto.Parser a -> Atto.Parser a spaced p = optional (void Atto.space) *> (p <* optional (void Atto.space))@@ -189,7 +187,7 @@   let parseOne = \case         ScrapedIngredient raw | Text.null raw -> pure Nothing         ScrapedIngredient raw -> Just <$> runParser ingredientP raw-  ingredients <- left (const "Failed to parse ingredients")+  ingredients <- left (\err -> "Failed to parse " <> tshow xs <> "\n" <> Text.pack err)     . fmap (nubOrd . fmap scrubIngredient . catMaybes)     $ for xs parseOne   requireNonEmpty "ingredients" ingredients
src/Chez/Grater/Readable/Types.hs view
@@ -9,6 +9,7 @@ import Data.Aeson (FromJSON, ToJSON) import Data.Aeson.TH (deriveJSON) import qualified Data.CaseInsensitive as CI+import Data.Ratio (approxRational, denominator, numerator)  data ReadableFraction = ReadableFraction   { readableFractionNumerator   :: Int@@ -37,42 +38,20 @@ deriveJSON (jsonOptions "readableIngredient") ''ReadableIngredient  mkReadableQuantity :: Quantity -> ReadableQuantity-mkReadableQuantity q = case splitQuantity q of-  Nothing -> ReadableQuantity Nothing Nothing-  Just (w, d) ->-    case (w == 0, find (\((lo, hi), _) -> lo <= d && d <= hi) knownQuantities) of-      (False, Just (_, (numerator, denominator))) -> ReadableQuantity (Just w) (Just (ReadableFraction numerator denominator))-      (True, Just (_, (numerator, denominator))) -> ReadableQuantity Nothing (Just (ReadableFraction numerator denominator))-      (False, Nothing) -> ReadableQuantity (Just w) Nothing-      (True, Nothing) -> ReadableQuantity Nothing Nothing--  where--    quantityPrecision :: Double-    quantityPrecision = 0.01--    quarter = 0.25-    third = 1 / 3-    half = 0.5-    twoThird = 2 / 3-    threeQuarter = 0.75+mkReadableQuantity = \case+  QuantityMissing -> ReadableQuantity Nothing Nothing+  Quantity q ->+    ReadableQuantity+      (if whole == 0 then Nothing else Just whole)+      (if numer == 0 then Nothing else Just (ReadableFraction numer denom))+    where+      (whole, numer) = divMod rawNumer denom -    knownQuantities :: [((Double, Double), (Int, Int))]-    knownQuantities =-      [ ((quarter - quantityPrecision, quarter + quantityPrecision), (1, 4))-      , ((third - quantityPrecision, third + quantityPrecision), (1, 3))-      , ((half - quantityPrecision, half + quantityPrecision), (1, 2))-      , ((twoThird - quantityPrecision, twoThird + quantityPrecision), (2, 3))-      , ((threeQuarter - quantityPrecision, threeQuarter + quantityPrecision), (3, 4))-      ]+      rawNumer = fromIntegral $ numerator nearest+      denom = fromIntegral $ denominator nearest -    splitQuantity :: Quantity -> Maybe (Int, Double)-    splitQuantity = \case-      QuantityMissing -> Nothing-      Quantity q2 ->-        case abs (fromIntegral (round q2 :: Int) - q2) < quantityPrecision of-          True -> Just (round q2, 0.0)-          False -> let w = truncate q2 in Just (w, q2 - fromIntegral w)+      -- keep the denominator within reason; less than about 500:+      nearest = approxRational q 0.001  showReadableQuantity :: ReadableQuantity -> Maybe Text showReadableQuantity ReadableQuantity {..} =
src/Chez/Grater/Scraper.hs view
@@ -24,19 +24,18 @@   let domainMay = SiteName . Text.replace "www." "" . Text.pack . uriRegName <$> uriAuthority uri       name = fromMaybe (ScrapedRecipeName "Untitled") $ Scalpel.scrape title tags -      runScraper :: forall a b. ([a] -> Either Text [b]) -> Scalpel.Scraper Text [a] -> Maybe [b]-      runScraper parser scraper = either (const Nothing) Just . parser =<< Scalpel.scrape scraper tags+      runScraper :: forall a b. ([a] -> Either Text [b]) -> Scalpel.Scraper Text [a] -> Either Text [b]+      runScraper parser scraper = parser =<< maybe (Left "Nothing scraped") pure (Scalpel.scrape scraper tags)        goIngredient IngredientScraper {..} = case Scalpel.scrape ingredientScraperTest tags of-        Just True -> (,ingredientScraperMeta) <$> runScraper runIngredientParser ingredientScraperRun+        Just True -> (,ingredientScraperMeta) <$> either (const Nothing) Just (runScraper runIngredientParser ingredientScraperRun)         _ -> Nothing       goStep StepScraper {..} = case Scalpel.scrape stepScraperTest tags of-        Just True -> (,stepScraperMeta) <$> runScraper runStepParser stepScraperRun+        Just True -> (,stepScraperMeta) <$> either (const Nothing) Just (runScraper runStepParser stepScraperRun)         _ -> Nothing    (ingredients, ingredientMeta) <- case flip HashMap.lookup (scrapersIngredientBySite scrapers) =<< domainMay of-    Just IngredientScraper {..} -> maybe (throwIO $ ScrapeError "Failed to scrape known URL") (pure . (,ingredientScraperMeta)) $-      runScraper runIngredientParser ingredientScraperRun+    Just IngredientScraper {..} -> either (throwIO . ScrapeError) (pure . (,ingredientScraperMeta)) $ runScraper runIngredientParser ingredientScraperRun     Nothing -> maybe (throwIO $ ScrapeError "Failed to scrape URL from defaults") pure      . lastMay      . sortOn (length . fst)@@ -44,7 +43,7 @@      . scrapersIngredients      $ scrapers   stepsMay <- case flip HashMap.lookup (scrapersStepBySite scrapers) =<< domainMay of-    Just StepScraper {..} -> pure . fmap (,stepScraperMeta) . runScraper runStepParser $ stepScraperRun+    Just StepScraper {..} -> pure . fmap (,stepScraperMeta) . either (const Nothing) Just . runScraper runStepParser $ stepScraperRun     Nothing -> pure       . lastMay       . sortOn (length . fst)
src/Chez/Grater/Scraper/Site.hs view
@@ -1,13 +1,11 @@ -- |Ingredient and step scrapers for all the websites we know about.-module Chez.Grater.Scraper.Site-  ( allScrapers, debugI, debugS-  ) where+module Chez.Grater.Scraper.Site (allScrapers) where  import Chez.Grater.Internal.Prelude  import Chez.Grater.Scraper.Types   ( IngredientScraper(..), ScrapeMeta(..), ScrapeName(..), ScrapeVersion(..), ScrapedIngredient(..)-  , ScrapedStep(..), Scrapers(..), SiteName(..), StepScraper(..), inception+  , ScrapedStep(..), Scrapers(..), SiteName(..), StepScraper(..), hasClassPrefix, inception   ) import Data.Function (on) import Text.HTML.Scalpel ((//), (@:), (@=), Scraper, Selector)@@ -21,16 +19,17 @@ ingredientScrapers :: HashMap SiteName IngredientScraper ingredientScrapers = HashMap.fromList   [ ("allrecipes.com", allrecipesI)+  , ("bhg.com", allrecipesI)    , ("cooking.nytimes.com", nytimesI) -  , ("food.com", geniusKitchen2I)+  , ("food.com", foodI)+   , ("geniuskitchen.com", geniusKitchen1I)   , ("tasteofhome.com", geniusKitchen1I)    , ("rachlmansfield.com", tastyI2)   , ("cookieandkate.com", tastyI1)-  , ("simpleveganblog.com", tastyI1)   , ("eatyourselfskinny.com", tastyI1)   , ("lexiscleankitchen.com", tastyI2)   , ("sallysbakingaddiction.com", tastyI2)@@ -72,6 +71,8 @@   , ("sweetandsavorymeals.com", wprmI)   , ("melskitchencafe.com", wprmI)   , ("glutenfreecuppatea.co.uk", wprmI)+  , ("damndelicious.net", wprmI)+  , ("simpleveganblog.com", wprmI)    , ("101cookbooks.com", cb101I) @@ -82,7 +83,6 @@   , ("smittenkitchen.com", jetpackI)    , ("eatingwell.com", eatingWellI)-  , ("bhg.com", eatingWellI)    , ("yummly.com", yummlyI) @@ -95,7 +95,6 @@   , ("lazycatkitchen.com", ingredientLi2)   , ("deliciouslyella.com", ingredientLi3)   , ("cookingandcooking.com", ingredientLi5)-  , ("damndelicious.net", ingredientLi6)   , ("hemsleyandhemsley.com", ingredientLi6)   , ("slenderkitchen.com", ingredientLi7)   , ("everydayannie.com", ingredientLi8)@@ -105,6 +104,7 @@   , ("uitpaulineskeuken.nl", ingredientLi13)   , ("leukerecepten.nl", ingredientLi14)   , ("wsj.com", ingredientLi15)+  , ("cucchiao.it", ingredientLi16)    , ("delish.com", delishI)   , ("thepioneerwoman.com", delishI)@@ -125,16 +125,17 @@ stepScrapers :: HashMap SiteName StepScraper stepScrapers = HashMap.fromList   [ ("allrecipes.com", allrecipesS)+  , ("bhg.com", allrecipesS)    , ("cooking.nytimes.com", nytimesS) -  , ("food.com", geniusKitchen2S)+  , ("food.com", foodS)+   , ("geniuskitchen.com", geniusKitchen1S)   , ("tasteofhome.com", geniusKitchen1S)    , ("rachlmansfield.com", tastyS1)   , ("cookieandkate.com", tastyS1)-  , ("simpleveganblog.com", tastyS1)   , ("eatyourselfskinny.com", tastyS1)   , ("lexiscleankitchen.com", tastyS1)   , ("sallysbakingaddiction.com", tastyS2)@@ -177,6 +178,8 @@   , ("sweetandsavorymeals.com", wprmS)   , ("melskitchencafe.com", wprmS)   , ("glutenfreecuppatea.co.uk", wprmS)+  , ("damndelicious.net", wprmS)+  , ("simpleveganblog.com", wprmS)    , ("101cookbooks.com", cb101S) @@ -187,7 +190,6 @@   , ("smittenkitchen.com", jetpackS)    , ("eatingwell.com", eatingWellS)-  , ("bhg.com", eatingWellS)    , ("yummly.com", yummlyS) @@ -197,7 +199,6 @@   , ("bettycrocker.com", stepLi1)   , ("pillsbury.com", stepLi1)   , ("tasty.co", stepLi2)-  , ("damndelicious.net", stepLi3)   , ("lazycatkitchen.com", stepLi4)   , ("deliciouslyella.com", stepLi5)   , ("slenderkitchen.com", stepLi6)@@ -209,6 +210,8 @@   , ("leukerecepten.nl", stepLi12)   , ("wsj.com", stepLi13)   , ("eatfigsnotpigs.com", stepLi14)+  , ("cucchiao.it", stepLi15)+  , ("picantecooking.com", stepLi16)    , ("delish.com", delishS)   , ("thepioneerwoman.com", delishS)@@ -227,14 +230,12 @@  -- |Get all step scrapers, ordered by most popular first. allStepScrapers :: [StepScraper]-allStepScrapers = fmap head . reverse . sortOn length  . groupBy ((==) `on` (scrapeMetaName . stepScraperMeta)) . sortOn (scrapeMetaName . stepScraperMeta) . HashMap.elems $ stepScrapers+allStepScrapers = fmap head . reverse . sortOn length  . groupBy ((==) `on` (scrapeMetaName . stepScraperMeta)) . sortOn (scrapeMetaName . stepScraperMeta) $+  HashMap.elems stepScrapers <> [stepLi3]  testScrape :: Selector -> Scraper Text Bool testScrape test = not . Text.null <$> Scalpel.html test -acceptAll :: Scraper Text Bool-acceptAll = pure True- denyAll :: Scraper Text Bool denyAll = pure True @@ -260,52 +261,46 @@         <$> Scalpel.text Scalpel.anySelector       ) -debugI :: IngredientScraper-debugI = simpleIngredientScraper "debug" (testScrape "div") "div"--debugS :: StepScraper-debugS = simpleStepScraper "debug" (testScrape "div") "div"- allrecipesI :: IngredientScraper-allrecipesI = setIngredientVersion 2 $ simpleIngredientScraper "allrecipes"-  (testScrape ("meta" @: ["content" @= "Allrecipes"]))-  ("span" @: [Scalpel.hasClass "ingredients-item-name"])+allrecipesI = simpleIngredientScraper "allrecipes"+  (testScrape ("ul" @: [Scalpel.hasClass "mntl-structured-ingredients__list"]))+  ("ul" @: [Scalpel.hasClass "mntl-structured-ingredients__list"] // "li")  allrecipesS :: StepScraper allrecipesS = simpleStepScraper "allrecipes"-  (testScrape ("meta" @: ["content" @= "Allrecipes"]))-  ("ul" @: [Scalpel.hasClass "instructions-section"] // "div" @: [Scalpel.hasClass "section-body"])+  (testScrape ("ol" @: [Scalpel.hasClass "mntl-sc-block"]))+  ("ol" @: [Scalpel.hasClass "mntl-sc-block"] // "li")  nytimesI :: IngredientScraper nytimesI = simpleIngredientScraper "nytimes"-  (testScrape ("meta" @: ["content" @= "NYT Cooking"]))-  ("ul" @: [Scalpel.hasClass "recipe-ingredients"] // "li")+  denyAll+  ("li" @: [hasClassPrefix "ingredient_ingredient__"])  nytimesS :: StepScraper nytimesS = simpleStepScraper "nytimes"-  (testScrape ("meta" @: ["content" @= "NYT Cooking"]))-  ("ol" @: [Scalpel.hasClass "recipe-steps"] // "li")+  denyAll+  ("li" @: [hasClassPrefix "preparation_step__"]) +foodI :: IngredientScraper+foodI = simpleIngredientScraper "food"+  (testScrape ("ul" @: [Scalpel.hasClass "ingredient-list"]))+  ("ul" @: [Scalpel.hasClass "ingredient-list"] // "li")++foodS :: StepScraper+foodS = simpleStepScraper "food"+  (testScrape ("ul" @: [Scalpel.hasClass "direction-list"]))+  ("ul" @: [Scalpel.hasClass "direction-list"] // "li")+ geniusKitchen1I :: IngredientScraper geniusKitchen1I = setIngredientVersion 2 $ simpleIngredientScraper "geniusKitchen1"   (testScrape ("div" @: [Scalpel.hasClass "recipe-ingredients"]))   ("div" @: [Scalpel.hasClass "recipe-ingredients"] // "li") -geniusKitchen2I :: IngredientScraper-geniusKitchen2I = setIngredientVersion 2 $ simpleIngredientScraper "geniusKitchen2"-  (testScrape ("ul" @: [Scalpel.hasClass "ingredients"]))-  ("ul" @: [Scalpel.hasClass "ingredients"] // "li")- geniusKitchen1S :: StepScraper geniusKitchen1S = setStepVersion 2 $ simpleStepScraper "geniusKitchen1"   (testScrape ("div" @: [Scalpel.hasClass "recipe-directions"]))   ("div" @: [Scalpel.hasClass "recipe-directions"] // "li") -geniusKitchen2S :: StepScraper-geniusKitchen2S = setStepVersion 2 $ simpleStepScraper "geniusKitchen2"-  (testScrape ("ul" @: [Scalpel.hasClass "directions"]))-  ("ul" @: [Scalpel.hasClass "directions"] // "li")- tastyI1 :: IngredientScraper tastyI1 = simpleIngredientScraper "tasty1"   (testScrape ("div" @: [Scalpel.hasClass "tasty-recipe-ingredients"]))@@ -504,6 +499,11 @@   (testScrape ("ul" @: [Scalpel.hasClass "ingredients-list"]))   ("ul" @: [Scalpel.hasClass "ingredients-list"] // "li") +ingredientLi16 :: IngredientScraper+ingredientLi16 = simpleIngredientScraper "ingredientLi16"+  (testScrape ("div" @: [Scalpel.hasClass "c-recipe__list2"]))+  ("div" @: [Scalpel.hasClass "c-recipe__list2"] // "li")+ stepLi1 :: StepScraper stepLi1 = simpleStepScraper "stepLi1"   (testScrape ("ul" @: [Scalpel.hasClass "recipeSteps"]))@@ -574,15 +574,25 @@   (testScrape ("li" @: [Scalpel.hasClass "instruction"]))   ("li" @: [Scalpel.hasClass "instruction"]) +stepLi15 :: StepScraper+stepLi15 = simpleStepScraper "stepLi15"+  (testScrape ("div" @: [Scalpel.hasClass "recipe_procedures"]))+  ("div" @: [Scalpel.hasClass "recipe_procedures"] // "p")++stepLi16 :: StepScraper+stepLi16 = simpleStepScraper "stepLi16"+  (testScrape ("section" @: [Scalpel.hasClass "instructions"]))+  ("section" @: [Scalpel.hasClass "instructions"] // "p")+ delishI :: IngredientScraper delishI = simpleIngredientScraper "delish"-  acceptAll-  ("div" @: [Scalpel.hasClass "ingredient-item"])+  denyAll+  ("ul" @: [Scalpel.hasClass "ingredient-lists"] // "li")  delishS :: StepScraper delishS = simpleStepScraper "delish"-  acceptAll-  ("div" @: [Scalpel.hasClass "direction-lists"] // "li")+  denyAll+  ("ul" @: [Scalpel.hasClass "directions"] // "li")  spoonacularI :: IngredientScraper spoonacularI = simpleIngredientScraper "spoontacular"
src/Chez/Grater/Scraper/Types.hs view
@@ -80,3 +80,9 @@  inception :: ScrapeVersion inception = ScrapeVersion 1++hasClassPrefix :: Text -> Scalpel.AttributePredicate+hasClassPrefix cls =+  Scalpel.match $ \case+    "class" -> \classes -> any (\cls' -> cls `Text.isPrefixOf` cls') . Text.split ((==) ' ') . Text.pack $ classes+    _ -> const False
src/Chez/Grater/Test/ParsedIngredients.hs view
@@ -61,11 +61,11 @@  allRecipesSteps :: [Step] allRecipesSteps =-  [ Step "Preheat oven to 425 degrees F (220 degrees C.)"-  , Step "In a saucepan, combine chicken, carrots, peas, and celery. Add water to cover and boil for 15 minutes. Remove from heat, drain and set aside."-  , Step "In the saucepan over medium heat, cook onions in butter until soft and translucent. Stir in flour, salt, pepper, and celery seed. Slowly stir in chicken broth and milk. Simmer over medium-low heat until thick. Remove from heat and set aside."-  , Step "Place the chicken mixture in bottom pie crust. Pour hot liquid mixture over. Cover with top crust, seal edges, and cut away excess dough. Make several small slits in the top to allow steam to escape."-  , Step "Bake in the preheated oven for 30 to 35 minutes, or until pastry is golden brown and filling is bubbly. Cool for 10 minutes before serving."+  [ Step "Preheat the oven to 425 degrees F (220 degrees C.)"+  , Step "Combine chicken, carrots, peas, and celery in a saucepan; add water to cover and bring to a boil. Boil for 15 minutes, then remove from the heat and drain."+  , Step "While the chicken is cooking, melt butter in another saucepan over medium heat. Add onion and cook until soft and translucent, 5 to 7 minutes. Stir in flour, salt, pepper, and celery seed. Slowly stir in chicken broth and milk. Reduce heat to medium-low and simmer until thick, 5 to 10 minutes. Remove from heat and set aside."+  , Step "Place chicken and vegetables in the bottom pie crust. Pour hot liquid mixture over top. Cover with top crust, seal the edges, and cut away any excess dough. Make several small slits in the top crust to allow steam to escape."+  , Step "Bake in the preheated oven until pastry is golden brown and filling is bubbly, 30 to 35 minutes. Cool for 10 minutes before serving. Dotdash Meredith Food Studios"   ]  foodIngredients :: [Ingredient]
+ test/Chez/Grater/ReadableSpec.hs view
@@ -0,0 +1,35 @@+module Chez.Grater.ReadableSpec where++import Chez.Grater.Internal.Prelude++import Chez.Grater.Types (Quantity(..))+import Test.Hspec (Spec, describe, it, shouldBe)++-- the module being tested+import Chez.Grater.Readable.Types++spec :: Spec+spec = describe "Readable" $ do+  describe "Quantity" $ do+    it "shows 1" $+      (showReadableQuantity . mkReadableQuantity) (Quantity 1)+        `shouldBe` Just "1"+    it "shows 1/2" $+      (showReadableQuantity . mkReadableQuantity) (Quantity 0.5)+        `shouldBe` Just "1/2"+    it "shows 1 1/4" $+      (showReadableQuantity . mkReadableQuantity) (Quantity 1.25)+        `shouldBe` Just "1 1/4"+    it "shows 3/8" $+      (showReadableQuantity . mkReadableQuantity) (Quantity 0.375)+        `shouldBe` Just "3/8"+    it "shows 7/9" $+      (showReadableQuantity . mkReadableQuantity) (Quantity 7/9)+        `shouldBe` Just "7/9"+    it "shows (an approximation of) 1 13/100" $+      (showReadableQuantity . mkReadableQuantity) (Quantity 113/100)+        `shouldBe` Just "1 3/23"++    it "can't show missing" $+      (showReadableQuantity . mkReadableQuantity) QuantityMissing+        `shouldBe` Nothing
test/Chez/GraterSpec.hs view
@@ -89,7 +89,7 @@       scrapeAndParse         env         "https://www.allrecipes.com/recipe/26317/chicken-pot-pie-ix/"-        "Chicken Pot Pie IX Recipe | Allrecipes"+        "Chicken Pot Pie Recipe"         (allRecipesIngredients, allRecipesSteps)      it "can parse food" $
test/main.hs view
@@ -3,6 +3,7 @@ import Chez.Grater.TestEnv (loadEnv) import Test.Hspec (hspec) import qualified Chez.Grater.ParserSpec+import qualified Chez.Grater.ReadableSpec import qualified Chez.GraterSpec  main :: IO ()@@ -10,4 +11,5 @@   env <- loadEnv   hspec $ do     Chez.Grater.ParserSpec.spec+    Chez.Grater.ReadableSpec.spec     Chez.GraterSpec.spec env