GoogleフォームとGASと匿名投票

Google

はじめに

Googleフォームで候補者の中から1名を選ぶ匿名投票に近いものを作れないかと調べてみました。

環境

Google Workspaceを使いました。

- Google Workspace
- Google App Script(GAS)
- Google Forms
    - アンケートフォーム
- Google Sheets
    - メールアドレス
    - 回答データ
    - 候補者一覧

欲しい機能

・誰が投票したか分かる。
・重複投稿は認めない。
・回答データと投票者が紐づけできないこと。
・不公平をなくすために、候補者の順番を毎回シャッフルする。

処理の流れ

スプレッドシートに書き込む場合は、GASのスクリプトロックを使って排他制御します。

フォームの標準機能として、プルダウンやラジオボタンには「選択肢の順序をシャッフルする」があります。
候補者が少ない場合はフォームに直接入力して、この機能を使えば良いので、スプレッドシートで準備する必要はないと考えます。
その場合は該当場所をコメントしてください。

大まかな処理は以下のようになります。

  1. フォームから回答データが送られてくる。
  2. 候補者をシャッフルしてフォームの項目に設定する。(次回のフォーム表示時に反映される。)
  3. メールアドレスの重複があれば中止し、フォームに保存された情報を削除する。
  4. メールアドレスをスプレッドシートに記録する。
  5. スプレッドシートに記録したメールアドレスをシャッフルする。
  6. アンケート内容をスプレッドシートに記録する。
  7. フォームに保存された情報を削除する。

メールアドレス重複の有無で送信後のメッセージを変更する

これが可能なら投稿の重複をユーザーに通知できたのですが、GASの仕様から無理でした。
メールアドレスの重複の有無に関わらずメッセージが同じになるため、ユーザー側からは処理されたように見えます。
実際には、メールアドレスが重複した場合は処理を中断します。

以下にその記載があります。
また、フォームの表示時にメールアドレスの重複をチェックできないかと考えたのですが、設定できるトリガーが意図したものではありませんでした。

how to set Confirmation Message based on form Responses in google forms using Apps Script?
In google forms, using google apps script, will I be able to use the values submitted through the fo...

上記には、実行順序は以下とあります。

  1. フォームの値をスプレッドシートに送信する。
  2. 送信後のフォームUIで確認メッセージを表示する。
  3. トリガー「フォーム送信時」に設定したGASを実行する。

まとめると以下のようになります。

  • フォーム送信時にsetConfirmationMessageを実行しても即時反映されない。(次のフォーム表示時に反映)
  • フォーム送信時に実行するGASで、送信後のフォームUIを取得できないため、メッセージダイアログを表示することはできない。
  • フォームで設定する「起動時」のトリガーは、フォーム「作成者」がフォーム「編集画面」を開いたときに発火するトリガーのこと。

フォームの回答をGASで書き込む方法

以下を使わせていただきました。
呼び出し元の関数でロックを行うので、ロック部はコメントしています。

【GAS】Googleフォームの回答をスプレッドシートに書き込む方法 – 理咲ログ
Googleフォームでは、スプレッドシートを回答先に選択することで、回答を自動でシートに書き込める。しかし、場合によっては、この機能を利用せずにシートへ書き込みたいことがある。今回はその方法として、G...

フォームの設定

フォームを作成して、候補者を表示する項目(プルダウン、またはラジオボタン)を作成します。

設定タブ

メールアドレスの重複はGASで判別します。
送信後に「別の回答をする」でなく「回答を記録しました」を表示させるため、「回答を1回に制限する」を有効にします。

「メールアドレスを収集する」をチェックしない場合、フォームにメールアドレスの項目が表示されます。
この場合、作成したGASではログインしたメールアドレスと、入力されたメールアドレスを比較して異なっている場合は、処理を中止します。

回答タブ

自前で作成したスプレッドシートを使うのでリンクはしません。

スプレッドシートの作成

メールアドレスと回答データ、候補者一覧を保存するための3個のスプレッドシートを作成します。
メールアドレスと回答データは、空のスプレッドシートです。

