2ちゃんねる Q&A 検索のその後

http://qarc.info/
のその後の日記です。

変更したこと

ドメインを変えた

ドメインは、前はkako.feezch.infoでしたが、Q&Aなのにkakoとかフィードを配信していないのにfeezch.infoのドメインとか意味不明な感じだったので変えました。qarc.infoです。これまたあまり意味のある名前ではないのですが、何か名前が必要だと思って、Q&Aスレをアーカイブするからqarcみたいな名前です。なぜsearchじゃないのかというと、arcのほうが響きがかっこいいからです。

質問スレ以外の過去ログを消した

公開時は、一部の板で1000に達したスレの過去ログを全部保存した上で、その中から質問スレを選んで、さらに質問文を選んで……としていましたが、レスのレコードが増すぎてやばい感じだったのと、全体の過去ログとか別にうちで持たなくてもいいやと思ったので消しました。
一度取り始めるとやめるのは勿体無い気がしてきますが、僕は●ユーザーなので過去ログはURLさえ分かればいつでも取れるし、また必要になったときに取ればいいです。
保存していたのは、今後質問レス抽出の精度が上がれば、質問スレ以外からも抽出しようと思っていたからですが、もう消したので、精度はあまり気にならなくなりました。

全文検索をちょっとマシにした
僕の魔界を救って→僕まか
モンスターハンターフロンティア→MHF

みたいな略語の辞書を作って、略されてても全文検索でヒットするようにしました。ただ、手動なのでつらいというか、ぜんぜん対応出来てない。Googleはこういうのがすごくよく出来ているので、Googleに慣れている人は平気で適当なクエリを投げてきますが、そんなの30分くらいで作ったうちの全文検索システムで出来るわけないです。
ただこのへんをきちんと作らないとトップページのQ&A検索を自信を持って勧められないので、全文検索をまともにするのが今後の主な課題になると思います。

XMLサイトマップを作ってクローラー目的の一覧を消した

公開時はQ&Aの一覧が新着順・人気順などありましたが、もともとクローラーがQ&Aごとのページをインデックスできるような意味が大きかったし、ログが増えて人間が見るのは不可能な量になっていたので、クローラー用のXMLサイトマップを作るようにして一覧を消しました。

アフィをいろいろアレした

省略します。

アクセス数

http://f5f3506df624bc426d789d5c0811abde.gazou.me/large.png
2/10に公開してからのGoogle Analyticsによる訪問者数(セッション数)のグラフです。
Google様の気分しだいで上がったり下がったりします。
今は大体一日12000UU、18000PVくらいです。

滞在時間はかなり短いのですが、質問文で検索すると2ちゃんねるで過去に行われた質問と回答のページが出てきてそれを見ると一発で答えが分かる(または分からないことが分かる)というのが基本的なモデルなので、短くていいと思います。

サーバー

SaaSesのOsukini Server GT 1台にクローラーからウェブ、DBまで何もかも入れています。検索と過去ログ一覧以外はページキャッシュしてnginxで配信しているだけなので、負荷はほとんどありません。
クローラーもfeezchのようなスレの差分を集めて回るタイプではなく、スレッド一覧から質問スレが落ちたのを検出して丸ごと取って終わりなので、あまり負荷はありません。
当分はこれ1台でいけるのではと思っています。

まとめ

サイトはシンプルになったと思います。
サイトのシステムは全自動で動いているので、僕はたまに思いついたことを実装して反映して、あとは結果を観測しているだけという状態です。さまざまな反応がゆっくりと進んでいる感じなので、早く時間がたって、どうなったか見たいと思っています。

2ちゃんねるに寄せられた質問と回答から検索できるページを作った

2ちゃんねる Q&A 検索 - QARC

人類の知の資産であるところの2ちゃんねるの過去ログから、特に有用と思われる質問と回答のやりとりを高度な自然言語処理技術を用いて抽出・アーカイブし、それらを誰もが自由に検索できるページを作りました。
僕は、feezch.infoという2ちゃんねるのパートスレを次スレまで次々に自動追跡しながらひとつのストリームとしてフィードを吐くウェブサービスを運営しているのですが、フィードの利用者がとても少ないわりに、過去ログへのアクセスがものすごくあって、そのほとんどは質問スレの回答求めて訪れる方のようなので、それなら一発で回答にアクセスできるページを作れば非常に大きな社会貢献になるのではないか、feezch.infoのコードを使えば簡単に作れるし、ビッグデータや。そう考えてこのサービスを作ることにしました。

コレを使うと、たとえば、「skyrim 水銀のインゴット」と入力して検索ボタンを押すと…
http://2bd642aadabd8fdc27fb3bd56a192efb.gazou.me/large.png

このように過去に2ちゃんねるに寄せられた質問レスが出てきます。
「回答を見る」をクリックすると……

http://1e14e14a1c3f2fc208bd1ed8f7b94289.gazou.me/large.png

知の資産に一発アクセス!

全文検索はpg_trgmで適当に作ったもので、短いクエリのパフォーマンスが最悪なのでなので、どちらかというとGoogle様に期待しています。

それと2ちゃんねるのレスを文に分割するライブラリを作った

さて、全自動2chまとめブログの話はこれくらいにして、技術的な話。

質問文の抽出はいろいろがんばっているのですが、正直なところ「?が含まれるかどうか??」のほうが精度がいいのでは???ってレベルです。
そのため質問スレ以外はゴミを大量に作りそうで処理してないのですが、今後精度がよくなれば持っている過去ログ全てから質問のみを抽出するといったこともやっていきたいと思っています。

で、少しはがんばった結果、ちょっと便利なライブラリができたのでここで紹介だけしておきます。

2ちゃんねるのレスは、たとえば、

wryebashについて調べていたら、「MODのインスコとアンインスコでゴミを残す」
というのが出てきたのでそれについて調べてみたのですが
他のサイトで同じようなバグは報告されていませんでした
実際に使っている方は問題なく使えていますか?
また、他のバグや不満な点などを教えてもらえると有りがたいです。
NMMから乗り換えようと思っているのでお願いします。

「氷の上の血」のクエストが発生しないんで困ってるんだけど
なんとか、コンソールとかで強制的に発生できないかな?
wiki見る限り、経過日数が250日経過してるとアウトっぽいんで
今のデータだとほぼ詰みっぽいんで、家が買えない・・・・

古びたお守り:龍の護石から回避距離6達人10のお守りが出たのですが
風化したお守りからは同等もしくはそれ以上の数値を持つお守りが出ることはあるのでしょうか
それとも古おまのみにしか出ないお守りなのでしょうか
誰か持っている人いたら回答よろしくお願いします
風おまを狙って凍土へ行くか古おま、光おまも狙って火山へ行くか…
早く下山したい…

のような「。」があったりなかったり、「。」の代わりに改行が使われていたり、しかも「。」とは関係なく文字列の幅を調節するために改行していたりで、どこまでがひとつの文なのか分からなくなっています。
文章をbag of wordsで処理する分には問題ならないと思いますけど、係り受け解析までしたいときに文の区切りが分からないとうまく処理できないとか、そこまでしなくても、たとえば疑問文かどうかは文末を見れば大体分かりますが、文末がどこか分からないと見ることができません。
おい、困ったぞ。
そこで、これをこういった文字列を自動で文に区切るライブラリを作りました。(Rubyですが、MeCabがあるところならきっと簡単に移植できます)

句読点のない文字列を文単位に区切る — Gist

こんな感じになります。(別途MeCab-Rubyが必要です)

require 'zch_sentence'
res1 = <<T
wryebashについて調べていたら、「MODのインスコとアンインスコでゴミを残す」
というのが出てきたのでそれについて調べてみたのですが
他のサイトで同じようなバグは報告されていませんでした
実際に使っている方は問題なく使えていますか?
また、他のバグや不満な点などを教えてもらえると有りがたいです。
NMMから乗り換えようと思っているのでお願いします。
T
res2 = <<T
「氷の上の血」のクエストが発生しないんで困ってるんだけど
なんとか、コンソールとかで強制的に発生できないかな?
wiki見る限り、経過日数が250日経過してるとアウトっぽいんで
今のデータだとほぼ詰みっぽいんで、家が買えない・・・・ 
T
res3 = <<T
古びたお守り:龍の護石から回避距離6達人10のお守りが出たのですが
風化したお守りからは同等もしくはそれ以上の数値を持つお守りが出ることはあるのでしょうか
それとも古おまのみにしか出ないお守りなのでしょうか
誰か持っている人いたら回答よろしくお願いします
風おまを狙って凍土へ行くか古おま、光おまも狙って火山へ行くか…
早く下山したい…
T
puts "---"
puts ZchSentence.emend(res1)
puts "---"
puts ZchSentence.emend(res2)
puts "---"
puts ZchSentence.emend(res3)

出力

    • -

wryebashについて調べていたら、「MODのインスコとアンインスコでゴミを残す」というのが出てきたのでそれについて調べてみたのですが、他のサイトで同じようなバグは報告されていませんでした。
実際に使っている方は問題なく使えていますか?
また、他のバグや不満な点などを教えてもらえると有りがたいです。
NMMから乗り換えようと思っているのでお願いします。

    • -

「氷の上の血」のクエストが発生しないんで困ってるんだけど、なんとか、コンソールとかで強制的に発生できないかな?
wiki見る限り、経過日数が250日経過してるとアウトっぽいんで、今のデータだとほぼ詰みっぽいんで、家が買えない・・・・

    • -

古びたお守り:龍の護石から回避距離6達人10のお守りが出たのですが、風化したお守りからは同等もしくはそれ以上の数値を持つお守りが出ることはあるのでしょうか。
それとも古おまのみにしか出ないお守りなのでしょうか。
誰か持っている人いたら回答よろしくお願いします。
風おまを狙って凍土へ行くか古おま、光おまも狙って火山へ行くか…
早く下山したい…

適切に「。」や「、」を補完し、1行1文にして返してくれます。便利。

アルゴリズムとしては、まず「改行の位置は文の区切りまたは文節の区切りである」という仮定を置いた上で、

  • 「。」や「?」「!」などで終わっていたら文の区切りでしょう
  • 「、」で終わっていたら文の区切りではないでしょう
  • 行がアスキーコードの範囲のみで構成されているなら、きっとそれはURLであり1行でひとつの単位でしょう
  • 連続して改行されていたら(空行を挟んでいたら)その上はきっと文の終わりでしょう

などなどのルールを適用しようとして、それらのルールで最後まで対応できない場合は、あらかじめ学習している機械学習器で文として区切れるか区切れないかを判定しています。

この分類器はあまり精度がよくないのですが、ルールのおかげであまり使うこともないので、大体はうまくいくという感じのものです。
この部分はいろいろ妥協していたり、もっとうまくやる方法があると思うけど、僕が詳しくないために試せていないとか、もっと自然言語処理やってますみたいな人が作って僕に無料で使わせるべきだ、みたいに思っている部分があるので、今度またどうやって学習しているのかなどの話を詳しく書こうと思っています。

ちなみにZchSentence.splitという関数を使うと機械学習器だけを使って区切ります。
(。や?や改行が含まれないデータで学習しているので、あらかじめ消しておきます)

