Header

  1. View current page

    Haskell Programming Language

Profile_image?t=1224044209&type=small
Haskell 관련 자료 정리
5

웹 크롤러 구현하기

 

 전체 소스

 

  1. import Network.HTTP
    import Network.URI
    import System.Environment (getArgs)
    import System.IO
    import Text.ParserCombinators.Parsec
  2.  
  3. data LimitUrlQueue = LimitUrlQueue (Int, [String])
  4. pushQueue :: [String] -> LimitUrlQueue -> Maybe LimitUrlQueue
    pushQueue e (LimitUrlQueue (i, u)) =
        if (length u > i)
            then Nothing
            else Just $ LimitUrlQueue (i, u ++ e)
           
    popQueue :: LimitUrlQueue -> Maybe (String, LimitUrlQueue)
    popQueue (LimitUrlQueue (i, u)) =
        if (length u == 0)
            then Nothing
            else Just (head u, LimitUrlQueue (i, tail u))
       
    main = do
        (qSize : seedList) <- getArgs
        simpleCrawler 0 $ LimitUrlQueue (read qSize, seedList)
        putStrLn "done!!!"
       
    simpleCrawler idx urlQ = do
        case popQueue urlQ of
            Nothing -> putStrLn "queue is empty!!!"
            Just (url, queue) -> do
                (nextIdx, links) <- crawl url idx
                case pushQueue links queue of
                    Nothing -> putStrLn "queue is full!!!"
                    Just nextQueue -> simpleCrawler nextIdx nextQueue
                   
    crawl url idx = do
        putStr (url ++ " --> ")
  5.     case parseURI url of
            Nothing -> putStrLn "Invalid URL" >> return (idx, [])
            Just u -> do
                contents <- get u
                case contents of
                    Nothing -> return (idx, [])
                    Just body -> do
                        nextIdx <- saveContents idx body
                        linkList <- extractLinks u body
                        putStrLn ("crawled!")
                        return (nextIdx, linkList);
               
    get url = do
        response <- simpleHTTP $ request url
        case response of
            Left error -> return Nothing
            Right repData -> return $ Just $ rspBody repData
  6.  
  7. request uri = Request{ rqURI = uri,
                           rqMethod = GET,
                           rqHeaders = [],
                           rqBody = "" }
                          
    saveContents idx body = writeFile (show idx ++ ".html") body >> return (idx+1)
           
    extractLinks baseUrl body = extractAnchor body >>= extractLinkSrc baseUrl
       
    extractLinkSrc baseUrl = return . (filter ((/=0) . length)) . (map (escapeUrl . (filterUrl baseUrl) . (extractLinkSrc' baseUrl)))
    extractLinkSrc' baseUrl anchor = case (parseRun parseAnchorLink anchor) of
        Left err -> Nothing
        Right link -> parseRelativeReference link >>= (`relativeTo` baseUrl)
  8.  
  9. isDuplicateUrl url1 url2 = if (uriScheme url1 == uriScheme url2
                                   && uriAuthority url1 == uriAuthority url2
                                   && uriPath url1 == uriPath url2
                                   && uriQuery url1 == uriQuery url2) then True else False
                                  
    escapeUrl = escapeURIString isAllowedInURI
  10.  
  11. filterUrl _ Nothing = ""
    filterUrl baseUrl (Just url) = if (isDuplicateUrl baseUrl url) then "" else show url
  12.  
  13. parseAnchorLink = parseStartLink
        >> (try parseEndLink1 <|> try parseEndLink2 <|> try parseEndLink3 <|> try parseEndLink4)
  14.  
  15. doubleQuote = char '\"'
    singleQuote = char '\''
    parseStartLink = string "a " >> manyTill anyChar (string "href=")
    parseEndLink1 = doubleQuote >> manyTill anyChar doubleQuote
    parseEndLink2 = singleQuote >> manyTill anyChar singleQuote
    parseEndLink3 = manyTill anyChar space
    parseEndLink4 = manyTill anyChar eof
  16.  
  17. extractAnchor html = extractTag html >>= (return . filter (checkTag check_a_href))
  18.  
  19. check_a_href = string "a " >> manyTill anyChar (string "href")
    checkTag f tag = case (parseRun f tag) of
        Left _ -> False
        Right _ -> True
       
    extractTag html = case (parseRun extractTag' html) of
        Left err -> print err >> return []
        Right tagList -> return tagList
       
    extractTag' = do {tag <- tagParser
                    ; tagList <- extractTag'
                    ; return (tag:tagList)
                    } <|> return [""]
  20.  
  21. parseRun p t = parse p "" t
  22. tagParser = try parseComment <|> try parseScript <|> try parseTag <|> try parsePlain
    parseComment = string "<!--" >> manyTill anyChar (string "-->") >> return ""
    parseScript = string "<script" >> manyTill anyChar (string "</script>") >> return ""
    parseTag = char '<' >> manyTill anyChar (char '>')
    parsePlain = anyChar >> return ""
  23.  

위 소스를 컴파일하고 실행한 화면입니다.

 

 

HTTP 라이브러리 설치 및 기본 사용법

웹 크롤러는 자동으로 웹문서를 수집하는 프로그램입니다. 웹 로봇, 스파이더 등의 이름으로도 불립니다.
기본 원리는 단순합니다. 최초 시작 url 주소리스트를 시작점으로 해서 HTTP 프로토콜을 이용해 웹 문서를 요청하고 그 문서의 내용에서 링크 url 을 추출하여 다시 추가적인 문서 수집을 해나갑니다. 이 과정을 계속 반복하면 됩니다.
물론 실제 사용되는 크롤러에서는 효율성과 사이클링 방지를 위해 중복 문서를 제거하거나 웹 호스팅 서버에 과부하가 걸리는 것을 막기 위해 수집 속도를 조절하는 등의 여러 가지 정책이 사용됩니다.

먼저 크롤링을 위해서는 HTTP 프로토콜을 통한 통신 기능이 필요합니다.
하스켈 컴파일러인  GHC에는 여러 가지 좋은 라이브러리들이 많으며 특히 컴파일러를 설치하면 제공되는 기본 라이브러리 외에도 많은 확장 라이브러리들이 계속 추가 및 발전되고 있습니다.
이런 확장 라이브러리는 마치 Perl의 CPAN과 유사한 HackageDB 라고 하는 곳에 잘 정리되고 있습니다. 그리고 저는 HTTP통신을 위해 HackageDB에 있는 Network.HTTP 라이브러리를 사용했습니다. 이 라이브러리는 http://hackage.haskell.org/cgi-bin/hackage-scripts/package/HTTP-3000.0.0 에서 다운받을 수 있습니다.

설치법은 아주 간단합니다. HackageDB에 있는 라이브러리 패키지들은 하스켈의 패키지 포맷인 CABAL(Common Architecture for Building Applications and Libraries) 로 패키징되어 있습니다. 즉, cabal은 간단히 말하면 윈도우즈의 msi처럼 프로그램 배포를 편하게 하기 위한 패키징 포맷이라고 생각하시면 됩니다. 이렇게 패키징된 하스켈 프로그램(혹은 라이브러리)는 다음과 같은 방법으로 설치할 수 있습니다.
우선 당연히 GHC 컴파일러가 있어야 합니다. 참고로 전 현재 시점에서 가장 최신 버전인 GHC 6.6.1 컴파일러를 사용하고 있습니다. 이 컴파일러를 정상적으로 설치했다면 bin 디렉토리에 runghc.exe 라는 실행 파일이 있을 것입니다. 이 프로그램이 cabal 포맷으로 된 패키지를 설치할 수 있는 프로그램입니다.
HTTP라이브러리를 다운받아 압축을 풀고 나면 아마 디렉토리에 setup.lhs라는 파일이 있을 것입니다. 그러면 해당 디렉토리에서 콘솔 모드로 아래와 같이 입력합니다.

runghc setup.lhs configure

그러면 해당 패키지를 설치할 수 있는지 여부를 체크하게 됩니다. 보통 이 패키지 설치를 위해 필요한 라이브러리들의 디펜던시를 체크하는 것이 주 목적입니다. HTTP 라이브러리의 경우 GHC의 기본 라이브러리만 사용하기 때문에 무사히 설정 확인이 끝날 것입니다. 그러면 이제 다음과 같이 차례로 입력합니다.

runghc setup.lhs build
runghc setup.lhs install

이제 정상적으로 라이브러리가 설치되었는지 확인하는 의미에서 아래 샘플을 작성하고 실행시켜 봅니다.

 

  1. import Network.HTTP
    import Network.URIget url = do
        case parseURI url of
            Nothing -> putStrLn "Invalid url format"
            Just u -> do response <- simpleHTTP $ request u
                         case response of
                             Left error -> putStrLn "HTTP response error"
                             Right repData -> putStrLn $ show $ rspCode repData
           
    request uri = Request{ rqURI = uri,
                           rqMethod = GET,
                           rqHeaders = [],
                           rqBody = "" }

 

위 소스를 파일에 저장한 후 ghci 인터프리터로 다음과 같이 확인해 봅니다.

get "http://agbird.egloos.com"

아마 다음과 같이 결과가 나올 것입니다.

(2,0,0)

위 소스를 간략히 설명하자면,
parseURI 함수는 문자열을 받아 URI 타입으로 반환하는 함수입니다. 하스켈은 자료형을 매우 엄격하게 사용하며 대개의 경우 각 문맥에 맞는 자료형을 정의해 사용합니다. 따라서 HTTP 통신을 위한 url도 그냥 문자열을 사용하는 것이 아니라 이렇게 URI 타입을 따로 정의해 놓았습니다.
parseURI는 Maybe 라는 타입을 반환하는데 입력 문자열을 파싱해서 올바른 형태인 경우에만 Just URI 타입으로 반환하며 잘못된 형식인 경우에는 Nothing 을 반환합니다.
아 마 하스켈에 익숙하지 않은 분들은 이 Maybe 타입이 생소할 것입니다. 자세한 것은 차차 알아나가도록 하고 어쨌든 Maybe 타입은 에러 처리를 위해 사용되는 idiom 정도로만 이해하시기 바랍니다. 위의 예제의 parseURI처럼 Maybe 타입을 사용하게 되면 에러 발생 시에는 Nothing을 반환하고 올바르게 처리되면 해당 반환값 앞에 Just를 붙히고 반환합니다. 그러면 해당 함수를 호출한 쪽에서는 Nothing 값인 경우에는 에러 처리를 하고 Just 값인 경우 뒤에 붙은 값만 뽑아서 처리하면 됩니다.
처음에는 어색할지 몰라도 익숙해지면 에러 처리가 매우 간단해지고 에러 상황과 정상 상황을 자연스럽게 분리해서 기술할 수 있는 장점이 있습니다. (이에 대한 좀더 자세한 설명은 귤님이 쓰신 하스켈 모나드에 관한 글을 참고하시기 바랍니다.)

simpleHTTP는 아주 간단한 HTTP 통신을 위해 제공된 라이브러리 함수입니다. 위 에제에서처럼 Request 자료형 값을 넘겨주면 서버와 통신을 수행하고 그 결과값을 넘겨줍니다.

결과값에는 결과 코드값, 결과 코드에 해당하는 문자열, 헤더, 바디 값이 있는데 각각 rspCode, rspReason, rspHeaders, rspBody 함수를 통해 확인할 수 있습니다. 예를 들어 위 소스의

  1. Right repData -> putStrLn $ show $ rspCode repData

를 아래와 같이 바꾸면

  1. Right repData -> putStrLn $ (show $ rspCode repData) ++ " - " ++ rspReason repData

결과는 아래와 같이 나옵니다.

(2,0,0) - OK

 

크롤 소스 설명

이 웹크롤러는 다음과 같은 방식으로 동작합니다.

1. 먼저 실행 인자로 최대 큐에 저장가능한 URL 갯수와 시작 지점이 될 seed URL 리스트를 받습니다.
2. 1에서 받은 실행 인자들을 이용해 URL리스트를 저장할 큐를 만듭니다.
3. 큐에서 URL을 하나 꺼내 HTTP GET 명령을 통해 해당 URL의 HTML 데이터를 가져옵니다.
4. HTML 데이터를 파싱해서 <a href='...'> 에 있는 link URL 들을 추출합니다.
5. 4에서 추출된 link URL 리스트를 큐에 저장합니다.
6. 4에서 큐에 URL 리스트를 저장할 때 총 저장 갯수가 최초 설정한 최대 크기를 초과하거나 혹은 큐가 텅비게 되면 프로그램을 종료합니다. 그렇지 않으면 3번 과정으로 돌아가 다시 위 동작들을 반복합니다.

그러면 소스를 살펴보겠습니다.

우선 크롤러에서 사용할 라이브러리들을 지정합니다. 하스켈에서는 자바와 유사하게, 자기 모듈 이름을 먼저 지정하고(생략 가능), 사용될 외부 모듈을 기술한 후에 구현 소스가 이어집니다.

  1. import Network.HTTP  -- HTTP 통신을 위한 각종 함수들이 정의되어 있습니다.
    import Network.URI    -- URI 파싱 및 상대 URL을 절대 URL로 변환하기와 같은 URL 파싱 관련 함수들이 정의된 모듈
    import System.Environment (getArgs) -- 실행 인자 처리를 위한 모듈
    import System.IO  -- readFile, writeFile 등과 같은 입출력 관련 함수들 정의 모듈
    import Text.ParserCombinators.Parsec -- 문자열 파싱을 위해 사용되는 파싱 모듈, 여기서는 HTML 파싱을 위해 사용됨

 

그 다음에는 최대 사이즈가 정해진 큐 데이터 구조를 정의합니다.

 

  1. data LimitUrlQueue = LimitUrlQueue (Int, [String])

 이 큐는 최대 사이즈를 나타내는 정수형 변수와 URL 리스트를 저장하는 String list의 쌍으로 이루어진 간단한 자료형입니다.
이제 큐에 데이터를 저장하고 빼는 함수인 pushQueue와 popQueue를 구현합니다.

 

  1. pushQueue :: [String] -> LimitUrlQueue -> Maybe LimitUrlQueue
    pushQueue e (LimitUrlQueue (i, u)) =
        if (length u + length e > i)
            then Nothing
            else Just $ LimitUrlQueue (i, u ++ e)
          

 pushQueue 는 저장 대상 e 와 저장할 공간인 큐를 파라미터로 받습니다. 만약 현재 저장된 URL 리스트와 저장할 URL 리스트의 총 길이가 큐 최대 사이즈인 i를 초과하면 Nothing을 반환하고 그렇지 않으면 큐에 저장 대상 e를 추가한 큐를 가진 Just 생성자를 반환합니다.

 

  1. popQueue :: LimitUrlQueue -> Maybe (String, LimitUrlQueue)
    popQueue (LimitUrlQueue (i, u)) =
        if (length u == 0)
            then Nothing
            else Just (head u, LimitUrlQueue (i, tail u))
      

 popQueue 는 큐에서 가장 앞에있는 원소를 하나 꺼냅니다. 이 때 큐에 데이터가 하나도 없으면 Nothing 을 반환하며 데이터가 있으면 큐의 맨 앞에 있는 URL (head u)과 이 URL을 삭제한 큐(LimitUrlQueue (i, tail u))의 쌍에 대한 Just 생성자를 반환합니다.

pushQueue와 popQueue는 데이터가 큐에 하나도 없는 상황에서 원소를 꺼내려고 하거나 최대 큐 사이즈를 초과하는 양을 저장하려고 할 때와 같은 비정상적인 요청에 대한 처리가 필요합니다. 하스켈에서는 이처럼 예외 상황에 대한 처리를 위한 방법 중 하나로 Maybe 타입을 사용합니다. pushQueue, popQueue 역시 반환값으로 Maybe 타입을 사용합니다. 

 

  1. main = do
        (qSize : seedList) <- getArgs
        simpleCrawler 0 $ LimitUrlQueue (read qSize, seedList)
        putStrLn "done!!!"

이 프로그램의 메인 함수입니다. 메인에서는 실질적인 크롤 작업을 수행하는 함수인 simpleCrawler 함수를 호출하고 함수 호출이 끝나면 "done!!!" 이라는 메시지를 출력하고 프로그램을 종료합니다.
getArgs 함수는 실행 인자를 문자열 리스트로 반환해주는 함수입니다. 첫번째 실행인자는 최대 큐사이즈인데 문자열로 되어 있으므로 정수형으로 변환하기 위해 read라는 함수를 사용했습니다.

 

  1. simpleCrawler idx urlQ = do
        case popQueue urlQ of
            Nothing -> putStrLn "queue is empty!!!"
            Just (url, queue) -> do
                (nextIdx, links) <- crawl url idx
                case pushQueue links queue of
                    Nothing -> putStrLn "queue is full!!!"
                    Just nextQueue -> simpleCrawler nextIdx nextQueue

simpleCrawler 함수는
1) 주어진 url 큐에서 url을 하나 꺼냅니다. 큐가 비어 있으면 "queue is empty!!!"라는 메시지를 출력하고 종료하며 그렇지 않은 경우에는 크롤링을 수행합니다.
2) 크롤링 함수인 crawl함수는 작업이 성공하면 데이터를 '인덱스.html' 형태로 저장하고 다음 인덱스 값과 추출된 링크 리스트의 쌍을 반환합니다. 
3) 추출된 링크 리스트를 큐에 추가합니다. 만약 큐 최대 사이즈를 초과하면 "queue is full!!!"이라는 메시지를 출력하고 종료합니다. 그렇지 않은 경우에는 simpleCrawler 함수를 재귀적으로 호출하여 위 과정을 반복합니다.

 

  1. crawl url idx = do
        putStr (url ++ " --> ")
        case parseURI url of
            Nothing -> putStrLn "Invalid URL" >> return (idx, [])
            Just u -> do
                contents <- get u
                case contents of
                    Nothing -> return (idx, [])
                    Just body -> do
                        nextIdx <- saveContents idx body
                        linkList <- extractLinks u body
                        putStrLn ("crawled!")
                        return (nextIdx, linkList)

