열번째 날: LWP 모듈로 웹 데이터를 긁어오자

저자

@laen0k - Freenode IRC #perl-kr 입성 1년째. 그러나 Perl 학습&시작은 세달전부터인 초보. 대항해시대 온라인에 심취해있다.

시작하며

여러분들 중에도 웹상에 떠도는 데이타를 내 수중에 넣고 요리해 보고 싶은 분들이 계실 것입니다. 제 경우, 최근 심취해 있는 대항해시대의 각 함선에 대한 레벨별 정보를 뽑아 그리드 형식으로 출력하는 프로그램을 한 번 만들어보았습니다. 이러한 작업은 CPAN에 있는 다양하고 유용한 여러 모듈들을 지니고 있는 Perl과 함께라면 아주 간단합니다. 저같은 초보도 잘 사용하기만 하면 뚝딱 만들어 낼수 있을 정도니까요~ 그럼 시작해 볼까요?

준비물

준비물은 아래와 같습니다.

Wx 모듈의 경우 CPAN대신 PPM으로 바이너리 파일들을 받아옵시다. Strawberry Perl 5.14.2.1 버전은 상위 버전의 PPM으로 설치해주셔야 ppd를 제대로 읽어오는데 Installing PDL on Windows 하단 "Installing PPM"을 참고하시기 바랍니다.

C:\> ppm install http://www.wxperl.co.uk/repo29/Alien-wxWidgets.ppd
C:\> ppm install http://www.wxperl.co.uk/repo29/Wx.ppd
C:\> ppm install http://www.wxperl.co.uk/repo29/Wx-ActiveX.ppd
C:\> ppm install http://www.wxperl.co.uk/repo29/Wx-Demo.ppd
C:\> ppm install http://www.wxperl.co.uk/repo29/Wx-GLCanvas.ppd
C:\> ppm install http://www.wxperl.co.uk/repo29/Wx-PdfDocument.ppd
C:\> ppm install http://www.wxperl.co.uk/repo29/Wx-Perl-FSHandler-LWP.ppd
C:\> ppm install http://www.wxperl.co.uk/repo29/Wx-Perl-ListCtrl.ppd
C:\> ppm install http://www.wxperl.co.uk/repo29/Wx-Perl-ListView.ppd
C:\> ppm install http://www.wxperl.co.uk/repo29/Wx-Perl-ProcessStream.ppd
C:\> ppm install http://www.wxperl.co.uk/repo29/Wx-Perl-TreeView.ppd
C:\> ppm install http://www.wxperl.co.uk/repo29/Wx-Scintilla

작성할 파일

앞으로 작성할 프로그램의 디렉터리 구조는 다음과 같습니다.

.\
|  lib\         # 개인 모듈을 보관할 디렉토리
|  |--Ship.pm   # LWP로 웹 데이타를 긁어와서 해쉬에 저장하고 정렬하는 기능을 담당합니다
|  |--wxGrid.pm # 그리드 형태로 출력하며 상단 라벨에 마우스 양 버튼을 누르면 정렬된 데이타를 보여줍니다
|--main.pl

Ship.pm - 함선 정보를 담당하는 모듈 작성

Perl에서는 package가 클래스입니다. new() 생성자는 bless()를 통해 내부에서 객체를 생성하고, 생성한 객체를 반환해줍니다. 그 사이에 _init() 함수를 실행했습니다. 상단에 보이는 utf8 프라그마는 소스코드 파일이 UTF-8 형식일때 사용하며 소스코드 파일 내에서 사용하는 유니코드(해시 키-값 한글 사용)를 Perl 내부 유니코드 포맷으로 변경해주는 역할을 합니다.

이러한 처리를 하지 않으면 wxPerl에서 문자를 출력할 때 글자가 깨지게 됩니다. use Encode qw/decode/ 모듈의 decode() 함수 또한 LWP::UserAgent로 받아온 HTML 문서를 동일한 포맷으로 변경하여 Perl 내부에서 유니코드 처리를 원활하게 합니다.