puts "---"
puts ZchSentence.split(res1.gsub(/[。、?\n]/,"")).join("。\n") + "。\n"
puts "---"
puts ZchSentence.split(res2.gsub(/[。、?\n]/,"")).join("。\n") + "。\n"
puts "---"
puts ZchSentence.split(res3.gsub(/[。、?\n]/,"")).join("。\n") + "。\n"
    • -

wryebashについて調べていたら「MODのインスコとアンインスコでゴミを残す。
」というのが出てきたのでそれについて調べてみたのですが他のサイトで同じようなバグは報告されていませんでした。
実際に使っている方は問題なく使えていますか。
また他のバグや不満な点などを教えてもらえると有りがたいです。
NMMから乗り換えようと思っているのでお願いします。

    • -

「氷の上の血」のクエストが発生しないんで困ってるんだけどなんとかコンソールとかで強制的に発生できないかな。
wiki見る限り経過日数が250日経過してるとアウトっぽいんで今のデータだとほぼ詰み。
っぽいんで家が買えない。
・・・・。

    • -

古びたお守り:龍の護石から回避距離6達人10のお守りが出たのですが風化した。
お守りからは同等もしくはそれ以上の数値を持つお守りが出ることはあるのでしょうか。
それとも古おまのみにしか出ない。
お守りなのでしょうか。
誰か持っている。
人いたら回答よろしくお願いします。
風おまを狙って凍土へ行くか。
古おま光。
おまも狙って火山へ行くか。
…早く下山したい…。

たったこれだけの文字列でも結構間違えていますね。
学習データが青空文庫のテキストなので、そのせいもあるかもしれませんけど、とにかくあまりよくないです。最悪です。

OpenMPとウェブアプリケーションの組み合わせについて

PerlMagick が OpenMP 有効だと高負荷になる件 :: drk7jp

を見て、僕も自分が書いているライブラリで、OpenMPを使いまくっていて、将来的に同じことを言われる不安があるので、うまく付き合うためのノウハウを書いておきます。

まずOpenMPとは何かというと、並列コンピューティング環境を利用するために用いられる標準化された基盤です(Wikipediaより)。これを使うとC言語のfor文を並列化したりが簡単にできるので、マルチコアのCPUを使っている場合に、うまくいけばコア数倍の速度で処理できるようになります。1000msかかってた処理が4コアのCPUだと250msで処理できるようになった! やったね! というわけ。

しかし、gccVC++でのOpenMPの並列化for文はデフォルトではプロセス毎にコアを全部使って処理するので、これをマルチプロセスと組み合わせると、プロセスごとにコア数分のスレッドが生成されるため、システム全体では、OSから見えるプロセッサ数以上のスレッドが生成されて各プロセスが処理を取り合うことになり逆に性能が落ちます。
こういう場合でもうまく動かすために、環境変数OMP_NUM_THREADSというものがあって、この変数にスレッド数を設定することでOpenMPが生成するデフォルトのスレッド数を変更できます。なので、

同時に立ち上がるプロセス数 * OMP_NUM_THREADS = OSから見えるプロセッサ数(普通はCPUのコア数)

とすれば最高の性能が出ると思います。設定方法はサーバーソフトウェアによって違うので書きませんが、プロセス数の制限と環境変数を設定する方法は大体あると思います。(またこれは単純な数値計算だけの場合で、長いI/Oが入る一般的な処理を含む場合はOSから見えるプロセッサ数より多いほうが性能が出る場合があります)

これを前提として、目的によって、同時に立ち上がるプロセス数を多く取るか、OMP_NUM_THREADSを多く取るかを選ぶことになります。

たとえば、アクセスは少ないけどすごく重い処理をするので、できるだけ速くレスポンスを返したい場合は、同時に立ち上がるプロセス数を1にしてOMP_NUM_THREADSをCPUのコア数と同じにします。これが、1リクエストについて1番短い時間でレスポンスを返せる設定です。ただ、これだと1リクエストずつ順番に処理することになるので、同時に何人かアクセスする環境では、待ち時間が長い人が出てきます。そういう場合は、同時に立ち上がるプロセス数を増やして、その分OMP_NUM_THREADSを減らします。プロセス数が2の場合は、OMP_NUM_THREADSが1/2になり、同時に2つのリクエストを処理できるようになる代わりに、1リクエストあたりにかかる時間が2倍になる可能性があります。OMP_NUM_THREADS=1がOpenMPが無効なのと同じ状態です(ただ、並列化の処理を分けて書いている場合はオーバーヘッドがあるので、ずっと1にする気なら無効にしたほうがいいです)。

ウェブはたくさんの人がアクセスするものですから、ほとんどの場合は無効でいいと思いますが、スーパークソ重い処理をするので、できるだけ速くレスポンスを返したくて、プロセス数でなくハードウェアの台数で並列化できる場合は、OpenMPを有効にしてプロセス数少なめでスレッド数と台数を増やしたほうが速く処理できてうれしいと思います。

ちょっと面倒ですけど、ただ飯の時代は終わった。

追記

OpenMP3.0だとOMP_THREAD_LIMITという環境変数があるらしく、使える場合はこっちを使ったほうが適切です。OMP_NUM_THREADSはプログラム中でスレッド数を指定している場合はそっちの優先度が高いので制限できない。
あと自分のライブラリだとなぜか環境変数の設定が効かなくて、oreore_procs()で並列数を決めるようにして、この中で自分で環境変数を読んだりしているので、なにか別の問題があるのかもしれないです。

CaboCha(バッド)ノウハウ

前回の 日本語係り受け解析器 CaboCha Ruby 拡張の基本的な使い方とちょっとした応用 - デーで書くのを忘れてて気になるので書いておきます。

CaboCha::Parserをたくさん作らない

CaboCha::Parserはメモリの使用量がすごいので、ひとつだけ作るようにします。
ari3_botを作った当初、CaboCha::Parser.newしまくっていて、さくらインターネットの共有サーバーはメモリ使用量が多いとkillされるので、killされまくっていて、botの状態をデシリアライズする前にkillされてデータが破滅するなどしていました。
Singletonにするなどグローバルにひとつ置いたほうがいいです。

助詞が省略された文を無理やり解析するために「、」を挿入する

Twitterでは、

  • 学校行く (学校へ行く)
  • なに食べる? (なにを食べる?)

のように助詞が省略された文が多くて、こういう文をCaboChaで解析すると「学校行く」「なに食べる?」でひとつのチャンクになってしまいます。(ならないこともある。学習データによるのだと思う。)

学校行く
学校行く
EOS
* 0 -1D 1/1 0.000000
学校    名詞,一般,*,*,*,*,学校,ガッコウ,ガッコー,,      O
行く    動詞,自立,*,*,五段・カ行促音便,基本形,行く,イク,イク,いく/行く, O
EOS
なに食べる?
なに食べる?
EOS
* 0 -1D 1/1 0.000000
なに    名詞,代名詞,一般,*,*,*,なに,ナニ,ナニ,, O
食べる  動詞,自立,*,*,一段,基本形,食べる,タベル,タベル,たべる/食べる/食る,      O
?      記号,一般,*,*,*,*,?,?,?,,    O
EOS

係り受け解析の前処理で省略された助詞をうまく補完できればいいですが、結構難しい問題なので、ari3_botでは

  • 名詞と動詞が並んでいた場合に間に「、」を挿入する

という超テキトウな前処理を入れています。逆におかしくなることもありますが、大体はよくなります。

学校、行く
学校、-D
    行く
EOS
* 0 1D 0/0 0.000000
学校    名詞,一般,*,*,*,*,学校,ガッコウ,ガッコー,,      O
、      記号,読点,*,*,*,*,、,、,、,,    O
* 1 -1D 0/0 0.000000
行く    動詞,自立,*,*,五段・カ行促音便,基本形,行く,イク,イク,いく/行く, O
EOS
なに、食べる?
  なに、-D
  食べる?
EOS
* 0 1D 0/0 0.000000
なに    名詞,代名詞,一般,*,*,*,なに,ナニ,ナニ,, O
、      記号,読点,*,*,*,*,、,、,、,,    O
* 1 -1D 0/0 0.000000
食べる  動詞,自立,*,*,一段,基本形,食べる,タベル,タベル,たべる/食べる/食る,      O
?      記号,一般,*,*,*,*,?,?,?,,    O
EOS

助詞が省略されない場合と大体は同じ結果になるので、プログラムとしては扱いやすくなります。

日本語係り受け解析器 CaboCha Ruby 拡張の基本的な使い方とちょっとした応用

ari3_botの会話エンジンで使っている自然言語の処理の中から他の方にも有益そうなところだけ書いておこうと思います。
このエンジン自体はアドホックにヒーリスティクスな処理を追加しまくっていて、とても説明しにくいのですが、基本的な部分だけ抜き出して……まとめて……解説します。全部実装できるほど詳細な解説はできないので、取っ掛かりになる程度です。
ちなみにCaboChaのAPIはきちんとしたドキュメントがない?ようで、僕はソースコードを自分で読んで得た知識から解説を行っています。また日本語文法関する知識もari3_botを作るためだけに得たものであり、あやしいので、あまり信じすぎないようにしてください。

もくじです。

  • ari3_botの会話エンジンとは
  • CaboChaの基本的な使い方
  • CaboChaで主語と述語っぽいところを抜き出す
  • 述語を活用してみる
  • その他のいろいろなヒント
  • まとめ

ari3_botの会話エンジンとは

@ari3_botのエンジンです。
ari3_botは、仮想世界で暮らす有限状態マシンのペット『アリさん』に餌をやったりすることで精神を安定させるプログラムです。
オマケとして簡単な会話ができるようになっています。

エンジンの最初の構想としては、

  • 日本語文章の構造を解析してPrologの式に変換する
  • 事実(fact)であれば記録する
  • 質問(query)であれば推論を実行し結果から自然な日本語を生成して返す

という80年代っぽい人工知能を作ろうと思ったのですが、実装は結構テキトウであやしいものになっています。

こんな会話ができます。


@ari3_bot こんにちは
@ultraistter こんにちは
@ari3_bot いい天気ですね
- yes_no: 天気(アリさん, いい), negative => false
@ultraistter いい天気。
@ari3_bot 暑いですね
- yes_no: 暑い(アリさん, nil), negative => false
@ultraistter 暑くない。
@ari3_bot ソクラテスって知ってますか
- yes_no: 知る(ソクラテス, nil), negative => false
@ultraistter ソクラテスは知るよ。
@ari3_bot ソクラテスは誰
- query: ソクラテス(誰, nil), negative => false type => 0
@ultraistter 誰が..ソクラテス..。アリさん、わからない。
@ari3_bot 僕がソクラテスです
- fact: ソクラテス(ultraistter, nil), negative => false
@ultraistter が..ソクラテス..。メモした。
@ari3_bot ソクラテスは人間です
- fact: 人間(ソクラテス, nil), negative => false
@ultraistter ソクラテスは..人間..。アリさん、信じる。
@ari3_bot 人間は死ぬ?
- yes_no: 死ぬ(人間, nil), negative => false
@ultraistter 死なない。
@ari3_bot 人間は死にます
- fact: 死ぬ(人間, nil), negative => false
@ultraistter 人間は..死ぬ..。アリさん、おぼえた。
@ari3_bot ソクラテスは死ぬ?
- yes_no: 死ぬ(ソクラテス, nil), negative => false
@ultraistter そう、ソクラテスは人間だから死ぬ。
@ari3_bot 僕は死ぬ?
- yes_no: 死ぬ(ultraistter, nil), negative => false
@ultraistter は死なないよ。
@ari3_bot 僕は人間ですよ
- fact: 人間(ultraistter, nil), negative => false
@ultraistter は..人間..。メモした。
@ari3_bot 僕は死なない?
- yes_no: 死ぬ(ultraistter, nil), negative => true
@ultraistter 死ぬ。
@ari3_bot 僕は誰?
- query: ultraistter(誰, nil), negative => false type => 0
@ultraistter は、ソクラテスと人間。
@ari3_bot 人間はどうなる
- query: どうするの(人間, nil), negative => false type => 1
@ultraistter 死ぬ。