crawl 함수는
1) 주어진 url을 먼저 parseURI 함수를 이용해 파싱합니다. parseURI 함수는 주어진 문자열을 분석해서 올바른 URI 형태를 가진 문자열이면 URI 타입으로 반환합니다.
2) get 함수를 이용해 HTTP GET 명령을 수행합니다. get 명령이 성공하면 서버에서 전달받은 HTTP body 데이터를 반환합니다.
3) 전달받은 웹 문서를 saveContents 함수를 이용해 '인덱스.html' 형태의 파일로 저장합니다. saveContents 함수는 저장에 성공하면 다음 인덱스 값을 반환합니다.
4) 웹 문서를 파싱해서 <a href='...'> 에 있는 링크 url 들을 추출합니다.
5) 추출된 링크 url 리스트와 다음 index 값을 반환합니다.

 

  1. get url = do
        response <- simpleHTTP $ request url
        case response of
            Left error -> return Nothing
            Right repData -> return $ Just $ rspBody repData

    request uri = Request{ rqURI = uri,
                           rqMethod = GET,
                           rqHeaders = [],
                           rqBody = "" }

get 함수는 말그대로 HTTP GET 명령을 수행하고 성공 시 서버에서 전달받은 데이터를 반환합니다.

 

  1. saveContents idx body = writeFile (show idx ++ ".html") body >> return (idx+1)

