1. 何を作ったか
Googleサイトで作った会社のホームページに、次の動きをする問い合わせ窓口を付けました。
- 問い合わせる人が、会社名を数文字入力する
- 国の公開データベース(gBizINFO)から候補が表示される
- 候補を選ぶと、会社名・法人番号・所在地が入った状態でGoogleフォームが開く
- 残りの項目(氏名、連絡先、相談内容)を入力して送信する
受け取る側は、最初の問い合わせの時点で法人番号が手元に届きます。会社名の表記ゆれ(「(株)」「株式会社」、旧社名など)に悩まされず、顧客管理の仕組みに登録するときも法人番号をそのままキーにできます。
筆者は中小企業診断士であり、コードを書いた経験がありません。この記事のコードはすべて生成AIに書かせたもので、筆者が行ったのは「何をどこまでやらせるか」の指示と、できあがったものの確認です。何を確認したかは7章にまとめました。
全体の流れは次のとおりです。
2. 方針:中小企業の情報管理の範囲でつくる
支援先の中小企業でも同じものを作れるように、次の方針を置きました。
- 新しい契約・サーバー・管理対象を増やさない。 使うのは、会社ですでに使っているGoogleアカウントの中にあるもの(Googleサイト、Google Apps Script、Googleフォーム)だけです。
- 外に出す情報は「検索された会社名」だけにする。 Googleの外に出る通信は、会社名をgBizINFOに問い合わせる1本だけです。氏名・メールアドレス・問い合わせ本文は、この仕組みの中を通しません。それらはGoogleフォームに直接入力してもらいます。
- 止まっても問い合わせは受け取れるようにする。 検索が使えないときも、会社を選ばずにフォームへ進める経路を残します。
情報システムの専任者がいない会社では、「管理するものが1つ増える」こと自体が負担になります。そのため、便利さよりも、増やさないことを優先しました。
3. 用意するもの
| 用意するもの | 補足 |
|---|---|
| Googleアカウント | Googleサイト、Apps Script、フォームを使います |
| gBizINFOのAPIトークン | gBizINFO のサイトから利用申請をして受け取ります |
| Googleサイトで作ったページ | 検索窓を置く場所です |
gBizINFOは経済産業省が運営する、法人の公開情報のデータベースです。利用にあたっては、利用規約とAPIのポリシーを先に読んでおくことをおすすめします(8章にリンクを置きました)。
4. フォーム側の準備
4.1 項目を作る
Googleフォームに、次の項目を作ります。
- ご相談の種類(プルダウン)
- 会社名(記述式)
- 法人番号(記述式)
- 所在地(記述式)
- お名前、メールアドレス、ご相談内容 など
上の4つが、検索窓から自動で入る項目です。
4.2 事前入力のURLから項目の番号を調べる
- フォームの編集画面で、右上のメニュー(︙)から「事前入力したURLを取得」を選ぶ
- 自動で入れたい項目に、目印になる文字(例:
AAA、BBB)を入力する - 「リンクを取得」を押して、URLをコピーする
コピーしたURLは次の形をしています。
https://docs.google.com/forms/d/e/【フォームのID】/viewform?usp=pp_url&entry.1111111111=AAA&entry.2222222222=BBB
entry. で始まる番号が、各項目の番号です。どの番号がどの項目かを控えておきます。
5. GASで中継を作る
5.1 なぜ中継が必要か
gBizINFOのAPIは、呼び出すときにトークン(合言葉のようなもの)が必要です。これをホームページ側のコードに書くと、ページを見た人なら誰でも読めてしまいます。
そこで、トークンを知っているのはGASだけにします。ホームページからはGASに会社名を渡し、GASがトークンを付けてgBizINFOに問い合わせ、結果だけを返します。
5.2 トークンを保存する
- Apps Script で新しいプロジェクトを作る
- 左の歯車(プロジェクトの設定)を開く
- 「スクリプト プロパティ」に、プロパティ名
GBIZ_TOKEN、値に受け取ったトークンを入れて保存する
トークンはコードの中には書きません。生成AIとのやり取りにも貼りません。
5.3 コード(Code.gs)
/**
* 会社名検索の中継スクリプト(gBizINFO REST API)
*
* 使い方:
* 1. スクリプト プロパティに GBIZ_TOKEN という名前でAPIトークンを保存する
* 2. testSearch を実行して、ログに会社の候補が出ることを確認する
* 3. ウェブアプリとしてデプロイする(実行ユーザー:自分 / アクセス:全員)
*
* トークンはこのコードの中に書かないでください。
*/
// 新しいAPI(v2)を先に試し、使えなければ従来のAPI(v1)に切り替えます
var API_URLS = [
'https://api.info.gbiz.go.jp/hojin/v2/hojin',
'https://info.gbiz.go.jp/hojin/v1/hojin'
];
var MIN_LENGTH = 2; // この文字数未満では検索しない
var MAX_RESULTS = 10; // 返す候補の最大件数
var CACHE_SECONDS = 21600; // 同じ検索語の結果を6時間保持
/** サイトの検索窓から呼ばれる入口。?q=会社名 で検索する */
function doGet(e) {
var q = '';
if (e && e.parameter && e.parameter.q) {
q = String(e.parameter.q).trim();
}
var result = (q.length >= MIN_LENGTH) ? searchCompanies_(q) : { items: [] };
return ContentService
.createTextOutput(JSON.stringify(result))
.setMimeType(ContentService.MimeType.JSON);
}
/** 会社名で検索し、名称・法人番号・所在地の一覧を返す */
function searchCompanies_(q) {
var cache = CacheService.getScriptCache();
var cacheKey = 'q_' + Utilities.base64EncodeWebSafe(
Utilities.computeDigest(Utilities.DigestAlgorithm.MD5, q, Utilities.Charset.UTF_8));
var cached = cache.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
var token = PropertiesService.getScriptProperties().getProperty('GBIZ_TOKEN');
if (!token) {
return { items: [], error: 'GBIZ_TOKEN が設定されていません' };
}
var lastError = '';
for (var i = 0; i < API_URLS.length; i++) {
var url = API_URLS[i] + '?name=' + encodeURIComponent(q) + '&limit=' + MAX_RESULTS;
try {
var res = UrlFetchApp.fetch(url, {
method: 'get',
headers: { 'X-hojinInfo-api-token': token, 'Accept': 'application/json' },
muteHttpExceptions: true
});
var code = res.getResponseCode();
if (code !== 200) {
lastError = API_URLS[i] + ' → HTTP ' + code;
continue;
}
var body = JSON.parse(res.getContentText());
var list = body['hojin-infos'] || body['hojin_infos'] || body['hojinInfos'] || [];
var items = [];
for (var j = 0; j < list.length && items.length < MAX_RESULTS; j++) {
var h = list[j];
items.push({
name: h.name || '',
number: h.corporate_number || h.corporateNumber || '',
address: h.location || ''
});
}
var result = { items: items };
cache.put(cacheKey, JSON.stringify(result), CACHE_SECONDS);
return result;
} catch (err) {
lastError = API_URLS[i] + ' → ' + err;
}
}
return { items: [], error: lastError };
}
/** 動作確認用。エディタで選んで「実行」し、実行ログを確認する */
function testSearch() {
var result = searchCompanies_('トヨタ自動車');
Logger.log(JSON.stringify(result, null, 2));
}
このコードがしていることは、次の4点です。
- 2文字未満では検索しない
- 返すのは名称・法人番号・所在地の3つだけで、最大10件
- 同じ検索語の結果は6時間覚えておき、gBizINFOへの問い合わせ回数を減らす
- 受け取った会社名はどこにも保存しない(上記の一時的な記憶を除く)
5.4 動作確認とデプロイ
- エディタ上部の関数選択で
testSearchを選び、「実行」を押す(初回は権限の確認が出ます) - 実行ログに会社の候補が出ることを確認する
- 右上の「デプロイ」→「新しいデプロイ」→種類「ウェブアプリ」を選ぶ
- 「次のユーザーとして実行:自分」「アクセスできるユーザー:全員」にしてデプロイする
- 表示されたウェブアプリのURL(
https://script.google.com/macros/s/…/exec)を控える
コードを直したときは、「デプロイを管理」から新しいバージョンとして更新します。更新しないと、古いコードのまま動き続けます。
6. Googleサイトに検索窓を埋め込む
6.1 埋め込み方
- Googleサイトの編集画面で「挿入」→「埋め込む」→「埋め込みコード」を選ぶ
- 下のコードを貼り、
YOUR_…の部分を自分のものに書き換える - 枠の大きさを調整して公開する
書き換える箇所は3つです。
| 箇所 | 入れるもの |
|---|---|
GAS_URL |
5.4で控えたウェブアプリのURL |
FORM_URL |
フォームのURL(…/viewform まで) |
ENTRY |
4.2で調べた項目の番号 |
6.2 コード
<div id="cs">
<label for="cs-type">ご相談の種類</label>
<select id="cs-type">
<option value="">選択してください(任意)</option>
<option>相談の種類A</option>
<option>相談の種類B</option>
<option>その他</option>
</select>
<label for="cs-q">会社名を入力してください</label>
<input id="cs-q" type="text" autocomplete="off" placeholder="例:トヨタ自動車">
<div id="cs-status"></div>
<ul id="cs-list"></ul>
<div id="cs-picked"></div>
<a id="cs-go" target="_blank" rel="noopener">この内容で問い合わせる</a>
<a id="cs-skip" target="_blank" rel="noopener">会社を選ばずに問い合わせる</a>
<p style="margin:16px 0 0;font-size:11px;line-height:1.5;color:#666;text-align:center">会社名の候補:「Gビズインフォ」(経済産業省)を加工して作成<br>https://info.gbiz.go.jp/</p>
</div>
<style>
#cs { font-family: Roboto, "Noto Sans JP", sans-serif; font-size: 15px; color: #222; max-width: 560px; margin: 0 auto; padding: 8px; }
#cs label { display: block; font-weight: bold; margin-bottom: 6px; }
#cs-type { width: 100%; box-sizing: border-box; padding: 10px 12px; font-size: 16px; border: 1px solid #888; border-radius: 4px; background: #fff; margin-bottom: 14px; }
#cs-q { width: 100%; box-sizing: border-box; padding: 10px 12px; font-size: 16px; border: 1px solid #888; border-radius: 4px; }
#cs-status { min-height: 20px; font-size: 13px; color: #555; margin: 4px 0; }
#cs-list { list-style: none; margin: 0; padding: 0; max-height: 240px; overflow-y: auto; border: 1px solid #ccc; border-radius: 4px; display: none; }
#cs-list li { padding: 8px 12px; cursor: pointer; border-bottom: 1px solid #eee; }
#cs-list li:last-child { border-bottom: none; }
#cs-list li:hover, #cs-list li:focus { background: #eef1f7; outline: none; }
#cs-list small { display: block; color: #555; font-size: 12px; }
#cs-picked { display: none; margin: 10px 0; padding: 10px 12px; background: #f3f5f9; border-radius: 4px; line-height: 1.6; }
#cs-go { display: none; text-align: center; padding: 12px; background: #2b3350; color: #fff; text-decoration: none; border-radius: 4px; font-weight: bold; }
#cs-skip { display: block; text-align: center; margin-top: 10px; font-size: 13px; color: #2b3350; }
</style>
<script>
(function () {
var GAS_URL = 'YOUR_GAS_WEBAPP_URL';
var FORM_URL = 'https://docs.google.com/forms/d/e/YOUR_FORM_ID/viewform';
var ENTRY = { subject: 'entry.YOUR_ID_1', name: 'entry.YOUR_ID_2', number: 'entry.YOUR_ID_3', address: 'entry.YOUR_ID_4' };
var q = document.getElementById('cs-q');
var list = document.getElementById('cs-list');
var status = document.getElementById('cs-status');
var picked = document.getElementById('cs-picked');
var go = document.getElementById('cs-go');
var timer = null;
var latest = 0;
var type = document.getElementById('cs-type');
var skip = document.getElementById('cs-skip');
var current = null;
function buildUrl(item) {
var url = FORM_URL + '?usp=pp_url';
if (type.value) { url += '&' + ENTRY.subject + '=' + encodeURIComponent(type.value); }
if (item) {
url += '&' + ENTRY.name + '=' + encodeURIComponent(item.name)
+ '&' + ENTRY.number + '=' + encodeURIComponent(item.number)
+ '&' + ENTRY.address + '=' + encodeURIComponent(item.address);
}
return url;
}
function refreshLinks() { skip.href = buildUrl(null); if (current) { go.href = buildUrl(current); } }
type.addEventListener('change', refreshLinks);
refreshLinks();
function clearList() { list.innerHTML = ''; list.style.display = 'none'; }
function choose(item) {
clearList();
status.textContent = '';
q.value = item.name;
picked.textContent = '';
[item.name, '法人番号:' + item.number, item.address].forEach(function (t, i) {
if (i) picked.appendChild(document.createElement('br'));
picked.appendChild(document.createTextNode(t));
});
picked.style.display = 'block';
current = item;
go.href = buildUrl(item);
go.style.display = 'block';
}
function show(items) {
clearList();
if (!items.length) { status.textContent = '該当する会社が見つかりませんでした。正式名称の一部で試してください。'; return; }
status.textContent = items.length + '件の候補(クリックで選択)';
items.forEach(function (item) {
var li = document.createElement('li');
li.tabIndex = 0;
li.appendChild(document.createTextNode(item.name));
var s = document.createElement('small');
s.textContent = item.address;
li.appendChild(s);
li.onclick = function () { choose(item); };
li.onkeydown = function (e) { if (e.key === 'Enter') choose(item); };
list.appendChild(li);
});
list.style.display = 'block';
}
function search(word) {
var mine = ++latest;
clearList();
status.textContent = '検索中…';
var ctrl = new AbortController();
var limit = setTimeout(function () { ctrl.abort(); }, 15000);
fetch(GAS_URL + '?q=' + encodeURIComponent(word), { signal: ctrl.signal })
.then(function (r) { return r.json(); })
.then(function (data) { clearTimeout(limit); if (mine === latest) show(data.items || []); })
.catch(function () { clearTimeout(limit); if (mine === latest) { clearList(); status.textContent = '検索に時間がかかっています。会社名を少し変えて試すか、下のリンクからお問い合わせください。'; } });
}
var composing = false;
function onChange() {
clearTimeout(timer);
picked.style.display = 'none';
go.style.display = 'none';
current = null;
if (composing) { return; }
var word = q.value.trim();
if (word.length < 2) { latest++; clearList(); status.textContent = ''; return; }
status.textContent = '検索中…';
timer = setTimeout(function () { search(word); }, 500);
}
q.addEventListener('compositionstart', function () { composing = true; clearTimeout(timer); latest++; clearList(); status.textContent = '変換を確定すると検索します'; });
q.addEventListener('compositionend', function () { composing = false; onChange(); });
q.addEventListener('input', onChange);
q.addEventListener('keydown', function (e) { if (e.key === 'Enter' && !e.isComposing) { composing = false; onChange(); } });
})();
</script>
6.3 このコードがしていること
- 入力が止まってから0.5秒待って検索する(1文字ごとに問い合わせない)
- 日本語の変換中は検索せず、確定してから検索する
- 候補を選ぶと、会社名・法人番号・所在地を入れたフォームのURLを組み立てる
- 「会社を選ばずに問い合わせる」リンクを常に表示する
- 15秒待っても結果が返らないときは、その旨を表示してフォームへ案内する
- 出典(gBizINFOを加工して作成した旨)を検索窓の下に表示する
7. 生成AIに書かせて、人が確認したこと
コードは生成AIが書きましたが、次の点は筆者が指示し、できあがったものを自分で確かめました。コードが読めなくても確認できる方法で行っています。
| 確認したこと | 理由 | 確かめ方 |
|---|---|---|
| トークンがコードにも、ホームページにも書かれていない | 書かれていると、誰でも自分の名義でAPIを使えてしまう | コードとページのソースを「トークンの先頭数文字」で検索し、見つからないことを確認 |
| 氏名・メールアドレス・問い合わせ本文が、検索窓とGASを通らない | 自前で個人情報を受け取らないため | 検索窓にあるのは「相談の種類」と「会社名」の2項目だけであることを確認 |
| フォームのURLに入るのは、相談の種類と会社の公開情報だけ | URLは履歴などに残りやすいため | 候補を選んで開いたフォームのURLを目で見て確認 |
| 検索が止まっても、問い合わせは受け取れる | APIは予告なく制限されることがあり、GASにも1日の上限がある | GASのURLをわざと間違えた状態で、「会社を選ばずに問い合わせる」からフォームが開くことを確認 |
| 出典を表示している | gBizINFOの利用規約で求められているため | 公開ページで表示を確認 |
なお、GASのウェブアプリは「全員」がアクセスできる設定のため、URLを知っている第三者が大量に呼び出すことはできます。その場合に止まるのは会社名検索の機能で、問い合わせの受付は止まりません。筆者はこの範囲の損失は受け入れられると判断し、追加の制限は設けていません。ここは作る会社ごとに判断が分かれるところだと考えています。
生成AIに任せられるのは「書くこと」までで、どの情報を持たせるか、止まったときにどうなるかを決めるのは人の仕事だと感じました。
8. 出典・参考
- gBizINFO(Gビズインフォ)(経済産業省)
- gBizINFO 利用規約、gBizINFO REST APIポリシー(いずれも上記サイト内に掲載)
- この仕組みを作った背景と、経営者向けの説明は次の記事にまとめています:法人番号を最初から受け取る問い合わせ導線
本記事の検索窓で表示する会社名の候補は、「Gビズインフォ」(経済産業省)を加工して作成したものです。