CLOVER🍀

That was when it all began.

Apache Solr 5.x+crawler4jで、Webサイトをクロールしてインデックス化する

Apache Solrを使った、Webクローリングと全文検索をどうやってやろうかなぁ〜と思いまして。

OSSのクローラーっていくつかあると思うんですけど

参考)
Comparison of existing open-source tools forWeb crawling and indexing of free Music
http://ja.scribd.com/doc/123153248/Comparison-of-existing-open-source-tools-for-Web-crawling-and-indexing-of-free-Music#scribd

FessやNutchのようなものを使ってもよいのですが、今回はある程度カスタマイズなどを前提にして、自分でプログラムを書くタイプのものがいいなぁと思いまして。

で、今回選んだのがこちらのcrawler4j。

crawler4j
https://github.com/yasserg/crawler4j

Javaでクローラーを作る(crawler4j)
http://qiita.com/MT-01/items/78691f946ba928387153

Javaでクローラを実装する
http://qiita.com/petitviolet/items/e3b850fece36a176bf97

全文検索エンジンを作ろうと思って、まずはクローラーを作ってみた
http://white-azalea.hatenablog.jp/entry/2015/05/24/165125

いくつか日本語情報もあるので、これらも参考に。

今回は、crawler4jを使ってこのサイト(はてなの自分のページ)の2015年分をクローリングして、Solrにインデキシングするところまでやってみます。

準備

Maven依存関係。

まずはcrawler4j。

        <dependency>
            <groupId>edu.uci.ics</groupId>
            <artifactId>crawler4j</artifactId>
            <version>4.1</version>
        </dependency>

その他、SolrにアクセスするためにSolrJ,HTMLの中身を解析するためにjsoup、あとはロギングでslf4j-simpleをとりあえず入れました。

        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-simple</artifactId>
            <version>1.7.12</version>
        </dependency>
        <dependency>
            <groupId>org.apache.solr</groupId>
            <artifactId>solr-solrj</artifactId>
            <version>5.3.0</version>
        </dependency>
        <dependency>
            <groupId>org.jsoup</groupId>
            <artifactId>jsoup</artifactId>
            <version>1.8.3</version>
        </dependency>

プログラムを書く

crawler4jの使い方は、GitHubのQuickstartを見るのがよさそうです。

Quickstart
https://github.com/yasserg/crawler4j#quickstart

これで、たいていのことはできる気がします。

今回は、Solrにインデックスを作ることを踏まえてプログラムを書きます。

まずは、クローラーそのものの実装。
src/main/java/org/littlewings/mycrawler/MyLittleCrawler.java

package org.littlewings.mycrawler;

import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;

import edu.uci.ics.crawler4j.crawler.Page;
import edu.uci.ics.crawler4j.crawler.WebCrawler;
import edu.uci.ics.crawler4j.parser.HtmlParseData;
import edu.uci.ics.crawler4j.url.WebURL;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class MyLittleCrawler extends WebCrawler {
    private final static Pattern FILTERS = Pattern.compile(".*(\\.(css|js|gif|jpg"
            + "|png|mp3|mp3|zip|gz))$");

    private Logger logger = LoggerFactory.getLogger(getClass());

    @Override
    public boolean shouldVisit(Page referringPage, WebURL url) {
        String hrefUrl = url.getURL();
        String href = hrefUrl.toLowerCase();
        return !FILTERS.matcher(href).matches()
                && hrefUrl.matches("http://d\\.hatena\\.ne\\.jp/Kazuhira/2015\\d\\d.+");
    }

    @Override
    public void visit(Page page) {
        String url = page.getWebURL().getURL();
        logger.info("URL: {}", url);

        if (page.getParseData() instanceof HtmlParseData) {
            HtmlParseData htmlParseData = (HtmlParseData) page.getParseData();
            String html = htmlParseData.getHtml();
            Set<WebURL> links = htmlParseData.getOutgoingUrls();

            logger.info("Number of outgoing links: {}", links.size());

            Document jsoupDoc = Jsoup.parse(html);
            String title = jsoupDoc.title();
            Element elm = jsoupDoc.select("div.body div.section").get(0);
            String text = elm.text();

            logger.info("add Solr Document.");

            Map<String, Object> document = new LinkedHashMap<>();
            document.put("url", url);
            document.put("title", title);
            document.put("contents", text);
            Solr.getInstance().addDocument(document);
        }
    }
}