僕→ソクラテス→人間→死ぬとリンクしていますが、アリなので、再帰的に問い合わせてゴールに繋がるか判定できるほど高度な知能は持っていません。途中でやめてしまいます。アリなので、アリなので……。また「いい天気ですね」と「ソクラテスって知ってますか」にYESを返しているのは、問い合わせに対する知識を持っていない場合に質問文の評価分析を行って、相手にとってポジティブな答えを返しているからです。たとえば「私ってかわいくないよね」には「かわいい(NO)」と返し、「僕って高速ですか?」には「高速(YES)」と返すなど気を使った返答を行います。

ari3_botでは


述語(主語, 目的語や補語)

という形の式を使っていて、この式に対して、

  • YES/NOを設定(事実、規則)
  • YES/NOを問い合わせ(YES/NOの質問)
  • パラメーターのどれか、たとえば主語を指定せずにどんな主語ならYESを返すか問い合わせ(聞き出す文)

という操作が行えるようになっています。
たとえば、「鳥は空を飛ぶ」という文が入力されると


飛ぶ(鳥, 空)

という式に変換され、これがYESを返すように内部のデータが調節されます。
「鳥は空を飛びますか?」という文が入力されると

飛ぶ(鳥, 空)

に変換された後に評価してYES/NOを受け取り、結果に従って日本語を生成(飛ぶ/飛ばない)して返します。未定の場合はNOになりますが、YESと返したほうがポジティブならYESと返します。

また「空を飛ぶのはなんですか」だと


飛ぶ(X, 空)

のような式に変換され、これが満たされるXの一覧を受け取り、結果が空でなければ「空を飛ぶのはX_1やX_2」などと返します。

入力された文から式の形に変換するためには、

  • 文が平叙文か疑問文かを判定する
  • 文を解析して主語、述語、目的語などを抜き出す

という処理を行います。

結果から日本語を生成して返すには、

  • 式と結果から述語を活用するなどして自然な日本語に変換する

という処理を行います。

この文を解析して主語、述語、目的語などを抜き出すという部分にCaboChaを使っています。また質問の式と結果から日本語を生成して返すときに述語を否定系へ変換したりと日本語文法に基づいた処理をしています。

他の話題は、http://ari3.jottit.com/にあります。

CaboCha Ruby拡張の基本的な使い方

CaboChaは日本語係り受け解析器です。日本語係り受け解析というのは、「僕はまともな人間です」から「人間です」に係っているのは「僕は」と「まともな」、というような文の構造を解析する構文解析です。どのチャンク(文節)がどのチャンクに係っているか(修飾しているか、説明しているか)という関係を解析して返してくれます。


$ cabocha
太郎はこの本を二郎を見た女性に渡した。
太郎は-----------D
この-D |
本を-------D
二郎を-D |
見た-D |
女性に-D
渡した。
EOS

次のような関係があることが分かります。

太郎は → 渡した。
この → 本を
本を → 見た
二郎を → 見た
見た → 女性に
女性に → 渡した。

インストール

http://code.google.com/p/cabocha/#%E3%82%A4%E3%83%B3%E3%82%B9%E3%83%88%E3%83%BC%E3%83%AB
をよく読んでください。
Ubuntu環境にCaboChaの最新バージョンとCaboChaのインストールに必要なライブラリをインストールします。カッコ内は僕がインストールしたときのバージョンです。

細かい手順は書きませんが、この順番にインストールします。各./configure --helpを見て、文字コードの指定には注意してください。全体的に--with-charset=utf8 --enable-utf8-onlyにするのがいいと思います。

cabochaまでインストールしたら、


$ sudo ldconfig
$ cabocha
太郎はこの本を二郎を見た女性に渡した。
太郎は-----------D
この-D |
本を-------D
二郎を-D |
見た-D |
女性に-D
渡した。
EOS

としましょう。文字化けせずにそれっぽく表示されればOKです。

CaboCha Ruby拡張のインストール

Ruby拡張をSWIGで生成してインストールします。
注意として、0.60(この文章を書いている時点での最新)には、CaboCha::Token#surfaceというメソッドを何度か呼ぶとメモリでエラーで落ちるバグがあります。この文章では、代わりにCaboCha::Token#normalized_surfaceを使うことで回避します。バグ報告はしているため、0.60よりも後のバージョンでは修正されていると思います。

cabochaのソースディレクトリのswig/に移動してします。

make ruby

とするとruby/にRuby拡張のソースコードが生成されます。swigがない場合は、

sudo apt-get install libffi-dev swig

として入れときましょう。

ruby/に移動して、

ruby extconf.rb
make
sudo make install

でインストールします。

ruby test.rb

が実行されればインストールできています。

係り受け解析して結果を表示してみる

例から見てみましょう。

# -*- coding: utf-8 -*-
require 'CaboCha'

# sentenceを係り受け解析して結果ツリーを得る

sentence = "太郎はこの本を二郎を見た女性に渡した。"
parser = CaboCha::Parser.new;
tree = parser.parse(sentence)

# 全てのチャンクに対して
(0 ... tree.chunk_size).each do |i|
  chunk = tree.chunk(i)
  
  # linkが繋がっていれば
  if (chunk.link >= 0)
    # リンク元と
    chunk_from = (0 ... chunk.token_size).map do |j|
      tree.token(chunk.token_pos + j).normalized_surface
    end.join("-")
    
    # リンク先を
    chunk = tree.chunk(chunk.link)
    chunk_to = (0 ... chunk.token_size).map do |j|
      tree.token(chunk.token_pos + j).normalized_surface
    end.join("-")
    
    # 表示
    puts "#{chunk_from} => #{chunk_to}"
  end
end
太郎-は => 渡し-た-。
この => 本-を
本-を => 渡し-た-。
二-郎-を => 見-た
見-た => 女性-に
女性-に => 渡し-た-。

となります。=>で係り受け関係を表しています。-はトークンの区切りです。
このコードから分かることを箇条書きにすると……

  • CaboCha::Parser#parseで文章を解析してCaboCha::Treeを得る
  • CaboCha::Tree#chunk_sizeでチャンク数、CaboCha::Tree#chunkでCaboCha::Chunkを取得できる
  • CaboCha::Chunk#linkが0以上なら、linkの値が係っているchunkの添え字となっている
  • CaboCha::Chunkの文節は、CaboCha::Chunk#token_posからCaboCha::Chunk#token_size分のCaboCha::Tokenからなる
  • CaboCha::TokenはCaboCha::Tree#tokenで取得できる
  • CaboCha::Token#surface(normalized_surface)でトークンの文字列を取得できる

文章はトークンという単位に分けられて、連続するいくつのかトークンをつなげてチャンクにします。係り受け関係はチャンクの単位で行われます。
normalized_surfaceはsurfaceの結果を正規化したものです。たとえば、半角カナを全角カナにしたり全角数字を半角数字にしたりといった変換が行われます。surfaceが元の文章のままのトークンですが、cabocha-0.60のRuby拡張で呼び出すとエラーで落ちるため今回は使いません。

ここまでがCaboChaモジュールによる係り受け解析の基本的な使い方です。

CaboChaモジュールを拡張して使いやすくしてみる

CaboChaを使ってこれからさまざまな処理をしていくわけですが、標準のモジュールは使いにくいので、モンキーパッチで勝手に拡張していきます。

まず、

  • CaboCha::ChunkからtokenにアクセスできるようにCaboCha::Treeへの参照を持たせてCaboCha::Tree側でセットするようにする
  • CaboCha::Tree#chunksやCaboCha::Chunk#tokensなど配列を返すメソッドを追加する
  • Cabocha::Chunk#next_chunk、Cabocha::Chunk#prev_chunksでチャンクの双方向の関係が取れるようにする
  • Cabocha::Chunk#to_s、Cabocha::Token#to_sを定義して文字列化しやすくする

という拡張を行いました。

# -*- coding: utf-8 -*-
require 'CaboCha'

# CaboChaモジュールを拡張
module CaboCha
  class Token
    def to_s
      @to_s ||=
      if ("".respond_to?("force_encoding"))
        # ruby 1.9
        normalized_surface.force_encoding("utf-8")
      else
        normalized_surface # 本当はsurface        
      end
    end
  end
  class Chunk
    attr_accessor :tree
    def tokens
      @tokens ||= (0 ... token_size).map{|i| tree.token(token_pos + i) }
    end
    def next_chunk
      @next_chunk ||= (link >= 0) ? tree.chunk(link) : nil
    end
    def prev_chunks
      @prev_chunks ||= tree.chunks.select{|chunk| chunk.link == self_index }
    end
    def to_s
      @to_s ||= tokens.map{|t| t.to_s }.join
    end
    def self_index
      @self_index ||= tree.chunks.reduce([nil, 0]) do |argv, chunk| 
        if (chunk.token_pos == self.token_pos)
          argv[0] = argv[1]
        else
          argv[1] += 1
        end
        argv
      end.shift
    end
  end
  class Tree
    alias :chunk_org :chunk
    def chunk(i)
      if (@chunks)
        @chunks[i]
      else
        chunk = chunk_org(i)
        chunk.tree = self
        chunk
      end
    end
    def chunks
      @chunks ||= (0 ... chunk_size).map {|i| chunk(i)}
    end
  end
end

# sentenceを係り受け解析して結果ツリーを得る
sentence = "太郎はこの本を二郎を見た女性に渡した。"

parser = CaboCha::Parser.new;
tree = parser.parse(sentence)

# 全てのチャンクに対して
tree.chunks.each do |chunk|
  # 係っているチャンクがあれば
  if (chunk.next_chunk)
    # 表示
    puts "#{chunk.prev_chunks.inspect} => #{chunk} => #{chunk.next_chunk}"
  end
end
[] => 太郎は => 渡した。
[] => この => 本を
[この] => 本を => 渡した。
[] => 二郎を => 見た
[二郎を] => 見た => 女性に
[見た] => 女性に => 渡した。

最初の例と比べてかなり簡単に書けるようになったと思います。
説明が楽なので、あとの例でも同じようにCaboCha自体を拡張していきます。

主語と述語っぽいところを抜き出す

