これは ドリコム Advent Calendar 2016 の15日目です。

14日目は ishida-k さんの 新人アートディレクター必見!業務を円滑に進めるための3つの心得 です。

多くの人が安心して遊べるゲーム環境を守るため、ニックネームなど、ゲームユーザの自由な文章入力を受け付ける機能には、NGワード検出が必要です。反社会的な不適切単語や、電話番号などの個人情報が無制限に出回るのを許すわけにはいきません。Mobage, GREEなどでブラウザゲームを提供している時は、主にプラットフォームのテキストAPIを通じてなされていたNGワード検出ですが、ネイティブシフトにあたって自前で機能を持つ必要が発生しました。本稿では、いろんな人々が必要に応じて拡張を入れてきた様子を記します。

あらかじめお伝えしておきますがリアルタイム監視などの高度な機能には触れません。またNGワードそのものは秘匿データであること、またゲームごとに要件が異なることもあり、以下の例はコードも単語も、実際のゲームで使用されているものとは異なります。またサンプルコードはRuby, Railsです。

はじまり

まず最初に作られたのは、入力に対して、NGワードリストをループしてひとつづつ、単純に正規表現マッチするものでした。NGワードリストは、他のゲーム内マスタデータと運用を合わせられるよう、DBに登録するものとされました。

# db/migrate/XXXXXXXXXXXXXX_create_taboo_words.rb
class CreateTabooWords < ActiveRecord::Migration
  def change
    create_table :taboo_words do |t|
      t.string :word
      t.timestamps
    end
  end
end
# app/models/taboo/word.rb
module Taboo
  class Word < ActiveRecord::Base
    def self.list
      self.pluck(:word).map { |word| Regexp.new(Regexp.escape(word)) }
    end

    def self.validate(val)
      list.each do |ng_word_re|
        return false if ng_word_re =~ val
      end
      true
    end
  end
end
# 用例
console> Taboo::Word.validate("電話番号教えて")
# => false    # ⊂ミ⊃^ω^ )⊃ アウアウ!!

機能はシンプルでしたが、ここで決定的に重要だったのは、社内gemとして綺麗に切り出されており、各アプリへの導入が非常にやりやすかったことです。Railsのmountable engineで提供されていて、キャッチーな taboo という名前と、rspecテストが付いていました。ベースがしっかりしており、実地に投入されているからこそ、さまざまな改修を入れて使い続けることができています。設計が大事、最初が肝心です。T.Mさんありがとうございます。いつもお世話になっております。

とはいえこのままでは毎回リクエストごとにNGワードを全件メモリに読み込んでは捨てることになり非効率なので、クラスインスタンス変数にキャッシュするように改修したうえ、本番に投入されました。副作用として、DB上のNGワードを追加編集削除しても、APサーバ(Unicornなど)を再起動するまで、反映されなくなりました。しかたがないですね。

ループ除去

NGワード検出には、NGワードが含まれていない、良心ある入力文ほど最悪計算時間に近づくという性質があります。マッチするまで全ワードで確認する必要があるからです。自己紹介やゲーム内掲示板であるなどの、ニックネームより長く更新も多くなりがちな入力欄を増やしたり、検出すべきNGワードを足すなどの対応が行われるうち、実行速度に不安が生じてきました。

そこで入った改修が、NGワードの正規表現をあらかじめ全部連結しておき、正規表現判定を1回で済ませる、というものです。

# app/models/taboo/word.rb
# キャッシュ省略

module Taboo
  class Word < ActiveRecord::Base
    def self.list
      self.pluck(:word)
    end

    def self.regexp
      escaped_words = list.map {|word| Regexp.new(Regexp.escape(word)) }
      Regexp.union(escaped_words)
    end

    def self.validate(val)
      regexp !~ val
    end
  end
end

NGワードの数だけまわしていたループがvalidateからなくなっています。やりましたね。| でつないでいるので、正規表現エンジン内でバックトラックが発生するため、入力やNGワード数を無限に増やせるようになったわけではありませんが、実運用での懸念は軽くなりました。T.Fさんありがとうございます。いつもお世話になっております。

正規化