画像やCSSなどは省いて、うちのブログの2015年のもののみを対象に。

    @Override
    public boolean shouldVisit(Page referringPage, WebURL url) {
        String hrefUrl = url.getURL();
        String href = hrefUrl.toLowerCase();
        return !FILTERS.matcher(href).matches()
                && hrefUrl.matches("http://d\\.hatena\\.ne\\.jp/Kazuhira/2015\\d\\d.+");
    }

visitメソッドの中では、HTMLの中からタイトルとセレクタで指定した範囲を抜きだし、Solrのインデックスに登録します。

            HtmlParseData htmlParseData = (HtmlParseData) page.getParseData();
            String html = htmlParseData.getHtml();
            Set<WebURL> links = htmlParseData.getOutgoingUrls();

            logger.info("Number of outgoing links: {}", links.size());

            Document jsoupDoc = Jsoup.parse(html);
            String title = jsoupDoc.title();
            Element elm = jsoupDoc.select("div.body div.section").get(0);
            String text = elm.text();

            logger.info("add Solr Document.");

            Map<String, Object> document = new LinkedHashMap<>();
            document.put("url", url);
            document.put("title", title);
            document.put("contents", text);
            Solr.getInstance().addDocument(document);

起動元となる、Mainクラスはこちら。
src/main/java/org/littlewings/mycrawler/Launcher.java

package org.littlewings.mycrawler;

import edu.uci.ics.crawler4j.crawler.CrawlConfig;
import edu.uci.ics.crawler4j.crawler.CrawlController;
import edu.uci.ics.crawler4j.fetcher.PageFetcher;
import edu.uci.ics.crawler4j.robotstxt.RobotstxtConfig;
import edu.uci.ics.crawler4j.robotstxt.RobotstxtServer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class Launcher {
    public static void main(String... args) throws Exception {
        Logger logger = LoggerFactory.getLogger(Launcher.class);

        String crawlStorageFolder = "data/crawl/strage";
        int numberOfCrawlers = 7;

        CrawlConfig config = new CrawlConfig();
        config.setCrawlStorageFolder(crawlStorageFolder);
        config.setPolitenessDelay(1 * 1000);  // 1秒ごとにリクエスト

        PageFetcher pageFetcher = new PageFetcher(config);
        RobotstxtConfig robotstxtConfig = new RobotstxtConfig();
        RobotstxtServer robotstxtServer = new RobotstxtServer(robotstxtConfig, pageFetcher);
        CrawlController controller = new CrawlController(config, pageFetcher, robotstxtServer);

        logger.info("add Seed URL under... {}", "http://d.hatena.ne.jp/Kazuhira/");
        controller.addSeed("http://d.hatena.ne.jp/Kazuhira/");

        logger.info("Start crawler.");
        controller.start(MyLittleCrawler.class, numberOfCrawlers);

        logger.info("commit!!");
        Solr.getInstance().commit();

        Solr.getInstance().close();
    }
}

クローラーはスレッド7つで起動するようにして、1秒のスリープが入るように設定しています。

また、最後にSolrにコミットして、シャットダウンします。

Solrって書かれたクラスの内容は、こちら。ただのSolrJの簡易ラッパーです。
src/main/java/org/littlewings/mycrawler/Solr.java

package org.littlewings.mycrawler;

import java.io.IOException;
import java.util.Map;

