LINEヤフー Tech Blog

LINEヤフー株式会社のサービスを支える、技術・開発文化を発信しています。

1つの HTML ファイルだけで完結する校正支援ツールの作り方

こんにちは。LINEヤフー株式会社でテキストマイニングや自然言語処理などをやっている山下( @yto )です。

Yahoo!デベロッパーネットワークのテキスト解析 Web API が CORS(Cross-Origin Resource Sharing)対応したため、サーバがなくてもブラウザから直接 Web API にアクセスできるようになりました(参考)。

そのテキスト解析 Web API の機能の一つである「校正支援」は日本語文章の品質チェック(校正)を支援するもので、文字の入力ミス、言葉の誤用、わかりにくい表記、不適切な表現などが使われていないかをチェックして、指摘します(内部の辞書データをベースとしているため完全なものではないことをご承知おきください)。

この校正支援機能のサンプルプログラムとして「HTML ファイル1つだけで完結する校正支援ツール」を作ったので紹介します。入力されたテキストに対する校正指摘をわかりやすく表示し、指摘箇所へのアクションを簡単に行える UI を持ち、特別なアプリやサーバを用意する必要がなく、Web ブラウザさえあれば今日からすぐに使える校正支援ツールとなっております。

本稿では、まずはツールのこの校正支援ツール使い方を説明し、その後にコードの説明をしていきます。

校正支援ツールの使い方

ブラウザで校正支援ツールの HTML ファイル jlp-kousei.html(後述)を開きます。図1に実行例を示します。

図1: 実行例

校正支援ツールの実行画面

ページには3つのエリアが縦に並んでいます。上から、校正をかけたい文章を入力する「入力テキスト」エリア、校正支援 API の結果を表示・編集する「出力操作パネル」、最終稿を表示する「出力テキスト」エリアです。

こんな文章はあまり一般的ではないかもしれませんが、例文として

矢張りセキュリティーで食べられるのかが問題なる。
しかし一週年記念で蟀谷が痛い。

を入力しました。

出力操作パネルに指摘箇所が表示されます。指摘箇所と対応例をいくつか解説します。

  • 「セキュリティー」→「セキュリティ」
    • 「用字」に関する指摘で、末尾の「ー」について
    • 「語末が-tyだが昨今のネット上の慣習に準ず」とのこと
    • 指摘理由はマウスカーソルを置くとポップアップされます (図2)
      • ポップアップは title 属性を使っているだけなので、表示されるまで時間がかかることがあります
    • 変更候補(グリーン背景)をクリックして承認
  • 「食べれる」→「食べられる」
    • 「ら抜き」に関する指摘
    • 変更候補をクリックして承認
  • 「問題なる」
    • 「助詞不足の可能性あり」という指摘
    • クリックして修正(助詞「と」を挿入)して承認
  • 「蟀谷」
    • 「表外漢字あり」という指摘
    • クリックして修正して承認 (図3)

図2: 指摘理由のポップアップ

指摘理由のポップアップ

図3: 変更候補の編集

変更候補の編集

一番下の出力テキストエリアに修正後の文章が表示されるので、必要に応じてさらにそこで編集して、最後にコピーボタンでコピーして終了です。

もう少し詳しい使い方も載せておきます。API を使い倒したい人やプログラムをカスタムしたい人など向けです。

  • 「入力テキスト」エリア
    • 校正したいテキストを貼り付ける
    • 「校正開始」ボタンを押す
    • 「出力操作パネル」に結果が出力される
  • 「出力操作パネル」
    • 校正支援 API による指摘箇所の入ったテキストが表示される
    • 指摘箇所
      • ピンク背景がオリジナル文字列
        • 校正支援 API に指摘された文字列である
        • クリックすると指摘が否認される(オリジナル文字列が採用される)
      • グリーン背景が変更候補文字列
        • 変更候補がない場合はオリジナル文字列が表示される
        • クリックすると指摘が承認される(変更候補が採用される)
        • 変更候補文字列は編集可能
      • 承認と否認の切り替えはチェックボックスでも可能
      • 指摘箇所にマウスカーソルをあてると、指摘理由がポップアップされる
    • 出力操作パネルの下にあるボタン
      • 「一括承認」ボタンはすべてを承認
      • 「承認リセット」ボタンはすべてを否認
  • 「出力テキスト」エリア
    • 「出力操作パネル」の中身に連動した、校正後のテキスト(最終結果)が表示される
    • 「コピー」ボタンでクリップボードにコピーできる

