スマホで完結!アマチュア無線の自作ログアプリ(GAS+スプレッドシート)でHAMLOG連携

【決定版】スマホで動く自作HAMLOG連携Webアプリ!GAS+スプレッドシートで快適ロギング

八重洲のFT5Dをはじめ、アイコムやケンウッドなどのハンディ機を手に屋外運用する際、スマホから直感的に交信記録(QSOログ)を入力できる自作Webアプリの構築手順です[cite: 3, 5, 14]。

画面上の数字やモードを指で「カチカチッ」と上下に滑らせて選択できる自作スワイプドラムUIを搭載し、帰宅後はワンクリックでTurbo HAMLOG(ターボハムログ)標準21項目仕様のCSVを一括出力できます[cite: 9]。

本システムの主な機能

  • ガジェット風スワイプドラムUI:周波数・RSTレポート・モード選択は、ポップアップが出ない指フリック式の立体ドラム構造。
  • マルチモード対応:FMやC4FMに加え、D-STAR・SSB・CW・AMにも対応し、あらゆる無線機・運用スタイルで利用可能[cite: 1, 2, 3]。
  • 過去交信履歴の自動照会:コールサイン入力時にスプレッドシート内を検索し、同局との過去QSO履歴(日付・周波数・名前・QTH等)を表示。
  • HAMLOG完全互換(21項目):名前(項目11)・QTH(項目12)・備考(項目13)をHAMLOGの正しい列に自動マッピング[cite: 9]。
  • 直接ダウンロード発火:Googleドライブの権限エラーを回避し、ブラウザのメモリからShift-JIS形式CSVを即時保存。

STEP 1:Googleスプレッドシートの準備

  1. Googleドライブでスプレッドシートを新規作成し、ファイル名を「交信ログ」などに設定します。
  2. 1行目(ヘッダー行)に以下の見出しを入力します。
    A列:日付 | B列:時刻 | C列:コールサイン | D列:RST(送) | E列:RST(受) | F列:モード | G列:周波数 | H列:出力 | I列:お名前 | J列:運用場所(QTH) | K列:備考

STEP 2:Google Apps Script(GAS)完全コードの配置

スプレッドシート上部メニューの「拡張機能」>「Apps Script」を開き、既存のコードを消して以下のプログラムを丸ごと貼り付けて保存します。

function onOpen() {
  var ui = SpreadsheetApp.getUi();
  ui.createMenu('HAMLOG拡張機能')
    .addItem('HAMLOG用CSVを出力', 'exportHamlogCsvMenu')
    .addToUi();
}

function exportHamlogCsvMenu() {
  var csvText = generateHamlogCsvText();
  if (!csvText) {
    SpreadsheetApp.getUi().alert("出力対象の交信データがありません。");
    return;
  }
  
  var blob = Utilities.newBlob("").setDataFromString(csvText, "Shift_JIS");
  var base64Data = Utilities.base64Encode(blob.getBytes());
  var fileName = "hamlog_export_" + Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyyMMdd_HHmmss') + ".csv";

  var htmlContent = '<!DOCTYPE html><html><head><base target="_top">' +
    '<style>body{font-family:sans-serif;padding:15px;text-align:center;} button{padding:12px 24px;background:#007BFF;color:white;border:none;border-radius:6px;font-size:16px;font-weight:bold;cursor:pointer;}</style>' +
    '</head><body>' +
    '<p style="font-size:16px;font-weight:bold;color:#28a745;">CSVデータを作成しました!</p>' +
    '<p style="font-size:14px;color:#555;">自動で保存されない場合は下のボタンを押してください。</p>' +
    '<button id="dlBtn" onclick="downloadCsv()">今すぐCSVをダウンロード</button>' +
    '<script>' +
    'function downloadCsv(){' +
    '  var b64 = "' + base64Data + '";' +
    '  var bin = atob(b64);' +
    '  var len = bin.length;' +
    '  var bytes = new Uint8Array(len);' +
    '  for (var i = 0; i < len; i++) { bytes[i] = bin.charCodeAt(i); }' +
    '  var blob = new Blob([bytes], { type: "text/csv;charset=shift_jis;" });' +
    '  var link = document.createElement("a");' +
    '  link.href = URL.createObjectURL(blob);' +
    '  link.download = "' + fileName + '";' +
    '  document.body.appendChild(link);' +
    '  link.click();' +
    '  document.body.removeChild(link);' +
    '}' +
    'window.onload = function(){ downloadCsv(); };' +
    '</script>' +
    '</body></html>';

  var htmlOutput = HtmlService.createHtmlOutput(htmlContent)
    .setWidth(380)
    .setHeight(180);
    
  SpreadsheetApp.getUi().showModalDialog(htmlOutput, 'HAMLOG用CSVダウンロード');
}