import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.apache.solr.client.solrj.SolrClient;
import org.apache.solr.client.solrj.SolrServerException;
import org.apache.solr.client.solrj.impl.HttpSolrClient;
import org.apache.solr.common.SolrInputDocument;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class Solr {
    private static final Solr INSTANCE = new Solr();

    private SolrClient client;

    private Logger logger = LoggerFactory.getLogger(getClass());

    private Solr() {
        PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
        CloseableHttpClient httpClient = HttpClients.custom()
                .setConnectionManager(cm)
                .build();
        client = new HttpSolrClient("http://localhost:8983/solr/mycore", httpClient);
    }

    public static Solr getInstance() {
        return INSTANCE;
    }

    public void addDocument(Map<String, Object> document) {
        try {
            SolrInputDocument solrDocument = new SolrInputDocument();

            document.entrySet().forEach(entry -> solrDocument.addField(entry.getKey(), entry.getValue()));

            client.add(solrDocument);
        } catch (IOException | SolrServerException e) {
            logger.info("add Document error.", e);
        }
    }

    public void commit() {
        try {
            client.commit();
        } catch (IOException | SolrServerException e) {
            logger.info("commit error.", e);
        }
    }

    public void close() {
        try {
            client.close();
        } catch (IOException e) {
            logger.info("close error.", e);
        }
    }
}

SolrJの使い方は、こちらを参考に。

Using SolrJ
https://cwiki.apache.org/confluence/display/solr/Using+SolrJ

Solr側の設定

Solr側のスキーマ定義は、このように。

    <field name="url" type="string" indexed="true" stored="true" required="true" multiValued="false" />
    <field name="title" type="text_ja" indexed="true" stored="true" required="true" multiValued="false" />
    <field name="contents" type="text_ja" indexed="true" stored="true" required="true" multiValued="false" />
     <uniqueKey>url</uniqueKey>

「text_ja」は、SolrのデフォルトのKuromojiの設定です。

ユニークキーは、URLということで。

動作確認

それでは、クローラーを動かしてみます。

[main] INFO edu.uci.ics.crawler4j.crawler.CrawlController - Deleted contents of: data/crawl/strage/frontier ( as you have configured resumable crawling to false )
[main] INFO org.littlewings.mycrawler.Launcher - add Seed URL under... http://d.hatena.ne.jp/Kazuhira/
[main] INFO org.littlewings.mycrawler.Launcher - Start crawler.
[main] INFO edu.uci.ics.crawler4j.crawler.CrawlController - Crawler 1 started
[main] INFO edu.uci.ics.crawler4j.crawler.CrawlController - Crawler 2 started
[main] INFO edu.uci.ics.crawler4j.crawler.CrawlController - Crawler 3 started
[main] INFO edu.uci.ics.crawler4j.crawler.CrawlController - Crawler 4 started
[main] INFO edu.uci.ics.crawler4j.crawler.CrawlController - Crawler 5 started
[main] INFO edu.uci.ics.crawler4j.crawler.CrawlController - Crawler 6 started
[main] INFO edu.uci.ics.crawler4j.crawler.CrawlController - Crawler 7 started
[Crawler 1] INFO org.littlewings.mycrawler.MyLittleCrawler - URL: http://d.hatena.ne.jp/Kazuhira/
[Crawler 1] INFO org.littlewings.mycrawler.MyLittleCrawler - Number of outgoing links: 251
[Crawler 1] INFO org.littlewings.mycrawler.MyLittleCrawler - add Solr Document.

こんな感じでクローラーが起動して、

