kmuto’s blog

はてな社でMackerel CREをやっています。料理と旅行といろんなIT技術

「textlint-rule-kmu-termcheck」というtextlintルールを作った

文章チェックツールのtextlintに、類似度検査で製品名とかサービス名の表記ミスを見つけるためのルールを作ってみたよ、という話。Node.jsベイビーなので、リファクタリングなどパッチ大歓迎。

github.com

実行例。

以下は中身についての長い話で、実装に興味のある方向け。

前職からで業務制作するときの校正チェックは私物のJust Right!でおおむね自分自身としては事足りているんだけど、そこそこお高いので、今のチームの皆で導入して使いましょうという代物ではない。

部署や全社的に広げていくことを考えるとまぁtextlintが無難じゃろ、ということで、ちまちまルールプリセットに着手している。

で、作業を進めていくと、文章面の校正は相互レビューでひっかけられるのでそこまで必要性はないし(あっても損ではないけど)、実行時間もかかりすぎる(これはトレードオフにしてはちょっと重いなと感じている)ので、表記揺れ系、特にサービス名まわりのほうが何も考えずとも自動チェックしてほしいのでは?と思い至った。

たとえば「Mackerel」というのは英語的・音声的に自然に感じられればタイプミスはしにくいんだけど、表記された文字のみで覚えているとミスしやすい。AWSのサービスなどだとスペースがあったりなかったり大文字だったり小文字だったりで、ルールを覚えようがない。

textlint的にはprhルールにひたすら可能性を正規表現で並べるというのが王道っぽいのだけれども、この場合はどうしても漏れが発生するのと、前後に誤った文字を1つタイプミスで入れたとか、1文字抜けたとかいったあたりを検出しづらいという問題がある。

ということで何か方法はないかなと考えたときに、最近のプログラミング言語インタラクティブシェル(irbとか)ではタイプミスしたときに「もしかして○○のつもりでしたか?」と煽ってくる…じゃなかった、サジェストしてくれる機能がある。

言語と違って辞書量が再現なく増えていく恐れはあるが、まぁドメイン限定で使う程度なら問題ないだろうと判断して、この仕組みを真似してみることにした。

おそらくリーベンシュタイン距離のアルゴリズムを使ったものだろうと推測して、textlintのルールを書く言語のNode.jsでも何かあるかと探したところ、didyoumeanというライブラリが見つかった。didyoumean3とかいろいろフォークされたものがあるけれども、とりあえずdidyoumeanのざっとソース見て安全そうだなと判断したので、そのままそれを使うことにしている。

英語部分しか使わない/使えないので、textlintでよく使われているkuromoji、というかそのラッパーのkuromojinの形態素解析の結果から、英字アルファベットのみで構成されているものを対象にした。

まずは簡単なTypeScriptコードでdidyoumeanに簡単な辞書配列と誤単語を入れたときを実験し、それっぽくsuggestされることを確認。次にkuromojinでの形態素解析を実験。ふむふむ。

さくっとできて、方向性としては悪くなさそうだったので、create-textlint-ruleを使い、textlintルールとして機能するように実装していく。Promiseで最終的にどう返していくのか最初わからなくて悩んでたり、テストルールもよくわかっていなかった。

単語レベルでは一応動くところまではいったものの、複数単語からなる製品名だと、1単語ずつに分けてしまったときにあまりに一般用語と近すぎて誤検出したり、「OpenShift」が「Oepn Shift」になっているといった誤記を検出できない。

形態素解析し終えたトークンから必死に再構成するのは辛いので、英単語間にスペースが入っているときにはそのスペースを代替文字に置き換えて擬似的に1つの単語にするようにした。記号類だとkuromojiに分断されてしまうのだが、補助ラテン文字範囲ならたぶん英字扱いなのではと予想したところ、これでうまくいった。ウムラウト付き文字でもよかったんだけど、\x7fは字形は除算記号なのだけれども英字扱いになるという発見があり、これを使うことにする。

辞書はシンプルに改行区切りのテキスト打ち。JSONでもいいんだけど、入力の簡易さを優先した。この手のもので#でコメント化できるようにするのは私の基本実装ルールの1つだったりする(なので、ユーザーが入力するものでコメント入れられないデータ構造は嫌い)。

kuromojiで数字箇所で分割するので、「EC2」と入れてもダメなことがわかった。つらい。たぶんkuromojiに固有名詞として追加すればいいのだろうなとは思うけれども、kuromojin経由でその登録ができるのかは未調査。

辞書は最初コード内に持っていたのを分割し、ユーザー辞書も読めるようにしたのだが、fsライブラリでエラーになって、Web向けに使えないのはわかるとしてtscだとビルドできるのになぜだ…と悩んでいた。結局ビルドコマンドのtextlint-scriptsのほうでfsについて制約していて、ちゃんとそっちのREADMEにも書いてあった…。環境変数渡しが必要なので、cross-envも導入し、NO_INLINE=1を渡してビルドできるように。

ちょこちょことnpm publishしながら、細部を改善していく。

普通は先頭大文字だけれども、URLに含まれると小文字になってエラーになる、というのがわずらわしいので、その対処をすることにした。先頭大文字小文字を無視するモードを用意して、比較時に先頭のdowncaseをするようにしてみる。「GitHub」みたいなのはダメだけど、かといって全downcase比較してしまうと「OpenShift」をひっかけられなくなる。まぁこのあたりはJust Right!でも無理だったし、許容する。トークンに文の背景情報(MarkdownのURLだよとかコード類だよ)まで乗せられるならワンチャンあるかもしれない。

Node.jsベイビーレベルなので、今回はChatGPTを壁打ち相手にして、「importでこのエラーが出たときの理由は?」「配列をソートして要素をユニークにした配列はどう作る?」など聞いたりしていた。コードの細部で悩んだときにGoogleに聞くよりは良かった。

全体のロジックまで聞くのはMITライセンス公開物としてよろしくないコードが出てきちゃいそうだし、リファクタリングまではしてくれないので、そのあたりはやはり人間とペアプロしたいなと思った。試行錯誤自体はよかったのだけど、つまらないところでつまづいて時間を無駄にしてはいたので、エキスパートに都度ツッコミをしていただきながらのほうが学習効率は良いな。

ともあれ、偽陽性の問題は多めではあるけれども、まぁまぁ良いものが出せたのでは、という気はする。機能強化のPRは大歓迎なので、よろしく! Happy Hacking!