PHPで形態素解析とMySQLで全文検索

備忘録メモです。長ったらしいタイトルっす。

ブログの簡易版みたいなスクリプト(管理者だけが書き込める掲示板みたいなやつ)の改造をちょっと前に依頼されたんですが、その中で検索機能(全文検索)を付けるというのがありました。全文検索っていっても、入力された単語にマッチしたレコードを全部表示する、要はSQLクエリーのselect文でlike演算子でマッチさせるだけでいい、ということだったんですが、ただでさえ、面白くないPHPの仕事だし(^^;;;、それだけでは僕にとっても得るものが少ないので(^^;、もうちょっと勉強になるものを作ってみよう、ということで調べました。

仕事しながら勉強って・・・ま、いいか。

日本語の文章をMySQLで全文検索させるには(FULLTEXTインデックスってことね。)、まず日本語の文章を形態素解析にかけて、名詞・動詞・助詞・・・といった風に分解することから始めなければいけません。英文などでは単語間は必ずスペースもしくはカンマで区切られますから、特に意識しなくても済むのですが、日本語の文章や主にアジア圏の言語では、そう簡単にはいきません。

幸いにもフリーで使用できる形態素解析エンジンは結構豊富にあります。有名なものとして、KAKASIやMeCab、Igoなどがあります。が、一般人が実際に使用するには、結構ハードルが高いものです。

まず、何よりレンタルサーバーなどの一般的なサーバーではほぼこのようなライブラリはインストールされていませんし、新たに追加できることは不可能でしょう。でも、最近では非常に低コストのVPSサーバーがあるので、それなりに知識がある人は導入できるでしょうけど、サーバー管理の知識がない方にとっては難しいでしょう。

ただ、PHP、しかも、レンタルサーバーでも利用できる・・・という条件だと、選択肢は非常に限られると思います。その中でも小規模なサイトに必要十分なものとして、Igo-PHPが手軽に利用できて、サイトへの組み込みも少ない工数で行えると思います。

Igo-PHPは、Javaの形態素解析エンジンIgoのPHP移植版で、Igo同様、MITライセンスで自由に利用できるというありがたいライブラリです。作者に感謝です。

流れとしては・・・

  1. Igo-PHPのダウンロード
  2. Igo本体のダウンロード(辞書生成に必要。別途Java実行環境が必要)
  3. 辞書の元となるファイルをダウンロード(MeCabサイト→ダウンロード→Mecab用の辞書(IPA辞書)
    (2017-10-22 リンク先修正)
  4. 2)でダウンロードしたIgo(Javaプログラム)を使用して辞書を生成。
    3)でダウンロードしたファイルを展開し、以下のコマンドをうつ。Windowsだと java.exeがあるディレクトリが%WINDIR%や%PROGRAMFILES%にあったりと環境によって違うと思います。

    >> java -cp igo-0.4.3.jar net.reduls.igo.bin.BuildDic ipadic mecab-ipadic-2.7.0-20070801 EUC-JP

といった感じになります。これらは全部Windows上で行えます。あとは・・・PHPスクリプトからIgo-PHP、生成した辞書を使って形態素解析ができます。

生成されたipadicディレクトリに辞書がビルドされていますので、以後、このipadicディレクトリとIgo-PHPだけを使用します。

WindowsにPHPをインストールされている方は、下記のようなスクリプトを作成して実行してみてください。

<?php
// test.php

// Igo-PHPとipadicディレクトリを 'lib'というディレクトリにまとめて置いとく。
require_once 'lib/Igo.php';
 
$igo = new Igo("./lib/ipadic");
$text = "私には夢がある。";

// 詳細な結果が欲しい場合は、parseメソッド
//$result = $igo->parse($text);

//単に区切ればいいだけなら、wakatiメソッド
$result = $igo->wakati($text);

//それぞれ、単語の配列が返ります。

echo mb_convert_encoding(implode('/',$result),'SJIS');

実行すると、こんな結果がでました。ちゃんと分解されてますね。

>>php test.php
私/に/は/夢/が/ある/。

さて、検索される文章・記事は、MySQLのデータベースに入れることが多いのでMySQL + PHPでの組み込みを中心に。

MySQLでテーブルを作成する際に、記事などを格納させるカラムとは別に、検索用のカラムを一個追加して、そのカラムにFULLTEXTインデックスを張ります。以下のような感じですかね?

CREATE TABLE  posts (id INT,title TEXT,content TEXT,content_s TEXT,FULLTEXT(content_s));

というようなテーブルを用意しておいて、INSERT,UPDATEする際に、contentの内容を形態素解析にかけ、名詞のみ抜き出し、content_sに抜き出した単語を半角スペース区切りでつなげた文字列を格納しておく。要は検索インデックスをcontent_sに貯めておくという方法です。

英語ならば、語と語は必ずスペースで区切られるから、こんなめんどっちーことをしなくてもいいんですが・・・。
データをINSERTするときは、こんな感じでしょうか。

<?php
$igo = new Igo("./lib/ipadic");
$pdo = new PDO('mysql:dbname=testdb;host=localhost','dbuser','password');

//テーブル作成
$pdo->exec('CREATE TABLE posts (id INT,title TEXT,content TEXT,content_s TEXT,FULLTEXT(content_s))');

//テストデータを用意
$id = 1;
$title = '私には夢がある。';
$content = '私には夢がある。私の四人の幼い子ども達が、いつの日か肌の色ではなく人格そのものによって評価される国に住めるようになるという夢です。';

//形態素解析
$result = $igo->wakati($content);
$content_s = implode(' ',$result);

//INSERT文組み立て
$sql = sprintf("INSERT INTO posts(id,title,content,content_s) VALUES(%d,'%s','%s','%s')",
               $id,
               $pdo->quote($title),
               $pdo->quote($content),
               $pdo->quote($content_s));

//クエリー実行
$pdo->exec($sql);

$pdo = null;
$igo = null;

こんな感じでデータをどんどん追加して、

で、全文検索させたいときは、contentカラムを検索するのではなく、content_sカラムを全文検索させるように、

SELECT * FROM posts WHERE MATCH(content_s) AGAINST('検索単語',IN BOOLEAN MODE);

と、するだけ。

ただ、これだけだと、ちょっと不便なことがあります。形態素解析にかけると、辞書にある単語を元に解析するので、二つ以上の名詞がくっついて一つの名詞になるような語がバラバラになってしまいます。

たとえば、「神戸市」の結果は、「神戸」と「市」に分かれてしまいます。「神戸市」と一つの単語で登録したい場合などは、ひと手間かける必要があります。

全文検索以外の他の用途でも使えそう。漢字混じりの文章をすべて平仮名や片仮名に変換したりも可能なので、使い道は結構あると思います。

またデスクトップアプリの機能として形態素解析エンジンを使いたい場合は、.NETアプリで使用できるNMeCabというMeCabの.NET移植版がありますし、形態素解析というと、ものすごく難しい、というイメージですが結構簡単に自分のアプリにも組み込めたりできますので、もっと活用の場があってもいいと思います。

packer(.NET版)を勝手に改造

これ、すごい。こんなスマートなビルの壊し方があるんすね。。。ビル爆破より地味だけど、なんかお金かかりそう。
http://www.bbc.co.uk/news/world-asia-21406927

それはともかく。

javascriptコードを圧縮するのに packer (http://dean.edwards.name/download/#packer) の.NET版をよく使っています。
が、.NET版は(たぶん)メンテされておらず?ソースコードも .NET Framework 1.0時代のものなので、見栄えがかなり悪いし、テキストボックスの入力が32KBに制限されていたり、とちょいと不満がくすぶっていました。
最近Javascriptコードを書くことが多くなって、頻繁に使い出すと、どうもこのUI自体が(僕にとっては)使いにくい。

で、あまり見た目を触らずボタン類の配置、ビジュアルスタイルの適用などを含めて改造。
自分用の書庫の意味もかねているので、ここにアップロード。

左がオリジナル。右がUI改造版
jspacker myjspacker

改造点は、

  • ボタン配置・レイアウトを変更した。
  • ビジュアルスタイルを適用するようにした。
  • ラベルを日本語化した。.configファイルでラベル編集可能。
  • テキストボックスの32KB制限を外した。
  • ファイルからの読込(Loadボタン)時に文字コードを判別するようにした。
    (文字判定にInternet Explorerのモジュールを使っているのでIEを削除している方(いないと思うけど)の環境では文字判定できません

ユーザーインターフェイスの部分(Visual C#のIDE上で編集できる部分)のみ修正したのでpacker本体のアルゴリズムには一切タッチせず。packer自体のバージョンは上がっていると思うので、たぶん古いアルゴリズムのままのはず。分からんけど。

プロジェクトのプラットフォームを.NET Framework 3.5にして、フォームを定義しているソースファイルでIDE機能のデザイナーで編集される部分のコードを分離( partial class化)してます。

<< Download from here if you need.>> もし必要ならダウンロードはこちら
(改造後のVisual C# 2008のプロジェクトソースも同梱してます。)
(なんか、chromeだと、危険だ!とか出てダウンロードできませんが、別になんも仕込んではいないです。)

使い方は・・・説明しなくても・・・(^^;;;
ラベルを英語にしたい場合は、Javascript packer.exe.config ファイルを開いて、

&lt;setting name=&quot;Lang&quot; serializeAs=&quot;String&quot;&gt;
&lt;value&gt;ja&lt;/value&gt;
&lt;/setting&gt;

の中の、ja の部分を ja以外、たとえば、en とかにしてください。
ラベルを修正したい場合は、configファイルの該当箇所を修正してください。ただ、ボタン幅は固定なので長さによってははみ出るかも?

でわでわ。

ID値がグローバル変数に自動追加される

超初歩的なことに、はまってしまった。自分自身の備忘録として残しておこう。

自分で組んだjavascriptコードが chrome で動いたり動かなかったりしたことがきっかけだった。
そのスクリプトは、ブラウザ判定をしてて、chrome、いえ、webkit の検出に以下のようなコードを使用していた。
これ自体は、グーグル検索で辿り着いた、W3G(world wide web guide)というサイトに掲載されていたものを丸々拝借させてもらいました。

// chrome/safari等のオブジェクトを使った判別。
(function(undefined)
{
  var isWebkit = !document.uniqueID &amp;&amp; !window.opera &amp;&amp; !window.sidebar &amp;&amp; window.localStorage &amp;&amp; window.orientation === undefined;
})();

さて、ここで、chromeでアクセスしているにもかかわらず、HTMLファイルによってはtrueになったりfalseになったりして ????という状態で、falseと判定されるHTMLを開いてブラウザのコンソールで、調べていったら、 !window.sidebarfalseになることが判明。

これでピンときた(思い出した)。

あ!、HTMLファイルのある要素(DIVタグ)に id=”sidebar” って属性をつけてるわ・・・。

とほほ。ご存じのように、タグのid属性に指定した値は、グローバル変数に(厳密に言うと、windowオブジェクト)に追加されるんだった・・・orz

いや、確かに昔、Internet Explorer が中心だったころ、よくこれでラクしてたんだよな・・・最近は、document.getElementByIdというDOMメソッドを使用することがほとんどだったので忘れてた。でもIEならともかく・・・webkitでこれに直面するとわおもわなんだ。

結局、元になるHTMLファイルから、sidebar という名のid属性値を、違う値に全部変更することで一件落着。

これから、タグにid属性を含めるときは、必ずハイフンを使用した値にするようにしよう・・・と固く誓うのであった・・・。
というより、こんなIEの独自仕様は端っからwebkitから消し去ってくれればいいのに・・・。

C#でWordPressのエクスポートファイルを取得する

とある業務ソフトのヘルプをWordpressのブログシステム上で作成しよう、ということになってまして、まぁ、フツーはそのWordpressをユーザー認証をかけて、そのまま公開すれば話は済むんですが・・・普通はね・・・。でもクローズドな環境にも対応しなければいけません! とかいう、メンドクサイ仕様になってまして・・・。
それなら、Wordpressなんか使う必要あらへん、ホームページビルダなり、なんなり、使いやすいソフトにすればいいじゃねーの? とフツーはなるんですが・・・。
まぁ、ヘルプ作成について色んな方法を僕がいくら提案しても受け入れてくれないのは分かっているので、グダグダ文句を言っても始まりません。 結局は、ヘルプファイル(HTML)はローカルに置けるように、Wordpressの記事をすべてHTMLに落とすという全くもって本末転倒&意味不明のプログラムを組むことで落ち着きました。

はじめは、そんなの誰かがもうやってるだろう・・・とググったけど、探し方がまずいのか、ヒットしません。ま、そりゃ、そーだよな、わざわざWordpressの記事を全部HTMLに落とすなんてこと・・・する必要もねーし、そんなヘルプの作成の仕方しているの素人集団のウチだけだよな。

ま、グチを言ってても始まりません(笑)

WordPressはXML-RPCをサポートしてますので、XML-RPCで・・・と思ったんですが、Wordpressが吐き出すWXR形式のエクスポートファイル(XML)をダウンロードしてパースした方が早いんじゃねーの? という根拠のない脳内ベンチを信じて、C#で組み始めることに。

エクスポートファイルをパースしてHTMLファイルを一気に吐き出すコードは簡単にできたんですが、エクスポートファイルをWordpressから排出させるところで躓いた。

サーバー上のWordpressへのアクセスに System.Net.WebClient クラスを使ったんですが・・・ログイン処理、具体的にはクッキー処理がうまく行かず、ちょっと時間がかかってしまいました。ネットで検索して出てくる、WebClientを継承して、GetRequestメソッドをオーバーライドしてCookieを保存できるようにカスタマイズしたWebClientを試してみたんですが、どういうわけか、Wordpressへのログインは成功するんだけど、クッキーが保存されないのでログイン画面にリダイレクトされてしまう。
結局、HttpWebRequestの自動リダイレクトを無効/AllowAutoRedirectプロパティをfalseにすることで、解決しました。

/*
  エラー処理・タイムアウト処理はしていない。
*/
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Text;
using System.Text.RegularExpressions;
using System.Linq;
using System.Net;
using System.Threading;

///<summary>
/// 自動リダイレクトを無効にするため、WebClientを継承します。
///</summary>
class XmlWebClient : WebClient
{
  protected override WebRequest GetWebRequest(Uri address)
    {
      WebRequest request = base.GetWebRequest(address);
      
      if (request is HttpWebRequest)
        ((HttpWebRequest)request).AllowAutoRedirect = false;
      
      return request;
    }
  
  ///<summary>
  ///エクスポートファイルを新規スレッドでダウンロードする。
  ///非同期APIを使えばいいんですが、ややこしいので、スレッドを一本起こしてます。
  ///</summary>
  public static Thread GetWXRFileAsync(string url,string user,string phrase,string ofilepath,Action cb)
    {
      try
        {
          var thread = new Thread(o =>
                                  {
                                    GetWXRFile(url, user, phrase, ofilepath);
                                    cb();
                                  });
          
          thread.Start();
          return thread;
        }
      catch(Exception exception)
        {
          throw exception;
        }
    }
  
  ///<summary>
  ///エクスポートファイルをダウンロードする。
  ///</summary>
  public static void GetWXRFile(string url,string user,string phrase,string ofilepath)
    {
      var xwc = new XmlWebClient { Encoding = Encoding.UTF8 };
      
      xwc.Headers[HttpRequestHeader.UserAgent] = "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.0; Trident/5.0)";
      xwc.Headers[HttpRequestHeader.Referer] = url + "wp-login.php";
      
      var param = new NameValueCollection();
      param.Add("log", user);
      param.Add("pwd", phrase);
      param.Add("rememberme","forever");
      param.Add("wp-submit","login");
      param.Add("redirect_to",url + "wp-admin/");
      param.Add("testcookie","1");
      
      xwc.UploadValues(url + "wp-login.php",param);
      
      string setCookie = xwc.ResponseHeaders[HttpResponseHeader.SetCookie];
      string[] cookies = Regex.Split(setCookie, "(?<!expires=.{3}),")
        .Select(s => s.Split(';').First().Split('='))
          .Select(xs => new { Name = xs.First(), Value = string.Join("=", xs.Skip(1).ToArray()) })
            .Select(a => a.Name + "=" + a.Value)
              .ToArray();
      
      xwc.Headers[HttpRequestHeader.Cookie] = string.Join(";", cookies);
      xwc.DownloadFile(url + "wp-admin/export.php?content=all&download=true", ofilepath);
    }
}

クッキー処理のところは、たまたま検索したページ(サイト失念(m_m))のコピペ。SetCookieヘッダーの処理って面倒なんですよね・・・助かる(^^)
あとは、下記のように WordPressのURLとログイン名・パスワード・ダウンロードするエクスポートファイルを保存するファイルパスを指定してコールするだけ。

//ダウンロード
XmlWebClient.GetWXRFile("ワードプレスのURL","ユーザー名","パスワード","ファイルパス");

//おまけで、コールバック関数を指定して別スレッドで動かす「なんちゃって非同期バージョン」
//Windowsフォームを使うアプリだと、非同期は必須っすね。
XmlWebClient.GetWXRFileAsync("ワードプレスのURL",
                             "ユーザー名",
                             "パスワード",
                             "ファイルパス",
                             () => {Console.WriteLine("エクスポートファイルの取得が終了しました。");});

iframeのloadイベント 【書き直し】

今月の初めに書いた投稿が、完全に勘違いだったので、削除して改めて書き直し。

jQuery(1.7.2)でiframeを動的に生成して、そのloadイベントを書いてたときに、どうしても理解できないことが一つあります。
たぶん、僕が無知なだけかもしれませんが・・・

// &lt;a id=&quot;test&quot; href=&quot;http://somewhere.url/&quot;&gt;iframeにリンク先をロードさせる&lt;/a&gt;
// というような、HTMLがあって、

;(function($)
  {
    $('a#test').click(function(ev)
                      {
                        ev.preventDefault();
                        ev.stopPropagation();
                                          
                        //クリックされたアンカータグのhref属性を保存。
                        var href = this.href;
                        
                        //デバッグ用のカウンタ
                        var count = 1

                        //iframeを生成して、そこに読み込ませます。
                        var $iframe = $(document.createElement('iframe')).appendTo(document.body);
                        
                        //onloadイベントでチェック
                        $iframe.load(function()
                                    {
                                      console.log([count++,this.src]);
                                    });

                        //普通に読み込むと、ログには [1,'http://somewhere.url/'] が表示されます。
                        $iframe.attr('src',href);
                        
                        //window.setTimeout(function(){$iframe.attr('src',href);},100);
                        //しかし、IEやFireFoxだとsetTimeoutメソッドで強制的に非同期で読み込ませると、
                        //ログには・・・
                        //---------------------------
                        //[1,'']
                        //[2,'http://somewhere.url/']
                        //---------------------------
                        //と表示され、2回コールされています。
                        //しかし、safariやchromeなどのwebkit系のブラウザでは1回しかコールされません。
                      });
  })(jQuery);

単純にsrc属性を設定してロードした場合だと、どのブラウザでも1回しかコールされない。
だけど、setTimeoutを使用してロードを遅延(非同期)実行させると、IE/Mozilla系ブラウザは2回コールされる。
ただ、タイムアウトミリ秒を1とか10とかのようにすると1回しかコールされないときもある。

よく分からない。そういうものなの???