へっぽこ、ミニマル、DIY menu

静的サイトにGoogleフォームとスプレッドシートで簡易コメント投稿機能をつける方法

footfoot |
静的サイトにGoogleフォームとスプレッドシートで簡易コメント投稿機能をつける方法
静的サイト(このサイト)にコメント投稿機能をつけたかったのでGoogleフォームとGoogleスプレッドシートで作ってみました。そのやり方をご紹介します。

静的サイトにコメント機能をつけられる有名なサービスにDisqusがありますが、自作の静的サイトビルダーのfafantoで使ってみたらうまく動作しなかったんですよね。余計なURLが大量に作られてしまってそれを編集する方法もよくわからずみたいな。サイトのUIも煩雑であまり好きじゃなかった。あと無料版は上位のサイトに広告が付いちゃうみたいですね。

他にもDisqusのようなサービスはあるけどマイナーなサービスはいろいろデメリットがあるので、Googleのサービスで実装できるのならそっちのほうがいいなと。ただ今回のGoogleフォームを使う方法はコメント用のサービスではないのでJavaScriptでコメント投稿機能に仕立てる必要があります。

Googleフォーム製コメント機能のいいところ

  • 必要なのはGoogleアカウントのみ
  • 費用はかからず広告もなし
  • スプレッドシートでコメントを一括管理できてラク
  • コメントがきたらメールでお知らせ機能あり
  • 動作が軽い

Googleフォーム製コメント機能のよくないところ

  • 管理者以外に投稿したコメントを編集できない
  • コメントが正常に投稿されか分からない

簡易とつけた理由は、サイトの管理者しかコメント(スプレッドシート)を編集できなかったり、コメント投稿が成功したか確認できない等、足りてないところがあるためです。まぁでもこの個人サイトで使うには十分かなと。

参考にしたサイト

上記のサイトを参考にさせてもらいつつ簡単なスパム対策や返信(アンカーリンク)等の機能を追加してます。説明が足りてないところやこの機能欲しいとかいらんとかあると思うので参考元のサイトもご覧ください。

Googleフォームを作成

Google Formsにいきましてログイン状態で右上の「フォームに移動」をクリック。空白の「新しいフォームを作成」します。

Googleフォームで空白の新しいフォームを作成

質問を作成

右にあるプラスアイコンで質問を「url」にして作成します。形式は「記述式」で。

Googleフォームで質問を作成

同じように「title」(記事のタイトル)「name」(投稿者の名前)「comment」(コメント本文)「id」「reply id」(返信ID)、も作成。質問名は日本語でも問題ありません。

Googleフォームで複数の質問を作成

最低限必要な項目はURLと名前とコメント本文ですかね。URLはコメントデータをフィルタリングするのに必要になります。

記事のタイトルは全てのコメントを一覧するときに使いそうなので作っておきましたが必要なければ省いていいです。IDと返信IDは返信用のアンカーリンク用です。コメント本文ではHTMLタグはエスケープされてアンカーリンクはつけられない仕様にしたので返信用に設けました。

やり方はいろいろあるので自分用にカスタムしてください。

スプレッドシートを作成

Googleフォームはスプレッドシートに質問のデータを格納する仕様です。上のメニューを「回答」に切り替えて緑色のボタンをクリック。「新しいスプレッドシートを作成」を選択して「作成」。

スプレッドシートを作成する緑のボタン

タイムスタンプがAでBから設定した質問順にスプレッドシートの列になってることを確認。JavaScriptでは大文字のアルファベットでセルを判断します。

作成したスプレッドシートを確認

スプレッドシートを共有する

このままだと外部から読み書きできないので、右上の「共有」をクリックして「制限付き」→「リンクを知ってる全員」にします。

スプレッドシートの共有設定

固有のIDやキーを調べる

JavaScriptでスプレッドシートの読み書きを行うには固有のキーが必要になります。下記のJavaScriptのuniqにそれぞれコピペしてください。

GoogleフォームのID

フォームの右上に「送信」ボタンでフォームの回答ページのURLを開きます。このURLの/e/の後がGoogleフォームのキーになります。次の質問のキーもこの回答ページで取得するのでそのままで。

GoogleフォームのIDを確認

各質問項目のキー

質問のキーは上記のフォームの回答ページのソースコードを見ます。質問の項目にマウスカーソルを合わせて右クリックして「検証」を選択。右側にHTMLのコードが表示されます。親要素のdivdata-params属性があるのですが、この値の配列に中に数字が2か所あります。右側の数字がその質問項目のキーなのでコピペしてメモります。数字をダブルクリックするとピンポイントで選択できました。

これを全質問の項目でやります。

検証でGoogleフォームの質問のキーナンバーを確認 検証でGoogleフォームの質問のキーナンバーをズームして確認

スプレッドシートのID

上記で作成したスプレッドシートのURLの/d/の後のランダムな値がスプレッドシートのIDです。

スプレッドシートのID(URL)を確認

メールの通知設定