CaboChaを使って文章から主語と述語っぽいところを抜き出してみます。
ari3_botで「(主語は).. (述語)..。アリさんおぼえた」となる部分です。ari3_botは主語がない場合に補間したり、目的語なども扱っているので、もう少し複雑になりますが、ここでは主語と述語があるものだとしてそこだけ抜き出してみます。

手順としては、

  • ルールベースによる抽出のルールを決める
  • CaboChaの解析結果からルールに基づいて抽出する

とします。

まずどういった文章から、どの部分を取り出したいのか、適当な例を列挙してみます。
たとえば、

ソクラテスは人間である。 (ソクラテス => 人間)
福沢諭吉は1万円札に出てる人間です。(福沢諭吉 => 人間)
僕も普通の人間。(僕 => 人間)
鳥が空を飛んでいる。(鳥 => 飛ぶ)
馬ってたぶんうまい。(馬 => うまい)
飛ぶのは飛行機です(飛ぶの => 飛行機)
かわいいは正義(かわいい => 正義)
お星様はとてもまぶしい (お星様 => まぶしい)
拙者は時々切腹するでござる (拙者 => 切腹する)

ようなものです。
これをcabocha -f2コマンドに渡してどういった結果になるか試して見ます。

$ cabocha -f2
かわいいは正義
           かわいいは-D
  <PERSON>正義</PERSON>
EOS
* 0 1D 0/1 0.000000
かわいい        形容詞,自立,*,*,形容詞・イ段,基本形,かわいい,カワイイ,カワイイ,かわいい/可愛い, O
は      助詞,係助詞,*,*,*,*,は,ハ,ワ,,  O
* 1 -1D 0/0 0.000000
正義    名詞,固有名詞,人名,名,*,*,正義,マサヨシ,マサヨシ,,      B-PERSON
EOS
鳥が空を飛んでいる。
        鳥が---D
          空を-D
    飛んでいる。
EOS
* 0 2D 0/1 5.261511
鳥      名詞,一般,*,*,*,*,鳥,トリ,トリ,,        O
が      助詞,格助詞,一般,*,*,*,が,ガ,ガ,,       O
* 1 2D 0/1 0.000000
空      名詞,一般,*,*,*,*,空,ソラ,ソラ,,        O
を      助詞,格助詞,一般,*,*,*,を,ヲ,ヲ,,       O
* 2 -1D 0/2 0.000000
飛ん    動詞,自立,*,*,五段・バ行,連用タ接続,飛ぶ,トン,トン,とん/飛ん,   O
で      助詞,接続助詞,*,*,*,*,で,デ,デ,,        O
いる    動詞,非自立,*,*,一段,基本形,いる,イル,イル,,    O
。      記号,句点,*,*,*,*,。,。,。,,    O
EOS

のような出力になります。チャンクの区切り、チャンクの係り受け関係、各トークンの品詞、マサヨシなどが分かります。ちなみにトークンの横のCSVっぽいものはCaboCha::Token#feature_listで取れます。
例を思いつく限り入力して、できるだけ単純で汎用性のあるルールを考えましょう。

ここでは

  • 主語は述語に係っている。主語が係っているのが述語である。
  • 主語のあるチャンクは「は」「が」「って」「も」のような助詞で終わっている。名詞が多いが動詞+「の」や形容詞(+「の」)のこともある。
  • 述語はどのチャンクにも係らない

というルールで抽出することにします。
プログラム的には、

  • 接続先があってかつ主語っぽいチャンクを抽出
  • 接続先のチャンクを述語とする
  • 述語には接続先がない

となります。

# -*- coding: utf-8 -*-
require 'CaboCha'

if (RUBY_VERSION < "1.9.0")
  $KCODE = 'u'
end

# CaboChaモジュールを拡張
module CaboCha
  class Token
    # 名詞?
    def noun?
      feature_list(0) == '名詞'
    end
    # 名詞接続? (「お星様」の「お」など)
    def meishi_setsuzoku?
      feature_list(0) == '接頭詞' &&
        feature_list(1) == '名詞接続'
    end
    # 動詞?
    def verb?
      feature_list(0) == '動詞'
    end
    # 形容詞?
    def adjective?
      feature_list(0) == '形容詞'
    end
    # サ変接続? (掃除する 洗濯する など)
    def sahen_setsuzoku?
      feature_list(0) == '名詞' &&
        feature_list(1) == 'サ変接続'
    end
    # サ変する?
    def sahen_suru?
      feature_list(4) == 'サ変・スル'
    end
    
    # 基本形へ
    def to_base
      if (feature_list_size > 6 && feature_list(6) != "*")
        feature_list(6)
      else
        to_s
      end
    end
    
    def to_s
      @to_s ||=
      if ("".respond_to?("force_encoding"))
        normalized_surface.force_encoding("utf-8")
      else
        normalized_surface # 本当はsurface        
      end
    end
    
    alias :feature_list_org :feature_list
    def feature_list(i)
      if (@feature_list)
        @feature_list[i]
      else
        if ("".respond_to?("force_encoding"))
          @feature_list ||= (0 ... feature_list_size).map do |j|
            feature = feature_list_org(j)
            feature.force_encoding("utf-8")
          end
          @feature_list[i]
        else
          @feature_list ||= (0 ... feature_list_size).map{|j| feature_list_org(j) }
          @feature_list[i]
        end
      end
    end
  end
  class Chunk
    attr_accessor :tree

    # 動詞?
    def verb?
      tokens[0].verb? || verb_sahen?
    end
    # 名詞サ変接続+スル
    def verb_sahen?
      (tokens.length > 1 &&
       tokens[0].sahen_setsuzoku? && tokens[1].sahen_suru?)
    end
    # 名詞?
    def noun?
      (!verb_sahen? && (tokens[0].noun? || tokens[0].meishi_setsuzoku?))
    end
    # 形容詞?
    def adjective?
      tokens[0].adjective?
    end
    # 主語っぽい?
    def subject?
      (((noun? && %w(は って も が).include?(tokens[-1].to_s)) ||
        (adjective? && %w(は って も が).include?(tokens[-1].to_s)) ||
        (verb? && %w(は って も が).include?(tokens[-1].to_s))))
    end
    # 基本形へ変換
    def to_base
      @to_base ||=
      if (noun?)
        # 連続する名詞、・_や名詞接続をくっつける
        base = ""
        tokens.each do |token|
          if (token.meishi_setsuzoku?)
            base += token.to_base
          elsif (token.noun?)
            base += token.to_base
          elsif (["_",""].include?(token.to_s))
            base += token.to_base
          elsif (base.length > 0)
            break
          end
        end
        base
      elsif (verb_sahen?)
        # 名詞サ変接続 + スル
        tokens[0].to_base + tokens[1].to_base
      elsif (verb?)
        tokens[0].to_base
      elsif (adjective?)
        tokens[0].to_base
      else
        to_s
      end
    end
    
    def tokens
      @tokens ||= (0 ... token_size).map{|i| tree.token(token_pos + i) }
    end
    def next_chunk
      @next_chunk ||= (link >= 0) ? tree.chunk(link) : nil
    end
    def prev_chunks
      @prev_chunks ||= tree.chunks.select{|chunk| chunk.link == self_index }
    end
    def to_s
      @to_s ||= tokens.map{|t| t.to_s }.join
    end
    def self_index
      @self_index ||= tree.chunks.reduce([nil, 0]) do |argv, chunk| 
        if (chunk.token_pos == self.token_pos)
          argv[0] = argv[1]
        else
          argv[1] += 1
        end
        argv
      end.shift
    end
  end
  class Tree
    alias :chunk_org :chunk
    def chunk(i)
      if (@chunks)
        @chunks[i]
      else
        chunk = chunk_org(i)
        chunk.tree = self
        chunk
      end
    end
    def chunks
      @chunks ||= (0 ... chunk_size).map {|i| chunk(i)}
    end
  end
end

# 主語と述語っぽいのを抜き出してみる

sentences = %w(
ソクラテスは人間です。
福沢諭吉は1万円札に出てる人間です。
僕も普通の人間。
鳥が空を飛んでいる。
馬ってたぶんうまい。
飛ぶのは飛行機です。
かわいいは正義。
お星様はとてもまぶしい。
拙者は時々切腹するでござる。
)

parser = CaboCha::Parser.new;
sentences.each do |sentence|
  puts "+ #{sentence}"
  tree = parser.parse(sentence)
  # 全てのチャンクに対して
  tree.chunks.each do |chunk|
    # 接続先があって主語っぽくて接続先の接続先がないチャンクを抽出
    if (chunk.next_chunk && chunk.subject? && chunk.next_chunk.next_chunk.nil?)
      # 主語 => 述語を表示
      puts "-- #{chunk.to_base} => #{chunk.next_chunk.to_base}"
    end
  end
end
+ ソクラテスは人間です。
-- ソクラテス => 人間
+ 福沢諭吉は1万円札に出てる人間です。
-- 福沢諭吉 => 人間
+ 僕も普通の人間。
-- 僕 => 人間
+ 鳥が空を飛んでいる。
-- 鳥 => 飛ぶ
+ 馬ってたぶんうまい。
-- 馬 => うまい
+ 飛ぶのは飛行機です。
-- 飛ぶ => 飛行機
+ かわいいは正義。
-- かわいい => 正義
+ お星様はとてもまぶしい。
-- お星様 => まぶしい
+ 拙者は時々切腹するでござる。
-- 拙者 => 切腹する

注意することは

  • 名詞サ変接続 + スルはひとつの動詞ということにする (掃除する 洗濯する など)
  • 連続する名詞や接頭詞はつなげてひとつの名詞ということにする

としている点です。こうするといろいろ捗ります。
コードはローマ字と英語が混じっててファーと思うかもしれませんが、コーディング上の細かい話は気にせずにいきましょう。

述語を活用してみる

抽出した主語と述語から自然な文章が意のままに生成できると便利です。
たとえば、

  • 否定文にする
  • 疑問文にする
  • 命令文にする

などがあります。他いろいろ。

これらは述部を変形することで可能です。
変形の方法として、IPA辞書を使うこともできますが、ここでは単純なルールと動詞の場合は活用表を使うことにしてみます。
CaboChaとはあまり関係ありませんが、トークンの品詞などが取れているのでそれをヒントに独自実装を行います。

否定する

まず例として否定する場合を考えてみます。
前章と同様に、どういった文章をどのように変換したいか、適当な例を列挙してみます。

+ 太郎はこの本を二郎を見た女性に渡した。
-- 太郎は渡さない。
+ ソクラテスは人間です。
-- ソクラテスは人間じゃない。
+ ネコはかわいい。
-- ネコはかわいくない。
+ イヌはこわい。
-- イヌはこわくない。
+ 鳥って飛ぶらしい。
-- 鳥は飛ばない。
+ おじいさんはいつものように山へ芝刈りに行きました。
-- おじいさんは行かない。
+ 僕は死にます。
-- 僕は死なない。
+ 拙者が切腹します。
-- 拙者は切腹しない。
+ 君がご飯を食べます。
-- 君は食べない。
+ 魚は泳ぐ。
-- 魚は泳がない。
+ 台風が来る。
-- 台風は来ない。
+ 金がある。
-- 金はない。
+ 金がない。
-- 金はある。
+ かわいいは正義。
-- かわいいのは正義じゃない。
  • 名詞の場合は、「じゃない(ではない、にあらず)」をつける
  • 形容詞の場合は、最後の1文字(「い」)を消して「くない」をつける
  • 動詞の場合は、未然形にして「ない」をつける
  • ない <=> ある のような例外処理もいる