saveContents 함수는 서버에서 전달받은 웹 문서를, 주어진 idx 숫자를 파일이름으로 하는 html 파일로 만듭니다. writeFile은 System.IO 모듈에 정의된 파일 쓰기 함수입니다.

 

파섹(Parsec) 라이브러리 소개

서버에서 전송받은 웹문서에서 <a href> 태그에 있는 링크 url 을 추출하는 소스를 설명하기 위해 우선 하스켈에서 파싱 구문을 처리하는 방법에 대해 소개하겠습니다.
저는 html 파싱을 위해 Parsec 이라고 하는 파싱 라이브러리를 사용했습니다. 파섹은 모나드 기반의 라이브러리인데 대단히 직관적이고 사용하기 쉬우면서도 성능도 괜찮습니다.(사실 성능을 직접 측정해본적은 없지만 그렇다고 하데요...^^;)

파섹은 앞서 언급했듯이 모나드 방식으로 동작합니다. 때문에 C++나 자바와 같은 명령형 언어처럼 수행 순서가 정해져 있습니다. 즉, 모나드 함수들은 수행 순서가 달라지면 결과가 달라집니다. (원래 하스켈과 같은 함수형 언어에서 일반적인 함수들은 수행 순서에 상관없이 동일한 결과를 갖습니다.)

