gimmesilver's blog

Agbird.egloos.com

포토로그



하스켈로 웹크롤러 구현하기...5 하스켈 스프링노트

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

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

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

-- 단순히 사용하지 않는 매개 변수 "" 를 중복해서 입력하지 않기 위해 사용한 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에 의해 아래와 같이 조합됩니다.

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

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

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

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

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

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

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를 수행하고 그 결과값의 리스트를 반환하는 작업을 수행하는 것입니다.

extractAnchor html = extractTag html >>= (return . filter (checkTag check_a_href))

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 을 추출합니다.

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로 변환해줘야 합니다. 위 소스에서 

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


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

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

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 함수는 보기에 조금 복잡해 보이는데 이것을 풀어서 쓰면 아래와 같습니다.

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 들 중 빈문자열인 원소들을 제거한 리스트를 반환합니다.

이렇게 해서 웹문서에서 a href 태그의 링크들을 추출하는 소스까지 웹 크롤러 프로그램을 구현해 봤습니다. 전체 소스는 하스켈로 웹크롤러 구현하기...1 에 있습니다. 비록 단일 쓰레드 기반의 매우 단순한 크롤러 프로그램이지만 하스켈이 단순한 수학용 언어이다라는 편견을 깨는데 도움이 되었으면 좋겠습니다.


덧글

  • 백승우 2007/07/17 20:05 # 답글

    DOM객체로 만들어 <A>노드의 getAttribute("href")하면.. ㅡ ㅡ
    하스켈 전용 xml 스펙을 이용할 순 없을까요? ^^
  • silverbird 2007/07/17 23:52 # 답글

    // 백승우
    하스켈에 HakageDB에 보면 HaXml이라는 xml 파싱 라이브러리모듈이 있긴 합니다. 근데 사용법도 그다지 편하지 않은데다가 테스트를 해보니 대부분의 웹 문서가 파싱 오류를 일으켜서 사용할만한 수준이 못되더군요...
  • 백승우 2007/07/18 23:06 # 답글

    네..xml이 적잖이 까칠해서리..^^
  • 2010/04/09 05:12 # 답글 비공개

    비공개 덧글입니다.
  • silverbird 2010/04/09 12:17 #

    XPath 라는 것을 이용한 Parser 를 한번 찾아보시기 바랍니다. XPath 는 HTML 이나 XML 로 명세된 데이터를 처리할 때 사용하는 언어인데 이 언어로 표현했을 때 "//body//text()[name(parent::node()) != 'SCRIPT' and name(parent::node()) != 'STYLE']" 정도면 보통의 웹 문서에서는 텍스트만 뽑을 수 있을 것입니다. 참고로 FireFox 의 플러그인 중 DOM Inspector 라는 것이 있는데 이 플로그인을 이용하면 위 XPath 표현식으로 해당 웹 문서의 텍스트만 뽑을 수 있습니다.
댓글 입력 영역