한글판 johnsonj 2007.03.10
화면 긁기(Screen scraping)
세상에서 재미있는 서버는 대부분 웹 서버이다. 웹 페이지의 조감이 HTML이어서 컴퓨터로 (약간 노력이 필요) 다룰 수 있고, 파일에 있는 데이터는 본질적으로 인간이 읽을 수 있으며 소프트웨어로 쉽게 추출하도록 설계되어 있지 않다. 그러나, 방법이 없는 것은 아니다.
OpenEye의 데모 사이트와 PubChem을 그럴듯한 예제로 사용해 보려고 했지만 너무 복잡해서 이 에세이에 사용할 수 없었다. 약간의 검색을 마친 후에 나는 NIST에서 프로그램 하나를 만났는데 분자량에 기반하여 화합물을 찾을 수 있도록 해주는 것이었다.
이 코드의 첫 버전으로는 주어진 원자량 +/- 0.5 amu만을 검색하도록 지원하였다. 인터페이스는 다음과 같다:
>>> results = mw_search(145) >>> len(results) 118 >>> results[0] (144.86, 'AsCl2', 'AsCl2') >>>다시 말해, 값 하나를 주면 일치된 것들을 리스트로 돌려준다. 각 일치값들은 3-터플로서 무게 (실수표현)와 간단한 ASCII 이름 그리고 HTML에 대한 이름으로 구성된다.
웹에 사용되는 HTTP 프로토콜은 다양한 많은 요청 유형을 지원한다. 그 대부분은 GET 요청이고 어떤 것은 POST 요청이다. 가장 간단하게 GET 요청을 식별하는 방법은 탐색 페이지의 URL을 살펴보는 것이다. "복잡하다면" ('?' 다음에 또 텍스트가 따른다면) 거의 GET 요청이다. 한가지 검증 방법은 그 페이지를 즐겨찾기 해보고, 떠났다가 다시 그 즐겨찾기에 돌아와 보는 것이다. 그 결과가 그대로이면 GET 요청이다. 또다른 방법은 검색을 시작한 페이지의 HTML을 살펴보는 것이다. "<input type="POST" ...>"이라면 POST이다. 아무것도 지정되어 있지 않으면 GET 요청이다.
웹 페이지를 검색하려고 시도할 때 그 결과 페이지는 URL이 있다:
http://webbook.nist.gov/cgi/cbook.cgi? Value=145&VType=MW&Formula=&AllowExtra=on&Units=SI화면에 맞게 두 줄로 갈랐다.
이는 거의 GET 요청이 확실하다. 테스트해 보기 위해 "145"를 "146"으로 바꾸었다. "145"는 나의 MW 탐색 기준이었다. 새로운 결과 페이지는 그에 맞게 변했다. GET 탐색은 더 다루기 쉽다. URL에 모든 것이 있기 때문이다. POST 요청이라면 HTML을 살펴볼 필요가 있기 때문인데, 디버깅 프록시를 사용하거나 네트워크 스니퍼 또는 요즘에는 파이어폭스 확장을 사용하기도 한다.
어떤 일이 진행되는지 알아보려는 시도를 역공학(reverse engineering)이라고 부른다. 이 경우는 아주 간단하다. 매개변수는 아주 쉽게 메인 페이지의 입력과 일치한다:
- Value = 원자 무게
- VType = "MW" (이것은 HTML에 숨겨진 필드로서, "MW"로 고정됨)
- Formula = 탐색 제한을 위한 선택적 처리방안
- AllowExtra = 처리방안에 주어진 것보다 더 많은 요소 유형을 허용한다면 "on"이다
- Units = SI 단위는 "SI", 칼로리는 "CAL"로 단위를 표기
파이썬은 웹과 작동하는 라이브러리가 몇가지 있다. HTTP와 FTP에 대한 라이브러리가 있고 그 위에서 URL과 작동하는 라이브러리가 하나 있다. 실제로는 두가지다; urllib와 urllib2가 그것이다. 두 번째를 사용하겠다. 첫 번째의 단점을 보완한 것으로 믿어지기 때문이다. 다음과 같이 잘 알려진 URL을 먹여 보겠다. 파일-류의 객체를 하나 돌려준다. 완전한 응답을 읽고 그 첫 200개의 문자를 화면에 표시해 보겠다.
>>> f = urllib.urlopen("http://webbook.nist.gov/cgi/cbook.cgi?"
... "Value=145&VType=MW&Formula=&AllowExtra=on&Units=SI")
>>> s = f.read()
>>> print s[:200]
<!DOCTYPE html
PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Search Result
>>>
내가 원하는 데이터는 한참 더 아래에서 시작한다
>>> print s[1700:2200] following will be displayed: </p> <ul> <li>Molecular weight</li> <li>Chemical name</li> <li>Chemical formula</li> </ul> <p> Click on the name to see more data. </p> <ol> <li><strong>  144.86 </strong> <a href="/cgi/cbook.cgi?ID=C41996376&Units=SI">AsCl2</a> (AsCl<sub>2</sub>)</li> <li><strong>  144.86 </strong> <a href="/cgi/cbook.cgi?ID=B131&Units=SI">AsCl2 anion</a> (AsCl<sub>2</sub><sup>-</sup>)</li> <li><strong>  144.89 </strong> <a href="/cgi/cbook.cgi?ID=C1 >>>함수가 올바른 URL 질의 문자열을 만들도록 할 필요가 있다. 간단하게 문자열 교체를 하면 된다:
import urllib2
_weight_query = ("http://webbook.nist.gov/cgi/cbook.cgi?"
"Value=%f&VType=MW&Formula=&AllowExtra=on&Units=SI")
# ^^ 요기에 원자량이 들어간다
def mw_search(weight):
query = _weight_query % (weight,)
return urllib2.urlopen(query)
print mw_search(145).read()
이것을 실행시키면 날 HTML이 화면에 인쇄되는 것을 볼 수 있다.
잠시 그 HTML을 살펴보면 원하는 줄이 항상 "<li><strong>"로 시작함을 알 수 있다. 그 형태가 절대로 바뀌지 않는다고 가정하면 아주 간단한 해석기를 이용해 원하는 필드들을 가져 올 수 있다. 다음이 그것이다
import urllib2
_weight_query = ("http://webbook.nist.gov/cgi/cbook.cgi?"
"Value=%f&VType=MW&Formula=&AllowExtra=on&Units=SI")
# ^^ 요기에 원자량이 들어간다.
def _extract_data(infile):
results = []
for line in infile:
if not line.startswith("<li><strong>"):
continue
# 이 줄들에 내가 원하는 데이터가 담긴다.
# 원자량은 ';'와 '<' 사이이다
# <li><strong>  144.86 </strong>
weight_start = line.index(";")+1
weight_end = line.index("<", weight_start)
weight = float(line[weight_start:weight_end])
# 화학명은 'SI">'와 다음 '<' 사이이다.
# SI">AsCl2</a>
name_start = line.index('SI">')+4
name_end = line.index('<', name_start)
name = line[name_start:name_end]
# (HTML 형태의) 화학식은 반괄호 사이이다.
formula_start = line.index("(", name_end) + 1
formula_end = line.index(")", formula_start)
formula = line[formula_start:formula_end]
results.append( (weight, name, formula) )
return results
def mw_search(weight):
query = _weight_query % (weight,)
f = urllib2.urlopen(query)
return _extract_data(f)
if __name__ == "__main__":
results = mw_search(145)
print results[0]
print len(results)
별로 우아한 해석기는 아니지만 작동은 한다. 다음은 그 출력이다.
(144.86000000000001, 'AsCl2', 'AsCl<sub>2</sub>') 118
데이터를 HTML으로부터 뽑아내는 이 과정을 화면 긁기(screen scraping)라고 부르는데 그 데이터를 직접 얻기보다 화면에서 뽑아내기 때문이다. 기본 처리과정은 정확하게 다음 예제와 똑 같다: 요청을 구성하고, 그 응답을 해석한다. 물론, 좀 더 복잡한 경우라면 몇가지 반복처리를 해야한 필요한 결과를 얻을 수 있다.
HTML을 해석하는 것은 종종 문제 해결에서 가장 힘든 부분이다. 서버에서 돌려주는 HTML은 정의가-허술하며 종종 유효하지 않은 경우도 있다. 유효한 경우라도 어느 요소가 어디에 있는지 추출할 데이터를 어떻게 식별할지 정의할 방법이 없다. 그런 일은 경험으로 조사해 알아볼 필요가 있다.
HTML 화면 긁기에 유용한 라이브러리 하나는 BeautifulSoup이다. 이 라이브러리는 품질이 형편없는 HTML이라도 트리 구조로 변환시켜 준다. HTML을 문자열로 작업하는 것에 비해 트리 구조가 훨씬 해석하기가 편하다.
문서 구조에 관하여 줄 집합이 아니라 트리로 이해하기를 요구한다. 이 경우에는 마치 화학 정보가 레코드에서 ol의 li 필드에 있는 것 처럼 보인다.
>>> f = urllib.urlopen("http://webbook.nist.gov/cgi/cbook.cgi?"
... "Value=145&VType=MW&Formula=&AllowExtra=on&Units=SI")
>>> s = f.read()
>>> import BeautifulSoup
>>> soup = BeautifulSoup.BeautifulSoup(s)
>>> ol = soup.first("ol")
>>> ol.first("li")
<li><strong>  144.86 </strong> <a href="/cgi/cbook.cgi?ID=C41996376&Units=SI">AsCl2</a> (AsCl<sub>2</sub>)</li>
>>>
몇가지 실험과 테스트를 한 후에 다음과 같이 BeautifulSoup 버전으로 해석기를 만들었다.
import BeautifulSoup
import urllib2
_weight_query = ("http://webbook.nist.gov/cgi/cbook.cgi?"
"Value=%f&VType=MW&Formula=&AllowExtra=on&Units=SI")
# ^^ 요기에 원자량이 들어간다.
def _extract_data(soup):
results = []
ol = soup.first("ol")
for li in ol.fetch("li"):
weight_term = li.first("strong").string
# 앞의 유니코드 문자는 무시한다.
weight = float(weight_term.split()[1])
name = li.first("a").string
# 여전히 텍스트를 검색할 필요가 있다:(
s = str(li)
formula_start = s.find("(")+1
formula_end = s.find(")", formula_start)
formula = s[formula_start:formula_end]
results.append( (weight, name, formula) )
return results
def mw_search(weight):
query = _weight_query % (weight,)
f = urllib2.urlopen(query)
soup = BeautifulSoup.BeautifulSoup(f.read())
return _extract_data(soup)
if __name__ == "__main__":
results = mw_search(145)
print results[0]
print len(results)
이 경우 원래의 줄-지향적 해석기보다 약간 더 명료할 뿐이다. 그 이유는 해석이 쉬운 서버를 선택했고 에러 처리를 시도하지 않았기 때문이다. 그래서 그렇게 해보자.
음의 값을 건네면 서버는 어떻게 하는가? 상호대화적으로 시도해 보자 다음 페이지를 얻었다:
No Matching Species Found
No species with the requested data and a molecular weight in the range of [-145.50, -144.50] were found in the database.
그리고 위의 함수를 사용하여 빈 리스트를 얻었다. 그것이 내가 원한 것이다.
좋다. 오직 일치가 한 개면 어떻게 할까? mw=2011을 검색하면 다음과 같은 결과가 반환된다
In14P13 anion
- Formula: In14P13-
- Molecular Weight: 2010.11
- CAS Registry Number: 243867-98-3
그리고 다음에 추가 정보가 따른다. 화합물이 하나 밖에 없다면 서버는 데이터를 좀 더 많이 그리고 다른 형태로 보여주는 듯하다. 해석을 위한 적절한 HTML 형태는 다음과 같다
<h1><a id="Top" name="Top">In14P13 anion</a></h1> <ul> <li><strong>Formula:</strong> In<sub>14</sub>P<sub>13</sub><sup>-</sup></li> <li><strong>Molecular Weight:</strong> 2010.11</li>이 경우에는 해석기를 작성해도 된다. 언제 어느 것을 사용할지만 알면 된다. 잠시 HTML을 살펴 본 후에, h1 필드에a가 있다면 그것은 한개의 화합물에 관한 상세한 정보이다. 그렇지 않으면, 결과 리스트 또는 그 범위에서는 결과가 없다는 에러 메시지이다. 가장 만족스런 해결책은 아니지만 그것이 스크린 긁기할 때는 전형이다.
다음 코드는 그 로직을 구현한다. 어떻게 한 함수가 수프 내용을 식별해서 적절한 해석기로 건네어 올바른 데이터를 추출하는지 주목하자. 이렇게 구분해 놓으면 코드가 더 읽기 쉽고 테스트하기 더 쉽다.
import BeautifulSoup
import urllib2
_weight_query = ("http://webbook.nist.gov/cgi/cbook.cgi?"
"Value=%f&VType=MW&Formula=&AllowExtra=on&Units=SI")
# ^^ 원자량은 여기에 들어간다
## 다음을 해석한다
# <ol>
# <li><strong> 144.86 </strong> <a href="/cgi/cbook.cgi?ID=C41996376&Units=SI">AsCl2</a> (AsCl<sub>2</sub>)</li>
# <li><strong> 144.86 </strong> <a href="/cgi/cbook.cgi?ID=B131&Units=SI">AsCl2 anion</a> (AsCl<sub>2</sub><sup>-</sup>)</li>
# <li><strong> 144.89 </strong> <a href="/cgi/cbook.cgi?ID=C166899805&Units=SI">Al3S2 anion</a> (Al<sub>3</sub>S<sub>2</sub><sup>-</sup>)</li>
def _extract_search_results(soup):
results = []
ol = soup.first("ol")
for li in ol.fetch("li"):
weight_term = li.first("strong").string
# 앞의 유니코드 문자는 무시한다.
weight = float(weight_term.split()[1])
name = li.first("a").string
# 여전히 텍스트를 검색할 필요가 있다 :(
s = str(li)
formula_start = s.find("(")+1
formula_end = s.find(")", formula_start)
formula = s[formula_start:formula_end]
results.append( (weight, name, formula) )
return results
## 다음을 해석한다
# <h1><a id="Top" name="Top">In14P13 anion</a></h1>
# <ul>
# <li><strong>Formula:</strong> In<sub>14</sub>P<sub>13</sub><sup>-</sup></li>
# <li><strong>Molecular Weight:</strong> 2010.11</li>
# <li><strong>CAS Registry Number:</strong> 243867-98-3</li>
def _extract_single_result(soup):
name = soup.first("h1").first("a").string
lis = soup.first("ul").fetch("li")
# 빈줄 하고 </li> 사이의 텍스트이다.
s = str(lis[0])
formula_start = s.index(" ")+1
formula_end = s.index("</li>")
formula = s[formula_start:formula_end]
weight = float(lis[1].contents[1].string)
return [(weight, name, formula)]
def _extract_data(soup):
h1 = soup.first("h1")
# 'h1'에 'a' 태그가 있다면,
# 결과 요소가 달랑 하나이다.
if h1.first("a") is not BeautifulSoup.Null:
return _extract_single_result(soup)
else:
return _extract_search_results(soup)
def mw_search(weight):
query = _weight_query % (weight,)
f = urllib2.urlopen(query)
soup = BeautifulSoup.BeautifulSoup(f.read())
return _extract_data(soup)
더 많은 일을 할 수 있다. 함수 호출은 보다 더 많은 서버 검색 매개변수를 지원하도록 확장이 가능하다. 리스트 결과 페이지에 대한 해석기에 각 화합물에 관한 자세한 정보로 가는 링크를 포함시킬 수 있다. 해석기가 돌려주는 정보에는 검색 한계에 도달했는지 알려주는 플래그가 포함되어야 한다. 그 화합물의 세부정보에 대하여 해석기는 그 페이지에서 더 많은 세부정보들을 추출할 수 있다; 구조에 대한 이미지, 2D mol 파일, CAS 번호, 대안 이름, 등등.
웹 서비스에 대하여 클라이언트를 개발하는 전반적 과정은 subprocess로 실행파일을 포장하여 인터페이스하는 앞의 과정과 비슷하다. 먼저, 기대에 맞게 시스템과 어떻게 상호작용하고 싶은지 알아내자. 여러분이 만든 데이터 모델이 서버의 데이터모델과 부합하지 않을 수 있다: 그렇다면 오버로드된 서버 함수들을 서로다른 API 함수들로 쪼개거나, 여러 서버 요청을 합병하는 객체 하나를 만들면 된다.
기본 기능을 코드한다. 잘 작동하면, 서버를 이상한 방식으로 망가트리는 방법을 알아낸다. 창조적으로 생각하자. 그런 테스트들을 자동 테스트 시스템에 배치하는 것을 명심하자. 코드를 재작성하거나 깨끗이 다듬고 나면, 그 테스트들을 실행하자. 테스트가 통과하면 수정한 것이 새로운 문제를 초래하거나 알려진 예전 문제를 건너뛰지 않았다고 확신할 수 있다.
확장하고, 테스트하고, 검열하고, 멈추고, 수정한다. 필요한 것을 얻을 때까지 반복한다. 명심하자. 필요하지 않을 특징을 구현할 필요가 없고 일어나지 않을 실패를 테스트할 필요가 없다.
그런데, 더 복잡한 URL 질의 문자열을 구성하려고 한다면, 꼭 urllib.urlencode() 함수를 사용하자. 이 글에서는 오직 사용자-정의 매개변수는 숫자여서 %f를 사용하여 다루기 쉬웠다. 대부분의 다른 경우 매개변수 필드에는 보통 문자가 담겨 있다. URL에 대한 규칙들에는 URL에 어느 문자들이 허용되는지 제한을 두고 있다. 다른 문자들은 반드시 그 규칙들을 따라 피신시켜야 하는데, 이 일을 urlencode가 대신 해준다.
Copyright ⓒ 2001-2005 Dalke Scientific Software, LLC.


