Ruby nokogiri를 이용한 Web Spider 만들기

지난 포스팅에선 nokogiri를 이용한 parsing 을 했다면 이번에는 조금 더 발전 시켜서 간단한 Spider를 만들어볼까 합니다. 물론 훨씬 좋은 라이브러리들이 있지만 가장 기본이되는 nokogiri를 잘 안다면 많은 도움이 있을 수 있겠지요.

Web Spider

물론 아시고 들어오셨겠지만, Web Spider 대한 정의를 다시 한번 생각해보고 하는게 좋을 것 같습니다. 웹을 탐색하는 프로그램, 사이트를 탐색해서 구조를 펼쳐주는 프로그램이며 Web crawler라고도 불립니다.

A Web crawler, sometimes called a spider or spiderbot and often shortened to crawler, is an Internet bot that systematically browses the World Wide Web and that is typically operated by search engines for the purpose of Web indexing (web spidering). Wikipedia

이런 작업을 수행하는 도구 중 대표적인게 바로 검색 엔진의 Bot 이며 웹 페이지를 수시로 탐색하며 인덱싱합니다.

How

Web Spider는 시드(seeds)라고 불리는 URL 리스트에서부터 시작하고 페이지의 모든 하이퍼링크를 인식하여 URL 리스트를 갱신한다. 갱신된 URL 리스트는 재귀적으로 다시 방문하면서 리스트를 만들어갑니다. 보통 시드는 sitemap.xml 같이 구조화되된 링크거나 사이트의 메인이 되는 경우가 많습니다.

웹은 결국 각 페이지에서 가리키는 링크들의 모음으로 구성되어 있습니다. 각각의 링크는 서로 연결되어 하나의 웹 사이트를 만들고 서비스를 만들어갑니다. 그럼 반대로 우리는 각 링크들을 타고 다니며 사이트/서비스의 구성을 만들어갈 수도 있겠네요 :D

Writing a Spider

지난번 포스팅에서 css를 이용한 parsing 기억나시나요? 그것을 이용해서 쉽게 링크를 찾아낼 수 있습니다. 바로 링크 시 많이 사용되는 a 태그를 통해 찾는다면 조금 수월하게 링크 리스트를 뽑아낼 수 있겠지요. 그래도 간단하지만 우리가 생각하기 쉽도록 함수로 만들었습니다.

def get_link(page)
 return page.css("a")
end

내용을 살펴보면 단순하게 a 태그를 찾아서 반환해주는 함수입니다. 여기서 점점 살을 붙여볼게요. nokogiri는 map이라는 메소드를 지원합니다. 이 메소드는 하위에 데이터를 걸러줄 수 있는 역할을 하는데요 아래와 같이 href 만 뽑아낼 수 있겠지요.

def get_link(page)
 return page.css("a").map{|link|link['href']}
end

자 1차적으로 Spidering에 필요한 page를 분석해서 링크를 뽑아내는 과정은 완성되었습니다. full code로 보고 실행해보면 제 블로그에 걸려있는 링크 주소를 얻어올 수 있습니다.

require 'open-uri'
require 'nokogiri'

def get_link(page)
 return page.css("a").map{|link|link['href']}
end

page = Nokogiri::HTML(open("http://www.hahwul.com")) 
links = get_link(page)

puts links

간단하죠?

Escape Special Char

외부 링크까지 크롤링하는 스캐너도 있으나 기본적으로는 자신의 도메인만 크롤링하는게 원칙입니다. 우리가 뽑아낸 URL 리스트에서도 분명 다른 url이 있었지요. 그래서 그 url을 제거하는 과정을 만들어보도록 하겠습니다.

1번 과정을 통해 만들어진 links는 Array 형으로 만들어져있습니다. 각각 배열에서 하나하나씩 읽어서 불러오기 쉽죠.

irb(main):022:0* links.class
=> Array
irb(main):023:0> puts links[0]
#main
=> nil
irb(main):024:0> puts links[2]
/p/introduction.html
=> nil
irb(main):025:0> puts links[4]
/search/label/Hacking?updated-max=&max-results=7
=> nil
irb(main):026:0>

자 이제 이 배열에서 각각 links 안의 값이 우리가 크롤링하는 도메인인지, 혹여나 javascript 나 다른 문자열이 있는지 확인하는 과정이 필요합니다. 아까 만든 get_link 함수를 좀 더 강화시켜보죠.