というルールである程度変換できそうです。
難しそうなのは、動詞を未然形にするという部分だと思いますが、トークンの品詞情報と活用表を使えば実装できます。

以下が実装です。動詞の活用を実装すれば、否定以外の変形も簡単にできるので、いくつか適当に変形してみます。
この活用表は、僕がMeCabの辞書にあわせて独自の調査により作成したもので、ちょっとあやしいかもしれません。

# -*- coding: utf-8 -*-
require 'CaboCha'

if (RUBY_VERSION < "1.9.0")
  $KCODE = 'u'
end

# CaboChaモジュールを拡張
module CaboCha
  class Token
    # 名詞?
    def noun?
      feature_list(0) == '名詞'
    end
    # 名詞接続? (「お星様」の「お」など)
    def meishi_setsuzoku?
      feature_list(0) == '接頭詞' &&
        feature_list(1) == '名詞接続'
    end
    # 動詞?
    def verb?
      feature_list(0) == '動詞'
    end
    # 形容詞?
    def adjective?
      feature_list(0) == '形容詞'
    end
    # 名詞サ変接続?
    def sahen_setsuzoku?
      feature_list(0) == '名詞' &&
        feature_list(1) == 'サ変接続'
    end
    # サ変する?
    def sahen_suru?
      feature_list(4) == 'サ変・スル'
    end
    
    # 基本形へ
    def to_base
      if (feature_list_size > 6 && feature_list(6) != "*")
        feature_list(6)
      else
        to_s
      end
    end

    # 動詞の活用
    
    ## 一段命令形の形 (0:食べよ 1:食べれ 2:食べろ)
    V_ICHIDAN_MEIREI = ['', '', '']
    V_ICHIDAN_MEIREI_TYPE = 2
    
    def v_godan?
      @v_godan ||= (feature_list(4) =~ /^五段/)
    end
    def v_rahen?
      @v_rahen ||= (feature_list(4) =~ /^ラ変/)
    end
    def v_ichidan?
      @v_ichidan ||= (feature_list(4) =~ /^一段/)
    end
    def v_yodan?
      @v_yodan ||= (feature_list(4) =~ /^四段/)
    end
    def v_kahen?
      @v_kahen ||= (feature_list(4) =~ /^カ変/)
    end
    def v_sahen?
      @v_sahen ||= (feature_list(4) =~ /^サ変/)
    end
    def v_kami_nidan?
      @v_kami_nidan ||= (feature_list(4) =~ /^上二/)
    end

    ## 活用
    def v_inflect(type)
      if (v_godan?)
        base = to_base
        v_type = feature_list(4)
        case (v_type)
        when /カ行/
          base.gsub(/\w$/, '') + %w(か き く く け け)[type]
        when /ガ行/
          base.gsub(/\w$/, '') + %w(が ぎ ぐ ぐ げ げ)[type]
        when /サ行/
          base.gsub(/\w$/, '') + %w(さ し す す せ せ)[type]
        when /タ行/
          base.gsub(/\w$/, '') + %w(た ち つ つ て て)[type]
        when /ナ行/
          base.gsub(/\w$/, '') + %w(な に ぬ ぬ ね ね)[type]
        when /バ行/
          base.gsub(/\w$/, '') + %w(ば び ぶ ぶ べ べ)[type]
        when /マ行/
          base.gsub(/\w$/, '') + %w(ま み む む め め)[type]
        when /ラ行/
          base.gsub(/\w$/, '') + %w(ら り る る れ れ)[type]
        when /ワ行/
          base.gsub(/\w$/, '') + %w(わ い う う え え)[type]
        else
          raise "unknown feature_list(4) #{feature_list(4)}"
        end
      elsif (v_sahen?)
        v_type = feature_list(4)
        case v_type
        when /スル/
          %w(し し する する すれ しろ)[type]
        when /ズル/
          to_base.gsub(/\w$/, '') + %w(ぜ ず ずる ずる ずれ ぜよ)[type]
        else
          raise "unknown feature_list(4) #{feature_list(4)}"
        end
      elsif (v_ichidan?)
        to_base.gsub(/\w$/, '') +
          ['', '', '', '', '', V_ICHIDAN_MEIREI[V_ICHIDAN_MEIREI_TYPE]][type]
      elsif (v_rahen?)
        to_base.gsub(/\w$/,'') + %w(ら り り る れ れ)[type]
      elsif (v_yodan?)
        v_type = feature_list(4)
        case v_type
        when /カ行/
          to_base.gsub(/\w$/,'') + %w(か き く く け け)[type]
        when /ガ行/
          to_base.gsub(/\w$/,'') + %w(が ぎ ぐ ぐ げ げ)[type]
        when /サ行/
          to_base.gsub(/\w$/,'') + %w(さ し す す せ せ)[type]
        when /タ行/
          to_base.gsub(/\w$/,'') + %w(た ち つ つ て て)[type]
        when /ハ行/
          to_base.gsub(/\w$/,'') + %w(は ひ ふ ふ へ へ)[type]
        when /バ行/
          to_base.gsub(/\w$/,'') + %w(ば び ぶ ぶ べ べ)[type]
        when /マ行/
          to_base.gsub(/\w$/,'') + %w(ま み む む め め)[type]
        when /ラ行/
          to_base.gsub(/\w$/,'') + %w(ら り る る れ れ)[type]
        else
          raise "unknown feature_list(4) #{feature_list(4)}"
        end
      elsif (v_kahen?)
        v_type = feature_list(4)
        if (v_type == 'カ変・クル' || v_type == 'カ変・来ル')
          %w(来 来 来る 来る 来れ 来い)[type]
        end
      elsif (v_kami_nidan?)
        v_type = feature_list(4)
        case v_type
        when /カ行/
          to_base.gsub(/\w$/,'') + %w(き き く くる くれ きよ)[type]
        when /ガ行/
          to_base.gsub(/\w$/,'') + %w(ぎ ぎ ぐ ぐる ぐれ ぎよ)[type]
        when /タ行/
          to_base.gsub(/\w$/,'') + %w(ち ち つ つる つれ ちよ)[type]
        when /ダ行/
          to_base.gsub(/\w$/,'') + %w(ぢ ぢ づ づる づれ ぢよ)[type]
        when /ハ行/
          to_base.gsub(/\w$/,'') + %w(ひ ひ ふ ふる ふれ ひよ)[type]
        when /バ行/
          to_base.gsub(/\w$/,'') + %w(び び ぶ ぶる ぶれ びよ)[type]
        when /マ行/
          to_base.gsub(/\w$/,'') + %w(み み む むる むれ みよ)[type]
        when /ヤ行/
          to_base.gsub(/\w$/,'') + %w(い い ゆ ゆる ゆれ いよ)[type]
        when /ラ行/
          to_base.gsub(/\w$/,'') + %w(り り る るる るれ りよ)[type]
        else
          raise "unknown feature_list(4) #{feature_list(4)}"
        end
      else
        raise "unknown feature_list(4) #{feature_list(4)}"
      end
    end
    
    def to_s
      @to_s ||=
      if ("".respond_to?("force_encoding"))
        normalized_surface.force_encoding("utf-8")
      else
        normalized_surface # 本当はsurface        
      end
    end
    
    alias :feature_list_org :feature_list
    def feature_list(i)
      if (@feature_list)
        @feature_list[i]
      else
        if ("".respond_to?("force_encoding"))
          @feature_list ||= (0 ... feature_list_size).map do |j|
            feature = feature_list_org(j)
            feature.force_encoding("utf-8")
          end
          @feature_list[i]
        else
          @feature_list ||= (0 ... feature_list_size).map{|j| feature_list_org(j) }
          @feature_list[i]
        end
      end
    end
  end
  class Chunk
    attr_accessor :tree

    # 活用
    def v_inflect(type)
      if (verb_sahen?)
        tokens[0].to_base + tokens[1].v_inflect(type)
      else
        tokens[0].v_inflect(type)
      end
    end
    # 未然形
    def to_mizen
      @to_mizen ||= v_inflect(0)
    end
    # 連用形
    def to_renyo
      @to_renyo ||= v_inflect(1)
    end
    # 終止形
    def to_syushi
      @to_syushi ||= v_inflect(2)
    end
    # 連体形
    def to_rentai
      @to_rentai ||= v_inflect(3)
    end
    # 仮定形
    def to_katei
      @to_katei ||= v_inflect(4)
    end
    # 命令形
    def to_meirei
      @to_meirei ||= v_inflect(5)
    end
    # 否定形
    def to_negative
      @to_negative ||=
      if (noun?)
        to_base + 'じゃない'
      elsif (adjective?)
        if (to_base == "ない" || to_base == "無い")
          "ある"
        else
          to_base.gsub(/\w$/,'') + 'くない'
        end
      elsif (verb?)
        if (to_base == 'ある')
          "ない"
        else
          to_mizen + "ない"
        end
      else
        to_base
      end
    end
    def to_noun
      @to_noun ||=
      if (noun?)
        to_base
      elsif (verb?)
        # 飛ぶの, 泳ぐの
        to_base + ''
      elsif (adjective?)
        # かわいいの, 大きいの
        to_base + ''
      else
        to_base
      end
    end
    
    # 動詞?
    def verb?
      tokens[0].verb? || verb_sahen?
    end
    # 名詞サ変接続+スル 動詞 (掃除する 洗濯する など)
    def verb_sahen?
      (tokens.length > 1 &&
       tokens[0].sahen_setsuzoku? && tokens[1].sahen_suru?)
    end
    # 名詞?
    def noun?
      (!verb_sahen? && (tokens[0].noun? || tokens[0].meishi_setsuzoku?))
    end
    # 形容詞?
    def adjective?
      tokens[0].adjective?
    end
    # 主語っぽい?
    def subject?
      (((noun? && %w(は って も が).include?(tokens[-1].to_s)) ||
        (adjective? && %w(は って も が).include?(tokens[-1].to_s)) ||
        (verb? && %w(は って も が).include?(tokens[-1].to_s))))
    end
    # 基本形へ
    def to_base
      @to_base ||=
      if (noun?)
        # 連続する名詞、・_や名詞接続をくっつける
        base = ""
        tokens.each do |token|
          if (token.meishi_setsuzoku?)
            base += token.to_base
          elsif (token.noun?)
            base += token.to_base
          elsif (["_",""].include?(token.to_s))
            base += token.to_base
          elsif (base.length > 0)
            break
          end
        end
        base
      elsif (verb_sahen?)
        tokens[0].to_base + tokens[1].to_base
      elsif (verb?)
        tokens[0].to_base
      elsif (adjective?)
        tokens[0].to_base
      else
        to_s
      end
    end
    
    def tokens
      @tokens ||= (0 ... token_size).map{|i| tree.token(token_pos + i) }
    end
    def next_chunk
      @next_chunk ||= (link >= 0) ? tree.chunk(link) : nil
    end
    def prev_chunks
      @prev_chunks ||= tree.chunks.select{|chunk| chunk.link == self_index }
    end
    def to_s
      @to_s ||= tokens.map{|t| t.to_s }.join
    end
    def self_index
      @self_index ||= tree.chunks.reduce([nil, 0]) do |argv, chunk| 
        if (chunk.token_pos == self.token_pos)
          argv[0] = argv[1]
        else
          argv[1] += 1
        end
        argv
      end.shift
    end
  end
  class Tree
    alias :chunk_org :chunk
    def chunk(i)
      if (@chunks)
        @chunks[i]
      else
        chunk = chunk_org(i)
        chunk.tree = self
        chunk
      end
    end
    def chunks
      @chunks ||= (0 ... chunk_size).map {|i| chunk(i)}
    end
  end
