GASワーク型研修・コード配布

Geminiと作る
おみくじWebアプリ

プロンプトと完成版コードを、ボタン1つでコピーできます。 GoogleスプレッドシートとApps Scriptを開いた状態で、上から順に進めてください。

初心者向け スマホ対応 外部ライブラリ不要 スプレッドシート連携

作り始める前に、4つの画面が開くか確認します

カードをクリックすると、それぞれの画面が新しいタブで開きます。

この4つのURLをブックマークしておくと便利です。

最初に準備するもの

Googleスプレッドシートを1つ用意すれば始められます。

Googleスプレッドシート

おみくじの内容と、引いた履歴を保存します。

Apps Script

スプレッドシートから「拡張機能」→「Apps Script」で開きます。

Gemini

下のプロンプトをコピーし、コード作成と修正を体験します。

研修の流れ

  1. 空のスプレッドシートを作成分かりやすい名前を付けて保存します。
  2. Geminiへ1回目のプロンプトを送信まず、シンプルなおみくじを作ります。
  3. 動作を確認コード.gsindex.htmlをApps Scriptへ貼り付けます。
  4. Geminiへ2回目のプロンプトを送信スプレッドシート連携へ発展させます。
  5. 完成版コードと見比べるうまくいかない場合は、このページの完成版を使えます。
このサンプルにIDの差し替えはありません

完成版はgetActiveSpreadsheet()で、Apps Scriptを開いた元のスプレッドシートを自動取得します。 スプレッドシートIDやAPIキーは、コードに直接書いていません。

【 】があるコードを使う場合のルール

今後、教材内のコードに【スプレッドシートID】【APIキー】が出てきた場合は、 【 】を含めて自分の値へ差し替えます。実際のID、APIキー、パスワード、顧客情報は、公開ページへ掲載しないでください。

SpreadsheetApp.openById('【スプレッドシートID】')

例:上の【スプレッドシートID】全体を、自分のIDへ置き換えます。この教材の完成版では、この書き方は使いません。

Geminiへ入れるプロンプト

「プロンプトをコピー」を押し、Geminiの入力欄へ貼り付けます。

1回目:シンプルなおみくじを作る

Gemini用プロンプト 1
GoogleスプレッドシートのApps Scriptで動く、おみくじWebアプリを作ってください。

・ファイルは「コード.gs」と「index.html」の2つ
・doGet()でindex.htmlを表示するWebアプリにする
・ボタンを押すと、運勢(大吉〜大凶)と一言が1つ出る
・見た目は和風。スマホでも見やすくする
・外部ライブラリやCDNは使わない
・コードは省略せず、2ファイルの全文を出す

2回目:スプレッドシートと連携する

Gemini用プロンプト 2
このおみくじを、スプレッドシートと連携するように直してください。

・シート「マスタ」:列は 運勢 / 重み / 一言
・重みの比率で運勢を選び、その行の一言を表示する
・シート「履歴」:引くたびに 日時 / 運勢 / 一言 / 名前 を1行追記する
・シートは getActiveSpreadsheet() で取得する
・シートが無ければ、初期データ入りで自動作成する

初期データ:大吉10、中吉20、小吉25、吉25、末吉15、凶4、大凶1
・日時は日本時間。スプレッドシートのタイムゾーンを Asia/Tokyo にする
・マスタは手で書き換える。空欄や文字が入った重みは読み飛ばす
・アプリ画面に「名前」の入力欄を追加して履歴へ記録する
・画面下部にスプレッドシートへのリンクを置く
・コードを省略せず、コード.gsとindex.htmlの全文を出す
Geminiの回答が完成版と違っても問題ありません

生成AIは、同じ指示でも毎回少し違うコードを作ります。まず自分のコードを試し、 エラーになった場合や研修時間内にそろえたい場合は、次の完成版コードへ切り替えてください。

完成版コードを貼り付ける

コードは2ファイルです。それぞれのボタンで全文をコピーできます。

貼り付け前の確認

コード.gsに最初から入っている内容は、いったんすべて消してから貼り付けます。 HTMLファイルは左側の「+」から追加し、ファイル名を半角小文字のindexにしてください。