コメントがきたらメールを送ってくれる機能をオンにしたい場合は、Googleフォームの「回答」メニューで右端にある点3つのアイコンから「新しい回答についてのメール通知を受け取る」をクリック。解除も同じ。

Googleフォームでメール通知の設定

静的サイトにコメントを設置

このソースコードはこのサイト用なので参考程度にカスタムしてお使いください。CSSは省いてます。

HTMLのコメント要素

<div class="comment">
  <h2>コメント</h2>
  <ol class="list"></ol>
  <b>コメントする</b><span class="info"></span>
  <form id="form"></form>
</div>

HTMLはシンプル。フォームを空のformタグのみにしてるのはスパム対策で、フォームの中の要素はJavaScriptで追加するようにしてます。これだけでbotによるスパムはある程度防げるらしい。

olタグのlistクラスに名前やコメントが並びます。infoクラスはちょっとした注意文などのテキストを切り替えます。

JavaScriptのコメント機能

//HTMLが読み込まれたら実行
document.addEventListener('DOMContentLoaded', () => {
  const CMNT = document.body.querySelector('.comment');
  //commentクラスが存在したら
  if (CMNT) {

    //uniqは固有の値なので変更してください
    const uniq = { 
      gglFormID: 'GoogleフォームのID',
      splSheetID: 'スプレッドシートのID',
      urlKey: 'urlのキー',
      titleKey: 'titleのキー',
      nameKey: 'nameのキー',
      commentKey: 'commnetのキー',
      idKey: 'idのキー',
      replyIdKey: 'reply idのキー'
    }

    const FORM = document.getElementById('form');

    //コメントに必要な要素をformに追加(スパム対策)
    FORM.innerHTML = '<input name="name" placeholder="名前"><textarea name="comment" placeholder="コメント" rows="10" maxlength="400"></textarea><div><button type="button">送信</button><span class="anchor"></span><input type="email" name="email" style="display:none;" title="スパム用"></div>';

    //HTML特殊文字をエスケープ
    const escapeHTML = (str) => {
      return str.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#039;');
    }

    //ランダムなアルファベット8文字を生成(ID用)
    const createRandomID = () => {
      let chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
      let randStr = '';
      for (let i = 0; i < 8; i++) randStr += chars.charAt(Math.floor(Math.random() * chars.length));
      return randStr;
    }

    //スプレッドシートのCSVを2次元配列に変換
    const splCsvToArr = (csv) => {
      const arr = [];
      const rem = csv.substring(1, csv.length - 1);
      const rows = rem.split('"\n"');
      for (const row of rows) arr.push(row.split('","'));
      for (const num of [2,3]) for (const col of arr) col[num] = col[num].replaceAll('""', '"');
      return arr;
    }

    const LIST = CMNT.querySelector('.list');
    const INFO = CMNT.querySelector('.info');
    const ANCH = FORM.querySelector('.anchor');
    const url = location.pathname;
    const query = encodeURIComponent(`select A, D, E, F, G where B = '${url}' order by A`); //Google Query関数を参照

    //スプレッドシートを読み込む
    const loadSplSheet = async () => {
      const response = await fetch(`https://docs.google.com/spreadsheets/d/${uniq.splSheetID}/gviz/tq?tqx=out:csv&tq=${query}&headers=0`); //headers=0で最初の列を除外
      if (response.ok) {
        const csv = await response.text();
        if (csv) {
          const data = splCsvToArr(csv);
          LIST.innerHTML = '';

          //リストにコメントを表示
          for (let i = 0; i < data.length; i++) {
            let reply = '';
            if (data[i][4]) {
            const replyElem = document.getElementById(data[i][4]);
            if (replyElem) reply = `<a class="reply" href="#${data[i][4]}">>>${replyElem.dataset.num}</a>`;
            else reply = `<small class="reply">>>返信元のコメントは削除されたようです...</small>`;
            }
            LIST.innerHTML += `<li id="${data[i][3]}" data-num="${i+1}"><div>${i+1}.<b>${escapeHTML(data[i][1])}</b><small>${data[i][0]}</small><a href="#form">返信</a></div>${reply}<pre>${escapeHTML(data[i][2])}</pre></li>`;
          }

          //各コメントの返信をクリック
          for (const a of LIST.querySelectorAll(`li > div > a`)) a.onclick = (e) => {
            const li = e.target.parentNode.parentNode;
            ANCH.innerHTML = `<i title="アンカーリンクを削除">Ⓧ</i><a href="#${li.id}" data-rep="${li.id}">>>${li.dataset.num}</a>`;
            ANCH.querySelector('i').onclick = () => ANCH.innerHTML = '';
          }

        } else LIST.innerHTML = '<div style="text-align:center;">コメントはまだありません</div>';
      } else INFO.textContent = '⚠ コメントの取得に失敗しました...時間をおいてリロードしてください。';
    }
    loadSplSheet();

    //送信ボタンをクリック
    FORM.querySelector('button').onclick = async (e) => {
      if (FORM.elements['email'].value) throw console.log('スパムを検出しました'); //罠のinputに値が入ってるとエラーになる
      const nVal = FORM.elements['name'].value;
      const cVal = FORM.elements['comment'].value;
      if (!nVal) throw alert('⚠ 名前の入力が空です');
      if (!cVal) throw alert('⚠ コメントの入力が空です');
      const blackWords = ['バカ','馬鹿','死ね','","','"\n"']; //禁止ワードを設定(最後の2つはsplCsvToArr用)
      if (blackWords.some((bw) => nVal.includes(bw))) throw alert('⚠ 名前に不適切なワードが含まれてます');
      if (blackWords.some((bw) => cVal.includes(bw))) throw alert('⚠ コメントに不適切なワードが含まれてます');
      e.target.setAttribute('disabled', 'true'); //ボタンを無効にして連続クリック防止
      const thisID = createRandomID();
      const title = encodeURIComponent(document.querySelector('h1').textContent); //記事のタイトルを取得
      const reply = FORM.querySelector('span > a')?.dataset.rep ?? '';

      //コメントデータをスプレッドシートに保存
      fetch(`https://docs.google.com/forms/d/e/${uniq.gglFormID}/formResponse`, {
        method: 'POST',
        mode: 'no-cors',
        headers: {
        'Content-Type': 'application/x-www-form-urlencoded'
        },
        body: `entry.${uniq.urlKey}=${url}&entry.${uniq.titleKey}=${title}&entry.${uniq.nameKey}=${encodeURIComponent(nVal)}&entry.${uniq.commentKey}=${encodeURIComponent(cVal)}&entry.${uniq.idKey}=${thisID}&entry.${uniq.replyIdKey}=${reply}`
      });
      INFO.textContent = 'ⓘ コメント投稿中...';

      // 2.5秒後にスプレッドシートを読み込んでリストに表示
      setTimeout( async () => {
        await loadSplSheet();
        e.target.removeAttribute('disabled'); //ボタン復活
        if (LIST.querySelector(`#${thisID}`)) {
          INFO.textContent = 'ⓘ コメント成功!';
          FORM.elements['comment'].value = '';
          FORM.querySelector('span').innerHTML = '';
        }
        else INFO.textContent = '⚠ リロードしてコメントが正常に投稿されてるか確認してください。';
      }, 2500);
    }
  }
});

