HTML 화면 긁어 모으기: 하우투 문서

저자: 데이브 쿨만(Dave Kuhlman)
한글판: johnsonj 2007.01.16 원문위치
Address:
dkuhlman@rexx.com
http://www.rexx.com/~dkuhlman
Revision: 1.0a
Date: Jan. 9, 2004
Copyright: Copyright (c) 2004 Dave Kuhlman. 이 문서는 MIT 라이센스로 보호된다: http://www.opensource.org/licenses/mit-license.

요약

이 문서에서는 HTML 화면 긁어모으기를 설명한다. 실제로 웹을 한 자원으로 다루는 법을 보여준다. HTML 웹 페이지로부터 데이터를 추출하여 열람하는 법을 배울 수 있다.

내용

1   들어가는 말

웹에는 엄청난 양의 정보가 있다. 이 문서는 퀴호테 웹 어플리케이션 뒤의 후방 자원으로 웹을 사용하는 법을 보여준다.

이 문서는 퀴호테 어플리케이션 뒤에서 이렇게 하는 방법을 설명한다 (그렇지만 비슷한 테크닉으로 다른 환경에서도 사용이 가능하다). 특히, 다음을 살펴보겠다:

배포 파일에 소스 코드가 들어 있다. 이 파일에서 예제들을 뽑았다. 다음에서 찾을 수 있다: http://www.rexx.com/~dkuhlman/quixote_htmlscraping.zip.

퀴호테 어플리케이션을 위한 후방-자원으로서의 웹은 그냥 퀴호테 어플리케이션 뒤에 놓을 수 있는 자원중의 하나일 뿐이다. 또다른 후방 자원으로, 예를 들면 관계형 데이터베이스와 XML-RPC 그리고 SOAP와 파이썬 확장 모듈 등이 있다. 퀴호테 아래에서 여러 후방 자원에 접근하는 법에 대한 정보를 더 자세하게 알고 싶다면, 퀴호테(Quixote) 어플리케이션: 시작하기에서 특수 작업 -- 후방 자원을 참조하자 .

2   명령어-줄 사용법

명령어 줄에서 테스트 패턴을 실행해서 빨리 만들면 좋은 경우가 있다. 다음이 바로 그렇게 하는 간단한 함수이다. urllib 모듈을 사용하여 웹 페이지를 열람하고 popen2 모듈을 사용하여 sgrep을 실행한다:

import urllib
import popen2

#
# URL의 소를 훓어본다.
# URL을 열람한 다음, 그 내용(문자열)을 sgrep에 먹인다.
#
def search_url(pattern, url, addOptions):
    if not addOptions:
        addOptions = ''
    options = "-g html -o '%r:::' -T " + addOptions
    cmd = "sgrep %s '%s' -" % (options, pattern)
    print 'cmd:', cmd
    try:
        instream = urllib.urlopen(url)
    except IOError:
        print '*** bad url: %s' % url
        return
    content = instream.read()
    instream.close()
    print 'len(content): %d' % len(content)
    # 내용을 sgrep에 먹이고 그 결과를 수집한다.
    outfile, infile = popen2.popen2(cmd)
    infile.write(content)
    infile.close()
    results = outfile.read()
    outfile.close()
    print 'results:\n========\n'
    resultlist = results.split(':::')
    for result in resultlist:
        if result.strip():
            print result
            print '---------------'

설명:

3   퀴호테(Quixote) 내부로부터

솔직히 말해 퀴호테 안에서 이렇게 하는 일은 기본적으로 퀴호테 밖에서 하는 것과 똑 같다. 역시 또, urllib를 사용하여 웹 페이지를 열람한 다음, sgrep을 사용하여 그 페이지로부터 텍스트 조각들을 추출하고, 마지막으로 파이썬의 정규 표현식 모듈 re (또는 기타 파이썬 해석 테크닉)을 사용하여 데이터 항목들을 sgrep이 돌려주는 결과로부터 추출한다.

한가지 걱정은 지연시간이다. 특히, 다음이 문제가 될 수 있다:

  1. 퀴호테 어플리케이션이 (예, urllib를 통하여) 요청이 완수 되기를 기다리는 동안 서버가 블록될 수 있다.
  2. 클라이언트도 퀴호테 어플리케이션이 웹 페이지를 열람하는 동안 또 지연을 맞이할 가능성이 있다.

