스물두번째 날: dcgrep: 갤러리를 넘나드는 패턴검색

저자

dsm - 공군 병사, foollbar at gmail.com

시작하며

grep은 유닉스의 명령줄 유틸리티로써 패턴으로 문자열 속의 일치하는 부분을 찾아내는 도구입니다. 기사에서는 문자열의 범위를 웹으로 넓혀, 여러 웹 페이지를 수집한 후 그 속에서 원하는 패턴을 찾아내는 방법에 대해 알아보겠습니다. 웹 페이지를 검색하려면 먼저 어떤 웹페이지를 검색할지 정해야 합니다. 디씨인사이드는 갤러리 별 게시판이 존재하며, 각 갤러리 별로 주제 구분이 명확하고, 올라오는 글들의 특성도 다르기 때문에 패턴 검색에 적절하다고 판단해 선택했습니다. 큰 의미는 없으니 편하게 읽어주세요. :)

준비물

필요한 모듈은 다음과 같습니다.

직접 CPAN을 이용해서 설치한다면 다음 명령을 이용해서 모듈을 설치합니다.

$ sudo cpan Mojo::UserAgent

사용자 계정으로 모듈을 설치하는 방법을 정확하게 알고 있거나 perlbrew를 이용해서 자신만의 Perl을 사용하고 있다면 다음 명령을 이용해서 모듈을 설치합니다.

$ cpan Mojo::UserAgent

필요한 모듈은 Mojolicious 프레임워크에 포함된 Mojo::UserAgent 하나 뿐입니다. "오, 하나만 설치하면 되겠구나!"할 수도 있지만 Mojolicious외부 모듈 의존성은 없지만 여러 개의 내부 모듈 파일로 이루어진 웹 프레임워크이기 때문에 수동으로 설치하지 않는 이상 각각의 모듈 별로 따로 설치하기는 어렵습니다. 따라서 Mojolicious 모듈을 설치하든, Mojo::UserAgent 모듈를 설치하든 상관없이 Mojolicious 웹 프레임워크가 통째로 설치됩니다. 하지만 Mojolicious는 매우 가벼운 웹프레임워크니 걱정하지 마세요. :)

얼개

만들려는 dcgrep은 다음과 같은 입력과 출력을 가져야 합니다.

$ dcgrep "입대" airforce
airforce 작년 12월30일입대한 애비기수로서 한마디하자면 - ㅁ | 35
airforce 입대휴학 성적 반영 관련해서 질문하나만 쌀게요 - 눈팅족 | 14
airforce 나 전공만 있고 자격증 하나도 없는 예비 통전입대자인데 헬보직 확정임? - ddfs | 67
$

모티브인 grep처럼 패턴과 대상을 입력하면 대상 속에서 일치하는 문자열을 찾아 적당한 형식으로 출력합니다. 하지만 grep의 검색 대상은 로컬 시스템의 파일이거나 일반 문자열이지만 dcgrep의 검색 대상은 웹 페이지입니다. 그렇다면 먼저 웹페이지를 가져와야겠죠.

Mojo::UserAgent

Mojo::UserAgent 모듈을 이용하면 매우 간단하게 웹 페이지를 다운로드 받을 수 있습니다.

#!/usr/bin/env perl

use v5.18;
use strict;
use warnings;
use Mojo::UserAgent;

my $url = "http://gall.dcinside.com/board/lists/?id=airforce";

my $ua = Mojo::UserAgent->new;
say $ua->get($url)->res->body;

네! 디씨인사이드 공군 갤러리의 웹 페이지를 얻었습니다. 이제 원하는 패턴만 찾으면 됩니다.

Mojo::DOM

DOM은 문서 객체 모델(Document Object Model)로 HTML이나 XML 같은 구조화된 문서를 표현하기 위해 만들어진 형식입니다. Mojo::DOM 모듈은 HTML이나 XML 문서를 파싱해서 DOM 트리를 순회할 수 있는 편리한 방법을 제공합니다.

