📖

Google Docs でブログ記事を管理する | Google Docs + SpreadSheet + GAS

2024/06/22に公開

概要

Google Docs でブログ記事を管理できるようにしたので、それについてのメモです。

目的

  • Google Drive でファイルの管理(作成・更新・削除)ができる。
  • ファイルは Google Docs で編集できるものにする。
  • Google Drive のディレクトリ構造をそのまま URL に反映できる。
  • コンテンツに変更を加えたとき、GAS の関数を起動するだけで更新できること。
  • 最終的に、Astro.js などで SSR できること。

実装

1. Google Drive 上のディレクトリ構成

全体的なディレクトリ構成は以下のようになっています. なおサンプルのディレクトリも書いてあるので、* で必須を示してあります

My Drive/
└ blog/               # *記事管理のルート
    ├ .dist/          # *GAS で処理されたファイルが出力されるところ
    ├ src/            # *記事データを入れるところ --- 以下はサンプル ---
        ├ /               # Index ディレクトリ
            └ hoge        # Google Docs ファイル (URLは '/hoge')
        └ foo/
            └ foo_doc     # Google Docs ファイル (URLは '/foo/foo_doc')
    ├ DataSheet       # *データを管理するための SpreadSheet ファイル
    └ GetGoogleDocs   # *データを処理するための GAS ファイル

2. DataSheet にシートを作る

DataSheet という名前の Google SpreadSheet に exported_files という名前のシートを作成.
A1 から F1 に次の値を入れます.

セル番地 --- A1 B1 C1 D1 E1 F1
入力値 --- src_file_id src_file_name file_dir file_path dist_file_id created_timestamp

なお、後で使うので、シートIDを控えておきます.

3. GetGoogleDocs を編集する

ファイル構成

作成するファイルは以下の通りです.

  1. Logger.gs
  2. doGet.gs
  3. docExportAsHTMLFile.gs
  4. fetchDocAsHTML.gs
  5. FileNameDatabase.gs
  6. index.html

ライブラリ

二つのライブラリを使っています. ひとつはログ記録用の BetterLog、もうひとつは SpreadSheet を DB のように使える SpreadSheetSQL です.

  1. BetterLog より BetterLog としてインポート
  2. SpreadSheetsSQL より SpreadSheetsSQL としてインポート

スクリプトプロパティ

次のようなスクリプトプロパティを設定します. シート ID や フォルダ ID を適切なものに置き換えてください.

  • DATASHEET_SS_ID: DataSheet のシート ID
  • DIST_FOLDER_ID : MyDrive/blog/.dist ディレクトリのフォルダ ID
  • SRC_FOLDER_ID : MyDrive/blog/src ディレクトリのフォルダ ID

1. Logger.gs

BetterLog を使うための設定です.

Logger.gs
const _betterlog = BetterLog.useSpreadsheet(PropertiesService.getScriptProperties().getProperty("DATASHEET_SS_ID"));

const Logger = (() => {
  const log = (label, msg = '') => {
    const text = `[GetGoogleDocument] <${label}> ${msg}`;
    _betterlog.log(text.trim());
  }
  
  return { log };
})();

2. doGet.gs

doGet.gs
// Get イベントが発生したときに起動する関数
function doGet(e) {
    if (e === undefined) return;
    // file という URL パラメータを持つ
    const { file } = e.parameter;
    // file が未指定のとき、index.html (リファレンス) を表示.
    if (file === undefined) {
        const template = HtmlService.createTemplateFromFile('index');
        const htmlOutput = template.evaluate();
        return htmlOutput;
    // file が all のとき、すべてのコンテンツデータを JSON で渡す.
    } else if (file === 'all') {
        const data = FileNameDatabase.getAll();
        return responseJson(data);
    // file が src_file_id を指定しているとき、
    // それに対応するコンテンツデータを渡す.
    } else {
        const { dist_file_id } = FileNameDatabase.getBySrcFileId(file);
        if (dist_file_id === undefined) {
            return responseJson({
                error: 'No Such File ID'
            });
        }
        // src_file_id から dist_file_id を取得して、ファイルインスタンスを生成
        const fileApp = DriveApp.getFileById(dist_file_id);
        // ファイルコンテンツを Blob で取得
        const blob = fileApp.getBlob();
        // Blob を Text に変換
        const text = blob.getDataAsString();
        return responseJson(text);
    }
}