候補者一覧のスプレッドシートは、1列目にフォームに表示するための候補者を入力します。

GASの設定

フォームのスクリプトエディタを開いて、下記のGASを貼り付けます。

GASにある「spreadsheetEmail」、「spreadsheetAnswer」、「spreadsheetCA 」の変数に各スプレッドシートのIDを入力します。
スプレッドシートのIDは、スプレッドシートを開いてURLの「・・・/d/xxxxx/edit#gid=・・・」の部分を探し、dとeditに挟まれた文字列(xxxxxの部分)です。

「トリガー」を設定してアクセス権限を付与します。

フォームに作成した候補者の項目にスプレッドシートに入力したデータを反映させるために、スクリプトエディタの「実行」をクリックしてGASを実行します。
フォームの情報が取得できずにエラーになりますが、問題ありません。
「トリガー」の設定時に承認できていないものがあれば、認証画面が表示されるのでアクセス権限を付与します。

テスト

Googleフォーム、3個のスプレッドシートを開いて、実際にフォームから送信して正常に動作することを確認します。

GASソース

作成したGASのソースです。

function Submit(e)
{
  // メールアドレスを記録するスプレッドシートのID
  //「https://docs.google.com/spreadsheets/d/xxx/edit」のxxxを入力する。
  var spreadsheetEmail = 'xxxxxxxxxxe1ctqR3cxuFR5XTcnpmV_TbPxxxxxxxxxx';

  // 回答データを記録するスプレッドシートのID
  //「https://docs.google.com/spreadsheets/d/xxx/edit」のxxxを入力する。
  var spreadsheetAnswer = 'xxxxxxxxxx2nQT3RIX7N2hLL1zYTqrlwppxxxxxxxxxx';

  // 候補者を記入したスプレッドシートのID
  //「https://docs.google.com/spreadsheets/d/xxx/edit」のxxxを入力する。
  var spreadsheetCA = 'xxxxxxxxxxJDSIP65sn_GC0Pc2UrYjXGyKxxxxxxxxxx';

  // 候補者の項目は1番目とする。
  var questionNo = 1;

  // 候補者の選択項目の種類(true:ラジオボタン  false:プルダウン)
  // 予めフォームで項目を作成しておく。
  flgRadio = false;

  //
  // 以下、触らない。
  //
  var ssEmail    = SpreadsheetApp.openById(spreadsheetEmail);
  var sheetEmail = ssEmail.getActiveSheet();
  var ssData     = SpreadsheetApp.openById(spreadsheetAnswer);
  var sheetData  = ssData.getActiveSheet();
  var ssCA       = SpreadsheetApp.openById(spreadsheetCA);
  var sheetCA    = ssCA.getActiveSheet();
  var range;

  // フォームの取得
  const form = FormApp.getActiveForm();

  /////////////////////////////////////
  // フォームに表示する候補者をシャッフルする。
  // 次回の表示時に反映される。
  /////////////////////////////////////
  // 候補者リストをシャッフルして取得する。
  var lastRowCA  = sheetCA.getLastRow();
  range = sheetCA.getRange(1, 1, lastRowCA);
  range.randomize();
  var listCA = range = sheetCA.getRange(1, 1, lastRowCA).getValues();
  // 全ての質問項目を取得する。
  var items = form.getItems();
  // questionNoで指定した質問項目を指定する。
  var item = items[questionNo - 1];
  // フォームに反映する。
  if ( flgRadio ) {
    // ラジオボタン
    item.asMultipleChoiceItem().setChoiceValues(listCA).showOtherOption(false);
  }
  else {
    // プルダウン
    item.asListItem().setChoiceValues(listCA);
  }

  // Googleフォームの「メールアドレスを収集する」を使用してメールアドレスを取得する。
  var email = e.response.getRespondentEmail();

  // フォームの設定で「信頼できる組織のユーザーに限定する」がOFFの場合に、
  // 入力されたメールアドレスとログインしているメールアドレスが不一致ならば中止する。
  if ( form.requiresLogin() == false ) {
    var emailAvtive = Session.getActiveUser().getEmail();
    if (email != emailAvtive) {
      console.log('×:メールアドレスの不一致:' + emailAvtive + '    ' + email);
      return;
    }
  }

  //
  // 排他制御の設定(スクリプトロック)
  //
  const lock = LockService.getScriptLock();
  // ロックの取得を試みる時間:ミリ秒
  // https://developers.google.com/apps-script/reference/lock?hl=ja
  const tryTime = 10 * 1000;

  // 入力されたメールアドレスがスプレッドシートに記録されているか検索する。
  var textObject = sheetEmail.createTextFinder(email);
  var results    = textObject.findAll();

  // 入力されたメールアドレスが既に記録されていたら処理を中止する。
  if ( results.length ) {
    // Googleフォームからデータを削除する。
    form.deleteAllResponses();

    // 送信後にGoogleフォームに表示するメッセージを上書きする。
    //form.setConfirmationMessage('既に回答済みです。');
    //var ui       = FormApp.getUi();
    //var response = ui.alert('既に回答済みです。');

    console.log('メールアドレス一致数:' + results.length);
    Logger.log('メールアドレスが一致したので停止。');
    return;
  }

  // ロックの取得を試みる。
  if ( ! lock.tryLock(tryTime) ) {
    console.log(tryTime + '秒待機してもロックできなかったので処理中止。');
    return;
  }

  ////////////////////
  // メールアドレスの処理
  ////////////////////
  // 1列目の最終行に記録する。
  var lastRowEmail = sheetEmail.getLastRow();
  sheetEmail.getRange(lastRowEmail + 1, 1).setValue(email);
  //SpreadsheetApp.flush();
  // 1列目をシャッフルする。
  range = sheetEmail.getRange(1, 1, lastRowEmail + 1);
  range.randomize();
  //SpreadsheetApp.flush();

  ////////////////
  // 回答データの処理
  ////////////////
  // データをスプレッドシートに書き込む
  const Writer = new AnswerInfoWriter();
  const Answer = e.response.getItemResponses();
  Writer.Write(sheetData, Answer);

  // スプレッドシートの修正を即時反映する。
  SpreadsheetApp.flush();

  // ロック開放
  lock.releaseLock();

  // Googleフォームからデータを削除する。
  form.deleteAllResponses();

  // 送信後にGoogleフォームに表示するメッセージを上書きする。
  //form.setConfirmationMessage('ご回答いただき、ありがとうございました。');
}