ファイル1:コード.gs

コード.gs
/**
 * おみくじWebアプリ
 * スプレッドシートにひも付けたApps Scriptで使用します。
 */

var CONFIG = {
  TIME_ZONE: 'Asia/Tokyo',
  DEFAULT_NAME: '名無し',
  MAX_NAME_LENGTH: 20,

  // true:画面下部にスプレッドシートへのリンクを表示
  // false:リンクを表示しない
  SHOW_SPREADSHEET_LINK: true
};

/**
 * 「マスタ」と「履歴」シートを取得します。
 * 無ければ初期データ入りで作成します。
 * @return {{spreadsheet: Spreadsheet, master: Sheet, history: Sheet}}
 */
function getOrCreateSheets() {
  var lock = LockService.getDocumentLock();
  lock.waitLock(10000);

  try {
    var ss = SpreadsheetApp.getActiveSpreadsheet();

    if (!ss) {
      throw new Error(
        'スプレッドシートから「拡張機能」→「Apps Script」の順に開いてください。'
      );
    }

    ss.setSpreadsheetTimeZone(CONFIG.TIME_ZONE);

    var masterSheet = ss.getSheetByName('マスタ');
    if (!masterSheet) {
      masterSheet = ss.insertSheet('マスタ');

      var defaultMasterData = [
        ['運勢', '重み', '一言'],
        ['特選 大吉', 10, '本醸造の極上醤油のように、あなたの魅力が深く熟成される一日。何事も味わい深い最高の結果に!しょーゆーこと!'],
        ['中吉', 20, 'じっくり熟成が進む良い運気。焦らず煮物のようにじっくりコトコト進めると、深みのある成果が出ます。'],
        ['小吉', 25, '隠し味のような小さな幸せが見つかる日。日々の料理に一差し加える工夫が、思わぬ幸運を呼び込みます。'],
        ['吉', 25, '安定したいつもの味。奇をてらわず、基本を大切に過ごすことで穏やかで心地よい一日になります。'],
        ['末吉', 15, '少し塩分(刺激)が足りないかも?日常に新しいスパイスや挑戦をひとたらししてみると運気が上向きます。'],
        ['凶', 4, '煮詰めすぎて焦げつき注意!思い通りにいかない時は一息ついて、出汁(周りのサポート)を取り直しましょう。'],
        ['大凶', 1, '醤油のフタが外れてドバッと出ちゃったような日。でも大丈夫!洗い流して出直せば、次はもっと美味しくなります。']
      ];

      masterSheet
        .getRange(1, 1, defaultMasterData.length, 3)
        .setValues(defaultMasterData);
      masterSheet.setFrozenRows(1);
      masterSheet.getRange('A1:C1').setFontWeight('bold');
      masterSheet.autoResizeColumns(1, 3);
    }

    var historySheet = ss.getSheetByName('履歴');
    if (!historySheet) {
      historySheet = ss.insertSheet('履歴');
      historySheet.getRange(1, 1, 1, 4).setValues([
        ['日時', '運勢', '一言', '名前']
      ]);
      historySheet.setFrozenRows(1);
      historySheet.getRange('A1:D1').setFontWeight('bold');
      historySheet.getRange('A:A').setNumberFormat('yyyy/MM/dd HH:mm:ss');
      historySheet.autoResizeColumns(1, 4);
    }

    return {
      spreadsheet: ss,
      master: masterSheet,
      history: historySheet
    };
  } finally {
    lock.releaseLock();
  }
}

/**
 * Webアプリへアクセスしたときにindex.htmlを表示します。
 * @return {HtmlOutput}
 */
function doGet() {
  var sheets = getOrCreateSheets();
  var template = HtmlService.createTemplateFromFile('index');

  // IDをコードへ直接書かず、現在のスプレッドシートURLを取得します。
  template.spreadsheetUrl = sheets.spreadsheet.getUrl();
  template.showSpreadsheetLink = CONFIG.SHOW_SPREADSHEET_LINK;

  return template
    .evaluate()
    .setTitle('しょーゆーこと占い | 老舗醤油屋の運勢鑑定')
    .addMetaTag('viewport', 'width=device-width, initial-scale=1.0')
    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}

