packages feed

imm-1.9.0.0: src/main/Alternate.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes        #-}
{-# LANGUAGE TypeApplications  #-}
{-# LANGUAGE TypeFamilies      #-}
module Alternate (AlternateException(..), resolveFeedURI) where

-- {{{ Imports
import           Control.Exception.Safe
import           Data.Aeson
import qualified Data.ByteString.Char8  as Char8
import qualified Data.Text              as Text
import           Imm.Feed
import qualified Imm.HTTP               as HTTP
import           Imm.Logger             as Logger
import           Imm.Pretty
import           Pipes
import           Pipes.ByteString       as Pipes (toHandle)
import           Safe
import           System.IO              (hClose)
import           System.Process.Typed
import           URI.ByteString
-- }}}


newtype AlternateException = FeedNotFound URI
  deriving(Eq, Show)

instance Exception AlternateException where
  displayException = show . pretty

instance Pretty AlternateException where
  pretty (FeedNotFound uri) = "No feeds found at" <+> prettyURI uri


data AlternateLink = AlternateLink Text Text Text
  deriving(Eq, Ord, Show)

instance Pretty AlternateLink where
  pretty (AlternateLink title linkType uri) = "Alternate link" <+> braces (hsep parameters) where
    parameters = [ "title" <> equals <> dquotes (pretty title)
                 , "type" <> equals <> dquotes (pretty linkType)
                 , "href" <> equals <> dquotes (pretty uri)
                 ]

instance FromJSON AlternateLink where
  parseJSON (Object v) = AlternateLink <$> v .: "title" <*> v .: "type" <*> v .: "href"
  parseJSON _          = mzero


data FeedType = RssXml | AtomXml
  deriving(Eq, Ord, Read, Show)

asFeedType :: MonadFail m => Text -> m FeedType
asFeedType "application/rss+xml"  = pure RssXml
asFeedType "application/atom+xml" = pure AtomXml
asFeedType t                      = fail $ "Invalid feed type: " <> show t


asFeedURI :: MonadFail m => URI -> Text -> m URI
asFeedURI baseURI href = let bytes = encodeUtf8 @Text href in
  case parseURI laxURIParserOptions bytes of
    Right uri -> pure uri
    _ -> case parseRelativeRef laxURIParserOptions bytes of
      Right relativeRef -> pure $ mergeURIs baseURI relativeRef
      Left e            -> fail $ show e

mergeURIs :: URI -> URIRef Relative -> URI
mergeURIs uri relativeRef = URI scheme authority path query fragment where
  scheme = uriScheme uri
  authority = rrAuthority relativeRef <|> uriAuthority uri
  path = mergePaths (uriPath uri) (rrPath relativeRef)
  query = rrQuery relativeRef
  fragment = rrFragment relativeRef

mergePaths :: ByteString -> ByteString -> ByteString
mergePaths basePath path = case Char8.head path of
  '/' -> path
  _   -> basePath <> "/" <> path


data FeedLink = FeedLink
  { _feedTitle :: Text
  , _feedType  :: FeedType
  , _feedURI   :: URI
  } deriving(Eq, Ord, Show)

asFeedLink :: MonadFail m => URI -> AlternateLink -> m FeedLink
asFeedLink baseURI (AlternateLink a b c) = FeedLink a <$> asFeedType b <*> asFeedURI baseURI c


extractAlternateLinks :: MonadIO m => Logger.Handle IO -> Producer' ByteString IO () -> m [AlternateLink]
extractAlternateLinks logger html = io $ withProcessWait pup $ \pupProcess -> do
  log logger Debug $ pretty $ show @String pupProcess

  runEffect $ html >-> toHandle (getStdin pupProcess)
  hClose (getStdin pupProcess)

  links <- getStdout pupProcess & atomically <&> decode <&> fromMaybe mempty
  log logger Debug $ "Found alternate links:" <+> pretty links

  return links
  where pup = proc "pup" ["html head link[rel=\"alternate\"] json{}"]
          & setStdin createPipe
          & setStdout byteStringOutput
          & setStderr nullStream


resolveFeedURI :: m ~ IO => Logger.Handle m -> HTTP.Handle m -> FeedLocation -> m URI
resolveFeedURI _ _ (FeedDirectURI uri) = pure uri
resolveFeedURI logger httpClient (FeedAlternateLink uri title) = HTTP.withGet logger httpClient uri $ \html -> extractAlternateLinks logger html
  <&> concatMap (asFeedLink uri)
  <&> filterByTitle
  <&> maximumByMay compareFeeds
  >>= maybe (throwM $ FeedNotFound uri) (return . _feedURI)
  where filterByTitle = if Text.null title then id else filter (\f -> _feedTitle f == title)
        compareFeeds (FeedLink _ AtomXml _) (FeedLink _ RssXml _) = GT
        compareFeeds (FeedLink _ RssXml _) (FeedLink _ AtomXml _) = LT
        compareFeeds _ _                                          = EQ