//
// 回答をスプレッドシートに書き込む関数
// https://risaki-masa.com/write-form-answer-to-spreadsheet/
//
// 既に呼び出し元の関数でlockしているので、LOCKの箇所をコメントする。
//
AnswerInfoWriter = function()
{
    function _Write( sheet, item_and_answer_pairs )
    {
        const LOCK = LockService.getScriptLock();

        /**
        if ( ! LOCK.tryLock( 60 * 1000 ) )
        {
            console.log(tryTime + '秒待機してもロックできなかったので処理中止。');
            return;
        }
        */

        // 毎回ヘッダを書き込むのが無駄だが仕方なし。
        _WriteHeader( sheet, item_and_answer_pairs );
        _WriteData  ( sheet, item_and_answer_pairs );
        //SpreadsheetApp.flush();

        //LOCK.releaseLock();
    }

    function _WriteHeader( sheet, item_and_answer_pairs )
    {
        const TITLES = item_and_answer_pairs.map( pair => pair.getItem().getTitle() );
        
        TITLES.forEach( ( title, index ) => 
        {
            const CELL = sheet.getRange( 1, index + 1 );
            CELL.setValue( title );
        } );
    }

    function _WriteData( sheet, item_and_answer_pairs )
    {
        const ROW_NUMBER  = sheet.getLastRow() + 1;
        const ANSWERS     = item_and_answer_pairs.map( pair => pair.getResponse() );

        ANSWERS.forEach( ( answer, index ) => 
        { 
            const CELL = sheet.getRange( ROW_NUMBER, index + 1 );
            CELL.setValue( answer );
        } );
    }

    return {
        Write: _Write,
    };
}

Comments