JavaScriptとCSVで作る検索システム

更新日2021-09-22
投稿日2021-09-22
タグ職場, JavaScript
投稿者 @amount86

こんばんは。

@amount86です。

今回の投稿は、footlog開発とは無関係の、職場での簡単な開発を記事にしました。


自分はとある官公庁で事務職として働いています。

職場で提供されている業務システムの多くは、キーとなる番号を特定した上でしか照会できないのですが、全文検索できると職員の業務負担をかなり効率化できるな〜と考え、業務のかたわらJavaScriptとCSVで作ってみました。

適当に作った割には、思いの外、完成度が高くできたんじゃないかなと思いまして、今回、記事にしました。

CSVデータは、職場のシステムから定期的にExcel VBAでスクレイピングして、情報取得しています。これについては、調べれば腐るほど情報があるので、ググってみてください。

※職場を特定されるような情報はすべて落としています。また、データもダミーデータに変更しています。

キャプチャ



なぜ、JavaScriptのみで実装したのかというと、以下の2つが大きな理由です。

  • サーバ更改時のメンテナンスが不要
  • 使えるサーバの貧弱さ


(理由1)サーバ更改時のメンテナンスが不要

まず、1つ目の理由ですが、公務員という職場は2年程度で人事異動があります。

人の入れ替わりが激しい職場環境上、極力、インフラ起因のメンテナンスが不要な仕組みにしたかったというの理由です。

過去に作られたプログラムが、サーバ更改やPC更改を機に動かなくなってしまい、放置されているということが散見されていますので。

その点、JavaScriptであれば、ブラウザさえ変わらなければ、基本、下方互換は担保されているので、動かなくなることはほとんどありません。



(理由2)使えるサーバの貧弱さ

2つ目の理由は、使えるCGIサーバの貧弱さにあります。

職場には、職員が自由に使えるWEBサーバがあります(もちろん、職場内に閉じたネットワークです)。

HTMLの静的コンテンツ用のものだけではなく、一応、CGIが動くものが用意されているのですが、メモリの割当が300MBしかなく、部署の100人が同時アクセスして検索すると耐えきれるか不安でした。

であれば、ブラウザ側にすべて処理をよせてしまった方が早いのではないかと思ったのが理由です

ブラウザによって多少の違いはあれど、PC版ブラウザではだいたい2GBほどのメモリが使えるそうです。





以上の理由から、今回の記事の本命である、JavaScriptとCSVだけで検索システムを作りました。

完成版ソースは、GitHubにあげていますのでご覧ください(特定されるような情報はすべて除いた上で、データはダミーデータに置き換えています)。


検索ロジックに使うライブラリーは、以下の2つです。


処理の概要としては、

  1. WEBサーバ上に置いたCSVを、jQueryのajaxで読み取る
  2. 読み取った配列から1レコードずつ取り出す
  3. 検索対象の要素に、ユーザーが指定したフリーワードが含まれているか判断
  4. 含まれていればDOMに挿入

./js/application.js

// ページ読み込み後に呼ぶ処理
$(function() {
  getNotice();
  placeholder();
  pageTop();
});

// 検索を行い、結果表示を行う関数
// 引数 :なし
// 戻り値:なし(DOMの変更)
// 備考 :「検索する」ボタン押下時に呼び出される
function onSearch() {
  // 検索開始
  loadingStart();
  $('#searchButton').prop('disabled', true);
  var number = $('#primaryKey').val();
  var freeWord = $('#freeWord').val();
  var csvPath = './data/documents.csv';
  var count = 0;
  var target = '#resultTable';

  // 結果テーブルを初期化
  $(target).empty();

  var getData = function(count){
    $.get(csvPath).done(function(data) {
      var csv = $.csv.toArrays(data, {separator: ',', delimiter: '\n'});
      $(csv).each(function(_index, element) {
        var searchedWord = element[2].split('<br>').join('').split(' ').join('').split(' ').join('');
        // 空レコードか否かの確認
        if (element.length > 0) {
          // 表示レコード数が300件を超えたか否かの確認
          if (count <= 300) {
            if(checkNumber(number, element[0]) && checkFreeWord(freeWord, searchedWord)){
              var insert = '';
              insert = '<tr><td class="small break-word">' + element[0] + '</td><td class="small break-word">' + dateFormat(element[1]) + '</td><td class="small break-word">' + element[2] + '</td></tr>';
              $(target).append(insert);
              count += 1;
            }
          } else {
            loadingEnd(count);
            return false;
          }
        }
      });
      loadingEnd(count);
    }).fail(function() {
      // 検索終了
      loadingError();
      return false;
    });
  };
  getData(count);
  // get_dataをメモリから解放(意味があるかはわからない)
  delete getData;
}