botのスパム対策

機械的にサイトを巡回してスパム行為をしてくるbot対策は、フォームの要素をJavaScriptで追加するようにしたのともう一つ、人間には見えないinputタグにname="email"と属性をつけて仕込みまして、このinputタグにbotが値を入れたら送信できなくなる罠も仕掛けました。こんな簡単な罠でも効果あるみたい。簡易的な対策だけどとりあえずこれで様子見中。

スプレッドシートのCSVについて

スプレッドシートのダウンロード形式はCSV以外にも対応してるので、試しにHTMLでダウンロードしてみたらデータサイズがCSVの10倍近くありました。HTMLならDOMParserが使えるので変換がラクなんですけど、軽い方がいいのでsplCsvToArr関数を作ってCSVをちょっと強引に2次元配列にしてます。ちゃんと変換するならライブラリ使ったほうがよさそう。

それとこのJSでダウンロードしたCSVは値の囲いに"a","b","c"みたいにダブルクォーテーションが使われてました。ブラウザからダウンロードするとなぜかダブルクォーテーションなし。CSVの仕様上どちらもあるみたい。ダブルクォーテーションあったほうが変換しやすいのでいいですが。それとダウンロードする時に値の中にダブルクォーテーションがあるとエスケープ?されて2重になるので取り除いてます。

Query関数でロードするデータを抽出

Query関数(スプレッドシートQuery関数)はURLに仕込んでスプレッドシートに命令できる機能で、スプレッドシートのセルのアルファベット(大文字)で必要なコメントデータだけ抽出します。query変数がその命令文で、selectは取得するセルの選択、whereは同じURLのみを抽出、order byはタイムスタンプの時間でソートをしてます。調べながらなんとかやりたことはできたけど難しい..。他にもいろいろ操作できるようです。

コメントが正常に保存されたかを知りたい...

Googleフォーム製コメント投稿機能の欠点ですけど、コメントが正常に保存されたかどうか返す機能がないようです(たぶん)。なので2.5秒後にスプレッドシートを読み込んで投稿したIDが存在するかを判断してます。2.5秒以内に保存される保証もないので微妙だけど他にいい方法あるかな?

よだん

テストしてみるとスプレッドシートに直接読み書きしてるように見えるのでGoogleフォームって必要あるのかな?と思ったんですけど、本当にスプレッドシートだけでやる場合はGoogle Cloud Platformでやらないといけないっぽいです。その方が余計な処理(Google側で)がなくてシンプルな気もしますが作るのは難しそうだなぁ。

この記事のコメント欄も上記のソースコードでできてるので動作確認してみてください。

コメント

    コメントする