자질구레한 설명은 차차 하기로 하고 먼저 간단한 파섹 라이브러리 동작 구조를 알아보겠습니다. 파섹에는 우선 파싱을 수행하는 parse 라고 하는 함수가 있습니다. 이 함수의 자료형은 아래와 같습니다.

  1. parse :: GenParser tok () a -> SourceName -> [tok] -> Either ParseError a

왠지 모르게 복잡해 보이는데 간단히 설명하면 parse 함수는 세 개의 매개 변수를 받아 그 결과를 Either 타입의 값으로 반환하는 함수입니다. parse가 받는 세 가지 매개 변수 및 결과값은 아래와 같습니다.

1) GenParser tok() a : 첫 번째 매개 변수는 실제 파싱을 수행할 때 사용될 파싱 규칙과 동작을 기술한 함수입니다. parse 함수는 이 매개 변수로 전달되는 함수를 이용해서 파싱을 수행합니다.
2) SourceName : 이건 참조 문서에 보면 에러 메시지를 처리하는 파일을 지정하는 거라고 나와 있는데 무시해도 됩니다. 문서에서도 보통 빈 문자열인 ""을 넣으면 된다라고 나와 있군요...그냥 무시
3) [tok] : 파싱을 수행할 문자열입니다.
4) Either ParseError a : 1)에서 전달한 함수를 이용해서 파싱을 수행해서 규칙대로 파싱에 성공하면 해당 파싱 함수가 빈환하는 값을, 실패하면 실패한 위치와 그 이유가 기술된 에러 메시지를 반환합니다. 이 때 파싱 함수가 반환하는 값과 에러 메시지의 타입이 다를 수 있으므로 반환값을 Either라는 타입을 이용해서 한번 감싸서 반환합니다. Either 타입은 각각 Left와 Right 라는 생성자를 갖으며 Left 생성자는 에러 메시지를, Right 생성자는 GenParser가 반환한 값을 갖습니다. 그러므로 parse 함수의 일반적인 사용법은 보통 아래와 같습니다.

  1. case (parse parsingFunc "" "test.txt") of
        Left err -> print err    -- 파싱 실패! 에러 메시지 출력
        Right ret -> print ret    -- 파싱 성공! 파싱 함수가 반환하는 값을 출력

 