コード

校正支援ツール (jlp-kousei.html) のソースコードです。約100行の HTML ファイルとなっています。

少し長いので見にくいかもしれませんがご容赦ください。

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>テキスト解析Web API 校正支援ツール</title>
    <style>
      * { box-sizing: border-box; }
      body { margin: 0; padding: 0 1rem 2rem 1rem; }
      textarea { margin-top: 1rem; width: 100%; height: 8rem; font-size: large; }
      #view { border: dashed 2px gray; padding: 1rem; margin-top: 1rem; }
      del { background-color: #fcd; text-decoration: none; }
      ins { background-color: #bfb; text-decoration: none; color: gray; }
    </style>
    <script>
      const APPID = 'あなたのClient ID(アプリケーションID)';

      async function kousei_webapi(text) {
          const url = 'https://jlp.yahooapis.jp/KouseiService/V2/kousei?appid=' + encodeURIComponent(APPID);
          return await fetch(url, {
              method: 'POST',
              body: JSON.stringify({
                  "id": "1234-1",
                  "jsonrpc" : "2.0",
                  "method" : "jlp.kouseiservice.kousei",
                  "params" : { "q" : text }
              }),
              mode: 'cors'
          }).then(res => res.json()).catch(console.error);
      }

      async function do_kousei() {
          const text = document.querySelector("#input-text").value;
          const obj = await kousei_webapi(text);
          if (!obj) return;
          make_view(text, obj);
          convert_view_to_text();
      }

      function make_view(text, obj) {
          [...obj.result.suggestions].reverse().forEach(s => {
              const pre = text.substring(0, parseInt(s.offset));
              const post = text.substring(parseInt(s.offset) + parseInt(s.length));
              const note = s.rule + (s.note ? ' : ' + s.note : '');
              const sug = s.suggestion ? s.suggestion : s.word;
              text = `${pre}<span title="${note}"><del>${s.word}</del><ins>${sug}</ins>` +
                  `<input type="checkbox"></span>${post}`;
          });
          document.querySelector("#view").innerHTML = text.replaceAll('\n', '<br>\n');
          Array.from(document.querySelectorAll("#view span")).forEach(e => {
              e.querySelector('del').addEventListener('click', () => set_checkbox(e, false));
              e.querySelector('ins').addEventListener('click', () => set_checkbox(e, true));
              e.querySelector('ins').addEventListener('input', () => convert_view_to_text());
              e.querySelector('ins').contentEditable = true;
              e.querySelector('input').addEventListener('change', () => change_suggestion_style(e));
          });
      }

      function change_suggestion_style(e) {
          e.querySelector('del').style.color = e.querySelector('input').checked ? "gray" : "black";
          e.querySelector('ins').style.color = e.querySelector('input').checked ? "black" : "gray";
          convert_view_to_text();
      }

      function convert_view_to_text() {
          const tree = document.querySelector("#view").cloneNode(true);
          Array.from(tree.querySelectorAll('span')).forEach(e =>
              e.querySelector(e.querySelector('input').checked ? 'del' : 'ins').remove());
          document.querySelector("#output-text").value = tree.textContent;
      }

      const set_checkboxes = (checked) =>
            Array.from(document.querySelectorAll('#view span')).forEach(e => set_checkbox(e, checked));
      const set_checkbox = (e, checked) =>
            (e.querySelector('input').checked != checked) && e.querySelector('input').click();
    </script>
  </head>
  <body>
    <h1>テキスト解析Web API 校正支援ツール</h1>
    <textarea id="input-text" placeholder="入力テキスト"></textarea>
    <button onclick="do_kousei()">校正開始</button>
    <div id="view">出力操作パネル</div>
    <button onclick="set_checkboxes(true)">一括承認</button>
    <button onclick="set_checkboxes(false)">承認リセット</button>
    <textarea id="output-text" placeholder="出力テキスト"></textarea>
    <button onclick="navigator.clipboard.writeText(document.getElementById('output-text').value)">コピー</button>
  </body>
</html>

なお、テキスト解析 Web API を利用するには Client ID が必要となります。お手数ですが各自 Client ID を取得してください。サンプルコード内の

const APPID = 'あなたのClient ID(アプリケーションID)';

を取得した Clinet ID へ置き換えてご利用ください。

コードの中身について解説していきます。

CSS (<style>...</style>) と HTML 構造 (<body>...</body>) についての詳細説明は割愛します。 id="input-text" が入力テキストエリア、id="view" が出力操作パネル、id="output-text" が出力テキストエリアということだけ念頭に置いてください。

JavaScript コードに関しては、処理の流れにそって主だった関数だけ解説します。

入力テキストエリアにテキストを入力したのち「校正開始」ボタンを押すと、関数 do_kousei() が呼ばれます。

do_kousei() は入力テキストエリアのテキストを校正支援 API にかけるため、関数 kousei_webapi() を呼びます。

kousei_webapi() は校正支援 API にアクセスし、結果を JSON オブジェクトで返します。

それをもとに make_view() で出力操作パネルを描画します。

ときどき出てくる convert_view_to_text() は出力操作パネルの編集結果を最終稿テキストに変換し出力テキストエリアに表示する関数です。操作によって何かが変更されるたびに呼び出されるため、出力テキストエリアには常に最新のテキストが表示されます。

make_view() は校正支援ツールの肝となる関数です。校正支援 API の結果である全指摘箇所 (suggestions) を入力テキストに適用します。具体的には、入力テキスト (Plain text) に対して、全指摘箇所の承認・否認のスイッチを HTML コードとして挿入していきます。ここで言うスイッチとは、以下のように指摘箇所全体を <span></span> 、元の表記を <del></del> 、変更候補を <ins></ins> で囲み、<input> を追加したものです。

<span title="指摘理由">
<del>元の表記</del>
<ins>変換候補</ins>
<input type="checkbox">
</span>

承認・否認は <input> (チェックボックス) で行うのですが、<del>, <ins> でもクリックイベント発生時に承認・否認を行えるよう addEventListener で設定しています。また、<ins> には contenteditable 属性をセットしているので、変更候補が意に沿わないときなど自由に編集できます。

さて、指摘箇所を HTML 化する際にはテキストの後方に位置する指摘箇所から前方のものへと順次処理していきます。make_view() での suggestionsreverse() がこれにあたります。なぜ後方から処理するかというと、それぞれの指摘箇所は文字列の開始位置で識別されており、前方から処理して HTML コードを挿入していくと次の指摘箇所の本来の開始位置が後ろにずれてしまうからです。

大まかではありますが、以上でコードの解説を終わります。

おわりに

テキスト解析 Web API の「校正支援」は、あくまで校正「支援」です。文脈に依存する判断やユーザ側の校正ルールとの差異など対応できていないものもあるため、指摘箇所や変更候補は必ずしも正しいわけではありません。自分の判断で取捨選択する必要があります。ご留意ください。

テキスト解析 Web API には校正支援以外にも形態素解析、ルビ振り、キーフレーズ抽出、かな漢字変換などさまざまな機能があり、共通のインターフェースでアクセスできるように設計されています(参考)。

これらの Web API で今回のサンプルプログラムをカスタムするのも楽しいかもしれません。例えば追加機能として、「形態素解析」で品詞別出現頻度を出し名詞表現が多くなっていないか、「ルビ振り」で子供向けのテキスト中に難しい読みの漢字がないか等のチェック機能が実現できそうです(こちらの記事が参考になるかと思います)。ぜひチャレンジしてみてください。