packages feed

chez-grater (empty) → 0.0.1

raw patch · 23 files changed

+2126/−0 lines, 23 filesdep +QuickCheckdep +aesondep +attoparsec

Dependencies added: QuickCheck, aeson, attoparsec, base, bytestring, case-insensitive, chez-grater, containers, file-embed, file-path-th, hashable, hspec, http-client, http-client-tls, http-types, network-uri, scalpel, text, unordered-containers

Files

+ chez-grater.cabal view
@@ -0,0 +1,114 @@+cabal-version: 3.0+name:          chez-grater+version:       0.0.1+maintainer:    Dan Fithian+copyright:     2022 Dan Fithian+build-type:    Simple+description:   Parse and scrape recipe blogs+license:       MIT+extra-source-files:+    test/fixtures/banana-bread-rachelmansfield.txt+    test/fixtures/chicken-pot-pie-allrecipes.txt+    test/fixtures/chicken-pot-pie-pillsbury.txt+    test/fixtures/chicken-pot-pie-tasteofhome.txt+    test/fixtures/roast-chicken-food-network.txt++common options+  default-extensions:+      ConstraintKinds+      DataKinds+      DefaultSignatures+      DeriveDataTypeable+      DeriveGeneric+      DerivingStrategies+      DerivingVia+      EmptyDataDecls+      FlexibleContexts+      FlexibleInstances+      GADTs+      GeneralizedNewtypeDeriving+      LambdaCase+      MultiParamTypeClasses+      MultiWayIf+      NamedFieldPuns+      NoImplicitPrelude+      NoMonomorphismRestriction+      OverloadedStrings+      QuasiQuotes+      RankNTypes+      RecordWildCards+      ScopedTypeVariables+      StandaloneDeriving+      TemplateHaskell+      TupleSections+      TypeApplications+      TypeFamilies+      TypeOperators+      ViewPatterns+  default-language: Haskell2010++common ghc-options+  ghc-options: -Wall -Wunused-packages -fwarn-tabs -fwarn-redundant-constraints -Wincomplete-uni-patterns -eventlog -O0++library+  import: options, ghc-options+  exposed-modules:+      Chez.Grater+      Chez.Grater.Internal.CI.Orphans+      Chez.Grater.Internal.Json+      Chez.Grater.Internal.Prelude+      Chez.Grater.Manager+      Chez.Grater.Parser+      Chez.Grater.Parser.Types+      Chez.Grater.Scraper+      Chez.Grater.Scraper.Site+      Chez.Grater.Scraper.Types+      Chez.Grater.Types+  other-modules:+      Paths_chez_grater+  autogen-modules:+      Paths_chez_grater+  hs-source-dirs:+      src+  build-depends:+      aeson < 1.6+    , attoparsec < 0.14+    , base < 5.0+    , case-insensitive < 1.3+    , containers < 0.7+    , hashable < 1.4+    , http-client < 0.7+    , http-client-tls < 0.4+    , http-types < 0.13+    , network-uri < 2.7+    , scalpel < 0.7+    , text < 1.3+    , unordered-containers < 0.3++test-suite tests+  import: options, ghc-options+  type: exitcode-stdio-1.0+  main-is: main.hs+  other-modules:+      Chez.Grater.Gen+      Chez.Grater.ParsedIngredients+      Chez.Grater.ParserSpec+      Chez.Grater.TestEnv+      Chez.GraterSpec+      Paths_chez_grater+  hs-source-dirs:+      test+  build-depends:+      attoparsec < 0.14+    , base < 5.0+    , bytestring < 0.11+    , case-insensitive < 1.3+    , containers < 0.7+    , file-embed < 0.1+    , file-path-th < 0.2+    , hspec < 2.8+    , http-client < 0.7+    , network-uri < 2.7+    , QuickCheck < 2.15+    , text < 1.3+    , chez-grater
+ src/Chez/Grater.hs view
@@ -0,0 +1,28 @@+module Chez.Grater where++import Chez.Grater.Internal.Prelude++import Chez.Grater.Parser (parseScrapedIngredients, parseScrapedSteps)+import Chez.Grater.Scraper (scrape)+import Chez.Grater.Scraper.Types+  ( ScrapedRecipeName(..), ScrapeMetaWrapper, ScrapedIngredient, ScrapedStep+  )+import Chez.Grater.Types (RecipeName(..), Ingredient, Step)+import Control.Monad ((>=>))+import Network.HTTP.Client (Manager)+import Network.URI (URI)++-- |Scrape a URL without parsing it.+scrapeUrl :: Manager -> URI -> IO (ScrapedRecipeName, [ScrapedIngredient], [ScrapedStep], ScrapeMetaWrapper)+scrapeUrl = scrape id Right Right++-- |Scrape a URL and also parse it.+scrapeAndParseUrl :: Manager -> URI -> IO (RecipeName, [Ingredient], [Step], ScrapeMetaWrapper)+scrapeAndParseUrl = scrape+  (RecipeName . unScrapedRecipeName)+  (nonempty "ingredients" parseScrapedIngredients)+  (nonempty "steps" parseScrapedSteps)+  where+    nonempty typ ma = ma >=> \case+      [] -> Left $ "No " <> typ <> " found"+      xs -> Right xs
+ src/Chez/Grater/Internal/CI/Orphans.hs view
@@ -0,0 +1,25 @@+{-# OPTIONS_GHC -fno-warn-orphans #-}+module Chez.Grater.Internal.CI.Orphans where++import Chez.Grater.Internal.Prelude++import Data.Aeson+  ( FromJSON, FromJSONKey, ToJSON, ToJSONKey, fromJSONKey, fromJSONKeyList, parseJSON, toJSON+  , toJSONKey, toJSONKeyList+  )+import Data.Functor.Contravariant (contramap)+import qualified Data.CaseInsensitive as CI++instance (CI.FoldCase a, FromJSON a) => FromJSON (CI a) where+  parseJSON = fmap CI.mk . parseJSON++instance (CI.FoldCase a, FromJSONKey a) => FromJSONKey (CI a) where+  fromJSONKey = CI.mk <$> fromJSONKey+  fromJSONKeyList = fmap CI.mk <$> fromJSONKeyList++instance (ToJSON a) => ToJSON (CI a) where+  toJSON = toJSON . CI.original++instance (ToJSONKey a) => ToJSONKey (CI a) where+  toJSONKey = contramap CI.original toJSONKey+  toJSONKeyList = contramap (fmap CI.original) toJSONKeyList
+ src/Chez/Grater/Internal/Json.hs view
@@ -0,0 +1,22 @@+module Chez.Grater.Internal.Json where++import Chez.Grater.Internal.Prelude++import Data.Aeson.TH+  ( Options, allNullaryToStringTag, constructorTagModifier, defaultOptions, fieldLabelModifier+  , omitNothingFields+  )+import Data.Char (toLower)+import Data.List (stripPrefix)++lowerFirst :: String -> String+lowerFirst ((toLower -> c) : cs) = c : cs+lowerFirst cs = cs++jsonOptions :: String -> Options+jsonOptions prefix = defaultOptions+  { fieldLabelModifier     = \field -> lowerFirst . fromMaybe field . stripPrefix prefix $ field+  , constructorTagModifier = lowerFirst+  , allNullaryToStringTag  = True+  , omitNothingFields      = True+  }
+ src/Chez/Grater/Internal/Prelude.hs view
@@ -0,0 +1,49 @@+module Chez.Grater.Internal.Prelude+  ( module Prelude+  , module Control.Applicative+  , module Control.Arrow+  , module Control.Exception+  , module Control.Monad+  , module Data.CaseInsensitive+  , module Data.HashMap.Strict+  , module Data.List+  , module Data.Map.Strict+  , module Data.Maybe+  , module Data.Set+  , module Data.Text+  , module Data.Traversable+  , nubOrd, uncurry3, uncurry4, tshow, headMay, lastMay+  ) where++import Control.Applicative ((<|>), optional)+import Control.Arrow (first, left, right, second)+import Control.Exception (Exception, throwIO)+import Control.Monad (replicateM, void)+import Data.CaseInsensitive (CI)+import Data.Containers.ListUtils (nubOrd)+import Data.HashMap.Strict (HashMap)+import Data.List (find, groupBy, sortBy, sortOn)+import Data.Map.Strict (Map)+import Data.Maybe (catMaybes, fromMaybe, mapMaybe)+import Data.Set (Set)+import Data.Text (Text)+import Data.Traversable (for)+import Prelude+import qualified Data.Text as Text++uncurry3 :: (a -> b -> c -> d) -> (a, b, c) -> d+uncurry3 f (x, y, z) = f x y z++uncurry4 :: (a -> b -> c -> d -> e) -> (a, b, c, d) -> e+uncurry4 f (x, y, z, w) = f x y z w++tshow :: Show a => a -> Text+tshow = Text.pack . show++headMay :: [a] -> Maybe a+headMay = \case+  x:_ -> Just x+  [] -> Nothing++lastMay :: [a] -> Maybe a+lastMay = headMay . reverse
+ src/Chez/Grater/Manager.hs view
@@ -0,0 +1,16 @@+module Chez.Grater.Manager where++import Chez.Grater.Internal.Prelude++import Network.HTTP.Client (Manager, managerModifyRequest, requestHeaders)+import Network.HTTP.Client.TLS (newTlsManagerWith, tlsManagerSettings)+import Network.HTTP.Types (hUserAgent)++createManager :: IO Manager+createManager =+  newTlsManagerWith tlsManagerSettings+    { managerModifyRequest = \req -> do+        pure req+          { requestHeaders = [(hUserAgent, "Simulated")] <> requestHeaders req+          }+    }
+ src/Chez/Grater/Parser.hs view
@@ -0,0 +1,216 @@+module Chez.Grater.Parser where++import Chez.Grater.Internal.Prelude++import Chez.Grater.Parser.Types+  ( ParsedIngredient(..), ParsedIngredientName(..), ParsedQuantity(..), ParsedUnit(..)+  )+import Chez.Grater.Scraper.Types (ScrapedIngredient(..), ScrapedStep(..))+import Chez.Grater.Types+  ( Ingredient(..), IngredientName(..), Quantity(..), Step(..), Unit(..), box, cup, emptyQuantity+  , gram, liter, milligram, milliliter, mkQuantity, ounce, pinch, pound, splash, sprinkle+  , tablespoon, teaspoon, whole+  )+import Data.Char (isAlpha, isDigit, isSpace)+import Data.Function (fix)+import Text.Read (readMaybe)+import qualified Data.Attoparsec.Text as Atto+import qualified Data.CaseInsensitive as CI+import qualified Data.Map.Strict as Map+import qualified Data.Text as Text++unitAliasTable :: Map (CI Text) Unit+unitAliasTable = Map.fromList+  [ ("ounce", ounce)+  , ("ounces", ounce)+  , ("oz", ounce)+  , ("c", cup)+  , ("cup", cup)+  , ("cups", cup)+  , ("tablespoon", tablespoon)+  , ("tablespoons", tablespoon)+  , ("tbsp", tablespoon)+  , ("teaspoon", teaspoon)+  , ("teaspoons", teaspoon)+  , ("tsp", teaspoon)+  , ("pinch", pinch)+  , ("pinches", pinch)+  , ("box", box)+  , ("boxes", box)+  , ("pound", pound)+  , ("pounds", pound)+  , ("splash", splash)+  , ("splashes", splash)+  , ("sprinkle", sprinkle)+  , ("sprinkles", sprinkle)+  , ("whole", whole)++  , ("milliliter", milliliter)+  , ("millilitre", milliliter)+  , ("ml", milliliter)+  , ("liter", liter)+  , ("litre", liter)+  , ("l", liter)+  , ("milligram", milligram)+  , ("mg", milligram)+  , ("gram", gram)+  , ("g", gram)+  ]++quantityAliasTable :: Map (CI Text) Quantity+quantityAliasTable = fmap mkQuantity . Map.fromList $+  [ ("half dozen", 6)+  , ("dozen", 12)+  , ("quarter", 0.25)+  , ("third", 1 / 3)+  , ("half", 0.5)+  , ("one", 1)+  , ("two", 2)+  , ("three", 3)+  , ("four", 4)+  , ("five", 5)+  , ("six", 6)+  , ("seven", 7)+  , ("eight", 8)+  , ("nine", 9)+  , ("ten", 10)+  , ("eleven", 11)+  , ("twelve", 12)+  ]++scrubIngredientName :: ParsedIngredientName -> IngredientName+scrubIngredientName = IngredientName . unParsedIngredientName++scrubUnit :: ParsedUnit -> Maybe Unit+scrubUnit = \case+  ParsedUnit x -> Just $ Map.findWithDefault (Unit x) x unitAliasTable+  ParsedUnitMissing -> Nothing++scrubQuantity :: ParsedQuantity -> Quantity+scrubQuantity = \case+  ParsedQuantity q -> mkQuantity q+  ParsedQuantityWord w -> Map.findWithDefault emptyQuantity w quantityAliasTable+  ParsedQuantityMissing -> emptyQuantity++scrubIngredient :: ParsedIngredient -> Ingredient+scrubIngredient ParsedIngredient {..} = Ingredient+  { ingredientName = scrubIngredientName parsedIngredientName+  , ingredientQuantity = scrubQuantity parsedIngredientQuantity+  , ingredientUnit = scrubUnit parsedIngredientUnit+  }++quantityP :: Atto.Parser ParsedQuantity+quantityP = quantityExpression <|> quantityWord <|> quantityMissing+  where+    isIgnoredC c = elem c ['Â']+    isQuantityC c = isDigit c || isSpace c || elem c ['/', '.', '-', '⁄', '¼', '½', '¾', '⅓', '⅔'] || isIgnoredC c+    quantityParser p = p . Text.filter (not . isIgnoredC) =<< Atto.takeWhile isQuantityC+    strictQuantityParser p = p . Text.strip . Text.filter (not . isIgnoredC) =<< Atto.takeWhile1 isQuantityC++    quantitySingle str = maybe (fail $ Text.unpack str <> " is not a single quantity") pure . readMaybe . Text.unpack . Text.filter (not . isSpace) $ str+    quantityUnicode = \case+      "¼" -> pure 0.25+      "½" -> pure 0.5+      "¾" -> pure 0.75+      "⅓" -> pure $ 1 / 3+      "⅔" -> pure $ 2 / 3+      str -> fail $ Text.unpack str <> " is not a unicode quantity"+    quantityDecimal str = case Text.split ((==) '.') str of+      [x, y] -> maybe (fail $ Text.unpack str <> " is not a decimal quantity") pure $ do+        x' <- fromInteger <$> readMaybe (Text.unpack x)+        y' <- fromInteger <$> readMaybe (Text.unpack y)+        pure $ x' + (y' / (fromIntegral $ 10 * Text.length y))+      _ -> fail $ Text.unpack str <> " is not a decimal quantity"+    quantityFraction str = case Text.split ((==) '/') $ Text.replace "⁄" "/" str of+      [x, y] -> maybe (fail $ Text.unpack str <> " is not a fractional quantity") pure $+        (/) <$> readMaybe (Text.unpack x) <*> readMaybe (Text.unpack y)+      _ -> fail $ Text.unpack str <> " is not a fractional quantity"++    quantityImproper = quantityParser $ \str -> case filter (not . Text.null) . mconcat . fmap (Text.split isSpace) . Text.split ((==) '-') $ str of+      [x, y] -> do+        x' <- quantitySimple x+        y' <- quantitySimple y+        pure $ if x' < y' then (x' + y') / 2 else x' + y'+      _ -> fail $ Text.unpack str <> " is not an improper quantity"++    quantitySimple str =+      quantitySingle str+        <|> quantityUnicode str+        <|> quantityDecimal str+        <|> quantityFraction str++    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"++spaced :: Atto.Parser a -> Atto.Parser a+spaced p = optional (void Atto.space) *> (p <* optional (void Atto.space))++unitP :: Atto.Parser ParsedUnit+unitP = unitWord <|> pure ParsedUnitMissing+  where+    isIgnoredC c = elem c ['.']+    isUnitC c = isAlpha c || isIgnoredC c+    unitWord = do+      unit <- CI.mk . Text.filter (not . isIgnoredC) <$> spaced (Atto.takeWhile1 isUnitC)+      case unit `elem` Map.keys unitAliasTable of+        True -> pure $ ParsedUnit unit+        False -> fail "No unit found"++nameP :: Atto.Parser ParsedIngredientName+nameP = ParsedIngredientName . CI.mk . Text.strip . Text.unwords . filter (not . Text.null) . fmap Text.strip . Text.words <$> Atto.takeText++ingredientP :: Atto.Parser ParsedIngredient+ingredientP = mk <$> ((,,) <$> quantityP <*> unitP <*> nameP)+  where+    mk (q, u, n) = ParsedIngredient n q u++sanitize :: Text -> Text+sanitize = replacements . Text.filter (not . isIgnoredC)+  where+    replacements str = foldr (uncurry Text.replace) str+      [ ("\194", " ")+      , ("\226\150\162", "")+      ]+    isIgnoredC c = elem c ['▢', '☐']++runParser :: Atto.Parser a -> Text -> Either String a+runParser parser x = Atto.parseOnly parser (Text.strip (sanitize x))++-- |Parse scraped ingredients.+parseScrapedIngredients :: [ScrapedIngredient] -> Either Text [Ingredient]+parseScrapedIngredients xs = left (const "Failed to parse ingredients") . fmap (nubOrd . fmap scrubIngredient . catMaybes) . for xs $ \case+  ScrapedIngredient raw | Text.null raw -> pure Nothing+  ScrapedIngredient raw -> Just <$> runParser ingredientP raw++-- |Parse raw ingredients, i.e. ones we know should be separated by newlines.+parseRawIngredients :: Text -> Either Text [Ingredient]+parseRawIngredients content = do+  either (const $ Left "Failed to parse ingredients") (pure . fmap scrubIngredient)+    . traverse (runParser ingredientP)+    . filter (not . Text.null)+    . Text.lines+    $ content++-- |Passive ingredient parser which separates on newlines.+mkIngredients :: Text -> [Ingredient]+mkIngredients =+  fmap (\str -> Ingredient (IngredientName (CI.mk str)) emptyQuantity Nothing) . Text.lines++-- |Parse scraped steps.+parseScrapedSteps :: [ScrapedStep] -> Either Text [Step]+parseScrapedSteps = \case+  [ScrapedStep single] | "1." `Text.isPrefixOf` single -> flip fix (filter (not . Text.null) . Text.words . Text.drop 2 $ single, (1 :: Int), []) $ \f -> \case+    ([], _, parsed) -> Right $ reverse parsed+    (toParse, ordinal, parsed) ->+      let nextOrdinal = tshow (ordinal + 1) <> "."+          (next, rest) = span (not . Text.isSuffixOf nextOrdinal) toParse+      in case rest of+        x:xs -> case Text.stripSuffix nextOrdinal x of+          Just y -> f (xs, ordinal + 1, (Step (Text.unwords (next <> [y]))):parsed)+          Nothing -> f (xs, ordinal + 1, (Step (Text.unwords next)):parsed)+        [] -> f ([], ordinal + 1, (Step (Text.unwords next)):parsed)+  [ScrapedStep single] -> Right $ fmap (Step . Text.unwords . filter (not . Text.null) . Text.words) . filter (not . Text.null) . fmap Text.strip . Text.lines $ single+  xs -> Right $ fmap (\(ScrapedStep step) -> Step . Text.unwords . filter (not . Text.null) . Text.words $ step) xs
+ src/Chez/Grater/Parser/Types.hs view
@@ -0,0 +1,31 @@+module Chez.Grater.Parser.Types where++import Chez.Grater.Internal.Prelude++import GHC.Generics (Generic)++newtype ParsedIngredientName = ParsedIngredientName { unParsedIngredientName :: CI Text }+  deriving (Eq, Ord, Show, Generic)++data ParsedQuantity+  = ParsedQuantity Double+  -- ^The quantity is a number.+  | ParsedQuantityWord (CI Text)+  -- ^The quantity is a word.+  | ParsedQuantityMissing+  -- ^There was no detected quantity.+  deriving (Eq, Ord, Show)++data ParsedUnit+  = ParsedUnit (CI Text)+  -- ^Detected a unit.+  | ParsedUnitMissing+  -- ^There was no detected unit.+  deriving (Eq, Ord, Show)++data ParsedIngredient = ParsedIngredient+  { parsedIngredientName     :: ParsedIngredientName+  , parsedIngredientQuantity :: ParsedQuantity+  , parsedIngredientUnit     :: ParsedUnit+  }+  deriving (Eq, Ord, Show)
+ src/Chez/Grater/Scraper.hs view
@@ -0,0 +1,56 @@+module Chez.Grater.Scraper where++import Chez.Grater.Internal.Prelude++import Chez.Grater.Scraper.Site+  ( allIngredientScrapers, allStepScrapers, ingredientScrapers, stepScrapers+  )+import Chez.Grater.Scraper.Types+  ( IngredientScraper(..), ScrapeError(..), ScrapeMetaWrapper(..), ScrapedRecipeName(..)+  , SiteName(..), StepScraper(..), ScrapedIngredient, ScrapedStep, title+  )+import Network.HTTP.Client (Manager)+import Network.URI (URI, uriAuthority, uriRegName)+import qualified Data.HashMap.Strict as HashMap+import qualified Data.Text as Text+import qualified Text.HTML.Scalpel as Scalpel++scrape+  :: (ScrapedRecipeName -> a)+  -> ([ScrapedIngredient] -> Either Text [b])+  -> ([ScrapedStep] -> Either Text [c])+  -> Manager -> URI -> IO (a, [b], [c], ScrapeMetaWrapper)+scrape mkName runIngredientParser runStepParser manager uri = do+  let cfg = Scalpel.Config Scalpel.defaultDecoder (Just manager)+  tags <- Scalpel.fetchTagsWithConfig cfg (show uri)+  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++      goIngredient IngredientScraper {..} = case Scalpel.scrape ingredientScraperTest tags of+        Just True -> (,ingredientScraperMeta) <$> runScraper runIngredientParser ingredientScraperRun+        _ -> Nothing+      goStep StepScraper {..} = case Scalpel.scrape stepScraperTest tags of+        Just True -> (,stepScraperMeta) <$> runScraper runStepParser stepScraperRun+        _ -> Nothing++  (ingredients, ingredientMeta) <- case flip HashMap.lookup ingredientScrapers =<< domainMay of+    Just IngredientScraper {..} -> maybe (throwIO $ ScrapeError "Failed to scrape known URL") (pure . (,ingredientScraperMeta)) $+      runScraper runIngredientParser ingredientScraperRun+    Nothing -> maybe (throwIO $ ScrapeError "Failed to scrape URL from defaults") pure+     . lastMay+     . sortOn (length . fst)+     . mapMaybe goIngredient+     $ allIngredientScrapers+  stepsMay <- case flip HashMap.lookup stepScrapers =<< domainMay of+    Just StepScraper {..} -> pure . fmap (,stepScraperMeta) . runScraper runStepParser $ stepScraperRun+    Nothing -> pure+      . lastMay+      . sortOn (length . fst)+      . mapMaybe goStep+      $ allStepScrapers+  case stepsMay of+    Just (steps, stepMeta) | not (null steps) -> pure (mkName name, ingredients, steps, ScrapeMetaWrapperIngredientAndStep ingredientMeta stepMeta)+    _ -> pure (mkName name, ingredients, [], ScrapeMetaWrapperIngredient ingredientMeta)
+ src/Chez/Grater/Scraper/Site.hs view
@@ -0,0 +1,610 @@+-- |Description: Ingredient and step scrapers for all the websites we know about.+module Chez.Grater.Scraper.Site where++import Chez.Grater.Internal.Prelude++import Chez.Grater.Scraper.Types+  ( IngredientScraper(..), ScrapeMeta(..), ScrapeName(..), ScrapeVersion(..), ScrapedIngredient(..)+  , ScrapedStep(..), SiteName(..), StepScraper(..), inception+  )+import Data.Function (on)+import Text.HTML.Scalpel ((//), (@:), (@=), Scraper, Selector)+import qualified Data.HashMap.Strict as HashMap+import qualified Data.Text as Text+import qualified Text.HTML.Scalpel as Scalpel++ingredientScrapers :: HashMap SiteName IngredientScraper+ingredientScrapers = HashMap.fromList+  [ ("allrecipes.com", allrecipesI)++  , ("cooking.nytimes.com", nytimesI)++  , ("food.com", geniusKitchen2I)+  , ("geniuskitchen.com", geniusKitchen1I)+  , ("tasteofhome.com", geniusKitchen1I)++  , ("rachlmansfield.com", tastyI2)+  , ("cookieandkate.com", tastyI1)+  , ("simpleveganblog.com", tastyI1)+  , ("eatyourselfskinny.com", tastyI1)+  , ("lexiscleankitchen.com", tastyI2)+  , ("sallysbakingaddiction.com", tastyI2)+  , ("gimmesomeoven.com", tastyI2)+  , ("pinchofyum.com", tastyI2)+  , ("alexandracooks.com", tastyI3)+  , ("naturallyella.com", tastyI3)+  , ("brownedbutterblondie.com", tastyI3)++  , ("foodnetwork.com", foodNetworkI)++  , ("cafedelites.com", wprmI)+  , ("budgetbytes.com", wprmI)+  , ("daringgourmet.com", wprmI)+  , ("recipetineats.com", wprmI)+  , ("cookingclassy.com", wprmI)+  , ("natashaskitchen.com", wprmI)+  , ("justonecookbook.com", wprmI)+  , ("loveandlemons.com", wprmI)+  , ("foodiecrush.com", wprmI)+  , ("therecipecritic.com", wprmI)+  , ("ambitiouskitchen.com", wprmI)+  , ("halfbakedharvest.com", wprmI)+  , ("ohsweetbasil.com", wprmI)+  , ("myfoodstory.com", wprmI)+  , ("easypeasyfoodie.com", wprmI)+  , ("veganricha.com", wprmI)+  , ("simplydeliciousfood.com", wprmI)+  , ("deliciouseveryday.com", wprmI)+  , ("iamafoodblog.com", wprmI)+  , ("thelastfoodblog.com", wprmI)+  , ("thefoodblog.net", wprmI)+  , ("onceuponafoodblog.com", wprmI)+  , ("anotherfoodblogger.com", wprmI)+  , ("minimalistbaker.com", wprmI)+  , ("davidlebovitz.com", wprmI)+  , ("skinnytaste.com", wprmI)+  , ("twopeasandtheirpod.com", wprmI)+  , ("sweetandsavorymeals.com", wprmI)+  , ("melskitchencafe.com", wprmI)+  , ("glutenfreecuppatea.co.uk", wprmI)++  , ("101cookbooks.com", cb101I)++  , ("bakerella.com", mvI)++  , ("localmilkblog.com", zlI)++  , ("smittenkitchen.com", jetpackI)++  , ("eatingwell.com", eatingWellI)+  , ("bhg.com", eatingWellI)++  , ("yummly.com", yummlyI)++  , ("simplyrecipes.com", simplyRecipesI)+  , ("seriouseats.com", simplyRecipesI)++  , ("bettycrocker.com", ingredientLi1)+  , ("pillsbury.com", ingredientLi1)+  , ("tasty.co", ingredientLi1)+  , ("lazycatkitchen.com", ingredientLi2)+  , ("deliciouslyella.com", ingredientLi3)+  , ("cookingandcooking.com", ingredientLi5)+  , ("damndelicious.net", ingredientLi6)+  , ("hemsleyandhemsley.com", ingredientLi6)+  , ("slenderkitchen.com", ingredientLi7)+  , ("everydayannie.com", ingredientLi8)+  , ("notwithoutsalt.com", ingredientLi9)+  , ("chefspencil.com", ingredientLi10)+  , ("shutterbean.com", ingredientLi11)+  , ("uitpaulineskeuken.nl", ingredientLi13)+  , ("leukerecepten.nl", ingredientLi14)+  , ("wsj.com", ingredientLi15)++  , ("delish.com", delishI)+  , ("thepioneerwoman.com", delishI)++  -- , ("epicurious.com", epicuriousI)++  , ("spoonacular.com", spoonacularI)++  , ("food52.com", food52I)++  , ("thekitchn.com", thekitchnI)++  , ("eatwell101.com", eatwell101I)+  ]++stepScrapers :: HashMap SiteName StepScraper+stepScrapers = HashMap.fromList+  [ ("allrecipes.com", allrecipesS)++  , ("cooking.nytimes.com", nytimesS)++  , ("food.com", geniusKitchen2S)+  , ("geniuskitchen.com", geniusKitchen1S)+  , ("tasteofhome.com", geniusKitchen1S)++  , ("rachlmansfield.com", tastyS1)+  , ("cookieandkate.com", tastyS1)+  , ("simpleveganblog.com", tastyS1)+  , ("eatyourselfskinny.com", tastyS1)+  , ("lexiscleankitchen.com", tastyS1)+  , ("sallysbakingaddiction.com", tastyS2)+  , ("gimmesomeoven.com", tastyS2)+  , ("pinchofyum.com", tastyS2)+  , ("alexandracooks.com", tastyS2)+  , ("naturallyella.com", tastyS2)+  , ("brownedbutterblondie.com", tastyS3)+  , ("simple-veganista.com", tastyS4)++  , ("foodnetwork.com", foodNetworkS)++  , ("cafedelites.com", wprmS)+  , ("budgetbytes.com", wprmS)+  , ("daringgourmet.com", wprmS)+  , ("recipetineats.com", wprmS)+  , ("cookingclassy.com", wprmS)+  , ("natashaskitchen.com", wprmS)+  , ("justonecookbook.com", wprmS)+  , ("loveandlemons.com", wprmS)+  , ("foodiecrush.com", wprmS)+  , ("therecipecritic.com", wprmS)+  , ("ambitiouskitchen.com", wprmS)+  , ("halfbakedharvest.com", wprmS)+  , ("ohsweetbasil.com", wprmS)+  , ("myfoodstory.com", wprmS)+  , ("easypeasyfoodie.com", wprmS)+  , ("veganricha.com", wprmS)+  , ("simplydeliciousfood.com", wprmS)+  , ("deliciouseveryday.com", wprmS)+  , ("iamafoodblog.com", wprmS)+  , ("thelastfoodblog.com", wprmS)+  , ("thefoodblog.net", wprmS)+  , ("onceuponafoodblog.com", wprmS)+  , ("anotherfoodblogger.com", wprmS)+  , ("minimalistbaker.com", wprmS)+  , ("davidlebovitz.com", wprmS)+  , ("skinnytaste.com", wprmS)+  , ("twopeasandtheirpod.com", wprmS)+  , ("sweetandsavorymeals.com", wprmS)+  , ("melskitchencafe.com", wprmS)+  , ("glutenfreecuppatea.co.uk", wprmS)++  , ("101cookbooks.com", cb101S)++  , ("bakerella.com", mvS)++  , ("localmilkblog.com", zlS)++  , ("smittenkitchen.com", jetpackS)++  , ("eatingwell.com", eatingWellS)+  , ("bhg.com", eatingWellS)++  , ("yummly.com", yummlyS)++  , ("simplyrecipes.com", simplyRecipesS)+  , ("seriouseats.com", simplyRecipesS)++  , ("bettycrocker.com", stepLi1)+  , ("pillsbury.com", stepLi1)+  , ("tasty.co", stepLi2)+  , ("damndelicious.net", stepLi3)+  , ("lazycatkitchen.com", stepLi4)+  , ("deliciouslyella.com", stepLi5)+  , ("slenderkitchen.com", stepLi6)+  , ("everydayannie.com", stepLi7)+  , ("hemsleyandhemsley.com", stepLi8)+  , ("notwithoutsalt.com", stepLi9)+  , ("cookingandcooking.com", stepLi10)+  , ("uitpaulineskeuken.nl", stepLi11)+  , ("leukerecepten.nl", stepLi12)+  , ("wsj.com", stepLi13)+  , ("eatfigsnotpigs.com", stepLi14)++  , ("delish.com", delishS)+  , ("thepioneerwoman.com", delishS)++  , ("food52.com", food52S)++  , ("thekitchn.com", thekitchnS)+  ]++-- |Get all ingredient scrapers, ordered by most popular first.+allIngredientScrapers :: [IngredientScraper]+allIngredientScrapers = fmap head . reverse . sortOn length  . groupBy ((==) `on` (scrapeMetaName . ingredientScraperMeta)) . sortOn (scrapeMetaName . ingredientScraperMeta) . HashMap.elems $ ingredientScrapers++-- |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++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++setIngredientVersion :: Int -> IngredientScraper -> IngredientScraper+setIngredientVersion v scraper = scraper { ingredientScraperMeta = (ingredientScraperMeta scraper) { scrapeMetaVersion = ScrapeVersion v } }++setStepVersion :: Int -> StepScraper -> StepScraper+setStepVersion v scraper = scraper { stepScraperMeta = (stepScraperMeta scraper) { scrapeMetaVersion = ScrapeVersion v } }++simpleIngredientScraper :: Text -> Scraper Text Bool -> Selector -> IngredientScraper+simpleIngredientScraper sName test select = IngredientScraper (ScrapeMeta (ScrapeName sName) inception) test scrape+  where+    scrape = Scalpel.chroots select (+      ScrapedIngredient+        <$> Scalpel.text Scalpel.anySelector+      )++simpleStepScraper :: Text -> Scraper Text Bool -> Selector -> StepScraper+simpleStepScraper sName test select = StepScraper (ScrapeMeta (ScrapeName sName) inception) test scrape+  where+    scrape = Scalpel.chroots select (+      ScrapedStep+        <$> 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"])++allrecipesS :: StepScraper+allrecipesS = simpleStepScraper "allrecipes"+  (testScrape ("meta" @: ["content" @= "Allrecipes"]))+  ("ul" @: [Scalpel.hasClass "instructions-section"] // "div" @: [Scalpel.hasClass "section-body"])++nytimesI :: IngredientScraper+nytimesI = simpleIngredientScraper "nytimes"+  (testScrape ("meta" @: ["content" @= "NYT Cooking"]))+  ("ul" @: [Scalpel.hasClass "recipe-ingredients"] // "li")++nytimesS :: StepScraper+nytimesS = simpleStepScraper "nytimes"+  (testScrape ("meta" @: ["content" @= "NYT Cooking"]))+  ("ol" @: [Scalpel.hasClass "recipe-steps"] // "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"]))+  ("div" @: [Scalpel.hasClass "tasty-recipe-ingredients"] // "li")++tastyI2 :: IngredientScraper+tastyI2 = simpleIngredientScraper "tasty2"+  (testScrape ("div" @: [Scalpel.hasClass "tasty-recipes-ingredients"]))+  ("div" @: [Scalpel.hasClass "tasty-recipes-ingredients"] // "li")++tastyI3 :: IngredientScraper+tastyI3 = simpleIngredientScraper "tasty3"+  (testScrape ("div" @: [Scalpel.hasClass "tasty-recipes-ingredients"]))+  ("div" @: [Scalpel.hasClass "tasty-recipes-ingredients"] // "p")++tastyS1 :: StepScraper+tastyS1 = simpleStepScraper "tasty1"+  (testScrape ("div" @: [Scalpel.hasClass "tasty-recipe-instructions"]))+  ("div" @: [Scalpel.hasClass "tasty-recipe-instructions"] // "li")++tastyS2 :: StepScraper+tastyS2 = simpleStepScraper "tasty2"+  (testScrape ("div" @: [Scalpel.hasClass "tasty-recipes-instructions"]))+  ("div" @: [Scalpel.hasClass "tasty-recipes-instructions"] // "li")++tastyS3 :: StepScraper+tastyS3 = simpleStepScraper "tasty3"+  (testScrape ("div" @: [Scalpel.hasClass "tasty-recipes-instructions"]))+  ("div" @: [Scalpel.hasClass "tasty-recipes-instructions"] // "div" @: [Scalpel.hasClass "tasty-recipes-instructions-body"])++tastyS4 :: StepScraper+tastyS4 = simpleStepScraper "tasty4"+  (testScrape ("div" @: [Scalpel.hasClass "tasty-recipe-instructions"]))+  ("div" @: [Scalpel.hasClass "tasty-recipe-instructions"] // "p")++foodNetworkI :: IngredientScraper+foodNetworkI = IngredientScraper (ScrapeMeta (ScrapeName "foodNetwork") inception) denyAll $+  fmap ScrapedIngredient . filter (not . (==) "Deselect All") <$> Scalpel.chroots ("div" @: [Scalpel.hasClass "o-Ingredients__m-Body"] // "span") (Scalpel.text Scalpel.anySelector)++foodNetworkS :: StepScraper+foodNetworkS = StepScraper (ScrapeMeta (ScrapeName "foodNetwork") inception) denyAll $ do+  fmap ScrapedStep <$> Scalpel.chroots ("div" @: [Scalpel.hasClass "o-Method__m-Body"] // "li") (Scalpel.text Scalpel.anySelector)++wprmI :: IngredientScraper+wprmI = simpleIngredientScraper "wprm"+  (testScrape ("div" @: [Scalpel.hasClass "wprm-recipe"]))+  ("li" @: [Scalpel.hasClass "wprm-recipe-ingredient"])++wprmS :: StepScraper+wprmS = simpleStepScraper "wprm"+  (testScrape ("div" @: [Scalpel.hasClass "wprm-recipe"]))+  ("li" @: [Scalpel.hasClass "wprm-recipe-instruction"])++cb101I :: IngredientScraper+cb101I = simpleIngredientScraper "cb101"+  (testScrape ("div" @: [Scalpel.hasClass "cb101-recipe-main"]))+  ("li" @: [Scalpel.hasClass "cb101-recipe-ingredient"])++cb101S :: StepScraper+cb101S = simpleStepScraper "cb101"+  (testScrape ("div" @: [Scalpel.hasClass "cb101-recipe-main"]))+  ("li" @: [Scalpel.hasClass "cb101-recipe-instruction"])++mvI :: IngredientScraper+mvI = simpleIngredientScraper "mv"+  (testScrape ("div" @: [Scalpel.hasClass "mv-create-ingredients"]))+  ("div" @: [Scalpel.hasClass "mv-create-ingredients"] // "li")++mvS :: StepScraper+mvS = simpleStepScraper "mv"+  (testScrape ("div" @: [Scalpel.hasClass "mv-create-instructions"]))+  ("div" @: [Scalpel.hasClass "mv-create-instructions"] // "li")++zlI :: IngredientScraper+zlI = simpleIngredientScraper "zl"+  (testScrape ("ul" @: ["id" @= "zlrecipe-ingredients-list"]))+  ("ul" @: ["id" @= "zlrecipe-ingredients-list"] // "li")++zlS :: StepScraper+zlS = simpleStepScraper "zl"+  (testScrape ("ol" @: ["id" @= "zlrecipe-instructions-list"]))+  ("ol" @: ["id" @= "zlrecipe-instructions-list"] // "li")++jetpackI :: IngredientScraper+jetpackI = simpleIngredientScraper "jetpack"+  (testScrape ("div" @: [Scalpel.hasClass "jetpack-recipe"]))+  ("div" @: [Scalpel.hasClass "jetpack-recipe-ingredients"] // "li")++jetpackS :: StepScraper+jetpackS = simpleStepScraper "jetpack"+  (testScrape ("div" @: [Scalpel.hasClass "jetpack-recipe"]))+  ("div" @: [Scalpel.hasClass "jetpack-recipe-directions"])++eatingWellI :: IngredientScraper+eatingWellI = simpleIngredientScraper "eatingWell"+  (testScrape ("ul" @: [Scalpel.hasClass "ingredients-section"]))+  ("ul" @: [Scalpel.hasClass "ingredients-section"] // "li")++eatingWellS :: StepScraper+eatingWellS = simpleStepScraper "eatingWell"+  (testScrape ("ul" @: [Scalpel.hasClass "instructions-section"]))+  ("ul" @: [Scalpel.hasClass "instructions-section"] // "li" // "p")++yummlyI :: IngredientScraper+yummlyI = simpleIngredientScraper "yummly"+  denyAll+  ("li" @: [Scalpel.hasClass "IngredientLine"])++yummlyS :: StepScraper+yummlyS = simpleStepScraper "yummly"+  denyAll+  ("li" @: [Scalpel.hasClass "prep-step"])++simplyRecipesI :: IngredientScraper+simplyRecipesI = simpleIngredientScraper "simplyrecipes"+  denyAll+  ("section" @: ["id" @= "section--ingredients_1-0"] // "li")++simplyRecipesS :: StepScraper+simplyRecipesS = simpleStepScraper "simplyrecipes"+  denyAll+  ("section" @: ["id" @= "section--instructions_1-0"] // "li")++ingredientLi1 :: IngredientScraper+ingredientLi1 = simpleIngredientScraper "ingredientLi1"+  (testScrape ("li" @: [Scalpel.hasClass "ingredient"]))+  ("li" @: [Scalpel.hasClass "ingredient"])++ingredientLi2 :: IngredientScraper+ingredientLi2 = simpleIngredientScraper "ingredientLi2"+  (testScrape ("div" @: [Scalpel.hasClass "ingredients-section"]))+  ("div" @: [Scalpel.hasClass "ingredients-section"] // "li")++ingredientLi3 :: IngredientScraper+ingredientLi3 = simpleIngredientScraper "ingredientLi3"+  (testScrape ("li" @: ["itemprop" @= "recipeIngredient"]))+  ("li" @: ["itemprop" @= "recipeIngredient"])++ingredientLi4 :: IngredientScraper+ingredientLi4 = simpleIngredientScraper "ingredientLi4"+  (testScrape ("div" @: [Scalpel.hasClass "ingredients"]))+  ("div" @: [Scalpel.hasClass "ingredients"] // "li")++ingredientLi5 :: IngredientScraper+ingredientLi5 = simpleIngredientScraper "ingredientLi5"+  (testScrape ("div" @: [Scalpel.hasClass "listIngredient"]))+  ("div" @: [Scalpel.hasClass "listIngredient"] // "li")++ingredientLi6 :: IngredientScraper+ingredientLi6 = simpleIngredientScraper "ingredientLi6"+  (testScrape ("li" @: ["itemprop" @= "ingredients"]))+  ("li" @: ["itemprop" @= "ingredients"])++ingredientLi7 :: IngredientScraper+ingredientLi7 = simpleIngredientScraper "ingredientLi7"+  (testScrape ("ul" @: [Scalpel.hasClass "ingredients"]))+  ("ul" @: [Scalpel.hasClass "ingredients"] // "li")++ingredientLi8 :: IngredientScraper+ingredientLi8 = simpleIngredientScraper "ingredientLi8"+  (testScrape ("div" @: [Scalpel.hasClass "ingredient-text"]))+  ("div" @: [Scalpel.hasClass "ingredient-text"] // "ul" // "li")++ingredientLi9 :: IngredientScraper+ingredientLi9 = simpleIngredientScraper "ingredientLi9"+  (testScrape ("div" @: [Scalpel.hasClass "cookbook-container-ingredients"]))+  ("p" @: [Scalpel.hasClass "cookbook-ingredient-item"])++ingredientLi10 :: IngredientScraper+ingredientLi10 = simpleIngredientScraper "ingredientLi10"+  (testScrape ("table" @: [Scalpel.hasClass "ingredients-table"]))+  ("table" @: [Scalpel.hasClass "ingredients-table"] // "tr")++ingredientLi11 :: IngredientScraper+ingredientLi11 = simpleIngredientScraper "ingredientLi11"+  (testScrape ("blockquote" @: [Scalpel.hasClass "recipe-block"]))+  ("blockquote" @: [Scalpel.hasClass "recipe-block"] // "li")++ingredientLi12 :: IngredientScraper+ingredientLi12 = simpleIngredientScraper "ingredientLi12"+  (testScrape ("li" @: [Scalpel.hasClass "structured-ingredients__list-item"]))+  ("li" @: [Scalpel.hasClass "structured-ingredients__list-item"])++ingredientLi13 :: IngredientScraper+ingredientLi13 = simpleIngredientScraper "ingredientLi13"+  (testScrape ("section" @: [Scalpel.hasClass "ingredients-list"]))+  ("section" @: [Scalpel.hasClass "ingredients-list"] // "li")++ingredientLi14 :: IngredientScraper+ingredientLi14 = simpleIngredientScraper "ingredientLi14"+  (testScrape ("ul" @: [Scalpel.hasClass "page-content__ingredients-list"]))+  ("ul" @: [Scalpel.hasClass "page-content__ingredients-list"] // "li")++ingredientLi15 :: IngredientScraper+ingredientLi15 = simpleIngredientScraper "ingredientLi15"+  (testScrape ("ul" @: [Scalpel.hasClass "ingredients-list"]))+  ("ul" @: [Scalpel.hasClass "ingredients-list"] // "li")++stepLi1 :: StepScraper+stepLi1 = simpleStepScraper "stepLi1"+  (testScrape ("ul" @: [Scalpel.hasClass "recipeSteps"]))+  ("ul" @: [Scalpel.hasClass "recipeSteps"] // "li")++stepLi2 :: StepScraper+stepLi2 = simpleStepScraper "stepLi2"+  (testScrape ("ol" @: [Scalpel.hasClass "prep-steps"]))+  ("ol" @: [Scalpel.hasClass "prep-steps"] // "li")++stepLi3 :: StepScraper+stepLi3 = simpleStepScraper "stepLi3"+  (testScrape ("div" @: [Scalpel.hasClass "instructions"]))+  ("div" @: [Scalpel.hasClass "instructions"] // "li")++stepLi4 :: StepScraper+stepLi4 = simpleStepScraper "stepLi4"+  (testScrape ("div" @: [Scalpel.hasClass "method-section"]))+  ("div" @: [Scalpel.hasClass "method-section"] // "li")++stepLi5 :: StepScraper+stepLi5 = simpleStepScraper "stepLi5"+  (testScrape ("section" @: [Scalpel.hasClass "recipe__section"]))+  ("section" @: [Scalpel.hasClass "recipe__section"] // "li")++stepLi6 :: StepScraper+stepLi6 = simpleStepScraper "stepLi6"+  (testScrape ("div" @: [Scalpel.hasClass "step-instructions"]))+  ("div" @: [Scalpel.hasClass "step-instructions"] // "div" @: [Scalpel.hasClass "step"])++stepLi7 :: StepScraper+stepLi7 = simpleStepScraper "stepLi7"+  (testScrape ("div" @: ["id" @= "directions"]))+  ("div" @: ["id" @= "directions"] // "li")++stepLi8 :: StepScraper+stepLi8 = simpleStepScraper "stepLi8"+  (testScrape ("div" @: [Scalpel.hasClass "recipe-main-copy"]))+  ("div" @: [Scalpel.hasClass "recipe-main-copy"])++stepLi9 :: StepScraper+stepLi9 = simpleStepScraper "stepLi9"+  (testScrape ("p" @: [Scalpel.hasClass "cookbook-instruction-item"]))+  ("p" @: [Scalpel.hasClass "cookbook-instruction-item"])++stepLi10 :: StepScraper+stepLi10 = simpleStepScraper "stepLi10"+  (testScrape ("p" @: [Scalpel.hasClass "recipeText"]))+  ("p" @: [Scalpel.hasClass "recipeText"])++stepLi11 :: StepScraper+stepLi11 = simpleStepScraper "stepLi11"+  (testScrape ("div" @: [Scalpel.hasClass "preparation-list"]))+  ("div" @: [Scalpel.hasClass "preparation-list"] // "li")++stepLi12 :: StepScraper+stepLi12 = simpleStepScraper "stepLi12"+  (testScrape ("div" @: [Scalpel.hasClass "page-content__recipe"]))+  ("div" @: [Scalpel.hasClass "page-content__recipe"] // "div" @: [Scalpel.hasClass "step"])++stepLi13 :: StepScraper+stepLi13 = simpleStepScraper "stepLi13"+  (testScrape ("ol" @: [Scalpel.hasClass "steps-list"]))+  ("ol" @: [Scalpel.hasClass "steps-list"] // "li")++stepLi14 :: StepScraper+stepLi14 = setStepVersion 2 $ simpleStepScraper "stepLi14"+  (testScrape ("li" @: [Scalpel.hasClass "instruction"]))+  ("li" @: [Scalpel.hasClass "instruction"])++delishI :: IngredientScraper+delishI = simpleIngredientScraper "delish"+  acceptAll+  ("div" @: [Scalpel.hasClass "ingredient-item"])++delishS :: StepScraper+delishS = simpleStepScraper "delish"+  acceptAll+  ("div" @: [Scalpel.hasClass "direction-lists"] // "li")++spoonacularI :: IngredientScraper+spoonacularI = simpleIngredientScraper "spoontacular"+  denyAll+  ("div" @: [Scalpel.hasClass "spoonacular-ingredient"])++food52I :: IngredientScraper+food52I = simpleIngredientScraper "food52"+  denyAll+  ("div" @: [Scalpel.hasClass "recipe__list--ingredients"] // "li")++food52S :: StepScraper+food52S = simpleStepScraper "food52"+  denyAll+  ("div" @: [Scalpel.hasClass "recipe__list--steps"] // "li")++-- epicuriousI :: IngredientScraper+-- epicuriousI = simpleIngredientScraper "epicurious"+--   denyAll+--   ("div" @: [Scalpel.hasClass "recipe__ingredient-list"])++thekitchnI :: IngredientScraper+thekitchnI = simpleIngredientScraper "thekitchn"+  denyAll+  ("ul" @: [Scalpel.hasClass "Recipe__ingredients"] // "li")++thekitchnS :: StepScraper+thekitchnS = simpleStepScraper "thekitchn"+  denyAll+  ("ol" @: [Scalpel.hasClass "Recipe__instructions"] // "li")++eatwell101I :: IngredientScraper+eatwell101I = simpleIngredientScraper "eatwell101"+  denyAll+  ("div" @: [Scalpel.hasClass "pf-content"] // "li")
+ src/Chez/Grater/Scraper/Types.hs view
@@ -0,0 +1,75 @@+module Chez.Grater.Scraper.Types where++import Chez.Grater.Internal.Prelude++import Data.Hashable (Hashable)+import Data.String (IsString)+import GHC.Generics (Generic)+import Text.HTML.Scalpel (Scraper)+import qualified Data.Text as Text+import qualified Text.HTML.Scalpel as Scalpel++-- |Wrapper for scraper metadata.+data ScrapeMetaWrapper+  = ScrapeMetaWrapperIngredient (ScrapeMeta ScrapedIngredient)+  | ScrapeMetaWrapperIngredientAndStep (ScrapeMeta ScrapedIngredient) (ScrapeMeta ScrapedStep)+  deriving (Eq, Ord, Show)++-- |Name of a scraper.+newtype ScrapeName = ScrapeName { unScrapeName :: Text }+  deriving (Eq, Ord, Show, Generic)++-- |Version of a scraper.+newtype ScrapeVersion = ScrapeVersion { unScrapeVersion :: Int }+  deriving (Eq, Ord, Show, Generic)++-- |Metadata for a scraper.+data ScrapeMeta a = ScrapeMeta+  { scrapeMetaName    :: ScrapeName+  , scrapeMetaVersion :: ScrapeVersion+  }+  deriving (Eq, Ord, Show, Generic)++-- |The `<title>` element of the HTML.+newtype ScrapedRecipeName = ScrapedRecipeName { unScrapedRecipeName :: Text }+  deriving (Eq, Show, Generic)++-- |Unparsed ingredient.+data ScrapedIngredient = ScrapedIngredient Text+  deriving (Eq, Ord, Show)++data ScrapedStep = ScrapedStep Text+  deriving (Eq, Ord, Show)++data IngredientScraper = IngredientScraper+  { ingredientScraperMeta :: ScrapeMeta ScrapedIngredient+  -- ^Metadata about the scraper.+  , ingredientScraperTest :: Scraper Text Bool+  -- ^A test on the HTML to see if this scraper should work.+  , ingredientScraperRun  :: Scraper Text [ScrapedIngredient]+  -- ^Run the scraper!+  }++data StepScraper = StepScraper+  { stepScraperMeta :: ScrapeMeta ScrapedStep+  -- ^Metadata about the scraper.+  , stepScraperTest :: Scraper Text Bool+  -- ^A test on the HTML to see if this scraper should work.+  , stepScraperRun  :: Scraper Text [ScrapedStep]+  -- ^Run the scraper!+  }++-- |Domain like `halfbakedharvest.com`.+newtype SiteName = SiteName { unSiteName :: Text }+  deriving (Eq, Ord, Show, IsString, Hashable)++data ScrapeError = ScrapeError Text+  deriving (Eq, Show)++instance Exception ScrapeError++title :: Scraper Text ScrapedRecipeName+title = ScrapedRecipeName . Text.strip <$> Scalpel.text "title"++inception :: ScrapeVersion+inception = ScrapeVersion 1
+ src/Chez/Grater/Types.hs view
@@ -0,0 +1,100 @@+module Chez.Grater.Types where++import Chez.Grater.Internal.Prelude++import Chez.Grater.Internal.CI.Orphans ()+import Chez.Grater.Internal.Json (jsonOptions)+import Data.Aeson (FromJSON, ToJSON)+import Data.Aeson.TH (deriveJSON)+import GHC.Generics (Generic)++newtype IngredientName = IngredientName { unIngredientName :: CI Text }+  deriving (Eq, Ord, Show, Generic, FromJSON, ToJSON)++newtype RecipeName = RecipeName { unRecipeName :: Text }+  deriving (Eq, Ord, Show, Generic, FromJSON, ToJSON)++data Fraction = Fraction+  { fractionNumerator   :: Int+  , fractionDenominator :: Int+  }+  deriving (Eq, Show, Ord)++data Quantity = Quantity+  { quantityWhole    :: Maybe Int+  , quantityFraction :: Maybe Fraction+  }+  deriving (Eq, Show, Ord)++newtype Unit = Unit { unUnit :: CI Text }+  deriving (Eq, Ord, Show, FromJSON, ToJSON)++newtype Step = Step { unStep :: Text }+  deriving (Eq, Ord, Show, Generic, FromJSON, ToJSON)++data Ingredient = Ingredient+  { ingredientName     :: IngredientName+  , ingredientQuantity :: Quantity+  , ingredientUnit     :: Maybe Unit+  }+  deriving (Eq, Ord, Show)++deriveJSON (jsonOptions "fraction") ''Fraction+deriveJSON (jsonOptions "quantity") ''Quantity+deriveJSON (jsonOptions "Ingredient") ''Ingredient++pinch, teaspoon, tablespoon, cup, ounce, box, pound, splash, sprinkle, whole+  , milliliter, liter, milligram, gram :: Unit+pinch = Unit "pinch"+teaspoon = Unit "tsp"+tablespoon = Unit "tbsp"+cup = Unit "cup"+ounce = Unit "oz"+box = Unit "box"+pound = Unit "pound"+splash = Unit "splash"+sprinkle = Unit "sprinkle"+whole = Unit "whole"+milliliter = Unit "ml"+liter = Unit "l"+milligram = Unit "mg"+gram = Unit "g"++emptyQuantity :: Quantity+emptyQuantity = Quantity Nothing Nothing++mkQuantity :: Double -> Quantity+mkQuantity q = case splitQuantity q of+  Nothing -> Quantity Nothing Nothing+  Just (w, d) ->+    case (w == 0, find (\((lo, hi), _) -> lo <= d && d <= hi) knownQuantities) of+      (False, Just (_, (numerator, denominator))) -> Quantity (Just w) (Just (Fraction numerator denominator))+      (True, Just (_, (numerator, denominator))) -> Quantity Nothing (Just (Fraction numerator denominator))+      (False, Nothing) -> Quantity (Just w) Nothing+      (True, Nothing) -> Quantity Nothing Nothing++  where++    quantityPrecision :: Double+    quantityPrecision = 0.01++    quarter = 0.25+    third = 1 / 3+    half = 0.5+    twoThird = 2 / 3+    threeQuarter = 0.75++    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))+      ]++    splitQuantity :: Double -> Maybe (Int, Double)+    splitQuantity 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)
+ test/Chez/Grater/Gen.hs view
@@ -0,0 +1,65 @@+module Chez.Grater.Gen where++import Chez.Grater.Internal.Prelude++import Test.QuickCheck (Gen, arbitrary, elements, oneof)+import qualified Data.CaseInsensitive as CI+import qualified Data.Text as Text++import Chez.Grater.Types++maybeGen :: Gen a -> Gen (Maybe a)+maybeGen gen = oneof+  [ Just <$> gen+  , pure Nothing+  ]++arbitraryAlphaNum :: Gen Char+arbitraryAlphaNum = elements $ ['a'..'z'] <> ['A'..'Z'] <> ['0'..'9']++arbitraryAlphaNumStr :: Gen Text+arbitraryAlphaNumStr = do+  n <- arbitrary+  Text.pack <$> replicateM (abs n + 10) arbitraryAlphaNum++arbitraryCi :: Gen (CI Text)+arbitraryCi = CI.mk <$> arbitraryAlphaNumStr++arbitraryIngredientName :: Gen IngredientName+arbitraryIngredientName = IngredientName <$> arbitraryCi++arbitraryRecipeName :: Gen RecipeName+arbitraryRecipeName = RecipeName <$> arbitraryAlphaNumStr++trunc :: Int -> Double -> Double+trunc n x = (fromIntegral (floor (x * t) :: Int)) / t+  where t = 10^n++arbitraryDouble :: Gen Double+arbitraryDouble = trunc 5 . abs <$> arbitrary++arbitraryInt :: Gen Int+arbitraryInt = abs <$> arbitrary++arbitraryFraction :: Gen Fraction+arbitraryFraction = Fraction+  <$> arbitraryInt+  <*> ((+1) <$> arbitraryInt)++arbitraryQuantity :: Gen Quantity+arbitraryQuantity = Quantity+  <$> maybeGen arbitraryInt+  <*> maybeGen arbitraryFraction++arbitraryUnit :: Gen (Maybe Unit)+arbitraryUnit = maybeGen (Unit <$> arbitraryCi)++arbitraryIngredient :: Gen Ingredient+arbitraryIngredient = Ingredient+  <$> arbitraryIngredientName+  <*> arbitraryQuantity+  <*> arbitraryUnit++arbitraryStep :: Gen Step+arbitraryStep = Step+  <$> arbitraryAlphaNumStr
+ test/Chez/Grater/ParsedIngredients.hs view
@@ -0,0 +1,303 @@+module Chez.Grater.ParsedIngredients where++import Chez.Grater.Internal.Prelude++import Chez.Grater.Types+  ( Ingredient(..), IngredientName(..), Step(..), Unit(..), emptyQuantity, mkQuantity+  )+import qualified Data.CaseInsensitive as CI++allParsedIngredients :: [[Ingredient]]+allParsedIngredients =+  [ allRecipesIngredients+  , pillsburyIngredients+  , tasteOfHomeIngredients+  , rachelMansfieldIngredients+  , foodNetworkIngredients+  ]++pureIngredient :: Double -> Text -> Text -> Ingredient+pureIngredient q u i = Ingredient+  { ingredientName = IngredientName $ CI.mk i+  , ingredientQuantity = mkQuantity q+  , ingredientUnit = Just . Unit . CI.mk $ u+  }++pureIngredientNoQuantity :: Text -> Text -> Ingredient+pureIngredientNoQuantity u i = Ingredient+  { ingredientName = IngredientName $ CI.mk i+  , ingredientQuantity = emptyQuantity+  , ingredientUnit = Just . Unit . CI.mk $ u+  }++pureIngredientNoUnit :: Double -> Text -> Ingredient+pureIngredientNoUnit q i = Ingredient+  { ingredientName = IngredientName $ CI.mk i+  , ingredientQuantity = mkQuantity q+  , ingredientUnit = Nothing+  }++pureIngredientName :: Text -> Ingredient+pureIngredientName i = Ingredient+  { ingredientName = IngredientName $ CI.mk i+  , ingredientQuantity = emptyQuantity+  , ingredientUnit = Nothing+  }++allRecipesIngredients :: [Ingredient]+allRecipesIngredients =+  [ pureIngredient 1 "pound" "skinless, boneless chicken breast halves - cubed"+  , pureIngredient 1 "cup" "sliced carrots"+  , pureIngredient 1 "cup" "frozen green peas"+  , pureIngredient 0.5 "cup" "sliced celery"+  , pureIngredient (1 / 3) "cup" "butter"+  , pureIngredient (1 / 3) "cup" "chopped onion"+  , pureIngredient (1 / 3) "cup" "all-purpose flour"+  , pureIngredient 0.5 "tsp" "salt"+  , pureIngredient 0.25 "tsp" "black pepper"+  , pureIngredient 0.25 "tsp" "celery seed"+  , pureIngredient 1.75 "cup" "chicken broth"+  , pureIngredient (2 / 3) "cup" "milk"+  , pureIngredientNoUnit 2 "(9 inch) unbaked pie crusts"+  ]++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."+  ]++foodIngredients :: [Ingredient]+foodIngredients =+  [ pureIngredientNoUnit 1 "(10 ounce) packaged frozen chopped spinach, thawed"+  , pureIngredientNoUnit 1 "(14 ounce) can diced tomatoes, undrained"+  , pureIngredientNoUnit 3 "(14 ounce) cans chicken broth"+  , pureIngredientNoUnit 2 "(15 ounce) cans white beans, any variety"+  , pureIngredientNoUnit 1 "bay leaf"+  , pureIngredientNoUnit 2 "carrots, chopped"+  , pureIngredientNoUnit 4 "garlic cloves, minced"+  , pureIngredientName "grated parmesan cheese, and or pecorino romano cheese, to serve"+  , pureIngredientNoUnit 1 "medium onion, chopped"+  , pureIngredientName "salt and pepper"+  , pureIngredientNoUnit 2 "stalks celery, chopped"+  , pureIngredient 0.75 "cup" "macaroni, uncooked"+  , pureIngredient 0.25 "cup" "olive oil"+  , pureIngredient 0.25 "tsp" "dried thyme"+  ]++foodSteps :: [Step]+foodSteps =+  [ Step "Heat the oil in a 4 quart soup pot over medium heat."+  , Step "Add the onion, garlic, carrots, celery and thyme and cook until the vegetables are tender, about 10 minutes."+  , Step "Add the chicken broth, tomatoes and bay leaf."+  , Step "Drain the beans and roughly mash 1/2 a can of beans. Add the beans and mashed beans to the pot."+  , Step "Squeeze the water from the thawed spinach and add to the pot."+  , Step "Season with salt and pepper and simmer for at least 10 minutes and up to 30 minutes."+  , Step "Add the macaroni and cook until tender according to package directions. (About 10 minutes.)."+  , Step "Serve sprinkled with grated parmesan and/or pecorino romano."+  ]++pillsburyIngredients :: [Ingredient]+pillsburyIngredients =+  [ pureIngredient 1 "box" "(14.1 oz) refrigerated pillsbury\8482 pie crusts (2 count), softened as directed on box"+  , pureIngredient (1 / 3) "cup" "butter or margarine"+  , pureIngredient (1 / 3) "cup" "chopped onion"+  , pureIngredient (1 / 3) "cup" "all-purpose flour"+  , pureIngredient 0.5 "tsp" "salt"+  , pureIngredient 0.25 "tsp" "pepper"+  , pureIngredient 1.75 "cup" "progresso\8482 chicken broth (from 32-oz carton)"+  , pureIngredient 0.5 "cup" "milk"+  , pureIngredient 2.5 "cup" "shredded cooked chicken or turkey"+  , pureIngredient 2 "cup" "frozen mixed vegetables, thawed"+  ]++pillsburySteps :: [Step]+pillsburySteps =+  [ Step "1 Heat oven to 425\176F. Prepare pie crusts as directed on box for Two-Crust Pie using 9-inch glass pie pan."+  , Step "2 In 2-quart saucepan, melt butter over medium heat. Add onion; cook 2 minutes, stirring frequently, until tender. Stir in flour, salt and pepper until well blended. Gradually stir in broth and milk, cooking and stirring until bubbly and thickened."+  , Step "3 Stir in chicken and mixed vegetables. Remove from heat. Spoon chicken mixture into crust-lined pan. Top with second crust; seal edge and flute. Cut slits in several places in top crust."+  , Step "4 Bake 30 to 40 minutes or until crust is golden brown. During last 15 to 20 minutes of baking, cover crust edge with strips of foil to prevent excessive browning. Let stand 5 minutes before serving."+  ]++bettyCrockerIngredients :: [Ingredient]+bettyCrockerIngredients =+  [ pureIngredient 1 "box" "(18.3 oz) Betty Crocker\8482 traditional fudge brownie mix"+  , pureIngredient 1 "cup" "Betty Crocker\8482 Rich & Creamy vanilla frosting (from 16-oz container)"+  , pureIngredient 2 "cup" "Cool Whip frozen whipped topping (from 8-oz container), thawed"+  , pureIngredient (1 / 3) "cup" "heavy whipping cream"+  , pureIngredient 1 "cup" "miniature marshmallows"+  , pureIngredientNoUnit 20 "Oreo chocolate sandwich cookies, coarsely chopped (about 2 2/3 cups)"+  , pureIngredient 0.5 "cup" "semisweet chocolate chips"+  , pureIngredientName "Water, vegetable oil and eggs called for on brownie mix box for cakelike brownies"+  ]++bettyCrockerSteps :: [Step]+bettyCrockerSteps =+  [ Step "1 Heat oven to 350\176F. Spray bottom only of 13x9-inch pan with cooking spray."+  , Step "2 Make brownie batter as directed on box for cakelike brownies. Stir in 1 cup chopped cookies. Spread in pan. Bake 22 to 25 minutes or until toothpick inserted 2 inches from side of pan comes out almost clean. Cool completely, about 1 hour."+  , Step "3 In medium bowl, beat frosting and whipped topping with spoon until well blended. Spread over top of brownie. Sprinkle marshmallows over frosting layer. Top with remaining chopped cookies."+  , Step "4 In small microwavable bowl, microwave chocolate chips and whipping cream uncovered on High 45 seconds; stir. Continue to microwave in 15-second increments until chips are melted. Stir until smooth. Drizzle over top. Refrigerate about 1 hour or until chocolate is set."+  , Step "5 Cut into 6 rows by 4 rows. Store loosely covered in refrigerator."+  ]++tasteOfHomeIngredients :: [Ingredient]+tasteOfHomeIngredients =+  [ pureIngredient 2 "cup" "diced peeled potatoes"+  , pureIngredient 1.75 "cup" "sliced carrots"+  , pureIngredient 1 "cup" "butter, cubed"+  , pureIngredient (2 / 3) "cup" "chopped onion"+  , pureIngredient 1 "cup" "all-purpose flour"+  , pureIngredient 1.75 "tsp" "salt"+  , pureIngredient 1 "tsp" "dried thyme"+  , pureIngredient 0.75 "tsp" "pepper"+  , pureIngredient 3 "cup" "chicken broth"+  , pureIngredient 1.5 "cup" "whole milk"+  , pureIngredient 4 "cup" "cubed cooked chicken"+  , pureIngredient 1 "cup" "frozen peas"+  , pureIngredient 1 "cup" "frozen corn"+  , pureIngredientNoUnit 4 "sheets refrigerated pie crust"+  ]++tasteOfHomeSteps :: [Step]+tasteOfHomeSteps =+  [ Step "Preheat oven to 425\194\176. Place potatoes and carrots in a large saucepan; add water to cover. Bring to a boil. Reduce heat; cook, covered, 8-10 minutes or until crisp-tender; drain."+  , Step "In a large skillet, heat butter over medium-high heat. Add onion; cook and stir until tender. Stir in flour and seasonings until blended. Gradually stir in broth and milk. Bring to a boil, stirring constantly; cook and stir 2 minutes or until thickened. Stir in chicken, peas, corn and potato-carrot mixture; remove from heat."+  , Step "Unroll a pie crust into each of two 9-in. pie plates; trim crusts even with rims of plates. Add chicken mixture. Unroll remaining crusts; place over filling. Trim, seal and flute edges. Cut slits in tops."+  , Step "Bake 35-40 minutes or until crust is lightly browned. Let stand 15 minutes before cutting."+  ]++rachelMansfieldIngredients :: [Ingredient]+rachelMansfieldIngredients =+  [ pureIngredient (1 / 3) "cup" "+ 2 tablespoons coconut flour"+  , pureIngredientNoUnit 3 "eggs at room temperature"+  , pureIngredient 1 "tbsp" "maple syrup"+  , pureIngredientNoUnit 3 "medium/large ripe bananas mashed"+  , pureIngredient 1 "tbsp" "melted & cooled coconut oil"+  , pureIngredient 0.5 "tsp" "of baking powder"+  , pureIngredient 0.5 "cup" "of dark chocolate chips"+  , pureIngredient 0.5 "cup" "creamy nut butter"+  , pureIngredientNoQuantity "sprinkle" "of cinnamon"+  , pureIngredientNoQuantity "splash" "of vanilla extract"+  ]++rachelMansfieldSteps :: [Step]+rachelMansfieldSteps =+  [ Step "Preheat oven to 350 and line or spray a loaf pan with parchment paper (I used 8.5 x 4.5 x 2.5)"+  , Step "Place wet ingredients in a medium mixing bowl and mix with kitchen aid"+  , Step "Once mixed well, add dry ingredients to wet ingredients and continue to mix with kitchen aid"+  , Step "Fold in dark chocolate chips once everything is mixed well (I also saved some to sprinkle on top)"+  , Step "Bake in oven for 35-45 minutes (or until ends are golden)"+  , Step "Finally, enjoy with your toppings of choice or deliciously as is"+  , Step "You can keep in fridge for about 7 days or freeze for longer!"+  ]++foodNetworkIngredients :: [Ingredient]+foodNetworkIngredients =+  [ pureIngredientNoUnit 1 "(5 to 6 pound) roasting chicken"+  , pureIngredientName "kosher salt"+  , pureIngredientName "freshly ground black pepper"+  , pureIngredientNoUnit 1 "large bunch fresh thyme, plus 20 sprigs"+  , pureIngredientNoUnit 1 "lemon, halved"+  , pureIngredientNoUnit 1 "head garlic, cut in half crosswise"+  , pureIngredient 2 "tbsp" "(1/4 stick) butter, melted"+  , pureIngredientNoUnit 1 "large yellow onion, thickly sliced"+  , pureIngredientNoUnit 4 "carrots cut into 2-inch chunks"+  , pureIngredientNoUnit 1 "bulb of fennel, tops removed, and cut into wedges"+  , pureIngredientName "olive oil"+  ]++foodNetworkSteps :: [Step]+foodNetworkSteps =+  [ Step "Preheat the oven to 425 degrees F."+  , Step "Remove the chicken giblets. Rinse the chicken inside and out. Remove any excess fat and leftover pin feathers and pat the outside dry. Liberally salt and pepper the inside of the chicken. Stuff the cavity with the bunch of thyme, both halves of lemon, and all the garlic. Brush the outside of the chicken with the butter and sprinkle again with salt and pepper. Tie the legs together with kitchen string and tuck the wing tips under the body of the chicken. Place the onions, carrots, and fennel in a roasting pan. Toss with salt, pepper, 20 sprigs of thyme, and olive oil. Spread around the bottom of the roasting pan and place the chicken on top."+  , Step "Roast the chicken for 1 1/2 hours, or until the juices run clear when you cut between a leg and thigh. Remove the chicken and vegetables to a platter and cover with aluminum foil for about 20 minutes. Slice the chicken onto a platter and serve it with the vegetables."+  ]++sallysBakingIngredients :: [Ingredient]+sallysBakingIngredients =+  [ pureIngredient 0.5 "cup" "(115g; 1 stick) unsalted butter"+  , pureIngredient 6.0 "oz" "(170g) high quality semi-sweet chocolate*"+  , pureIngredient 0.25 "cup" "(31g) all-purpose flour (spoon & leveled)"+  , pureIngredient 0.5 "cup" "(60g) confectioners\8217 sugar"+  , pureIngredientNoUnit 2 "large egg yolks*"+  , pureIngredientNoUnit 2 "large eggs"+  , pureIngredientName "optional for topping: ice cream, raspberries, and/or chocolate syrup"+  , pureIngredient 0.125 "tsp" "salt"+  ]++sallysBakingSteps :: [Step]+sallysBakingSteps =+  [ Step "Spray four 6 ounce ramekin with nonstick cooking spray and dust with cocoa powder. This ensures the cakes will seamlessly come out of the ramekins when inverted onto a plate in step 7. *Or spray half of a 12-count muffin pan and dust with cocoa powder. If baking in a muffin pan, the recipe will yield 6 cakes."+  , Step "Preheat oven to 425\194\176F (218\194\176C)."+  , Step "Coarsely chop the chocolate. Place butter into a medium heat-proof bowl, then add chopped chocolate on top. Microwave on high in 10 second increments, stirring after each until completely smooth. Set aside."+  , Step "Whisk the flour, confectioners\8217 sugar, and salt together in a small bowl. Whisk the eggs and egg yolks together until combined in another small bowl. Pour the flour mixture and eggs into the bowl of chocolate. Slowly stir everything together using a rubber spatula or wooden spoon. If there are any lumps, gently use your whisk to rid them. The batter\194 will be slightly thick."+  , Step "Spoon chocolate batter evenly into each prepared ramekin or muffin cup."+  , Step "Place ramekins onto a baking sheet and bake for 12-14 minutes until the sides appear solid and firm\8211 the tops will still look soft. *If baking in a muffin pan, the cakes only take about 8-10 minutes."+  , Step "Allow to cool for 1 minute, then cover each with an inverted plate and turn over. Use an oven mitt because those ramekins are hot! The cakes should release easily from the ramekin. *If you used a muffin pan, use a spoon to release the cakes from the pan and place each upside down on plates."+  , Step "Add toppings. Serve immediately."+  ]++cafeDelitesIngredients :: [Ingredient]+cafeDelitesIngredients =+  [ pureIngredient 14 "oz" "(400g) tomato puree (tomato sauce/Passata)"+  , pureIngredient 28 "oz" "(800g) boneless and skinless chicken thighs cut into bite-sized pieces"+  , pureIngredient 1 "tsp" "brown sugar"+  , pureIngredient 2 "tbsp" "butter"+  , pureIngredient 4 "tbsp" "Fresh cilantro or coriander to garnish"+  , pureIngredient 1.5 "tsp" "garam masala"+  , pureIngredient 2 "tsp" "garam masala"+  , pureIngredient 1.5 "tbsp" "garlic finely grated"+  , pureIngredient 1 "tbsp" "ginger"+  , pureIngredient 1.0 "tbsp" "ginger finely grated"+  , pureIngredient 1 "tsp" "ground coriander"+  , pureIngredient 1 "tsp" "ground cumin"+  , pureIngredient 1.5 "tsp" "ground cumin"+  , pureIngredient 1 "tsp" "ground red chili powder (adjust to your taste preference)"+  , pureIngredient 1 "tsp" "Kashmiri chili (optional for colour and flavour)"+  , pureIngredient 1 "tsp" "Kashmiri chili (or 1/2 teaspoon ground red chili powder)"+  , pureIngredient 1.5 "tbsp" "minced garlic"+  , pureIngredient 1.25 "cup" "of heavy or thickened cream (use evaporated milk for lower calories)"+  , pureIngredient 1 "tsp" "of salt"+  , pureIngredient 2 "tbsp" "of vegetable/canola oil"+  , pureIngredient 1 "cup" "plain yogurt"+  , pureIngredient 1 "tsp" "salt"+  , pureIngredientNoUnit 2 "small onions (or 1 large onion) finely diced"+  , pureIngredient 1 "tsp" "turmeric"+  , pureIngredient 1 "tsp" "turmeric powder"+  , pureIngredient 0.25 "cup" "water if needed"+  ]++cafeDelitesSteps :: [Step]+cafeDelitesSteps =+  [ Step "In a bowl, combine chicken with all of the ingredients for the chicken marinade; let marinate for 10 minutes to an hour (or overnight if time allows)."+  , Step "Heat oil in a large skillet or pot over medium-high heat. When sizzling, add chicken pieces in batches of two or three, making sure not to crowd the pan. Fry until browned for only 3 minutes on each side. Set aside and keep warm. (You will finish cooking the chicken in the sauce.)"+  , Step "Melt the butter in the same pan. Fry the onions until soft (about 3 minutes) while scraping up any browned bits stuck on the bottom of the pan."+  , Step "Add garlic and ginger and saut\195\169 for 1 minute until fragrant, then add garam masala, cumin, turmeric and coriander. Fry for about 20 seconds until fragrant, while stirring occasionally."+  , Step "Pour in the tomato puree, chili powders and salt. Let simmer for about 10-15 minutes, stirring occasionally until sauce thickens and becomes a deep brown red colour."+  , Step "Stir the cream and sugar through the sauce. Add the chicken and its juices back into the pan and cook for an additional 8-10 minutes until chicken is cooked through and the sauce is thick and bubbling. Pour in the water to thin out the sauce, if needed."+  , Step "Garnish with cilantro (coriander) and serve with hot\194 garlic butter rice\194 and fresh homemade\194 Naan bread!"+  ]++eatingWellIngredients :: [Ingredient]+eatingWellIngredients =+  [ pureIngredientNoUnit 1 "(5-ounce) block feta cheese"+  , pureIngredient 2 "cup" "boiling water"+  , pureIngredient 1 "tsp" "dried dill"+  , pureIngredient 3 "tbsp" "extra-virgin olive oil"+  , pureIngredient 0.25 "tsp" "ground pepper"+  , pureIngredient 0.25 "tsp" "kosher salt"+  , pureIngredientNoUnit 2 "large cloves garlic, minced"+  , pureIngredient 8 "cup" "lightly packed baby spinach (about 5 ounces)"+  , pureIngredient 8 "oz" "penne or rotini"+  ]++eatingWellSteps :: [Step]+eatingWellSteps =+  [ Step "Preheat oven to 400°F."+  , Step "Place feta in the center of a 9-by-13-inch baking dish. Bake until softened and starting to brown, about 15 minutes."+  , Step "Meanwhile, combine spinach, oil, garlic, dill, salt and pepper in a large bowl. Use your hands to massage the spinach until it's reduced in volume by half. Stir in pasta."+  , Step "After the feta has baked for 15 minutes, add the spinach and pasta mixture to the baking dish. Pour boiling water over the mixture and gently stir. Cover with foil and bake until the pasta is tender, about 18 minutes. Remove from the oven and stir. Cover and let stand for at least 3 minutes before serving."+  ]
+ test/Chez/Grater/ParserSpec.hs view
@@ -0,0 +1,61 @@+module Chez.Grater.ParserSpec where++import Chez.Grater.Internal.Prelude++import Chez.Grater.ParsedIngredients+  ( allRecipesIngredients, foodNetworkIngredients, pillsburyIngredients, rachelMansfieldIngredients+  , tasteOfHomeIngredients+  )+import Chez.Grater.Parser.Types+  ( ParsedIngredient(..), ParsedIngredientName(..), ParsedQuantity(..), ParsedUnit(..)+  )+import Data.FileEmbed (embedFile)+import System.FilePath.TH (fileRelativeToAbsoluteStr)+import Test.Hspec (Expectation, Spec, describe, it, shouldBe, shouldMatchList)+import qualified Data.Attoparsec.Text as Atto+import qualified Data.Text as Text+import qualified Data.Text.Encoding as Text++-- the module being tested+import Chez.Grater.Parser++parseStrict :: (Eq a, Show a) => a -> Atto.Parser a -> Text -> Expectation+parseStrict expected parser input = Atto.parseOnly parser input `shouldBe` Right expected++spec :: Spec+spec = describe "Parser" $ do+  describe "Examples" $ do+    it "can parse a unit" $ parseStrict (ParsedUnit "ounces") unitP " ounces "+    it "can parse a fraction" $ parseStrict (ParsedQuantity $ 1 / 3) quantityP "1/3"+    it "can parse an improper fraction" $ parseStrict (ParsedQuantity 1.5) quantityP "1-1/2"+    it "can parse an improper fraction with spaces" $ parseStrict (ParsedQuantity 1.5) quantityP "1 1/2"+    it "can parse a range" $ parseStrict (ParsedQuantity 2.5) quantityP "2-3"+    it "can parse a decimal" $ parseStrict (ParsedQuantity 0.25) quantityP "0.25"+    it "can parse a word" $ parseStrict (ParsedQuantityWord "half") quantityP "\nhalf\n"+    it "can parse an ingredient name" $ parseStrict (ParsedIngredientName "chicken") nameP " chicken"+    it "can parse \"chicken\"" $ parseStrict (ParsedIngredient (ParsedIngredientName "chicken") ParsedQuantityMissing ParsedUnitMissing) ingredientP "chicken"+    it "can parse \"one chicken\"" $ parseStrict (ParsedIngredient (ParsedIngredientName "chicken") (ParsedQuantityWord "one") ParsedUnitMissing) ingredientP "one chicken"+    it "can parse \"whole chicken\"" $ parseStrict (ParsedIngredient (ParsedIngredientName "chicken") ParsedQuantityMissing (ParsedUnit "whole")) ingredientP "whole chicken"+    it "can parse \"one whole chicken\"" $ parseStrict (ParsedIngredient (ParsedIngredientName "chicken") (ParsedQuantityWord "one") (ParsedUnit "whole")) ingredientP "one whole chicken"+    it "can parse \"1/4 cup broth\"" $ parseStrict (ParsedIngredient (ParsedIngredientName "broth") (ParsedQuantity 0.25) (ParsedUnit "cup")) ingredientP "1/4\ncup\nbroth"++  describe "Paste" $ do+    it "can parse allrecipes" $ do+      actual <- either (fail . Text.unpack) pure $ parseRawIngredients $ Text.decodeUtf8 $(embedFile =<< fileRelativeToAbsoluteStr "../../fixtures/chicken-pot-pie-allrecipes.txt")+      actual `shouldMatchList` allRecipesIngredients++    it "can parse pillsbury" $ do+      actual <- either (fail . Text.unpack) pure $ parseRawIngredients $ Text.decodeUtf8 $(embedFile =<< fileRelativeToAbsoluteStr "../../fixtures/chicken-pot-pie-pillsbury.txt")+      actual `shouldMatchList` pillsburyIngredients++    it "can parse taste of home" $ do+      actual <- either (fail . Text.unpack) pure $ parseRawIngredients $ Text.decodeUtf8 $(embedFile =<< fileRelativeToAbsoluteStr "../../fixtures/chicken-pot-pie-tasteofhome.txt")+      actual  `shouldMatchList` tasteOfHomeIngredients++    it "can parse rachel mansfield" $ do+      actual <- either (fail . Text.unpack) pure $ parseRawIngredients $ Text.decodeUtf8 $(embedFile =<< fileRelativeToAbsoluteStr "../../fixtures/banana-bread-rachelmansfield.txt")+      actual  `shouldMatchList` rachelMansfieldIngredients++    it "can parse food network" $ do+      actual <- either (fail . Text.unpack) pure $ parseRawIngredients $ Text.decodeUtf8 $(embedFile =<< fileRelativeToAbsoluteStr "../../fixtures/roast-chicken-food-network.txt")+      actual `shouldMatchList` foodNetworkIngredients
+ test/Chez/Grater/TestEnv.hs view
@@ -0,0 +1,15 @@+module Chez.Grater.TestEnv where++import Chez.Grater.Internal.Prelude++import Chez.Grater.Manager (createManager)+import Network.HTTP.Client (Manager)++data Env = Env+  { envManager :: Manager+  }++loadEnv :: IO Env+loadEnv = do+  manager <- createManager+  pure $ Env manager
+ test/Chez/GraterSpec.hs view
@@ -0,0 +1,259 @@+module Chez.GraterSpec where++import Chez.Grater.Internal.Prelude++import Chez.Grater.ParsedIngredients+  ( allRecipesIngredients, allRecipesSteps, bettyCrockerIngredients, bettyCrockerSteps+  , cafeDelitesIngredients, cafeDelitesSteps, eatingWellIngredients, eatingWellSteps+  , foodIngredients, foodNetworkIngredients, foodNetworkSteps, foodSteps, pillsburyIngredients+  , pillsburySteps, rachelMansfieldIngredients, rachelMansfieldSteps, sallysBakingIngredients+  , sallysBakingSteps, tasteOfHomeIngredients, tasteOfHomeSteps+  )+import Chez.Grater.Scraper.Types ()+import Chez.Grater.TestEnv (Env(..))+import Chez.Grater.Types+  ( Ingredient(..), IngredientName(..), RecipeName(..), Step(..), emptyQuantity+  )+import Control.Monad (when)+import Data.List (intercalate)+import Network.URI (parseURI)+import Test.Hspec+  ( Expectation, Spec, describe, expectationFailure, it, shouldBe, shouldMatchList, shouldSatisfy+  , xit+  )+import qualified Data.CaseInsensitive as CI+import qualified Data.Map.Strict as Map+import qualified Data.Text as Text++-- the module being tested+import Chez.Grater++data TestCfg = TestCfg+  { requireOneQuantityUnit :: Bool+  , allowedDuplicates :: Int+  , requiredIngredients :: Int+  , requiredSteps :: Int+  , env :: Env+  }++defaultTestCfg :: Env -> TestCfg+defaultTestCfg env = TestCfg+  { requireOneQuantityUnit = True+  , allowedDuplicates = 3+  , requiredIngredients = 5+  , requiredSteps = 1+  , env = env+  }++scrapeAndParse :: Env -> String -> String -> ([Ingredient], [Step]) -> Expectation+scrapeAndParse Env {..} url expectedName (expectedIngredients, expectedSteps) = do+  uri <- maybe (fail "Invalid URL") pure $ parseURI url+  (name, ingredients, steps, _) <- scrapeAndParseUrl envManager uri+  name `shouldBe` RecipeName (Text.pack expectedName)+  ingredients `shouldMatchList` expectedIngredients+  steps `shouldMatchList` expectedSteps++scrapeAndParseConfig :: TestCfg -> String -> Expectation+scrapeAndParseConfig TestCfg {..} url = do+  let Env {..} = env+  uri <- maybe (fail "Invalid URL") pure $ parseURI url+  (name, ingredients, steps, _) <- scrapeAndParseUrl envManager uri+  unRecipeName name `shouldSatisfy` not . Text.null+  ingredients `shouldSatisfy` (\xs -> length xs >= requiredIngredients)+  ingredients `shouldSatisfy` any hasQuantityAndUnit+  ingredients `shouldSatisfy` duplicates+  lessThanThreePrefixes ingredients+  steps `shouldSatisfy` (\xs -> length xs >= requiredSteps)+  where+    hasQuantityAndUnit Ingredient {..} = if requireOneQuantityUnit then ingredientQuantity /= emptyQuantity && ingredientUnit /= Nothing else True+    duplicates = (< allowedDuplicates) . length . filter ((> 1) . length . snd) . Map.toList . foldr (\x@Ingredient {..} -> Map.insertWith (<>) ingredientName [x]) mempty+    lessThanThreePrefixes xs = do+      let names = ingredientName <$> xs+          toStr = Text.toLower . CI.original . unIngredientName+          go x y = case name `Text.isPrefixOf` otherName && name /= otherName of+            True -> [Text.unpack name <> " is a prefix of " <> Text.unpack otherName]+            False -> []+            where+              name = toStr x+              otherName = toStr y+          prefixes = [ z | x <- names, y <- names, z <- go x y ]+      when (length prefixes >= 3) $+        expectationFailure $ intercalate ", " prefixes++spec :: Env -> Spec+spec env = describe "Scrape" $ do+  let defCfg = defaultTestCfg env+  describe "Examples" $ do+    it "can parse allrecipes" $+      scrapeAndParse+        env+        "https://www.allrecipes.com/recipe/26317/chicken-pot-pie-ix/"+        "Chicken Pot Pie IX Recipe | Allrecipes"+        (allRecipesIngredients, allRecipesSteps)++    it "can parse food" $+      scrapeAndParse+        env+        "https://www.food.com/recipe/hearty-tuscan-white-bean-soup-192495"+        "Hearty Tuscan White Bean Soup Recipe - Food.com"+        (foodIngredients, foodSteps)++    it "can parse pillsbury" $+      scrapeAndParse+        env+        "https://www.pillsbury.com/recipes/classic-chicken-pot-pie/1401d418-ac0b-4b50-ad09-c6f1243fb992"+        "Classic Chicken Pot Pie Recipe - Pillsbury.com"+        (pillsburyIngredients, pillsburySteps)++    it "can parse betty crocker" $+      scrapeAndParse+        env+        "https://www.bettycrocker.com/recipes/mississippi-mud-brownies/dff02c0e-695b-4b01-90fd-7071ddb84457"+        "Mississippi Mud Brownies Recipe - BettyCrocker.com"+        (bettyCrockerIngredients, bettyCrockerSteps)++    it "can parse taste of home" $+      scrapeAndParse+        env+        "https://www.tasteofhome.com/recipes/favorite-chicken-potpie/"+        "Favorite Chicken Potpie Recipe: How to Make It"+        (tasteOfHomeIngredients, tasteOfHomeSteps)++    it "can parse rachel mansfield" $+      scrapeAndParse+        env+        "https://rachlmansfield.com/paleo-chocolate-chip-banana-bread/"+        "Paleo Chocolate Chip Banana Bread (Nut Free) - rachLmansfield"+        (rachelMansfieldIngredients, rachelMansfieldSteps)++    it "can parse food network" $+      scrapeAndParse+        env+        "https://www.foodnetwork.com/recipes/ina-garten/perfect-roast-chicken-recipe-1940592"+        "Perfect Roast Chicken Recipe | Ina Garten | Food Network"+        (foodNetworkIngredients, foodNetworkSteps)++    it "can parse sallys baking" $+      scrapeAndParse+        env+        "https://sallysbakingaddiction.com/chocolate-lava-cakes/"+        "How to Make Chocolate Lava Cakes - Sally's Baking Addiction"+        (sallysBakingIngredients, sallysBakingSteps)++    it "can parse cafe delites" $+      scrapeAndParse+        env+        "https://cafedelites.com/chicken-tikka-masala/"+        "Chicken Tikka Masala - Cafe Delites"+        (cafeDelitesIngredients, cafeDelitesSteps)++    it "can parse eatingwell" $+      scrapeAndParse+        env+        "https://www.eatingwell.com/recipe/7898240/baked-spinach-feta-pasta/"+        "Baked Spinach & Feta Pasta Recipe | EatingWell"+        (eatingWellIngredients, eatingWellSteps)++  describe "Smoke Test" $ do+    it "handles nytimes" $ scrapeAndParseConfig defCfg "https://cooking.nytimes.com/recipes/1017256-french-onion-soup"+    it "handles yummly" $ scrapeAndParseConfig defCfg "https://www.yummly.com/recipe/Barbecue-Baked-Chicken-Legs-9073054"+    -- this one can't parse anymore+    xit "handles epicurious" $ scrapeAndParseConfig defCfg "https://www.epicurious.com/recipes/food/views/cashew-chicken"+    it "handles tasty" $ scrapeAndParseConfig defCfg "https://tasty.co/recipe/cilantro-lime-chicken-veggie-rice-meal-prep"+    it "handles delish" $ scrapeAndParseConfig defCfg "https://www.delish.com/cooking/recipe-ideas/a27469808/acai-bowl-recipe/"+    it "handles delish" $ scrapeAndParseConfig defCfg "https://www.delish.com/cooking/a36146989/vegan-tofu-grain-bowl/"+    it "handles spoonacular" $ scrapeAndParseConfig (defCfg { requiredSteps = 0 }) "https://spoonacular.com/recipes/chocolate-chip-cookie-bars-1518975"+    it "handles cookieandkate" $ scrapeAndParseConfig defCfg "https://cookieandkate.com/cream-of-broccoli-soup-recipe/"+    it "handles budgetbytes" $ scrapeAndParseConfig defCfg "https://www.budgetbytes.com/spaghetti-with-vegetable-meat-sauce/"+    it "handles daringgourmet" $ scrapeAndParseConfig defCfg "https://www.daringgourmet.com/hamburger-gravy/"+    it "handles damndelicious" $ scrapeAndParseConfig defCfg "https://damndelicious.net/2020/12/04/creamy-chicken-and-gnocchi/"+    it "handles gimmesomeoven" $ scrapeAndParseConfig defCfg "https://www.gimmesomeoven.com/best-caesar-salad/"+    it "handles recipetineats" $ scrapeAndParseConfig defCfg "https://www.recipetineats.com/orecchiette-sausage-pasta-in-creamy-tomato-sauce/"+    it "handles cookingclassy" $ scrapeAndParseConfig defCfg "https://www.cookingclassy.com/sheet-pan-shrimp-asparagus/"+    it "handles natashaskitchen" $ scrapeAndParseConfig defCfg "https://natashaskitchen.com/oven-roasted-baby-red-potatoes/"+    it "handles pinchofyum" $ scrapeAndParseConfig defCfg "https://pinchofyum.com/spicy-chicken-sweet-potato-meal-prep-magic"+    it "handles justonecookbook" $ scrapeAndParseConfig defCfg "https://www.justonecookbook.com/teriyaki-pork-donburi/"+    it "handles loveandlemons" $ scrapeAndParseConfig defCfg "https://www.loveandlemons.com/artichoke-dipping-sauce/"+    it "handles foodiecrush" $ scrapeAndParseConfig defCfg "https://www.foodiecrush.com/strawberry-and-avocado-spinach-salad-with-chicken/"+    it "handles therecipecritic" $ scrapeAndParseConfig defCfg "https://therecipecritic.com/hot-spinach-artichoke-dip/"+    it "handles ambitiouskitchen" $ scrapeAndParseConfig defCfg "https://www.ambitiouskitchen.com/coconut-chocolate-peanut-butter-protein-bars/"+    it "handles melskitchencafe" $ scrapeAndParseConfig defCfg "https://www.melskitchencafe.com/easy-chicken-enchilada-casserole/"+    it "handles halfbakedharvest" $ scrapeAndParseConfig defCfg "https://www.halfbakedharvest.com/southern-butter-biscuits/"+    it "handles simpleveganblog" $ scrapeAndParseConfig defCfg "https://simpleveganblog.com/vegan-stuffed-peppers/"+    it "handles smittenkitchen" $ scrapeAndParseConfig defCfg "https://smittenkitchen.com/2021/04/spring-asparagus-galette/"+    it "handles 101cookbooks" $ scrapeAndParseConfig defCfg "https://www.101cookbooks.com/mushroom-scallion-tartine/"+    it "handles ohsweetbasil" $ scrapeAndParseConfig defCfg "https://ohsweetbasil.com/quick-grilled-chicken-with-oregaon-recipe/"+    it "handles myfoodstory" $ scrapeAndParseConfig defCfg "https://myfoodstory.com/prawn-rava-fry/"+    it "handles easypeasyfoodie" $ scrapeAndParseConfig defCfg "https://www.easypeasyfoodie.com/coffee-and-walnut-traybake/"+    it "handles veganricha" $ scrapeAndParseConfig defCfg "https://www.veganricha.com/potato-quinoa-waffles-aloo-tikki-waffles/"+    it "handles simplydeliciousfood" $ scrapeAndParseConfig defCfg "https://simply-delicious-food.com/general-tsos-chicken/"+    it "handles lexiscleankitchen" $ scrapeAndParseConfig defCfg "https://lexiscleankitchen.com/buffalo-chicken-dip/"+    it "handles lazycatkitchen" $ scrapeAndParseConfig defCfg "https://www.lazycatkitchen.com/vegan-blondies/"+    it "handles alexandracooks" $ scrapeAndParseConfig defCfg "https://alexandracooks.com/2020/06/25/easy-homemade-pita-bread-recipe/"+    it "handles deliciouslyella" $ scrapeAndParseConfig defCfg "https://deliciouslyella.com/recipes/sweet-potato-black-bean-shepherds-pie/"+    it "handles deliciouseveryday" $ scrapeAndParseConfig defCfg "https://www.deliciouseveryday.com/thai-pumpkin-soup-recipe/"+    it "handles eatyourselfskinny" $ scrapeAndParseConfig defCfg "https://www.eatyourselfskinny.com/easy-beef-stroganoff-casserole/"+    -- times out in gh actions+    xit "handles iamafoodblog" $ scrapeAndParseConfig defCfg "https://iamafoodblog.com/smashed-brussel-sprouts/"+    it "handles naturallyella" $ scrapeAndParseConfig defCfg "https://naturallyella.com/roasted-sweet-potato-sorghum-salad/"+    it "handles glutenfreecuppatea" $ scrapeAndParseConfig defCfg "https://glutenfreecuppatea.co.uk/2021/04/13/toblerone-millionaires-shortbread-recipe/"+    it "handles thelastfoodblog" $ scrapeAndParseConfig defCfg "https://www.thelastfoodblog.com/spinach-and-ricotta-cannelloni/"+    it "handles hemsleyandhemsley" $ scrapeAndParseConfig defCfg "https://hemsleyandhemsley.com/recipe/apple-rocket-and-feta-buckwheat-galettes/"+    -- certificate is invalid+    xit "handles localmilkblog" $ scrapeAndParseConfig defCfg "https://localmilkblog.com/2019/11/turkey-buttermilk-sage-dumpling-soup.html"+    it "handles thefoodblog" $ scrapeAndParseConfig defCfg "https://www.thefoodblog.net/air-fryer-salmon-recipe/"+    it "handles onceuponafoodblog" $ scrapeAndParseConfig defCfg "https://onceuponafoodblog.com/cheesy-bacon-spring-greens/"+    it "handles anotherfoodblogger" $ scrapeAndParseConfig defCfg "https://www.anotherfoodblogger.com/recipes/meatball-marinara/#wprm-recipe-container-6560"+    it "handles brownedbutterblondie" $ scrapeAndParseConfig defCfg "https://brownedbutterblondie.com/the-best-blueberry-streusel-muffins/#tasty-recipes-7789"+    it "handles seriouseats" $ scrapeAndParseConfig defCfg "https://www.seriouseats.com/recipes/2014/03/the-best-black-bean-burger-recipe.html"+    it "handles food52" $ scrapeAndParseConfig defCfg "https://food52.com/recipes/21960-grilled-corn-with-basil-butter"+    it "handles thekitchn" $ scrapeAndParseConfig (defCfg { requiredIngredients = 3 }) "https://www.thekitchn.com/mint-julep-mocktail-recipe-23015618"+    it "handles simplyrecipes" $ scrapeAndParseConfig defCfg "https://www.simplyrecipes.com/recipes/cauliflower_steak_sandwiches_with_red_pepper_aioli/"+    it "handles minimalistbaker" $ scrapeAndParseConfig defCfg "https://minimalistbaker.com/creamy-roasted-cauliflower-soup/"+    it "handles davidlebovitz" $ scrapeAndParseConfig defCfg "https://www.davidlebovitz.com/kofta-kefta-meat-beef-lamb-plant-based-sausage-persian-recipe-spiced/"+    it "handles thepioneerwoman" $ scrapeAndParseConfig defCfg "https://www.thepioneerwoman.com/food-cooking/recipes/a35880840/fried-pickles-recipe/"+    it "handles skinnytaste" $ scrapeAndParseConfig defCfg "https://www.skinnytaste.com/strawberries-and-cream/"+    it "handles twopeasandtheirpod" $ scrapeAndParseConfig defCfg "https://www.twopeasandtheirpod.com/shaved-brussels-sprouts-salad/"+    it "handles slenderkitchen" $ scrapeAndParseConfig defCfg "https://www.slenderkitchen.com/recipe/sunday-slow-cooker-saag-paneer"+    it "handles everydayannie" $ scrapeAndParseConfig defCfg "https://everydayannie.com/2020/12/28/raspberry-cheesecake-streusel-bars/"+    -- 500 internal server error+    xit "handles notwithoutsalt" $ scrapeAndParseConfig defCfg "http://notwithoutsalt.com/brussels-sprout-green-apple-slaw-pickled-cranberries/"+    it "handles chefspencil" $ scrapeAndParseConfig (defCfg { requiredSteps = 0 }) "https://www.chefspencil.com/recipe/carrot-tarte-tatin/"+    it "handles sweetandsavorymeals" $ scrapeAndParseConfig defCfg "https://sweetandsavorymeals.com/air-fryer-eggplant/"+    it "handles eatwell101" $ scrapeAndParseConfig (defCfg { requiredSteps = 0 }) "https://www.eatwell101.com/garlic-butter-chicken-bites-asparagus-recipe"++  describe "Implicit" $ do+    describe "WPRM" $ do+      it "handles downshiftology" $ scrapeAndParseConfig defCfg "https://downshiftology.com/recipes/chicken-salad/"+      it "handles altonbrown" $ scrapeAndParseConfig (defCfg { requiredIngredients = 4 }) "https://altonbrown.com/recipes/2-note-mousse/"+      it "handles spoonforkbacon" $ scrapeAndParseConfig (defCfg { allowedDuplicates = 4 }) "https://www.spoonforkbacon.com/chicken-and-dumplings/#wprm-recipe-container-40527"+      -- uses a captcha+      xit "handles dinnerthendessert" $ scrapeAndParseConfig defCfg "https://dinnerthendessert.com/ultimate-slow-cooker-pot-roast/"+      it "handles howsweeteats" $ scrapeAndParseConfig defCfg "https://www.howsweeteats.com/2021/04/pimento-cheese-poppers/"+      it "handles browneyedbaker" $ scrapeAndParseConfig defCfg "https://www.browneyedbaker.com/cannoli/"+      it "handles steamykitchen" $ scrapeAndParseConfig defCfg "https://steamykitchen.com/59396-shrimp-chow-mein.html"+      it "handles sweetashoney" $ scrapeAndParseConfig defCfg "https://www.sweetashoney.co/keto-french-baguette-recipe/"+      it "handles thestayathomechef" $ scrapeAndParseConfig defCfg "https://thestayathomechef.com/taco-casserole/"+      it "handles cookilicious" $ scrapeAndParseConfig defCfg "https://cookilicious.com/condiments/andhra-style-peanut-tomato-chutney-recipe-for-idli-dosa/"+      it "handles ohmyveggies" $ scrapeAndParseConfig defCfg "https://ohmyveggies.com/vegan-snickerdoodles/"+      it "handles thevanillabeanblog" $ scrapeAndParseConfig defCfg "https://www.thevanillabeanblog.com/2021/03/devils-food-cake-buttercream-chocolate-ganache.html"+      it "handles sugarfreelondoner" $ scrapeAndParseConfig defCfg "https://sugarfreelondoner.com/keto-breakfast-cookies/#wprm-recipe-container-20704"+    describe "Ingredient List" $ do+      it "handles loveandoliveoil" $ scrapeAndParseConfig defCfg "https://www.loveandoliveoil.com/2021/04/stuffed-cherry-amaretti-cookies.html"+      it "handles ohsheglows" $ scrapeAndParseConfig defCfg "https://ohsheglows.com/2020/09/20/perfect-little-pumpkin-cookies-with-spiced-buttercream/"+      it "handles shutterbean" $ scrapeAndParseConfig (defCfg { requiredSteps = 0 }) "https://www.shutterbean.com/2019/polenta-cornbread/"+    describe "MV" $ do+      it "handles bakerella" $ scrapeAndParseConfig defCfg "https://www.bakerella.com/secret-ingredient-chocolate-chip-cookies/"+      it "handles mybakingaddiction" $ scrapeAndParseConfig defCfg "https://www.mybakingaddiction.com/flank-steak-tacos/"+    describe "ZL" $ do+      it "handles cnz" $ scrapeAndParseConfig defCfg "https://cnz.to/recipes/meat-charcuterie/instant-pot-ramen-style-pork-belly-recipe/"+    describe "Tasty 2" $ do+      it "handles joythebaker" $ scrapeAndParseConfig defCfg "https://joythebaker.com/2019/03/chili-and-cheese-buttery-biscuits/"+    describe "Tasty 3" $ do+      it "handles ourbestbites" $ scrapeAndParseConfig (defCfg { requiredIngredients = 1 }) "https://ourbestbites.com/greek-pasta-salad/#tasty-recipes-45657"+    describe "Tasty 4" $ do+      it "handles simple-veganista" $ scrapeAndParseConfig (defCfg { requiredIngredients = 1 }) "https://simple-veganista.com/vegan-stuffed-peppers/#tasty-recipes-28732-jump-target"+    describe "Eating Well" $ do+      it "handles bhg" $ scrapeAndParseConfig (defCfg { requireOneQuantityUnit = False }) "https://www.bhg.com/recipe/air-fried-ginger-glazed-pork-ribs/"+    describe "Simply Recipes" $ do+      it "handles simplyrecipes" $ scrapeAndParseConfig defCfg "https://www.simplyrecipes.com/recipes/easy_green_chicken_chili/"
+ test/fixtures/banana-bread-rachelmansfield.txt view
@@ -0,0 +1,10 @@+3 medium/large ripe bananas mashed+1/2 cup creamy nut butter+3 eggs at room temperature+1 tablespoon maple syrup+1 tablespoon melted & cooled coconut oil+Splash of vanilla extract+1/3 cup + 2 tablespoons coconut flour+1/2 teaspoon of baking powder+Sprinkle of cinnamon+1/2 cup of dark chocolate chips
+ test/fixtures/chicken-pot-pie-allrecipes.txt view
@@ -0,0 +1,13 @@+1 pound skinless, boneless chicken breast halves - cubed+1 cup sliced carrots+1 cup frozen green peas+½ cup sliced celery+⅓ cup butter+⅓ cup chopped onion+⅓ cup all-purpose flour+½ teaspoon salt+¼ teaspoon black pepper+¼ teaspoon celery seed+1 ¾ cups chicken broth+⅔ cup milk+2 (9 inch)  unbaked pie crusts
+ test/fixtures/chicken-pot-pie-pillsbury.txt view
@@ -0,0 +1,10 @@+1 box (14.1 oz) Refrigerated Pillsbury™ pie crusts (2 Count), softened as directed on box+1/3 cup butter or margarine+1/3 cup chopped onion+1/3 cup all-purpose flour+1/2 teaspoon salt+1/4 teaspoon pepper+1 3/4 cups Progresso™ chicken broth (from 32-oz carton)+1/2 cup milk+2 1/2 cups shredded cooked chicken or turkey+2 cups frozen mixed vegetables, thawed
+ test/fixtures/chicken-pot-pie-tasteofhome.txt view
@@ -0,0 +1,14 @@+2 cups diced peeled potatoes+1-3/4 cups sliced carrots+1 cup butter, cubed+2/3 cup chopped onion+1 cup all-purpose flour+1-3/4 teaspoons salt+1 teaspoon dried thyme+3/4 teaspoon pepper+3 cups chicken broth+1-1/2 cups whole milk+4 cups cubed cooked chicken+1 cup frozen peas+1 cup frozen corn+4 sheets refrigerated pie crust
+ test/fixtures/roast-chicken-food-network.txt view
@@ -0,0 +1,21 @@+1 (5 to 6 pound) roasting chicken++Kosher salt++Freshly ground black pepper++1 large bunch fresh thyme, plus 20 sprigs++1 lemon, halved++1 head garlic, cut in half crosswise++2 tablespoons (1/4 stick) butter, melted++1 large yellow onion, thickly sliced++4 carrots cut into 2-inch chunks++1 bulb of fennel, tops removed, and cut into wedges++Olive oil
+ test/main.hs view
@@ -0,0 +1,13 @@+import Prelude++import Chez.Grater.TestEnv (loadEnv)+import Test.Hspec (hspec)+import qualified Chez.Grater.ParserSpec+import qualified Chez.GraterSpec++main :: IO ()+main = do+  env <- loadEnv+  hspec $ do+    Chez.Grater.ParserSpec.spec+    Chez.GraterSpec.spec env