그런 걱정을 불식시키기 위하여 다음을 시도해 볼만하다:

  1. 요청을 처리하는 동안 블로킹의 가능성은 퀴호테에 SCGI 서버를 사용하면 사라진다. 이 서버는 기존의 프로세스가 요청을 처리하지 못하면 각 요청에 대하여 새로 프로세스를 만든다. 이런 요청 처리자들은 따로따로 실행되기 때문에, 서로 블록시킬 염려가 없다. 이렇게 하면 파이썬의 전역 인터프리터 잠금(GIL)에 관하여 걱정하는 사람도 안심할 수 있을 것이다. 그리고, 최대 프로세스 개수는 서버가 기동할 때 증가시킬 수 있다.
  2. 클라이언트로서 현격한 지연은 참을 수 없다. 어떤 것들은 시간이 소요된다. 어떤 어플리케이션에서는 결과를 캐쉬해 둘 수 있다.

4   Sgrep 패턴

다음 섹션은 HTML 문서 안에서 데이터를 선택하는 sgrep 패턴을 작성하는데 도움이 된다. 수행하고 싶어할 만한 sgrep으로 전형적인 데이터 추출 작업을 하기 위한 예제들이 포함되어 있다.

몇가지 예제들:

몇 가지 추가 논평과 주의사항:

5   sgrep 실행

sgrep을 실행하기 위한 두 가지 테크닉이 있다:

pysgrep의 사용법은 PySgrep - Sgrep을 위한 파이썬 포장자에 기술되어 있으므로, 여기에서 재론하지 않겠다.

popen2을 사용하는 것이 간단하고 아래 예제에서 사용될 것이다.

비교 -- PySgrep vs. popen2:

6   Sgrep 그리고 정규 표현식

sgrep은 (아직) 정규 표현식을 사용할 능력을 갖추지 못하였다. 파이썬의 re 모듈을 사용하고 그것을 sgrep이 산출한 결과에 적용하면 정규 표현식의 효과를 얻을 수 있다.

기본적으로 sgrep이 돌려주는 각각의 코드조각으로부터 데이터 조각들을 정규 표현식을 사용하여 추출하겠다.

예제 하나 -- 서버와 도메인 이름을 URL에서 추출하고 싶다고 해 보자. 예를 들어, sgrep이 다음과 같은 것을 돌려준다고 가정하자:

http://www.python.org/doc/current/tut/tut.html

그리고, 다음과 같이 추출하고 싶다:

www.python.org

다음이 그 방법이다:

#
# 파일들을 훓는다.
# 파일들을 읽어서, 파일 내용(문자열)을 sgrep에 먹인다.
#
def search_files(pattern, filenames, addOptions, regex):
    if not addOptions:
        addOptions = ''
    options = "-g html -o '%r:::' " + addOptions
    #
    # 정규 표현식이 있다면 컴파일 한다.
    expr = None
    if regex:
        expr = re.compile(regex)
    cmd = "sgrep %s '%s' -" % (options, pattern)
    for filename in filenames:
        inputfile = file(filename, 'r')
        outfile, infile = popen2.popen2(cmd)
        infile.write(inputfile.read())
        infile.close()
        results = outfile.read()
        outfile.close()
        print '=' * 50
        s1 = 'file: %s' % filename
        print s1
        print '=' * len(s1)
        resultlist = results.split(':::')
        for result in resultlist:
            if result.strip():
                print result
                #
                # 정규 표현식이 있다면
                # 이용하여 결과를 검색한다.
                if expr:
                    matchobject = expr.search(result)
                    if matchobject:
                        print 'match: %s' % matchobject.group(1)
                    else:
                        print 'no match'
                print '---------------'

설명:

7   Sgrep 그리고 HTMLParser

어떤 경우 sgrep이 돌려주는 테스트 조각에 HTML 조판이 들어 있는 경우가 있다. 좀 복잡해서 정규 표현식으로 분석하려면 곤란하다. 또 어떤 경우는 그냥 sgrep에 HTML 조판을 그대로 돌려달라고 요청하는 것이 더 쉽다. 다음 섹션에서는 파이썬의 HTMLParser 모듈을 사용하여 이런 텍스트 조각들을 분석하는 법을 보여준다.