/**
 * おみくじを1回引き、履歴へ保存します。
 * index.htmlのgoogle.script.runから呼び出されます。
 * @param {string} userName 入力された名前
 * @return {{fortune: string, message: string, name: string}}
 */
function drawOmikuji(userName) {
  var sheets = getOrCreateSheets();
  var masterSheet = sheets.master;
  var historySheet = sheets.history;
  var name = normalizeUserName_(userName);

  var lastRow = masterSheet.getLastRow();
  if (lastRow < 2) {
    throw new Error('マスタシートにデータがありません。');
  }

  var masterValues = masterSheet
    .getRange(2, 1, lastRow - 1, 3)
    .getValues();

  var validList = [];
  var totalWeight = 0;

  for (var i = 0; i < masterValues.length; i++) {
    var fortune = masterValues[i][0];
    var weight = Number(masterValues[i][1]);
    var message = masterValues[i][2];

    // 空欄、数値以外、0以下の重みは読み飛ばします。
    if (!fortune || !message || isNaN(weight) || weight <= 0) {
      continue;
    }

    validList.push({
      fortune: String(fortune),
      weight: weight,
      message: String(message)
    });
    totalWeight += weight;
  }

  if (validList.length === 0 || totalWeight <= 0) {
    throw new Error('有効なおみくじマスタが見つかりません。');
  }

  var randomNumber = Math.random() * totalWeight;
  var currentWeight = 0;
  var selectedResult = validList[validList.length - 1];

  for (var j = 0; j < validList.length; j++) {
    currentWeight += validList[j].weight;
    if (randomNumber < currentWeight) {
      selectedResult = validList[j];
      break;
    }
  }

  // 同時に複数人が引いても履歴が重ならないよう、書き込みをロックします。
  var writeLock = LockService.getDocumentLock();
  writeLock.waitLock(10000);

  try {
    historySheet.appendRow([
      new Date(),
      toSafeSheetText_(selectedResult.fortune),
      toSafeSheetText_(selectedResult.message),
      toSafeSheetText_(name)
    ]);
  } finally {
    writeLock.releaseLock();
  }

  return {
    fortune: selectedResult.fortune,
    message: selectedResult.message,
    name: name
  };
}

/**
 * 名前を整え、長すぎる入力や制御文字を除きます。
 * 末尾の「_」は外部から直接呼ばせないためのApps Scriptの慣例です。
 * @param {*} value
 * @return {string}
 */
function normalizeUserName_(value) {
  var name = String(value || '')
    .replace(/[\u0000-\u001F\u007F]/g, '')
    .trim()
    .slice(0, CONFIG.MAX_NAME_LENGTH);

  return name || CONFIG.DEFAULT_NAME;
}

/**
 * 「=」などで始まる文字を数式として実行させないための対策です。
 * @param {*} value
 * @return {string}
 */
function toSafeSheetText_(value) {
  var text = String(value);
  return /^[=+\-@]/.test(text) ? "'" + text : text;
}