// 検索状態に表示を変更するための関数
// 引数 :なし
// 戻り値:なし(DOMの変更)
// 備考 :onSearch()から呼ばれる
function loadingStart(){
  $('#loadingEnd').css('display', 'none');
  $('#loadingStart').css('display', 'block');
  $('#result').css('display', 'block');
}

// 検索完了状態に表示を変更するための関数
// 引数 :なし
// 戻り値:なし(DOMの変更)
// 備考 :onSearch()から呼ばれる
function loadingEnd(recordCount){
  $('#loadingStart').css('display', 'none');
  $('#loadingEnd').css('display', 'block');

  if(recordCount == 301) {
    $('#loadingEnd').html('検索完了!<br>検索結果が300件を超えました。日付が新しいものから300件目以降は表示されません。');
  } else {
    $('#loadingEnd').html('検索完了!<br>検索結果は' + recordCount + '件です。');
  }

  $('#searchButton').prop('disabled', false);
}

// サーバエラー状態に表示を変更するための関数
// 引数 :なし
// 戻り値:なし(DOMの変更)
// 備考 :onSearch()から呼ばれる
function loadingError(){
  $('#loadingStart').css('display', 'none');
  $('#loadingEnd').css('display', 'block');
  $('#loadingEnd').html('エラーが発生しました。再度、検索して下さい。');
  $('#searchButton').prop('disabled', false);
}

// 日付表示を編集するための関数
// 引数 :YYYYMMDD(String)
// 戻り値:YYYY/MM/DD(String)
function dateFormat(str) {
  var result;
  if(str.length == 8) {
      result = str.substr(0,4) +"/"+ str.substr(4,2) + "/" +str.substr(6,2);
  } else {
    result = str;
  }
  return result;
}

// ユーザーが入力した番号とマッチするかを判定するための関数
// 引数 :ユーザーが入力した番号(String), ドキュメントの主キー(String)
// 戻り値:真偽値(Boolean)
// 備考 :onSearch()から呼ばれる
function checkNumber(inputNumber, number) {
  var result = false;
  console.log(inputNumber);
  console.log(number);
  if(inputNumber == '' || inputNumber == '検索したい番号を入力してください' || inputNumber == number) {
    result = true;
  }
  return result;
}

// 取得したドキュメントが検索条件にマッチするかを判定するための関数
// 引数 :フリーワード(String), 起案文書(String)
// 戻り値:真偽値(Boolean)
// 備考 :onSearch()から呼ばれる
function checkFreeWord(freeWord, doc) {
  var result = false;
  if(freeWord == '検索したいフリーワードを入力してください') {
    result = true;
  } else if(freeWord.indexOf(',') != -1){
    var freeWordArray = freeWord.split(',');
    var tempBool = true;
    for(var j = 0; j < freeWordArray.length; j++){
      // 全角・半角スペースの削除
      var searchedWord = freeWordArray[j].split(' ').join('').split(' ').join('');
      if(doc.indexOf(searchedWord) == -1){
        tempBool = false;
      }
    }
    if(tempBool){
      result = true;
    }
  } else if(doc.indexOf(freeWord.split(' ').join('').split(' ').join('')) != -1){
    result = true;
  }
  return result;
}

// お知らせ欄にコンテンツを表示するための関数
// 引数 :なし
// 戻り値:なし(DOMの追加)
// 備考 :画面ロード時に呼ばれる
function getNotice() {
  var path = './data/notice.csv';
  var count = 0;
  var getBool = true;
  var target = '#notice';
  var get_data = function(count){
    $.get(path).done(function(data) {
      var csv = $.csv.toArrays(data, {delimiter: '\n'});
      $(csv).each(function(_index, element) {
        // 空レコードか否かの確認
        if (element.length > 0) {
          // 表示レコード数が30件を超えたか否かの確認
          if (count <= 30) {
            var insert = '';
            insert = '<li class="list-group-item py-2 px-3">' + element + '</li>';
            $(target).append(insert);
            count += 1;
          } else {
            getBool = false;
            return false;
          }
        }
      });
    }).fail(function() {
        return false;
    });
  };
  get_data(count);
}

// ページトップに戻るための関数
// 引数 :なし
// 戻り値:なし
// 備考 :100pxスクロールしたらボタンを表示する
function pageTop() {
  var appear = false;
  var pageTop = $('#pageTop');
  $(window).scroll(function () {
    // 100pxスクロールしたら
    if ($(this).scrollTop() > 100) {
      if (appear == false) {
        appear = true;
        //0.3秒かけて現れる
        pageTop.stop().animate({
          //下から85pxの位置に
          'bottom': '85px'
        }, 300);
      }
    } else {
      if (appear) {
        appear = false;
        // 0.3秒かけて隠れる
        pageTop.stop().animate({
          // 下から-85pxの位置に
          'bottom': '-85px'
        }, 300);
      }
    }
  });
  pageTop.click(function () {
    //0.5秒かけてトップへ戻る
    $('body, html').animate({ scrollTop: 0 }, 500);
    return false;
  });
}