이런 테크닉은 HTMLParser.feed()에게 "완전한" 조판 조각을 넘겨줄 필요가 있을 경우로 제한된다. 즉 서로 쌍을 이루는 태그를 넘겨줄 필요가 있을 경우에만 사용해야 한다. 그렇지만, 이런 필수조건은 sgrep으로 쉽게 만족시킬 수 있다. 왜냐하면, "(stag("tag") .. etag("tag")) parenting ..."와 같은 형태의 질의가 돌려주는 HTML 조판 조각을 HTMLParser.feed(data) 메쏘드에 먹이면 된다. 그리고, 단 한번의 호출로 조판 조각들을 모두 다 HTMLParser.feed(data)에 먹일 필요가 없음을 주목하자; 여러번 feed를 호출해서 그렇게 해도 된다.

다음은 상당히 간단한 예제이다. 이 예제는 질의 하나를 가지고 http://jobs.com/를 검색한 다음, (1) 간략한 구인 정보, (2) URL 하나, (3) 회사 이름, 그리고 (4) 직장 위치를 추출하고 나서, 이렇게 추출된 정보로 웹 페이지 하나를 포맷한다.

다음은 질의와 데이터 추출을 수행하는 코드이다:

class JobService:

    #
    # 질의 하나를 처리한다.
    # 터플을 돌려준다: (urlList, descriptionList, companyList, locationList).
    # 질의는 단어의 연속열로서 공백문자로 분리된다.
    #
    def job_search(self, query):
        if query:
            q1 = query.replace(' ', '.')
            q2 = query.replace(' ', '%26')
            q3 = query.replace(' ', '+')
        else:
            return [], [], [], []
        req = 'http://%s.jobs.com/jobsearch.asp?re=9&vw=b&pg=1&cy=US&sq=%s&aj=%s' \
            % (q1, q2, q3)
        f = urllib.urlopen(req)
        content = f.read()
        f.close()
        resultTuple = self.job_parse(content)
        return resultTuple

    def job_parse(self, content):
        # URL을 추출한다.
        cmd = "sgrep -g html -o '%r:::' 'attribute(\"HREF\") in " \
              "((stag(\"A\") .. etag(\"A\")) childrening " \
              "(stag(\"TD\") .. etag(\"TD\")) containing " \
              "attribute(\"HREF\") containing \"getjob\")' -"
        urlList = self.extract(cmd, content)
        # 설명을 추출한다.
        cmd = "sgrep -g html -o '%r:::' '(stag(\"A\") __ etag(\"A\")) in " \
              "((stag(\"A\") .. etag(\"A\")) childrening " \
              "(stag(\"TD\") .. etag(\"TD\")) containing " \
              "attribute(\"HREF\") containing \"getjob\")' -"
        descriptionList = self.extract(cmd, content)
        # 회사 이름과 위치를 추출한다.
        cmd = "sgrep -g html -o '%r:::' '(stag(\"TR\") .. etag(\"TR\")) containing " \
              "(stag(\"TD\") .. etag(\"TD\")) parenting " \
              "stag(\"A\") containing " \
              "attribute(\"HREF\") containing \"getjob\"'"
        companyList, locationList = self.extract_with_htmlparser(cmd, content)
        return urlList, descriptionList, companyList, locationList

    def extract(self, cmd, content):
        outfile, infile = popen2.popen2(cmd)
        infile.write(content)
        infile.close()
        results = outfile.read()
        outfile.close()
        resultList = results.split(':::')
        return resultList

    def extract_with_htmlparser(self, cmd, content):
        parser = LocationHTMLParser()
        outfile, infile = popen2.popen2(cmd)
        infile.write(content)
        infile.close()
        results = outfile.read()
        outfile.close()
        resultList = results.split(':::')
        companyList = []
        locationList = []
        for result in resultList:
            parser.clear()
            parser.feed(result)
            companyList.append(parser.getCompany())
            locationList.append(parser.getLocation())
        return companyList, locationList