def get_link(page)
 after_link = Array.new()
 before_link = page.css("a").map{|link|link['href']}  #가공 전
 before_link = before_link.uniq  # A) 중복제거
 for index in before_link # Loop! # B) # & : 있는 줄 제거.
   if(index.index(/:|^#|^&/))
     # no run
   else
     after_link.push(index)
   end
 end
 return after_link
end

일단은 uniq 메소드를 이용해서 중복제거를 해줍니다. A 줄을 보시면됩니다. B부터는 index 메소드를 이용해서 제가 원하는 데이터가 String에 있는지 검색하고 있다면 처리하지 않고 없으면 새로운 리스트에 누적하도록 하겠습니다.

이렇게 돌리면서 새로운 배열에는 # , : 등이 들어간 줄은 포함되지 않게되지요. (javascript: 등등 제거용)

ruby test.rb
# /p/introduction.html
# /search/label/Hacking?updated-max=&max-results=7
# /search/label/System Hacking?updated-max=&max-results=7
# /search/label/Web Hacking?updated-max=&max-results=7
# /search/label/Metasploit?updated-max=&max-results=7
# ... snip ...

물론 pull url도 콜론(:)이 포함되기 때문에 걸리겠지만 고건 다음 파트에서 미리 빼두어 처리하도록 하겠습니다. 도메인을 받을 수 있게 ARGV를 받도록 처리해보면 아래와 같습니다.

require 'open-uri'
require 'nokogiri'

def get_link(page)
 after_link = Array.new()
 before_link = page.css("a").map{|link|link['href']}  #가공 전
 before_link = before_link.uniq  # 중복제거
 for index in before_link # Loop!
   if(index.index(/:|^#|^&|\/\//) != nil)  # A) 정규식 추가
     if(index.index($t_url) != nil)    # B) 자신의 url 제외
       after_link.push(index)
     end
   else
     after_link.push(index)
   end
 end
 return after_link
end

$t_url = ARGV[0] # e.g https://www.hahwul.com
page = Nokogiri::HTML(open($t_url)) 
links = get_link(page)
puts links

위에서 사용된 정규식과 알고리즘은 Spider의 성능을 크게 좌우합니다. 저는 이해하기 쉽게 간단한 예시로 작성하였지만, 실제로 Google bot 등은 굉장히 복잡한 로직으로 돌아가는 걸로 알고 있습니다. 참고 부탁드려요!

Recursive function

마지막 단계입니다. 이제 찾아낸 페이지를 다시 접근하면서 다시 링크를 찾으면 됩니다. 이미 앞에서 코드로 해당 동작을 구현했기 때문에 재귀함수로 구성하면 간단합니다.

방법은 쉽습니다. 아까 찾은 URL리스트를 가지고 하나하나 타면서 추가로 더 push해주면 됩니다. 다만 얼마나 깊게 들어갈지 Depth와 중복되는 URL에 대한 제거 로직을 넣어주면 되겠지요.

여기에 반복해서 체킹할 수 있도록 재귀함수로 넣어봤습니다.

def go_link(crawl_link,links)
  temp_link = links.pop()
  links = links+get_link(temp_link)
  crawl_link.push(temp_link)
  links = links-crawl_link

  puts temp_link

  if(links.size)
    go_link(crawl_link,links)
  else
    return 0
  end
end

이 함수는 루프를 계속(재귀로) 돌면서 크롤링된 array에 크롤링할 리스트를 넣어가며 하나하나씩 크롤링합니다.

Full-Code

require 'open-uri'
require 'nokogiri'

def get_link(t_url)
 if(t_url.index(/^\/|^.\//))
  t_url = $tg_url+"/"+t_url
 end
 puts t_url
 page = Nokogiri::HTML(open(t_url)) 
 after_link = Array.new()
 before_link = page.css("a").map{|link|link['href']}  #가공 전
 before_link = before_link.uniq  # 중복제거
 for index in before_link # Loop!
   if(index == nil)
    break;
   end
   if(index.index(/:|^#|^&|^\/\//) != nil)
     if(index.index(t_url) != nil)
       after_link.push(index)
     end
   else
     after_link.push(index)
   end
 end
 return after_link
end

def go_link(crawl_link,links)
 temp_link = links.pop()
 links = links+get_link(temp_link)
 crawl_link.push(temp_link)
 links = links-crawl_link
 puts temp_link
 if(links.size)
  go_link(crawl_link,links)
 else
  return 0
 end
end

t_url = ARVG[0]
$tg_url = t_url
crawl_link = Array.new()
links = get_link(t_url)
go_link(crawl_link,links)

Output

Reference