[Crawler 2] INFO org.littlewings.mycrawler.MyLittleCrawler - URL: http://d.hatena.ne.jp/Kazuhira/20150906
[Crawler 2] INFO org.littlewings.mycrawler.MyLittleCrawler - Number of outgoing links: 159
[Crawler 2] INFO org.littlewings.mycrawler.MyLittleCrawler - add Solr Document.
[Crawler 2] INFO org.littlewings.mycrawler.MyLittleCrawler - URL: http://d.hatena.ne.jp/Kazuhira/20150905
[Crawler 2] INFO org.littlewings.mycrawler.MyLittleCrawler - Number of outgoing links: 163
[Crawler 2] INFO org.littlewings.mycrawler.MyLittleCrawler - add Solr Document.
[Crawler 2] INFO org.littlewings.mycrawler.MyLittleCrawler - URL: http://d.hatena.ne.jp/Kazuhira/20150513/1431530070
[Crawler 2] INFO org.littlewings.mycrawler.MyLittleCrawler - Number of outgoing links: 167
[Crawler 2] INFO org.littlewings.mycrawler.MyLittleCrawler - add Solr Document.
[Crawler 5] INFO org.littlewings.mycrawler.MyLittleCrawler - URL: http://d.hatena.ne.jp/Kazuhira/20150517/1431859643
[Crawler 5] INFO org.littlewings.mycrawler.MyLittleCrawler - Number of outgoing links: 171
[Crawler 5] INFO org.littlewings.mycrawler.MyLittleCrawler - add Solr Document.
[Crawler 2] INFO org.littlewings.mycrawler.MyLittleCrawler - URL: http://d.hatena.ne.jp/Kazuhira/20150912/1442047726
[Crawler 2] INFO org.littlewings.mycrawler.MyLittleCrawler - Number of outgoing links: 174
[Crawler 2] INFO org.littlewings.mycrawler.MyLittleCrawler - add Solr Document.

クローリングが進んでSolrにドキュメントを追加していきます。

こんなのが出力されたら、終了。

[Thread-1] INFO edu.uci.ics.crawler4j.crawler.CrawlController - It looks like no thread is working, waiting for 10 seconds to make sure...
[Thread-1] INFO edu.uci.ics.crawler4j.crawler.CrawlController - No thread is working and no more URLs are in queue waiting for another 10 seconds to make sure...
[Thread-1] INFO edu.uci.ics.crawler4j.crawler.CrawlController - All of the crawlers are stopped. Finishing the process...
[Thread-1] INFO edu.uci.ics.crawler4j.crawler.CrawlController - Waiting for 10 seconds before final clean up...
[main] INFO org.littlewings.mycrawler.Launcher - commit!!

では、検索してみます。

「Lucene」

 curl 'http://localhost:8983/solr/mycore/select?wt=json&indent=true' -d '{ "query": "title:Lucene contents:Lucene" }'
{
  "responseHeader":{
    "status":0,
    "QTime":5,
    "params":{
      "indent":"true",
      "json":"{ \"query\": \"title:Lucene contents:Lucene\" }",
      "wt":"json"}},
  "response":{"numFound":70,"start":0,"docs":[
      {
        "url":"http://d.hatena.ne.jp/Kazuhira/20150716/1437059068",
        "title":"Lucene Kuromojiのトークナイズを、Graphvizを使ってビジュアル化する - CLOVER",
        "contents":"Lucene Kuromojiのトークナイズを、Graphvizを使ってビジュアル化する Lucene ちょっと前に見ていたこちらのエントリ。

〜省略〜

「Hibernate Search」

$ curl 'http://localhost:8983/solr/mycore/select?wt=json&indent=true' -d '{ "query": "(title:Hibernate contents:Hibernate) AND (title:Search contents:Search)" }'
{
  "responseHeader":{
    "status":0,
    "QTime":3,
    "params":{
      "indent":"true",
      "json":"{ \"query\": \"(title:Hibernate contents:Hibernate) AND (title:Search contents:Search)\" }",
      "wt":"json"}},
  "response":{"numFound":32,"start":0,"docs":[
      {
        "url":"http://d.hatena.ne.jp/Kazuhira/20150205/1423152546",
        "title":"Hibernate Searchと数値フィールド - CLOVER",
        "contents":"Hibernate Searchと数値フィールド Lucene, HibernateSearch Luceneでドキュメントに数値を保存する時、数値向けのフィールドとしてIntFieldやLongField、DoubleFieldなどを使うことができます。ですが、これらはTermとしてはちょっと別物になるので、普通のクエリでは検索できずNumericRangeQueryを使うことになります。 Luceneを試していた時は、最初この差を知らなくて検索できなかったり、たまにコロッと忘れてハマったりするのですが…Hibernate Search越しだとどうなるのかがちょっと気になって試してみました。 結果からいうと、Query DSLでもあまり意識することなく使えました。

〜省略〜

OKそうですね。

とりあえず、簡単なWebクローリングと検索ができましたよっと。