【書籍管理】GASでデプロイしたWEBアプリからバーコードスキャンでNotionに本の情報を登録してみる

## 概要
Notionで書籍の管理をしてみたいけどすでに大量の本があって、１冊ずつ本を登録するのが面倒。
バーコード読みとるだけでバンバン登録できたらいいのに... と思ったので Google Apps Script (GAS) を使用してウェブアプリケーションをデプロイし、手持ちのデバイスのカメラから本のバーコードを読み取りNotionで作成した書籍一覧に情報を連携するシステムです。読み取ったバーコードをもとに、Google Books APIから書籍のデータを取得し、Notion APIを使用してその情報をNotionデータベースに連携します。

### システム概要
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1438353/febfff83-7fb1-3e31-7d61-cae508af1e5b.png)

### Notion書籍登録イメージ
![スクリーン ショット 2024-10-23 に 16.21.01 午後.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1438353/327d83e9-d2ff-b0f0-d755-e5a805faea53.png)




## 必要なもの
- だいたい持ってるはず
  - Google アカウント
  - Notion アカウント
- GASに埋め込む情報
  - Google Books APIキー
  - Notion APIシークレットトークン
  - NotionデータベースID

## 各種APIキーの取得

### Google Books APIキーの取得

本の情報を取得するためにGoogle Books APIを使用します。

