think or die :

1970年代生まれの
人たちのための
エッセー集

think or die home > ITを考える > HTML変換処理処理

HTML変換処理処理

久々のいきなりJava入門

2004/12/25

ちょっと必要があって久しぶりにJavaプログラミングをやっている。内容はテキストのバッチ処理で、このWebサイトのHTML文書のフォーマットを一括変換するという、半年に一回ぐらいは気が向いて行う処理だ。JavaのAPIをあさると便利なクラスを発見する楽しみがある。今回は筆者が新たに学んだクラスについて、Javaプログラマーの皆さんの一助になればと思い書いてみた。

今まではローカルにあるHTMLファイルを読込みながら変換していたが、今回はWebサーバに直接httpプロトコルで接続して、HTMLファイルをダウンロードしながら変換する方式にした。Javaプログラムからhttpプロトコルで通信する方法はとても簡単だ。


URL url = new URL("http://www.hogehoge.com/index.htm");
BufferedReader br = new BufferedReader(
new InputStreamReader(url.openStream()));

接続したいページのアドレスをjava.net.URLクラスのコンストラクタに渡してインスタンスを作成する。そのopenStream()メソッド呼び出すと、InputStreamが返されるので、ここからBufferedReaderを作成すればいい。もちろんURLクラスやBufferedReaderクラスのコンストラクタは例外を発行するので、上記のコード全体をtry { }文で囲んで、入出力に関する例外を補足し忘れないように。URLクラスのコンストラクタが発行する例外はMalformedURLExceptionだけである。

しかし僕はこの方法の問題に気付いた。このままではキャッシュされた古いデータを読み込んでしまうのだ。キャッシュを回避するにはjava.net.HttpURLConnectionクラスのsetUseCaches()メソッドにfalseを渡す必要がある。するとコードはHttpURLConnectionクラスのインスタンスを生成しなければいけない分、以下のように変わってくる。


URL url = new URL("http://www.hogehoge.com/index.htm");
HttpURLConnection uc = (HttpURLConnection)url.openConnection();
uc.setUseCaches(false);
BufferedReader br = new BufferedReader(
new InputStreamReader(uc.getInputStream()));

今度はHttpURLConnectionクラスのgetInputStream()メソッドでInputStreamを返すことになる。プロキシーサーバ経由でインターネットに接続する環境なら、プログラムを実行するときに、引数としてプロキシーサーバの情報を渡すことができる。


java -Dhttp.proxyHost=www.proxy.com -Dhttp.proxyPort=8080
-Dhttps.proxyHost=www.proxy.com -Dhttps.proxyPort=8080 (クラス名)

「-D」はJVMに実行時のパラメータを渡すためのものだ。お気づきのようにhttpだけでなく、SSLを使ったhttpsプロトコルに対してもプロキシー情報を渡している。読者の方はご存知のように、このWebサイト「think or die」には会員専用領域があり、SSLで暗号化されており、かつ、ユーザ名とパスワードの認証が要求される。この2つの課題についてもJavaのJ2SE 1.4にはAPIが用意されている。上述のHttpURLConnectionを、https対応に書き換えてみよう。


URL url = new URL("http://www.hogehoge.com/index.htm");
HttpsURLConnection uc = (HttpsURLConnection)url.openConnection();
uc.setUseCaches(false);
BufferedReader br = new BufferedReader(
new InputStreamReader(uc.getInputStream()));

単にHttpURLConnectionクラスの代わりにjavax.net.ssl.HttpsURLConnectionを使うだけである。パッケージ名がjava.netではなくjavax.net.sslなので、import文を追加するのを忘れてはいけないが、これだけでhttps対応のクライアントプログラムが出来てしまうのがJavaの恐るべき便利さだ。次にユーザ名とパスワードの認証だが、こちらも至って簡単。java.net.Authenticatorクラスを利用すればいい。まず、このAuthenticatorクラスを継承・拡張した独自のクラスを定義する。