ファイル2:index.html

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <base target="_top">
  <meta charset="utf-8">
  <title>しょーゆーこと占い</title>
  <style>
    :root {
      --bg-color: #f7f3ed;
      --primary-color: #3d1e11;
      --accent-color: #a82e2e;
      --wood-color: #8c6239;
      --card-bg: #ffffff;
    }

    * {
      box-sizing: border-box;
      margin: 0;
      padding: 0;
    }

    body {
      min-height: 100vh;
      display: flex;
      align-items: center;
      justify-content: center;
      padding: 16px;
      background: var(--bg-color);
      color: var(--primary-color);
      font-family: "Hiragino Mincho ProN", "Yu Mincho", "MS PMincho", serif;
    }

    .container {
      position: relative;
      width: 100%;
      max-width: 480px;
      padding: 24px 16px;
      border: 3px double var(--primary-color);
      border-radius: 8px;
      background: var(--card-bg);
      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
      text-align: center;
    }

    .shop-header {
      margin-bottom: 20px;
      padding-bottom: 16px;
      border-bottom: 2px solid var(--wood-color);
    }

    .shop-subtitle {
      margin-bottom: 4px;
      color: var(--wood-color);
      font-size: 0.85rem;
      letter-spacing: 0.15em;
    }

    .shop-title {
      color: var(--primary-color);
      font-size: 1.8rem;
      letter-spacing: 0.1em;
    }

    .icon-container {
      margin: 16px 0;
    }

    .soy-barrel {
      display: flex;
      width: 70px;
      height: 70px;
      align-items: center;
      justify-content: center;
      margin: 0 auto;
      border: 3px solid var(--primary-color);
      border-radius: 50%;
      background: var(--bg-color);
      color: var(--accent-color);
      font-size: 1.4rem;
      font-weight: bold;
    }

    .description {
      margin-bottom: 20px;
      font-size: 0.9rem;
      line-height: 1.6;
    }

    .input-group {
      width: 85%;
      margin: 0 auto 20px;
      text-align: left;
    }

    .input-label {
      display: block;
      margin-bottom: 6px;
      color: var(--wood-color);
      font-size: 0.85rem;
      font-weight: bold;
    }

    .name-input {
      width: 100%;
      padding: 10px 12px;
      border: 1px solid var(--wood-color);
      border-radius: 4px;
      outline: none;
      background: var(--bg-color);
      color: var(--primary-color);
      font: inherit;
      font-size: 1rem;
    }

    .name-input:focus {
      border-color: var(--accent-color);
      box-shadow: 0 0 4px rgba(168, 46, 46, 0.3);
    }

    .draw-btn {
      width: 85%;
      max-width: 280px;
      padding: 14px 32px;
      border: 0;
      border-radius: 4px;
      background: var(--accent-color);
      box-shadow: 0 3px 6px rgba(168, 46, 46, 0.3);
      color: #ffffff;
      cursor: pointer;
      font: inherit;
      font-size: 1.1rem;
      font-weight: bold;
      letter-spacing: 0.1em;
    }

    .draw-btn:active {
      transform: translateY(2px);
      box-shadow: none;
    }

    .draw-btn:disabled {
      background: #cccccc;
      cursor: not-allowed;
    }

    .result-area {
      display: none;
      margin-top: 24px;
      padding: 20px 12px;
      border: 2px dashed var(--wood-color);
      border-radius: 6px;
      background: var(--bg-color);
      animation: fadeIn 0.5s ease-in-out;
    }

    @keyframes fadeIn {
      from {
        opacity: 0;
        transform: translateY(10px);
      }
      to {
        opacity: 1;
        transform: translateY(0);
      }
    }

    .result-header {
      margin-bottom: 8px;
      color: var(--wood-color);
      font-size: 0.9rem;
    }

    .fortune-title {
      margin-bottom: 12px;
      color: var(--accent-color);
      font-size: 2rem;
      font-weight: bold;
      letter-spacing: 0.15em;
    }

    .fortune-message {
      padding: 0 8px;
      font-size: 0.95rem;
      line-height: 1.7;
      text-align: left;
    }

    .loading {
      display: none;
      margin-top: 20px;
      color: var(--wood-color);
      font-size: 0.9rem;
    }

    .footer {
      margin-top: 24px;
      color: var(--wood-color);
      font-size: 0.75rem;
    }

    .sheet-link {
      display: inline-block;
      margin-top: 12px;
      color: var(--accent-color);
      font-weight: bold;
      text-underline-offset: 3px;
    }
  </style>