1. [Google Cloud Console](https://console.cloud.google.com/)にアクセスします。
2. プロジェクトを作成します。
3. 「APIとサービス」 > 「ライブラリ」に移動します。
4. 「Google Books API」を検索し、有効にします。
5. 「APIとサービス」 > 「認証情報」に移動し、「認証情報を作成」ボタンをクリックしてAPIキーを作成します。

### Notion APIシークレットトークンの取得
1. [Notion Developers](https://www.notion.so/my-integrations)にアクセスします。
2. 新しいインテグレーションを作成し、シークレットトークンを取得します。

### Notion 書籍一覧データベースの作成
1. Notionでデータベースを作成します。
2. データベースの名称を「書籍一覧」にします。
3. データベースのプロパティは、以下の通りにします。

| プロパティ名  | 型               | 説明                         |
| ------------- | ---------------- | ---------------------------- |
| Title         | タイトル         | 書籍のタイトル               |
| Subtitle      | テキスト         | 書籍のサブタイトル           |
| ISBN          | テキスト         | 書籍のISBN番号               |
| Description   | テキスト         | 書籍の説明                   |
| Author        | テキスト         | 書籍の著者                   |
| PublishedDate | 日付             | 書籍の出版日                 |
| PageCount     | 数値             | 書籍のページ数               |

### Notionデータベースにコネクションの追加

1. 作成したデータベースを開き、「コネクション」タブに移動します。
2. Connection - Connect to から先ほど作成したインテグレーションの名称を指定します。

### NotionデータベースIDの取得
1. Notionでデータベースをブラウザで開きます。
2. データベースのURLからIDを取得します。URLは次の形式です: `https://www.notion.so/xxxxxx?v=...`。`xxxxxx` の部分がデータベースIDです。

## GASプロジェクト作成

1. [Google Apps Script](https://script.google.com/)にアクセスします。（Google Driveから任意のディレクトリから作ってもOK）
2. 「新しいプロジェクト」をクリックします。
3. プロジェクト名を入力します。

## GASコード追加と書き換えデプロイ手順

2. `コード.gs`ファイルに以下のコードを貼り付け、最初の３つの定数を自分のAPIキーやトークンに書き換えます。
   ```javascript
    // Notion APIのシークレットトークン
    const NOTION_API_TOKEN = '実際の値に書き換える';
    // NotionデータベースのID
    const DATABASE_ID = '実際の値に書き換える';
    // Google Booksを有効にしたAPIKey
    const GOOGLE_APIKEY = '実際の値に書き換える'

    function doGet() {
        return HtmlService.createHtmlOutputFromFile('index');  // index.htmlを表示
    }

    function test_openbd() {
        const code = '9784480815781';
        fetchBookInfo(code);
    }

    function convertISBN13ToISBN10(isbn13) {
        // ISBN-13の先頭3桁が"978"でない場合は無効
        if (!isbn13.startsWith('978')) {
            return null;
        }

        // ISBN-13からISBN-10に変換するため、先頭の"978"を除去
        const isbn10Base = isbn13.substring(3, 12);

        // チェックデジットを計算する
        let sum = 0;
        for (let i = 0; i < 9; i++) {
            sum += (i + 1) * parseInt(isbn10Base[i]);
        }
        const remainder = sum % 11;
        const checkDigit = remainder === 10 ? 'X' : remainder.toString();

        // ISBN-10を返す
        return isbn10Base + checkDigit;
    }

    function fetchBookInfo(isbn) {
        const noImageUrl = 'https://via.placeholder.com/150?text=No+Image';
        const googleBooksApiUrl = `https://www.googleapis.com/books/v1/volumes?q=isbn:${isbn}&country=JP&startIndex=0&maxResults=1&key=${GOOGLE_APIKEY}`;

        // リクエストを送信
        const response = UrlFetchApp.fetch(googleBooksApiUrl);
        const bookData = JSON.parse(response.getContentText());

        // レスポンスのデータを確認し、書籍情報を取得
        if (bookData.totalItems > 0) {
            const volumeInfo = bookData.items[0].volumeInfo;

            // 書籍情報を取得
            const bookInfo = {};
            bookInfo.title = volumeInfo.title || 'タイトルなし';
            bookInfo.subtitle = volumeInfo.subtitle || 'タイトルなし';
            bookInfo.author = volumeInfo.authors ? volumeInfo.authors.join(', ') : '著者なし';
            bookInfo.coverUrl = volumeInfo.imageLinks ? volumeInfo.imageLinks.thumbnail : noImageUrl,
                bookInfo.description = volumeInfo.description || '詳細なし';
            bookInfo.asin = convertISBN13ToISBN10(isbn);
            bookInfo.publishedDate = volumeInfo.publishedDate || '1980-01-01';
            bookInfo.pageCount = volumeInfo.pageCount || 0;

            Logger.log(bookInfo);

            // Notion連携
            if (bookInfo.title) {
                const page = checkIfTitleExistsInNotion(bookInfo.title);
                if (page) {
                    // 既存のレコードがある場合Notionデータベース内の該当レコードを更新
                    Logger.log('既存のレコードあり');
                    updateNotionPage(page, isbn, bookInfo);
                } else {
                    // 新規の場合追加
                    Logger.log('新規レコード追加');
                    addBookInfoToNotion(isbn, bookInfo);
                }

            }
            return bookInfo;
        } else {
            Logger.log('書籍情報が見つかりませんでした。');
            return null;
        }
    }

    // タイトルで検索してNotionデータベース内でデータがあるか確認する関数
    function checkIfTitleExistsInNotion(title) {
        const url = `https://api.notion.com/v1/databases/${DATABASE_ID}/query`;

        // フィルタでタイトルに一致する項目を検索
        const payload = {
            filter: {
                property: 'Title',  // データベースの「タイトル」プロパティの名前を指定
                rich_text: {
                    contains: title   // タイトルに指定した文字列が含まれるか検索
                }
            }
        };

        const options = {
            method: 'post',
            contentType: 'application/json',
            headers: {
                'Authorization': `Bearer ${NOTION_API_TOKEN}`,
                'Notion-Version': '2022-06-28'
            },
            payload: JSON.stringify(payload)
        };

        try {
            // Notion APIにリクエストを送信
            const response = UrlFetchApp.fetch(url, options);
            const result = JSON.parse(response.getContentText());

            // 検索結果が存在するか確認
            if (result.results && result.results.length > 0) {
                Logger.log('Title already exists in the database.');
                const pageId = result.results[0].id;
                return pageId;  // 該当データが存在する
            } else {
                Logger.log('Title not found in the database.');
                return false;  // 該当データが存在しない
            }
        } catch (error) {
            Logger.log('Error: ' + error);
            return false;  // エラーが発生した場合もfalseを返す
        }
    }

    // Notionのページを更新する関数
    function updateNotionPage(pageId, isbn, bookInfo) {
        const url = `https://api.notion.com/v1/pages/${pageId}`;

        const payload = {
            properties: {
                'ISBN': {
                    rich_text: [
                        {
                            text: {
                                content: isbn  // ISBN
                            }
                        }
                    ]
                },
                'Description': {
                    rich_text: [
                        {
                            text: {
                                content: bookInfo.description  // ISBN
                            }
                        }
                    ]
                },
                'PublishedDate': {
                    date: {
                        start: bookInfo.publishedDate,
                        end: null
                    }
                },
                'PageCount': {
                    number: bookInfo.pageCount
                }
            },
            cover: {
                type: "external",
                external: {
                    url: `https://images-na.ssl-images-amazon.com/images/P/${bookInfo.asin}.09.LZZZZZZZ` // ここでURLを指定
                }
            },
            icon: {
                type: "external",
                external: {
                    url: `https://images-na.ssl-images-amazon.com/images/P/${bookInfo.asin}.09.THUMBZZZ`
                }
            },
        };

        const options = {
            method: 'patch',
            contentType: 'application/json',
            headers: {
                'Authorization': `Bearer ${NOTION_API_TOKEN}`,
                'Notion-Version': '2022-06-28'
            },
            payload: JSON.stringify(payload)
        };

        UrlFetchApp.fetch(url, options);
    }

    function addBookInfoToNotion(isbn, bookInfo) {
        const today = new Date();
        const formattedDate = today.toISOString().split('T')[0];  // "YYYY-MM-DD"形式に変換
        const url = 'https://api.notion.com/v1/pages';
        const payload = {
            parent: { database_id: DATABASE_ID },
            cover: {
                type: "external",
                external: {
                    url: `https://images-na.ssl-images-amazon.com/images/P/${bookInfo.asin}.09.LZZZZZZZ` // ここでURLを指定
                }
            },
            icon: {
                type: "external",
                external: {
                    url: `https://images-na.ssl-images-amazon.com/images/P/${bookInfo.asin}.09.THUMBZZZ`
                }
            },
            properties: {
                'Title': {  // Notion側のプロパティに合わせて設定
                    title: [
                        {
                            text: {
                                content: bookInfo.title  // 書籍タイトル
                            }
                        }
                    ]
                },
                'Subtitle': {
                    rich_text: [
                        {
                            text: {
                                content: bookInfo.subtitle  // ISBN
                            }
                        }
                    ]
                },
                'ISBN': {
                    rich_text: [
                        {
                            text: {
                                content: isbn  // ISBN
                            }
                        }
                    ]
                },
                'Description': {
                    rich_text: [
                        {
                            text: {
                                content: bookInfo.description  // ISBN
                            }
                        }
                    ]
                },
                'Author': {
                    rich_text: [
                        {
                            text: {
                                content: bookInfo.author  // 出版社名
                            }
                        }
                    ]
                },
                'PublishedDate': {
                    date: {
                        start: bookInfo.publishedDate,
                        end: null
                    }
                },
                'PageCount': {
                    number: bookInfo.pageCount
                }
            }
        };

        const options = {
            method: 'POST',
            muteHttpExceptions: true,
            validateHttpsCertificates: false,
            followRedirects: false,
            contentType: 'application/json',
            headers: {
                'Authorization': `Bearer ${NOTION_API_TOKEN}`,
                'Notion-Version': '2022-06-28'
            },
            payload: JSON.stringify(payload)
        };

        try {
            const response = UrlFetchApp.fetch(url, options);
            Logger.log(response)
        } catch (e) {
            // 例外エラー処理
            Logger.log('Error:')
            Logger.log(e)
        }
    }

   ```
3. Google Apps Scriptエディタにアクセスします。
4. ファイルの＋ボタンをクリックしHTMLを選択,indexと入力して、`index.html`ファイルを作成し、下記を貼り付けます。
    ```html
    <!DOCTYPE html>
    <html>

    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">

        <title>Barcode Scanner</title>
        <style>
            #camera-container {
                display: flex;
                justify-content: center;
                align-items: center;
            }

            #camera-container.viewport canvas.drawingBuffer,
            video.drawingBuffer {

                visibility: hidden;
            }
        </style>
        <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
        <script src="https://serratus.github.io/quaggaJS/examples/js/quagga.min.js"></script>


    </head>

    <body>

        <div class="container text-center">
            <div class='row'>
                <div class="col">
                    <h1>Barcode Scanner</h1>
                    <div id="camera-container"></div>
                    <div id="barcode-result"></div>

                    <button onclick="startScanner()" class='btn btn-primary btn-block' id="startScannerButton">Start
                        Scanner</button>
                </div>
            </div>

            <div class='row'>
                <div class="col">
                    <h4>読み取り結果:</h4>
                    <div id="bookInfo">
                        <h3 id="bookTitle">本のタイトル</h3>
                        <p id="bookAuthor">著者</p>
                        <img id="bookCover" src="https://via.placeholder.com/150?text=No+Image" alt="Book Cover">
                    </div>
                </div>
            </div>

        </div>

        <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script>
        <script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.5.3/dist/umd/popper.min.js"></script>
        <script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>

        <script>
            let lastScannedCode = '';  // 前回のバーコードを保存する変数
            let lastScannedISBNCode = '';  // 前回のバーコードを保存する変数
            let debounceTimer = null;  // 一定時間バーコードの入力を防ぐためのタイマー

            // 書籍情報を表示
            function displayBookInfo(bookInfo) {
                document.getElementById('bookTitle').textContent = bookInfo.title || 'No title available';
                document.getElementById('bookAuthor').textContent = "Author: " + (bookInfo.author || 'Unknown');
                document.getElementById('bookCover').src = 'https://images-na.ssl-images-amazon.com/images/P/' + bookInfo.asin + '.09.LZZZZZZZ';
            }

            function isValidISBN(code) {
                // ISBNは10桁か13桁
                code = code.trim();
                console.log(code);

                if (code.length === 13 && code.startsWith("978")) {
                    return true;  // 13桁のISBN (ISBN-13)
                } else if (code.length === 10) {
                    return true;  // 10桁のISBN (ISBN-10)
                }
                return false;
            }

            function startScanner() {
                document.getElementById('startScannerButton').disabled = true;
                document.getElementById('startScannerButton').textContent = 'Scanning...';
                // drawingBufferを非表示にする

                Quagga.init({
                    inputStream: {
                        name: "Live",
                        type: "LiveStream",
                        target: document.querySelector('#camera-container'),
                        constraints: {
                            decodeBarCodeRate: 3,
                            successTimeout: 500,
                            codeRepetition: true,
                            tryVertical: true,
                            frameRate: 15,
                            width: 640,
                            height: 480,
                            facingMode: "environment"
                        },
                    },
                    decoder: {
                        readers: ["ean_reader"]
                    }
                }, function (err) {
                    if (err) {
                        console.log(err);
                        return;
                    }
                    Quagga.start();
                    // Hide the drawing buffer after Quagga starts
                    const drawingBuffer = document.querySelector('#camera-container .drawingBuffer');
                    if (drawingBuffer) {
                        drawingBuffer.style.display = 'none';
                    }
                });

                Quagga.onDetected(function (result) {
                    const code = result.codeResult.code.trim();
                    // 連続読み取り制御
                    if (code !== lastScannedCode) {
                        console.log('Detected ISBN:', code);
                        lastScannedCode = code;
                        document.getElementById('startScannerButton').textContent = code + 'を検出...';

                        // ISBNであるかどうかを判定
                        if (code !== lastScannedISBNCode && isValidISBN(code)) {
                            lastScannedISBNCode = code;
                            // Quagga.stop();  // ISBNが有効ならスキャナを停止
                            google.script.run.withSuccessHandler(function (bookInfo) {
                                if (bookInfo) {
                                    console.log('Book info:', bookInfo);
                                    document.getElementById('startScannerButton').textContent = bookInfo.title + 'を検出...';

                                    // 画面に本の情報を表示
                                    displayBookInfo(bookInfo);

                                    setTimeout(startScanner, 500);  // 書籍情報が見つからない場合0.5秒後にスキャン再開
                                } else {
                                    console.log('Book not found in openBD');
                                    // setTimeout(startScanner, 500);  // 書籍情報が見つからない場合0.5秒後にスキャン再開
                                }
                            }).fetchBookInfo(code);  // Google Apps Script側の関数を呼び出し
                        } else {
                            console.log('Not a valid ISBN. Retrying...');
                            document.getElementById('startScannerButton').textContent = 'Not a valid ISBN. Retrying...';
                        }
                    }
                });
            }

        </script>
    </body>

    </html>
    ```
5. 「デプロイ」 > 「新しいデプロイ」を選択し、ウェブアプリとしてデプロイします。
6. デプロイ後、表示されたURLを使用してアプリにアクセスします。

## 使用方法
1. アプリを開き、「Start Scanner」ボタンをクリックします。
2. カメラが起動し、バーコードをスキャンします。
3. スキャンしたISBNに基づいて書籍情報が表示され、Notionデータベースに追加されます。

    ### スキャン後にタイトルとカバー画像を表示します
    ![スクリーン ショット 2024-10-23 に 15.49.10 午後.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1438353/d50d8902-6db6-3ed8-b513-1fc2288c09f0.png)

    ### Notionに登録された状態、アイコンとカバーに画像が登録されます
    ![スクリーン ショット 2024-10-23 に 16.09.17 午後.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1438353/b0686cb5-8e78-f037-0e63-49c0183be8bd.png)


## まだやりたいこと

- Kindleで買った本はどうやって登録しようか
- たまに全然違う本の情報をとってきちゃうミスリード対策