function generateHamlogCsvText() {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  var data = sheet.getDataRange().getValues();
  var csvLines = [];
  
  for (var i = 1; i < data.length; i++) {
    var row = data[i];
    if (!row[2]) continue;
    
    var d = row[0];
    var dateStr = "";
    if (d instanceof Date) {
      dateStr = Utilities.formatDate(d, 'Asia/Tokyo', 'yy/MM/dd');
    } else {
      var match = String(d).match(/\d+/g);
      if (match && match.length >= 3) {
        var yy = match[0].slice(-2);
        var mm = ("0" + match[1]).slice(-2);
        var dd = ("0" + match[2]).slice(-2);
        dateStr = yy + "/" + mm + "/" + dd;
      } else {
        dateStr = String(d);
      }
    }
    
    var t = row[1];
    var timeStr = "";
    if (t instanceof Date) {
      timeStr = Utilities.formatDate(t, 'Asia/Tokyo', 'HH:mm') + "J";
    } else {
      var tVal = String(t).trim();
      timeStr = tVal ? (tVal.endsWith("J") ? tVal : tVal + "J") : "";
    }
    
    var callsign = String(row[2]).toUpperCase().trim();
    var rst_s = String(row[3]);
    var rst_r = String(row[4]);
    var mode = String(row[5]);
    var freq = String(row[6]);
    var name = String(row[8] || "");
    var qth = String(row[9] || "");
    var remarks = String(row[10] || "");
    
    var fields = [
      callsign, dateStr, timeStr, rst_s, rst_r, freq, mode,
      "", "", "", name, qth, remarks, "", "", "", "", "", "", "", ""
    ];
    
    var line = fields.map(function(f) {
      return '"' + String(f).replace(/"/g, '""') + '"';
    }).join(",");
    
    csvLines.push(line);
  }
  
  if (csvLines.length === 0) return null;
  return csvLines.join("\r\n") + "\r\n";
}

function doGet(e) {
  var html = '<!DOCTYPE html><html><head>' +
             '<title>FT5D 交信ログ入力</title>' +
             '<style>' +
             'body { font-family: sans-serif; padding: 10px; background-color: #121212; color: #eee; margin:0; }' +
             '.container { max-width: 400px; margin: 0 auto; background: #1e1e1e; padding: 20px; border-radius: 12px; box-shadow: 0 8px 20px rgba(0,0,0,0.6); border: 1px solid #333; box-sizing: border-box; }' +
             'h2 { font-size: 24px; text-align: center; color: #00E676; margin-top: 0; letter-spacing: 1px; }' +
             'label { font-size: 15px; font-weight: bold; color: #aaa; display: block; margin-bottom: 6px; }' +
             'input[type="text"] { font-size: 18px; padding: 12px; width: 100%; box-sizing: border-box; background: #2a2a2a; color: #fff; border: 1px solid #444; border-radius: 6px; }' +
             'input[type="text"]:focus { outline: none; border-color: #00E676; }' +
             '.drum-group { display: flex; align-items: center; justify-content: center; gap: 4px; background: #0a0a0a; padding: 12px 6px; border-radius: 10px; border: 2px solid #333; box-shadow: inset 0 0 12px #000; user-select: none; -webkit-user-select: none; }' +
             '.drum-wrapper { position: relative; width: 62px; height: 90px; overflow: hidden; background: #1a1a1a; border-radius: 6px; border: 1px solid #444; touch-action: none; cursor: grab; }' +
             '.drum-list { position: absolute; width: 100%; top: 30px; left: 0; margin: 0; padding: 0; list-style: none; transition: transform 0.15s cubic-bezier(0.1, 0.85, 0.25, 1); }' +
             '.drum-item { height: 30px; line-height: 30px; text-align: center; font-size: 18px; font-weight: bold; color: #555; }' +
             '.drum-item.active { color: #00E676; font-size: 22px; text-shadow: 0 0 8px rgba(0,230,118,0.6); }' +
             '.drum-overlay { position: absolute; top: 30px; left: 0; width: 100%; height: 30px; border-top: 1px solid #00E676; border-bottom: 1px solid #00E676; background: rgba(0,230,118,0.08); pointer-events: none; }' +
             'input[type="submit"] { background: linear-gradient(180deg, #00E676 0%, #00A843 100%); color: #000; border: none; padding: 16px; border-radius: 8px; font-weight: bold; font-size: 20px; width: 100%; margin-top: 15px; cursor: pointer; box-shadow: 0 4px 12px rgba(0,230,118,0.3); }' +
             'input[type="submit"]:disabled { background: #444; color: #888; box-shadow: none; }' +
             '.mb-20 { margin-bottom: 18px; }' +
             '#msgBox { display:none; padding:12px; background-color:#155724; color:#d4edda; border-radius:6px; font-size:16px; font-weight:bold; text-align:center; margin-bottom:15px; border:1px solid #c3e6cb; }' +
             '</style>' +
             '<script>' +
             'var timer;' +
             'function SwipeDrum(wrapperId, items, defaultIdx) {' +
             '  this.wrapper = document.getElementById(wrapperId);' +
             '  this.items = items;' +
             '  this.currentIndex = defaultIdx || 0;' +
             '  this.itemHeight = 30;' +
             '  this.startY = 0;' +
             '  this.isDragging = false;' +
             '  this.init();' +
             '}' +
             'SwipeDrum.prototype.init = function() {' +
             '  var self = this;' +
             '  var list = document.createElement("ul");' +
             '  list.className = "drum-list";' +
             '  this.items.forEach(function(val) {' +
             '    var li = document.createElement("li");' +
             '    li.className = "drum-item";' +
             '    li.innerText = val;' +
             '    list.appendChild(li);' +
             '  });' +
             '  this.wrapper.appendChild(list);' +
             '  this.list = list;' +
             '  var overlay = document.createElement("div");' +
             '  overlay.className = "drum-overlay";' +
             '  this.wrapper.appendChild(overlay);' +
             '  var start = function(y) { self.isDragging = true; self.startY = y; self.list.style.transition = "none"; };' +
             '  var move = function(y) {' +
             '    if(!self.isDragging) return;' +
             '    var delta = y - self.startY;' +
             '    var targetY = -self.currentIndex * self.itemHeight + delta;' +
             '    self.list.style.transform = "translateY(" + targetY + "px)";' +
             '  };' +
             '  var end = function(y) {' +
             '    if(!self.isDragging) return;' +
             '    self.isDragging = false;' +
             '    var delta = y - self.startY;' +
             '    var moveSteps = Math.round(delta / self.itemHeight);' +
             '    self.currentIndex = Math.max(0, Math.min(self.items.length - 1, self.currentIndex - moveSteps));' +
             '    self.update();' +
             '  };' +
             '  this.wrapper.addEventListener("touchstart", function(e){ start(e.touches[0].clientY); });' +
             '  this.wrapper.addEventListener("touchmove", function(e){ move(e.touches[0].clientY); });' +
             '  this.wrapper.addEventListener("touchend", function(e){ end(e.changedTouches[0].clientY); });' +
             '  this.wrapper.addEventListener("mousedown", function(e){ start(e.clientY); });' +
             '  window.addEventListener("mousemove", function(e){ move(e.clientY); });' +
             '  window.addEventListener("mouseup", function(e){ if(self.isDragging) end(e.clientY); });' +
             '  this.update();' +
             '};' +
             'SwipeDrum.prototype.update = function() {' +
             '  this.list.style.transition = "transform 0.15s cubic-bezier(0.1, 0.85, 0.25, 1)";' +
             '  var targetY = -this.currentIndex * this.itemHeight;' +
             '  this.list.style.transform = "translateY(" + targetY + "px)";' +
             '  var children = this.list.children;' +
             '  for(var i=0; i<children.length; i++) {' +
             '    if(i === this.currentIndex) children[i].classList.add("active");' +
             '    else children[i].classList.remove("active");' +
             '  }' +
             '};' +
             'SwipeDrum.prototype.getValue = function() { return this.items[this.currentIndex]; };' +
             'var drumBand, drumMhz1, drumDot1, drumDot2, drumRstS, drumRstR, drumMode;' +
             'function initDrums() {' +
             '  drumBand = new SwipeDrum("d_band", ["43", "14"], 0);' +
             '  drumMhz1 = new SwipeDrum("d_mhz1", ["0","1","2","3","4","5","6","7","8","9"], 3);' +
             '  drumDot1 = new SwipeDrum("d_dot1", ["0","1","2","3","4","5","6","7","8","9"], 0);' +
             '  drumDot2 = new SwipeDrum("d_dot2", ["0","2","4","6","8"], 0);' +
             '  drumRstS = new SwipeDrum("d_rst_s", ["59","58","57","56","55","54","53","52","51"], 0);' +
             '  drumRstR = new SwipeDrum("d_rst_r", ["59","58","57","56","55","54","53","52","51"], 0);' +
             '  drumMode = new SwipeDrum("d_mode", ["FM", "C4FM", "D-STAR", "SSB", "CW", "AM"], 0);' +
             '}' +
             'function getSelectedFreq() {' +
             '  return drumBand.getValue() + drumMhz1.getValue() + "." + drumDot1.getValue() + drumDot2.getValue();' +
             '}' +
             'function checkCall() {' +
             '  var cs = document.getElementById("callsign").value.trim().toUpperCase();' +
             '  if(!cs) return;' +
             '  document.getElementById("pastData").innerHTML = "<span style=\'color:#aaa; font-size:14px;\'>検索中...</span>";' +
             '  google.script.run.withSuccessHandler(showPast).withFailureHandler(onFail).getPastLogs(cs);' +
             '}' +
             'function showPast(logs) {' +
             '  var div = document.getElementById("pastData");' +
             '  if(!logs || logs.length === 0) {' +
             '    div.innerHTML = "<span style=\'color:#00E676; font-size:15px; font-weight:bold;\'>★ 1st QSOです!</span>";' +
             '  } else {' +
             '    var txt = "<div style=\'background:#2a2a2a; padding:10px; border-radius:6px; font-size:13px; border:1px solid #444;\'>";' +
             '    txt += "<strong style=\'color:#FF5252; font-size:15px;\'>過去 " + logs.length + " 回の交信履歴あり</strong><ul style=\'margin:5px 0 0 0; padding-left:18px;\'>";' +
             '    for(var i=0; i<logs.length; i++) {' +
             '      var info = [];' +
             '      if(logs[i].name) info.push(logs[i].name);' +
             '      if(logs[i].qth) info.push(logs[i].qth);' +
             '      if(logs[i].remarks) info.push(logs[i].remarks);' +
             '      txt += "<li style=\'margin-bottom:4px;\'><b>" + logs[i].date + "</b> (" + logs[i].freq + "MHz)<br><span style=\'color:#ccc;\'>" + info.join(" / ") + "</span></li>";' +
             '    }' +
             '    txt += "</ul></div>";' +
             '    div.innerHTML = txt;' +
             '  }' +
             '}' +
             'function saveLog(e) {' +
             '  e.preventDefault();' +
             '  var btn = document.getElementById("submitBtn");' +
             '  btn.disabled = true;' +
             '  btn.value = "保存中...";' +
             '  var data = {' +
             '    callsign: document.getElementById("callsign").value,' +
             '    rst_s: drumRstS.getValue(),' +
             '    rst_r: drumRstR.getValue(),' +
             '    freq: getSelectedFreq(),' +
             '    mode: drumMode.getValue(),' +
             '    name: document.getElementById("name").value,' +
             '    qth: document.getElementById("qth").value,' +
             '    remarks: document.getElementById("remarks").value' +
             '  };' +
             '  timer = setTimeout(function(){ alert("タイムアウトしました。"); resetBtn(); }, 6000);' +
             '  google.script.run.withSuccessHandler(onSuccess).withFailureHandler(onFail).addLog(data);' +
             '}' +
             'function onSuccess(cs) {' +
             '  clearTimeout(timer);' +
             '  var msg = document.getElementById("msgBox");' +
             '  msg.innerText = cs + " 局との記録を保存しました!";' +
             '  msg.style.display = "block";' +
             '  document.getElementById("logForm").reset();' +
             '  document.getElementById("pastData").innerHTML = "";' +
             '  resetBtn();' +
             '  setTimeout(function(){ msg.style.display="none"; }, 4000);' +
             '}' +
             'function onFail(err) { clearTimeout(timer); alert("エラー: " + err); resetBtn(); }' +
             'function resetBtn() { var btn = document.getElementById("submitBtn"); btn.disabled = false; btn.value = "LOG RECORD"; }' +
             'window.onload = function() { initDrums(); };' +
             '</script>' +
             '</head><body>' +
             '<div class="container">' +
             '<h2>HAM LOG BOOK</h2>' +
             '<div id="msgBox"></div>' +
             '<form id="logForm" onsubmit="saveLog(event)">' +
             '<label>CALLSIGN:</label>' +
             '<div style="display:flex; gap:8px; margin-bottom: 5px;">' +
               '<input type="text" id="callsign" name="callsign" style="text-transform:uppercase; flex:1;" inputmode="email" autocapitalize="characters" required onchange="checkCall()">' +
               '<button type="button" onclick="checkCall()" style="background:#444; color:white; border:none; border-radius:6px; font-weight:bold; width:70px;">検索</button>' +
             '</div>' +
             '<div id="pastData" class="mb-20" style="min-height:20px;"></div>' +
             '<div class="mb-20">' +
               '<label>FREQUENCY (MHz):</label>' +
               '<div class="drum-group">' +
                 '<div id="d_band" class="drum-wrapper"></div>' +
                 '<div id="d_mhz1" class="drum-wrapper"></div>' +
                 '<span style="font-size:24px; font-weight:bold; color:#00E676;">.</span>' +
                 '<div id="d_dot1" class="drum-wrapper"></div>' +
                 '<div id="d_dot2" class="drum-wrapper"></div>' +
               '</div>' +
             '</div>' +
             '<div class="mb-20">' +
               '<label>RST (SENT / RCV):</label>' +
               '<div class="drum-group">' +
                 '<span style="color:#aaa; font-size:13px;">送</span>' +
                 '<div id="d_rst_s" class="drum-wrapper" style="width:80px;"></div>' +
                 '<span style="color:#aaa; font-size:13px; margin-left:8px;">受</span>' +
                 '<div id="d_rst_r" class="drum-wrapper" style="width:80px;"></div>' +
               '</div>' +
             '</div>' +
             '<div class="mb-20">' +
               '<label>MODE:</label>' +
               '<div class="drum-group">' +
                 '<div id="d_mode" class="drum-wrapper" style="width:140px;"></div>' +
               '</div>' +
             '</div>' +
             '<label>NAME:</label><input type="text" id="name" class="mb-20" placeholder="例: サトウ">' +
             '<label>QTH:</label><input type="text" id="qth" class="mb-20" placeholder="例: 名古屋市中区">' +
             '<label>REMARKS:</label><input type="text" id="remarks" class="mb-20">' +
             '<input type="submit" id="submitBtn" value="LOG RECORD">' +
             '</form>' +
             '</div>' +
             '</body></html>';

  return HtmlService.createHtmlOutput(html)
    .addMetaTag('viewport', 'width=device-width, initial-scale=1.0');
}

function addLog(formData) {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  var now = new Date();
  var dateStr = Utilities.formatDate(now, 'Asia/Tokyo', 'yyyy/MM/dd');
  var timeStr = Utilities.formatDate(now, 'Asia/Tokyo', 'HH:mm');
  var cs = String(formData.callsign).toUpperCase().trim();
  
  sheet.appendRow([
    dateStr, timeStr, cs, formData.rst_s, formData.rst_r, formData.mode, formData.freq, '5', formData.name, formData.qth, formData.remarks
  ]);
  
  return cs;
}

function getPastLogs(targetCallsign) {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  var data = sheet.getDataRange().getValues();
  var results = [];
  var target = String(targetCallsign).toUpperCase().trim();
  
  for (var i = 1; i < data.length; i++) {
    var rowCall = String(data[i][2]).toUpperCase().trim();
    if (rowCall === target) {
      var dateVal = data[i][0];
      var dStr = (dateVal instanceof Date) ? Utilities.formatDate(dateVal, 'Asia/Tokyo', 'yyyy/MM/dd') : String(dateVal);
      results.push({
        date: dStr, freq: String(data[i][6]), name: String(data[i][8] || ""), qth: String(data[i][9] || ""), remarks: String(data[i][10] || "")
      });
    }
  }
  return results;
}

STEP 3:Webアプリとしての公開(デプロイ)

  1. 画面右上の「デプロイ」>「新しいデプロイ」を選択します。
  2. 種類を「ウェブアプリ」にし、アクセス権を「自分」に設定してデプロイを実行します。
※スマホで「保存中…」から動かない場合の対策
Androidなどのブラウザで複数のGoogleアカウントにログインしているとバックグラウンド通信が制限されます。
解決策:スマホ側で無料の「Edge」または「Firefox」アプリを導入し、発行されたWebアプリURLを開いてホーム画面に追加してください。アカウントの競合が解消され一発で動作します。

STEP 4:HAMLOGへのCSVインポート手順

  1. PCのブラウザでスプレッドシートを開き、上部メニューの「HAMLOG拡張機能」>「HAMLOG用CSVを出力」をクリックします。
  2. ダウンロードされたCSVファイルを、Turbo HAMLOGの「オプション」>「データのインポート」>「CSVファイルからのインポート」から読み込みます[cite: 9]。
  3. お名前・QTH・備考欄がHAMLOGの各項目へ正しく取り込まれます[cite: 9]。

記事の中に登場した商品はこちら(PRを含みます)

Turbo HAMLOG/Win 実践活用ガイド [ 浜田 博 ]

価格:2970円
(2026/9/3 10:09時点)