週末には少しPerlを。

Perlスクリプトの学習日記です。

いかにしてメイドさん画像をダウンロードするか~NAVERまとめ編

動機

NAVERまとめを「メイド」検索すると約100のまとめがヒットします。 もちろんすべてが画像サイトというわけではありませんが ここに少なからぬ宝が埋蔵されているわけで、これを発掘したいと思ったわけです。

全体の構成

アプローチですが、DBに「巡回リスト」を用意してこの台帳に登録されているURLを順に巡回します。 それぞれの巡回先では画像を(もしあれば)取り込みながら、さらにそこからリンクされているURLを新たな巡回先として リストに登録します。

次のような流れになります。

  1. まずMySQLデータベースとそのユーザーを用意しておきます。 そして「巡回番号」と「URL」の2つの列だけの簡単な表を用意し、 巡回の起点となるNAVERまとめサイトの「メイド」検索結果のページを巡回番号1として登録しておきます。

  2. メインループではループカウンタを1から順に増やしながら、カウンタ値を巡回番号とするURLを巡回リストから読み取ります。 読み取ったURLを子プロセスに渡して親プロセスは次の周にまわります。

  3. それぞれの子プロセスは対象URLにアクセスして、

    • Aタグのリンク先を読み取り条件にあえば巡回リストに加えます。
    • Aタグのリンク先がjpg画像であればダウンロード処理をします。

巡回リスト

まず初期処理で巡回リストを作成します。 もしも既に一度スクリプトを実施済みでリストが存在したらDROP TABLEで一度クリアしてしまいます。 巡回リストのテーブル名はオプションで変更できるようにしておきました。 コードを抜粋すると次のようなものです。

sub init_db {
    my $h = shift;

    my $sql = "DROP TABLE IF EXISTS ".$opt->get_table_name;
    my $sth = $h->prepare($sql);
    my $result = $sth->execute();

    $sql = "CREATE TABLE ".$opt->get_table_name.
        "(id INTEGER AUTO_INCREMENT PRIMARY KEY,".
        "url VARCHAR(255),".
        "CONSTRAINT UNIQUE cu_test (url))";

    $sth = $h->prepare($sql);
    $result = $sth->execute();
    $sth->finish();

    insert_db($h, $opt->get_target_url);
}

列idはAUTO_INCREMENTを指定していますので新たなレコードが追加されるたびに自動的に1ずつ増加した値が入ります。 列urlは巡回先で重複を避けるためにUNIQUE制約をかけています。

最後にサブルーチンinsert_dbで巡回起点URLを登録しています。 これも抜粋しておくと、

sub insert_db {
    my $h = shift;
    my $u = shift;

    my $sql = "INSERT INTO ".$opt->get_table_name." (id, url) VALUES (NULL, ?)";
    my $sth = $h->prepare($sql);
    {
        local $sth->{PrintError} = 0;
        my $result = $sth->execute($u);
        if (not defined $result) {
            if ($sth->err == 1062) {
                ; # It is nothing
            } else {
                ERROR "code : ".$sth->err;
                ERROR "string : ".$sth->errstr;
                ERROR "state : ".$sth->state;
            }
        }
    }

    $sth->finish();
}

以前にこのブログに書いた一意キー制約違反を無視するしかけをしています。

親プロセスのメインループ

巡回リストが作成できたら、ひたすらリストを読み取って巡回することを繰り返します。 この部分をParallel::ForkManagerを使って並列処理化しました。 コードを抜粋すると、

my $dbh = connect_db() or die("Cannot connect DB".$DBI::errstr);

init_db($dbh);
my $pm = Parallel::ForkManager->new($opt->get_num_of_workers);

for (my $c = 1; $c < $opt->get_max_pages; $c++) {
    my $u = get_url($c);
    if (not defined $u) {
        last;
    }
    my $pid = $pm->start and next;
    worker($u, $c);
    $pm->finish;
}
disconnect_db($dbh);

サブルーチンget_urlがDBから巡回先URLを取ってくる部分です。 少し困るのは子プロセスが次の巡回先をDBに登録する前にこのサブルーチンが呼ばれたときの動作なのですが、 そこは「1秒おきにリストを読みに行く」というポーリング動作をget_urlの中で書いて誤魔化しています。

sub get_url {
    my $id = shift;
    my $query = "SELECT url FROM ".$opt->get_table_name.
        " WHERE id = ?";

    for (my $i = 0; $i < $opt->get_wait_count; $i++) {
        my $sth = $dbh->prepare_cached($query);
        $sth->execute($id);
        my @rs = $sth->fetchrow_array();
        $sth->finish;
        if ($rs[0]) {
            return ($rs[0]);
        }
        sleep 1;
    }
    return undef;
}

子プロセスの動作

起動された子プロセスは指定URLを巡回しますが、目的と無関係な画像のダウンロードを避けるために コンテンツに「メイド」の文字列が存在することをチェックすることにしました。 また「NAVERまとめ」の外のサイトは巡回リストに登録しないよう、フィルタをかけました。

コードは以下のような具合です。

sub worker {
    my $url = shift;
    my $c = shift;

    my $ua = LWP::UserAgent->new(
        ssl_opts => {
            verify_hostname => 0,
        }
        );

    my $target_url = URI->new($url);

    my $r = $ua->get($target_url);
    if (not ($r->is_success)) {
        return;
    }
    my $contents = $r->decoded_content;

    my $t = HTML::TreeBuilder->new;
    $t -> parse( $contents);
    $t -> eof();

    if ($contents =~ /メイド/) {
        DEBUG "メイド found";
    } else {
        return;
    }

    my $h = connect_db();
    if ($h) {
        foreach my $a ($t->find("a") ) {
            my $tgt = $a->attr('href');
            if ($tgt) {
                my $uri = URI->new_abs($tgt, $target_url);
                get_photo($ua, $tgt, $target_url);
                if ($uri =~ m{^http://matome.naver.jp/odai/}) {
                    insert_db($h, $uri);
                }
            }
        } 

        disconnect_db($h);
    }
    $t->delete;
}

サブルーチン get_photo はリンク先がJPEG画像で100KB以上のサイズだったときにのみファイルにダウンロードする処理をします。

その成果

メインループの最大回数を1万回に設定して実行したところ、 600枚強の画像を取得できました。 今回はかなりの精度でメイドさん関連の画像をダウンロードできており 大満足です。