package Ship;

use LWP::UserAgent;
use HTML::TreeBuilder;
use Encode qw/decode/;
use utf8;

sub new {
    my $class = shift;
    my $self = bless {}, $class;
    $self->_init;
    return $self;
}

객체를 생성하는 시점에 대항해시대 두부에서 함선 정보를 가져옵니다.

sub _init{
    my $self = shift;

    $self->{'attrorder'} = ["함선 종류", "모험 레벨", "교역 레벨", "전투 레벨"];
    my %ship_kind = ("탐험용" => 1, "상업용" => 2, "전투용" => 3, "※캐쉬" => 9);
    my %ship_html;
    my $ua = LWP::UserAgent->new;

    my ($rsp, $html, $tree);
    foreach (keys %ship_kind) {
        $rsp = $ua->get("http://uwodbmirror.ivyro.net/kr/main.php?id=145&chp=".$ship_kind{$_});
        $html = decode('utf8', $rsp->content);

        $tree = HTML::TreeBuilder->new;
        $ship_html{$_} = $tree->parse($html);
    }

    foreach my $ship_kind (keys %ship_kind) {
        my $ship_name;
        my @htmls = $ship_html{$ship_kind}->look_down(
            sub {
                $_[0]->attr('href') =~ /main\.php\?id=5\d{7}/
                or
                $_[0]->attr('class') =~ /level\d/
            }
        );
        foreach (@htmls) {
            if ($_->attr_get_i('href')) {
                $ship_name = $_->as_text;
                $self->{ship}{$ship_name}{'함선 종류'} = $ship_kind;
            } else {
                $self->{ship}{$ship_name}{$_->attr_get_i('title')} = $_->as_text;
            }
        }
    }
}

자세히 설명해보겠습니다. LWP::UserAgentget('<URL>')로 해당 페이지의 소스를 긁어오는데 이때 해당 텍스트를 decode()해서 Perl 내부 유니코드 포맷으로 변경해주어야 문자열 처리에 문제가 생기지 않습니다. (WWW::Mechanize의 최신 버전은 자동으로 디코드하는 점에 유의하세요.)

다음으로 HTML::TreeBuilderparse()로 구문 분석을 하며 분석을 완료한 객체를 look_down() 함수를 이용해 $_[0]->attr('속성명')으로 접근해 정규표현식이 일치하는 태그를 빼옵니다. 이제 해당 태그의 텍스트를 추출해 배의 정보를 고스란히 담아주면 완료입니다.

sub info      { shift->{ship} }
sub attrorder { shift->{attrorder} }
sub count     { scalar keys %{shift->{ship}} }

여기서는 shift() 내장 함수를 이용해 해당 객체의 해시 정보를 아주 쉽게 접근합니다. 외부에서 $객체->{'해시키'}로 접근할 수도 있지만 객체지향적으로는 결코 좋은 방법이 아니겠죠~ 아래와 같이 정렬을 마치고 함선 배열을 보유한 해시 레퍼런스를 반환합니다.

sub grid_list {
    my ( $self, $getCol, $order ) = @_;
    my $ship_grid;

    my @sorted_ship_names =
      sort { $self->_sort($getCol, $order) } keys %{$self->info};

    foreach my $ship_name ( @sorted_ship_names ) {
        push @{$ship_grid->{함선명}}, $ship_name;
        foreach ( @{$self->attrorder} ) {
            push @{$ship_grid->{함선정보}}, $self->info->{$ship_name}{$_};
        }
    }

    return $ship_grid;
}

wxGrid 쪽에서 마우스 이벤트가 발생했을 때 다시 정렬해서 그리드에 그려주기 위한 grid_list() 사용자 함수입니다.

sub sort_grid {
    my $self = shift;

    return sub {
        my $order = shift;

        return sub {
            my ( $grid, $event ) = @_;
            my $sort;

            $sort = $self->grid_list( $event->GetCol, $order );

            $grid->draw_grid(
                $sort->{함선명},
                $self->attrorder,
                $sort->{함선정보},
            );
        }
    }
}