アルファベットやカタカナといった、大文字小文字、全角半角の表記にぶれが出る文字を、全パターンリストに登録するのは大変なので、正規化して比較する改修が入りました。正規化には Moji gem を利用しました。

# lib/taboo/normalizer.rb
require 'moji'

module Taboo
  module Normalizer
    def normalize_input(val)
      Moji.normalize_zen_han(val)
    end
  end
end
# app/models/taboo/word.rb
require 'taboo/normalizer'

module Taboo
  class Word < ActiveRecord::Base
    extend Taboo::Normalizer

    # (略)

    def self.regexp
      escaped_words = list.map {|word| Regexp.escape(word) }
      regexp = Regexp.union(escaped_words)
      Regexp.compile(regexp.source, Regexp::IGNORECASE)
    end

    def self.validate(val)
      regexp !~ normalize_input(val)
    end

リストを増やす必要もなくなり、表記を意図的に混ぜて(090→090など)無理やり突破しようとするイタズラも防げました。やりましたね。gussanさん、さっちゃんさんありがとうございます。いつもお世話になっております。

多言語化(スペース区切り言語対応)

ここまでは、NGワードが入力中のどこでも、どこかに入っていれば検出と判定していました。日本語を対象にしている間は、誤検出はありつつもこれで運用できていましたが、しかしこれでは海外展開にあたって都合が悪すぎます。欧米圏のように、文字の種類が少なく、単語をスペース区切りで書く言語は、「それ自体は問題がないのに、部分文字列としてNGワードを含むような単語」が多すぎるからです。いわゆる clbuttic問題 が多発して、ろくに入力ができません。

この問題に対し解決策としてまず、最初に海外展開することになったアプリで、正規表現中のNGワードの前後を単語境界 \b で囲む、というモンキーパッチで対応がなされました。しゅんしゅんさんありがとうございます。いつもお世話になっております。

しかしこれを、そのまま日本国内向けアプリに取り込むことはできませんでした。スペース区切り言語を、非スペース区切り言語の中にゼロ距離で埋め込んで使われた時の検出ができないのです。

あまりキツいNGワードはこのブログにも書けないので、ぬるい例で許してほしいのですが、たとえば電話番号を示す TEL をNGにしたいとして、正規表現/\bTEL\b/teleportation の誤検知(false positive)は防げます。しかしたとえば TEL番教えて のような、本来検出したかったケースまで漏れてしまう(false negative)のです。(UTF-8でのみ確認) (※例はほんとにこれしか思い浮かばなかったんです。勘弁してください。)

これを防ぐには \b だけではだめで、スペース区切り言語文字と非スペース区切り言語文字の境界も単語境界とみなす必要があります。困っていたところ奈良阪が先読み、後読みを教えてくれました。ありがとうございます。いつもお世話になっております。

正規表現の先読み、後読みとは 「ある位置から続く文字列が ある部分式にマッチするならばその位置にマッチする という正規表現」 と解説されています。例にある「<b> </b> の内側を取り出す」というのは、今やりたい「日本語文字に挟まれたLatin-1文字のNGワードを取り出す」によく似ています。やってみましょう。非Latin-1文字はざっくり [^a-zA-Z0-9\u00C0-\u00FF] で表現しておきます。Latin-1 Supplement (Unicode block)