아마 하스켈에 익숙하지 않으면 위의 내용이 잘 이해가 가지 않을 수도 있습니다. 이전 글에서 언급한 Maybe나 여기에 나온 Either와 같은 것들은 저 역시 처음에 접하고는 '엥 이건 뭐지?' 라는 반응을 보였으니까요... 너무 집요하게 이해하려 하지 말고 처음에는 관용구처럼 이해하도록 합시다... 우선은 Maybe가 에러 처리를 위한 하나의 idiom 같은 거라면 Either는 다른 성격을 가진 두 종류의 값을 반환하는 함수를 만들고자 할 때 사용하는 idiom이라고 생각하시기 바랍니다. 즉, parse 함수는 파싱 결과와 에러 메시지라는 서로 다른 두 종류의 값 중 하나를 반환하는 함수이기 때문에 이 두 자료형을 동시에 처리하기 위해서 Either를 사용한 것입니다.

이제 실제 파싱을 수행하는 함수인 GenParser 에 해당하는 부분을 살펴보겠습니다. 하스켈에는 파싱을 위한 다양한 함수들이 존재하며 이들을 적절히 조합하면 대부분의 파싱 작업을 수행할 수 있습니다. 예를 들어 "1+2" 같은 문자열을 받으면 이를 파싱해서 3이라는 결과를 제공해주는 간단한 덧셈 함수를 만든다고 합시다. 아마도 아래와 같이 만들 수 있을 것입니다.

  1. addParser expr = case (parse addParser' "" expr) of
        Left err -> print err
        Right ret -> print ret
     
    addParser' = do
        left <- many digit >>= return . read
        op <- char '+' >> return (+)
        right <- many digit >>= return . read
        return $ left `op` right

위 함수는 아래와 같이 사용할 수 있습니다.

  1. addParser "12+34"
    46

 

실제 파싱을 수행하는 핵심 부분은 addParser' 이며 위에 빨간색으로 강조한 부분이 파섹에서 제공하는 파싱함수입니다. 각 함수는 이름이 무척 직관적이기 때문에 별다른 설명이 필요없을 정도입니다만 간략하게 언급을 하자면,

digit : 하나의 문자를 읽어서 이 문자가 숫자값이면 해당 숫자값을 반환합니다.
char : 역시 하나의 문자를 읽어서 이 문자가 char 함수 뒤에 나온 문자와 일치하면 해당 문자를 그대로 반환합니다.
many : many 뒤에 나온 함수가 파싱에 실패할 때까지 계속 문자열을 읽습니다.

따라서 위의 addParser' 함수는 다음과 같은 동작을 수행합니다.

1) left <- many digit >>= return . read : 주어진 문자열에서 숫자값이 아닐때까지 계속 읽어서 이 숫자값들을 반환합니다. 예에서처럼 "12+34"를 입력하면 + 앞부분인 12를 읽어서 12를 left에 저장합니다.
2) op <- char '+' >> return (+) : 다음 문자열이 '+' 라면 덧셈함수인 (+) 연산자를 op에 저장합니다.
3) right <- many digit >>= return . read : 다시 주어진 문자열에서 숫자값이 아닌 값이 나오거나 문자열이 끝날 때까지 숫자를 읽어서 right에 저장합니다.
4) return $ left `op` right : read함수를 사용해서 left와 right에 대해 덧셈 연산자를 저장하고 있는 op 함수를 적용한 값을 반환한다.
 
만약 위 함수에 잘못된 수식을 입력하면 에러 메시지가 발생합니다. 예를 들어 덧셈대신 뺄셈을 입력하면 아래와 같은 결과가 나옵니다.

 

  1. addParser "12-34"
    (line 1, column 3):
    unexpectted "-"
    expecting digit or "+"

 

말그대로 숫자나 '+' 값이 나와야 하는데 '-'값이 나와서 파싱에 실패했다는 에러 메시지가 출력됩니다.
그렇다면 만약 뺄셈도 처리하고 싶다면 어떻게 해야 할까요? 위의 2)번 부분에서 '+' 이면 덧셈 연산자를 리턴하고 '-'이면 뺄셈 연산자를 리턴하면 됩니다. 이렇게 'a 아니면 b를 처리해라' 라고 기술하고 싶을 때 사용하는 연산자가 <|> 입니다. 사용법은 다음과 같습니다.

  1. op <- (char '+' >> return (+)) <|> (char '-' >> return (-))