class LocationHTMLParser(HTMLParser.HTMLParser):

    def __init__(self):
        HTMLParser.HTMLParser.__init__(self)
        self.count = 0
        self.company = ''
        self.location = ''

    def handle_starttag(self, tag, attrs):
        if tag == 'td':
            self.count += 1

##    def handle_endtag(self, tag):
##        pass

    def handle_data(self, data):
        if self.count == 4:
            self.company += data
        elif self.count == 5:
            self.location += data

    #
    #  self에 주목:  "reset"이라는 이름을 사용하지 말 것.  
    #  HTMLParser가 그 이름을 정의하고 사용한다.
    def clear(self):
        self.count = 0
        self.company = ''
        self.location = ''

    def getCompany(self):
        return self.company

    def getLocation(self):
        return self.location

설명:

다음은 퀴호테 웹 사용자 인터페이스를 제공하고 웹 페이지를 생성하는 코드이다:

class ServicesUI:
    o
    o
    o

    def do_job_search [html] (self, request):
        queryString = widget.StringWidget('query_string')
        submit = widget.SubmitButtonWidget(value='Search')
        if request.form:
            queryStringValue = queryString.parse(request)
        else:
            queryStringValue = ''
        jobservice = services.JobService()
        urlList, descriptionList, companyList, locationList = jobservice.job_search(
            str(queryStringValue))
        header('Jobs')
        '<form method="POST" action="job_search">\n'
        '<p>Query string:'
        queryString.render(request)
        '</p>\n'
        '<p>'
        submit.render(request)
        '</p>\n'
        '</form>\n'
        '<hr/>\n'
        '<ul>\n'
        re1 = re.compile(str('href="([^"]*)"'))
        q1 = queryStringValue.replace(str(' '), str('.'))
        for idx in range(len(urlList)):
            result = urlList[idx]
            description = descriptionList[idx]
            company = companyList[idx]
            location = locationList[idx]
            if result.strip():
                mo = re1.search(result)
                if mo:
                    url = mo.group(1)
                    '<li><a href="http://%s.jobs.com%s">%s</a> at %s in %s</li>' % \
                        (q1, url, description, company, location)
        '</ul>'
        footer()

설명:

8   HTML 긁어 모으기 개발 방법론

다음 섹션은 간단하게 요약/검토하고 제안을 하면서 다음과 같은 일들을 차례대로 따라가 보겠다.

각 HTML 긁어 모으기 연산에 대하여, 다음과 같은 일을 한다:

  1. URL을 결정하고 잡아온다 -- 웹 브라우저를 사용하여 원하는 데이터가 있는 페이지를 방문한다. 다음 그 주소의 내용을 복사한다. 파이썬 코드에서는 URL에 인자를 교체할 필요가 있을 수 있으니 주의하자.

  2. 샘플 웹 페이지 하나를 파일로 저장한다. 다음은 웹 페이지 하나를 열람하고 그것을 표준화면에 쓰는 간단한 스크립트이다. 마음대로 파일에 파이프 처리해 넣을 수 있다:

    import urllib
     
    def get_page(url):
        f = urllib.urlopen(url)
        content = f.read()
        f.close()
        print content
    
  3. 파일에 잡아 둔 이 샘플 페이지로부터 데이터 항목들을 추출하고 싶다면, 오프-라인(즉 퀴호테 밖)에서 스크립트를 작성하고 테스트하자. 다음은 데이터 추출 스크립트를 테스트해 보는데 사용할 수 있는 설비이다. 실제로 유닛 테스트에서 취했으며, 위의 예제에 있는 코드를 테스트한다:

    from test2.services import jobsearchservices
    
    class TestDataExtraction(unittest.TestCase):
        o
        o
        o
        def test_retrieve_and_parse(self):
            jobservice = jobsearchservices.JobService()
            urlList, descriptionList, companyList, locationList = \
                jobservice.job_search('python internet')
            self.assert_(len(urlList) > 0)
            self.assert_(len(urlList) == len(descriptionList))
            self.assert_(len(urlList) == len(companyList))
            self.assert_(len(urlList) == len(locationList))
    
  4. 방금 테스트 한 데이터 추출 함수를 퀴호테 어플리케이션 안에 잘라 붙이자. 또는, 약간 준비를 하고, 이 데이터 추출 함수들을 파이썬 모듈 안에 배치해도 좋다. 오프-라인 테스트 동안 그리고 퀴호테 어플리케이션 안에서 사용할 수 있다..

  5. 파이썬의 unittest 작업틀을 이용하여 데이터 추출 함수들을 위한 테스트들을 설정하자.

