0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

スマホで名刺をスキャン→Google スプレッドシートに自動保存するアプリをGASで作った

0
Posted at

はじめに

名刺をもらうたびに手入力するのが面倒で、スマホで撮影するだけで自動的にスプレッドシートに保存できるアプリを作りました。

使った技術・サービス:

  • Google Apps Script(GAS)
  • Google Cloud Vision API(OCR)
  • Google スプレッドシート

費用:ほぼ無料
Vision APIは月1,000リクエストまで無料です(クレジットカード登録は必要)。GASとスプレッドシートは完全無料です。


完成イメージ

  1. スマホでアプリを開く
  2. 名刺をカメラで撮影
  3. 「テキストを読み取る」をタップ
  4. OCR結果がフォームに自動入力される
  5. 内容を確認・修正して「スプレッドシートに保存」

保存される項目:登録日・氏名・読み仮名・会社名・電話・FAX・メール・住所・ウェブサイト・メモ・OCR生テキスト


ステップ1:Google Cloud Vision APIの準備

1-1. プロジェクト作成

Google Cloud Console にアクセスし、新しいプロジェクトを作成します。

1-2. Vision APIを有効化

左メニュー「APIとサービス」→「ライブラリ」→「Cloud Vision API」を検索して「有効にする」をクリック。

1-3. APIキーを取得

「APIとサービス」→「認証情報」→「認証情報を作成」→「APIキー」をクリック。

「APIの制限の選択」で Cloud Vision API を選択し「作成」。表示されたAPIキーをコピーしておきます。

1-4. 課金を有効化

Vision APIは無料枠があっても、課金を有効にしないと使えません。左メニュー「お支払い」からクレジットカードを登録してください。月1,000回以内なら請求は$0です。


ステップ2:Google Apps Scriptのセットアップ

2-1. スプレッドシートを新規作成

Google スプレッドシート で新しいスプレッドシートを作成します。

2-2. Apps Scriptエディタを開く

メニュー「拡張機能」→「Apps Script」をクリック。

2-3. Code.gs を貼り付ける

左側のファイル一覧で コード.gs を選択し、既存の内容を全部削除して以下を貼り付けます。

YOUR_API_KEY_HERE の部分を、ステップ1で取得したAPIキーに書き換えてください。

// ===================================================
// Business Card Scanner - Google Apps Script
// Server-side code (Code.gs)
// ===================================================

// Name of the sheet to save data
const SHEET_NAME = 'BusinessCards';

// Google Cloud Vision API key
// Replace 'YOUR_API_KEY_HERE' with your actual API key
const VISION_API_KEY = 'YOUR_API_KEY_HERE';

// Entry point: serve the web app HTML
function doGet() {
  return HtmlService.createHtmlOutputFromFile('index')
    .setTitle('名刺スキャナー')
    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}

// Process image: call Google Cloud Vision API, parse result
function processCardImage(base64Data) {
  // Remove the "data:image/...;base64," prefix
  const base64Image = base64Data.replace(/^data:image\/[a-zA-Z]+;base64,/, '');

  const apiUrl = 'https://vision.googleapis.com/v1/images:annotate?key=' + VISION_API_KEY;

  const requestBody = {
    requests: [
      {
        image: { content: base64Image },
        features: [{ type: 'TEXT_DETECTION' }],
        imageContext: { languageHints: ['ja', 'en'] }
      }
    ]
  };

  const options = {
    method: 'POST',
    contentType: 'application/json',
    payload: JSON.stringify(requestBody),
    muteHttpExceptions: true
  };

  const response = UrlFetchApp.fetch(apiUrl, options);
  const result = JSON.parse(response.getContentText());

  if (result.error) {
    throw new Error('Vision APIエラー: ' + result.error.message);
  }

  const responses = result.responses;
  if (!responses || responses.length === 0 || !responses[0].fullTextAnnotation) {
    throw new Error('テキストを読み取れませんでした。');
  }

  const rawText = responses[0].fullTextAnnotation.text;
  return parseBusinessCard(rawText);
}