이렇게 하면 먼저 '+' 인지를 먼저 검사해보고 실패하면 '-' 인지를 검사하게 됩니다. 위의 2) 부분을 이렇게 고치면 이제 뺄셈도 처리할 수 있습니다.

  1. addParser "12-34"
    -22

다음에는 이렇게 기초적인 파섹 함수들을 사용하여 <a href> 태그의 링크를 추출하는 소스에 대해 설명하도록 하겠습니다.

 

p.s. 파섹에 대한 또다른 글인 '하스켈로 파서 만들기'도 참고하세요

 

링크 추출 소스 설명

이번엔 파섹을 이용해 html 문서에서 링크 url을 추출하는 소스를 설명하겠습니다. 저는 링크 추출을 위해 다음과 같은 단계를 거치도록 구현했습니다.

1) 태그 추출: '<'문자와 '>'문자 사이에 있는 태그 정보를 추출합니다. 이 때 주석, 자바 스크립트 소스의 경우 태그가 아닌데도 '<' 문자가 나올 수 있으므로 주석과 자바스크립트 소스를 먼저 제거합니다.
2) anchor 태그 추출: 1)에서 추출된 태그들 중 a href 태그가 있는 태그만을 추출합니다.
3) 링크 url 추출: 2)에서 추출된 anchor 태그에서 a href='...'에 url  부분만을 추출합니다.
4) 링크 url 변환: 추출된 링크 url이 상대경로 url이면 절대경로로 바꿔주고 원래 웹문서 url과 비교하여 중복된 url은 제거합니다.

그럼 소스를 하나씩 살펴보겠습니다. 먼저 html 문서에서 태그만을 추출하기 위한 소스는 아래와 같습니다.

  1. -- 단순히 사용하지 않는 매개 변수 "" 를 중복해서 입력하지 않기 위해 사용한 wrapping 함수입니다.
    parseRun p t = parse p "" t    

    -- 파싱을 수행해서 제대로 파싱이 되면 링크 url 리스트를 반환하고, 파싱에 실패하면 빈 리스트를 반환합니다.
    extractTag html = case (parseRun extractTag' html) of
        Left err -> print err >> return []
        Right tagList -> return tagList

    extractTag' = do {tag <- tagParser
                    ; tagList <- extractTag'
                    ; return (tag:tagList)
                    } <|> return [""]

    tagParser = try parseComment <|> try parseScript <|> try parseTag <|> try parsePlain

    parseComment = string "<!--" >> manyTill anyChar (string "-->") >> return ""
    parseScript = string "<script" >> manyTill anyChar (string "</script>") >> return ""
    parseTag = char '<' >> manyTill anyChar (char '>')
    parsePlain = anyChar >> return ""

 

먼저 parseComment와 parseScript는 각각 주석과 자바스크립트소스를 제거하는 역할을 수행합니다. string은 이름 그대로 다음에 나오는 문자열과 일치하면 해당 문자열을 반환하는 파섹 라이브러리 함수입니다.
그리고 manyTill anyChar 는 어떤 문자열에 대해 뒤에 나오는 파싱 함수가 성공할 때 까지 어떤 문자열이든 다 허용하겠다는 뜻입니다. 따라서 parseComment 함수가 의미하는 것은

1) string "<!--" : 해당 문자열이 "<!--" 로 시작하면
2) manyTill anyChar (string "-->") : "-->" 문자열을 만날때까지 모든 문자열을 받아서
3) return "" : 그 문자열들을 모두 무시하고 빈 문자열을 반환하겠다는 뜻입니다.

마찬가지로 parseScript 함수는 "<script" 문자열에서부터 "</script>" 사이의 모든 문자열을 받아 대신 "" 문자열을 반환합니다.
반면 parseTag는 '<' 와 '>' 사이에 있는 문자열을 반환하겠다는 뜻입니다.
parsePlain 은 어떤 문자든지 다 받아서 대신 "" 문자열을 반환합니다.

이렇게 4가지 파싱 함수는 tagParser에 의해 아래와 같이 조합됩니다.

  1. tagParser = try parseComment <|> try parseScript <|> try parseTag <|> try parsePlain

 

<|> 함수는 이전 글에서 언급했듯이 여러 파싱 함수를 조합하여 성공할 때까지 왼쪽부터 차례로 파싱을 수행합니다. 따라서 tagParser는 먼저 입력 문자열을 parseComment에 적용해보고 성공하면(즉, 해당 문자열이 주석이면) 빈문자열을 반환하며 실패하면 parseScript에 대입해봅니다. 따라서 tagParser를 이용하면 먼저 주석을 제거하고 그 다음에 자바 스크립트를 제거하며 남은 경우에 한해서 '<'로 시작하는 문자열이면 '<'과 '>' 사이의 문자열을 추출하고 그렇지 않으면 일반 텍스트로 판단하여 해당 글자를 제거하는 절차를 수행합니다.
이 때 위에 소스를 보면 try 라는 함수를 사용했는데 이것은 파섹 라이브러리의 특성때문입니다. 파섹 라이브러리는 입력값을 스트림처럼 받아서 처리합니다. 따라서 위에서처럼 몇 가지 파싱 라이브러리를 조합한, 혹은 string처럼 여러 문자를 처리하는 함수들을 <|> 로 조합하게 되면 파싱에 실패했을때 실패한 부분부터 처리를 재개합니다. 예를 들어

  1. sampleParser = string "abc" <|> string "abd"

