scalpel 0.3.1 → 0.4.0
raw patch · 11 files changed
+586/−228 lines, 11 filesdep +faildep +vectordep ~tagsoupPVP ok
version bump matches the API change (PVP)
Dependencies added: fail, vector
Dependency ranges changed: tagsoup
API changes (from Hackage documentation)
- Text.HTML.Scalpel: Any :: Any
- Text.HTML.Scalpel: class AttributeName k
- Text.HTML.Scalpel: class Selectable s
- Text.HTML.Scalpel: class TagName t
- Text.HTML.Scalpel: data Any
- Text.HTML.Scalpel: toSelector :: Selectable s => s -> Selector
+ Text.HTML.Scalpel: AnyAttribute :: AttributeName
+ Text.HTML.Scalpel: AnyTag :: TagName
+ Text.HTML.Scalpel: AttributeString :: String -> AttributeName
+ Text.HTML.Scalpel: TagString :: String -> TagName
+ Text.HTML.Scalpel: anySelector :: Selector
+ Text.HTML.Scalpel: data AttributeName
+ Text.HTML.Scalpel: data TagName
+ Text.HTML.Scalpel: infixl 5 //
+ Text.HTML.Scalpel: infixl 6 @=~
+ Text.HTML.Scalpel: infixl 9 @:
+ Text.HTML.Scalpel: position :: (Ord str, StringLike str) => Scraper str Int
+ Text.HTML.Scalpel: tagSelector :: String -> Selector
- Text.HTML.Scalpel: (//) :: (Selectable a, Selectable b) => a -> b -> Selector
+ Text.HTML.Scalpel: (//) :: Selector -> Selector -> Selector
- Text.HTML.Scalpel: (@:) :: TagName tag => tag -> [AttributePredicate] -> Selector
+ Text.HTML.Scalpel: (@:) :: TagName -> [AttributePredicate] -> Selector
- Text.HTML.Scalpel: (@=) :: AttributeName key => key -> String -> AttributePredicate
+ Text.HTML.Scalpel: (@=) :: AttributeName -> String -> AttributePredicate
- Text.HTML.Scalpel: (@=~) :: (AttributeName key, RegexLike re String) => key -> re -> AttributePredicate
+ Text.HTML.Scalpel: (@=~) :: RegexLike re String => AttributeName -> re -> AttributePredicate
- Text.HTML.Scalpel: attr :: (Ord str, Show str, StringLike str, Selectable s) => String -> s -> Scraper str str
+ Text.HTML.Scalpel: attr :: (Ord str, Show str, StringLike str) => String -> Selector -> Scraper str str
- Text.HTML.Scalpel: attrs :: (Ord str, Show str, StringLike str, Selectable s) => String -> s -> Scraper str [str]
+ Text.HTML.Scalpel: attrs :: (Ord str, Show str, StringLike str) => String -> Selector -> Scraper str [str]
- Text.HTML.Scalpel: chroot :: (Ord str, StringLike str, Selectable s) => s -> Scraper str a -> Scraper str a
+ Text.HTML.Scalpel: chroot :: (Ord str, StringLike str) => Selector -> Scraper str a -> Scraper str a
- Text.HTML.Scalpel: chroots :: (Ord str, StringLike str, Selectable s) => s -> Scraper str a -> Scraper str [a]
+ Text.HTML.Scalpel: chroots :: (Ord str, StringLike str) => Selector -> Scraper str a -> Scraper str [a]
- Text.HTML.Scalpel: html :: (Ord str, StringLike str, Selectable s) => s -> Scraper str str
+ Text.HTML.Scalpel: html :: (Ord str, StringLike str) => Selector -> Scraper str str
- Text.HTML.Scalpel: htmls :: (Ord str, StringLike str, Selectable s) => s -> Scraper str [str]
+ Text.HTML.Scalpel: htmls :: (Ord str, StringLike str) => Selector -> Scraper str [str]
- Text.HTML.Scalpel: innerHTML :: (Ord str, StringLike str, Selectable s) => s -> Scraper str str
+ Text.HTML.Scalpel: innerHTML :: (Ord str, StringLike str) => Selector -> Scraper str str
- Text.HTML.Scalpel: innerHTMLs :: (Ord str, StringLike str, Selectable s) => s -> Scraper str [str]
+ Text.HTML.Scalpel: innerHTMLs :: (Ord str, StringLike str) => Selector -> Scraper str [str]
- Text.HTML.Scalpel: text :: (Ord str, StringLike str, Selectable s) => s -> Scraper str str
+ Text.HTML.Scalpel: text :: (Ord str, StringLike str) => Selector -> Scraper str str
- Text.HTML.Scalpel: texts :: (Ord str, StringLike str, Selectable s) => s -> Scraper str [str]
+ Text.HTML.Scalpel: texts :: (Ord str, StringLike str) => Selector -> Scraper str [str]
Files
- CHANGELOG.md +14/−0
- README.md +122/−0
- benchmarks/Main.hs +25/−9
- scalpel.cabal +9/−6
- src/Text/HTML/Scalpel.hs +5/−4
- src/Text/HTML/Scalpel/Internal/Scrape.hs +100/−41
- src/Text/HTML/Scalpel/Internal/Scrape/StringLike.hs +2/−1
- src/Text/HTML/Scalpel/Internal/Select.hs +195/−103
- src/Text/HTML/Scalpel/Internal/Select/Combinators.hs +7/−7
- src/Text/HTML/Scalpel/Internal/Select/Types.hs +32/−44
- tests/TestMain.hs +75/−13
CHANGELOG.md view
@@ -2,6 +2,20 @@ ## HEAD +## 0.4.0++- Add the `chroot` tricks (#23 and #25) to README.md and added examples.+- Fix backtracking that occurs when using `guard` and `chroot`.+- Fix bug where the same tag may appear in the result set multiple times.+- Performance optimizations when using the (//) operator.+- Make Scraper an instance of MonadFail. Practically this means that failed+ pattern matches in `<-` expressions within a do block will evaluate to mzero+ instead of throwing an error and bringing down the entire script.+- Pluralized scrapers will now return the empty list instead mzero when there+ are no matches.+- Add the `position` scraper which provides the index of the current sub-tree+ within the context of a `chroots`'s do-block.+ ## 0.3.1 - Added the `innerHTML` and `innerHTMLs` scraper.
README.md view
@@ -109,3 +109,125 @@ imageURL <- attr "src" $ "img" @: [hasClass "image"] return $ ImageComment author imageURL ```++Tips & Tricks+-------------++The primitives provided by scalpel are intentionally minimalistic with the+assumption being that users will be able to build up complex functionality by+combining them with functions that work on existing type classes (Monad,+Applicative, Alternative, etc.).++This section gives examples of common tricks for building up more complex+behavior from the simple primitives provided by this library.++### OverloadedStrings++`Selector`, `TagName` and `AttributeName` are all `IsString` instances, and+thus it is convenient to use scalpel with `OverloadedStrings` enabled. If not+using `OverloadedStrings`, all tag names must be wrapped with `tagSelector`.++### Matching Wildcards++Scalpel has 3 different wildcard values each corresponding to a distinct use case.++- `anySelector` is used to match all tags:++ `textOfAllTags = texts anySelector`++- `AnyTag` is used when matching all tags with some attribute constraint. For+ example, to match all tags with the attribute `class` equal to `"button"`:++ `textOfTagsWithClassButton = texts $ AnyTag @: [hasClass "button"]`++- `AnyAttribute` is used when matching tags with some arbitrary attribute equal+ to a particular value. For example, to match all tags with some attribute+ equal to `"button"`:++ `textOfTagsWithAnAttributeWhoseValueIsButton = texts $ AnyTag @: [AnyAttribute @= "button"]`++### Complex Predicates++It is possible to run into scenarios where the name and attributes of a tag are+not sufficient to isolate interesting tags and properties of child tags need to+be considered.++In these cases the `guard` function of the `Alternative` type class can be+combined with `chroot` and `anySelector` to implement predicates of arbitrary+complexity.++Building off the above example, consider a use case where we would like find the+html contents of a comment that mentions the word "cat".++The strategy will be the following:++1. Isolate the comment div using `chroot`.++2. Then within the context of that div the textual contents can be retrieved+ with `text anySelector`. This works because the first tag within the current context+ is the div tag selected by chroot, and the `anySelector` selector will match the+ first tag within the current context.++3. Then the predicate that `"cat"` appear in the text of the comment will be+ enforced using `guard`. If the predicate fails, scalpel will backtrack and+ continue the search for divs until one is found that matches the predicate.++4. Return the desired HTML content of the comment div.++```haskell+catComment :: Scraper String String+catComment =+ -- 1. First narrow the current context to the div containing the comment's+ -- textual content.+ chroot ("div" @: [hasClass "comment", hasClass "text"]) $ do+ -- 2. anySelector can be used to access the root tag of the current context.+ contents <- text anySelector+ -- 3. Skip comment divs that do not contain "cat".+ guard ("cat" `isInfixOf` contents)+ -- 4. Generate the desired value.+ html anySelector+```++For the full source of this example, see+[complex-predicates](https://github.com/fimad/scalpel/tree/master/examples/complex-predicates/)+in the examples directory.++### Generalized Repetition++The pluralized versions of the primitive scrapers (`texts`, `attrs`, `htmls`)+allow the user to extract content from all of the tags matching a given+selector. For more complex scraping tasks it will at times be desirable to be+able to extract multiple values from the same tag.++Like the previous example, the trick here is to use a combination of the+`chroots` function and the `anySelector` selector.++Consider an extension to the original example where image comments may contain+some alt text and the desire is to return a tuple of the alt text and the URLs+of the images.++The strategy will be the following:++1. to isolate each img tag using `chroots`.++2. Then within the context of each img tag, use the `anySelector` selector to extract+ the alt and src attributes from the current tag.++3. Create and return a tuple of the extracted attributes.++```haskell+altTextAndImages :: Scraper String [(String, URL)]+altTextAndImages =+ -- 1. First narrow the current context to each img tag.+ chroots "img" $ do+ -- 2. Use anySelector to access all the relevant content from the the currently+ -- selected img tag.+ altText <- attr "alt" anySelector+ srcUrl <- attr "src" anySelector+ -- 3. Combine the retrieved content into the desired final result.+ return (altText, srcUrl)+```++For the full source of this example, see+[generalized-repetition](https://github.com/fimad/scalpel/tree/master/examples/generalized-repetition/)+in the examples directory.
benchmarks/Main.hs view
@@ -1,9 +1,13 @@+{-# LANGUAGE OverloadedStrings #-}+ import Text.HTML.Scalpel import Control.Applicative ((<$>)) import Control.Monad (replicateM_) import Criterion.Main (bgroup, bench, defaultMain, nf)+import Data.Foldable (foldr') import qualified Data.Text as T+import qualified Text.HTML.TagSoup as TagSoup main :: IO ()@@ -18,24 +22,36 @@ , bench "10000" $ nf sumListTags nested10000 ] , bgroup "many-selects" [- bench "10" $ nf (manySelects 10) nested1000+ bench "10" $ nf (manySelects 10) nested1000 , bench "100" $ nf (manySelects 100) nested1000 , bench "1000" $ nf (manySelects 1000) nested1000 ]+ , bgroup "many-//" [+ bench "10" $ nf (manySelectNodes 10) nested1000+ , bench "100" $ nf (manySelectNodes 100) nested1000+ , bench "1000" $ nf (manySelectNodes 1000) nested1000+ ] ] -makeNested :: Int -> T.Text-makeNested i = T.concat [T.replicate i open, one, T.replicate i close]+makeNested :: Int -> [TagSoup.Tag T.Text]+makeNested i = TagSoup.parseTags+ $ T.concat [T.replicate i open, one, T.replicate i close] where open = T.pack "<tag>" close = T.pack "</tag>" one = T.pack "1" -sumListTags :: T.Text -> Maybe Integer-sumListTags testData = scrapeStringLike testData- $ (sum . map (const 1)) <$> texts "tag"+sumListTags :: [TagSoup.Tag T.Text] -> Maybe Integer+sumListTags testData = flip scrape testData+ $ sum <$> chroots "tag" (return 1) -manySelects :: Int -> T.Text -> Maybe ()-manySelects i testData = scrapeStringLike testData+manySelects :: Int -> [TagSoup.Tag T.Text] -> Maybe ()+manySelects i testData = flip scrape testData $ replicateM_ i- $ texts "tag"+ $ sum <$> chroots "tag" (return 1)++manySelectNodes :: Int -> [TagSoup.Tag T.Text] -> Maybe T.Text+manySelectNodes i testData = flip scrape testData+ $ text+ $ foldr' (//) (tagSelector "tag")+ $ replicate (i - 1) (tagSelector "tag")
scalpel.cabal view
@@ -1,5 +1,5 @@ name: scalpel-version: 0.3.1+version: 0.4.0 synopsis: A high level web scraping library for Haskell. description: Scalpel is a web scraping library inspired by libraries like Parsec and@@ -24,7 +24,7 @@ source-repository this type: git location: https://github.com/fimad/scalpel.git- tag: v0.3.1+ tag: v0.4.0 library other-extensions:@@ -47,10 +47,12 @@ , containers , curl >= 1.3.4 , data-default+ , fail , regex-base , regex-tdfa , tagsoup >= 0.12.2 , text+ , vector default-extensions: ParallelListComp , PatternGuards@@ -79,8 +81,9 @@ hs-source-dirs: benchmarks main-is: Main.hs build-depends:- base >=4.7 && <5- , criterion >=1.1- , scalpel- , text+ base >=4.7 && <5+ , criterion >=1.1+ , scalpel+ , tagsoup+ , text ghc-options: -Wall
src/Text/HTML/Scalpel.hs view
@@ -100,12 +100,12 @@ module Text.HTML.Scalpel ( -- * Selectors Selector-, Selectable (..) , AttributePredicate-, AttributeName-, TagName+, AttributeName (..)+, TagName (..)+, tagSelector -- ** Wildcards-, Any (..)+, anySelector -- ** Tag combinators , (//) -- ** Attribute predicates@@ -128,6 +128,7 @@ , texts , chroot , chroots+, position -- ** Executing scrapers , scrape , scrapeStringLike
src/Text/HTML/Scalpel/Internal/Scrape.hs view
@@ -12,6 +12,7 @@ , texts , chroot , chroots+, position ) where import Text.HTML.Scalpel.Internal.Select@@ -21,6 +22,8 @@ import Control.Monad import Data.Maybe +import qualified Control.Monad.Fail as Fail+import qualified Data.Vector as Vector import qualified Text.HTML.TagSoup as TagSoup import qualified Text.StringLike as TagSoup @@ -28,7 +31,7 @@ -- | A value of 'Scraper' @a@ defines a web scraper that is capable of consuming -- a list of 'TagSoup.Tag's and optionally producing a value of type @a@. newtype Scraper str a = MkScraper {- scrapeOffsets :: [(TagSoup.Tag str, CloseOffset)] -> Maybe a+ scrapeTagSpec :: TagSpec str -> Maybe a } instance Functor (Scraper str) where@@ -47,6 +50,7 @@ | otherwise = b tags instance Monad (Scraper str) where+ fail = Fail.fail return = pure (MkScraper a) >>= f = MkScraper combined where combined tags | (Just aVal) <- a tags = let (MkScraper b) = f aVal@@ -57,11 +61,14 @@ mzero = empty mplus = (<|>) +instance Fail.MonadFail (Scraper str) where+ fail _ = mzero+ -- | The 'scrape' function executes a 'Scraper' on a list of -- 'TagSoup.Tag's and produces an optional value. scrape :: (Ord str, TagSoup.StringLike str) => Scraper str a -> [TagSoup.Tag str] -> Maybe a-scrape s = scrapeOffsets s . tagWithOffset . TagSoup.canonicalizeTags+scrape s = scrapeTagSpec s . tagsToSpec . TagSoup.canonicalizeTags -- | The 'chroot' function takes a selector and an inner scraper and executes -- the inner scraper as if it were scraping a document that consists solely of@@ -69,18 +76,19 @@ -- -- This function will match only the first set of tags matching the selector, to -- match every set of tags, use 'chroots'.-chroot :: (Ord str, TagSoup.StringLike str, Selectable s)- => s -> Scraper str a -> Scraper str a-chroot selector (MkScraper inner) = MkScraper- $ join . (inner <$>)- . listToMaybe . select selector+chroot :: (Ord str, TagSoup.StringLike str)+ => Selector -> Scraper str a -> Scraper str a+chroot selector inner = do+ maybeResult <- listToMaybe <$> chroots selector inner+ guard (isJust maybeResult)+ return $ fromJust maybeResult -- | The 'chroots' function takes a selector and an inner scraper and executes -- the inner scraper as if it were scraping a document that consists solely of -- the tags corresponding to the selector. The inner scraper is executed for -- each set of tags matching the given selector.-chroots :: (Ord str, TagSoup.StringLike str, Selectable s)- => s -> Scraper str a -> Scraper str [a]+chroots :: (Ord str, TagSoup.StringLike str)+ => Selector -> Scraper str a -> Scraper str [a] chroots selector (MkScraper inner) = MkScraper $ return . mapMaybe inner . select selector @@ -89,28 +97,28 @@ -- -- This function will match only the first set of tags matching the selector, to -- match every set of tags, use 'texts'.-text :: (Ord str, TagSoup.StringLike str, Selectable s) => s -> Scraper str str-text s = MkScraper $ withHead tagsToText . select_ s+text :: (Ord str, TagSoup.StringLike str) => Selector -> Scraper str str+text s = MkScraper $ withHead tagsToText . select s -- | The 'texts' function takes a selector and returns the inner text from every -- set of tags matching the given selector.-texts :: (Ord str, TagSoup.StringLike str, Selectable s)- => s -> Scraper str [str]-texts s = MkScraper $ withAll tagsToText . select_ s+texts :: (Ord str, TagSoup.StringLike str)+ => Selector -> Scraper str [str]+texts s = MkScraper $ withAll tagsToText . select s -- | The 'html' function takes a selector and returns the html string from the -- set of tags described by the given selector. -- -- This function will match only the first set of tags matching the selector, to -- match every set of tags, use 'htmls'.-html :: (Ord str, TagSoup.StringLike str, Selectable s) => s -> Scraper str str-html s = MkScraper $ withHead tagsToHTML . select_ s+html :: (Ord str, TagSoup.StringLike str) => Selector -> Scraper str str+html s = MkScraper $ withHead tagsToHTML . select s -- | The 'htmls' function takes a selector and returns the html string from -- every set of tags matching the given selector.-htmls :: (Ord str, TagSoup.StringLike str, Selectable s)- => s -> Scraper str [str]-htmls s = MkScraper $ withAll tagsToHTML . select_ s+htmls :: (Ord str, TagSoup.StringLike str)+ => Selector -> Scraper str [str]+htmls s = MkScraper $ withAll tagsToHTML . select s -- | The 'innerHTML' function takes a selector and returns the inner html string -- from the set of tags described by the given selector. Inner html here meaning@@ -118,15 +126,15 @@ -- -- This function will match only the first set of tags matching the selector, to -- match every set of tags, use 'innerHTMLs'.-innerHTML :: (Ord str, TagSoup.StringLike str, Selectable s)- => s -> Scraper str str-innerHTML s = MkScraper $ withHead tagsToInnerHTML . select_ s+innerHTML :: (Ord str, TagSoup.StringLike str)+ => Selector -> Scraper str str+innerHTML s = MkScraper $ withHead tagsToInnerHTML . select s -- | The 'innerHTMLs' function takes a selector and returns the inner html -- string from every set of tags matching the given selector.-innerHTMLs :: (Ord str, TagSoup.StringLike str, Selectable s)- => s -> Scraper str [str]-innerHTMLs s = MkScraper $ withAll tagsToInnerHTML . select_ s+innerHTMLs :: (Ord str, TagSoup.StringLike str)+ => Selector -> Scraper str [str]+innerHTMLs s = MkScraper $ withAll tagsToInnerHTML . select s -- | The 'attr' function takes an attribute name and a selector and returns the -- value of the attribute of the given name for the first opening tag that@@ -134,40 +142,91 @@ -- -- This function will match only the opening tag matching the selector, to match -- every tag, use 'attrs'.-attr :: (Ord str, Show str, TagSoup.StringLike str, Selectable s)- => String -> s -> Scraper str str+attr :: (Ord str, Show str, TagSoup.StringLike str)+ => String -> Selector -> Scraper str str attr name s = MkScraper- $ join . withHead (tagsToAttr $ TagSoup.castString name) . select_ s+ $ join . withHead (tagsToAttr $ TagSoup.castString name) . select s -- | The 'attrs' function takes an attribute name and a selector and returns the -- value of the attribute of the given name for every opening tag that matches -- the given selector.-attrs :: (Ord str, Show str, TagSoup.StringLike str, Selectable s)- => String -> s -> Scraper str [str]+attrs :: (Ord str, Show str, TagSoup.StringLike str)+ => String -> Selector -> Scraper str [str] attrs name s = MkScraper- $ fmap catMaybes . withAll (tagsToAttr nameStr) . select_ s+ $ fmap catMaybes . withAll (tagsToAttr nameStr) . select s where nameStr = TagSoup.castString name +-- | The 'position' function is intended to be used within the do-block of a+-- `chroots` call. Within the do-block position will return the index of the+-- current sub-tree within the list of all sub-trees matched by the selector+-- passed to `chroots`.+--+-- For example, consider the following HTML:+--+-- @+-- \<article\>+-- \<p\> First paragraph. \</p\>+-- \<p\> Second paragraph. \</p\>+-- \<p\> Third paragraph. \</p\>+-- \</article\>+-- @+--+-- The `position` function can be used to determine the index of each @\<p\>@ tag+-- within the @article@ tag by doing the following.+--+-- @+-- chroots "article" // "p" $ do+-- index <- position+-- content <- text "p"+-- return (index, content)+-- @+--+-- Which will evaluate to the list:+--+-- @+-- [+-- (0, "First paragraph.")+-- , (1, "Second paragraph.")+-- , (2, "Third paragraph.")+-- ]+-- @+position :: (Ord str, TagSoup.StringLike str) => Scraper str Int+position = MkScraper $ Just . tagsToPosition+ withHead :: (a -> b) -> [a] -> Maybe b withHead _ [] = Nothing withHead f (x:_) = Just $ f x withAll :: (a -> b) -> [a] -> Maybe [b]-withAll _ [] = Nothing withAll f xs = Just $ map f xs -tagsToText :: TagSoup.StringLike str => [TagSoup.Tag str] -> str-tagsToText = TagSoup.innerText+foldSpec :: TagSoup.StringLike str+ => (TagSoup.Tag str -> str -> str) -> TagSpec str -> str+foldSpec f = Vector.foldr' (f . infoTag) TagSoup.empty . (\(a, _, _) -> a) -tagsToHTML :: TagSoup.StringLike str => [TagSoup.Tag str] -> str-tagsToHTML = TagSoup.renderTags -tagsToInnerHTML :: TagSoup.StringLike str => [TagSoup.Tag str] -> str-tagsToInnerHTML = tagsToHTML . reverse . drop 1 . reverse . drop 1+tagsToText :: TagSoup.StringLike str => TagSpec str -> str+tagsToText = foldSpec f+ where+ f (TagSoup.TagText str) s = str `TagSoup.append` s+ f _ s = s +tagsToHTML :: TagSoup.StringLike str => TagSpec str -> str+tagsToHTML = foldSpec (\tag s -> TagSoup.renderTags [tag] `TagSoup.append` s)++tagsToInnerHTML :: TagSoup.StringLike str => TagSpec str -> str+tagsToInnerHTML (tags, tree, ctx)+ | len < 2 = TagSoup.empty+ | otherwise = tagsToHTML (Vector.slice 1 (len - 2) tags, tree, ctx)+ where len = Vector.length tags+ tagsToAttr :: (Show str, TagSoup.StringLike str)- => str -> [TagSoup.Tag str] -> Maybe str-tagsToAttr attr tags = do- tag <- listToMaybe tags+ => str -> TagSpec str -> Maybe str+tagsToAttr attr (tags, _, _) = do+ guard $ 0 < Vector.length tags+ let tag = infoTag $ tags Vector.! 0 guard $ TagSoup.isTagOpen tag return $ TagSoup.fromAttrib attr tag++tagsToPosition :: TagSpec str -> Int+tagsToPosition (_, _, ctx) = ctxPosition ctx
src/Text/HTML/Scalpel/Internal/Scrape/StringLike.hs view
@@ -13,4 +13,5 @@ -- tags and executes a 'Scraper' on it. scrapeStringLike :: (Ord str, TagSoup.StringLike str) => str -> Scraper str a -> Maybe a-scrapeStringLike html scraper = scrape scraper (TagSoup.parseTags html)+scrapeStringLike html scraper+ = scrape scraper (TagSoup.parseTagsOptions TagSoup.parseOptionsFast html)
src/Text/HTML/Scalpel/Internal/Select.hs view
@@ -1,45 +1,91 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-}+{-# LANGUAGE BangPatterns #-} {-# OPTIONS_HADDOCK hide #-} module Text.HTML.Scalpel.Internal.Select (- CloseOffset+ SelectContext (..)+, TagSpec+, TagInfo (..) , select-, select_-, tagWithOffset+, tagsToSpec ) where import Text.HTML.Scalpel.Internal.Select.Types import Control.Applicative ((<$>), (<|>))-import Control.Arrow (first)-import Data.List (tails)-import Data.Maybe (catMaybes)-import GHC.Exts (sortWith)+import Data.Maybe (catMaybes, isJust, fromJust, fromMaybe) import qualified Data.Map.Strict as Map+import qualified Data.Text as T+import qualified Data.Tree as Tree+import qualified Data.Vector as Vector import qualified Text.HTML.TagSoup as TagSoup import qualified Text.StringLike as TagSoup -type CloseOffset = Maybe Int+type Index = Int+type Name = Maybe T.Text+type CloseOffset = Maybe Index +-- | The span of a tag in terms of the index of the opening tag and the index of+-- the closing tag. If there is no closing tag the closing tag is equal to the+-- opening tag.+data Span = Span !Int !Int++-- | A representation of the hierarchal structure of a document. Nodes of the+-- tree are spans which mark the start and end of a tag. The tree is organized+-- such that tags that appear earlier in the parsed string appear earlier in the+-- list of nodes, and that a given node is completely within the span of its+-- parent.+type TagForest = Tree.Forest Span++-- | A tag and associated precomputed meta data that is accessed in tight inner+-- loops during scraping.+data TagInfo str = TagInfo {+ infoTag :: !(TagSoup.Tag str)+ , infoName :: !Name+ , infoIndex :: !Index+ , infoOffset :: !CloseOffset+ }++-- | A vector of tags and precomputed meta data. A vector is used because it+-- allows for constant time slicing and sharing memory between the slices.+type TagVector str = Vector.Vector (TagInfo str)++-- | Ephemeral meta-data that each TagSpec is tagged with. This type contains+-- information that is not intrinsic in the sub-tree that corresponds to a given+-- TagSpec.+data SelectContext = SelectContext {+ -- | `select` generates a list of `TagSpec`s that match a+ -- selector. This indicates the index in the result list+ -- that this TagSpec corresponds to.+ ctxPosition :: !Index+ }++-- | A structured representation of the parsed tags that provides fast element+-- look up via a vector of tags, and fast traversal via a rose tree of tags.+type TagSpec str = (TagVector str, TagForest, SelectContext)+ -- | The 'select' function takes a 'Selectable' value and a list of -- 'TagSoup.Tag's and returns a list of every subsequence of the given list of -- Tags that matches the given selector.-select :: (Ord str, TagSoup.StringLike str, Selectable s)- => s- -> [(TagSoup.Tag str, CloseOffset)]- -> [[(TagSoup.Tag str, CloseOffset)]]-select s = selectNodes nodes- where (MkSelector nodes) = toSelector s+select :: (Ord str, TagSoup.StringLike str)+ => Selector -> TagSpec str -> [TagSpec str]+select s tagSpec = newSpecs+ where+ (MkSelector nodes) = s+ newSpecs = zipWith applyPosition [0..] (selectNodes nodes tagSpec [])+ applyPosition p (tags, f, ctx) = (tags, f, SelectContext p) --- | Like 'select' but strips the 'CloseOffset' from the result.-select_ :: (Ord str, TagSoup.StringLike str, Selectable s)- => s- -> [(TagSoup.Tag str, CloseOffset)]- -> [[TagSoup.Tag str]]-select_ s = map (map fst) . select s+-- | Creates a TagSpec from a list of tags parsed by TagSoup.+tagsToSpec :: forall str. (Ord str, TagSoup.StringLike str)+ => [TagSoup.Tag str] -> TagSpec str+tagsToSpec tags = (vector, tree, ctx)+ where+ vector = tagsToVector tags+ tree = vectorToTree vector+ ctx = SelectContext 0 -- | Annotate each tag with the offset to the corresponding closing tag. This -- annotating is done in O(n * log(n)).@@ -62,114 +108,160 @@ -- unclosed tags from the state are added to the result set without a -- closing offset. ----- (5) The result set is then sorted and the indices are stripped from the--- tags.-tagWithOffset :: forall str. (Ord str, TagSoup.StringLike str)- => [TagSoup.Tag str] -> [(TagSoup.Tag str, CloseOffset)]-tagWithOffset tags = let indexed = zip tags [0..]- unsorted = go indexed Map.empty- sorted = sortWith snd unsorted- in map fst sorted+-- (5) The result set is then sorted by their indices.+tagsToVector :: forall str. (Ord str, TagSoup.StringLike str)+ => [TagSoup.Tag str] -> TagVector str+tagsToVector tags = let indexed = zip tags [0..]+ total = length indexed+ unsorted = go indexed Map.empty+ emptyVec = Vector.replicate total undefined+ in emptyVec Vector.// unsorted where- go :: [(TagSoup.Tag str, Int)]- -> Map.Map str [(TagSoup.Tag str, Int)]- -> [((TagSoup.Tag str, CloseOffset), Int)]- go [] state = map (first (, Nothing)) $ concat $ Map.elems state+ go :: [(TagSoup.Tag str, Index)]+ -> Map.Map T.Text [(TagSoup.Tag str, Index)]+ -> [(Index, TagInfo str)]+ go [] state = map (\(t, i) -> (i, TagInfo t (maybeName t) i Nothing))+ $ concat+ $ Map.elems state+ where+ maybeName t | TagSoup.isTagOpen t = Just $ getTagName t+ | TagSoup.isTagClose t = Just $ getTagName t+ | otherwise = Nothing go (x@(tag, index) : xs) state | TagSoup.isTagClose tag = let maybeOpen = head <$> Map.lookup tagName state state' = Map.alter popTag tagName state+ info = TagInfo tag (Just tagName) index Nothing res = catMaybes [- Just ((tag, Nothing), index)- , calcOffset <$> maybeOpen- ]+ Just (index, info)+ , calcOffset <$> maybeOpen+ ] in res ++ go xs state' | TagSoup.isTagOpen tag = go xs (Map.alter appendTag tagName state)- | otherwise = ((tag, Nothing), index) : go xs state+ | otherwise =+ let info = TagInfo tag Nothing index Nothing+ in (index, info) : go xs state where tagName = getTagName tag - appendTag :: Maybe [(TagSoup.Tag str, Int)]- -> Maybe [(TagSoup.Tag str, Int)]+ appendTag :: Maybe [(TagSoup.Tag str, Index)]+ -> Maybe [(TagSoup.Tag str, Index)] appendTag m = (x :) <$> (m <|> Just []) - calcOffset :: (t, Int) -> ((t, Maybe Int), Int)+ calcOffset :: (TagSoup.Tag str, Int) -> (Index, TagInfo str) calcOffset (t, i) = let offset = index - i- in offset `seq` ((t, Just offset), i)+ info = TagInfo t (Just tagName) i (Just offset)+ in offset `seq` (i, info) popTag :: Maybe [a] -> Maybe [a] popTag (Just (_ : y : xs)) = let s = y : xs in s `seq` Just s popTag _ = Nothing +getTagName :: TagSoup.StringLike str => TagSoup.Tag str -> T.Text+getTagName (TagSoup.TagOpen name _) = TagSoup.castString name+getTagName (TagSoup.TagClose name) = TagSoup.castString name+getTagName _ = undefined++-- | Builds a forest describing the structure of the tags within a given vector.+-- The nodes of the forest are tag spans which mark the indices within the+-- vector of an open and close pair. The tree is organized such for any node n+-- the parent of node n is the smallest span that completely encapsulates the+-- span of node n.+vectorToTree :: TagSoup.StringLike str => TagVector str -> TagForest+vectorToTree tags = fixup $ forestWithin 0 (Vector.length tags)+ where+ forestWithin :: Int -> Int -> TagForest+ forestWithin !lo !hi+ | hi <= lo = []+ | not isOpen = forestWithin (lo + 1) hi+ | otherwise = Tree.Node (Span lo closeIndex) subForest+ : forestWithin (closeIndex + 1) hi+ where+ info = tags Vector.! lo+ isOpen = TagSoup.isTagOpen $ infoTag info+ closeIndex = lo + fromMaybe 0 (infoOffset info)+ subForest = forestWithin (lo + 1) closeIndex++ -- Lifts nodes whose closing tags lay outside their parent tags up to+ -- within a parent node that encompasses the node's entire span.+ fixup :: TagForest -> TagForest+ fixup [] = []+ fixup (Tree.Node (Span lo hi) subForest : siblings)+ = Tree.Node (Span lo hi) ok : bad+ where+ (ok, bad) = malformed (fixup siblings) $ fixup subForest++ malformed :: TagForest -- Forest to prepend bad trees on.+ -> TagForest -- Remaining trees to examine.+ -> (TagForest, TagForest)+ malformed preBad [] = ([], preBad)+ malformed preBad (n@(Tree.Node (Span _ nHi) _) : ns)+ | hi < nHi = (ok, n : bad)+ | otherwise = (n : ok, bad)+ where (ok, bad) = malformed preBad ns++-- | Generates a list of 'TagSpec's that match the given list of 'SelectNode's.+-- This is is done in linear time with respect to the number of tags.+--+-- The algorithm is a simple DFS traversal of the tag forest. While traversing+-- the forest if the current SelectNode is satisfied by the current node in the+-- tree the SelectNode is popped and the current node's sub-forest is traversed+-- with the remaining SelectNodes. If there is only a single SelectNode then any+-- node encountered that satisfies the SelectNode is returned as an answer. selectNodes :: TagSoup.StringLike str- => [SelectNode]- -> [(TagSoup.Tag str, CloseOffset)]- -> [[(TagSoup.Tag str, CloseOffset)]]-selectNodes nodes tags = head' $ reverse results- where results = [concatMap (selectNode s) ts | s <- nodes- | ts <- [tags] : results]- head' [] = []- head' (x:_) = x+ => [SelectNode] -> TagSpec str -> [TagSpec str] -> [TagSpec str]+selectNodes [] _ acc = acc+selectNodes [_] (_, [], _) acc = acc+-- Now that there is only a single SelectNode to satisfy, search the remaining+-- forests and generates a TagSpec for each node that satisfies the condition.+selectNodes [n] (tags, f : fs, ctx) acc+ | nodeMatches n info = (shrunkSpec :)+ $ selectNodes [n] (tags, fs, ctx)+ $ selectNodes [n] (tags, Tree.subForest f, ctx) acc+ | otherwise = selectNodes [n] (tags, fs, ctx)+ $ selectNodes [n] (tags, Tree.subForest f, ctx) acc+ where+ Span lo hi = Tree.rootLabel f+ shrunkSpec = (+ Vector.slice lo (hi - lo + 1) tags+ , [fmap recenter f]+ , ctx+ )+ recenter (Span nLo nHi) = Span (nLo - lo) (nHi - lo)+ info = tags Vector.! lo+-- There are multiple SelectNodes that need to be satisfied. If the current node+-- satisfies the condition, then the current nodes sub-forest is searched for+-- matches of the remaining SelectNodes.+selectNodes (_ : _) (_, [], _) acc = acc+selectNodes (n : ns) (tags, f : fs, ctx) acc+ | nodeMatches n info = selectNodes ns (tags, Tree.subForest f, ctx)+ $ selectNodes (n : ns) (tags, fs, ctx) acc+ | otherwise = selectNodes (n : ns) (tags, Tree.subForest f, ctx)+ $ selectNodes (n : ns) (tags, fs, ctx) acc+ where+ Span lo _ = Tree.rootLabel f+ info = tags Vector.! lo -selectNode :: TagSoup.StringLike str- => SelectNode- -> [(TagSoup.Tag str, CloseOffset)]- -> [[(TagSoup.Tag str, CloseOffset)]]-selectNode (SelectNode node attributes) tags = concatMap extractTagBlock nodes- where nodes = filter (checkTag node attributes) $ tails tags-selectNode (SelectAny attributes) tags = concatMap extractTagBlock nodes- where nodes = filter (checkPreds attributes) $ tails tags+-- | Returns True if a tag satisfies a given SelectNode's condition.+nodeMatches :: TagSoup.StringLike str => SelectNode -> TagInfo str -> Bool+nodeMatches (SelectNode node preds) info = checkTag node preds info+nodeMatches (SelectAny preds) info = checkPreds preds (infoTag info) -- | Given a tag name and a list of attribute predicates return a function that -- returns true if a given tag matches the supplied name and predicates. checkTag :: TagSoup.StringLike str- => String- -> [AttributePredicate]- -> [(TagSoup.Tag str, CloseOffset)]- -> Bool-checkTag name preds tags@((TagSoup.TagOpen str _, _) : _)- = TagSoup.fromString name == str && checkPreds preds tags-checkTag _ _ _ = False+ => T.Text -> [AttributePredicate] -> TagInfo str -> Bool+checkTag name preds (TagInfo tag tagName _ _)+ = TagSoup.isTagOpen tag+ && isJust tagName+ && name == fromJust tagName+ && checkPreds preds tag +-- | Returns True if a tag satisfies a list of attribute predicates. checkPreds :: TagSoup.StringLike str- => [AttributePredicate] -> [(TagSoup.Tag str, CloseOffset)] -> Bool-checkPreds preds ((TagSoup.TagOpen _ attrs, _) : _)- = and [or [checkPred p attr | attr <- attrs] | p <- preds]-checkPreds _ _ = False---- | Given a list of tags, return the prefix of the tags up to the closing tag--- that corresponds to the initial tag.-extractTagBlock :: TagSoup.StringLike str- => [(TagSoup.Tag str, CloseOffset)]- -> [[(TagSoup.Tag str, CloseOffset)]]-extractTagBlock (ctag@(tag, maybeOffset) : tags)- | not $ TagSoup.isTagOpen tag = []- | Just offset <- maybeOffset = [takeOrClose ctag offset tags]- -- To handle tags that do not have a closing tag, fake an empty block by- -- adding a closing tag. This function assumes that the tag is an open- -- tag.- | otherwise = [[ctag, (closeForOpen tag, Nothing)]]-extractTagBlock _ = []---- | Take offset number of elements from tags if available. If there are not--- that many available, then fake a closing tag for the open tag. This happens--- with malformed HTML that looks like `<a><b></a></b>`.-takeOrClose :: TagSoup.StringLike str- => (TagSoup.Tag str, CloseOffset)- -> Int- -> [(TagSoup.Tag str, CloseOffset)]- -> [(TagSoup.Tag str, CloseOffset)]-takeOrClose open@(tag, _) offset tags = go offset tags (open :)- where- go 0 _ f = f []- go _ [] _ = [open, (closeForOpen tag, Nothing)]- go i (x : xs) f = go (i - 1) xs (f . (x :))--closeForOpen :: TagSoup.StringLike str => TagSoup.Tag str -> TagSoup.Tag str-closeForOpen = TagSoup.TagClose . getTagName--getTagName :: TagSoup.StringLike str => TagSoup.Tag str -> str-getTagName (TagSoup.TagOpen name _) = name-getTagName (TagSoup.TagClose name) = name-getTagName _ = undefined+ => [AttributePredicate] -> TagSoup.Tag str -> Bool+checkPreds preds tag+ = TagSoup.isTagOpen tag+ && and [or [checkPred p attr | attr <- attrs] | p <- preds]+ where (TagSoup.TagOpen _ attrs) = tag
src/Text/HTML/Scalpel/Internal/Select/Combinators.hs view
@@ -20,7 +20,7 @@ -- | The '@:' operator creates a 'Selector' by combining a 'TagName' with a list -- of 'AttributePredicate's.-(@:) :: TagName tag => tag -> [AttributePredicate] -> Selector+(@:) :: TagName -> [AttributePredicate] -> Selector (@:) tag attrs = MkSelector [toSelectNode tag attrs] infixl 9 @: @@ -29,7 +29,7 @@ -- -- If you are attempting to match a specific class of a tag with potentially -- multiple classes, you should use the 'hasClass' utility function.-(@=) :: AttributeName key => key -> String -> AttributePredicate+(@=) :: AttributeName -> String -> AttributePredicate (@=) key value = MkAttributePredicate $ \(attrKey, attrValue) -> matchKey key attrKey && TagSoup.fromString value == attrValue@@ -38,8 +38,8 @@ -- | The '@=~' operator creates an 'AttributePredicate' that will match -- attributes with the given name and whose value matches the given regular -- expression.-(@=~) :: (AttributeName key, RE.RegexLike re String)- => key -> re -> AttributePredicate+(@=~) :: RE.RegexLike re String+ => AttributeName -> re -> AttributePredicate (@=~) key re = MkAttributePredicate $ \(attrKey, attrValue) -> matchKey key attrKey && RE.matchTest re (TagSoup.toString attrValue)@@ -48,10 +48,10 @@ -- | The '//' operator creates an 'Selector' by nesting one 'Selector' in -- another. For example, @"div" // "a"@ will create a 'Selector' that matches -- anchor tags that are nested arbitrarily deep within a div tag.-(//) :: (Selectable a, Selectable b) => a -> b -> Selector+(//) :: Selector -> Selector -> Selector (//) a b = MkSelector (as ++ bs)- where (MkSelector as) = toSelector a- (MkSelector bs) = toSelector b+ where (MkSelector as) = a+ (MkSelector bs) = b infixl 5 // -- | The classes of a tag are defined in HTML as a space separated list given by
src/Text/HTML/Scalpel/Internal/Select/Types.hs view
@@ -2,43 +2,38 @@ {-# LANGUAGE Rank2Types #-} {-# LANGUAGE ImpredicativeTypes #-} {-# OPTIONS_HADDOCK hide #-}+ module Text.HTML.Scalpel.Internal.Select.Types ( Selector (..)-, Selectable (..) , AttributePredicate (..) , checkPred-, Any (..) , AttributeName (..)+, matchKey , TagName (..)- , SelectNode (..)+, tagSelector+, anySelector+, toSelectNode ) where import Data.Char (toLower)+import Data.String (IsString, fromString) import qualified Text.HTML.TagSoup as TagSoup import qualified Text.StringLike as TagSoup+import qualified Data.Text as T --- | The 'Selectable' class defines a class of types that are capable of being--- cast into a 'Selector' which in turns describes a section of an HTML DOM--- tree.-class Selectable s where- toSelector :: s -> Selector+-- | The 'AttributeName' type can be used when creating 'Selector's to specify+-- the name of an attribute of a tag.+data AttributeName = AnyAttribute | AttributeString String --- | The 'AttributeName' class defines a class of types that can be used when--- creating 'Selector's to specify the name of an attribute of a tag. Currently--- the only types of this class are 'String' for matching attributes exactly,--- and 'Any' for matching attributes with any name.-class AttributeName k where- matchKey :: TagSoup.StringLike str => k -> str -> Bool+matchKey :: TagSoup.StringLike str => AttributeName -> str -> Bool+matchKey (AttributeString s) = ((TagSoup.fromString $ map toLower s) ==)+matchKey AnyAttribute = const True --- | The 'TagName' class defines a class of types that can be used when creating--- 'Selector's to specify the name of a tag. Currently the only types of this--- class are 'String' for matching tags exactly, and 'Any' for matching tags--- with any name.-class TagName t where- toSelectNode :: t -> [AttributePredicate] -> SelectNode+instance IsString AttributeName where+ fromString = AttributeString -- | An 'AttributePredicate' is a method that takes a 'TagSoup.Attribute' and -- returns a 'Bool' indicating if the given attribute matches a predicate.@@ -51,38 +46,31 @@ => AttributePredicate -> TagSoup.Attribute str -> Bool checkPred (MkAttributePredicate p) = p --- | 'Any' can be used as a wildcard when constructing selectors to match tags--- and attributes with any name.------ For example, the selector @Any \@: [Any \@= \"foo\"]@ matches all tags that--- have any attribute where the value is @\"foo\"@.-data Any = Any- -- | 'Selector' defines a selection of an HTML DOM tree to be operated on by -- a web scraper. The selection includes the opening tag that matches the -- selection, all of the inner tags, and the corresponding closing tag. newtype Selector = MkSelector [SelectNode] -data SelectNode = SelectNode String [AttributePredicate]- | SelectAny [AttributePredicate]--instance Selectable Selector where- toSelector = id+tagSelector :: String -> Selector+tagSelector tag = MkSelector [toSelectNode (TagString tag) []] -instance Selectable String where- toSelector node = MkSelector [SelectNode (map toLower node) []]+-- | A selector which will match all tags+anySelector :: Selector+anySelector = MkSelector [SelectAny []] -instance Selectable Any where- toSelector = const (MkSelector [SelectAny []])+instance IsString Selector where+ fromString = tagSelector -instance AttributeName Any where- matchKey = const . const True+data SelectNode = SelectNode !T.Text [AttributePredicate]+ | SelectAny [AttributePredicate] -instance AttributeName String where- matchKey = (==) . TagSoup.fromString . map toLower+-- | The 'TagName' type is used when creating a 'Selector' to specify the name+-- of a tag.+data TagName = AnyTag | TagString String -instance TagName Any where- toSelectNode = const SelectAny+instance IsString TagName where+ fromString = TagString -instance TagName String where- toSelectNode = SelectNode . TagSoup.fromString . map toLower+toSelectNode :: TagName -> [AttributePredicate] -> SelectNode+toSelectNode AnyTag = SelectAny+toSelectNode (TagString str) = SelectNode . TagSoup.fromString $ map toLower str
tests/TestMain.hs view
@@ -1,9 +1,13 @@ {-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE OverloadedStrings #-}+ module Main (main) where import Text.HTML.Scalpel import Control.Applicative+import Control.Monad (guard)+import Data.List (isInfixOf) import System.Exit import Test.HUnit @@ -43,12 +47,12 @@ , scrapeTest "<a>foo</a>"- Nothing+ (Just []) (htmls ("b" @: [])) , scrapeTest "<a>foo"- (Just ["<a></a>"])+ (Just ["<a>"]) (htmls ("a" @: [])) , scrapeTest@@ -73,7 +77,7 @@ , scrapeTest "<a class=\"a b\">foo</a>"- Nothing+ (Just []) (htmls ("a" @: [hasClass "c"])) , scrapeTest@@ -84,27 +88,27 @@ , scrapeTest "<a foo=\"value\">foo</a><a bar=\"value\">bar</a>" (Just ["<a foo=\"value\">foo</a>", "<a bar=\"value\">bar</a>"])- (htmls ("a" @: [Any @= "value"]))+ (htmls ("a" @: [AnyAttribute @= "value"])) , scrapeTest "<a foo=\"other\">foo</a><a bar=\"value\">bar</a>" (Just ["<a bar=\"value\">bar</a>"])- (htmls ("a" @: [Any @= "value"]))+ (htmls ("a" @: [AnyAttribute @= "value"])) , scrapeTest "<a foo=\"value\">foo</a><b bar=\"value\">bar</b>" (Just ["<a foo=\"value\">foo</a>", "<b bar=\"value\">bar</b>"])- (htmls (Any @: [Any @= "value"]))+ (htmls (AnyTag @: [AnyAttribute @= "value"])) , scrapeTest "<a foo=\"other\">foo</a><b bar=\"value\">bar</b>" (Just ["<b bar=\"value\">bar</b>"])- (htmls (Any @: [Any @= "value"]))+ (htmls (AnyTag @: [AnyAttribute @= "value"])) , scrapeTest "<a foo=\"bar\">1</a><a foo=\"foo\">2</a><a bar=\"bar\">3</a>" (Just ["<a foo=\"foo\">2</a>", "<a bar=\"bar\">3</a>"])- (htmls (Any @: [match (==)]))+ (htmls (AnyTag @: [match (==)])) , scrapeTest "<a>foo</a>"@@ -156,9 +160,15 @@ (Just "bar") (text ("a" // "d") <|> text ("a" // "c")) - , scrapeTest "<img src='foobar'>" (Just "foobar") (attr "src" "img")+ , scrapeTest+ "<img src='foobar'>"+ (Just "foobar")+ (attr "src" "img") - , scrapeTest "<img src='foobar' />" (Just "foobar") (attr "src" "img")+ , scrapeTest+ "<img src='foobar' />"+ (Just "foobar")+ (attr "src" "img") , scrapeTest "<a>foo</a><A>bar</A>"@@ -177,7 +187,7 @@ , scrapeTest "<a B=C>foo</a>"- Nothing+ (Just []) (texts $ "A" @: ["b" @= "c"]) , scrapeTest@@ -213,17 +223,69 @@ , scrapeTest "<a>1<b>2</b>3</a>" (Just "1<b>2</b>3")- (innerHTML Any)+ (innerHTML anySelector) , scrapeTest "<a>" (Just "")- (innerHTML Any)+ (innerHTML anySelector) , scrapeTest "<a>foo</a><a>bar</a>" (Just ["foo","bar"]) (innerHTMLs "a")++ , scrapeTest+ "<a>foo</a><a>bar</a><a>baz</a>"+ (Just "<a>bar</a>")+ (chroot "a" $ do+ t <- text anySelector+ guard ("b" `isInfixOf` t)+ html anySelector)++ , scrapeTest+ "<div id=\"outer\"><div id=\"inner\">inner text</div></div>"+ (Just ["inner"])+ (attrs "id" ("div" // "div"))++ , scrapeTest+ "<div id=\"a\"><div id=\"b\"><div id=\"c\"></div></div></div>"+ (Just ["b", "c"])+ (attrs "id" ("div" // "div"))++ , scrapeTest+ "<a>1<b>2<c>3</c>4</b>5</a>"+ (Just "12345")+ (text anySelector)++ , scrapeTest+ "<a>1</a>"+ Nothing+ $ do+ "Bad pattern" <- text "a"+ return "OK"++ , scrapeTest+ "<a>1</a>"+ (Just "OK")+ $ do+ "1" <- text "a"+ return "OK"+ , scrapeTest+ "<article><p>A</p><p>B</p><p>C</p></article>"+ (Just [(0, "A"), (1, "B"), (2, "C")])+ (chroots ("article" // "p") $ do+ index <- position+ content <- text anySelector+ return (index, content))++ , scrapeTest+ "<article><p>A</p></article><article><p>B</p><p>C</p></article>"+ (Just [[(0, "A")], [(0, "B"), (1, "C")]])+ (chroots "article" $ chroots "p" $ do+ index <- position+ content <- text anySelector+ return (index, content)) ] scrapeTest :: (Eq a, Show a) => String -> Maybe a -> Scraper String a -> Test