robots-txt-0.4.1.9: src/Network/HTTP/Robots.hs
{-# LANGUAGE OverloadedStrings #-}
module Network.HTTP.Robots where
import Control.Applicative
import Control.Monad
import Data.Attoparsec.ByteString.Char8 hiding (skipSpace)
import qualified Data.Attoparsec.Text as AT (isEndOfLine)
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as BS
import Data.Char (toUpper)
import Data.Either (partitionEithers)
import Data.List (find)
import Data.Maybe (catMaybes)
import Data.Ratio
import Data.Time.Clock
import Data.Time.LocalTime ()
type Robot = ([([UserAgent], [Directive])], [Unparsable])
type Unparsable = ByteString
data UserAgent = Wildcard | Literal ByteString
deriving (Show, Eq)
type Path = ByteString
-- http://www.conman.org/people/spc/robots2.html
-- This was never actually accepted as a standard,
-- but some sites do use it.
type TimeInterval = (DiffTime, DiffTime)
-- Crawldelay may have a decimal point
-- http://help.yandex.com/webmaster/controlling-robot/robots-txt.xml
-- Added directives NoArchive, NoSnippet, NoTranslate, SiteMap.
-- http://bloganddiscussion.com/anythingcomputer/1/robots-txt-noarchive-nocache-nosnippet/
data Directive
= Allow Path
| Disallow Path
| CrawlDelay
{ crawlDelay :: Rational,
timeInterval :: TimeInterval
}
| NoArchive Path
| NoSnippet Path
| NoTranslate Path
| -- not used by Google, Yahoo or Live Search/Bing
-- http://searchengineland.com/a-deeper-look-at-robotstxt-17573
NoIndex Path
deriving (Show, Eq)
-- For use in the attoparsec monad, allows to reparse a sub expression
subParser :: Parser a -> ByteString -> Parser a
subParser p = either (const mzero) return . parseOnly p
-- Seems the rational parser is unsecure in the presence of an exponent
-- but since there is no alternative to parse a rational, we just to refuse
-- to parse numbers with 'e' / exponent
-- https://hackage.haskell.org/package/attoparsec-0.12.1.0/docs/Data-Attoparsec-ByteString-Char8.html#v:rational
safeParseRational :: Parser Rational
safeParseRational = do
(bs, _) <- match scientific
if BS.elem 'e' bs || BS.elem 'E' bs
then mzero
else subParser rational bs
-- Yeah, robots.txt should be ASCII, but some sites
-- include the UTF-8 marker at start.
-- We just drop it, but handle the file as ASCII.
dropUTF8BOM :: ByteString -> ByteString
dropUTF8BOM bs =
if BS.take 3 bs
== ( '\239'
`BS.cons` '\187'
`BS.cons` '\191'
`BS.cons` BS.empty
)
then BS.drop 3 bs
else bs
parseHourMinute :: Parser (Integer, Integer)
parseHourMinute = parseWithColon <|> parseWithoutColon
where
parseWithColon = do
hours <- skipSpace >> decimal
void $ skipSpace >> char ':'
mins <- skipSpace >> decimal
return (hours, mins)
parseWithoutColon = do
h <- Data.Attoparsec.ByteString.Char8.take 2 >>= subParser decimal
m <- Data.Attoparsec.ByteString.Char8.take 2 >>= subParser decimal
return (h, m)
parseTimeInterval :: Parser TimeInterval
parseTimeInterval = do
(hours_start, mins_start) <- parseHourMinute
void $ (skipSpace >> char '-' >> skipSpace) <|> skipSpace
(hours_end, mins_end) <- parseHourMinute
return
( secondsToDiffTime (hours_start * 60 * 60 + mins_start * 60),
secondsToDiffTime (hours_end * 60 * 60 + mins_end * 60)
)
allDay :: TimeInterval
allDay =
( secondsToDiffTime 0,
secondsToDiffTime (24 * 60 * 60) -- because of leap seconds
)
parseRequestRate :: Parser Directive
parseRequestRate = do
void $ stringCI "Request-rate:"
docs <- skipSpace >> decimal
void $ skipSpace >> char '/'
ptim <- skipSpace >> decimal
units <-
skipSpace
>> ( (char 's' >> return (1 :: Integer))
<|> (char 'm' >> return (60 :: Integer))
<|> (char 'h' >> return (60 * 60 :: Integer))
<|> return (1 :: Integer)
)
tint <- skipSpace >> (parseTimeInterval <|> return allDay)
return $ CrawlDelay ((ptim * units) % docs) tint
parseVisitTime :: Parser Directive
parseVisitTime = do
void $ stringCI "Visit-time:"
tint <- skipSpace >> parseTimeInterval
return $ CrawlDelay (0 % 1) tint
parseCrawlDelay :: Parser Directive
parseCrawlDelay = do
delay <- stringCI "Crawl-Delay:" >> skipSpace >> safeParseRational
return $ CrawlDelay delay allDay
-- ... yeah.
strip :: ByteString -> ByteString
strip = BS.reverse . BS.dropWhile (== ' ') . BS.reverse . BS.dropWhile (== ' ')
-- | parseRobots is the main entry point for parsing a robots.txt file.
parseRobots :: ByteString -> Either String Robot
parseRobots input = case parsed of
-- special case no parsable lines and rubbish
Right ([], out@(_ : _)) ->
Left ("no parsable lines: " ++ show out)
_ -> parsed
where
parsed =
parseOnly robotP
. BS.unlines
-- Filthy hack to account for the fact we don't grab sitemaps
-- properly. people seem to just whack them anywhere, which makes it
-- hard to write a nice parser for them.
. filter (not . BS.isPrefixOf "SITEMAP:" . BS.map toUpper)
. filter (not . BS.isPrefixOf "HOST:" . BS.map toUpper)
. filter (\x -> BS.head x /= '#')
. filter (not . BS.null)
. map strip
. BS.lines
-- worst way of handling windows newlines ever
. BS.filter (/= '\r')
. dropUTF8BOM
$ input
robotP :: Parser Robot
robotP = do
(dirs, unparsable) <- partitionEithers <$> many (eitherP agentDirectiveP unparsableP) <?> "robot"
return (dirs, filter (/= "") unparsable)
unparsableP :: Parser ByteString
unparsableP = takeTill AT.isEndOfLine <* endOfLine -- char '\n'
agentDirectiveP :: Parser ([UserAgent], [Directive])
agentDirectiveP = (,) <$> many1 agentP <*> many1 directiveP <?> "agentDirective"
skipSpace :: Parser ()
skipSpace = skipWhile (\x -> x == ' ' || x == '\t')
directiveP :: Parser Directive
directiveP = do
skipSpace
choice
[ stringCI "Disallow:"
>> skipSpace
>> ( (Disallow <$> tokenP)
<|>
-- This requires some explanation.
-- The RFC suggests that an empty Disallow line means
-- anything is allowed. Being semantically equivalent to
-- 'Allow: "/"', I have chosen to change it here
-- rather than carry the bogus distinction around.
(endOfLine >> return (Allow "/"))
),
stringCI "Allow:"
>> skipSpace
>> ( (Allow <$> tokenP)
<|>
-- If an empty disallow means 'disallow nothing',
-- an empty allow means 'allow nothing'. Right?
-- Not sure, actually, but only the americanexpress.com
-- has such a case, which in one hand I am tempted
-- to consider an error... but for now:
(endOfLine >> return (Disallow "/"))
),
parseCrawlDelay,
parseRequestRate,
parseVisitTime,
NoArchive <$> (stringCI "Noarchive:" >> skipSpace >> tokenP),
NoSnippet <$> (stringCI "Nosnippet:" >> skipSpace >> tokenP),
NoTranslate <$> (stringCI "Notranslate:" >> skipSpace >> tokenP),
NoIndex <$> (stringCI "Noindex:" >> skipSpace >> tokenP)
]
<* commentsP
<?> "directive"
agentP :: Parser UserAgent
agentP = do
void $ stringCI "user-agent:"
skipSpace
( (string "*" >> return Wildcard)
<|> (Literal <$> tokenWithSpacesP)
)
<* skipSpace
<* endOfLine
<?> "agent"
commentsP :: Parser ()
commentsP =
skipSpace
>> ( (string "#" >> takeTill AT.isEndOfLine >> endOfLine)
<|> endOfLine
<|> return ()
)
tokenP :: Parser ByteString
tokenP = skipSpace >> takeWhile1 (not . isSpace) <* skipSpace
tokenWithSpacesP :: Parser ByteString
tokenWithSpacesP =
skipSpace
>> takeWhile1 (not . (\c -> c == '#' || AT.isEndOfLine c))
<* takeTill AT.isEndOfLine
-- I lack the art to make this prettier.
-- Currently does not take into account the CrawlDelay / Request Rate directives
canAccess :: ByteString -> Robot -> Path -> Bool
canAccess _ _ "/robots.txt" = True -- special-cased
canAccess agent (robot, _) path = case stanzas of
[] -> True
((_, directives) : _) -> matchingDirective directives
where
stanzas =
catMaybes
[ find (any (`isLiteralSubstring` agent) . fst) robot,
find ((Wildcard `elem`) . fst) robot
]
isLiteralSubstring (Literal a) us = a `BS.isInfixOf` us
isLiteralSubstring _ _ = False
matchingDirective [] = True
matchingDirective (x : xs) = case x of
Allow robot_path ->
robot_path `BS.isPrefixOf` path || matchingDirective xs
Disallow robot_path ->
not (robot_path `BS.isPrefixOf` path) && matchingDirective xs
_ -> matchingDirective xs