end

# 主語と述語っぽいのを抜き出して、述語をテキトウに変形してみる

sentences = %w(
太郎はこの本を二郎を見た女性に渡した。
ソクラテスは人間です。
ネコはかわいい。
イヌもこわい。
鳥って飛ぶらしい。
おじいさんはいつものように山へ芝刈りに行きました。
僕は死にます。
拙者が切腹します。
君がご飯を食べます。
魚は泳ぐ。
台風が来る。
金がある。
金がない。
かわいいは正義。
)

parser = CaboCha::Parser.new;
sentences.each do |sentence|
  puts "+ #{sentence}"
  tree = parser.parse(sentence.to_s)
  # 全てのチャンクに対して
  tree.chunks.each do |chunk|
    # 主語と述語を抜き出す
    if (chunk.next_chunk && chunk.subject? && chunk.next_chunk.next_chunk.nil?)
      # 主語 => 述語を表示
      puts "-- #{chunk.to_base} => #{chunk.next_chunk.to_base}"
      # 否定
      puts "--- 否定: #{chunk.to_noun}#{chunk.next_chunk.to_negative}よ。"
      
      # テキトウに変換してみます
      if (chunk.next_chunk.verb?)
        puts "--- 命令: #{chunk.to_noun}#{chunk.next_chunk.to_meirei}"
        puts "--- 仮定: #{chunk.to_noun}#{chunk.next_chunk.to_katei}ばよくね?"
        puts "--- したい: #{chunk.to_noun}#{chunk.next_chunk.to_renyo}たい。」"
        puts "--- 敬語: #{chunk.to_noun}#{chunk.next_chunk.to_renyo}ます。"
        puts "--- はじめた: #{chunk.to_noun}#{chunk.next_chunk.to_renyo}はじめた。"
        puts "--- 続けてる: #{chunk.to_noun}#{chunk.next_chunk.to_renyo}続けている。"
        puts "--- ふり: #{chunk.to_noun}#{chunk.next_chunk.to_rentai}ふりをしている。"
      elsif (chunk.next_chunk.noun?)
        puts "--- なった: #{chunk.to_noun}#{chunk.next_chunk.to_base}になった。"
      elsif (chunk.next_chunk.adjective?)
        puts "--- なった: #{chunk.to_noun}#{chunk.next_chunk.to_base.gsub(/い$/,'')}くなった。"
      end
    end
  end
end

結果

+ 太郎はこの本を二郎を見た女性に渡した。
-- 太郎 => 渡す
--- 否定: 太郎は渡さないよ。
--- 命令: 太郎が渡せ!
--- 仮定: 太郎が渡せばよくね?
--- したい: 太郎「渡したい。」
--- 敬語: 太郎が渡します。
--- はじめた: 太郎が渡しはじめた。
--- 続けてる: 太郎が渡し続けている。
--- ふり: 太郎が渡すふりをしている。
+ ソクラテスは人間です。
-- ソクラテス => 人間
--- 否定: ソクラテスは人間じゃないよ。
--- なった: ソクラテスは人間になった。
+ ネコはかわいい。
-- ネコ => かわいい
--- 否定: ネコはかわいくないよ。
--- なった: ネコはかわいくなった。
+ イヌもこわい。
-- イヌ => こわい
--- 否定: イヌはこわくないよ。
--- なった: イヌはこわくなった。
+ 鳥って飛ぶらしい。
-- 鳥 => 飛ぶ
--- 否定: 鳥は飛ばないよ。
--- 命令: 鳥が飛べ!
--- 仮定: 鳥が飛べばよくね?
--- したい: 鳥「飛びたい。」
--- 敬語: 鳥が飛びます。
--- はじめた: 鳥が飛びはじめた。
--- 続けてる: 鳥が飛び続けている。
--- ふり: 鳥が飛ぶふりをしている。
+ おじいさんはいつものように山へ芝刈りに行きました。
-- おじいさん => 行く
--- 否定: おじいさんは行かないよ。
--- 命令: おじいさんが行け!
--- 仮定: おじいさんが行けばよくね?
--- したい: おじいさん「行きたい。」
--- 敬語: おじいさんが行きます。
--- はじめた: おじいさんが行きはじめた。
--- 続けてる: おじいさんが行き続けている。
--- ふり: おじいさんが行くふりをしている。
+ 僕は死にます。
-- 僕 => 死ぬ
--- 否定: 僕は死なないよ。
--- 命令: 僕が死ね!
--- 仮定: 僕が死ねばよくね?
--- したい: 僕「死にたい。」
--- 敬語: 僕が死にます。
--- はじめた: 僕が死にはじめた。
--- 続けてる: 僕が死に続けている。
--- ふり: 僕が死ぬふりをしている。
+ 拙者が切腹します。
-- 拙者 => 切腹する
--- 否定: 拙者は切腹しないよ。
--- 命令: 拙者が切腹しろ!
--- 仮定: 拙者が切腹すればよくね?
--- したい: 拙者「切腹したい。」
--- 敬語: 拙者が切腹します。
--- はじめた: 拙者が切腹しはじめた。
--- 続けてる: 拙者が切腹し続けている。
--- ふり: 拙者が切腹するふりをしている。
+ 君がご飯を食べます。
-- 君 => 食べる
--- 否定: 君は食べないよ。
--- 命令: 君が食べろ!
--- 仮定: 君が食べればよくね?
--- したい: 君「食べたい。」
--- 敬語: 君が食べます。
--- はじめた: 君が食べはじめた。
--- 続けてる: 君が食べ続けている。
--- ふり: 君が食べるふりをしている。
+ 魚は泳ぐ。
-- 魚 => 泳ぐ
--- 否定: 魚は泳がないよ。
--- 命令: 魚が泳げ!
--- 仮定: 魚が泳げばよくね?
--- したい: 魚「泳ぎたい。」
--- 敬語: 魚が泳ぎます。
--- はじめた: 魚が泳ぎはじめた。
--- 続けてる: 魚が泳ぎ続けている。
--- ふり: 魚が泳ぐふりをしている。
+ 台風が来る。
-- 台風 => 来る
--- 否定: 台風は来ないよ。
--- 命令: 台風が来い!
--- 仮定: 台風が来ればよくね?
--- したい: 台風「来たい。」
--- 敬語: 台風が来ます。
--- はじめた: 台風が来はじめた。
--- 続けてる: 台風が来続けている。
--- ふり: 台風が来るふりをしている。
+ 金がある。
-- 金 => ある
--- 否定: 金はないよ。
--- 命令: 金があれ!
--- 仮定: 金があればよくね?
--- したい: 金「ありたい。」
--- 敬語: 金があります。
--- はじめた: 金がありはじめた。
--- 続けてる: 金があり続けている。
--- ふり: 金があるふりをしている。
+ 金がない。
-- 金 => ない
--- 否定: 金はあるよ。
--- なった: 金はなくなった。
+ かわいいは正義。
-- かわいい => 正義
--- 否定: かわいいのは正義じゃないよ。
--- なった: かわいいのは正義になった。

大体できてる気がしますね。ダイタイできればよい。
この活用表だけではできない表現もありますが、日本語は自由度が高いので、がんばれば同じような意味の文がこの活用表の範囲でも生成できることがあります。

その他いろいろなヒント

これまでやったように

  • 例文と得たい結果を思いつくだけ書いてみる
  • それをcabochaに突っ込んで解析結果を得る
  • 解析結果を眺めながら例文を得たい結果に変換するルールを考える

ということをすれば、大体うまくいくルールは大体作れると思います。
難しければ、機械学習するというのもありだと思います。たとえば、疑問文か平叙文か分類する処理は、機械学習のほうがうまくいきそうです。ari3_botはルールベースですけど。

あと基本的なこととして、日本語の文法を知っておくとよいルールを思いつきやすいというのがあります。中学校でやった国文法レベルでも結構役に立ちます。

まとめ

ari3_botのエンジンで使っているCaboChaの基本的な使い方と、ちょっとした応用を解説しました。
中学校で習う国語はとても役に立ちますから、プログラミング言語やOSのシステムコールと同様に勉強しといたほうがいいと思いますけど、完全に忘れてても本1冊読めばとりあえずどうにかなります。この本を買いましょう。

日本語の文法

日本語の文法


僕はari3_botを作ったときにこの本を参考にしました。もうほとんど覚えていませんが、手元にあるとまた読むことができます。

追記

Ruby 1.9だと、.gsub(/\w$/,'') (末尾のひらがなを消したい)が置換されないので、.gsub(/[ぁ-ん]$/,'') 等に……

feezch.infoで使われているあやしい技術を解説します

feezch.infoで使われている一部の技術を解説しようと思います。
Railsやnginxの設定の話は、僕が書くことでもないので、一般的なウェブサービスではあまり使われてなさそうな2ちゃんねるに関連した技術やあやしめ技術をいくつか選んでみました。

主に画像関連です。
クローラー周りも、とてもよく考えられたすばらしい仕組みを持っているのですが、特にあやしくなく本当にすごいので、ここでは省略します。
以下がもくじです。(リンクはつけ方が分からなかったので、ついてないです)

  1. 画像URLのルール集『ImageViewURLReplace.dat』を使って投稿内容から画像URLを抽出しダウンロードする
  2. グロ画像ブラックリスト『NGFiles.txt』でグロ画像をブロックする
  3. ウェブブラウザがリファラを送らないように画像にリンクする
  4. グーグルの新機能『Search by Image』を使ってサムネイルとインターネットのどこかにある元画像をリンクする

feezch.infoとは

まずfeezh.infoとは何かというと、最近僕が作って公開している2ちゃんねるのパートスレをフィードリーダーで読むためのウェブツールです。
とても便利であり、使っていない人はちょっとどうかと思うので、知らない方はfeezch: ヘルプをぜひ読んでみてみてください。

画像URLのルール集『ImageViewURLReplace.dat』を使って投稿内容から画像URLを抽出しダウンロードする

feezch.infoでは、2ちゃんねるの投稿に含まれる画像URLを抽出して、各画像からサムネイル(プレビュー用のとても小さな画像)を作成しています。
投稿から画像のURLを抽出する単純な方法として、

  • 拡張子が画像っぽいURLを正規表現で抽出する

といった方法があります。しかし、画像アップローダーの中には拡張子だけ見ても画像と分からないURLを生成するものが多くあり、たとえば、Twitterなどでよく見かけるtwitpicやyfrogも意味としては画像を指したリンクですが、単純な画像拡張子の正規表現では抽出できない形式をしています。こういう場合には、単純な正規表現による抽出では一部のURLを落としてしまいます。

