はじめに
私の所属している会社には共有本棚があり、社員は自由に書籍を借りることができるようになっています。
定期的に書籍を買い足しているのですが、少しずつ冊数が増えてきたこともあり、「この本すでに買っていたっけ?」がわからなくなってきました。
そこで、何かしら書籍が管理できるシステムがほしいと思い、せっかくなら自分で作るか、となりました。
要件は、
- スマホでバーコードを読み取って書誌情報を取得したい
- 取得した書誌情報を、複数人で簡単に共有可能なリストにまとめたい
の2点です。
検討した結果、「バーコードをスキャンしたら書誌情報が取得できて、Googleスプレッドシートに記録できる」仕組みを作ることにしました。
作ったもの
スマホカメラで書籍のISBNバーコードをスキャンして、書名・著者・出版社などの情報をGoogleスプレッドシートに蓄積するWebアプリです。
動作の流れはこうなっています。
起動
→ カメラ起動・スキャン待機
→ ISBN取得(978 / 979 始まり 13 桁のみ通過)
→ OpenBD API へ直接 GET → 書誌情報取得
→ 重複チェック
→ 書誌情報を表示
→ [登録する] 押下
→ /api/register(CF Functions)→ GAS doPost() → スプレッドシートに appendRow()
→ 「登録しました」表示 → スキャン待機に戻る
画面はページ遷移なしで、スキャン中と確認の2つの状態が切り替わります。スキャン中はカメラが全画面表示され、スキャン成功後は書誌情報と登録ボタンの画面に丸ごと切り替わります。
実際の動きはこんな感じです。