class MyAuthenticator extends Authenticator {
private String username;
private String password;
public MyAuthenticator(String username, String password) {
this.username = username;
this.password = password;
}
protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication(
username, password.toCharArray());
}
}

ここではAuthenticatorクラスを継承して、仮にMyAuthenticatorという名前のクラスを定義している。コンストラクタにはユーザ名とパスワードをString型で渡す。そしてもともとAuthenticatorクラスに定義されているgetPasswordAuthenticationというメソッドをオーバーライド(上書き定義)している。このメソッドは、プログラムが実行中にユーザ名とパスワードによる認証を必要としたとき、JVMが自動的に(勝手に)呼び出すメソッドで、プログラマー自身が呼び出す必要はまったくない。

このようにMyAuthenticatorという独自のクラスを定義したら、あとはこのクラスをデフォルトの認証手段としてJVMに登録する必要がある。その登録は次のようにAuthenticatorクラスのstaticメソッドを使って行う。


Authenticator.setDefault(new MyAuthenticator("username", "password"));

この1文は、プログラムの中でhttpによるネットワーク接続が始まる前ならどこに書いておいてもいい。この1文でMyAuthenticatorのインスタンスが作成され、それがこのプログラムの実行中に使われる認証手段として登録される。プログラムが実行中に、ネットワーク経由でユーザ名とパスワードを要求されたら、JVMは自動的にMyAuthenticatorクラスのgetPasswordAuthenticationメソッドを呼び出してくれる。

以上でプロキシーサーバ経由の接続、SSL通信、パスワード認証という3つの課題をすべて解消できるので、後はBufferedStreamでHTML文書を読み込みながら、テキスト処理をするだけになる。BufferedStreamで1行ずつ読み込む処理は次のようになる。


String line;
while ((line = br.readLine()) != null) {
}

こうやって1行ずつHTML文書を読み込みながら、StringクラスのindexOf()やsubstring()などを使ってタグの解析をするのも一つの方法だが、非常にロジックが複雑になる。そこでHTML文書を解析するためのクラスが実は用意されているのだ。javax.swing.text.html.HTMLEditorKit.ParserCallbackというクラスである。パッケージ名がjavax.swingという風にSwing GUI部品の一部という扱いになっているのは、もともとSwing GUI部品を使ってHTMLエディターを作成すべく用意されているからのようだ。

このHTMLEditorKit.ParserCallbackはクラス名から分かるようにコールバック用のクラスである。コールバックというのは、プログラマー自ら呼び出すことはできず、JVMが自動的に呼び出すクラスという意味だ。プログラマーの預かり知らないところから、特定のイベントが起こったタイミングで繰返し呼び出されるクラスである。

そういう意味で先ほどのAuthenticatorと似ている。Authenticatorはプログラムの実行中に、パスワード認証を要求されたというイベントが発生するたびに、JVMが自動的に呼び出すクラスであり、HTMLEditorKit.ParserCallbackは、文書解析を一手に引き受ける代理人クラス(ParserDelegatorクラス)がHTML文書を解析している途中に、タグや地の文などに突き当たるというイベントが発生するたびに、JVMが自動的に呼び出すクラスである。

したがって、まずはHTMLEditorKit.ParserCallbackを継承(拡張)して独自のクラスを定義することから始める。


class MyParserCallback extends HTMLEditorKit.ParserCallback {
public void handleStartTag(HTML.Tag tag,
MutableAttributeSet attr, int pos){
if (tag.equals(HTML.Tag.A)) {
String href = (String)attr.getAttribute(
HTML.Attribute.HREF);
}
if (tag.equals(HTML.Tag.IMG)) {
String src = (String)attr.getAttribute(
HTML.Attribute.SRC);
}
}
public void handleText(char[] data, int pos) {
String text = new String(data);
}
}