また、

  • Cookieリファラを与えないと画像が取得できないことがある(取得できないとサムネイルが作成できない)
  • スクレイピングをしないと画像のURLが分からないことがある

ということもあり、URL抽出だけでなく、ダウンロード時の設定も必要になってきます。
さらに、JaneStyleなどの2ちゃんねるブラウザに慣れていると、

  • 動画のURLやアマゾンの商品URLなどからもサムネイルを表示して欲しい

などといった要求もでてきます。JaneStyleでは設定を行えばこういったことができているからです。

ImageViewURLReplace.dat

2ちゃんねるブラウザではできている――ではどうやっているのかというと、ImageViewURLReplace.datというファイルにしたがって画像のURLの抽出、ダウンロードを行っています。
これには、

  1. 画像を指すURLの正規表現
  2. 抜き出したURLを画像URLに変換するルール(正規表現による置換またはスクレイピングによる抽出)
  3. 画像URLにアクセスするときのルール(CookieRefererの指定)
  4. アクセスしてはいけないURL(グロ, おまんちんなど)

が定義されています。
このファイルは、2ちゃんねる2ちゃんねるブラウザのコミュニティによって編集され、まとめたものがインターネット上で公開されています。
歴史的な経緯はよく知りませんが、たぶんオープンソース2ちゃんねるブラウザ OpenJane に実装されて、その派生ブラウザであるJaneViewやJaneStyleなどで拡張されていったのではないかと思います。注意することは、ImageViewURLReplace.datと呼ばれているファイルにも、ブラウザの実装によってフォーマットや機能に違いがあるということです。僕の確認している範囲では、4種類におよぶ1行コメントの書き方があります。

JaneStyleでの仕様は、ImageViewURLReplace.dat [2ちゃんねる専用ブラウザ「Jane Style」オンラインヘルプ]で確認できます。JaneStyleの仕様(と思われる)でのImageViewURLReplace.datは、StreamingPlayer3で配布されています。
OpenJaneでの仕様は、ImageViewURLReplace.datで確認できます。OpenJane(実際にはその派生のJaneView)での仕様のImageViewURLReplace.datは、scriptstuffで配布されています。

詳細は、上記URLを見てほしいのですが、処理内容がイメージできるように簡単に説明します。

ファイルの各行は

URLの正規表現\t画像URLの置換パターン\tRefererの置換パターン\t拡張1\t拡張2...

(\tはタブ)
となっています。
たとえば、youtubeのサムネイルを抜き出すルールは

http://(?:[^/]+\.)?(youtube\.com/)(?:(?:verify_age\?next_url=/)?(?:w/)?(?:watch)?/?(?:\?|%3F|#).*v(?:=|%3D)|(?:.+?/)?v/|.+?[?&]video_id=)([\-\w]{11})	http://img.$1vi/$2/hqdefault.jpg

となっています。これにhttp://www.youtube.com/watch?v=b3d2o6caTMAを入力すると、

  1. URLが最初の正規表現にマッチ
  2. $& => http://www.youtube.com/watch?v=b3d2o6caTMA, $1 => youtube.com/, $2 => b3d2o6caTMA, などと変数がセットされる
  3. 2番目の引数 http://img.$1vi/$2/hqdefault.jpg を置換 => http://img.youtube.com/vi/b3d2o6caTMA/hqdefault.jpg
  4. 画像取得

といった処理を行います。3番目の引数はないので、Refererは必要ありません。3番目がある場合は、2番目と同じルールで変換を行った後、Refererにセットして、画像URLにアクセスします。

4番目の引数に$EXTRACTが指定してある場合は、RefererのURLをスクレイピングして画像URLを得ます。たとえば、twitpicのルールは、

http://twitpic\.com/(?!(?:photos|tag|show/large)/)(\w+)	$EXTRACT	$&/full#/$1	$EXTRACT	src="(http://[^"]+)

となっています。

これにhttp://twitpic.com/4fdrc5を入力すると、

  1. URLが最初の正規表現にマッチ
  2. $& => http://twitpic.com/4fdrc5, $1 => 4fdrc5などと変数がセットされる
  3. 2番目の引数 $&/full#/$1 を置換 => http://twitpic.com/4fdrc5/full#/4fdrc5
  4. 5番目の引数 src="(http://[^"]+) を置換 => src="(http://[^"]+) (変数がないのでそのまま)
  5. http://twitpic.com/4fdrc5/full#/4fdrc5 にアクセスしてCookieとコンテンツを取得
  6. コンテンツから5番目の正規表現 src="(http://[^"]+) でマッチング
  7. $EXTRACT => http://s3.amazonaws.com/twitpic/photos/full/267700901.jpg?AWSAccessKeyId=AKIAJF3XCCKACR3QDMOA&Expires=1318099070&Signature=lVFULcrM4BUS0WKKRYtj6zAv5Z4%3Dなどが変数にセットされる
  8. 2番目の引数 $EXTRACT を置換 => http://s3.amazonaws.com/twitpic/photos/full/267700901.jpg?AWSAccessKeyId=AKIAJF3XCCKACR3QDMOA&Expires=1318099070&Signature=lVFULcrM4BUS0WKKRYtj6zAv5Z4%3D
  9. スクレイピング時に受け取ったCookieと置換したRefererをセットして画像を取得

といった動きになります。置換パターンの変数$EXTRACT,$EXTRACT1〜9は、5番目の正規表現でマッチングした際の$1,$1〜$9に対応しています。

さて……
レガシーっぽくてなんだか面倒くさそうですが、どうやら、この処理系を書けばやりたいことが実現できそうです。

feezch.infoでは、ImageViewURLReplace.datのRubyライブラリを書いて、クローラーに組み込んでいます。基本的には、JaneStyle系の仕様にあわせていますが、せっかく配布されているのでJaneView系のファイルも読めるようにしています。完全に対応しているわけではないので、注意は必要ですが、上記のURLで配布されている両ファイルは適当にマージしてそのまま使うことができます。

Zch::ImageURL

Zchというのは、feezch.infoのために僕が作った超高性能なヒミツの2ちゃんねるRubyライブラリです。
ImageViewURLReplace.datの処理系もこの一部として実装されていて、本当はヒミツなのですが、特別にこの部分だけgithubにて配布することにしました。好きにしてよいです。

ImageViewURLRepalce.datのRuby実装 — Gist

ImageViewURLReplace.datの読み込み

デフォルトでは、ライブラリのファイルと同じディレクトリにある"ImageViewURLReplace.dat"というファイルを読みます。
変更したい場合は、はじめに

Zch::ImageURL.load("./ImageViewURLReplace.dat")

と呼べば、指定されたファイルを使うようになります。

画像URLの抽出

Zch::ImageURL.extractで抽出できます。

content = <<CONTENT
すごい
tp://www.youtube.com/watch?v=b3d2o6caTMA

すごい
ttp://twitpic.com/4fdrc5

CONTENT

Zch::ImageURL.extract(content).each do |m|
  pp({
       :match_url => m.match_url,
       :url => m.url,
       :referer => m.referer,
       :image_url => m.image_url,
       :ng => m.ng?
     })
end
{:match_url=>"tp://www.youtube.com/watch?v=b3d2o6caTMA",
 :url=>"http://www.youtube.com/watch?v=b3d2o6caTMA",
 :referer=>nil,
 :image_url=>"http://img.youtube.com/vi/b3d2o6caTMA/hqdefault.jpg",
 :ng=>false}
{:match_url=>"ttp://twitpic.com/4fdrc5",
 :url=>"http://twitpic.com/4fdrc5",
 :referer=>"http://twitpic.com/4fdrc5/full#/4fdrc5",
 :image_url=>
  "http://s3.amazonaws.com/twitpic/photos/full/267700901.jpg?AWSAccessKeyId=AKIAJF3XCCKACR3QDMOA&Expires=1318100850&Signature=zVcPfY3cy%2B9z5Zet8XUumlSi%2Fg0%3D",
 :ng=>false}

となります。match_urlにマッチしたURLそのまま、urlには、正規化されたURLが入っています。image_urlは$EXTRACTモードの場合は、ネットワークにアクセスするので注意が必要です。事前にネットワークにアクセスするか(ブロックしたりネットワークのエラーで例外が発生したりするか)チャックしたい場合は、network_required?でチェックできます。またng?がtrueの場合、NG指定のURLです。

画像データの取得

Zch::ImageURL.extractで抽出したあと、fetchメソッドで取得できます。

content = <<CONTENT
すごい
tp://www.youtube.com/watch?v=b3d2o6caTMA

すごい
ttp://twitpic.com/4fdrc5

CONTENT

Zch::ImageURL.extract(content).each do |m|
  content, header = m.fetch
  if (content)
    p m.url
    p "content-type: #{header['content-type']}, size: #{content.length}"
  end
end
"http://www.youtube.com/watch?v=b3d2o6caTMA"
"content-type: image/jpeg, size: 10926"
"http://twitpic.com/4fdrc5"
"content-type: image/jpeg, size: 203497"

となります。取得した内容とレスポンスヘッダーを返します。
fetchで使うメソッドは、デフォルトでは、

Zch::ImageURL.fetcher = Proc.new do |url, http_options|
  content = nil
  headers = {}
  if (RUBY_VERSION >= "1.9.0")
    OpenURI.open_uri(url, "r:binary", http_options) do |f|
      f.meta.each do |k,v|
        headers[k] = v
      end
      content = f.read
    end
  else
    OpenURI.open_uri(url, http_options) do |f|
      f.meta.each do |k,v|
        headers[k] = v
      end
      content = f.read
    end
  end
  [content, headers]
end

となっています。インターフェースをあわせれば、置き換えることができます。
テキトウに使う分にはこのままでいいと思いますが、ウェブサービスなどで使う場合には置き換えたほうがいいと思います。
feezch.infoでは、クローラー用のfetchメソッドで置き換えて使用しています。
たとえば、

  1. 接続タイムアウトの設定
  2. 受信タイムアウトの設定
  3. 最大受信サイズの設定
  4. ローカルネットワークへのアクセス拒否
  5. タイムアウトするホストへ連続アクセスしないための制限
  6. これらのエラーに対して適切な例外を投げる

などの機能を追加しています。

グロ画像のブラックリスト『NGFiles.txt』によるグロ画像判定

2ちゃんねるでは、よく嫌がらせとしてグロ画像(首がもげたり顔が爆発したりしている死体などの画像)が投稿されています。僕はできるだけグロ画像を見たくないので、サムネイルにする前の段階でグロ画像を判定してブロックする仕組みがあるとうれしいです。

画像認識がある程度分かる自分としては、グロ画像を機械学習して云々と考え始めてしまうのですが、グロ画像というのは、全裸の老婆から昆虫までさまざま種類があり、実用レベルの精度を出すのは無理だろと思います(たとえば、後述のNGFiles.txtから抜き出したグロ画像の説明一覧を見てください)。
また、2ちゃんねるに投稿されるグロ画像はほとんどが使いまわしのコピペなので、ハッシュ値の完全一致でもかなりブロックできるだろうという予想があります。

NGFiles.txt