技術スタックの選定
OpenBD――認証不要で使える書誌情報API
書誌情報の取得には OpenBD を使いました。
GET https://api.openbd.jp/v1/get?isbn=9784000000000
ISBNをクエリパラメータに渡すだけで、書名・著者・出版社・発行日が返ってきます。
APIキーも登録も不要で、CORS対応済みのためフロントエンドから直接呼び出せます。
サービス終了の予告が出ているという情報もありますが、記事執筆時点では新刊の情報も取得できています。
GAS――スプレッドシートをHTTPエンドポイントとして公開する
※GASは厳密にはJavaScriptではないですが、タイトルについてはご容赦ください
「複数人で気軽に共有できるリスト」という要件に対して、Googleスプレッドシートは最適な選択肢だなと思いました。
URLを共有するだけで複数人が閲覧・編集でき、フィルタリングやソートも標準機能で済みます。専用のデータベースやフロントエンドを用意するよりずっと手軽です。
そして、Google Apps Script(GAS)を使えば、スプレッドシートの読み取り・書き込みのAPIエンドポイントを簡単に作成することができます。
GASをWeb Appとしてデプロイすると、doPost() がPOSTエンドポイントに、doGet() がGETエンドポイントになります。
/** 書誌情報をスプレッドシートに追記する */
function doPost(e) {
try {
const { isbn, title, author, publisher, publishedDate } =
JSON.parse(e.postData.contents);
const sheet = SpreadsheetApp.getActiveSpreadsheet()
.getSheetByName("シート1");
sheet.appendRow([
new Date(), isbn, title, author, publisher, publishedDate
]);
return jsonOutput({ success: true });
} catch (err) {
return jsonOutput({ success: false, error: err.message });
}
}
/** ISBN の重複チェックを行う */
function doGet(e) {
const isbn = e.parameter?.isbn;
const sheet = SpreadsheetApp.getActiveSpreadsheet()
.getSheetByName("シート1");
const lastRow = sheet.getLastRow();
if (lastRow < 1) return jsonOutput({ exists: false });
const isbnValues = sheet.getRange(1, 2, lastRow, 1).getValues().flat();
const exists = isbnValues.some(v => String(v) === isbn);
return jsonOutput({ exists });
}
function jsonOutput(data) {
return ContentService.createTextOutput(JSON.stringify(data))
.setMimeType(ContentService.MimeType.JSON);
}
Web AppとしてデプロイするとエンドポイントのURLが発行され、そのまま外部から呼び出せます。
フレームワークも依存ライブラリも不要で、このコードだけでスプレッドシートへのHTTPインターフェースが完結します。
「個人用ツールにわざわざサーバーを立てる必要はない」という判断で選びましたが、この手軽さは想定以上でした。
なお、GAS Web Appのアクセス権は「全員がアクセス可能」にしています。これは認証なしで外部から呼び出せる設定です。アクセスを自分のみに制限する場合はOAuth2による認証実装が必要になりますが、書籍一覧への書き込みが漏洩しても実害がないため、そのコストは不要と判断しました。
Cloudflare Pages + Functions
フロントエンドのホスティングには Cloudflare Pages を使いました。HTMLとJavaScriptをpushするだけでデプロイが完了します。
さらに、GASとのやり取りをサーバーサイドで中継する必要があったため、Pages に付属する Cloudflare Functions も合わせて使うことにしました。
Cloudflare Functions とは
Cloudflare Functions(正式名称: Pages Functions)は、Cloudflare Pagesの一機能です。別サービスではなく、Pagesプロジェクトに functions/ ディレクトリを追加するだけで有効になります。(Next.jsのRoute Handlersのようなイメージです)
ディレクトリ構造がそのままURLルートに対応します。
functions/
api/
check.js → GET /api/check
register.js → POST /api/register
各ファイルでは onRequestGet / onRequestPost といったメソッド別のハンドラをexportします。Cloudflare Workersの上で動くため、通常のサーバーサイドfetchが使えます。環境変数はCloudflareダッシュボードで設定し、env 経由で参照できます。
なぜ使ったか
今回の用途は、フロントエンドとGASの間のプロキシです。理由は2つあります。
-
GAS Web AppのURLをクライアント側に書かない — 「全員がアクセス可能」で公開している以上、URLが漏れると誰でもスプレッドシートに書き込めてしまいます。CF Functionsを挟み、
GAS_URLはCloudflareの環境変数として管理することでフロントエンドには露出しません。ただ、以下の注意事項の通り、今回は厳密にはセキュリティ上の隠蔽というより、クライアント側のコードにURLをハードコードしないための整理に近いです。
この構成はあくまでGASのURLをクライアントに露出させないための措置です。CF FunctionsのエンドポイントはデプロイURLがわかれば誰でも呼び出せます。認証情報や個人情報を扱うAPIを作る場合は、JWTやAPIキーによるリクエスト認証を別途実装する必要があります。
-
GASのPOSTは302リダイレクトを返す — ブラウザから直接POSTすると、リダイレクト先でCORSエラーが発生することがあります。サーバーサイドのfetchであれば
redirect: "follow"でそのまま追跡できます。
// functions/api/register.js
export async function onRequestPost({ request, env }) {
const body = await request.text();
const gasRes = await fetch(env.GAS_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
redirect: "follow", // GAS は POST 時に 302 を返すことがある
});
return new Response(await gasRes.text(), {
headers: { "Content-Type": "application/json" },
});
}
重複チェック用の functions/api/check.js も同様の構成で、GAS の doGet へ中継しています。
クライアント側からはPagesと同一オリジンになるため、相対パスで呼び出せます。
// 重複チェック
const res = await fetch(`/api/check?isbn=${encodeURIComponent(isbn)}`);
const { exists } = await res.json();
// 登録
const res = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(book),
});
ホスティングとサーバーレス関数を別々のサービスで管理したくないという理由でCloudflareに統一しましたが、functions/ を置くだけで動く手軽さは使ってみて実感しました。
バーコード読み取り――@zxing/library
ISBNバーコードの読み取りには @zxing/library を使っています。UMDバンドルをscriptタグで読み込むと window.ZXing に展開されます。
<script src="https://unpkg.com/@zxing/library@0.23.0/umd/index.min.js" defer></script>
const codeReader = new window.ZXing.BrowserMultiFormatReader();
await codeReader.decodeFromConstraints(
{ video: { facingMode: { ideal: "environment" } } }, // リアカメラを優先
elPreview,
(result, _err) => {
if (!result) return;
const raw = result.getText();
if (!/^97[89]\d{10}$/.test(raw)) return; // ISBN-13 のみ通過
handleIsbn(raw);
}
);
facingMode: { ideal: "environment" } でリアカメラを優先しつつ、978 / 979 始まりの13桁でISBNをフィルタリングしています。ビルドステップなしで動かせるのが便利です。
アーキテクチャ全体像
最終的な構成はこうなっています。
[スマホブラウザ (on Cloudflare Pages)]
│
├─ @zxing/library でカメラ起動 → ISBN取得
├─ OpenBD API(直接 GET)→ 書誌情報取得
├─ /api/check(Cloudflare Functions)→ GAS doGet() → 重複チェック
└─ /api/register(Cloudflare Functions)→ GAS doPost() → スプレッドシートに追記
- Cloudflare Pages: フロントエンドホスティング
- Cloudflare Functions: GASへのプロキシ(URL隠蔽・リダイレクト吸収)
- OpenBD: 書誌情報取得(フロントから直接呼び出し)
- GAS: 重複チェック(doGet)とスプレッドシートへの書き込み(doPost)
まとめ
GAS・Cloudflare Functions・OpenBDの3つを組み合わせることで、フレームワークもサーバーもなしに実用的な書籍スキャンアプリが作れました。
改めて振り返ると、「最小コードで動くものを作る」という観点での選択肢として、どれも十分に機能します。
-
GAS: サーバーを立てずにスプレッドシートをHTTPエンドポイントにできる。
doGet/doPostだけで読み書きが完結する - OpenBD: 認証不要・CORS対応済みの書誌情報APIとして今も現役
- Cloudflare Pages + Functions: ホスティングとサーバーレス関数をまとめて管理でき、環境変数による秘匿も簡単
個人開発で似たようなツールを作りたいエンジニアの方に、参考になれば幸いです。