이런 파싱함수가 있다고 했을때 원래 의도대로라면 "abd"를 입력했을 때 파싱에 성공해야 겠지만 파섹에서는 처음 적용한 파싱 함수인 string "abc"에서 "ab"까지는 성공했으므로 실패한 문자인 "c" 부분부터 string "abd" 함수에 적용을 하게 됩니다. 따라서 "abd" 문자열은 제대로 파싱되지 않습니다. 이런 문제를 해결하기 위한 함수가 try 입니다. try를 앞에 붙이게 되면 파싱 실패 시 실패한 부분부터 시작하는 것이 아니라 처음부터 다시 파싱을 수행합니다. 따라서

  1. sampleParser = try string "abc" <|> string "abd"

이렇게 하면 의도대로 "abd"가 파싱에 성공합니다. 마찬가지로 주석이나 자바스크립트나 태그가 모두 '<'로 시작하기 때문에 만약 try를 사용하지 않으면 "<script>...</script>" 라는 문자열을 파싱하게 되면 parseComment에서 '<'문자까지는 파싱에 성공하기 때문에 parseScript 함수에서는 "script>...</script>"를 파싱해야 할 것입니다.

 

  1. extractTag' = do {tag <- tagParser
                    ; tagList <- extractTag'   -- 1)
                    ; return (tag:tagList)
                    } <|> return [""]   -- 2)

tagParser 는 일회성 파싱함수입니다. 즉, 어떤 문자열이 주어졌을 때 그 문자열에서 처음 문자열이 "<!--" 로 시작하면 "-->" 까지의 문자열만을 읽고 끝내며 "<script" 로 시작하면 "</script>"까지, "<"로 시작하면 ">"까지, 위 세 경우가 아닌 경우에는 단지 한 글자만을 읽어서 처리합니다.
하지만 우리가 원하는 것은 전체 웹문서를 모두 파싱하는 것이므로 이에 대한 처리가 필요한데 extractTag' 함수가 바로 그런 처리를 수행합니다. 위 소스에서 1)을 보면 알 수 있듯이 extractTag' 함수는 먼저 tagParser를 수행하고 나면 다시 자기 자신을 재귀적으로 호출합니다. 이렇게 하면 다시 tagParser 를 수행하게 되고 이 과정을 tagParser 함수가 파싱에 실패할 때 까지 반복적으로 수행합니다. 만약 tagParser 함수가 파싱에 실패하면 2) 부분이 수행되며 여기서 extractTag' 함수는 빈 문자열의 리스트를 반환합니다. 그러면 마지막으로 재귀호출했던 1)부분의 tagList에 빈 문자열 리스트인 [""] 가 대입되고 tagParser에서 반환된 값이 링크 url이거나 빈문자열이 대입된 tag 값이 이 리스트에 추가되면서 재귀 호출이 차례로 복구됩니다. 결국 그동안 재귀 호출을 통해 계속 수행된 tagParser의 반환값들의 리스트가 만들어 집니다.
그러면 tagParser는 언제 실패할까요? 바로 입력 문자열인 웹문서의 끝에 도달했을 때 입니다. 그러므로 extractTag' 함수는 웹문서가 끝날때까지 tagParser를 수행하고 그 결과값의 리스트를 반환하는 작업을 수행하는 것입니다.

 

  1. extractAnchor html = extractTag html >>= (return . filter (checkTag check_a_href))
  2. check_a_href = string "a " >> manyTill anyChar (string "href")
    checkTag f tag = case (parseRun f tag) of
        Left _ -> False
        Right _ -> True

 

앞서 언급했듯이 extractTag 함수의 결과값은 tagParser 반환값의 리스트입니다. 여기에는 실제 태그 문자열도 있겠지만 주석이나 자바스크립트, 일반 텍스트를 파싱하면서 나온 결과인 빈문자열도 포함되어 있습니다. 게다가 우리가 실제로 필요한 리스트는 태그 중에서도 a href 태그입니다. extractAnchor 함수는 extractTag에서 추출된 리스트에서 우리가 필요한 a href 태그만을 필터링해주는 함수입니다.
filter 함수는 필터링 조건이 되는 함수와 필터링 대상이 되는 리스트를 매개 변수로 받아 각 리스트 원소를 조건 함수에 적용해서 True 값이 나오는 원소들만의 리스트를 반환하는 함수입니다. 여기서 extractAnchor는 extractTag함수의 결과 리스트를 대상으로 checkTag 함수를 적용합니다.
checkTag 함수는 매개변수로 받는 파싱함수를 이용해서 해당 파싱함수가 성공하는 태그에 대해서만 True 값을 반환합니다. 여기서는 check_a_href 함수를 이용해서 "a "로 시작하고 "href" 문자열이 있는 태그만을 추출합니다.
결국,extractAnchor html 함수는 주어진 html 문서에서 a href 태그 리스트를 반환하는 함수가 됩니다.