NGFiles.txtとは、2ちゃんねるブラウザJaneStyleが使っているブラックリストです。OpenJane由来のものなのかなど、歴史的なことはよく知りません。
JaneStyleでは、画像を選んで「対象をNGファイルに追加」を選択すると、画像のハッシュ値がNGFiles.txtに追加されます。NGFiles.txtに存在している画像はビューワーで表示されなくなり、グロ画像等を見なくてもよくなるという仕組みです。
このファイルもまた、2ちゃんねるブラウザのコミュニティによって編集され、まとめたものがインターネット上で公開されています。
たとえば、JaneView用ツールまとめ@wiki - NGFiles.txtにあります。

feezch.infoではグロ画像ブロッキングの最初の処理として、このNGFiles.txtを使ったブラックリスト方式でグロ画像の判定を行っています。

NGFiles.txtのフォーマット
009R8F7EL0LGTSMOQOB2HB3VE5=*刃物刺さりまくり惨殺死体|グロ|死体
00C4N25VHBALTFGUN4JF6RD7B4=*頭部が縦に割れてる
00DAMRQA5JM108NQGUKBCDNQQ2=*うんこ

ようになっています。=*が区切り文字に見えますが、=が区切り文字で*は何かの印だと思います(よく分かっていない)。
最初のカラムがハッシュ値で、2番目のカラムがコメントです。
この短いハッシュ値は独自のハッシュアルゴリズムで、MD5をスクランブルして作ります。(ぎゃー)

feezch.infoでのNgFileクラス

ハッシュは独自のものですが、nghash.zipC言語での実装があります。
feezch.infoでは、これをRuby 1.9.2 + Rails 3.0用に移植して、ActiveRecordのModelとして使っています。

コードは、
NGFile.txt用のActiveRecordのModel (Ruby 1.9.2) — Gist
においてあります。

Rails用ですが、MD5からNGFiles用のハッシュ値を得る部分は、適当に切り出せると思います。その部分だけあれば、適当に使うことができます。

rails runner NgFile.pull('./NGFiles_01.txt')
rails runner NgFile.pull('./NGFiles_02.txt')
...

で差分をデータベースに取り込んで、あとは、

md5 = Digest::MD5.hexdigest("画像データ")
if (ngfile = NgFile.find_by_md5(md5))
  # ブロッキングの処理
end

と判定に使っています。

ウェブブラウザがリファラを送らないように画像にリンクする

feezch.infoのフィードをlivedoor ReaderGoogle Readerなどウェブアプリケーション型のフィードリーダーで読んでいる場合、フィードはウェブブラウザ上で表示され、フィードには画像へリンクが張られていることがあります。このような画像へのリンクをクリックしたとき、リファラとしてフィードリーダーのアドレスを送ってしまうと画像を置いているホストからアクセス拒否され、画像を表示できない場合があります。こういう挙動をするホストで有名なところにFC2があります。

feezch.infoでは、この問題を回避するために、ウェブブラウザがリファラを送らないように画像にリンクする2種類のあやしいテクニックを使っています。

Meta refreshよるRefererのクリア

IEやFirefoxでは、

<meta http-equiv="refresh" content="0; url='http://example.com/'" />

のようなMETAタグでジャンプした場合に、Refererが送信されない実装になっています。
ただし、ChromeではRefererが送信されてしまいます。

Data URI scheme + Meta refreshによるRefererのクリア

FirefoxChromeに実装されているData URI schemeを使うと、URIの中にデータを埋め込むことができます。
データの種類としてtext/htmlを指定すると、URIからHTMLページを生成して、そのページをブラウザで開くことができます。
そして、このData URI schemeで生成したHTMLページからのリンクにRefererが送信されない実装になっています。

// ブラウザのアドレスバーへコピペすると確認くんへのリンクを作ります
data:text/html;charset=utf-8,%3Chtml%3E%3Chead%3E%3C%2Fhead%3E%3Cbody%3E%3Ca%20href%3D%22http%3A%2F%2Fwww.ugtop.com%2Fspill.shtml%22%3Elink%3C%2Fa%3E%3C%2Fbody%3E%3C%2Fhtml%3E

この性質を使うと、

  1. JavaScriptURIに埋め込んだMeta refreshを生成
  2. location.hrefに代入しData URIへジャンプ
  3. 埋め込まれたページにあるMeta refreshでさらにジャンプ

という多段のジャンプによってリファラを送らないリダイレクタを作ることができます。

UAによってリダイレクトの方法を分ける

あまりたくさんのブラウザで調べていないし、動かないブラウザも結構ありそうですけど、いまのところ、こんな感じのviewでリダイレクタを作っています。フィードから外部の画像にリンクするときは、jump/noref?url=URLでリンクします。

<!-- noref.html.erb -->
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html lang="ja">
  <head>
    <title>
      redirecting to <%= params[:url] %>..
    </title>
    <% if request.user_agent.include?('MSIE') %>
      <meta http-equiv="refresh" content="0; url='<%= raw encode_uri(params[:url]) %>'" />
    <% else %>
      <script type="text/javascript">
        var html = "<html><head><meta http-equiv=\"refresh\" content=\"0; url='<%= raw encode_uri(params[:url]) %>'\" /></head></html>";
        location.href = "data:text/html;charset=utf-8," + encodeURIComponent(html);
      </script>
    <% end %>
  </head>
  <body>
    redirecting to <%= link_to params[:url], params[:url] %>..
  </body>
</html>

Data URI Schemeが使えそうにないIEだったらMeta refresh、それ以外ならData URI Schemeを使ってparams[:url]へジャンプします。
encode_uriはこんなのです。('もエンコードしてることに注意)

  def encode_uri(s)
    URI.escape(s, /[^-_.!~*()a-zA-Z\d;\/?:@&=+$,#]/n)
  end

ここのエスケープの処理はちょっと自信が無いので、まずそうだったら教えてください。

グーグルの新機能『Search by Image』を使ってサムネイルとインターネットのどこかにある元画像をリンクする

feezch.infoを作ろうと思いたったのは、1年以上も前なのですが、どうしても気になる超重要な問題があって、手が出ずにいました。

  • フィードを配信してLDRで読みたい=インターネット上で公開するってこと(プライベートにできない)
  • どうせインターネット上で公開するなら過去ログも有効に使いたい
  • 画像スレも読みたいよね。サムネイルも出てほしい
  • しかしうpロダの画像はすくに消えてしまう
    • つまり画像へのリンクが失われてしまうのだ
    • 画像へのリンクが失われた画像スレの過去ログはゴミに等しい
  • だからといって画像自体は持ちたくない
    • ハードディスク容量が足りなくなりそう
    • グロや無修正や児童ポルノなどが張られることがあって運用が面倒くささそう
  • でも画像スレの過去ログをゴミにしたくない
  • あばばばばb

なんらか元画像へアクセスできる可能性は残したいけど、自分のところで画像をホスティングするのは避けたい、といった自分勝手な悩みですが、これがどうにかできない限り、feezch.infoは運用できないと考えていました。
最初に考えていたのは、GazouMe: 画像アップローダーというサイトを作って、ここに画像を投げるようにして、アクセスの多い人気の画像を残すようにする+報告のあった画像を自動的に消して二度と登録できないようにする、ということでした。問題を薄くするというか運用をできるだけ楽にして、一部の画像だけでもリンクを残そうというものです。
でも…これでもアレガー・・・・・うー・・・あー…逮・・・オー

となっていたときに、グーグルの新機能
Search by Image – Inside Search – Google
が現れました。
これはコンテンツベースの画像検索エンジンです。日本語で言うと、「画像による画像の検索」です。つまり画像をクエリに画像が検索できる。普通は同じものが写っている別の画像を探すときに使うものですが、Googleの実装だと違うサイズの同じ画像や別のホストにある同じ画像も探すことができます。
つまりどういうことかというと、

  • サムネイルをクエリにインターネットのどこかにある元画像が検索できる

わけです。
feezch.infoは画像のサムネイル(プレビュー用のとても小さな画像)を保持していますから、たとえうpロダの元画像が消えたとしても、元画像がインターネット上に存在する限り、サムネイルをクエリにGoogle検索すると見つかるのです。ゆるいリンクはずっと繋がったままなわけです。(本当にマズイ画像や人気のない画像はインターネットから消えていくでしょう)
というわけで、feezch.infoのサムネイルには『この画像でググる』という検索アイコンが付いています。

Search by ImageのAPIを勝手に使う方法

Search by Imageを使えば、いいということは分かりましたが、出たばかりのサービスだしAPIが公開されていないようです。そもそもGoogleは昔クエリつきのリンクを張るなと言っていた気がするし、公開されないかもしれません。
と思っていたところ目に付いたのが、Search by ImageのFirefox拡張です。
これをインストールすると、ウェブページ上の全ての画像に検索アイコンが現れ、アイコンをクリックすることで、画像をクエリにググれるようになっていました。まさに僕がやりたいことです。この拡張をリバースエンジニアリングすればAPIの使い方が分かりそうです。

さて、だんだんタイトルどおり、あやしくなってきました。僕としては、専用のFirefox拡張を作ってまで使ってほしそうなので、ぜひ有効に使わせてもらいましょう、という自分勝手な解釈により勝手に使っています。使おうと思う方は自分が何をしているのかよく考えてからにしてください。

Firefoxの拡張形式であるxpiは、zipフォーマットなので解凍できます。中身はJavaScriptで実装されているので、特にあやしいことをすることなくソースコードが見れました。

画像のURLからクエリを送っている箇所は、quimby.jsの192行目辺りにあります。

    var newTab = gBrowser.addTab(
        'http://' + SERVER + '/searchbyimage?' +
        FIREFOX_EXTENSION_VERSION_PARAMETER_NAME + '=' +
        FIREFOX_EXTENSION_VERSION +
        '&image_url=' + encodeURIComponent(src));
    gBrowser.selectedTab = newTab;

サーバーの/searchbyimageにimage_url=URLというパラメーターを渡せばいいだけです。
いくつか試したところ、ホストは、google.co.jpでも可能(日本語表示になる)、バージョンはパラメーターの互換性のためについていると思うのですが、image_urlのパラメーターは多分変わらないだろうと思うので、外しています。

ということで、

http://www.google.co.jp/searchbyimage?image_url=http://www.st-hatena.com/users/ul/ultraist/user_p.gif
とすれば勝手に使えます。

まとめ

feezch.infoの画像周りで使われている2ちゃんねるブラウザ由来の技術、各種あやしい技術を突然妙なテンションで解説しました。長い。

ImageViewURLReplace.datはサイトのHTMLが変更されたりで結構使えなくなっているパターンが入っているので、使いながら修正する必要があります。スレを見てマージするのがメンドイので、AutoPagerizeのSITEINFOようにWedataとか使ってみんなで共有したら便利そうです。

NGFiles.txtは思ったよりもヒットしないです。まとめを使うよりも自分ひとりが3日くらいかけてグロ画像を集めまくったほうがいいリストが作れそうです。これもWedataで共有すると便利そう。

2ちゃんねるの周辺ツールは全体的に時代遅れに見えますけど、ユーザーが多くて十分なデータがあるので、互換性を持たせた実装を作るといろいろ便利になるものも多いと思います。

at your own risk
welcome to underground