앞에서 살펴 본 예제의 $ua->get(...)에서 get() 메소드는 Mojo::Transaction::HTTP 객체를 반환하고, 이 객체의 res() 메소드는 Mojo::Message::Response 객체를 반환합니다. raw 데이터를 얻기 위해 Mojo::Message::Response 객체의 body() 메소드를 사용했지만, 이제는 웹 페이지에서 게시글의 제목도 찾아야 하고, 작성자도 확인해야 되고, 조회수도 얻어야 합니다. 이때 필요한 것이 바로 DOM입니다.

말은 복잡하지만 res() 메소드 대신, dom() 메소드를 사용해 HTML문서의 Mojo::DOM 객체를 얻어오는 것은 무척 간단합니다.

my $dom = $ua->get($url)->res->dom;

이제 DOM을 이용해서 필요한 정보를 뽑아낼 모든 준비를 마쳤습니다. 하지만 그 전에 정보가 어떻게 담겨있는지 파악해야 합니다. 일반 grep의 경우 문자열 속에서 패턴을 찾기만 하면되기 때문에 정보를 추출하는데 따로 필요한 절차가 없습니다. 하지만 이제는 웹 페이지 속에서 패턴을 찾아야 합니다. 웹 페이지는 HTML라는 언어로 구조화되어있고 HTML을 대상으로 패턴 검색을 수행하면 안타깝게도 찾으려는 정보보다 딸려오는 HTML 태그가 더 많을 겁니다.

정보 추출

디씨인사이드 갤러리의 게시판은 하나의 테이블 태그로 이루어져 있습니다. 테이블은 여러 행으로 구성되어 있고 각 행은 하나의 글에 대한 정보를 담고 있습니다. 우리가 수집할 정보는 글의 제목, 작성자, 조회수로 총 3개입니다. 수집을 시작하려면 먼저 테이블의 행들을 찾아야 하고 행을 찾았다면 행 속에서 세 가지 정보를 얻은 뒤 사용자가 원하는 패턴을 가지고 있는지 검사하여 올바른 결과만 취해야 합니다.

Mojo::DOM의 DOM 순회 기능과 Mojo::Collectiongrep() 메소드를 사용하면 매우 쉽게 원하는 목표를 달성할 수 있습니다.

my $articles = $dom->find( 'tr[class="tb"]' )->grep( qr/$pattern/ );

DOM 객체의 find() 메소드는 CSS 선택자(CSS selector)를 인자로 받아, DOM에서 검색을 수행한 후 일치한 결과가 담긴 Mojo::Collection 객체를 반환합니다. 예제에서 사용한 'tr[class="tb"]'tr 태그 중 클래스가 tb인 항목을 의미하는 CSS 선택자입니다.

$articles에 담긴 각 요소(행이겠죠?)는 하나의 글(내용을 제외한)을 의미하기에 각 글에 대해 다시 DOM을 순회하며 제목, 작성자, 조회수를 추출해야 합니다.

push(
    @result,
    {
        gallery => $gallery,
        subject => $_->at('td[class="t_subject"]')->at('a')->all_text,
        author  => $_->at('td[class*="writer"]')->at('span')->all_text,
        hit     => $_->at('td[class="t_hits"]')->all_text,
    }
) for @$articles;

컬렉션을 순회하며 글에 대한 정보를 익명 해시로 포장한 뒤 결과 배열에 차곡차곡 집어넣습니다. at() 메소드는 find() 메소드와 비슷하게 CSS 선택자로 검색을 수행하는데, 컬렉션을 반환 하는 것이 아니라 첫 번째로 일치한 하나의 DOM만을 반환합니다. 마지막으로 모두 all_text() 메소드를 사용해 DOM에서 태그를 제거한 문자열만을 취함으로써 원하는 정보를 전부 얻었습니다.

전체 코드

전체 코드는 다음과 같습니다.

#!/usr/bin/env perl

use v5.18;
use strict;
use warnings;
use Mojo::UserAgent;

my $url_prefix = 'http://gall.dcinside.com/board/lists/?id=';

die "Usage: $0 <pattern> <gallery> [ <gallery> ... ]\n" unless @ARGV >= 2;
my ( $pattern, @galleries ) = @ARGV;