이제 추출된 a href 태그에서 링크 url 을 추출합니다.

 

  1. extractLinkSrc' baseUrl anchor = case (parseRun parseAnchorLink anchor) of
        Left err -> Nothing
        Right link -> parseRelativeReference link >>= (`relativeTo` baseUrl)

    parseAnchorLink = parseStartLink
        >> (try parseEndLink1 <|> try parseEndLink2 <|> try parseEndLink3 <|> try parseEndLink4)

    doubleQuote = char '\"'
    singleQuote = char '\''

    parseStartLink = string "a " >> manyTill anyChar (string "href=")

    parseEndLink1 = doubleQuote >> manyTill anyChar doubleQuote
    parseEndLink2 = singleQuote >> manyTill anyChar singleQuote
    parseEndLink3 = manyTill anyChar space
    parseEndLink4 = manyTill anyChar eof

a href 태그에서 링크를 추출하는 parseAnchorLink함수는 태그를 추출하는 tagParser 함수와 크게 다르지 않습니다.
다만 몇 가지 주의점이 있는데
1) a href 태그는 a 태그와 href 속성 사이에 다른 속성이 있을 수 있으므로 바로 string "a href"라고 하지 않고 string "a " >> manyTill anyChar (string "href=") 라고 표현합니다.
2) 속성의 값을 표현할 때는 다음 네 가지 상황이 가능합니다.
 2)-1. href="링크 url" : 속성값이 큰 따옴표로 묶여있는 경우
 2)-2. href='링크 url' : 속성값이 작은 따옴표로 묶여 있는 경우
 2)-3. href=링크 url 다른 속성들... : href 속성 이후에 다른 속성들이 나오는 경우
 2)-4. href=링크 : href 속성 이후에 다른 속성이 나오지 않는 경우

위의 2)-3 과 2)-4 가 구분된 이유는 종료 조건이 다르기 때문입니다.

어쨌든 이렇게 하면 a href의 속성값이 링크 url이 추출됩니다. 이렇게 추출된 링크 url은 상대경로 url 일 수 있기 때문에 이것을 절대경로 ur로 변환해줘야 합니다. 위 소스에서 

 

  1. Right link -> parseRelativeReference link >>= (`relativeTo` baseUrl)

 

이 부분이 바로 절대경로로 변환해주는 부분입니다. parseRelativeReference 함수와 relativeTo 함수는 Network.URI 모듈에 있는 라이브러리 함수로써 각각 주어진 문자열을 URI 타입으로 변환해주고, 이 URI를 baseUrl과 비교해서 절대 경로로 바꿔주는 역할을 수행합니다.
참고로 parseRelativeReference 함수나 relativeTo 함수는 제대로된 값을 반환하지 못할 수 있기 때문에 반환 타입이 Maybe 입니다. 따라서 extractLinkSrc' 함수의 반환값은 Maybe 타입입니다. 이 값은 다음 부분에서 - Just 값인 경우 - 적절하게 변환되거나 - Nothing 인 경우 - 제거될 것입니다.

이제 마지막으로 웹문서를 받으면 지금까지 설명한 함수들을 이용해서 링크 url을 추출하고, 추출된 링크 url 에서 중복된 url을 제거한 최종 리스트를 반환하는 부분입니다.

 

  1. extractLinks baseUrl body = extractAnchor body >>= extractLinkSrc baseUrl

    isDuplicateUrl url1 url2 = if (uriScheme url1 == uriScheme url2
                                   && uriAuthority url1 == uriAuthority url2
                                   && uriPath url1 == uriPath url2
                                   && uriQuery url1 == uriQuery url2) then True else False
                                  
    escapeUrl = escapeURIString isAllowedInURI

    filterUrl _ Nothing = ""
    filterUrl baseUrl (Just url) = if (isDuplicateUrl baseUrl url) then "" else show url

    extractLinkSrc baseUrl = return . (filter ((/=0) . length)) . (map (escapeUrl . (filterUrl baseUrl) . (extractLinkSrc' baseUrl)))

크롤러에서 수집된 웹 문서는 extractLinks 함수에 전달됩니다. extractLinks 함수는 extractAnchor 함수를 이용해 a href 태그 리스트를 추출하고 이 리스트를 extractLinkSrc 함수에 전달해 링크 url 리스트를 추출합니다.

extractLinkSrc 함수는 보기에 조금 복잡해 보이는데 이것을 풀어서 쓰면 아래와 같습니다.

 

  1. extractLinkSrc baseUrl anchorList = do
        let maybeUrlList = map (extractLinkSrc' baseUrl) anchorList -- 1)
        let linkList = map (filterUrl baseUrl) maybeUrlList  -- 2)
        let escapedLinkList = map escapeUrl linkList -- 3)
        let finalList = filter ((/=0) . length) escapedLinkList -- 4)
        return finalList

1) 주어진 a href 태그 리스트의 각 원소에 대해서 extractLinkSrc' 를 적용해 링크 url을 추출합니다.
2) 결과로 나온 Maybe 타입의 링크 리스에 대해서 filterUrl 함수를 적용합니다. filterUrl 함수는 위에 나와 있듯이 Nothing 값이면 빈 문자열을, 그렇지 않으면 해당 url 에 대해서 원래 웹문서 url과 비교해서 동일한 웹문서를 가리키는 url이면 빈문자열을 그렇지 않으면 추출된 링크 url을 반환합니다.
3) 2)에서 추출된 링크 url 의 한글이나 특수 문자, 공백등을 escape 문자로 변환합니다. 이건 단지 url을 통일성있게 관리하기 위한 처리일뿐 반드시 해줘야 할 작업은 아닙니다.
4) 최종적으로 처리된 url 들 중 빈문자열인 원소들을 제거한 리스트를 반환합니다.

 

History

Last edited on 08/22/2007 00:09 by gimmesilver

Comments (0)

You must log in to leave a comment. Please sign in.