자세히 보면 익명 함수를 두단계에 걸쳐서 반환하고 있는데, wxGrid 모듈 안의 마우스 클릭 이벤트가 인자로 가져야 할 익명 함수를 반환하고 그 내부에서의 처리를 위해 또 다시 익명 함수를 반환하기 위해 클로저 형태로 구성하였습니다. 여기서 눈여겨 보아야 할 점은 제일 안쪽의 익명 함수의 첫 번째와 두 번째 인자가 wxGrid 객체와 Event 객체를 받아서 이벤트를 처리하게 된다는 점입니다.

sub _sort {
    my ( $self, $getCol, $order ) = @_;
    my @ship_cols = @{ $self->attrorder };

    unless ( $getCol ){
        ($order)?
        return $self->info->{$a}{$ship_cols[$getCol]} cmp $self->info->{$b}{$ship_cols[$getCol]} || $a cmp $b:
        return $self->info->{$b}{$ship_cols[$getCol]} cmp $self->info->{$a}{$ship_cols[$getCol]} || $a cmp $b;      
    } else {
        ($order)?
        return $self->info->{$b}{$ship_cols[$getCol]} <=> $self->info->{$a}{$ship_cols[$getCol]} || $a cmp $b:
        return $self->info->{$a}{$ship_cols[$getCol]} <=> $self->info->{$b}{$ship_cols[$getCol]} || $a cmp $b;
    }
}

1;

함선 정보가 문자인지 숫자인지에 따라 cmp<=>로 비교연산자를 다르게 사용해야 합니다. 그리고 셀의 정보는 내림차순, 함선명은 오름차순으로 정렬하게 만들면서 약간 복잡하게 되어버린, 여하튼 sort()시에 끼워넣을 함수입니다. 마지막 줄의 1;은 모듈 작성시에 꼭 넣어주셔야 합니다. 이게 없으면 실행시에 모듈이 참값을 반환하지 못했다고 뜹니다.

wxGrid.pm - GUI폼에 그리드를 그려주기 위한 모듈 작성

wxGrid.pmWx::Grid에서 상속받은 클래스입니다. $class->SUPER::new()를 이용해 객체를 생성하는데, 이때 중요한 점은 Grid 객체 생성자는 첫번째 인자로 Frame 객체를 받는다는 것입니다. 여기서는 아래와 같이 main.pl에서 작성한 $frame을 넘겨받습니다.

package wxGrid;

use base 'Wx::Grid';
use Wx::Event qw/EVT_GRID_LABEL_LEFT_CLICK EVT_GRID_LABEL_RIGHT_CLICK/;
use wxPerl::Styles 'wxVal';

sub new {
    my ($class, $frame, @arg_list) = @_;
    my $self = $class->SUPER::new($frame, -1);
    $self->_init(@arg_list);
    return $self;
}

아래와 같이 행, 열, 셀에 각각 들어가야 할 배열을 레퍼런스로 받아 그리드를 생성해줍니다.

sub _init{
    my ($self, $rows, $cols, $cells) = @_;

    $self->CreateGrid( scalar @$rows, scalar @$cols );
    $self->draw_grid( $rows, $cols, $cells );
    $self->SetRowLabelSize(150);
    $self->AutoSizeColumns(1);
    $self->SetDefaultCellAlignment(wxVAL('align_right'), wxVal('align_center'));
}

AutoSize로 시작하는 함수는 문자 크기대로 셀의 크기를 맞춰주고, SetDefault로 시작하는 Alignment는 셀 내부 문자의 정렬을 담당하는데 이때 wxPerl::Styles 모듈의 wxVal() 함수가 상수를 올바르게 전달하는 역할을 해줍니다. 아래와 같이 함선 정보를 모두 담고 있는 행, 열, 셀에 대한 배열을 그리드로 그려줍니다.