words_space_delimited = ["TEL", "mail"]
words_non_space_delimited = ["電話番号", "メルアド"]
re_space_delimited = /(?:\b|(?<=[^a-zA-Z0-9\u00C0-\u00FF]))(?:#{Regexp.union(words_space_delimited).source})(?:\b|(?=[^a-zA-Z0-9\u00C0-\u00FF]))/i
re_non_space_delimited = Regexp.union(words_non_space_delimited)
unioned = Regexp.union([re_space_delimited, re_non_space_delimited].compact)
re = Regexp.new(unioned.source, Regexp::IGNORECASE)

re.source
# => "(?i-mx:(?:\\b|(?<=[^a-zA-Z0-9\\u00C0-\\u00FF]))(?:TEL|mail)(?:\\b|(?=[^a-zA-Z0-9\\u00C0-\\u00FF])))|(?-mix:電話番号|メルアド)"

re !~ "teleportation"
# => true    # ⊂(^ω^)⊃ セフセフ!!
re !~ "TEL番教えて"
# => false    # ⊂ミ⊃^ω^ )⊃ アウアウ!!

いっすね。最終的にコードはこのようになりました。

# db/migrate/XXXXXXXXXXXXXX_add_column_and_index_to_taboo_words.rb
class AddColumnAndIndexToTabooWords < ActiveRecord::Migration
  # NOTE: メジャーverupなんだからirreversibleでいいのでは…!
  def up
    change_table :taboo_words, bulk: true do |t|
      t.boolean :space_delim, default: false, null: false  # スペース区切り言語の単語はtrueを入れる
      t.index [:space_delim, :word], unique: true
    end
  end

  def down
    raise ActiveRecord::IrreversibleMigration
  end
end
# app/models/taboo/word.rb
# キャッシュ省略

    # [[<word1>, <boolean1>], [<word2>, <boolean2>],... ]
    def self.list_with_space_delim
      group(:space_delim, :word).pluck(:word, :space_delim)
    end

    # NOTE: DBにNGワードがひとつもない場合、決して何にもマッチしないRegexpを返す
    # =全ての入力がvalidになる。直観と合う
    def self.regexp
      word_pairs = list_with_space_delim

      words_space_delimited, words_non_space_delimited = [], []

      word_pairs.each do |word, space_delim|
        if space_delim
          words_space_delimited
        else
          words_non_space_delimited
        end << Regexp.new(Regexp.escape(word), Regexp::IGNORECASE)
      end

      re_space_delimited =
        if 0 < words_space_delimited.count
          /(?:\b|(?<=[^a-zA-Z0-9\u00C0-\u00FF]))(?:#{Regexp.union(words_space_delimited).source})(?:\b|(?=[^a-zA-Z0-9\u00C0-\u00FF]))/i
        else
          nil
        end
      re_non_space_delimited =
        if 0 < words_non_space_delimited.count
          Regexp.union(words_non_space_delimited)
        else
          nil
        end

      unioned = Regexp.union([re_space_delimited, re_non_space_delimited].compact)
      compiled = Regexp.new(unioned.source, Regexp::IGNORECASE)
      compiled
    end

これから

たくさんの人が自分の担当要件に応じてgemを作り、拡張を加え、それがまた別のゲームでも利用され、少しずつ機能が増えていった様子をお伝えしました。皆様いつもお世話になっております。いつもありがとうございます。

課題はまだあって、そのひとつに日本語(非スペース区切り言語)の単語にも、「それ自体は問題がないのに、部分文字列としてNGワードを含むような単語」は多数あり、それを誤検知(false positive)してしまう問題があります。特にひらがなカタカナ、人名など固有名詞に多く見られます。しかしスペースのような分かりやすい単語境界はないので、もうひと工夫が必要です。多言語の対応の時のように、ひらがな・カタカナ・漢字の境界を単語境界と見做すようにするか、あるいは固有名詞対応と割り切って、ホワイトリストを作って検査対象から除外するか。形態素解析まで必要かどうかは微妙です。

またこのコードは、日本語と、北米・西欧、ハングルまではどうにかなっていますが、中国語、アラビア文字、キリル文字…などターゲットを広げていくとまた違った問題が出てくるとおもいます。不勉強なので具体的なことは分かりません。

計算速度に対する懸念もあります。ループ除去の際に導入した正規表現が、多言語化でいっそう複雑になり、ベンチマークで数倍の速度低下を招いています(今回は掲載を見送ります)。現在のコードは | を多用しているため、バックトラックが多発しています。これを、できるだけ早く部分文字列のマッチ有無を確定させるようにすると、高速化が見込めます。単語リスト部分に gfx/ruby-regexp_trie: Optimized Regexp.union() with Trie を適用し、共通部分のくくり出しを行うことで、速度向上が期待できます。

そういった改修を入れて、今後もうしばらく使い続けていくのだとおもいます。もしかしたら次に改修を入れるのは、これを読んでいるまだ見ぬあなたかもしれませんね。とかいうね。採用窓口あるいは下記のWantedlyまでご連絡をお待ちしております。以上今後ともよろしくお願い申し上げます。

おわり

16日目は sanoko さんです。