// ie9で非対応のplaceholderに対応するたの関数
// 引数 :なし
// 戻り値:なし
// 備考 :ページロード時に呼ばれる
function placeholder() {
  var supportsInputAttribute = function (attr) {
    var input = document.createElement('input');
    return attr in input;
  };
  if (!supportsInputAttribute('placeholder')) {
    $('[placeholder]').each(function () {
    var
      input = $(this),
      placeholderText = input.attr('placeholder'),
      // placeholderColor = 'GrayText',
      placeholderColor = '#BBBBBB';
      defaultColor = input.css('color');
    input.
      focus(function () {
      if (input.val() === placeholderText) {
        input.val('').css('color', defaultColor);
      }
      }).
      blur(function () {
      if (input.val() === '') {
        input.val(placeholderText).css('color', placeholderColor);
      } else if (input.val() === placeholderText) {
        input.css('color', placeholderColor);
      }
      }).
      blur().
      parents('form').
      submit(function () {
        if (input.val() === placeholderText) {
        input.val('');
        }
      });
    });
  }
}



検索ロジックの根幹は、関数onSearch()です。

分かりやすいよう、ソースコードにコメントで処理内容を付加していますので、ご覧ください。

検索したいデータの取得さえできれば、リポジトリのソースコードを多少、加工すれば簡単に検索ツールを導入することが可能です。

function onSearch() {
  // 「検索中・・・」という文字列を表示
  loadingStart();

  // 検索ボタンを二重押下できないよう、検索が終わるまで無効化する
  $('#searchButton').prop('disabled', true);

  // フォームからユーザーが入力した内容を取得して、変数に格納する
  var number = $('#primaryKey').val();
  var freeWord = $('#freeWord').val();

  // データが格納されたCSVのパスを変数に格納
  var csvPath = './data/documents.csv';

  // 検索にヒットした件数を記録するための変数
  var count = 0;

  // 結果を表示するHTMLタグのID
  var target = '#resultTable';

  // 検索結果を初期化
  $(target).empty();

  // 一連の検索処理を変数に格納
  var getData = function(count){

    // jQueryのajaxでCSVからデータを取得
    $.get(csvPath).done(function(data) {

      // jquery-csvを利用して、それぞれの行を列ごとに配列に変換
      var csv = $.csv.toArrays(data, {separator: ',', delimiter: '\n'});

      // 2次元配列に変換したデータを1行ずつループで処理
      $(csv).each(function(_index, element) {

        // 検索時にヒットしないことを防ぐため、検索対象のドキュメントから「改行タグ」と「全角・半角スペース」を削除
        // ※GitHubにあげているダミーデータには上記文字は存在しませんが、私の職場で利用しているデータは上記文字が含まれているため入れている処理です
        var searchedWord = element[2].split('<br>').join('').split(' ').join('').split(' ').join('');

        // 空レコードか否かの確認
        if (element.length > 0) {

          // 全件表示してしまうと、データ量によってはブラウザがクラッシュしてしまうため、表示レコード数を300件とするための処理
          if (count <= 300) {

            // ユーザーが入力した番号又はフリーワードが含まれているか否かのチェック処理
            if(checkNumber(number, element[0]) && checkFreeWord(freeWord, searchedWord)){

              // 表示対象である場合は、HTMLにデータを挿入する
              var insert = '';
              insert = '<tr><td class="small break-word">' + element[0] + '</td><td class="small break-word">' + dateFormat(element[1]) + '</td><td class="small break-word">' + element[2] + '</td></tr>';
              $(target).append(insert);

              // 件数をインクリメント
              count += 1;
            }
          } else {

            // 表示件数上限である300件を超えていた場合は、「表示件数」と「検索が完了した」旨を表示する
            loadingEnd(count);
            return false;
          }
        }
      });

      // 「表示件数」と「検索が完了した」旨を表示する
      loadingEnd(count);
    }).fail(function() {

      // ajaxが失敗した場合は、「エラーが発生した」旨を表示する
      loadingError();
      return false;
    });
  };

  // 一連の検索処理を実行する
  getData(count);

  // get_dataをメモリから解放(意味があるかはわからない)
  delete getData;
}



この検索システムを作ったことにより、職員の業務負担をかなり削減できたことが評価され、この期は一番いい評価をもらうことができました。


ボーナスもほんの少し普段と比べるとよかったですが、それよりも、自分が開発したツールを多くの人にが使ってもらえて、感謝されるのがとても嬉しかったですね。


職場での開発ネタはいくつかあるので、また記事に書く予定です。

誰かの参考になれば嬉しいです。