packages feed

follow (empty) → 0.1.0.0

raw patch · 38 files changed

+2724/−0 lines, 38 filesdep +HandsomeSoupdep +aesondep +basesetup-changed

Dependencies added: HandsomeSoup, aeson, base, bytestring, exceptions, feed, follow, hspec, hxt, req, text, time, transformers, unordered-containers, yaml

Files

+ ChangeLog.md view
@@ -0,0 +1,8 @@+# Change Log+All notable changes to this project will be documented in this file.++The format is based on [Keep a Changelog](http://keepachangelog.com/) +and this project adheres to [Semantic Versioning](http://semver.org/).++## [0.0.1] - 2018-09-14+- Initial release
+ LICENSE view
@@ -0,0 +1,30 @@+Copyright Author name here (c) 2018++All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++    * Redistributions of source code must retain the above copyright+      notice, this list of conditions and the following disclaimer.++    * Redistributions in binary form must reproduce the above+      copyright notice, this list of conditions and the following+      disclaimer in the documentation and/or other materials provided+      with the distribution.++    * Neither the name of Author name here nor the names of other+      contributors may be used to endorse or promote products derived+      from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ README.md view
@@ -0,0 +1,220 @@+# Follow++`Follow` is a Haskell library to build recipes which allow you to+follow the content published about any subject you are interested.++Here bellow you have a quick tutorial you can follow.  Just run the+snippets of code in the repl.++```haskell+:set -XOverloadedStrings+import Follow+import Data.Time (LocalTime)+import Control.Monad (join)+import qualified Data.Text.IO as T (writeFile)+import Data.Yaml (decodeFileThrough)+```++## Subject++A subject is just a bunch of information about what is being+followed. It consists of a title, a description and a list of tags,++```haskell+haskell =+  Subject+    { sTitle = "Haskell"+    , sDescription = "Some resources about Haskell"+    , sTags = ["haskell", "programming"]+    }+```++## Directory++A directory is just a subject and a list of entries.++An entry is an item meant to contain an URI with content relative to+the associated subject along with associated information.++```haskell+manualDirectory =+  Directory+    { dSubject = haskell+    , dEntries =+        [ Entry+            { eURI =+                Just "https://bartoszmilewski.com/2013/06/19/basics-of-haskell/"+            , eGUID = Just "basics-of-haskell"+            , eTitle = Just "Basics of Haskell"+            , eDescription = Just "Introductory material for Haskell"+            , eAuthor = Just "Bartosz Milewski"+            , ePublishDate = Just (read "2013-06-19 14:14:00" :: LocalTime)+            }+        ]+    }+```++## Fetchers++Of course, building list of entries by hand is not very+useful. Fetchers are functions which usually reach the outside world+to return a list of entries and which can throw an error.++Any fetcher can be used, but `Follow` tries to ship with common+ones. Right now there are two fetchers available:++- [Feed](src/Follow/Fetchers/Feed.hs): Take entries from a RSS or Atom feed.+- [Web Scraping](src/Follow/Fetchers/WebScraping.hs): Take entries+  scraping the HTML of a web page.++The function `directoryFromFetched` can be used to glue a subject with+some fetched content:++```haskell+import qualified Follow.Fetchers.Feed as Feed++directory =+  directoryFromFetched (Feed.fetch "https://bartoszmilewski.com/feed/") haskell+```++## Middlewares++Fetched content may need some further processing in order to fit what+is actually desired. A middleware is a function which transforms a+directory into another directory, allowing us to do any kind of+transformation.++The aim of `Follow` is to provide some common middlewares. For now,+there are these ones:++- [Filter](src/Follow/Middlewares/Filter.hs): Filter entries according+  some predicate.+- [Sort](src/Follow/Middlewares/Sort.hs): Sort entries.+- [Decode](src/Follow/Middlewares/Decode.hs): Decodes entries from+  UTF8 or other encodings.++```haskell+import qualified Follow.Middlewares.Sort as Sort++sortedDirectory =+  Sort.apply (Sort.byGetter eTitle) <$> directory+```++## Digesters++Once you have your distillate content, you need some way to consume+it. A `Digester` is a function which transforms a directory into+anything that can be consumed by an end user.++As before, `Follow` aims to provide useful ones out of the box. Right+now the following are available:++- [Simple Text](src/Follow/Digesters/SimpleText.hs): Simple textual+  representation of the directory.+- [Pocket](src/Follow/Digesters/Pocket.hs): Send fetched links to+  [Pocket](https://getpocket.com).++```haskell+import qualified Follow.Digesters.SimpleText as SimpleText++content = SimpleText.digest <$> sortedDirectory+```++Now, for example, you are ready to save the content to a file:++```haskell+join $ T.writeFile "/your/path/haskell.txt" <$> content+```++## Recipes: Combining sources and middlewares++Content is not limited to be fetched from a single source. Instead, a+directory can be built merging the entries fetched from different+sources. Also, the stack of middlewares to be applied to each source can be+given in a single shot.++This whole process specification is called a `Recipe`, and it contains+all the information needed to follow a subject.++To build the recipe you need to provide three fields:++- The subject being followed.+- A list of two field tuples where:+  - First field is some fetched content.+  - Second field is a list of middlewares to apply to the fetched content in the first field.+- A list of middlewares to apply to the directory resulted after applying the list of fetched/middlewares.++```haskell+haskellRecipe =+  Recipe+    { rSubject = haskell+    , rSteps =+        [ ( Feed.fetch "https://bartoszmilewski.com/feed/"+          , [Sort.apply (Sort.byGetter eTitle)])+        , (Feed.fetch "https://planet.haskell.org/rss20.xml", [])+        ]+    , rMiddlewares = []+    }+```++You can combine the function `directoryFromRecipe` and some digester+to quickly consume a recipe:++```haskell+SimpleText.digest <$> directoryFromRecipe haskellRecipe+```++## Collecting recipes++One nice thing in Follow is that you don't need to create the recipes+programmatically each time you need them. Instead, you can store them+in a [YAML](https://en.wikipedia.org/wiki/YAML) file and just parse+them when you need.++For example, the previous recipe can be represented in a file+`recipe.yml` as the following:++```yaml+subject:+  title: Haskell+  description: Some resources about Haskell+  tags: [haskell, programming]+steps:+  -+    - type: feed+      options:+        url: "https://bartoszmilewski.com/feed/"+    -+      - type: sort+        options:+          function:+            type: by_field+            options:+              field: title+middlewares: []+```++You can use now decode functions in+[`Data.Yaml`](https://hackage.haskell.org/package/yaml) to get the+recipe back:++```haskell+recipe' <- decodeFileThrow "/your/path/recipe.yml" :: IO (Recipe IO)+directory' = directoryFromRecipe recipe'+```++Look at [`src/Follow/Parser.hs`](src/Follow/Parser.hs) for details about+encoding each kind of fetcher and middleware.++## Contributing++Bug reports and pull requests are welcome on GitHub at+https://github.com/waiting-for-dev/follow. This project is+intended to be a safe, welcoming space for collaboration, and+contributors are expected to adhere to the [Contributor+Covenant](http://contributor-covenant.org) code of conduct.++## License++The package is available as open source under the terms of the [GNU+LGPLv3 License](http://opensource.org/licenses/LGPL-3.0).
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ app/PocketAuth.hs view
@@ -0,0 +1,26 @@+{-|+Description: Executable to authorize Follow into Pocket.++"Follow.Digesters.Pocket" needs the user to grant privileges to our+app so that we can add articles to her account.++Run it with: @stack exec follow_pocket_auth@.++After running it, an access token is printed to stdout. This can be+used to run the pocket digester (see `Follow.Digesters.Pocket.digest`.+-}+module Main where++import           Data.Text               (Text)+import qualified Data.Text               as T (concat, unpack)+import           Follow.Digesters.Pocket++main :: IO ()+main = do+  (rToken, url) <- requestTokenStep+  putStrLn "Please, press ENTER after visiting:"+  putStrLn $ T.unpack url+  getChar+  aToken <- accessTokenStep rToken+  putStrLn "Your access token is:"+  putStrLn $ T.unpack aToken
+ follow.cabal view
@@ -0,0 +1,131 @@+-- This file has been generated from package.yaml by hpack version 0.28.2.+--+-- see: https://github.com/sol/hpack+--+-- hash: 0f00896fbd211fccddea5848a59a319724267d6c8630594170bac97733a1d85c++name:           follow+version:        0.1.0.0+synopsis:       Haskell library to follow content published on any subject.+description:    Please, see the README on GitHub at <https://github.com/waiting-for-dev/follow#readme>+category:       Web+homepage:       https://github.com/waiting-for-dev/follow#readme+bug-reports:    https://github.com/waiting-for-dev/follow/issues+author:         Marc Busqué Pérez+maintainer:     marc@lamarciana.com+copyright:      2018 Marc Busqué Pérez+license:        LGPL-3+license-file:   LICENSE+build-type:     Simple+cabal-version:  >= 1.10+extra-source-files:+    ChangeLog.md+    README.md+data-files:+    test/Fixtures/rss.xml+    test/Fixtures/webscraping.html++source-repository head+  type: git+  location: https://github.com/waiting-for-dev/follow++library+  exposed-modules:+      Data.Time.Follow+      Follow+      Follow.Digesters.Pocket+      Follow.Digesters.Pocket.Auth+      Follow.Digesters.SimpleText+      Follow.Fetchers.Feed+      Follow.Fetchers.Feed.Internal+      Follow.Fetchers.WebScraping+      Follow.Fetchers.WebScraping.Internal+      Follow.Middlewares.Decode+      Follow.Middlewares.Filter+      Follow.Middlewares.Filter.Internal+      Follow.Middlewares.Sort+      Follow.Parser+      Follow.Types+      HTTP.Follow+  other-modules:+      Paths_follow+  hs-source-dirs:+      src+  build-depends:+      HandsomeSoup+    , aeson+    , base >=4.7 && <5+    , bytestring+    , exceptions+    , feed+    , hxt+    , req+    , text+    , time+    , transformers+    , unordered-containers+    , yaml+  default-language: Haskell2010++executable follow_pocket_auth+  main-is: PocketAuth.hs+  other-modules:+      Paths_follow+  hs-source-dirs:+      app+  ghc-options: -threaded -rtsopts -with-rtsopts=-N+  build-depends:+      HandsomeSoup+    , aeson+    , base >=4.7 && <5+    , bytestring+    , exceptions+    , feed+    , follow+    , hxt+    , req+    , text+    , time+    , transformers+    , unordered-containers+    , yaml+  default-language: Haskell2010++test-suite follow-test+  type: exitcode-stdio-1.0+  main-is: Spec.hs+  other-modules:+      Data.Time.FollowSpec+      Follow.Digesters.SimpleTextSpec+      Follow.Fetchers.Feed.InternalSpec+      Follow.Fetchers.FeedSpec+      Follow.Fetchers.WebScraping.InternalSpec+      Follow.Fetchers.WebScrapingSpec+      Follow.Middlewares.Filter.InternalSpec+      Follow.Middlewares.FilterSpec+      Follow.Middlewares.SortSpec+      Follow.ParserSpec+      FollowSpec+      Helpers.EndPointFixtures+      Helpers.Factories+      Paths_follow+  hs-source-dirs:+      test+  ghc-options: -threaded -rtsopts -with-rtsopts=-N+  build-depends:+      HandsomeSoup+    , aeson+    , base >=4.7 && <5+    , bytestring+    , exceptions+    , feed+    , follow+    , hspec+    , hxt+    , req+    , text+    , time+    , transformers+    , unordered-containers+    , yaml+  default-language: Haskell2010
+ src/Data/Time/Follow.hs view
@@ -0,0 +1,40 @@+{- |+Description: Date and time utilities used in the library.++This module exposes functions related with date and time which are+used in other parts of the library.+-}+module Data.Time.Follow+  ( parseTimeGuess+  ) where++import           Control.Applicative ((<|>))+import           Data.Text           (Text)+import qualified Data.Text           as T (unpack)+import           Data.Time           (ParseTime, defaultTimeLocale, formatTime,+                                      iso8601DateFormat, parseTimeM,+                                      rfc822DateFormat)++-- | Parses a date guessing from a list of accepted formats. Right now+-- these are:+--   - iso8601: 2018-01-01+--   - iso8601 with time: 2018-01-01T12:35+--   - iso8601 with time and timezone: 2018-01-01T12:35+0100+--   - iso8601 with fractions of seconds: 2018-01-01T12:35.001000+--   - iso8601 with fractions of seconds and timezone:+--   2018-01-01T12:35.001000+0100+--   - rfc822: Mon, 01 Jan 2018 12:35:00 +0100+--   - epoch: 1514810100+parseTimeGuess :: ParseTime t => Text -> Maybe t+parseTimeGuess string =+  let formats =+        [ iso8601DateFormat Nothing+        , iso8601DateFormat $ Just "%H:%M:%S%Z"+        , iso8601DateFormat $ Just "%H:%M:%S%Q%Z"+        , rfc822DateFormat+        , "%s"+        ]+      results = parseTime' (T.unpack string) <$> formats+   in foldl1 (<|>) results+  where+    parseTime' = flip $ parseTimeM True defaultTimeLocale
+ src/Follow.hs view
@@ -0,0 +1,65 @@+{- |+Description: Defines and reexports application level components.++Read the [README](https://github.com/waiting-for-dev/follow#readme)+file for a description of `Follow` application.++Importing this module makes all application scope components+available. In addition, you probably will need to import individual+fetchers, middlewares and digesters.+-}+module Follow+  ( Recipe(..)+  , Subject(..)+  , Entry(..)+  , Fetched+  , Directory(..)+  , Step+  , Middleware+  , Digester+  , directoryFromRecipe+  , directoryFromFetched+  , applyMiddlewares+  , applySteps+  , emptyDirectory+  , mergeEntries+  ) where++import           Control.Monad.Catch (MonadThrow)+import           Data.Foldable       (foldlM)+import           Data.List           (nub)+import           Follow.Types        (Digester, Directory (..), Entry (..),+                                      Fetched, Middleware, Recipe (..), Step,+                                      Subject (..))++-- | Builds a directory from the specification stored in a recipe+directoryFromRecipe :: MonadThrow m => Recipe m -> m Directory+directoryFromRecipe recipe =+  let Recipe {rSubject = subject, rSteps = steps, rMiddlewares = middlewares} =+        recipe+   in applyMiddlewares middlewares <$> applySteps (emptyDirectory subject) steps++-- | Helper to build a directory from a subject and a list of fetched+-- entries.+directoryFromFetched :: MonadThrow m => m [Entry] -> Subject -> m Directory+directoryFromFetched fetched header = Directory header <$> fetched++-- | Applies, from left to right, given middlewares to the directory.+applyMiddlewares :: [Middleware] -> Directory -> Directory+applyMiddlewares = flip $ foldl (flip ($))++-- | Applies, from left to right, a list of steps to given directory.+applySteps :: MonadThrow m => Directory -> [Step m] -> m Directory+applySteps = foldlM applyStep+  where+    applyStep directory (fetched, middlewares) =+      applyMiddlewares middlewares . mergeEntries directory <$> fetched++-- | Creates a directory with given subject and no entries.+emptyDirectory :: Subject -> Directory+emptyDirectory s = Directory {dSubject = s, dEntries = mempty}++-- | Merges a list of entries into a Directory, keeping only first+-- appearance of duplicates.+mergeEntries :: Directory -> [Entry] -> Directory+mergeEntries d e = d {dEntries = nub $ dEntries d ++ e}
+ src/Follow/Digesters/Pocket.hs view
@@ -0,0 +1,56 @@+{-# LANGUAGE OverloadedStrings #-}++{-|+Description: Send entries in the directory to Pocket.++This module contains the definition of a digester which sends all the+entries within the directory to Pocket (https://getpocket.com)+application.++In order to be able to send the information to Pocket, first the user+needs to authorize Follow application. For this reason, this module+also exports functions which deal with the authentication+process. However, as a user, the most straighforward way to+authenticate is to execute:++@+stack exec follow_pocket_auth+@++After the process is finished, the previous command will print an+access token which needs to be provided to the `digest` method.++-}+module Follow.Digesters.Pocket+  ( requestTokenStep+  , accessTokenStep+  , digest+  ) where++import           Control.Monad.Catch          (MonadThrow)+import           Data.Aeson                   (Value, object, (.=))+import qualified Data.HashMap.Strict          as HS+import           Data.Maybe                   (fromJust, isJust)+import           Data.Text                    (Text)+import           Follow.Digesters.Pocket.Auth+import           Follow.Types                 (Digester, Directory (..),+                                               Entry (..))+import           Network.HTTP.Req             (MonadHttp, https, (/:))++digest ::+     (MonadHttp m, MonadThrow m) => Text -> Digester (m (HS.HashMap Text Value))+digest token directory = jsonPostResponseBody url body jsonHeaders+  where+    url = https "getpocket.com" /: "v3" /: "send"+    body =+      object+        [ "consumer_key" .= consumerKey+        , "access_token" .= token+        , "actions" .=+          (entryToAction <$> filter (isJust . eURI) (dEntries directory))+        ]+    entryToAction entry =+      object+        [ "action" .= ("add" :: Text)+        , "url" .= ((fromJust $ eURI entry) :: Text)+        ]
+ src/Follow/Digesters/Pocket/Auth.hs view
@@ -0,0 +1,113 @@+{-# LANGUAGE DataKinds         #-}+{-# LANGUAGE DeriveAnyClass    #-}+{-# LANGUAGE OverloadedStrings #-}++{-|+Description: Authentication logic for Pocket.++This module contains functions to deal with the authentication process+of Pocket. Read https://getpocket.com/developer/docs/authentication to+have all the details of this process.++A user friendly method to interact with the authentication process is+through the command:++@+stack exec follow_pocket_auth+@+-}+module Follow.Digesters.Pocket.Auth+  ( requestTokenStep+  , accessTokenStep+  , jsonPostResponseBody+  , jsonHeaders+  , consumerKey+  ) where++import           Control.Monad.Catch (Exception, MonadThrow, throwM)+import           Data.Aeson          (ToJSON, Value (..), object, (.=))+import qualified Data.HashMap.Strict as HS+import           Data.Text           (Text)+import qualified Data.Text           as T (concat)+import           GHC.Generics        (Generic)+import           HTTP.Follow+import           Network.HTTP.Req    (MonadHttp, Option, POST (..),+                                      ReqBodyJson (..), Url, header, https,+                                      jsonResponse, req, responseBody, (/:))++-- | Errors authenticating in pocket API.+data PocketError+  = PocketTokenNotFound+  | PocketTokenDecodingError+  deriving (Eq, Show, Exception)++-- | Consumer key for the Follow application in Pocket.+consumerKey :: Text+consumerKey = "80296-6e350545b4382d839b3aa5df"++-- | First auth step. Asks Pocket for a request token. It returns the+-- token itself and the URL that the user must visit in order to grant+-- access before proceeding to the second step.+requestTokenStep :: (MonadThrow m, MonadHttp m) => m (Text, Text)+requestTokenStep = do+  token <- getRequestToken+  processTokenStep token $ \token ->+    ( token+    , T.concat+        [ "https://getpocket.com/auth/authorize?request_token="+        , token+        , "&redirect_uri="+        , "https://getpocket.com"+        ])++-- | Second auth step. Once a user has granted permission to Follow,+-- it exchanges a request token for an access token. The access token+-- is what it needs to be used in any other Pocket API call.+accessTokenStep :: (MonadHttp m, MonadThrow m) => Text -> m Text+accessTokenStep rToken = do+  token <- getAccessToken rToken+  processTokenStep token id++processTokenStep ::+     (MonadHttp m, MonadThrow m) => Maybe Value -> (Text -> a) -> m a+processTokenStep token f =+  case token of+    Nothing             -> throwM PocketTokenNotFound+    Just (String token) -> return $ f token+    Just _              -> throwM PocketTokenDecodingError++-- | Gets the JSON response body of a POST request.+jsonPostResponseBody ::+     (ToJSON b, MonadHttp m, MonadThrow m)+  => Url scheme+  -> b+  -> Option scheme+  -> m (HS.HashMap Text Value)+jsonPostResponseBody url body options =+  responseBody <$> req POST url (ReqBodyJson body) jsonResponse options++-- | Pocket expected headers for a JSON interaction.+jsonHeaders =+  header "Content-Type" "application/json; charset=UTF-8" <>+  header "X-Accept" "application/json"++getRequestToken :: (MonadHttp m, MonadThrow m) => m (Maybe Value)+getRequestToken =+  parseValue "code" <$> jsonPostResponseBody url body jsonHeaders+  where+    url = https "getpocket.com" /: "v3" /: "oauth" /: "request"+    body =+      object+        [ "consumer_key" .= String consumerKey+        , "redirect_uri" .= String "https://getpocket.com"+        ]++getAccessToken :: (MonadHttp m, MonadThrow m) => Text -> m (Maybe Value)+getAccessToken rToken =+  parseValue "access_token" <$> jsonPostResponseBody url body jsonHeaders+  where+    url = https "getpocket.com" /: "v3" /: "oauth" /: "authorize"+    body = object ["consumer_key" .= String consumerKey, "code" .= rToken]++parseValue :: Text -> HS.HashMap Text Value -> Maybe Value+parseValue = HS.lookup
+ src/Follow/Digesters/SimpleText.hs view
@@ -0,0 +1,65 @@+{-# LANGUAGE OverloadedStrings #-}++{-|+Description: Digests a subject into a textual representation.++This module defines a digester which justs takes the directory and+transforms it into a text with a minimal format.+-}+module Follow.Digesters.SimpleText+  ( digest+  ) where++import           Data.Maybe   (fromMaybe)+import           Data.Text    (Text)+import qualified Data.Text    as T (concat, intercalate, pack, replicate)+import           Follow.Types (Digester, Directory (..), Entry (..),+                               Subject (..))++-- | The digester strategy to transform a directory into a simple text+-- representation.+digest :: Digester Text+digest directory =+  let header = dSubject directory+      headerTitle = sTitle header+      headerDescription = sDescription header+      headerTags = sTags header+      entries = dEntries directory+   in T.intercalate+        emptyLine+        [ headerTitle+        , headerDescription+        , T.intercalate "," headerTags+        , headerSeparator+        , digestEntries entries+        ]+  where+    headerSeparator :: Text+    headerSeparator = T.replicate 80 "#"++digestEntries :: [Entry] -> Text+digestEntries entries = T.intercalate entrySeparator $ map digestEntry entries+  where+    entrySeparator :: Text+    entrySeparator = T.concat ["\n", T.replicate 80 "-", "\n"]+    digestEntryItem :: Maybe Text -> Text+    digestEntryItem = fromMaybe ""+    digestEntry :: Entry -> Text+    digestEntry entry =+      let entryURI = eURI entry+          entryTitle = eTitle entry+          entryDescription = eDescription entry+          entryAuthor = eAuthor entry+          entryPublishDate = ePublishDate entry+       in T.intercalate+            emptyLine+            (digestEntryItem <$>+             [ entryURI+             , entryTitle+             , T.pack . show <$> entryPublishDate+             , entryDescription+             , entryAuthor+             ])++emptyLine :: Text+emptyLine = "\n\n\n"
+ src/Follow/Fetchers/Feed.hs view
@@ -0,0 +1,26 @@+{-|+Description: Defines a fetcher strategy to take entries from a RSS or+             Atom feed.++This module is the namespace to define a fetcher strategy which takes+entries from a URL pointing to a RSS or Atom feed.+-}+module Follow.Fetchers.Feed+  ( fetch+  , FeedError(..)+  ) where++import           Control.Monad.Catch           (MonadThrow)+import qualified Data.ByteString               as BS (ByteString)+import           Follow.Fetchers.Feed.Internal+import           Follow.Types                  (Fetched)+import           HTTP.Follow                   (getResponseBody, parseUrl)+import qualified Network.HTTP.Req              as R (MonadHttp)++-- | The fetcher strategy.+fetch :: (R.MonadHttp m, MonadThrow m) => BS.ByteString -> Fetched m+fetch url = do+  url' <- parseUrl url+  response <- getResponseBody url'+  feed <- parseFeed response+  return $ feedToEntries feed
+ src/Follow/Fetchers/Feed/Internal.hs view
@@ -0,0 +1,49 @@+{-# LANGUAGE DeriveAnyClass #-}++{-|+Description: Wiring for the feed fecther strategy.++This module defains low level helper functions to be used by the feed+fetcher strategy.+-}+module Follow.Fetchers.Feed.Internal+  ( feedToEntries+  , parseFeed+  , FeedError(..)+  ) where++import           Control.Monad.Catch  (Exception, MonadThrow, throwM)+import qualified Data.ByteString.Lazy as BL (ByteString)+import           Data.Time            (UTCTime)+import           Data.Time.Follow     (parseTimeGuess)+import           Follow.Types         (Entry (..))+import           Text.Feed.Import     as F (parseFeedSource)+import           Text.Feed.Query      as F (feedItems, getItemAuthor,+                                            getItemDescription, getItemId,+                                            getItemLink,+                                            getItemPublishDateString,+                                            getItemTitle)+import           Text.Feed.Types      as F (Feed, Item)++-- | Errors when parsing a feed+data FeedError =+  FeedWrongFormat+  deriving (Show, Eq, Exception)++-- | Parses a feed type from a textual representation.+parseFeed :: MonadThrow m => BL.ByteString -> m F.Feed+parseFeed body = maybe (throwM FeedWrongFormat) return (F.parseFeedSource body)++-- | Transforms a feed to a list of entries.+feedToEntries :: F.Feed -> [Entry]+feedToEntries feed = itemToEntry <$> F.feedItems feed+  where+    itemToEntry :: F.Item -> Entry+    itemToEntry item =+      Entry+        (F.getItemLink item)+        (snd <$> F.getItemId item)+        (F.getItemTitle item)+        (F.getItemDescription item)+        (F.getItemAuthor item)+        (parseTimeGuess =<< F.getItemPublishDateString item)
+ src/Follow/Fetchers/WebScraping.hs view
@@ -0,0 +1,69 @@+{-|+Description: Fetcher strategy to scrap info from an HTML URI.++This module is the namespace to define a fetcher strategy which+generates entries scraping the contents requested to an HTML URI.++A `Selector` must be given in order to know from where the information+for each entry field should be taken.++Be aware that scraping an HTML page has very few consistency+warantee. So, depending on the page structure and the selector you+give, you could end up with 5 URIs, 4 titles and 6 descriptions. Keep+in mind that the leading and limiting asset are the URIs, so in the+previous scenario one `Nothing` title would be added and one+description would be discarded.++Here it is an example:++@+import Follow+import Follow.Fetchers.WebScraping++selector :: Selector+selector = Selector {+    selURI = Just $ Attr ".title a" "href"+  , selGUID = Just $ Attr ".title a" "href"+  , selTitle = Just $ InnerText ".title a"+  , selDescription = Just $ InnerText ".description"+  , selAuthor = Just $ InnerText ".author"+  , selPublishDate = Nothing+}++result :: IO [Entry]+result = fetch ("http://an_url.com", selector)+@+-}+module Follow.Fetchers.WebScraping+  ( fetch+  , Selector(..)+  , SelectorItem(..)+  , CSSSelector+  , HTMLAttribute+  ) where++import           Control.Monad.Catch                  (MonadThrow)+import           Control.Monad.IO.Class               (MonadIO, liftIO)+import qualified Data.ByteString                      as BS (ByteString)+import qualified Data.ByteString.Lazy                 as BL (ByteString)+import           Data.Text                            (Text)+import           Follow.Fetchers.WebScraping.Internal (CSSSelector,+                                                       HTMLAttribute,+                                                       Selector (..),+                                                       SelectorItem (..),+                                                       htmlToEntries)+import           Follow.Types                         (Fetched)+import           HTTP.Follow                          (getResponseBody,+                                                       parseUrl)+import           Network.HTTP.Req                     (MonadHttp)++-- | Fetches entries from given url using specified selectors.+fetch ::+     (MonadThrow m, MonadIO m, MonadHttp m)+  => BS.ByteString+  -> Selector+  -> Fetched m+fetch url selector = do+  url' <- parseUrl url+  response <- getResponseBody url'+  liftIO $ htmlToEntries response selector
+ src/Follow/Fetchers/WebScraping/Internal.hs view
@@ -0,0 +1,125 @@+{-|+Description: Internals for the web scraping fetching strategy.++This module contains the inner wiring for the scraping fetching+strategy.+-}+module Follow.Fetchers.WebScraping.Internal+  ( Selector(..)+  , SelectorItem(..)+  , CSSSelector+  , HTMLAttribute+  , htmlToEntries+  ) where++import           Control.Monad              (join)+import qualified Data.ByteString            as BS (ByteString)+import qualified Data.ByteString.Lazy       as BL (ByteString)+import qualified Data.ByteString.Lazy.Char8 as BL (unpack)+import           Data.List                  (uncons)+import           Data.Text                  (Text)+import qualified Data.Text                  as T (pack, unpack)+import           Data.Time                  (UTCTime)+import           Data.Time.Follow           (parseTimeGuess)+import           Data.Tree.NTree.TypeDefs   (NTree)+import           Follow.Types               (Entry (..))+import           Text.HandsomeSoup          (css, parseHtml, (!))+import           Text.XML.HXT.Core          (IOSArrow, XNode, getChildren,+                                             getText, isText, runX, (>>>))+import           Text.XML.HXT.DOM.TypeDefs  (XmlTree)++-- | Data type with the selectors to use when scraping each `Entry`+-- item.+data Selector = Selector+  { selURI         :: Maybe SelectorItem+  , selGUID        :: Maybe SelectorItem+  , selTitle       :: Maybe SelectorItem+  , selDescription :: Maybe SelectorItem+  , selAuthor      :: Maybe SelectorItem+  , selPublishDate :: Maybe SelectorItem+  } deriving (Eq, Show)++-- | Selector to use when scraping an `Entry` item.+data SelectorItem+  = InnerText CSSSelector -- ^ This selector will take the inner text+  -- immediately descendant of a tag selected with given css selector.+  | Attr CSSSelector+         HTMLAttribute -- ^ This selector will take the value of given+  -- argument in the tag matched by given css selector.+  deriving (Eq, Show)++-- | A CSS2 selector.+type CSSSelector = Text++-- | An HTML attribute name.+type HTMLAttribute = Text++type Doc b = IOSArrow b (NTree XNode)++type SLinks = [Text]++type SGuids = [Text]++type STitles = [Text]++type SDescriptions = [Text]++type SAuthors = [Text]++type SPublishDates = [Text]++-- | Converts a bytestring with HTML content to a list of entries,+-- scraping entry items using given selector. The return type is+-- wrapped within an IO because of the underlying vendor API.+htmlToEntries :: BL.ByteString -> Selector -> IO [Entry]+htmlToEntries html selector = do+  links <- scrap doc (selURI selector)+  guids <- scrap doc (selGUID selector)+  titles <- scrap doc (selTitle selector)+  descriptions <- scrap doc (selDescription selector)+  authors <- scrap doc (selAuthor selector)+  publishDates <- scrap doc (selPublishDate selector)+  return $ entriesFromItems links guids titles descriptions authors publishDates+  where+    doc = parseHtml $ BL.unpack html++entriesFromItems ::+     SLinks+  -> SGuids+  -> STitles+  -> SDescriptions+  -> SAuthors+  -> SPublishDates+  -> [Entry]+entriesFromItems [] _guids _titles _descriptions _authors _publishDates = []+entriesFromItems (u:us) guids titles descriptions authors publishDates =+  let (g, gs) = extract guids id+      (t, ts) = extract titles id+      (d, ds) = extract descriptions id+      (a, as) = extract authors id+      (p, ps) = extract publishDates parseTimeGuess+   in Entry+        { eURI = Just u+        , eGUID = g+        , eTitle = t+        , eDescription = d+        , eAuthor = a+        , ePublishDate = join p+        } :+      entriesFromItems us gs ts ds as ps+  where+    extract :: [a] -> (a -> b) -> (Maybe b, [a])+    extract l f =+      case uncons l of+        Nothing     -> (Nothing, [])+        Just (h, t) -> (Just (f h), t)++scrap :: Doc XmlTree -> Maybe SelectorItem -> IO [Text]+scrap _doc Nothing = return []+scrap doc (Just (InnerText selector)) =+  let items =+        doc >>> css (T.unpack selector) >>> getChildren >>> isText >>> getText+   in fmap T.pack <$> runX items+scrap doc (Just (Attr selector attr)) =+  let items = doc >>> css (T.unpack selector) ! T.unpack attr+   in fmap T.pack <$> runX items
+ src/Follow/Middlewares/Decode.hs view
@@ -0,0 +1,57 @@+{-|+Description: Decodes entry fields to some encoding.++Middleware to decode entry fields, usually fetched from some external+source, from a given encoding different to default Latin1.++@+import Follow.Middlewares.Decode++apply UTF8 directory+@+-}+module Follow.Middlewares.Decode+  ( apply+  , Encoding(..)+  ) where++import qualified Data.ByteString.Char8    as BSC (pack)+import qualified Data.Text                as T (unpack)+import           Data.Text.Encoding       (decodeUtf16BEWith, decodeUtf16LEWith,+                                           decodeUtf32BEWith, decodeUtf32LEWith,+                                           decodeUtf8With)+import           Data.Text.Encoding.Error (lenientDecode)+import           Follow.Types             (Directory (..), Entry (..),+                                           Middleware)++-- | Supported encodings.+data Encoding+  = UTF8+  | UTF16LE+  | UTF16BE+  | UTF32LE+  | UTF32BE+  deriving (Eq, Show)++-- | Middleware operation. Give it a `Encoding` and get back the+-- decoded directory.+apply :: Encoding -> Middleware+apply encoding directory =+  directory {dEntries = decodeEntry encoding <$> dEntries directory}++decodeEntry :: Encoding -> Entry -> Entry+decodeEntry encoding entry =+  let d = decodingFunction encoding lenientDecode . BSC.pack . T.unpack+   in entry+        { eURI = d <$> eURI entry+        , eGUID = d <$> eGUID entry+        , eTitle = d <$> eTitle entry+        , eDescription = d <$> eDescription entry+        , eAuthor = d <$> eAuthor entry+        }+  where+    decodingFunction UTF8    = decodeUtf8With+    decodingFunction UTF16LE = decodeUtf16LEWith+    decodingFunction UTF16BE = decodeUtf16BEWith+    decodingFunction UTF32LE = decodeUtf32LEWith+    decodingFunction UTF32BE = decodeUtf32BEWith
+ src/Follow/Middlewares/Filter.hs view
@@ -0,0 +1,38 @@+{-|+Description: Middleware to filter entries according to a predicate.++This middleware allows to filter the directory entries according to a+predicate. The predicate is a function @Entry -> Bool@.++Some pre-built predicate builders are also exported. Example:++@+import Follow+import Follow.Middlewares.Filter++-- Suppose we have a `Directory` d++apply (eTitle `equalP` "Some title") d+@++-}+module Follow.Middlewares.Filter+  ( apply+  , Predicate+  , equalP+  , lessP+  , greaterP+  , infixP+  , prefixP+  , suffixP+  , andP+  , orP+  , notP+  ) where++import           Follow.Middlewares.Filter.Internal+import           Follow.Types                       (Directory (..), Middleware)++-- | Middleware to filter a directory according to a given predicate.+apply :: Predicate -> Middleware+apply p directory = directory {dEntries = filter p (dEntries directory)}
+ src/Follow/Middlewares/Filter/Internal.hs view
@@ -0,0 +1,84 @@+{-|+Description: Predicate builders and combinators to be used in the+             filter middleware.++This module defines some predicate builders and combinators useful to+apply the filter middleware to a directory.++-}+module Follow.Middlewares.Filter.Internal+  ( Predicate+  , equalP+  , lessP+  , greaterP+  , infixP+  , prefixP+  , suffixP+  , andP+  , orP+  , notP+  ) where++import           Data.Maybe   (fromMaybe)+import           Data.Text    (Text)+import qualified Data.Text    as T (isInfixOf, isPrefixOf, isSuffixOf)+import           Follow.Types (Entry, EntryGetter)++-- | Predicate that takes an `Entry` as argument.+type Predicate = Entry -> Bool++-- | Builds a predicate which takes the field returned by the getter+-- and compares with given value for equality. It returns `False` when+-- field is `Nothing`.+equalP :: Eq a => EntryGetter a -> a -> Predicate+equalP = liftP (==)++-- | Builds a predicate which takes the field returned by the getter+-- and compares whether it is less than given value. It returns `False` when+-- field is `Nothing`.+lessP :: Ord a => EntryGetter a -> a -> Predicate+lessP = liftP (<)++-- | Builds a predicate which takes the field returned by the getter+-- and compares whether it is greater than given value. It returns `False` when+-- field is `Nothing`.+greaterP :: Ord a => EntryGetter a -> a -> Predicate+greaterP = liftP (>)++-- | Builds a predicate which takes given value and checks whether it+-- is an infix for the field returned by given getter. It returns `False`+-- when field is `Nothing`.+infixP :: Text -> EntryGetter Text -> Predicate+infixP = flip $ liftP (flip T.isInfixOf)++-- | Builds a predicate which takes given value and checks whether it+-- is a prefix for the field returned by given getter. It returns `False`+-- when field is `Nothing`.+prefixP :: Text -> EntryGetter Text -> Predicate+prefixP = flip $ liftP (flip T.isPrefixOf)++-- | Builds a predicate which takes given value and checks whether it+-- is a suffix for the field returned by given getter. It returns `False`+-- when field is `Nothing`.+suffixP :: Text -> EntryGetter Text -> Predicate+suffixP = flip $ liftP (flip T.isSuffixOf)++-- | Builds a predicate which combines with a logical and given+-- predicates.+andP :: Predicate -> Predicate -> Predicate+andP = liftB (&&)++-- | Builds a predicate which combines with a logical or given+-- predicates.+orP :: Predicate -> Predicate -> Predicate+orP = liftB (||)++-- | Build a predicte which negates the result of given predicate.+notP :: Predicate -> Predicate+notP p entry = not $ p entry++liftP :: (a -> b -> Bool) -> EntryGetter a -> b -> Predicate+liftP p getter x entry = maybe False (`p` x) (getter entry)++liftB :: (Bool -> Bool -> Bool) -> Predicate -> Predicate -> Predicate+liftB f p q entry = p entry `f` q entry
+ src/Follow/Middlewares/Sort.hs view
@@ -0,0 +1,40 @@+{-|+Description: Middleware to sort entries according to a function.++This middleware allows to sort the directory entries according to a+comparison function. The comparison function has a type @Entry ->+Entry -> Ordering@.++Some pre-built comparison function builders are also+exported. Example:++@+import Follow+import Follow.Middlewares.Sort++-- Suppose we have a `Directory` d++apply (byGetter eTitle) d+@++-}+module Follow.Middlewares.Sort+  ( apply+  , byGetter+  , ComparisonFunction+  ) where++import           Data.List    (sortBy)+import           Follow.Types (Directory (..), Entry, EntryGetter, Middleware)++-- | Function to compare entries.+type ComparisonFunction = Entry -> Entry -> Ordering++-- | Middleware to sort a directory according to a given comparison function.+apply :: ComparisonFunction -> Middleware+apply f directory = directory {dEntries = sortBy f (dEntries directory)}++-- | Creates a comparison function that sorts by the values returned+-- by a getter+byGetter :: Ord a => EntryGetter a -> ComparisonFunction+byGetter getter e1 e2 = compare (getter e1) (getter e2)
+ src/Follow/Parser.hs view
@@ -0,0 +1,406 @@+{-# LANGUAGE FlexibleInstances    #-}+{-# LANGUAGE OverloadedStrings    #-}+{-# LANGUAGE TypeSynonymInstances #-}++{-|+Description: Instances to parse Follow types from YAML or JSON.++This module contains `Data.Yaml.FromJSON` instances in order to be+able to parse Follow types up to a `Recipe` from a yaml or json text.++You can use decode functions in "Data.Yaml" in order to do the parsing.++For time fields, any format accepted by+`Data.Time.Follow.parseTimeGuess` is accepted.+-}+module Follow.Parser+  (+  ) where++import           Control.Monad.Catch         (MonadThrow, throwM)+import           Control.Monad.IO.Class      (MonadIO)+import           Data.ByteString             (ByteString)+import           Data.Text                   (Text)+import qualified Data.Text                   as T (unpack)+import qualified Data.Text.Encoding          as T (encodeUtf8)+import           Data.Time                   (LocalTime)+import           Data.Time.Follow            (parseTimeGuess)+import           Data.Yaml+import qualified Follow.Fetchers.Feed        as F.Feed (fetch)+import qualified Follow.Fetchers.WebScraping as F.WebScraping (Selector (..),+                                                               SelectorItem (..),+                                                               fetch)+import qualified Follow.Middlewares.Decode   as M.Decode (Encoding (..), apply)+import qualified Follow.Middlewares.Filter   as M.Filter (Predicate (..), andP,+                                                          apply, equalP,+                                                          greaterP, infixP,+                                                          lessP, notP, orP,+                                                          prefixP, suffixP)+import qualified Follow.Middlewares.Sort     as M.Sort (ComparisonFunction,+                                                        apply, byGetter)+import           Follow.Types                (Entry (..), EntryGetter, Fetched,+                                              Middleware, Recipe (..), Step,+                                              Subject (..))+import           Network.HTTP.Req            (MonadHttp)++type FetcherParser m = Object -> Parser (Fetched m)++type MiddlewareParser = Object -> Parser Middleware++data ParsedEntryGetter+  = PEG (EntryGetter Text)+  | PET (EntryGetter LocalTime)++-- | @+--   title: Title+--   description: Description+--   tags: [tag_1, tag_2]+--   @+instance FromJSON Subject where+  parseJSON =+    withObject "Subject" $ \v -> do+      title <- v .: "title"+      description <- v .: "description"+      tags <- v .: "tags"+      return $+        Subject {sTitle = title, sDescription = description, sTags = tags}++-- | @+--   type: text+--   options:+--     css: .selector+--   @+--+--   or+--+--   @+--   type: attr+--   options:+--     css: .link+--     name: href+--   @+instance FromJSON F.WebScraping.SelectorItem where+  parseJSON =+    withObject "SelectorItem" $ \v -> do+      kind <- v .: "type"+      options <- v .: "options"+      case (kind :: Text) of+        "attr" -> do+          css <- options .: "css"+          attr <- options .: "name"+          return $ F.WebScraping.Attr css attr+        "text" -> do+          css <- options .: "css"+          return $ F.WebScraping.InnerText css+        x -> fail $ concat ["Unknown type '", T.unpack x, "' for selector item"]++-- | @+--   uri: # See `SelectorItem` instance+--   title: null+--   description: null+--   guid: null+--   author: null+--   publish_date: null+--  @+instance FromJSON F.WebScraping.Selector where+  parseJSON =+    withObject "Selector" $ \v -> do+      uri <- v .: "uri"+      guid <- v .: "guid"+      title <- v .: "title"+      description <- v .: "description"+      author <- v .: "author"+      publishDate <- v .: "publish_date"+      return $+        F.WebScraping.Selector+          { F.WebScraping.selURI = uri+          , F.WebScraping.selGUID = guid+          , F.WebScraping.selTitle = title+          , F.WebScraping.selDescription = description+          , F.WebScraping.selAuthor = author+          , F.WebScraping.selPublishDate = publishDate+          }++-- | @utf8@, @utf16le@, @utf16be@, @utf32le@ or @utf32be@+instance FromJSON M.Decode.Encoding where+  parseJSON (String "utf8") = return M.Decode.UTF8+  parseJSON (String "utf16le") = return M.Decode.UTF16LE+  parseJSON (String "utf16be") = return M.Decode.UTF16BE+  parseJSON (String "utf32le") = return M.Decode.UTF32LE+  parseJSON (String "utf32be") = return M.Decode.UTF32BE+  parseJSON (String x) =+    fail $ concat ["Unknown type '", T.unpack x, "' for encoding item"]++instance FromJSON ParsedEntryGetter where+  parseJSON (String "title") = return $ PEG eTitle+  parseJSON (String "description") = return $ PEG eDescription+  parseJSON (String "uri") = return $ PEG eURI+  parseJSON (String "guid") = return $ PEG eGUID+  parseJSON (String "author") = return $ PEG eAuthor+  parseJSON (String "publish_date") = return $ PET ePublishDate+  parseJSON (String x) =+    fail $+    concat ["Unknown field '", T.unpack x, "' for by field comparison function"]++-- | @+--   type: by_field+--   options:+--     field: title+--   @+instance FromJSON M.Sort.ComparisonFunction where+  parseJSON =+    withObject "ComparisonFunction" $ \v -> do+      kind <- v .: "type"+      options <- v .: "options"+      case (kind :: Text) of+        "by_field" -> do+          field <- options .: "field"+          case field of+            PEG g -> return $ M.Sort.byGetter g+            PET g -> return $ M.Sort.byGetter g+        x ->+          fail $+          concat ["Unknown function '", T.unpack x, "' for comparison function"]++-- | @+--   type: equal+--   options:+--     field: title+--     value: Title+--   @+--+--   or+--+--   @+--   type: less+--   options:+--     field: publish_date+--     value: 2018-08-08 00:00:00+--   @+--+--   or+--+--   @+--   type: greater+--   options:+--     field: publish_date+--     value: 2018-08-08 00:00:00+--   @+--+--   or+--+--   @+--   type: infix+--   options:+--     field: title+--     value: something+--   @+--+--   or+--+--   @+--   type: prefix+--   options:+--     field: title+--     value: The+--   @+--+--   or+--+--   @+--   type: suffix+--   options:+--     field: title+--     value: end+--   @+--+--   or+--+--   @+--   type: not+--   options:+--     operator: # See `Predicate` instance+--   @+--+--   or+--+--   @+--   type: and+--   options:+--     operator1: # See `Predicate` instance+--     operator2: # See `Predicate` instance+--   @+--+--   or+--+--   @+--   type: or+--   options:+--     operator1: # See `Predicate` instance+--     operator2: # See `Predicate` instance+--   @+instance FromJSON M.Filter.Predicate where+  parseJSON =+    withObject "Predicate" $ \v -> do+      kind <- v .: "type"+      options <- v .: "options"+      case (kind :: Text) of+        "equal" -> do+          getter <- options .: "field"+          value <- options .: "value"+          case getter of+            PEG g -> returnTextFilter g value M.Filter.equalP+            PET g -> returnTimeFilter g value M.Filter.equalP+        "less" -> do+          getter <- options .: "field"+          value <- options .: "value"+          case getter of+            PEG g -> returnTextFilter g value M.Filter.lessP+            PET g -> returnTimeFilter g value M.Filter.lessP+        "greater" -> do+          getter <- options .: "field"+          value <- options .: "value"+          case getter of+            PEG g -> returnTextFilter g value M.Filter.greaterP+            PET g -> returnTimeFilter g value M.Filter.greaterP+        "infix" -> dispatchTextFilter options M.Filter.infixP "infix"+        "prefix" -> dispatchTextFilter options M.Filter.prefixP "prefix"+        "suffix" -> dispatchTextFilter options M.Filter.suffixP "suffix"+        "not" -> do+          operation <- options .: "operation"+          return $ M.Filter.notP operation+        "and" -> do+          operation1 <- options .: "operation1"+          operation2 <- options .: "operation2"+          return $ M.Filter.andP operation1 operation2+        "or" -> do+          operation1 <- options .: "operation1"+          operation2 <- options .: "operation2"+          return $ M.Filter.orP operation1 operation2+    where+      returnTextFilter getter value builder = return $ builder getter value+      returnTimeFilter getter value builder =+        let time = parseTimeGuess value :: Maybe LocalTime+         in case time of+              Nothing -> fail "Format for time is unknown"+              Just t  -> return $ builder getter t+      dispatchTextFilter object builder name = do+        getter <- object .: "field"+        value <- object .: "value"+        case getter of+          PEG g -> return $ builder value g+          PET g ->+            fail $+            concat+              [ "Tried to apply '"+              , name+              , "' filter with a field which is not text"+              ]++-- | @+--   type: feed+--   options:+--     url: http://someurl.com+--   @+--+--   or+--+--   @+--   type: webscraping+--   options:+--     url: http://someurl.com+--     selector: # See `Selector` instance+--   @+instance (MonadThrow m, MonadHttp m) => FromJSON (Fetched m) where+  parseJSON =+    withObject "Fetcher" $ \v -> do+      kind <- v .: "type"+      options <- v .: "options"+      dispatchToFetcher kind options++-- | @+--   type: decode+--   options:+--     encoding: # See `Encoding` instance+--  @+--+--  or+--+--  @+--   type: sort+--   options:+--     function: # See `ComparisonInstance` instance+--  @+--+--  or+--+--  @+--   type: filter+--   options:+--     operation: # See `Predicate` instance+--  @+instance FromJSON Middleware where+  parseJSON =+    withObject "Middleware" $ \v -> do+      kind <- v .: "type"+      options <- v .: "options"+      dispatchToMiddleware kind options++-- | @+--   subject: # See `Subject` instance+--   steps:+--     -+--       - # See `Fetched` instance+--       -+--         - # See `Middleware` instance+--   middlewares:"+--     - # See `Middleware` instance+--  @+instance (MonadThrow m, MonadHttp m) => FromJSON (Recipe m) where+  parseJSON =+    withObject "Recipe" $ \v -> do+      subject <- v .: "subject"+      steps <- v .: "steps"+      middlewares <- v .: "middlewares"+      return+        Recipe {rSubject = subject, rSteps = steps, rMiddlewares = middlewares}++dispatchToFetcher ::+     (MonadThrow m, MonadHttp m) => Text -> Value -> Parser (Fetched m)+dispatchToFetcher kind options =+  case kind of+    "feed"        -> withObject "Options" parseFFeed options+    "webscraping" -> withObject "Options" parseFWebScraping options++parseFFeed :: (MonadThrow m, MonadHttp m) => FetcherParser m+parseFFeed o = do+  url <- o .: "url"+  return $ F.Feed.fetch (T.encodeUtf8 url)++parseFWebScraping :: (MonadThrow m, MonadHttp m) => FetcherParser m+parseFWebScraping o = do+  url <- o .: "url"+  selector <- o .: "selector"+  return $ F.WebScraping.fetch (T.encodeUtf8 url) selector++dispatchToMiddleware :: Text -> Value -> Parser Middleware+dispatchToMiddleware kind options =+  case kind of+    "decode" -> withObject "Options" parseMDecode options+    "sort"   -> withObject "Options" parseMSort options+    "filter" -> withObject "Options" parseMFilter options++parseMDecode :: MiddlewareParser+parseMDecode o = do+  encoding <- o .: "encoding"+  return $ M.Decode.apply encoding++parseMSort :: MiddlewareParser+parseMSort o = do+  function <- o .: "function"+  return $ M.Sort.apply function++parseMFilter :: MiddlewareParser+parseMFilter o = do+  predicate <- o .: "operation"+  return $ M.Filter.apply predicate
+ src/Follow/Types.hs view
@@ -0,0 +1,70 @@+{-|+Description: Definition of types.++This module defines the types used in the whole application.+-}+module Follow.Types+  ( Recipe(..)+  , Subject(..)+  , Entry(..)+  , Fetched+  , EntryGetter+  , Directory(..)+  , Step+  , Middleware+  , Digester+  ) where++import           Data.Text (Text)+import           Data.Time (LocalTime)+import           Data.Yaml (Object, Parser)++-- | Subject being followed. The whole idea of `Follow` is being able+-- to build strategies to gather URIs for the content published about any+-- subject.+data Subject = Subject+  { sTitle       :: Text -- ^ Title.+  , sDescription :: Text -- ^ Description.+  , sTags        :: [Text] -- ^ List of tags.+  } deriving (Eq, Show)++-- | An item of content that has been published somewhere.+data Entry = Entry+  { eURI         :: Maybe Text -- ^ URI that identifies the location of the item.+  , eGUID        :: Maybe Text -- ^ Unique identifier.+  , eTitle       :: Maybe Text -- ^ Title.+  , eDescription :: Maybe Text -- ^ Description.+  , eAuthor      :: Maybe Text -- ^ Author.+  , ePublishDate :: Maybe LocalTime -- ^ Item publish date.+  } deriving (Eq, Show)++-- | Entries fetched usually from the outside world.+type Fetched m = m [Entry]++-- | Function that returns a field from an `Entry`. They are the -+-- automatically generated methods for the `Entry` record.+type EntryGetter a = Entry -> Maybe a++-- | Gathering of `Item` published for some `Subject`.+data Directory = Directory+  { dSubject :: Subject -- ^ The subject.+  , dEntries :: [Entry] -- ^ The list of entries.+  } deriving (Eq, Show)++-- | Middleware does something to a directory.+type Middleware = Directory -> Directory++-- | A list of middlewares to be applied to some fetched entries.+type Step m = (Fetched m, [Middleware])++-- | A recipe is a specification of a complete strategy to create the+-- content to follow a subject.+data Recipe m = Recipe+  { rSubject     :: Subject -- ^ The subject being followed.+  , rSteps       :: [Step m] -- ^ List of steps to be applied.+  , rMiddlewares :: [Middleware] -- ^ List of middlewares to apply to the result of all steps.+  }++-- | Digesters are strategies to transform a directory into something+-- to be consumed by an end user.+type Digester a = Directory -> a
+ src/HTTP/Follow.hs view
@@ -0,0 +1,47 @@+{-# LANGUAGE DataKinds      #-}+{-# LANGUAGE DeriveAnyClass #-}++{-|+Description: HTTP utils used elsewhere in the library.++This module contains HTTP functions needed and used from other modules+within the `Follow` library.+-}+module HTTP.Follow+  ( parseUrl+  , getResponseBody+  , HTTPError(..)+  ) where++import           Control.Monad.Catch  (Exception, MonadThrow, throwM)+import qualified Data.ByteString      as BS (ByteString)+import qualified Data.ByteString.Lazy as BL (ByteString)+import qualified Network.HTTP.Req     as R (GET (..), HttpException, MonadHttp,+                                            NoReqBody (..), Option, Scheme (..),+                                            Url, handleHttpException,+                                            lbsResponse, parseUrl, req,+                                            responseBody)++type Url s = (R.Url s, R.Option s)++type EitherUrl = (Either (Url R.Http) (Url R.Https))++-- HTTP errors+data HTTPError =+  URLWrongFormat+  deriving (Eq, Show, Exception)++-- | Parses a url type from a textual representation.+parseUrl :: (MonadThrow m) => BS.ByteString -> m EitherUrl+parseUrl url = maybe (throwM URLWrongFormat) return (R.parseUrl url)++-- | Performs a request to given url and returns just the response body+getResponseBody :: (R.MonadHttp m, MonadThrow m) => EitherUrl -> m BL.ByteString+getResponseBody = either fetch fetch+  where+    fetch (url, option) =+      R.responseBody <$> R.req R.GET url R.NoReqBody R.lbsResponse option++-- | Declares how to handle request errors for IO monad.+instance R.MonadHttp IO where+  handleHttpException = throwM
+ test/Data/Time/FollowSpec.hs view
@@ -0,0 +1,43 @@+{-# LANGUAGE OverloadedStrings #-}++module Data.Time.FollowSpec where++import           Data.Time        (UTCTime)+import           Data.Time.Follow+import           Test.Hspec++spec :: Spec+spec =+  describe ".parseTimeGuess" $ do+    it "parses from iso8601" $ do+      let string = "2018-09-02"+      let time = parseTimeGuess string :: Maybe UTCTime+      (show <$> time) `shouldBe` Just "2018-09-02 00:00:00 UTC"+    it "parses from iso8601 with time" $ do+      let string = "2018-09-02T01:00:00"+      let time = parseTimeGuess string :: Maybe UTCTime+      (show <$> time) `shouldBe` Just "2018-09-02 01:00:00 UTC"+    it "parses from iso8601 with time and timezone" $ do+      let string = "2018-09-02T01:00:00+0000"+      let time = parseTimeGuess string :: Maybe UTCTime+      (show <$> time) `shouldBe` Just "2018-09-02 01:00:00 UTC"+    it "parses from iso8601 with fractions of seconds" $ do+      let string = "2018-09-02T01:00:00.001000"+      let time = parseTimeGuess string :: Maybe UTCTime+      (show <$> time) `shouldBe` Just "2018-09-02 01:00:00.001 UTC"+    it "parses from iso8601 with fractions of seconds and timezone" $ do+      let string = "2018-09-02T01:00:00.001000+0000"+      let time = parseTimeGuess string :: Maybe UTCTime+      (show <$> time) `shouldBe` Just "2018-09-02 01:00:00.001 UTC"+    it "parses from rfc822" $ do+      let string = "Sun, 02 Sep 2018 01:00:00 +0000"+      let time = parseTimeGuess string :: Maybe UTCTime+      (show <$> time) `shouldBe` Just "2018-09-02 01:00:00 UTC"+    it "parses from epoch" $ do+      let string = "1535846400"+      let time = parseTimeGuess string :: Maybe UTCTime+      (show <$> time) `shouldBe` Just "2018-09-02 00:00:00 UTC"+    it "returns Nothing when unknown" $ do+      let string = "None"+      let time = parseTimeGuess string :: Maybe UTCTime+      time `shouldBe` Nothing
+ test/Fixtures/rss.xml view
@@ -0,0 +1,28 @@+<?xml version="1.0" encoding="UTF-8" ?>+<rss version="2.0">+<channel>+ <title>RSS Title</title>+ <description>This is an example of an RSS feed</description>+ <link>http://www.example.com/main.html</link>+ <lastBuildDate>Mon, 06 Sep 2010 00:01:00 +0000 </lastBuildDate>+ <pubDate>Sun, 06 Sep 2009 16:20:00 +0000</pubDate>+ <ttl>1800</ttl>++ <item>+  <title>Example entry</title>+  <description>Short description.</description>+  <link>http://www.example.com/blog/post/1</link>+  <guid isPermaLink="false">7bd204c6-1655-4c27-aeee-53f933c5395f</guid>+  <pubDate>Sun, 06 Sep 2009 16:20:00 +0000</pubDate>+  <author>Joe Doe</author>+ </item>++ <item>+  <title>Second example entry</title>+  <description>Here is some text containing an interesting description for the second example.</description>+  <link>http://www.example.com/blog/post/2</link>+  <guid isPermaLink="false">8bd204c6-1655-4c27-aeee-53f933c5395f</guid>+  <pubDate>Sun, 06 Sep 2009 16:20:00 +0000</pubDate>+ </item>+</channel>+</rss>
+ test/Fixtures/webscraping.html view
@@ -0,0 +1,27 @@+<!DOCTYPE html>+<html lang="en"> +  <head>+    <title>Sample Blog</title>+  </head>+  <body>+    <h1>Sample Blog</h1>+    <article>+      <p class="title"><a href="http://sampleblog.com/first-article">First article</a></p>+      <p class="description">Description of the first article</p>+      <p class="author">Joe Doe</p>+      <p class="publish-date" datetime="1535684406">Yesterday</p>+    </article>+    <article>+      <p class="title"><a href="http://sampleblog.com/second-article">Second article</a></p>+      <p class="description">Description of the second article</p>+      <p class="author">Joe Doe</p>+    </article>+    <article>+      <p class="title">Here article link is missing</p>+      <p class="description">Description of the missing article</p>+      <p class="author">Joe Doe</p>+    </article>+  </body>+  </body>+  </body>+</html>
+ test/Follow/Digesters/SimpleTextSpec.hs view
@@ -0,0 +1,42 @@+{-# LANGUAGE OverloadedStrings #-}++module Follow.Digesters.SimpleTextSpec where++import qualified Data.Text                   as T (isInfixOf)+import           Data.Time                   (LocalTime)+import           Follow.Digesters.SimpleText+import           Follow.Types                (Directory (..), Entry (..),+                                              Subject (..))+import           Test.Hspec++spec :: Spec+spec =+  describe ".digest" $ do+    let isInfixOf' = flip T.isInfixOf+    let subject = Subject "Subject Title" "Subject Description" ["tag"]+    let entries =+          [ Entry+              (Just "http://a_url.com")+              (Just "a_guid")+              (Just "Entry Title")+              (Just "Entry Description")+              (Just "Entry Author")+              (Just (read "2009-09-06 16:20:00" :: LocalTime))+          ]+    let directory = Directory subject entries+    let output = digest directory+    it "adds subject title" $ do+      "Subject Title" `shouldSatisfy` (isInfixOf' output)+    it "adds subject description" $ do+      "Subject Description" `shouldSatisfy` (isInfixOf' output)+    it "adds subject tags" $ do "tag" `shouldSatisfy` (isInfixOf' output)+    it "adds each entry URL" $ do+      "http://a_url.com" `shouldSatisfy` (isInfixOf' output)+    it "adds each entry title" $ do+      "Entry Title" `shouldSatisfy` (isInfixOf' output)+    it "adds each entry description" $ do+      "Entry Description" `shouldSatisfy` (isInfixOf' output)+    it "adds each entry author" $ do+      "Entry Author" `shouldSatisfy` (isInfixOf' output)+    it "adds each entry publish date" $ do+      "2009-09-06 16:20:00" `shouldSatisfy` (isInfixOf' output)
+ test/Follow/Fetchers/Feed/InternalSpec.hs view
@@ -0,0 +1,41 @@+{-# LANGUAGE OverloadedStrings #-}++module Follow.Fetchers.Feed.InternalSpec where++import qualified Data.ByteString               as BS (ByteString)+import           Data.Time                     (LocalTime)+import           Follow.Fetchers.Feed.Internal+import           Follow.Types                  (Entry (..))+import           Test.Hspec+import qualified Text.Feed.Import              as F (parseFeedFromFile)+import qualified Text.Feed.Types               as F (Feed)++loadFeedFromFile :: IO F.Feed+loadFeedFromFile = F.parseFeedFromFile "test/Fixtures/rss.xml"++spec :: Spec+spec = do+  describe ".feedToEntries" $ do+    before loadFeedFromFile $ do+      it "adds an entry for each feed item" $ \feed -> do+        let entries = feedToEntries feed+        length entries `shouldBe` 2+      it "sets item link as entry uri" $ \feed -> do+        let entry = head $ feedToEntries feed+        eURI entry `shouldBe` Just "http://www.example.com/blog/post/1"+      it "sets item guid as entry id" $ \feed -> do+        let entry = head $ feedToEntries feed+        eGUID entry `shouldBe` Just "7bd204c6-1655-4c27-aeee-53f933c5395f"+      it "sets item title as entry title" $ \feed -> do+        let entry = head $ feedToEntries feed+        eTitle entry `shouldBe` Just "Example entry"+      it "sets item description as entry description" $ \feed -> do+        let entry = head $ feedToEntries feed+        eDescription entry `shouldBe` Just "Short description."+      it "sets item author as entry author" $ \feed -> do+        let entry = head $ feedToEntries feed+        eAuthor entry `shouldBe` Just "Joe Doe"+      it "sets item publish date as entry publish date" $ \feed -> do+        let entry = head $ feedToEntries feed+        ePublishDate entry `shouldBe`+          Just (read "2009-09-06 16:20:00" :: LocalTime)
+ test/Follow/Fetchers/FeedSpec.hs view
@@ -0,0 +1,29 @@+{-# LANGUAGE OverloadedStrings #-}++module Follow.Fetchers.FeedSpec where++import           Data.Maybe               (fromJust)+import qualified Data.Text                as T (isInfixOf, pack)+import           Follow.Fetchers.Feed+import           Follow.Types             (Entry (..))+import           Helpers.EndPointFixtures (endPointWithStatus, feedEndPoint,+                                           invalidEndPoint, simpleEndPoint)+import           HTTP.Follow              (HTTPError (..))+import qualified Network.HTTP.Req         as R (HttpException (..))+import           Test.Hspec++spec :: Spec+spec = do+  describe ".fetcher" $ do+    it "fetches entries from given url" $ do+      entries <- fetch feedEndPoint+      let entry = head entries+      let url = (fromJust . eURI) entry+      T.isInfixOf "nytimes" url `shouldBe` True+    it "returns error when URL is not valid" $ do+      fetch invalidEndPoint `shouldThrow` (== URLWrongFormat)+    it "returns error when response can't be parsed to a feed" $ do+      fetch simpleEndPoint `shouldThrow` (== FeedWrongFormat)+    it "returns the http error when it happens" $ do+      fetch (endPointWithStatus 404) `shouldThrow` \e ->+        "HttpException" `T.isInfixOf` (T.pack $ show (e :: R.HttpException))
+ test/Follow/Fetchers/WebScraping/InternalSpec.hs view
@@ -0,0 +1,49 @@+{-# LANGUAGE OverloadedStrings #-}++module Follow.Fetchers.WebScraping.InternalSpec where++import           Data.ByteString.Lazy                 as BL (ByteString)+import           Data.ByteString.Lazy.Char8           as BL (pack)+import           Follow.Fetchers.WebScraping.Internal+import           Follow.Types                         (Entry (..))+import           Test.Hspec++loadHtmlFromFile :: IO BL.ByteString+loadHtmlFromFile = BL.pack <$> readFile "test/Fixtures/webscraping.html"++spec :: Spec+spec =+  describe ".htmlToEntries" $ do+    let selector =+          Selector+            { selURI = Just $ Attr "article a" "href"+            , selGUID = Just $ Attr "article a" "href"+            , selTitle = Just $ InnerText ".title a"+            , selDescription = Just $ InnerText ".description"+            , selAuthor = Just $ InnerText ".author"+            , selPublishDate = Just $ Attr ".publish-date" "datetime"+            }+    before loadHtmlFromFile $ do+      it "creates as many entries as links" $ \html -> do+        entries <- htmlToEntries html selector+        length entries `shouldBe` 2+      it "uses URI selector to parse URIs" $ \html -> do+        entry <- head <$> htmlToEntries html selector+        eURI entry `shouldBe` Just "http://sampleblog.com/first-article"+      it "uses GUID selector to parse GUIDs" $ \html -> do+        entry <- head <$> htmlToEntries html selector+        eGUID entry `shouldBe` Just "http://sampleblog.com/first-article"+      it "uses title selector to parse titles" $ \html -> do+        entry <- head <$> htmlToEntries html selector+        eTitle entry `shouldBe` Just "First article"+      it "uses description selector to parse descriptions" $ \html -> do+        entry <- head <$> htmlToEntries html selector+        eDescription entry `shouldBe` Just "Description of the first article"+      it "uses publish date selector to parse publish dates" $ \html -> do+        pendingWith+          "https://mail.haskell.org/pipermail/haskell-cafe/2018-September/129934.html"+        entry <- head <$> htmlToEntries html selector+        (show <$> ePublishDate entry) `shouldBe` Just "2018-08-31 03:00:06"+      it "fills unmatched with Nothing" $ \html -> do+        entry <- head . tail <$> htmlToEntries html selector+        ePublishDate entry `shouldBe` Nothing
+ test/Follow/Fetchers/WebScrapingSpec.hs view
@@ -0,0 +1,28 @@+{-# LANGUAGE OverloadedStrings #-}++module Follow.Fetchers.WebScrapingSpec where++import           Data.Maybe                  (fromJust)+import qualified Data.Text                   as T (isInfixOf, pack)+import           Follow.Fetchers.WebScraping+import           Follow.Types                (Entry (..))+import           Helpers.EndPointFixtures+import           Helpers.Factories+import           HTTP.Follow                 (HTTPError (..))+import qualified Network.HTTP.Req            as R (HttpException (..))+import           Test.Hspec++spec :: Spec+spec =+  describe ".fetch" $ do+    let selector = _selector {selURI = Just (Attr ".headline a" "href")}+    it "fetches entries from given url" $ do+      entries <- fetch webScrapingEndPoint selector+      let entry = head entries+      let url = (fromJust . eURI) entry+      T.isInfixOf "nytimes" url `shouldBe` True+    it "returns error when URL is not valid" $ do+      fetch invalidEndPoint selector `shouldThrow` (== URLWrongFormat)+    it "returns the http error when it happens" $ do+      fetch (endPointWithStatus 404) selector `shouldThrow` \e ->+        "HttpException" `T.isInfixOf` (T.pack $ show (e :: R.HttpException))
+ test/Follow/Middlewares/Filter/InternalSpec.hs view
@@ -0,0 +1,111 @@+{-# LANGUAGE OverloadedStrings #-}++module Follow.Middlewares.Filter.InternalSpec where++import           Follow.Middlewares.Filter.Internal+import           Follow.Types                       (Entry (..))+import           Helpers.Factories+import           Test.Hspec++spec :: Spec+spec = do+  describe ".equalP" $ do+    it "returns true on equality" $ do+      let entry = _entry {eURI = Just "http://url.com"}+      (eURI `equalP` "http://url.com") entry `shouldBe` True+    it "returns false on inequality" $ do+      let entry = _entry {eGUID = Just "GUID1"}+      (eGUID `equalP` "GUID2") entry `shouldBe` False+    it "returns false when field is Nothing" $ do+      let entry = _entry {eTitle = Nothing}+      (eTitle `equalP` "Title") entry `shouldBe` False+  describe ".infixP" $ do+    it "returns true when needle is contained" $ do+      let entry = _entry {eURI = Just "http://this_is_a_url.com"}+      ("url" `infixP` eURI) entry `shouldBe` True+    it "returns false when needle is not contained" $ do+      let entry = _entry {eGUID = Just "GUID1"}+      ("NO" `infixP` eGUID) entry `shouldBe` False+    it "returns false when field is Nothing" $ do+      let entry = _entry {eTitle = Nothing}+      ("Title" `infixP` eTitle) entry `shouldBe` False+  describe ".prefixP" $ do+    it "returns true when needle is prefix" $ do+      let entry = _entry {eURI = Just "http://url.com"}+      ("http://" `prefixP` eURI) entry `shouldBe` True+    it "returns false when needle is not prefix" $ do+      let entry = _entry {eGUID = Just "GUID1"}+      ("NO" `prefixP` eGUID) entry `shouldBe` False+    it "returns false when field is Nothing" $ do+      let entry = _entry {eTitle = Nothing}+      ("Title" `prefixP` eTitle) entry `shouldBe` False+  describe ".suffixP" $ do+    it "returns true when needle is suffix" $ do+      let entry = _entry {eURI = Just "http://url.com"}+      (".com" `suffixP` eURI) entry `shouldBe` True+    it "returns false when needle is not suffix" $ do+      let entry = _entry {eGUID = Just "GUID1"}+      ("NO" `suffixP` eGUID) entry `shouldBe` False+    it "returns false when field is Nothing" $ do+      let entry = _entry {eTitle = Nothing}+      ("Title" `suffixP` eTitle) entry `shouldBe` False+  describe ".lessP" $ do+    it "returns true when item is lesser" $ do+      let entry = _entry {eTitle = Just "A"}+      (eTitle `lessP` "B") entry `shouldBe` True+    it "returns false when item is not lesser" $ do+      let entry = _entry {eTitle = Just "C"}+      (eTitle `lessP` "B") entry `shouldBe` False+    it "returns false when item is equal" $ do+      let entry = _entry {eTitle = Just "A"}+      (eTitle `lessP` "A") entry `shouldBe` False+    it "returns false when item is Nothing" $ do+      let entry = _entry {eTitle = Nothing}+      (eTitle `lessP` "A") entry `shouldBe` False+  describe ".greaterP" $ do+    it "returns true when item is greater" $ do+      let entry = _entry {eTitle = Just "C"}+      (eTitle `greaterP` "A") entry `shouldBe` True+    it "returns false when item is not greater" $ do+      let entry = _entry {eTitle = Just "A"}+      (eTitle `greaterP` "B") entry `shouldBe` False+    it "returns false when item is equal" $ do+      let entry = _entry {eTitle = Just "A"}+      (eTitle `greaterP` "A") entry `shouldBe` False+    it "returns false when item is Nothing" $ do+      let entry = _entry {eTitle = Nothing}+      (eTitle `greaterP` "A") entry `shouldBe` False+  describe ".andP" $ do+    let entry = _entry {eTitle = Just "The Title"}+    it "returns true when both predicates apply" $ do+      (("The" `prefixP` eTitle) `andP` ("Title" `suffixP` eTitle)) entry `shouldBe`+        True+    it "returns false when first predicates fails" $ do+      (("NO" `prefixP` eTitle) `andP` ("Title" `suffixP` eTitle)) entry `shouldBe`+        False+    it "returns false when second predicates fails" $ do+      (("The" `prefixP` eTitle) `andP` ("NO" `suffixP` eTitle)) entry `shouldBe`+        False+    it "returns false when both predicates fail" $ do+      (("NO" `prefixP` eTitle) `andP` ("NEITHER" `suffixP` eTitle)) entry `shouldBe`+        False+  describe ".orP" $ do+    let entry = _entry {eTitle = Just "The Title"}+    it "returns true when both predicates apply" $ do+      (("The" `prefixP` eTitle) `orP` ("Title" `suffixP` eTitle)) entry `shouldBe`+        True+    it "returns true when first predicates applies" $ do+      (("The" `prefixP` eTitle) `orP` ("NO" `suffixP` eTitle)) entry `shouldBe`+        True+    it "returns true when second predicates applies" $ do+      (("NO" `prefixP` eTitle) `orP` ("Title" `suffixP` eTitle)) entry `shouldBe`+        True+    it "returns false when both predicates fail" $ do+      (("NO" `prefixP` eTitle) `orP` ("NEITHER" `suffixP` eTitle)) entry `shouldBe`+        False+  describe ".notP" $ do+    let entry = _entry {eTitle = Just "The Title"}+    it "returns true when predicate does not apply" $ do+      notP ("NO" `prefixP` eTitle) entry `shouldBe` True+    it "returns false when predicate applies" $ do+      notP ("The" `prefixP` eTitle) entry `shouldBe` False
+ test/Follow/Middlewares/FilterSpec.hs view
@@ -0,0 +1,18 @@+{-# LANGUAGE OverloadedStrings #-}++module Follow.Middlewares.FilterSpec where++import           Follow.Middlewares.Filter+import           Follow.Types              (Directory (..), Entry (..))+import           Helpers.Factories+import           Test.Hspec++spec :: Spec+spec =+  describe ".apply" $ do+    it "filter entries according to given predicate" $ do+      let entry1 = _entry {eTitle = Just "A"}+      let entry2 = _entry {eTitle = Just "B"}+      let directory = Directory _subject [entry1, entry2]+      let directory' = apply (eTitle `equalP` "A") directory+      dEntries directory' `shouldBe` [entry1]
+ test/Follow/Middlewares/SortSpec.hs view
@@ -0,0 +1,18 @@+{-# LANGUAGE OverloadedStrings #-}++module Follow.Middlewares.SortSpec where++import           Follow.Middlewares.Sort+import           Follow.Types            (Directory (..), Entry (..))+import           Helpers.Factories+import           Test.Hspec++spec :: Spec+spec = do+  describe ".apply" $ do+    it "sorts by given field getter" $ do+      let entry1 = _entry {eTitle = Just "DEF"}+      let entry2 = _entry {eTitle = Just "ABC"}+      let directory = Directory _subject [entry1, entry2]+      let sortedDirectory = apply (byGetter eTitle) directory+      dEntries sortedDirectory `shouldBe` [entry2, entry1]
+ test/Follow/ParserSpec.hs view
@@ -0,0 +1,352 @@+{-# LANGUAGE OverloadedStrings #-}++module Follow.ParserSpec where++import           Control.Monad.Catch         (MonadThrow)+import           Data.ByteString             (ByteString)+import qualified Data.ByteString.Char8       as B (unlines)+import           Data.List                   (isInfixOf)+import           Data.Time                   (LocalTime)+import           Data.Yaml                   (FromJSON, ParseException,+                                              decodeThrow)+import           Follow                      (directoryFromRecipe)+import qualified Follow.Fetchers.WebScraping as F.WebScraping (Selector (..),+                                                               SelectorItem (..))+import qualified Follow.Middlewares.Decode   as M.Decode (Encoding (..))+import qualified Follow.Middlewares.Filter   as M.Filter (Predicate)+import qualified Follow.Middlewares.Sort     as M.Sort (ComparisonFunction)+import           Follow.Parser+import           Follow.Types                (Directory (..), Entry (..),+                                              Fetched, Recipe (..),+                                              Subject (..))+import           Helpers.Factories+import           HTTP.Follow                 (HTTPError (..))+import           Test.Hspec++decode' :: (MonadThrow m, FromJSON a) => ByteString -> m a+decode' = decodeThrow++spec :: Spec+spec = do+  describe ".FromJSON" $ do+    describe "Subject" $ do+      it "has instance" $ do+        let string =+              B.unlines+                ["title: Title", "description: Description", "tags: [tag]"]+        subject <- decode' string :: IO Subject+        subject `shouldBe`+          Subject+            {sTitle = "Title", sDescription = "Description", sTags = ["tag"]}+    describe "SelectorItem" $ do+      it "recognizes Attr constructor" $ do+        let string =+              B.unlines+                ["type: attr", "options:", "  css: .selector", "  name: href"]+        item <- decode' string :: IO F.WebScraping.SelectorItem+        item `shouldBe` F.WebScraping.Attr ".selector" "href"+      it "recognizes InnerText constructor" $ do+        let string = B.unlines ["type: text", "options:", "  css: .selector"]+        item <- decode' string :: IO F.WebScraping.SelectorItem+        item `shouldBe` F.WebScraping.InnerText ".selector"+      it "throws custom error when type is unknown" $ do+        let string = B.unlines ["type: unknown", "options:", "  css: .selector"]+        (decode' string :: IO F.WebScraping.SelectorItem) `shouldThrow` \e ->+          ("Unknown type 'unknown' for selector item" `isInfixOf`+           show (e :: ParseException))+    describe "Selector" $ do+      it "has instance" $ do+        let string =+              B.unlines+                [ "uri:"+                , "  type: text"+                , "  options:"+                , "    css: .uri"+                , "guid:"+                , "  type: attr"+                , "  options:"+                , "    css: .guid"+                , "    name: href"+                , "title: null"+                , "description: null"+                , "author: null"+                , "publish_date: null"+                ]+        selector <- decode' string :: IO F.WebScraping.Selector+        selector `shouldBe`+          F.WebScraping.Selector+            { F.WebScraping.selURI = Just $ F.WebScraping.InnerText ".uri"+            , F.WebScraping.selGUID = Just $ F.WebScraping.Attr ".guid" "href"+            , F.WebScraping.selTitle = Nothing+            , F.WebScraping.selDescription = Nothing+            , F.WebScraping.selAuthor = Nothing+            , F.WebScraping.selPublishDate = Nothing+            }+    describe "Encoding" $ do+      it "recognizes UTF8 constructor" $ do+        let string = "utf8"+        encoding <- decode' string :: IO M.Decode.Encoding+        encoding `shouldBe` M.Decode.UTF8+      it "recognizes UTF16LE constructor" $ do+        let string = "utf16le"+        encoding <- decode' string :: IO M.Decode.Encoding+        encoding `shouldBe` M.Decode.UTF16LE+      it "recognizes UTF16BE constructor" $ do+        let string = "utf16be"+        encoding <- decode' string :: IO M.Decode.Encoding+        encoding `shouldBe` M.Decode.UTF16BE+      it "recognizes UTF32LE constructor" $ do+        let string = "utf32le"+        encoding <- decode' string :: IO M.Decode.Encoding+        encoding `shouldBe` M.Decode.UTF32LE+      it "recognizes UTF32BE constructor" $ do+        let string = "utf32be"+        encoding <- decode' string :: IO M.Decode.Encoding+        encoding `shouldBe` M.Decode.UTF32BE+      it "throws custom error when type is unknown" $ do+        let string = "unknown"+        (decode' string :: IO M.Decode.Encoding) `shouldThrow` \e ->+          ("Unknown type 'unknown' for encoding" `isInfixOf`+           show (e :: ParseException))+    describe "ComparisonFunction" $ do+      it "recognizes byGetter with each field" $ do+        let string = B.unlines ["type: by_field", "options:", "  field: title"]+        function <- decode' string :: IO M.Sort.ComparisonFunction+        let entry1 = _entry {eTitle = Just "A"}+        let entry2 = _entry {eTitle = Just "B"}+        function entry1 entry2 `shouldBe` LT+      it "throws custom error when field is unknown" $ do+        let string =+              B.unlines ["type: by_field", "options:", "  field: unknown"]+        (decode' string :: IO M.Sort.ComparisonFunction) `shouldThrow` \e ->+          ("Unknown field 'unknown' for by field comparison function" `isInfixOf`+           show (e :: ParseException))+      it "throws custom error when type is unknown" $ do+        let string = B.unlines ["type: unknown", "options:", "  field: unknown"]+        (decode' string :: IO M.Sort.ComparisonFunction) `shouldThrow` \e ->+          ("Unknown function 'unknown' for comparison function" `isInfixOf`+           show (e :: ParseException))+    describe "Predicate" $ do+      it "recognizes equalP" $ do+        let string =+              B.unlines+                ["type: equal", "options:", "  field: title", "  value: A"]+        predicate <- decode' string :: IO M.Filter.Predicate+        let entry = _entry {eTitle = Just "A"}+        predicate entry `shouldBe` True+      it "recognizes lessP" $ do+        let string =+              B.unlines+                ["type: less", "options:", "  field: title", "  value: B"]+        predicate <- decode' string :: IO M.Filter.Predicate+        let entry = _entry {eTitle = Just "A"}+        predicate entry `shouldBe` True+      it "recognizes greaterP" $ do+        let string =+              B.unlines+                ["type: greater", "options:", "  field: title", "  value: A"]+        predicate <- decode' string :: IO M.Filter.Predicate+        let entry = _entry {eTitle = Just "B"}+        predicate entry `shouldBe` True+      it "recognizes infixP" $ do+        let string =+              B.unlines+                ["type: infix", "options:", "  field: title", "  value: B"]+        predicate <- decode' string :: IO M.Filter.Predicate+        let entry = _entry {eTitle = Just "ABC"}+        predicate entry `shouldBe` True+      it "fails with custom error when infixP is given with non text getter" $ do+        let string =+              B.unlines+                [ "type: infix"+                , "options:"+                , "  field: publish_date"+                , "  value: A"+                ]+        (decode' string :: IO M.Filter.Predicate) `shouldThrow` \e ->+          ("Tried to apply 'infix' filter with a field which is not text" `isInfixOf`+           show (e :: ParseException))+      it "recognizes prefixP" $ do+        let string =+              B.unlines+                ["type: prefix", "options:", "  field: title", "  value: A"]+        predicate <- decode' string :: IO M.Filter.Predicate+        let entry = _entry {eTitle = Just "ABC"}+        predicate entry `shouldBe` True+      it "fails with custom error when prefixP is given with non text getter" $ do+        let string =+              B.unlines+                [ "type: prefix"+                , "options:"+                , "  field: publish_date"+                , "  value: A"+                ]+        (decode' string :: IO M.Filter.Predicate) `shouldThrow` \e ->+          ("Tried to apply 'prefix' filter with a field which is not text" `isInfixOf`+           show (e :: ParseException))+      it "recognizes suffixP" $ do+        let string =+              B.unlines+                ["type: suffix", "options:", "  field: title", "  value: C"]+        predicate <- decode' string :: IO M.Filter.Predicate+        let entry = _entry {eTitle = Just "ABC"}+        predicate entry `shouldBe` True+      it "fails with custom error when prefixP is given with non text getter" $ do+        let string =+              B.unlines+                [ "type: suffix"+                , "options:"+                , "  field: publish_date"+                , "  value: A"+                ]+        (decode' string :: IO M.Filter.Predicate) `shouldThrow` \e ->+          ("Tried to apply 'suffix' filter with a field which is not text" `isInfixOf`+           show (e :: ParseException))+      it "recognizes notP" $ do+        let string =+              B.unlines+                [ "type: not"+                , "options:"+                , "  operation:"+                , "    type: prefix"+                , "    options:"+                , "      field: title"+                , "      value: D"+                ]+        predicate <- decode' string :: IO M.Filter.Predicate+        let entry = _entry {eTitle = Just "ABC"}+        predicate entry `shouldBe` True+      it "recognizes andP" $ do+        let string =+              B.unlines+                [ "type: and"+                , "options:"+                , "  operation1:"+                , "    type: prefix"+                , "    options:"+                , "      field: title"+                , "      value: A"+                , "  operation2:"+                , "    type: suffix"+                , "    options:"+                , "      field: title"+                , "      value: C"+                ]+        predicate <- decode' string :: IO M.Filter.Predicate+        let entry = _entry {eTitle = Just "ABC"}+        predicate entry `shouldBe` True+      it "recognizes orP" $ do+        let string =+              B.unlines+                [ "type: or"+                , "options:"+                , "  operation1:"+                , "    type: prefix"+                , "    options:"+                , "      field: title"+                , "      value: A"+                , "  operation2:"+                , "    type: suffix"+                , "    options:"+                , "      field: title"+                , "      value: C"+                ]+        predicate <- decode' string :: IO M.Filter.Predicate+        let entry = _entry {eTitle = Just "ABD"}+        predicate entry `shouldBe` True+      it "fails with custom error when format for a time field is unknown" $ do+        let string =+              B.unlines+                [ "type: equal"+                , "options:"+                , "  field: publish_date"+                , "  value: unknown"+                ]+        (decode' string :: IO M.Filter.Predicate) `shouldThrow` \e ->+          ("Format for time is unknown" `isInfixOf` show (e :: ParseException))+  describe "Fetcher" $ do+    it "recognizes feed" $ do+      let string = B.unlines ["type: feed", "options:", "  url: invalid"]+      fetched <- decode' string :: IO (Fetched IO)+      fetched `shouldThrow` (== URLWrongFormat)+    it "recognizes web scraping" $ do+      let string =+            B.unlines+              [ "type: webscraping"+              , "options:"+              , "  url: invalid"+              , "  selector:"+              , "    title: null"+              , "    description: null"+              , "    uri: null"+              , "    guid: null"+              , "    author: null"+              , "    publish_date: null"+              ]+      fetched <- decode' string :: IO (Fetched IO)+      fetched `shouldThrow` (== URLWrongFormat)+  describe "Middleware" $ do+    it "recognizes decode" $ do+      let string = B.unlines ["type: decode", "options:", "  encoding: utf8"]+      middleware <- decode' string+      let directory = _directory+      middleware directory `shouldBe` directory+    it "recognizes sort" $ do+      let string =+            B.unlines+              [ "type: sort"+              , "options:"+              , "  function:"+              , "    type: by_field"+              , "    options:"+              , "      field: title"+              ]+      middleware <- decode' string+      let entry1 = _entry {eTitle = Just "B"}+      let entry2 = _entry {eTitle = Just "B"}+      let directory = _directory {dEntries = [entry1, entry2]}+      middleware directory `shouldBe` directory {dEntries = [entry2, entry1]}+    it "recognizes filter" $ do+      let string =+            B.unlines+              [ "type: filter"+              , "options:"+              , "  operation:"+              , "    type: equal"+              , "    options:"+              , "      field: title"+              , "      value: A"+              ]+      middleware <- decode' string+      let entry1 = _entry {eTitle = Just "A"}+      let entry2 = _entry {eTitle = Just "B"}+      let directory = _directory {dEntries = [entry1, entry2]}+      middleware directory `shouldBe` directory {dEntries = [entry1]}+  describe "Recipe" $ do+    it "has instance" $ do+      let string =+            B.unlines+              [ "subject:"+              , "  title: Title"+              , "  description: Description"+              , "  tags: [tag]"+              , "steps:"+              , "  -"+              , "    - type: feed"+              , "      options:"+              , "        url: invalid"+              , "    -"+              , "      - type: sort"+              , "        options:"+              , "          function:"+              , "            type: by_field"+              , "            options:"+              , "              field: title"+              , "middlewares:"+              , "  -"+              , "    type: decode"+              , "    options:"+              , "      encoding: utf8"+              ]+      recipe <- decode' string :: IO (Recipe IO)+      directoryFromRecipe recipe `shouldThrow` (== URLWrongFormat)
+ test/FollowSpec.hs view
@@ -0,0 +1,91 @@+{-# LANGUAGE OverloadedStrings #-}++module FollowSpec where++import           Data.Text         (Text)+import qualified Data.Text         as T (concat)+import           Follow+import           Follow.Types      (Digester, Directory (..), Entry (..),+                                    Middleware, Recipe (..), Subject (..))+import           Helpers.Factories+import           Test.Hspec++removeEntriesMiddleware = (\d -> Directory (dSubject d) []) :: Middleware++addEntryMiddleware entry =+  (\directory -> Directory (dSubject directory) $ entry : (dEntries directory)) :: Middleware++spec :: Spec+spec = do+  describe ".directoryFromFetched" $ do+    it "populates Directory Entries out of given fetcher" $ do+      let entry = _entry {eTitle = Just "Fetched"}+      directory <- directoryFromFetched (e2F entry) _subject+      dEntries directory `shouldBe` [entry]+    it "associates given subject with the Directory" $ do+      let subject = _subject {sTitle = "DirectoryFromFetched"}+      directory <- directoryFromFetched (e2F _entry) subject+      dSubject directory `shouldBe` subject+  describe ".applyMiddlewares" $ do+    it "applies in order given middlewares" $ do+      let directory = emptyDirectory _subject+      let middleware1 = addEntryMiddleware $ _entry {eTitle = Just "1"}+      let middleware2 = addEntryMiddleware $ _entry {eTitle = Just "2"}+      let result = applyMiddlewares [middleware1, middleware2] directory+      let resultEntries = dEntries result+      eTitle <$> resultEntries `shouldBe` [Just "2", Just "1"]+  describe ".directoryFromRecipe" $ do+    it "associates given subject to the created directory" $ do+      let recipe = Recipe _subject [] []+      directory <- directoryFromRecipe recipe+      dSubject directory `shouldBe` _subject+    it "concatenates given fetched entries" $ do+      let entry1 = _entry {eTitle = Just "Title 1"}+      let entry2 = _entry {eTitle = Just "Title 2"}+      let recipe = Recipe _subject [((e2F entry1), []), (e2F entry2, [])] []+      directory <- directoryFromRecipe recipe+      dEntries directory `shouldBe` [entry1, entry2]+    it "applies middlewares at each step" $ do+      let entry1 = _entry {eTitle = Just "Title 1"}+      let entry2 = _entry {eTitle = Just "Title 2"}+      let recipe =+            Recipe+              _subject+              [((e2F entry1), [removeEntriesMiddleware]), (e2F entry2, [])]+              []+      directory <- directoryFromRecipe recipe+      dEntries directory `shouldBe` [entry2]+    it "applies middlewares to fetched entries as a whole" $ do+      let entry1 = _entry {eTitle = Just "Title 1"}+      let recipe = Recipe _subject [(e2F entry1, [])] [removeEntriesMiddleware]+      directory <- directoryFromRecipe recipe+      dEntries directory `shouldBe` []+  describe ".applySteps" $ do+    let directory = emptyDirectory _subject+    it "concatenates given fetched entries" $ do+      let entry1 = _entry {eTitle = Just "Title 1"}+      let entry2 = _entry {eTitle = Just "Title 2"}+      directory <- applySteps directory [(e2F entry1, []), (e2F entry2, [])]+      dEntries directory `shouldBe` [entry1, entry2]+    it "applies middlewares at each step" $ do+      let entry1 = _entry {eTitle = Just "Title 1"}+      let entry2 = _entry {eTitle = Just "Title 2"}+      directory <-+        applySteps+          directory+          [(e2F entry1, [removeEntriesMiddleware]), (e2F entry2, [])]+      dEntries directory `shouldBe` [entry2]+  describe ".emptyDirectory" $ do+    let directory = emptyDirectory _subject+    it "assigns given subject" $ do dSubject directory `shouldBe` _subject+    it "assigns empty list as entries" $ do dEntries directory `shouldBe` []+  describe ".mergeEntries" $ do+    let entry1 = _entry {eTitle = Just "1"}+    let entry2 = _entry {eTitle = Just "2"}+    let directory = Directory _subject [entry1]+    it "concatenate entries" $ do+      let directory' = mergeEntries directory [entry2]+      dEntries directory' `shouldBe` [entry1, entry2]+    it "keeps first appearance of duplicates" $ do+      let directory' = mergeEntries directory [entry2, entry1]+      dEntries directory' `shouldBe` [entry1, entry2]
+ test/Helpers/EndPointFixtures.hs view
@@ -0,0 +1,28 @@+{-# LANGUAGE OverloadedStrings #-}++module Helpers.EndPointFixtures+  ( simpleEndPoint+  , endPointWithStatus+  , feedEndPoint+  , webScrapingEndPoint+  , invalidEndPoint+  ) where++import qualified Data.ByteString       as BS (ByteString, concat)+import qualified Data.ByteString.Char8 as BS (pack)++simpleEndPoint :: BS.ByteString+simpleEndPoint = "http://httpbin.org"++endPointWithStatus :: Int -> BS.ByteString+endPointWithStatus status =+  BS.concat ["http://httpbin.org/status/", BS.pack $ show status]++feedEndPoint :: BS.ByteString+feedEndPoint = "http://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml"++webScrapingEndPoint :: BS.ByteString+webScrapingEndPoint = "https://www.nytimes.com/section/world"++invalidEndPoint :: BS.ByteString+invalidEndPoint = "invalidurl"
+ test/Helpers/Factories.hs view
@@ -0,0 +1,51 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Out of the box built record to be used in testing.+module Helpers.Factories+  ( _subject+  , _entry+  , _directory+  , _recipe+  , _selector+  , e2F+  ) where++import           Control.Monad.Catch         (MonadThrow)+import           Follow.Fetchers.WebScraping (Selector (..))+import           Follow.Types                (Directory (..), Entry (..),+                                              Recipe (..), Subject (..))++_entry :: Entry+_entry =+  Entry+    { eURI = Nothing+    , eGUID = Nothing+    , eTitle = Nothing+    , eDescription = Nothing+    , eAuthor = Nothing+    , ePublishDate = Nothing+    }++_subject :: Subject+_subject =+  Subject {sTitle = "Title", sDescription = "Description", sTags = ["tag"]}++_directory :: Directory+_directory = Directory {dSubject = _subject, dEntries = [_entry]}++_recipe :: (MonadThrow m) => Recipe m+_recipe = Recipe {rSubject = _subject, rSteps = [], rMiddlewares = []}++e2F :: (MonadThrow m) => Entry -> m [Entry]+e2F entry = return [entry]++_selector :: Selector+_selector =+  Selector+    { selURI = Nothing+    , selGUID = Nothing+    , selTitle = Nothing+    , selDescription = Nothing+    , selAuthor = Nothing+    , selPublishDate = Nothing+    }
+ test/Spec.hs view
@@ -0,0 +1,1 @@+{-# OPTIONS_GHC -F -pgmF hspec-discover #-}