</head>
<body>
  <main class="container">
    <header class="shop-header">
      <div class="shop-subtitle">老舗醤油屋 謹製</div>
      <h1 class="shop-title">しょーゆーこと占い</h1>
    </header>

    <div class="icon-container" aria-hidden="true">
      <div class="soy-barrel">醤</div>
    </div>

    <p class="description">
      お名前を入力しておみくじをお引きください。<br>
      本日の運勢と心に染み渡る一言をお届けします。
    </p>

    <div class="input-group">
      <label class="input-label" for="userName">お名前(任意)</label>
      <input
        type="text"
        id="userName"
        class="name-input"
        placeholder="例:醤油 太郎"
        maxlength="20"
        autocomplete="name"
      >
    </div>

    <button id="drawBtn" class="draw-btn" type="button">
      おみくじを引く
    </button>

    <div id="loading" class="loading" role="status" aria-live="polite">
      醤油を醸造中...
    </div>

    <section
      id="resultArea"
      class="result-area"
      aria-live="polite"
      aria-atomic="true"
    >
      <div id="resultHeader" class="result-header"></div>
      <div id="fortuneTitle" class="fortune-title"></div>
      <div id="fortuneMessage" class="fortune-message"></div>
    </section>

    <footer class="footer">
      <div>&copy; しょーゆーこと占い</div>

      <? if (showSpreadsheetLink) { ?>
        <a
          class="sheet-link"
          href="<?= spreadsheetUrl ?>"
          target="_blank"
          rel="noopener noreferrer"
        >
          おみくじのスプレッドシートを開く
        </a>
      <? } ?>
    </footer>
  </main>

  <script>
    document
      .getElementById('drawBtn')
      .addEventListener('click', runOmikuji);

    document
      .getElementById('userName')
      .addEventListener('keydown', function (event) {
        if (event.key === 'Enter') {
          runOmikuji();
        }
      });

    function runOmikuji() {
      var button = document.getElementById('drawBtn');
      var loading = document.getElementById('loading');
      var resultArea = document.getElementById('resultArea');
      var userName = document.getElementById('userName').value;

      button.disabled = true;
      resultArea.style.display = 'none';
      loading.style.display = 'block';

      google.script.run
        .withSuccessHandler(showResult)
        .withFailureHandler(showError)
        .drawOmikuji(userName);
    }

    function showResult(data) {
      var button = document.getElementById('drawBtn');
      var loading = document.getElementById('loading');
      var resultArea = document.getElementById('resultArea');

      document.getElementById('resultHeader').textContent =
        '― ' + data.name + ' 様の運勢 ―';
      document.getElementById('fortuneTitle').textContent = data.fortune;
      document.getElementById('fortuneMessage').textContent = data.message;

      loading.style.display = 'none';
      resultArea.style.display = 'block';
      button.disabled = false;
      button.textContent = 'もう一度引く';
    }

    function showError(error) {
      var button = document.getElementById('drawBtn');
      var loading = document.getElementById('loading');
      var message = error && error.message
        ? error.message
        : 'もう一度お試しください。';

      loading.style.display = 'none';
      window.alert('エラーが発生しました:' + message);
      button.disabled = false;
    }
  </script>
</body>
</html>
名前は履歴へ保存されます

研修中は本名ではなく、ニックネームでも構いません。一般公開する場合は、入力された名前が スプレッドシートへ記録されることを利用者へ伝え、不要な個人情報を入力させない運用にしてください。

変更しやすい場所

変更したい内容 変更するファイル 探す文字
占いのタイトル コード.gsindex.html しょーゆーこと占い
運勢・重み・一言 作成されたスプレッドシート 「マスタ」シートの各行
スプレッドシートへのリンク コード.gs SHOW_SPREADSHEET_LINK: truefalseへ変更
色や見た目 index.html :root内の色コード

Webアプリとして公開する

保存後、Apps Scriptの画面からWebアプリをデプロイします。

  1. 右上の「デプロイ」→「新しいデプロイ」種類の選択で「ウェブアプリ」を選びます。
  2. 実行するユーザーを確認研修では、作成者のスプレッドシートへ履歴を書けるよう「自分」を基本にします。
  3. アクセスできるユーザーを選ぶ研修会場や組織のルールに合わせて選びます。不特定多数への一般公開が不要なら、公開範囲を広げすぎないでください。
  4. 「デプロイ」を押して権限を承認表示されたWebアプリのURLを新しいタブで開きます。
  5. おみくじを引いて確認「履歴」シートへ、日時・運勢・一言・名前が追加されれば完成です。
コードを直した後は、公開版の更新も必要です

保存しただけでは、すでに公開しているWebアプリへ修正が反映されない場合があります。 「デプロイ」→「デプロイを管理」から編集し、新しいバージョンとして更新してください。

最終チェック

参考:Google公式情報