packages feed

arxiv-client-0.1.0.0: src/Arxiv/Client.hs

{-# LANGUAGE OverloadedStrings, OverloadedRecordDot #-}

module Arxiv.Client
  ( queryArxiv
  , queryArxivIO
  , queryArxivRaw
  , queryArxivRawIO
  , buildRequestUrlText
  , downloadTo
  ) where

import           Control.Applicative ((<|>), asum)
import           Control.Monad.IO.Class (MonadIO(..))
import           Data.Maybe (listToMaybe, fromMaybe, mapMaybe)
import           Data.Text (Text)
import qualified Data.Text as T
import qualified Data.ByteString.Lazy as LBS
import           Data.Time (UTCTime, parseTimeM, defaultTimeLocale)
import           Data.Time.Format.ISO8601 (iso8601ParseM)
import           Arxiv.Query
import           Arxiv.Entry
import           Network.HTTP.Req
import qualified Text.XML as X
import           Text.XML.Cursor
import qualified Text.URI as URI

axisLocal :: Text -> Axis
axisLocal ln = element (nNamespace ln)

-- Build the query string for debugging (best-effort).
buildRequestUrlText :: ArxivQuery -> Text
buildRequestUrlText q =
  let base = "http://export.arxiv.org/api/query?"
      kv k v = k <> "=" <> v
      joinAmp = T.intercalate "&"
      parts =
        [ kv "search_query" (renderSearchQuery q)
        , kv "start"        (tshow (qStart q))
        , kv "max_results"  (tshow (qMax   q))
        ] <>
        maybe [] (\sb -> [kv "sortBy"    (sortByText sb)]) (qSortBy q) <>
        maybe [] (\so -> [kv "sortOrder" (sortOrderText so)]) (qSortOrder q) <>
        [kv "id_list" (T.intercalate "," (qIdList q)) | not (null (qIdList q))]
  in base <> joinAmp parts

-- Raw response (XML bytes) + parsed entries, for debugging.
queryArxivRaw :: (MonadHttp m, MonadIO m) => ArxivQuery -> m (LBS.ByteString, [ArxivEntry])
queryArxivRaw q = do
  let baseUrl = https "export.arxiv.org" /: "api" /: "query"
      mkParam name val = name =: (val :: Text)
      params =
           mkParam "search_query" (renderSearchQuery q)
        <> mkParam "start"       (tshow (qStart q))
        <> mkParam "max_results" (tshow (qMax   q))
        <> maybe mempty (\sb -> "sortBy"    =: sortByText sb) (qSortBy q)
        <> maybe mempty (\so -> "sortOrder" =: sortOrderText so) (qSortOrder q)
        <> (if null (qIdList q) then mempty else "id_list" =: T.intercalate "," (qIdList q))
  r <- req GET baseUrl NoReqBody lbsResponse params
  let bs = responseBody r
  pure (bs, parseFeed bs)

-- queryArxiv now just calls queryArxivRaw and returns parsed entries.
queryArxiv :: (MonadHttp m, MonadIO m) => ArxivQuery -> m [ArxivEntry]
queryArxiv q = snd <$> queryArxivRaw q
-----------------------------

queryArxivRawIO :: ArxivQuery -> IO (LBS.ByteString, [ArxivEntry])
queryArxivRawIO q = runReq defaultHttpConfig (queryArxivRaw q)

-- | Run in IO.
queryArxivIO :: ArxivQuery -> IO [ArxivEntry]
queryArxivIO q = runReq defaultHttpConfig (queryArxiv q)

tshow :: Show a => a -> Text
tshow = T.pack . show

sortByText :: SortBy -> Text
sortByText Relevance       = "relevance"
sortByText LastUpdatedDate = "lastUpdatedDate"
sortByText SubmittedDate   = "submittedDate"

sortOrderText :: SortOrder -> Text
sortOrderText Asc  = "ascending"
sortOrderText Desc = "descending"

nNamespace :: Text -> X.Name
nNamespace local = X.Name local (Just "http://www.w3.org/2005/Atom") Nothing

nAttr :: Text -> X.Name
nAttr local = X.Name local Nothing Nothing

parseFeed :: LBS.ByteString -> [ArxivEntry]
parseFeed lbs =
  let doc     = X.parseLBS_ X.def lbs
      rootCur = fromDocument doc
      -- Descendant axis + local-name match (namespace-agnostic).
      entries = rootCur $// axisLocal "entry"
  in mapMaybe cursorToEntry entries

contents :: Cursor -> [Text]
contents cur = case cur.node of
  X.NodeContent t -> [t]
  _               -> case cur $/ content of
                       [] -> []
                       ts -> ts

-- | Safely take the first concatenated text of an element.
txtFirstOf :: Cursor -> Text -> Maybe Text
txtFirstOf cur local =
  case concatMap contents (cur $// axisLocal local) of
    [] -> Nothing
    xs -> Just (T.strip (T.concat xs))

cursorToEntry :: Cursor -> Maybe ArxivEntry
cursorToEntry e = do
  title'     <- txtFirstOf e "title"
  summary'   <- txtFirstOf e "summary"
  idURL      <- txtFirstOf e "id"
  published' <- txtFirstOf e "published" >>= parseTime
  updated'   <- txtFirstOf e "updated" >>= parseTime

  -- Authors: for each <author>, concat all <name> text nodes. Missing -> "".
  let authors' =
        [ T.strip . T.concat $ concatMap contents (aCur $/ laxElement "name")
        | aCur <- e $// axisLocal "author"
        ]

      cats =
        (e $// axisLocal "category") >>= attribute (nAttr "term")

      -- pick <link ... href="..."> by attribute match
      pickLink :: Text -> Text -> Maybe Text
      pickLink attrName attrVal =
        let links = e $// (axisLocal "link" >=> attributeIs (nAttr attrName) attrVal)
        in listToMaybe (links >>= attribute (nAttr "href"))

      arxId = lastSegment idURL

      absL = fromMaybe ("https://arxiv.org/abs/" <> arxId)
             (pickLink "rel" "alternate")

      pdfL = fromMaybe ("https://arxiv.org/pdf/" <> arxId <> ".pdf")
             ( pickLink "title" "pdf"
               <|> pickLink "type" "application/pdf"
             )

      srcL = "https://arxiv.org/src/" <> arxId

  pure ArxivEntry
        { arxivId   = arxId
        , absUrl    = absL
        , pdfUrl    = pdfL
        , sourceUrl = srcL
        , title     = title'
        , summary   = summary'
        , authors   = authors'
        , categories= cats
        , published = published'
        , updated   = updated'
        }

parseTime :: Text -> Maybe UTCTime
parseTime t = --iso8601ParseM . T.unpack
  let s = T.unpack (T.strip t)
  in asum
       [ iso8601ParseM s
       , parseTimeM True defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ"      s
       , parseTimeM True defaultTimeLocale "%Y-%m-%dT%H:%M:%S%QZ"    s
       , parseTimeM True defaultTimeLocale "%Y-%m-%dT%H:%M:%S%Ez"    s
       , parseTimeM True defaultTimeLocale "%Y-%m-%dT%H:%M:%S%Ez"    s
       ]

lastSegment :: Text -> Text
lastSegment url =
  case reverse (T.splitOn "/" (T.takeWhile (/= '#') url)) of
    (x:_) | not (T.null x) -> x
    _                      -> url

-- | Download any http/https URL to a file, using 'modern-uri'.
downloadTo :: (MonadHttp m, MonadIO m) => Text -> FilePath -> m ()
downloadTo urlT fp =
  case URI.mkURI urlT of
    Left _err -> error "downloadTo: invalid URL"
    Right uri ->
      case useHttpsURI uri of
        Just (u, opt) -> do
          r <- req GET u NoReqBody lbsResponse opt
          liftIO (LBS.writeFile fp (responseBody r))
        Nothing ->
          case useHttpURI uri of
            Just (u, opt) -> do
              r <- req GET u NoReqBody lbsResponse opt
              liftIO (LBS.writeFile fp (responseBody r))
            Nothing ->
              error "downloadTo: unsupported scheme (expect http/https)"