ここではHTMLEditorKit.ParserCallbackを拡張してMyParserCallbackというクラスを定義している。このクラスには、コメントタグを処理するhandleComment()、通常のタグの始まりを処理するhandleStartTag()、通常のタグの終わりを処理するhandleEndTag()、「<BR>」などの単一のタグを処理するhandleSimpleTag()、地の文を処理するhandleText()などのメソッドがある。各メソッドの中味をオーバーライド(上書き定義)するときは、HTML解析処理が、タグや地の文に突き当たったときに、どういう処理をさせたいかを記述すればいい。

上の例では、開始タグに突き当たったときの処理をhandleStartTag()に記述している。突き当たったタグが、「<A HREF=...>」なのか「<IMG SRC=...>」なのかはif文で判断している。JVMが自動的にこのhandleStartTag()を呼び出すときに、タグの中味を最初の引数にHTML.Tag型で、そのタグの各種属性をMutableAttributeSet型で渡してくれているので、プログラマーはそれらの中味を基準に分岐処理を書けばいい。

渡された最初の引数、HTML.Tag型のtagには、「A」「IMG」などといったタグがセットされている。一方、javax.swing.text.html.HTML.Tagクラスには、各HTMLタグに対応する定数が定義されている(HTML.Tag.TITLE, HTML.Tag.BLOCKQUOTE, HTML.Tag.CENTERなど)ので、これらとequals()メソッドを使って等しいかどうかチェックすればいい。「if (tag.equals(HTML.Tag.A)) { ... }」と書けば、突き当たったタグが「<A ... >」タグかどうかを判定していることになる。

ここで「if (tag.equals("IMG")) { ... }」と書けそうな気もするが、これはやめた方がいい。タグが大文字で渡されるか、小文字で渡されるかは保証されていないからだ。handleStartTag()メソッドが呼び出されるとき、どうやら小文字で渡されているようなので、「if (tag.equals("img")) { ... }」と記述すれば正常に機能するが、やはりJavaの推奨どおりにHTML.Tag.IMGなどの既存のstatic定数を使うのが無難だ。

タグが判定できたら、次はそのタグの属性を調べる。「<A HREF="... >」タグなら「HREF」属性を調べたくなるだろう。「<IMG SRC="... >」タグなら「SRC」属性を調べたくなるだろう。これら属性部分は、「<A HREF="..." TARGET="_">」など「HREF」「TARGET」と複数ある場合もあるので、javax.swing.text.MutableAttributeSetインターフェースに、属性名と属性値の複数のペアとしてセットされる。

複数ある属性のうち、特定の属性値だけを取り出すには、MutableAttributeSetインターフェースのgetAttribute()メソッドに属性名を渡して呼び出せば、属性値を返してくれる。「String href = (String)attr.getAttribute(HTML.Attribute.HREF);」とやれば「<A HREF="... >」タグのリンク先のURLを取得できる。返ってくる値はObject型なので、文字列として取得するためにStringクラスにキャストするのを忘れないように。

注意したいのは、ここで属性名を渡すときも、「String href = (String)attr.getAttribute("href");」などと「"href"」という文字列をそのまま渡すのではなく、「HTML.Attribute.HREF」というstatic定数を使っている点だ。さきほどと同様、大文字("HREF")・小文字("href")のどちらで渡せばいいかという保証はないので、Javaで定義されている定数を使うのが無難である。HTML.Attributeクラスにも、HTML.Tagクラスと同様、さまざまな定数が定義されている。普通のHTMLの解析なら困ることはないだろう。

タグではなくて、HTMLの地の文に突き当たった場合は、JVMが自動的にhandleText()メソッドを呼び出してくれる。ここで地の文と言っているのは、タグとタグにはさまれたテキスト部分のことだ。そのテキスト部分はhandleText()メソッドの最初の引数にchar型の配列、char[]で自動的に渡されるので、とりあえずStringクラスの文字列を作成しておけば、いろんな処理がしやすい。