// Response 用関数: payload を JSON にして送る. 呼び出し側は Return しなければならない.
function responseJson(payload) {
  const output = ContentService.createTextOutput();
  output.setMimeType(ContentService.MimeType.JSON);
  output.setContent(JSON.stringify(payload));

  return output;
}

3. docExportAsHTMLFile.gs

ファイルに対する処理を行うメイン関数. ファイルを変更したら、これを起動する.

docExportAsHTMLFile.gs
function docExportAsHTMLFile() {
    const SrcFolderId = PropertiesService.getScriptProperties()
        .getProperty("SRC_FOLDER_ID");
    const DistFolderId = PropertiesService.getScriptProperties()
        .getProperty("DIST_FOLDER_ID");
    const SrcFolderApp = DriveApp.getFolderById(SrcFolderId);
    const DistFolderApp = DriveApp.getFolderById(DistFolderId);

    /*
     * recursiveComposer
     * フォルダを深さ優先で探索し、
     * 与えられた初期フォルダ内のすべてのファイルを取得する
     */
    let currentPath = []; // 処理中の階層を保管する変数
    const recursiveComposer = (outerFolder, handler) => {
        /* 与えられたフォルダ内のすべてのフォルダ */
        const folders = outerFolder.getFolders();
        // 子フォルダの処理 (子フォルダがないときはスキップ)
        while (folders.hasNext()) {
            const folder = folders.next();
            const folderName = folder.getName();
            currentPath.push(folderName);
            // 子フォルダをさらに探索
            recursiveComposer(folder, handler);
        }

        /*
         * 子フォルダがないとき、すなわち、
         * 最下層や、それより下層のフォルダを探索しつくしたとき、
         * 実行される. (子ファイルの処理)
         */
        /* 与えられたフォルダ内のすべてのファイル */
        const files = outerFolder.getFiles();
        while (files.hasNext()) {
            const file = files.next();
            const fileName = file.getName();
            const fileId = file.getId();
            /* ディレクトリの配列版 */
            const fileDirAry = currentPath;
            const filePathAry = fileDirAry.concat(fileName);
            /* ディレクトリの文字列版 */
            let fileDir = '/';
            if (fileDirAry[0] === '/' && fileDirAry.length > 1) {
                // '/(Root)/L2/L3...' (Root 内のファイル) -> '/L2/L3...'
                fileDir += fileDirAry.join('/').substring(1);
            } else if (fileDirAry[0] !== '/') {
                // '/L1/L2/L3...' -> '/L1/L2/L3...'
                fileDir += fileDirAry.join('/');
            }
            const filePath = (fileDir === '/')
                ? `/${fileName}`
                : `${fileDir}/${fileName}`;
            // 各ファイルに対する処理である、handler を実行する
            handler({
                fileId, fileName, fileDir,
                fileDirAry, filePath, filePathAry,
            });
        }
        // 下層以外のディレクトリに移るため、
        // 一番うしろ (現在のディレクトリ) はいらない
        currentPath.pop();
}

    recursiveComposer(SrcFolderApp, ({
        fileId, fileName, fileDir,
        fileDirAry, filePath, filePathAry,
    }) => {
        if (!FileNameDatabase.isExistSrcFileId(fileId)) return;
        // ファイルに対する処理
        // file を HTML として取得
        const fileHTML = fetchDocAsHTML(fileId);
        // その HTML をコンテンツにもつ .dist にファイルを作成
        const distFile = DistFolderApp
            .createFile(filePath, fileHTML, MimeType.HTML);
        // Datasheet にメタデータを登録
        FileNameDatabase.insertData({
            fileId, fileName, fileDir,
            filePath, distFileId: distFile.getId(),
        });
    }
  });
}

4. fetchDocAsHTML.gs

Google Docs を HTML に変換して取得する関数.

fetchDocAsHTML.gs
function fetchDocAsHTML(fileId) {
  const fileExportURL = `https://docs.google.com/feeds/download/documents/export/Export?id=${fileId}&exportFormat=html`; 
  // const storageUsed = DriveApp.getStorageUsed(); //これが無いと認証エラー
  const contentAsHTML = UrlFetchApp.fetch(fileExportURL, {
    method: 'get', 
    headers: {'Authorization': 'Bearer ' + ScriptApp.getOAuthToken()}, 
    muteHttpExceptions: true,
  }).getContentText();

  return contentAsHTML;
}

