이번 튜토리얼에서는 Mechanize를 사용해 링크를 클릭하고, 폼을 작성하고, 파일을 업로드하는 방법을 배웁니다. 또한 Mechanize의 페이지 객체를 원하는 대로 다루는 법과 Google 검색을 자동화해서 결과를 저장하는 방법까지 함께 살펴봅니다.
다룰 주제
- 단일 페이지 vs. 페이지네이션
- Mechanize
- Agent(에이전트)
- Page(페이지)
- Nokogiri 메서드
- 링크
- 클릭
- 폼
단일 페이지 vs. 페이지네이션
지금까지 우리는 Nokogiri를 활용해 단일 페이지 화면에서 데이터를 스크래핑하는 방법에 대해 알아보았습니다. 이는 한 단계 더 나아가 여러 페이지에서 콘텐츠를 추출하는 방법을 학습하기 위한 좋은 기반이 됩니다.
결국 우리가 해결하려 하는 문제는 140개가 넘는 에피소드의 콘텐츠를 가져오는 것인데, 이 정도 분량은 하나의 웹 페이지에 담기에는 너무 많습니다. 따라서 페이지네이션을 다뤄야 하며, 콘텐츠를 쫓아가 '토끼굴' 깊숙이 들어가는 방법을 찾아야 합니다.
바로 이 지점에서 Nokogiri의 역할은 끝나고, Mechanize라는 유용한 gem이 등장합니다.
Mechanize란?
Mechanize는 강력한 도구로, 웹사이트와의 상호작용을 자동화할 수 있는 다양한 기능을 제공합니다. 웹사이트에서 콘텐츠를 추출하는 데 필요한 작업들을 자동으로 처리할 수 있게 해주는데, 이 점에서 테스팅 도구인 Capybara에서 본 적 있는 기능들이 떠오르기도 합니다.
물론 Nokogiri만으로 단일 페이지를 다루는 것도 훌륭하지만, 좀 더 복잡한 데이터 추출 작업에는 조금 더 강력한 성능이 필요합니다. Mechanize를 사용하면 필요한 만큼 여러 페이지를 크롤링하면서 각 페이지의 요소들과 상호작용할 수 있습니다. 즉, 사람의 행동을 흉내 내고 자동화하는 것이죠. 꽤 강력한 기능입니다!
이 gem을 사용하면 링크를 따라가고, 폼 필드를 작성하고, 데이터를 제출하는 것은 물론 쿠키 처리까지 가능합니다. 즉, 사용자가 비공개 세션에 로그인하는 과정까지 흉내 내어, 본인만 접근할 수 있는 사이트의 콘텐츠를 가져올 수도 있습니다.
로그인 폼에 인증 정보를 입력하고 Mechanize에게 그 뒤의 흐름을 어떻게 따라갈지 알려주면 됩니다. 링크 클릭과 폼 제출이 모두 가능하기 때문에 이 도구로 할 수 없는 일은 거의 없습니다. Mechanize는 Nokogiri와 밀접한 관계가 있으며 실제로 Nokogiri에 의존합니다. 그리고 이 멋진 gem의 저자 중 한 명은 역시 Aaron Patterson입니다.
Mechanize 에이전트 생성하기
본격적으로 시작하기 전에 먼저 Mechanize 에이전트를 인스턴스화해야 합니다.
some_scraper.rb
require 'mechanize' agent = Mechanize.new
이 agent는 Nokogiri에서 했던 것처럼 페이지를 가져올 때 사용됩니다.
some_scraper.rb
require 'mechanize' agent = Mechanize.new podcast_url = "https://betweenscreens.fm/" page = agent.get(podcast_url)
여기서 일어난 일은 Mechanize 에이전트가 팟캐스트 페이지와 해당 페이지의 쿠키를 함께 가져왔다는 것입니다.
페이지 콘텐츠 추출하기
이제 추출 준비가 완료된 페이지가 생겼습니다. 본격적으로 추출하기 전에 inspect 메서드를 사용해 내부 구조를 살펴보는 것을 추천합니다.
some_scraper.rb
require 'mechanize' agent = Mechanize.new podcast_url = "https://betweenscreens.fm/" page = agent.get(podcast_url) puts page.inspect
출력 결과가 상당히 방대합니다. 직접 확인해 보면 Mechanize::Page 객체가 무엇으로 구성되어 있는지 알 수 있습니다. 페이지의 모든 속성을 여기서 볼 수 있습니다.
저에게 이 객체는 추출하고 싶은 데이터를 잘라내는 데 정말 유용한 도구입니다.
출력 결과
#<Mechanize::Page
{url #https://betweenscreens.fm/>}
{meta_refresh}
{title "Between | Screens "}
{iframes ...}
{frames}
{links
#<Mechanize::Page::Link "Logo cube" "/">
#<Mechanize::Page::Link "about" "pages/about/">
#<Mechanize::Page::Link "design" "design/">
#<Mechanize::Page::Link "code" "code/">
#<Mechanize::Page::Link "Randy J. Hunt" "episodes/144/">
#<Mechanize::Page::Link "Jason Long" "episodes/143/">
#<Mechanize::Page::Link "David Heinemeier Hansson" "episodes/142/">
#<Mechanize::Page::Link "Zach Holman" "episodes/141/">
#<Mechanize::Page::Link "Joel Glovier" "episodes/140/">
#<Mechanize::Page::Link "Older Stuff »" "page/2/">
...}
{forms}>
HTML 페이지 자체를 직접 확인하고 싶다면 body 또는 content 메서드를 붙여 사용하면 됩니다.
some_scraper.rb
... print page.body ...
이렇게 하면 페이지의 전체 HTML 소스를 출력할 수 있습니다.
참고로 github.com에서 반환되는 Mechanize::Page 객체는 훨씬 다양한 종류의 콘텐츠를 포함하고 있습니다. 다양한 형태의 페이지 구조에 익숙해지는 것도 중요합니다.
팟캐스트 사이트로 돌아와서, 인코딩, HTTP 응답 코드, URI, 응답 헤더 같은 정보들도 확인할 수 있습니다.
some_scraper.rb
require 'mechanize' agent = Mechanize.new podcast_url = "https://betweenscreens.fm/" page = agent.get(podcast_url) puts 'Encodings' puts page.encodings puts 'Response Headers' puts page.response puts 'HTTP response code' puts page.code puts 'URI' puts page.uri
출력 결과
Encodings
EUC-JP
utf-8
utf-8
Response Headers
{"server"=>"GitHub.com", "date"=>"Sat, 29 Oct 2016 17:56:00 GMT", "content-type"=>"text/html; charset=utf-8", ...}
HTTP response code
200
URI
https://betweenscreens.fm/
더 깊이 파고들면 확인할 수 있는 것들이 많지만, 여기까지만 살펴보겠습니다.
Nokogiri 메서드 활용하기
atsearch
Mechanize는 내부적으로 Nokogiri를 사용해 페이지에서 데이터를 스크래핑합니다. 첫 번째 글에서 배운 Nokogiri 지식을 Mechanize 페이지 객체에도 그대로 적용할 수 있습니다. 즉, 일반적으로는 Mechanize로 페이지를 탐색(navigate)하고, 실제 스크래핑 작업에는 Nokogiri 메서드를 사용하는 방식입니다.
예를 들어 단일 객체를 검색할 때는 at을 사용하고, 특정 페이지에서 선택자(selector)와 일치하는 모든 객체를 얻고 싶을 때는 search를 사용합니다. 다시 말해, 이 메서드들은 Nokogiri 문서 객체와 Mechanize 페이지 객체 모두에서 동작합니다.
some_scraper.rb
require 'mechanize'
agent = Mechanize.new
podcast_url = "https://betweenscreens.fm/"
page = agent.get(podcast_url)
first_title = page.at('h2.post-title')
all_titles = page.search('h2.post-title')
all_titles.each do |title|
puts title
end
puts " * "*33
puts first_title
출력 결과
<h2 class="post-title"><a href="episodes/144/">Randy J. Hunt</a></h2> <h2 class="post-title"><a href="episodes/143/">Jason Long</a></h2> <h2 class="post-title"><a href="episodes/142/">David Heinemeier Hansson</a></h2> <h2 class="post-title"><a href="episodes/141/">Zach Holman</a></h2> <h2 class="post-title"><a href="episodes/140/">Joel Glovier</a></h2> <h2 class="post-title"><a href="episodes/139/">João Ferreira</a></h2> <h2 class="post-title"><a href="episodes/138/">Corwin Harrell</a></h2> * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * <h2 class="post-title"><a href="episodes/144/">Randy J. Hunt</a></h2>
링크 다루기
linkslink_withlinks_with
이제 사이트 전체를 원하는대로 탐색할 수 있습니다. Mechanize의 가장 중요한 부분은 아마도 링크를 자유롭게 다룰 수 있다는 점일 겁니다. 그렇지 않았다면 Nokogiri만으로도 충분했을 테니까요. 페이지에 링크 목록을 요청하면 무엇이 반환되는지 살펴보겠습니다.
some_scraper.rb
require 'mechanize'
agent = Mechanize.new
podcast_url = "https://betweenscreens.fm/"
page = agent.get(podcast_url)
puts "#{page.links}"
위 코드는 해당 첫 페이지에 있는 링크들의 배열을 반환합니다. Mechanize는 페이지를 위에서 아래로 순회하며 링크 목록을 순서대로 반환합니다.
링크 텍스트만 추출하기
언제나 그렇듯, 텍스트만 따로 추출할 수도 있습니다.
some_scraper.rb
require 'mechanize' agent = Mechanize.new podcast_url = "https://betweenscreens.fm/" page = agent.get(podcast_url) page.links.each do |link| puts link.text end
특정 링크 찾기
이렇게 링크를 한꺼번에 다 받으면 유용할 수도 있지만, 때로는 번거로울 수 있습니다. 다행히 필요한 것만 골라낼 수 있는 도구들이 마련되어 있습니다.
some_scraper.rb
require 'mechanize'
agent = Mechanize.new
podcast_url = "https://betweenscreens.fm/"
page = agent.get(podcast_url)
focus_link = agent.page.links.find { |link| link.text == 'Focus' }
puts focus_link
출력 결과
Focus
이제 슬슬 감이 잡히시죠? 이런 식으로 특정 링크를 정확히 집어낼 수 있습니다. 링크의 텍스트를 기준으로 찾을 수도 있고, link_with나 links_with 같은 API를 활용할 수도 있습니다. 동일한 이름을 가진 링크가 여러 개일 경우, 배열 인덱싱 []으로 특정 위치의 링크를 고를 수도 있습니다.
some_scraper.rb
require 'mechanize' agent = Mechanize.new podcast_url = "https://betweenscreens.fm/" page = agent.get(podcast_url) focus_link = agent.page.links_with(:text => 'Focus')[2] puts focus_link
링크 텍스트가 아니라 링크 자체(href)를 기준으로 찾고 싶다면 href만 지정하면 됩니다. Mechanize는 이것조차 막지 않습니다. text 대신 href를 메서드에 넣어주면 됩니다.
some_scraper.rb
page = agent.page.link_with(href: '/episodes/95/') page = agent.page.links_with(href: '/episodes/95/')
원하는 텍스트를 가진 첫 번째 링크만 찾고 싶다면 다음 문법을 활용할 수도 있습니다. 매우 편리하고 가독성도 좋습니다.
some_scraper.rb
focus_links = agent.page.link_with(:text => 'Focus')
자, 그럼 이 Focus 링크 뒤에 무엇이 숨어 있는지 직접 따라가 볼까요? click해 보겠습니다!
링크 클릭하기
some_scraper.rb
require 'mechanize'
agent = Mechanize.new
podcast_url = "https://betweenscreens.fm/"
page = agent.get(podcast_url)
focus_links = agent.page.links.find { |link| link.text == 'Focus' }.click.links
puts focus_links
이 코드는 앞서 본 것처럼 긴 링크 목록을 다시 반환합니다. .click.links를 연결해서 사용하기가 얼마나 쉬운지 주목하세요. Mechanize가 링크를 클릭하고 새로운 목적지 페이지까지 따라갑니다. 링크 목록도 함께 요청했기 때문에, 새 페이지에서 발견할 수 있는 모든 링크를 반환받게 됩니다.
예를 들어, 동일한 인터뷰 대상자에 대한 텍스트 링크가 두 개 있다고 가정해 봅시다. 하나는 태그 페이지로, 다른 하나는 최신 에피소드로 연결됩니다. 각 페이지의 링크들을 모두 가져오고 싶다면 다음과 같이 하면 됩니다.
some_scraper.rb
require 'mechanize' agent = Mechanize.new podcast_url = "https://betweenscreens.fm/" page = agent.get(podcast_url) links = agent.page.links_with(text: "Some interviewee") links.each do |link| puts link.click.links end
이렇게 하면 두 페이지 모두의 링크 목록을 얻을 수 있습니다. 인터뷰 대상자의 각 링크를 순회하면서, Mechanize가 클릭된 링크를 따라가 새 페이지에서 발견한 링크들을 수집해 줍니다. 시작점 삼아 참고할 수 있는 몇 가지 조합 예시를 아래에 정리했습니다.
some_scraper.rb
agent.page.links.find { |l| l.text == 'Focus' }
agent.page.links.find { |l| l.text == 'Focus' }.click
agent.page.link_with(text: 'Focus')
agent.page.links_with(text: 'Focus')[0]
agent.page.links_with(text: 'Focus')[1].click
agent.page.links_with(text: 'Focus')[2].click.links
agent.page.link_with(href: '/some-href')
agent.page.link_with(href: '/some-href').click
agent.page.links_with(href: '/some-href')
폼 다루기
submitfield_withcheckbox_withradiobuttons_withfile_uploads
이제 폼을 살펴볼 차례입니다!
some_scraper.rb
require 'mechanize' agent = Mechanize.new google_url = "https://google.com/" page = agent.get(google_url) forms = page.forms puts forms.inspect
출력 결과
[#<Mechanize::Form
{name "f"}
{method "GET"}
{action "/search"}
{fields
[hidden type: hidden name: ie value: ISO-8859-1]
[hidden type: hidden name: hl value: es]
[hidden type: hidden name: source value: hp]
[hidden type: hidden name: biw value: ]
[hidden type: hidden name: bih value: ]
[text type: name: q value: ]
[hidden type: hidden name: gbv value: 1]}
{radiobuttons}
{checkboxes}
{file_uploads}
{buttons
[submit type: submit name: btnG value: Buscar con Google]
[submit type: submit name: btnI value: Voy a tener suerte]}>
]
forms 메서드를 사용하면 폼이 하나뿐이라도 배열로 반환됩니다. 이제 폼의 이름이 "f"라는 것을 알았으니, 단수형 메서드인 form을 사용해 해당 폼 하나만 정확히 집어낼 수 있습니다.
some_scraper.rb
require 'mechanize'
agent = Mechanize.new
google_url = "https://google.com/"
page = agent.get(google_url)
search_form = page.form('f')
puts search_form.inspect
form('f')를 사용함으로써 작업하고 싶은 특정 폼 하나만 골라냈습니다. 결과적으로 배열이 아닌 단일 객체가 반환됩니다.
텍스트 입력 필드의 이름(q)도 확인할 수 있습니다. 이 이름으로 필드를 지정하고, Ruby 속성처럼 값을 설정할 수 있습니다. 기본값이 비어 있으므로 새 값만 넣어주면 됩니다.
some_scraper.rb
require 'mechanize'
agent = Mechanize.new
google_url = "https://google.com/"
page = agent.get(google_url)
search_form = page.form('f')
search_form.q = 'New Google Search'
puts search_form.inspect
출력 결과를 보면 텍스트 필드의 값이 New Google Search로 변경된 것을 확인할 수 있습니다. 이제 폼을 submit하고 Google이 반환하는 페이지에서 결과를 수집하기만 하면 됩니다. 이보다 쉬울 수 없습니다. 이번에는 다른 검색어로 시험해 보겠습니다!
some_scraper.rb
require 'mechanize'
agent = Mechanize.new
google_url = "https://google.com/"
page = agent.get(google_url)
search_form = page.form('f')
search_form.q = 'GitHub TouchFart'
page = agent.submit(search_form)
pp page.search('h3.r').map(&:text)
여기서는 CSS 선택자 h3.r로 검색 결과 헤더를 식별하고, 그 text를 매핑한 후 결과를 pretty print로 출력했습니다. 어렵지 않았죠? 물론 간단한 예시지만, 이 기능으로 무궁무진한 가능성이 열린다는 점을 생각해 보세요!
체크박스, 라디오 버튼, 파일 업로드
Mechanize는 다양한 종류의 입력 필드를 다룰 수 있습니다. 심지어 파일 업로드도 가능합니다!
field_withcheckbox_withradiobuttons_withfile_uploads
라디오 버튼과 체크박스도 이름으로 식별한 후, 예상하셨겠지만 check 메서드로 체크할 수 있습니다.
some_scraper.rb
form.radiobuttons_with(:name => 'gender')[3].check form.checkbox_with(:name => 'coder').check
option 태그는 드롭다운 목록에서 항목을 하나 선택할 수 있게 해줍니다. 마찬가지로 이름으로 지정하고, 원하는 옵션 번호를 선택하면 됩니다.
some_scraper.rb
form.field_with(:name => 'countries').options[22].select
파일 업로드는 폼에 텍스트를 입력하는 것과 비슷하게 Ruby 속성처럼 설정하는 방식으로 동작합니다. 업로드 필드를 식별한 후, 전송하고 싶은 파일 경로(파일명)를 지정하면 됩니다. 설명보다 훨씬 간단합니다. 바로 살펴보겠습니다!
some_scraper.rb
form.file_uploads.first.file_name = "some-path/some-image.jpg"
마치며
어떠신가요, 결국 마법 같은 건 없습니다! 이제 스스로 재미있는 프로젝트를 진행할 준비가 되었습니다. Nokogiri와 Mechanize에 대해 배울 것이 더 남아 있긴 하지만, 불필요한 부분에 시간을 낭비하기보다는 직접 만져보면서 익히고, 초급 튜토리얼 범위를 넘어서는 문제에 부딪혔을 때 추가 문서를 찾아보는 것이 좋습니다.
이 gem이 얼마나 우아하게 단순하면서도 얼마나 강력한 힘을 제공하는지 느끼셨기를 바랍니다. 대중문화에서 이미 알고 있듯이, 강력한 능력에는 책임이 따릅니다. 반드시 합법적인 범위 안에서, 그리고 API가 제공되지 않는 경우에만 사용하세요. 이런 도구를 매일 쓸 일은 없겠지만, 진짜 스크래핑 작업이 필요한 순간에는 놀랍도록 유용하다는 것을 알게 될 겁니다.
약속한 대로 다음 글에서는 실제 사례를 다루겠습니다. 제 팟캐스트 사이트에서 데이터를 스크래핑하여, 오래된 Sinatra 사이트에서 추출한 후 Middleman으로 만든 새 사이트로 옮기는 과정을 보여드릴 겁니다. 에피소드마다 날짜, 에피소드 번호, 인터뷰 대상자 이름, 제목, 부제목 등을 추출하게 될 것입니다. 기대해 주세요!