8.1   유닛 테스트

훌륭한 개발 방법은 -- 첫째가, 문서이다. 둘째는, 유닛 테스트이다. 그 다음이 코드이다.

다음은 샘플 유닛 테스트로서 이것으로 시작해 보자:

#!/usr/bin/env python

import unittest
from test2.services import jobsearchservices

class TestDataExtraction(unittest.TestCase):
    
    def setUp(self):
        pass

    def test_retrieve_and_parse(self):
        jobservice = jobsearchservices.JobService()
        urlList, descriptionList, companyList, locationList = \
            jobservice.job_search('python internet')
        self.assert_(len(urlList) > 0)
        self.assert_(len(urlList) == len(descriptionList))
        self.assert_(len(urlList) == len(companyList))
        self.assert_(len(urlList) == len(locationList))

if __name__ == '__main__':
    unittest.main()

설명:

  • 이 샘플은 간단한 테스트를 수행하고 다음을 확인한다. 적어도 하나 이상의 데이터 항목을 추출했고 추출된 데이터 항목들이 담긴 반환된 리스트의 길이가 동등한지 확인한다.
  • 여기에서는 HTML 데이터 열람과 추출 코드가 (나머지 퀴호테 어플리케이션과 분리되어) 따로 모듈에 담겨 있기 때문에, 퀴호테 어플리케이션 밖에서 도입하고 테스트할 수 있다.
  • 추가로 테스트하려면, 이름이 "test"라는 문자로 시작하는 메쏘드를 더 추가하자.
  • 파이썬 유닛 테스트 작업틀에 관한 더 자세한 정보는, 다음을 참조하자: http://www.python.org/doc/current/lib/module-unittest.html.

9   요약

9.1   힌트와 제안

다음은 HTML 데이터의 접근을 구현하는데 필요한 몇가지 제안사항이다:

  • 사용자 인터페이스를 모델과 구분하자 -- 후방 접근 코드는 따로 퀴호테 코드가 하나도 담기지 않은 별도의 모델에 두자. 이렇게 해야할 이유가 몇 가지 있다: (1) 그렇게 하면, 사용자 인터페이스를 확실하게 모델 (어플리케이션 로직)과 분리할 수 있으며, (2) 그렇게 되면 코드를 퀴호테 밖에서 (예를 들어, 파이썬 유닛 테스트 작업틀로 작성된 유닛 테스트로부터) 수행할 수 있다.
  • API를 정의하자 -- 정의가-잘된 자원 세트에 접근할 수 있도록 코드를 작성하도록 하자. 이를 완수하기 위한 한 가지 방법은 (1) 각 웹 자원 접근에 대하여 따로 함수나 메쏘드를 구현하거나 (2) 각 함수/메쏘드에 대하여, URL과 반환될 데이터를 모두 지정하는 것이다. URL을 지정하려면 그 URL에 채워 줄 인자들, 예를 들어 CGI 변수들 같은 인자들을 기술할 필요가 있다. 반환된 데이터를 지정하려면 복합 구조, 예를 들어 리스트의 리스트 같은 복합구조를 기술할 필요가 있다.

9.2   동기와 도구들

웹의 거대한 자원이다. 빼놓은 것이 있다면 그것을 사용해야 할 동기와 그 도구들이다. 어쨌거나 ...

10   참조

http://www.mems-exchange.org/software/quixote/: 퀴호테(Quixote) 지원 웹 사이트.

Sgrep 홈 페이지

sgrep - 파일에서 구조화된 패턴을 찾는다.: sgrep 매뉴얼.

re -- 정규 표현식 연산: 파이썬의 정규 표현식 모듈에 관한 문서.

PySgrep - Sgrep에 대한 파이썬 포장자: sgrep 파이썬 확장 모듈에 관한 자세한 정보.

unittest: 파이썬 유닛 테스트 작업틀