my @result;
for my $gallery (@galleries) {
        my $ua       = Mojo::UserAgent->new;
        my $dom      = $ua->get("$url_prefix$gallery")->res->dom;
        my $articles = $dom->find( 'tr[class="tb"]' )->grep( qr/$pattern/ );

        push(
            @result,
            {
                gallery => $gallery,
                subject => $_->at('td[class="t_subject"]')->at('a')->all_text,
                author  => $_->at('td[class*="writer"]')->at('span')->all_text,
                hit         => $_->at('td[class="t_hits"]')->all_text,
            },
        ) for @$articles;
}

for (@result) {
    printf(
        "%s %s - %s | %d\n",
        $_->{gallery},
        $_->{subject},
        $_->{author},
        $_->{hit},
    );
}

실행

입대와 전역을 전군 갤러리에서 검색하면 꽤 재밌는 결과를 얻을 수 있습니다.

$ ./dcgrep.pl "입대|전역" airforce army navy
airforce 작년 12월30일입대한 애비기수로서 한마디하자면 - ㅁ | 52
airforce 입대휴학 성적 반영 관련해서 질문하나만 쌀게요 - 눈팅족 | 16
airforce 나 전공만 있고 자격증 하나도 없는 예비 통전입대자인데 헬보직 확정임? - ddfs | 76
army 15년 3월에 입대하고싶은데 - 9610월생 | 28
army 몇월 입대인데 1~2달후에 후임생기는 군번이 어쩌고.. 없다 그런거 - ㅇㅇ | 42
army 다음달 전역인데 심심해서 한번 와봄 - d30 | 28
army 2015년 1월에 입대하기 빡셔요 ? - ㅇㅇ | 72
army 1월13일 입대인데, 1월 군번이 젤좋아요? 이유가 ?.. - ㅇㅇ | 76
army 3사단 입대예정인데, 저 어디로빠질까요? - ㅇㅇ | 17
army 크리스마스에 전역하는 친구들있니? - 미리메리크리스. | 24
navy 해군입대하기전에 이거 필독하고 들어가라 개꿀팁 - 진기사상병 | 170
$

명령줄 도구이므로 원조 grep과의 결합도 가능합니다. :)

$ ./dcgrep.pl "입대|전역" airforce army navy|grep ㅇㅇ
army 몇월 입대인데 1~2달후에 후임생기는 군번이 어쩌고.. 없다 그런거 - ㅇㅇ | 43
army 2015년 1월에 입대하기 빡셔요 ? - ㅇㅇ | 72
army 1월13일 입대인데, 1월 군번이 젤좋아요? 이유가 ?.. - ㅇㅇ | 76
army 3사단 입대예정인데, 저 어디로빠질까요? - ㅇㅇ | 17
$

정리하며

지금까지 소개한 내용은 사실 웹 스크래핑이라는 기술입니다. 여기서 더 나간다면 게시글의 내용까지 포함해 검색하거나, 갤러리의 첫 번째 페이지만이 아닌 여러 페이지에 걸친 검색을 할 수도 있고, 웹 페이지에 내장된 검색 기능을 이용해서 패턴을 검색하고, 그 검색 결과를 검색하는 방법도 있을 것입니다.

하지만 글을 읽으면서 예상하셨겠지만 데이터 추출 기법치고는 안정성이 떨어지는 방법입니다. 정보를 얻기 위해 웹사이트의 특성들을 많이 사용하는데 이 특성은 웹페이지 관리자의 마음에 의존하기 때문이죠. 예를 들어서 관리자가 t_subject라는 클래스 이름을 h_subject로 바꾸기만 해도 이 프로그램은 동작하지 않을 겁니다. 그럼에도 불구하고 인터넷 트래픽의 23%가 Web scraping과 관련된 트래픽이라고 하며, 실제로 웹페이지의 구조가 그렇게 자주 바뀌지는 않으므로 웹페이지의 일반적 특성만 이용하면, 오랫동안 잘 활용할 수 있는 프로그램을 만들 수 있습니다.

기사에서 소개한 예제는 사용자가 입력한 패턴을 그대로 보간(interpolation)하기 때문에 강력한 펄의 정규 표현식을 그대로 이용할 수 있다는 것은 장점이지만 보안적으로 매우 위험합니다. 더불어 동작에 집중하기 위해 각각의 메소드 호출시 반환값에 따른 오류 처리가 빠져있기도 합니다. 몇 가지 개선 사항은 여러분의 몫으로 남겨두겠습니다. :)

blog comments powered by Disqus