sub draw_grid {
    my ( $self, $rows, $cols, $cells ) = @_;

    $self->SetRowLabelValue( $_, $rows->[$_] ) foreach 0 .. $#{$rows};
    $self->SetColLabelValue( $_, $cols->[$_] ) foreach 0 .. $#{$cols};
    $self->SetCellValue( $_ / @{$cols}, $_ % @$cols, $cells->[$_] ) foreach 0 .. $#{$cells};
}

다음은 그리드 상단의 라벨에 마우스 클릭 이벤트가 발생했을 경우 실행하게될 이벤트를 보유한 함수입니다. 앞쪽에서 미리 얘기했던 익명함수를 중첩 반환하는 sort_grid()$func가 받아서 이벤트 함수에 인자로 넘겨주는 형태로 작동하고 있습니다.

sub evt_click {
    my ($self, $func) = @_;

    EVT_GRID_LABEL_RIGHT_CLICK( $self, $func->(0) );
    EVT_GRID_LABEL_LEFT_CLICK( $self, $func->(1) );
}

1;

여기까지입니다. Ship.pm 모듈보다는 상당히 짧습니다. 모든 기능을 Ship 쪽에 다 집어넣었다고 봐야겠네요^^;

main.pl - 프로그램을 실행하다!

이제 실행해 보는 일만 남았군요~! 아래와 같이 작성했습니다.

#!/usr/bin/perl

use strict;
use warnings;
use Wx;

use lib 'lib';
use wxGrid;
use Ship;
use Data::Dumper;

my $app = Wx::SimpleApp->new;
my $frame = Wx::Frame->new( undef, -1, 'Wx Grid', [-1, -1], [500, 1000] );
my $ship = Ship->new;

my $grid = wxGrid->new($frame, $ship->grid_list->{'함선명'}, $ship->attrorder, $ship->grid_list->{'함선정보'});

$grid->evt_click($ship->sort_grid);

$frame->Show;
$app->MainLoop;

#print Dumper($ship);

일단 use lib 'lib'란 항목이 있습니다. 현재 이 파일 경로에 lib 디렉토리를 개인 모듈 공간으로 쓰겠다는 뜻입니다. 그리하여 use wxGriduse Ship이 정상적으로 작동하게 됩니다.

다음으로 Wx::SimpleApp->new 부분입니다. 원래는 Wx::App를 상속받은 모듈 하나를 따로 만들어서 작성하게 되지만 여기서는 Wx::SimpleApp를 이용했습니다. 이전에 언급했듯이 Wx::Frame 객체를 생성해주어야 하고, 각 속성값은 순서대로 (parent, id, title, position, size)입니다.

그 뒤로 (window style, window name)까지 넘겨줄 수 있지만, 이 부분은 취향대로 할 수 있습니다. Wx::Grid의 경우도 비슷한데 title 항목을 제외하였습니다. 네, 이제 그리드 객체에 함선 정보를 인자로 넘겨 $grid에 담고, evt_click()을 활성화해주고, $frame->show$app->MainLoop를 통해 GUI를 띄워주면 완료입니다.

마지막으로, 주석 처리한 print Dumper($ship) 부분이 있는데 Data::Dumper 모듈이 필요하며 해당 레퍼런스의 데이타를 몽땅 보여주게 됩니다. 본인이 작성한 배열, 해쉬, 객체가 자료를 제대로 보유하고 있는지 확인하고 싶다면 필수겠죠?^^ 또, perl\bin\wxperl_demo.bat 파일을 실행하여 데모를 시연해 볼 수 있습니다.

완성된 프로그램 그림 1. 완성된 프로그램 (원본)

정리하며

150줄의 이 짧은 코드는 제가 처음으로 제대로 작성해 본 코드입니다. 이 자리를 빌어 2010, 2011 크리스마스 기념 달력을 통해 Perl의 세계로 인도해주신 Perl 프로그래머 분들께 감사의 인사를 올리며, 이번 2012 크리스마스 펄 달력을 위해 밤낮으로 수고하신 am0c님의 열정에 또한 아낌없는 박수와 감사의 인사를 올리고 싶습니다.

참고

blog comments powered by Disqus