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 모듈을 이용하면 매우 간단하게 웹 페이지를 다운로드 받을 수 있습니다.
#!/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;
네! 디씨인사이드 공군 갤러리의 웹 페이지를 얻었습니다. 이제 원하는 패턴만 찾으면 됩니다.
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::Collection의
grep()
메소드를 사용하면 매우 쉽게 원하는 목표를 달성할 수 있습니다.
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)하기 때문에 강력한 펄의 정규 표현식을 그대로 이용할 수 있다는 것은 장점이지만 보안적으로 매우 위험합니다. 더불어 동작에 집중하기 위해 각각의 메소드 호출시 반환값에 따른 오류 처리가 빠져있기도 합니다. 몇 가지 개선 사항은 여러분의 몫으로 남겨두겠습니다. :)
X-mas tree
& Llama
ASCII Art by ASCII Art Farts.
Computer ASCII Art by Chris.com.
Font ASCII Art by TAAG.
Artwork by
Inkyung Park
& Keedi Kim.
Designed by
Hojung Youn
& Keedi Kim.
Articles by
Seoul Perl Mongers.
Edited by
Luzluna Park
& Keedi Kim.
Hosting generously sponsored by
Yuni Kim.
Sponsored by
SILEX.
.-''' __ __ / \/ \/ \ =-_- | \. -____- / \ // /|| '' //| //|| == = == ==