5. FileNameDatabase.gs

DataSheet と読み書きするクラス.

FileNameDatabase.gs
const DatabaseSheetName = 'exported_files';
const DatabaseBookId = PropertiesService.getScriptProperties().getProperty("DATASHEET_SS_ID");

const FileNameDatabase = (() => {
  const Database = SpreadSheetsSQL
    .open(DatabaseBookId, DatabaseSheetName);

  const getBySrcFileId = (fileId) => {
    const res = Database.select([ 'src_file_id', 'src_file_name', 'file_dir', 'file_path', 'dist_file_id', 'created_timestamp' ])
      .filter(`src_file_id = ${fileId}`).result();
    // Logger.log('FileNameDatabase.getBySrcFileId()', JSON.stringify(res));
    return res[0];
  }

  const getAll = () => {
    const res = Database.select([ 'src_file_id', 'src_file_name', 'file_dir', 'file_path', 'dist_file_id', 'created_timestamp' ]).result();
    return res;
  }

  const isExistSrcFileId = (fileId) => {
    const res = Database.select([ 'src_file_id', 'src_file_name', 'file_dir', 'file_path', 'dist_file_id', 'created_timestamp' ])
      .filter(`src_file_id = ${fileId}`).result();
    return (res !== null && res.length > 0);
  }

  const insertData = ({ fileId, fileName, fileDir, filePath, distFileId }) => {
    Database.insertRows([{
      src_file_id: fileId,
      src_file_name: fileName,
      file_dir: fileDir,
      file_path: filePath,
      dist_file_id: distFileId,
      created_timestamp: new Date(),
    }]);
  }

  return {
    getAll, isExistSrcFileId, insertData, getBySrcFileId,
  }
})();

6. index.html

パラメータをなくして GET をしたときに表示するリファレンス.

index.html
<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
    <title>GetGoogleDocument</title>
    <meta name="description" content="SendEmail は GAS からメールを送信するためのアプリケーションです。">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/default.min.css">
    <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js"></script>
  </head>
  <body>
    <h1>概要</h1>
    <p>
      GetGoogleDocument は Google Drive 上の Google Document で HTML コンテンツを編集・管理するためのアプリケーションです。
      また、リソースを外部から取得するための API を持っています。
    </p>
    <h1>仕様</h1>
    <h2>API リクエスト</h2>
    <table>
      <tr>
        <th>エンドポイント</th>
        <td>https://script.google.com/macros/s/.../exec</td>
      </tr>
      <tr>
        <th>HTTP メソッド</th>
        <td>GET</td>
      </tr>
      <tr>
        <th>GET プロパティ</th>
        <td>file -- Source File ID を指定</td>
      </tr>
    </table>
    <h2>GET プロパティ - file</h2>
    Source File ID を指定します。'all' が指定されたとき、すべてのメタデータを返します。
    <h2>レスポンス</h2>
    <h3>通常</h3>
    この、リファレンスを表示します。
    <h3>GET プロパティ - all</h3>
    下記のJSONを返します
    <pre><code>
      {
        src_file_id: String,
        src_file_name: String,
        file_dir: String,
        file_path: String,
        dist_file_id: String,
        created_timestamp: Number
      }[]
    </code></pre>
    <h3>GET プロパティ - {Source File ID}</h3>
    ドキュメントのHTMLテキストを返します
    <script>hljs.highlightAll();</script>
  </body>
</html>

使い方

  1. Google Drive の src にファイルを作成したら、GetGoogleDocs の docExportAsHTMLFile を実行する. そうすると、.dist にファイルが生成される.
  2. GetGoogleDocs をウェブアプリとして公開.
    例えば、https://script.google.com/macros/s/abcde-12345/exec のようなURLが得られる.
  • https://script.google.com/macros/s/abcde-12345/exec にアクセスすると、index.html が表示
  • https://script.google.com/macros/s/abcde-12345/exec?file=all にアクセスすると、ファイルのメタ情報 (DataSheet の内容) が全表示
  • https://script.google.com/macros/s/abcde-12345/exec?file=hogehoge にアクセスすると、hogehoge という src_file_id を持つファイルのコンテンツデータ (HTML) が表示

file を指定した時の戻り値は JSON なので、 JavaScript から自由に Fetch できる.

Discussion