Crawler4jを利用した日本語コンテンツの収集

# はじめに

コンテンツを収集したいWebサイトには古いファイルが残っているため、主にEUC-JPとUTF-8のHTMLファイルが対象となります。

Solrでイントラネットの全文検索エンジンを構築していますが、文字コードをUTF-8に変換する作業が必要になりました。

Solr固有のコードは省いて、Crawler4jで文字コードを変換する作業についてメモを残します。

# サンプルコード

クローリングする起点となるURL等を指定する必要がありますが、設定すれば動くコードをGithubに登録しています。

* [https://github.com/YasuhiroABE/crawler4j-japanese-contents](https://github.com/YasuhiroABE/crawler4j-japanese-contents)

内部で利用している [yasserg/crawler4j](https://github.com/yasserg/crawler4j) の依存関係にあるライブラリが古くなっているのでライブラリを更新したバージョンを利用しています。

* [https://github.com/YasuhiroABE/crawler4j](https://github.com/YasuhiroABE/crawler4j)

# リファレンス

* https://github.com/yasserg/crawler4j
* https://code.google.com/archive/p/juniversalchardet/
* https://kazuhira-r.hatenablog.com/entry/20150912/1442056103 (Solrjを利用する際に参考にしたサイト)

# 前提

処理のほとんどは、juniversalchardet の UniversalDetector クラスに依存しています。

Crawler4jに限らずJavaで日本語のコンテンツを収集する際の文字コードの変換目的だけであれば特に問題なく動作すると思います。

1. 1ファイルはたかだか数KByteという前提で、文字コードを判定するために全体をbyte[]変数に格納しています。添付ファイルを含むメールなど、1ファイルのサイズが非常に大きい場合にはパフォーマンス上の懸念となる可能性があります。
1. http,httpsの両プロトコルに対応したり、/~user/の指定を利用したいので、Patternクラスを利用しています。Regexは比較的処理の負荷が高いので、可能であればCrawler4jのサンプルのようにStringクラスのtoLowerCase()とstartsWith()を利用した方が効率は良いでしょう。

## 環境

最新のコードは次の環境で動作を確認しています。

2. Ubuntu 22.04
2. Maven 3.9.5
2. JRE - Apache Temurin 21.0.4+7

Javaは最新LTSの21を指定していますが、pom.xmlの指定を変更すれば11以降でコンパイル可能です。

# 実行方法と考慮点

Githubからコードをcloneし、実行します。

```bash
$ git clone https://github.com/YasuhiroABE/crawler4j-japanese-contents.git
$ cd crawler4j-japanese-contents
## edit config.properties
$ mvn compile
$ env mvn exec:java
```

config.propertiesで起点となるTARGET_URLなどの指定が行われている。このファイルでの指定は、環境変数で上書きできます。

```bash:環境変数を利用した例
$ env TARGET_URL="https://ja.wikipedia.org/wiki/%E3%82%AF%E3%83%AD%E3%83%BC%E3%83%A9" \
      VISIT_URL_PATTERN="https://ja\.wikipedia\.org/wiki/%E3%82%AF%E3%83%AD%E3%83%BC%E3%83%A9" \
      mvn exec:java
```

TARGET_URLは起点で、ここに含まれる全てのURLが次のターゲットとなります。

VISIT_URL_PATTERNとOK_FILTERは、Pattern.compile()によりCASE_INSENSITIVEオプション付きで評価され、いずれもmatcher(URL).matches()が呼び出されます。この2つのチェックに通過しない場合には実際の遷移(ネットワークアクセス)は発生しません。

VISIT_URL_PATTERNにマッチした場合に次の遷移対象となります。ここを適切に指定しないと延々とリンクを辿ってしまうため、通常はTARGET_URLを含む適切な階層を指定してください。Javaの正規表現が利用できます。

OK_FILTERも同様にマッチした場合に次の遷移対象となります。サイズの大きなメディアファイルを除くため、URLのSuffixを処理することを意図しています。。

```config
## crawl target URL
TARGET_URL=https://example.com/~user01/

## visit patterns:
VISIT_URL_PATTERN=^https?://example\\.com/(%7e|~)user1/.+

## Pass-through rules for the shouldVisit method.
OK_FILTER=.*(\\.(text|txt|html|htm|yaml|yml|csv|json))$
```

# UTF-8への変更方法

システムデフォルトがUTF-8で、外部から入力として受け取ったバイト列の文字コードを判別してからStringオブジェクトに変換しています。

```java:文字コード変換の基本的な流れ
UniversalDetector detector = new UniversalDetector(null);
byte buf[] = page.getContentData(); // pageオブジェクトはCrawler4jから渡されるPageクラスのインスタンス
detector.handleData(buf, 0, buf.length);
detector.dataEnd();
String detectCharset = detector.getDetectedCharset();

String html = "";
try {
    if(detectCharset != null) {
        html = new String(buf, detectCharset);
    }
} catch(java.io.UnsupportedEncodingException e) {
    logger.warn(e.toString());
}
```

この処理はファイルから文字コードの分からないテキストを受け取った時でも同様で、byte[]の形でデータをそのまま(As-Is)読み込み、判別してからエンコードを指定してStringオブジェクトに変換します。生成されたStringオブジェクトの文字コードは環境依存(システムデフォルトのUTF-8)です。

# この他の考慮点

## クローリングするWebサーバーの負荷を考慮する

Crawler4jはスレッドを複数起動できつつ、対象となるWebサーバーに対して、アクセスする間隔が指定できます。今回は1秒毎とコードに書き込んでいますが、実際にはconfig.propertiesのような外部の設定ファイルで時間間隔を調整できるようにしています。

24時間でアクセス可能なページ数は、60秒毎のアクセスでは1440件/日、1秒毎では86400件/日となります。サーバーに登録されているページ数が見積れるのであれば、不要に短い間隔にすることがないように注意してください。

業務でサーバーを監視していた時に、60秒毎でも管理者から怒らたこともありました。短ければ良いというものでもないので用途を明確にし、バランスを取りましょう。

## 取得したHTMLコードの解析

取得したHTMLコードを解析するためには、Crawler4jが提供する機能では不足していたため、JSoupをHTMLパーサーとして利用しています。

## shouldVisit()とvisit()

効率を考えた場合、shouldVisit()ではできるだけサイズの大きい不要なコンテンツを取得しないよう、積極的にreturn false;を返すようにしています。

効率よりも様々なファイルを取得したい場合や、URLの指定が https://example.com/ja のようにスラッシュ'/'で終わらない場合の301ステータス(Moved Permanently)に対応しながらトラバーサルする必要がでてきます。

サイトによっては、https:/www.example.com/ と https://example.com/ が区別されずに使われている場合も考慮しなければいけないかもしれません。

利用の形態によってはshouldVisit()では、積極的にreturn true;を返しつつ、visit()の中で取り込む情報を取捨選択する場合があるかもしれませんが、不要なネットワーク帯域を消費しないようにできるだけshouldVisit()でreturn false;として不要なコンテンツを除去するようにしましょう。

## ログメッセージが冗長すぎる

参考にしたコードからorg.slf4j.LoggerFactoryを利用しています。デフォルトでは、デバッグメッセージがかなり細かく出力されてしまいます。

このためlogback.xmlをsrc/main/resources/に配置して、debugメッセージの出力を抑制しています。

コンテナ環境を前提にして、/dev/nullに出力していますが必要に応じて適宜変更してください。

## jsoupを利用したテキストの取り込み

bodyタグの中にはコンテンツの本体だけでなく、ナビゲーション用のテキスト情報を含む場合もあって、Solrにテキストを取り込む場合には、bodyタグ全体を取り込むべきか、mainタグを考慮したり、そもそもbodyタグがない場合にメタ情報だけを格納するのか、捨てるのかといったことを検討する必要がありそうです。

headerタグの内部にauthor情報や、keywordsが指定されていたりするので、どこまでフォローするかは悩ましいところです。

検索対象から外れてしまうと問題とはいえ、jsoupのDocumentクラスのインスタンス全体からテキスト情報を抽出するべきか、積極的にDocumentをパースして必要な情報を抽出するべきか検討してください。

# 【追記】さいごに

Crawler4jを利用し始めてから5年以上が経過しました。

現在ではPythonやRubyなどのCrawlerを利用することが一般的だと思いますし、そのような選択肢を検討して欲しいところです。

言語変換をデフォルトで備えたライブラリも増えてきていて、選択肢は広がっていると思います。

ただ自分が管理していないWebサーバーへのアクセスは節度を持って行わないとDoS攻撃だと思われる場合があることは常に意識するべきです。

その上で情報は活用しなければ意味がありません。Webサイトに書き込まれた情報も、WordやExcelで作られたファイルもパースされなければただの2進数の数列でしかありません。

あまり恐れずにデータの取得と再利用に挑戦してください。

# 【参考】Solrへのデータの取り込み

Referenceに記載したサイトを参考に、Solrにデータを登録する際には、次のようなコードを追加しています。

```java
    Map<String,Object> document = new LinkedHashMap<>();
    document.put("id", url);
    document.put("content",html);
    document.put("content_ngram", html);
    MySolr.getInstance().addDocument(document);
```

contentやcontent_ngramは次のように設定している。

```bash
#!/bin/bash

## fieldType: text_ja_ngram
curl -X POST -H 'Content-type:application/json' --data-binary '{
  "add-field-type" : {
    "name" : "text_ja_ngram",
    "class" : "solr.TextField",
    "autoGeneratePhraseQueries" : "true",
    "positionIncrementGap" : "100",
    "analyzer" : {
      "charFilter" : {
        "class" : "solr.ICUNormalizer2CharFilterFactory"
      },
      "tokenizer" : {
        "class" : "solr.NGramTokenizerFactory",
        "minGramSize" : "2",
        "maxGramSize" : "2"
      },
      "filters" : [{
        "class" : "solr.CJKWidthFilterFactory",
        "class" : "solr.LowerCaseFilterFactory"
      }]
    }
  }
}' http://localhost:8983/solr/test/schema

## field: content
curl -X POST -H 'Content-type:application/json' --data-binary '{
  "add-field" : { 
    "name" : "content",
    "type" : "text_ja",
    "multiValued" : "true",
    "indexed" : "true",
    "required" : "true",
    "stored" : "true"
  }
}' http://localhost:8983/solr/test/schema

## field: content_ngram
curl -X POST -H 'Content-type:application/json' --data-binary '{
  "add-field" : { 
    "name" : "content_ngram",
    "type" : "text_ja_ngram",
    "multiValued" : "true",
    "indexed" : "true",
    "required" : "true",
    "stored" : "true"
  }
}' http://localhost:8983/solr/test/schema
```