// Parse OCR text and extract business card fields
function parseBusinessCard(text) {
  const parsed = {
    rawText: text,
    name: '',
    company: '',
    phone: '',
    fax: '',
    email: '',
    address: '',
    website: ''
  };

  const emailMatch = text.match(/[\w._%+\-]+@[\w.\-]+\.[a-zA-Z]{2,}/);
  if (emailMatch) parsed.email = emailMatch[0];

  const urlMatch = text.match(/https?:\/\/[\w.\-\/\?=&#%]+/i)
    || text.match(/(?:www\.|http)[^\s]+/i);
  if (urlMatch) parsed.website = urlMatch[0];

  const lines = text.split('\n');
  lines.forEach(line => {
    const clean = line.trim();
    if (/FAX|Fax|fax|FAX/.test(clean)) {
      const numMatch = clean.match(/[\d\-\(\)0-9]{9,15}/);
      if (numMatch) parsed.fax = parsed.fax || normalizeNum(numMatch[0]);
    } else if (/TEL|Tel|tel|電話|℡/.test(clean) || /\b0\d{1,4}[\-\s]\d{2,4}[\-\s]\d{4}\b/.test(clean)) {
      const numMatch = clean.match(/0\d{1,4}[\-\s]?\d{2,4}[\-\s]?\d{4}/);
      if (numMatch && !parsed.phone) parsed.phone = normalizeNum(numMatch[0]);
    }
  });

  if (!parsed.phone) {
    const phoneMatch = text.match(/0\d{1,4}[\-]\d{2,4}[\-]\d{4}/);
    if (phoneMatch) parsed.phone = phoneMatch[0];
  }

  const addressMatch = text.match(/〒\s*\d{3}[\-]\d{4}[^\n]*/);
  if (addressMatch) {
    const startIdx = text.indexOf(addressMatch[0]);
    const addressBlock = text.substring(startIdx, startIdx + 150);
    parsed.address = addressBlock.split('\n').slice(0, 3).join(' ').trim();
  }

  const companyMatch = text.match(/[^\n]*(?:株式会社|有限会社|合同会社|社団法人|財団法人|LLC|Inc\.|Ltd\.|Co\.,)[^\n]*/);
  if (companyMatch) parsed.company = companyMatch[0].trim();

  return parsed;
}

// Normalize phone number string (full-width → half-width)
function normalizeNum(str) {
  return str.replace(/[0-9]/g, s => String.fromCharCode(s.charCodeAt(0) - 0xFEE0))
            .replace(/\s/g, '');
}

// Save one row of data to Google Sheets
function saveToSheet(data) {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  let sheet = ss.getSheetByName(SHEET_NAME);

  if (!sheet) {
    sheet = ss.insertSheet(SHEET_NAME);
    const headers = [
      '登録日', '氏名', '読み仮名', '会社名',
      '電話', 'FAX', 'メール', '住所', 'ウェブサイト', 'メモ', 'OCR生テキスト'
    ];
    const headerRange = sheet.getRange(1, 1, 1, headers.length);
    headerRange.setValues([headers]);
    headerRange.setFontWeight('bold');
    headerRange.setBackground('#4A90D9');
    headerRange.setFontColor('#FFFFFF');
    sheet.setFrozenRows(1);
  }

  const now = new Date();
  const today = now.getFullYear() + '/' + String(now.getMonth() + 1).padStart(2, '0') + '/' + String(now.getDate()).padStart(2, '0');

  const row = [
    today,
    data.name     || '',
    data.furigana || '',
    data.company  || '',
    data.phone    || '',
    data.fax      || '',
    data.email    || '',
    data.address  || '',
    data.website  || '',
    data.memo     || '',
    data.rawText  || ''
  ];

  sheet.appendRow(row);
  return { success: true, rowNumber: sheet.getLastRow() };
}

2-4. index.html を追加する

左側のファイル一覧の「+」→「HTML」→ファイル名を index と入力(.htmlは不要)。

既存の内容を削除して以下を貼り付けます。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
  <title>名刺スキャナー</title>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Hiragino Sans', 'Noto Sans JP', sans-serif;
      background: #f0f4f8;
      color: #2d3748;
      min-height: 100vh;
    }
    header {
      background: linear-gradient(135deg, #4A90D9, #357abd);
      color: white;
      padding: 16px 20px;
      text-align: center;
      box-shadow: 0 2px 8px rgba(0,0,0,0.2);
    }
    header h1 { font-size: 20px; font-weight: 700; }
    header p { font-size: 12px; opacity: 0.85; margin-top: 2px; }
    .container { max-width: 480px; margin: 0 auto; padding: 16px; }
    .card {
      background: white;
      border-radius: 12px;
      padding: 20px;
      margin-bottom: 16px;
      box-shadow: 0 2px 8px rgba(0,0,0,0.08);
    }
    .card-title {
      font-size: 14px;
      font-weight: 700;
      color: #4A90D9;
      margin-bottom: 14px;
    }
    .upload-area {
      border: 2px dashed #cbd5e0;
      border-radius: 10px;
      padding: 28px 20px;
      text-align: center;
      cursor: pointer;
      transition: all 0.2s;
      position: relative;
    }
    .upload-area input[type="file"] {
      position: absolute;
      inset: 0;
      opacity: 0;
      cursor: pointer;
      width: 100%;
      height: 100%;
    }
    .upload-icon { font-size: 40px; margin-bottom: 10px; }
    .upload-text { font-size: 15px; font-weight: 600; color: #4A90D9; }
    .upload-sub  { font-size: 12px; color: #718096; margin-top: 4px; }
    #preview-area { display: none; margin-top: 14px; text-align: center; }
    #preview-img {
      max-width: 100%;
      max-height: 220px;
      border-radius: 8px;
      box-shadow: 0 2px 8px rgba(0,0,0,0.15);
      object-fit: contain;
    }
    .change-btn {
      display: inline-block;
      margin-top: 8px;
      font-size: 12px;
      color: #718096;
      cursor: pointer;
      text-decoration: underline;
    }
    .btn {
      width: 100%;
      padding: 14px;
      border: none;
      border-radius: 10px;
      font-size: 16px;
      font-weight: 700;
      cursor: pointer;
      transition: all 0.2s;
      display: flex;
      align-items: center;
      justify-content: center;
      gap: 8px;
    }
    .btn-primary { background: #4A90D9; color: white; }
    .btn-primary:disabled { background: #a0aec0; cursor: not-allowed; }
    .btn-success { background: #48bb78; color: white; }
    .btn-success:disabled { background: #a0aec0; cursor: not-allowed; }
    .spinner {
      width: 20px; height: 20px;
      border: 3px solid rgba(255,255,255,0.4);
      border-top-color: white;
      border-radius: 50%;
      animation: spin 0.8s linear infinite;
      display: none;
    }
    @keyframes spin { to { transform: rotate(360deg); } }
    #result-section { display: none; }
    .form-group { margin-bottom: 14px; }
    .form-label { display: block; font-size: 12px; font-weight: 600; color: #4a5568; margin-bottom: 4px; }
    .form-input {
      width: 100%;
      padding: 10px 12px;
      border: 1px solid #e2e8f0;
      border-radius: 8px;
      font-size: 14px;
      color: #2d3748;
      background: #f7fafc;
    }
    .form-input:focus {
      outline: none;
      border-color: #4A90D9;
      background: white;
      box-shadow: 0 0 0 3px rgba(74,144,217,0.15);
    }
    .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
    .raw-toggle {
      font-size: 12px;
      color: #718096;
      cursor: pointer;
      text-decoration: underline;
      display: inline-block;
      margin-bottom: 6px;
    }
    #raw-text {
      width: 100%;
      height: 100px;
      padding: 8px 10px;
      border: 1px solid #e2e8f0;
      border-radius: 8px;
      font-size: 12px;
      font-family: monospace;
      color: #4a5568;
      background: #f7fafc;
      resize: vertical;
      display: none;
    }
    .alert {
      padding: 12px 16px;
      border-radius: 8px;
      font-size: 14px;
      margin-bottom: 14px;
      display: none;
    }
    .alert-error   { background: #fff5f5; border: 1px solid #fc8181; color: #c53030; }
    .alert-success { background: #f0fff4; border: 1px solid #68d391; color: #276749; }
    footer { text-align: center; padding: 20px; font-size: 11px; color: #a0aec0; }
  </style>
</head>
<body>

<header>
  <h1>📇 名刺スキャナー</h1>
  <p>撮影 → OCR読取 → スプレッドシートに保存</p>
</header>

<div class="container">
  <div id="alert-error" class="alert alert-error"></div>
  <div id="alert-success" class="alert alert-success"></div>

  <div class="card">
    <div class="card-title">📷 ステップ1:名刺を撮影 / 選択</div>
    <div class="upload-area" id="upload-area">
      <input type="file" id="file-input" accept="image/*" capture="environment">
      <div class="upload-icon">📸</div>
      <div class="upload-text">タップしてカメラを起動</div>
      <div class="upload-sub">またはファイルを選択(JPG / PNG)</div>
    </div>
    <div id="preview-area">
      <img id="preview-img" src="" alt="名刺プレビュー">
      <br>
      <span class="change-btn" onclick="resetUpload()">別の画像を選ぶ</span>
    </div>
  </div>

  <button class="btn btn-primary" id="ocr-btn" onclick="runOCR()" disabled>
    <span class="spinner" id="ocr-spinner"></span>
    <span id="ocr-btn-text">🔍 テキストを読み取る</span>
  </button>

  <div id="result-section">
    <div class="card" style="margin-top:16px;">
      <div class="card-title">✏️ ステップ2:内容を確認・修正</div>

      <div class="form-group">
        <label class="form-label" for="f-name">氏名</label>
        <input type="text" id="f-name" class="form-input" placeholder="山田 太郎">
      </div>
      <div class="form-group">
        <label class="form-label" for="f-furigana">氏名(読み仮名)</label>
        <input type="text" id="f-furigana" class="form-input" placeholder="やまだ たろう">
      </div>
      <div class="form-group">
        <label class="form-label" for="f-company">会社名</label>
        <input type="text" id="f-company" class="form-input" placeholder="株式会社〇〇">
      </div>
      <div class="form-row">
        <div class="form-group">
          <label class="form-label" for="f-phone">電話</label>
          <input type="tel" id="f-phone" class="form-input" placeholder="03-0000-0000">
        </div>
        <div class="form-group">
          <label class="form-label" for="f-fax">FAX</label>
          <input type="tel" id="f-fax" class="form-input" placeholder="03-0000-0001">
        </div>
      </div>
      <div class="form-group">
        <label class="form-label" for="f-email">メール</label>
        <input type="email" id="f-email" class="form-input" placeholder="yamada@example.com">
      </div>
      <div class="form-group">
        <label class="form-label" for="f-address">住所</label>
        <input type="text" id="f-address" class="form-input" placeholder="〒000-0000 東京都...">
      </div>
      <div class="form-group">
        <label class="form-label" for="f-website">ウェブサイト</label>
        <input type="url" id="f-website" class="form-input" placeholder="https://example.com">
      </div>
      <div class="form-group">
        <span class="raw-toggle" onclick="toggleRaw()">▶ OCR生テキストを表示</span>
        <textarea id="raw-text" readonly></textarea>
      </div>
      <div class="form-group">
        <label class="form-label" for="f-memo">メモ</label>
        <textarea id="f-memo" class="form-input" rows="3" placeholder="備考・メモを入力..." style="resize:vertical;"></textarea>
      </div>
    </div>

    <button class="btn btn-success" id="save-btn" onclick="saveData()">
      <span class="spinner" id="save-spinner"></span>
      <span id="save-btn-text">💾 スプレッドシートに保存</span>
    </button>
  </div>
</div>

<footer>Powered by Google Cloud Vision API &amp; Google Apps Script</footer>

<script>
  let currentBase64 = null;
  let rawVisible = false;

  document.getElementById('file-input').addEventListener('change', function(e) {
    const file = e.target.files[0];
    if (!file) return;
    if (!file.type.startsWith('image/')) {
      showError('画像ファイルを選択してください。');
      return;
    }
    const reader = new FileReader();
    reader.onload = function(evt) {
      currentBase64 = evt.target.result;
      document.getElementById('preview-img').src = currentBase64;
      document.getElementById('preview-area').style.display = 'block';
      document.getElementById('upload-area').style.display = 'none';
      document.getElementById('ocr-btn').disabled = false;
      document.getElementById('result-section').style.display = 'none';
      hideAlerts();
    };
    reader.readAsDataURL(file);
  });

  function resetUpload() {
    currentBase64 = null;
    document.getElementById('file-input').value = '';
    document.getElementById('preview-area').style.display = 'none';
    document.getElementById('upload-area').style.display = 'block';
    document.getElementById('ocr-btn').disabled = true;
    document.getElementById('result-section').style.display = 'none';
    hideAlerts();
  }

  function runOCR() {
    if (!currentBase64) return;
    setLoading('ocr', true);
    hideAlerts();
    google.script.run
      .withSuccessHandler(onOCRSuccess)
      .withFailureHandler(onOCRError)
      .processCardImage(currentBase64);
  }

  function onOCRSuccess(parsed) {
    setLoading('ocr', false);
    document.getElementById('f-name').value     = parsed.name    || '';
    document.getElementById('f-furigana').value = '';
    document.getElementById('f-company').value  = parsed.company || '';
    document.getElementById('f-phone').value    = parsed.phone   || '';
    document.getElementById('f-fax').value      = parsed.fax     || '';
    document.getElementById('f-email').value    = parsed.email   || '';
    document.getElementById('f-address').value  = parsed.address || '';
    document.getElementById('f-website').value  = parsed.website || '';
    document.getElementById('raw-text').value   = parsed.rawText || '';
    document.getElementById('f-memo').value     = '';
    document.getElementById('result-section').style.display = 'block';
    document.getElementById('result-section').scrollIntoView({ behavior: 'smooth' });
  }

  function onOCRError(err) {
    setLoading('ocr', false);
    showError('OCRエラー: ' + (err.message || err));
  }

  function saveData() {
    const data = {
      name:     document.getElementById('f-name').value.trim(),
      furigana: document.getElementById('f-furigana').value.trim(),
      company:  document.getElementById('f-company').value.trim(),
      phone:    document.getElementById('f-phone').value.trim(),
      fax:      document.getElementById('f-fax').value.trim(),
      email:    document.getElementById('f-email').value.trim(),
      address:  document.getElementById('f-address').value.trim(),
      website:  document.getElementById('f-website').value.trim(),
      memo:     document.getElementById('f-memo').value.trim(),
      rawText:  document.getElementById('raw-text').value
    };
    setLoading('save', true);
    hideAlerts();
    google.script.run
      .withSuccessHandler(onSaveSuccess)
      .withFailureHandler(onSaveError)
      .saveToSheet(data);
  }

  function onSaveSuccess(result) {
    setLoading('save', false);
    showSuccess('' + (document.getElementById('f-name').value || '名刺') + '' + result.rowNumber + ' 行目に保存しました!');
    window.scrollTo({ top: 0, behavior: 'smooth' });
    setTimeout(resetUpload, 2000);
  }

  function onSaveError(err) {
    setLoading('save', false);
    showError('保存エラー: ' + (err.message || err));
  }

  function toggleRaw() {
    rawVisible = !rawVisible;
    const el = document.getElementById('raw-text');
    const toggle = document.querySelector('.raw-toggle');
    el.style.display = rawVisible ? 'block' : 'none';
    toggle.textContent = rawVisible ? '▼ OCR生テキストを隠す' : '▶ OCR生テキストを表示';
  }

  function setLoading(type, loading) {
    document.getElementById(type + '-btn').disabled = loading;
    document.getElementById(type + '-spinner').style.display = loading ? 'block' : 'none';
    if (type === 'ocr') {
      document.getElementById('ocr-btn-text').textContent = loading ? '読み取り中...' : '🔍 テキストを読み取る';
    } else {
      document.getElementById('save-btn-text').textContent = loading ? '保存中...' : '💾 スプレッドシートに保存';
    }
  }

  function showError(msg) {
    const el = document.getElementById('alert-error');
    el.textContent = msg;
    el.style.display = 'block';
  }

  function showSuccess(msg) {
    const el = document.getElementById('alert-success');
    el.textContent = msg;
    el.style.display = 'block';
  }

  function hideAlerts() {
    document.getElementById('alert-error').style.display   = 'none';
    document.getElementById('alert-success').style.display = 'none';
  }
</script>

</body>
</html>

ステップ3:Webアプリとして公開する

  1. Apps Script エディタ右上の「デプロイ」→「新しいデプロイ」をクリック
  2. 種類の選択で歯車アイコン →「ウェブアプリ」を選択
  3. 以下のように設定する
    • 次のユーザーとして実行:自分
    • アクセスできるユーザー:全員
  4. 「デプロイ」をクリック
  5. 「アクセスを承認」を求められたら指示に従って許可する(「詳細」→「安全でないページに移動」で進む)
  6. 表示された Webアプリの URL をコピーしてスマホで開く

⚠️ 「アクセスできるユーザー」を「自分のみ」にするとスマホで開けない場合があります。「全員」にしてください。URLを知っている人だけがアクセスできるので安全上の問題はほぼありません。


ハマったポイント

スマホで「現在、ファイルを開くことができません」と出る

ChromeアプリのGoogleアカウントのログイン状態を確認してください。ホーム画面のGoogleとChromeのログインは別管理です。シークレットモードで開ける場合はこれが原因です。

OCRの精度が低い

最初にOCR.spaceを試しましたが日本語名刺の精度が低かったため、Google Cloud Vision APIに切り替えました。Vision APIのほうが日本語の認識精度が大幅に高いのでおすすめです。


まとめ

GASだけで完結するので、サーバー不要・インストール不要で動くのが一番の利点です。月1,000枚以内であれば実質無料で使えます。氏名や会社名はOCRで完全に自動判定するのが難しいため、フォームで確認・修正してから保存する設計にしました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?