3.2 전체 사이트 크롤링
이전 섹션에서는 링크에서 링크로 움직이며 웹사이트를 무작위로 이동했습니다. 하지만 사이트의 모든 페이지를 어떤 시스템에 따라 분류하거나 검색해야 한다면 이런 방식은 적합하지 않습니다. 사이트 전체 크롤링, 특히 거대한 사이트의 크롤링은 메모리를 많이 요구하며 크롤링 결과를 바로 저장할 데이터베이스가 준비된 애플리케이션에 적합합니다. 하지만 이런 애플리케이션을 실제 규모로 실행하지 않아도, 어떻게 움직이는지 알아보는 건 가능합니다. 데이터베이스를 이용하는 애플리케이션에 대해서는 5장을 보십시오.
다크 웹과 딥 웹
딥 웹deep Web, 다크 웹dark Web, 히든 웹hidden Web 같은 용어를 최근 많이 들어봤을 겁니다. 이들은 무슨 뜻일까요?
딥 웹은 간단히 말해 표면 웹surface Web, 즉 검색 엔진에서 저장하는 부분을 제외한 나머지 웹을 일컫습니다. 정확히 알 수는 없지만, 딥 웹은 틀림없이 인터넷의 9할 정도를 차지할 겁니다. 구글도 폼을 전송하거나, 최상위 도메인에서 링크되지 않은 페이지를 찾아내거나, robots.txt로 막혀 있는 사이트를 조사할 수는 없으므로, 표면 웹은 비교적 작은 비율을 차지합니다.
다크 웹은 다크넷, 다크 인터넷이라고도 부르며 완벽히 다른 종류입니다. 다크 웹은 기존 네트워크 기반 구조에서 동작하기는 하지만, Tor 클라이언트와 HTTP 위에서 동작하며 보안 채널로 정보를 교환하는 애플리케이션 프로토콜을 사용합니다. 다크 웹도 다른 웹사이트와 마찬가지로 스크랩할 수는 있지만 이 책의 범위는 벗어납니다.
다크 웹과 달리 딥 웹은 비교적 쉽게 스크랩할 수 있습니다. 사실 이 책에서도 구글 봇이 검색할 수 없는 곳을 탐색하고 스크랩하는 여러 도구를 소개합니다.
그러면 웹사이트 전체 크롤링은 언제 유용하고, 언제 손해일까요? 사이트 전체를 이동하는 웹 스크레이퍼에는 여러 가지 장점이 있습니다. 몇 가지 꼽자면 다음과 같습니다.
| 사이트맵 생성 |
몇 해 전에 필자는 한 가지 문제를 겪었습니다. 중요한 클라이언트가 웹사이트 재설계 비용이 얼마나 될지 알아봐달라고 했지만, 현재 사용 중인 콘텐츠 관리 시스템 내부에 접근 권한을 주기는 꺼렸고 공개된 사이트맵도 없는 상황이었습니다. 필자는 사이트 전체를 이동하는 크롤러를 이용해 내부 링크를 모두 수집하고, 그 페이지들을 사이트의 실제 폴더 구조와 똑같이 정리 할 수 있었습니다. 이를 통해 존재하는지조차 몰랐던 부분들을 빨리 발견할 수 있었고, 다시 설계해야 하는 페이지가 얼마나 되고 이동해야 할 컨텐츠가 얼마나 되는지 정확히 산출할 수 있었습니다.
| 데이터 수집 |
어떤 클라이언트는 글(이야기, 블로그 포스트, 뉴스 기사 등)을 수집해서 전문화 검색 플랫폼의 프로토타입을 만들고 싶다고 의뢰했습니다. 이들 웹사이트는 철저히 탐색할 필요는 없었지만, 광범위하게 진행해야 했습니다(데이터를 가져올 사이트가 많지는 않았습니다). 필자는 각 사이트를 재귀적으로 이동하는 크롤러를 만들어 기사 페이지에서만 데이터를 수집할 수 있었습니다. 사이트를 철저히 크롤링하려면 보통 홈페이지 같은 최상위 페이지에서 시작해, 그 페이지에 있는 내부 링크를 모두 검색합니다. 검색한 링크를 모두 탐색하고, 거기서 다시 링크가 발견되면 크롤링 다음 라운드가 시작됩니다. 당연히 일은 금새 엄청나게 커집니다. 모든 페이지에 내부 링크가 10개씩 있고 사이트가 다섯 단계로 구성되어 있다면(중간 규모 사이트에서는 매우 일반적인 깊이입니다), 최소 105페이지, 최대 100,000페이지를 찾아야 사이트를 철저히 탐색했다고 할 수 있습니다. 좀 이상한 일이지만 ‘다섯 단계에, 페이지당 내부 링크 10개’가 매우 일반적인 크기인데도, 실제로 100,000페이지 이상으로 구성된 웹사이트는 거의 없습니다. 이유는 물론 내부 링크 중 상당수가 중복이기 때문입니다.
같은 페이지를 두 번 크롤링하지 않으려면 발견되는 내부 링크가 모두 일정한 형식을 취하고, 프로그램이 동작하는 동안 계속 유지되는 리스트에 보관하는 게 대단히 중요합니다. 새로운 링크만 탐색하고 거기서 다른 링크를 검색해야 합니다.
from urllib.request import urlopen
from bs4 import BeautifulSoup
import re
pages = set()
def getLinks(pageUrl):
global pages
html = urlopen("http://en.wikipedia.org"+pageUrl)
bsObj = BeautifulSoup(html, "html.parser")
for link in bsObj.findAll("a", href=re.compile("^(/wiki/)")):
if 'href' in link.attrs:
if link.attrs['href'] not in pages:
# 새 페이지를 발견
newPage = link.attrs['href']
print(newPage)
pages.add(newPage)
getLinks(newPage)
getLinks("")
웹 크롤링이 어떻게 동작하는지 충분히 보여드리기 위해, 이전 예제에서 사용한 내부 링크 기준을 완화했습니다. 이번에는 스크레이퍼가 항목 페이지만 찾는 게 아니라, /wiki/로 시작하는 모든 링크를 찾으며, div의 id나 콜론이 있는지 없는지도 검사하지 않습니다(항목 페이지에는 콜론이 들어 있지 않지만 파일 업로드 페이지나 토론 페이지, 또는 이 비슷한 링크들은 URL에 콜론이 들어 있습니다).
먼저 getLinks에 빈 URL을 넘겨 호출합니다. 함수 내부에서는 빈 URL의 앞에 http://en.wikipedia.org 을 붙여 위키백과 첫 페이지 URL로 바꿉니다. 다음에는 첫 번째 페이지의 각 링크를 순회하며 전역 변수인 pages에 들어 있는지 아닌지를 검사합니다. pages는 스크립트가 이미 발견한 페이지의 세트입니다. pages에 들어 있지 않은 링크라면 리스트에 추가하고, 화면에 출력한 다음, getLinks 함수를 재귀적으로 호출합니다.
CAUTION_ 재귀에 관한 경고
이 내용은 소프트웨어 책에 보통 포함되어 있지는 않지만, 필자는 짚고 넘어가야 한다고 생각합니다. 위 프로그램은 오랫동안 실행하면 거의 확실히 죽어버립니다.
파이썬은 기본적으로 재귀 호출을 1,000회로 제한합니다. 위키백과의 링크 네트워크는 대단히 넓으므로 이 프로그램은 결국 재귀 제한에 걸려서 멈추게 됩니다. 멈추는 일을 막으려면 재귀 카운터를 삽입하거나 다른 방법을 강구해야 합니다.
링크 깊이가 1,000단계까지 들어가지 않는 일반적인 사이트에서는 이 방법도 보통 잘 동작하지만, 가끔 예외는 있습니다. 예를 들어 필자는 블로그 포스트를 가리키는 내부 링크를 일정한 규칙에 따라 생성하는 웹사이트를 본 일이 있습니다. 그 규칙은 ‘현재 보고 있는 페이지 URL을 취해 /blog/title_of_blog.php를 덧붙인다’는 규칙이었습니다.
문제는 URL에 이미 /blog/가 들어 있는 페이지에도 /blog/title_of_blog.php를 붙인다는 겁니다. 그러면 /blog/가 또 추가되죠. 결국 필자가 만든 크롤러는 /blog/blog/blog/blog.../blog/title_of_blog.php 같은 URL까지 방문해야 하는 상황이었습니다.
결국 필자는 URL이 너무 우스꽝스러워 보이지는 않는지, 무한 루프로 보이는 반복된 조각이 들어 있지는 않는지 체크하는 코드를 삽입해야 했습니다. 이렇게 하지 않고 밤새 돌아가도록 내버려뒀더라면 금새 정지했을 겁니다.
3.2.1 전체 사이트에서 데이터 수집
물론 웹 크롤러가 페이지와 페이지 사이를 옮겨 다니기만 한다면 쓸모가 없을 겁니다. 쓸모가 있으려면 페이지에 머무르는 동안 뭔가 다른 일을 할 수 있어야죠. 페이지 제목, 첫 번째 문단, 편집 페이지를 가리키는 링크(존재한다면)를 수집하는 스크레이퍼를 만들어봅시다.
항상 그렇지만, 이런 일을 가장 잘하기 위해 첫 번째 할 일은 사이트의 페이지 몇 개를 살펴보며 패턴을 찾는 일입니다. 위키백과에서 항목 페이지와 개인정보 정책 페이지 같은 항목 외 페이지를 여럿 봤다면 다음과 같은 패턴을 알 수 있을 겁니다.
from urllib.request import urlopen
from bs4 import BeautifulSoup
import re
pages = set()
def getLinks(pageUrl):
global pages
html = urlopen("http://en.wikipedia.org"+pageUrl)
bsObj = BeautifulSoup(html, "html.parser")
try:
print(bsObj.h1.get_text())
print(bsObj.find(id ="mw-content-text").findAll("p")[0])
print(bsObj.find(id="ca-edit").find("span").find("a").attrs['href'])
except AttributeError:
print("This page is missing something! No worries though!")
for link in bsObj.findAll("a", href=re.compile("^(/wiki/)")):
if 'href' in link.attrs:
if link.attrs['href'] not in pages:
newPage = link.attrs['href']
print("----------------"+newPage)
pages.add(newPage)
getLinks(newPage)
getLinks("")
이 프로그램의 for 루프는 이전의 크롤링 프로그램과 거의 같습니다. 출력되는 콘텐츠를 더 명확히 구분하기 위해 대시를 추가했습니다.
원하는 데이터가 모두 페이지에 있다고 확신할 수는 없으므로, 각 print 문은 페이지에 존재할 확률이 높은 순서대로 정렬했습니다. <h1> 타이틀 태그는 모든 페이지에 존재하므로 이 데이터를 가장 먼저 가져옵니다. 파일 페이지를 제외하면, 대부분의 페이지에 텍스트 콘텐츠가 존재하므로 이것이 두 번째로 가져올 데이터입니다. 편집 버튼은 제목과 텍스트 콘텐츠가 모두 존재하는 페이지에만 있지만, 그렇다고 해도 100퍼센트는 아닙니다.
CAUTION_ 패턴에 따라 필요한 작업이 다릅니다
예외 핸들러 안에 여러 행을 넣는 것은 위험합니다. 우선 어떤 행에서 예외가 일어날지 모릅니다. 또한, 어떤 이유로든 페이지에 편집 버튼만 있고 제목이 없다면 편집 버튼도 가져오지 않게 됩니다. 하지만 원하는 데이터가 사이트에 있을 확률에 순서가 있고, 일부 데이터를 잃어도 되거나 자세한 로그를 유지할 필요가 없는 경우(자주 있습니다)에는 별문제가 없습니다.
여태까지는 데이터를 출력하기만 했을 뿐 ‘수집’하지는 않았습니다. 물론 터미널 화면에 있는 데이터는 가공하기 어렵습니다. 데이터를 저장하고 데이터베이스를 만드는 것은 5장에서 알아보겠습니다.
최신 콘텐츠