このようにHTMLEditorKit.ParserCallbackを継承(拡張)して独自のクラスを定義したら、次に、このコールバッククラスをJVMに登録する必要がある。さきほどHTML文書を読み込むためのBufferedReaderを作成した続きに、コールバッククラスを登録するコードを追加してみよう。


URL url = new URL("http://www.hogehoge.com/index.htm");
HttpURLConnection uc = (HttpURLConnection)url.openConnection();
uc.setUseCaches(false);
BufferedReader br = new BufferedReader(
new InputStreamReader(uc.getInputStream()));
MyParserCallback cb = new MyParserCallback();
ParserDelegator pd = new ParserDelegator();
pd.parse(br, cb, true);
br.close();

ポイントはjavax.swing.text.html.parser.ParserDelegatorというクラスだ。さきほどの独自コールバッククラスのインスタンスを作成した次の行で、ParserDelegatorクラスのインスタンスを作成する。このクラスはHTMLの解析をどこかしらにある文書解析プログラムに委任するためのものだ。ParserDelegatorクラスのparse()メソッドに、1つめの引数としてHTML文書のReader(ここではすぐ上で作成したBufferedReaderのインスタンス)、2つめの引数としてコールバック用のクラス(ここではすぐ上で作成したMyParserCallbackのインスタンス)、3つめの引数として文字セットを無視するかどうかのフラグ(ここでは無視するのでtrue)を渡してやればいい。

このparse()メソッドに正しい引数を渡して呼び出した瞬間、プログラマーのあずかり知らないところでHTML文書解析プログラムが走り出す。そしてこの解析プログラムがHTMLタグや地の文に突き当たるたびに、MyParserCallbackのhandleStartTag()、handleText()などのメソッドが勝手に呼び出され、BufferedReaderの最後までたどり着いたら、解析プログラムは勝手に解析をやめて、次の文「br.close();」に移る。

以上がHTML解析の仕組みだ。僕が作ったプログラムでは、一つのHTML文書内にあるすべての文書リンク「<A HREF=」のリンク先を、まとめて取得したかったので、リンク先の配列を保有するVectorクラスをMyParserCallbackに持たせてみた。簡略化して書くと次のようになる。


class MyParserCallback extends HTMLEditorKit.ParserCallback {
private Vector links = new Vector();
public void handleStartTag(HTML.Tag tag,
MutableAttributeSet attr, int pos){
if (tag.equals(HTML.Tag.A)) {
String href = (String)attr.getAttribute(
HTML.Attribute.HREF);
links.add(href);
}
}
public Vector getLinks() {
return links;
}
}

コールバッククラスにこのようにHREF属性の値を蓄積するVectorクラスを仕込んでおけば、HTML解析プログラムが終了した時点で次のようにして、解析したHTML文書に含まれていたすべての文書リンク先を取り出せる。


URL url = new URL("http://www.hogehoge.com/index.htm");
HttpURLConnection uc = (HttpURLConnection)url.openConnection();
uc.setUseCaches(false);
BufferedReader br = new BufferedReader(
new InputStreamReader(uc.getInputStream()));
MyParserCallback cb = new MyParserCallback();
ParserDelegator pd = new ParserDelegator();
pd.parse(br, cb, true);
br.close();
Vector links = cb.getLinks();

後は、こうして取得したリンクに含まれる「../index.html」「../../index.htm」などの相対リンクを、正しく絶対リンクに変換して、さらにリンク先のHTML文書を同様に解析すれば、トップページから開始してリンク先にあるすべてのHTML文書を順次処理していくことができる。

このとき自分自身のクラスを再帰呼出することになるので、当然、無限ループに陥る可能性がある。したがって自分自身を呼び出すときに、何回目の再帰呼出であるかをパラメータとして渡す。呼び出された側(といっても自分自身なのだが)で、その深さが一定以上に達したら、解析処理をせずに直ちにreturnするようにしておけばいい。

ただし、ここでちょっとした問題がある。一つのHTML文書に同じ文書へのリンクが繰り返し出てくる場合があるのだ。この場合はダブりなくリンクを取り出したい。ダブりをなくそうと思うと、Vectorクラスを使ったのではまずいことになる。Vectorクラスは要素の重複を許すからだ。ここではもう一つ欲張って、HTML文書に含まれる文書リンクを、重複なく、かつ、アルファベット順に取り出すことにしたい。こんな目的に重宝するのがTreeSetクラスである。

TreeSetクラスは、要素の重複を自動的に排除するだけでなく、昇順に並べ替えてくれる。厳密に言うと、要素となっているオブジェクトごとに定義された大小の比較ロジックにしたがって、小さいもの順に並べ替えてくれる。ここではStringクラスが要素になっているので、Stringクラスに定義されている大小比較のロジックにしたがって昇順に自動で並べ替えてくれるのだ。TreeSetを使うと上記のプログラムは以下のように書き換えられる。


class MyParserCallback extends HTMLEditorKit.ParserCallback {
private TreeSet links = new TreeSet();
(... 中略 ...)
public TreeSet getLinks() {
return links;
}
}


URL url = new URL("http://www.hogehoge.com/index.htm");
(... 中略 ...)
TreeSet links = cb.getLinks();

実は今回のプログラムを作るとき、独自に定義したクラスもTreeSetを使って自動昇順並べ替えをしてやろうと思い立ち、そういうコードを書いてみた。ダミーのコードで示せば以下のようになる。


TreeSet myTreeSet = new TreeSet();
MyClass = new MyClass();
myTreeSet.add(myClass);

すると、どうしても最後のadd()メソッドを呼び出す部分で実行時にClassCastExceptionという例外になってしまう。初めはその理由が分からなかったのだが、TreeSetクラスのadd()メソッドにある、ClassCastExceptionの説明をよく読んでみると「if the specified object cannot be compared with the elements currently in the set.」(現在セットの中にある要素と、指定されたオブジェクトが比較可能でない場合)と書いてある。

なるほど、言われてみれば、僕が勝手に定義した独自のMyClassというクラスのインスタンスが2つあったとして、どちらが「より大きいか」など、Javaは分かるはずがない。定義者の責任として、MyClassというクラスのインスタンス同士を比較するロジックも、加えて定義する必要があるのだ。そのときフッと、「Comparable」というクラスだかインターフェースだかがあったなぁと思い出した。

Java APIを調べてみると、案の定java.lang.Comparableというインターフェースがある。クラスではなくインターフェースだということは、MyClassでこのインターフェースを実装してしまえばいいのだ。Comparableインターフェースが持つ唯一のメソッドはcompareTo(Object o)で、Objectクラスの引数を一つだけ持つ。戻り値は、「より大きい」場合は正の整数(int)、「同じ」場合はゼロ、「より小さい」場合は負の整数(int)を戻せばいいことになっている。なるほどということで、MyClassクラスを次のように定義し直してみた。


class MyClass implements Comparable {
private String filename;
public String getFilename() {
return filename;
}
public int compareTo(Object o) {
MyClass mc = (MyClass)o;
return filename.compareTo(mc.getFilename());
}
}

まずComparableインターフェースを実装する宣言として「implements Comparable」を追加する。次にcompareTo()メソッドの定義を追加する。Objectクラスで渡された引数をMyClassにキャストしてから、メンバー変数のfilename同士を比較すればいい。メンバ変数はStringクラスだが、Stringクラスには既にcompareTo()メソッドが実装されているので、この戻り値をそのままMyClassのcompareTo()メソッドの戻り値として返してやる。こうすればMyClassは、メンバー変数filenameの大小を基準にして並べ替えができ、無事、TreeSetに要素として追加できるというわけだ。



sub title home > ITを考える > HTML変換処理処理
筆者のブログ
「愛と苦悩の日記」
おすすめ記事