NotionをヘッドレスCMSとして使ったら画像がS3 Presigned URLだったので生成AIで解決してみた

NotionをヘッドレスCMSとして使ったら画像がS3 Presigned URLだったので生成AIで解決してみた

Notion APIでサイト制作をする際に、画像がS3 Presigned URLになるのでローカルにダウンロードする処理を追加しました。生成AIを使って処理を書いたので一連の流れを紹介します。
Clock Icon2025.04.11

Notion APIと静的サイトジェネレーター11tyを使ってAPIによるデータ取得とHTMLの生成を行いました、こちらの記事の続編となります。
https://dev.classmethod.jp/articles/notion-api-11ty-cms/

取得した画像はS3 Presigned URLになり、期限が切れると画像が表示されなくなってしまいます。
HTMLを生成する際に画像をローカルにダウンロードするというscriptを追加する必要があります。
この記事では、生成AIを活用してscriptを書く流れを紹介します。
前記事の作業が完了していることを前提とします。

検証時の環境

  • ChatGPT-4o
  • macOS Sonoma バージョン: 14.6.1
  • node: v22.11.0
  • Google Chrome バージョン: 135.0.7049.41

ディレクトリ構成とコードを全て渡す

現状のディレクトリ構成に極力変更を加えずに画像のダウンロードを実現したいので、次の情報を伝えました。

  • ディレクトリ構成
  • 必要になりそうなコードの中身
  • ビルドファイルに画像を含めたいこと
  • 改修の際にコードに処理内容のコメントも書いてもらう

プロンプト

静的サイトジェネレーターである11tyを利用しています。
サードパーティAPIとしてNotion APIを利用しています。
Notion Database内に画像が含まれている場合、ビルドしたHTMLには
Amazon S3 Pre-signed URLが含まれています。
ビルドされるアーティファクト一式に画像ファイルも含めたい場合にはどうしたらいいですか?

現在の構成はこちらです。これに合わせてください。
.
├── .env
├── _data
│   ├── api_members.js
│   └── api_schedules.js
├── _site
│   └── index.html
├── index.njk
├── node_modules
└── package.json

api_members.jsを改修してソースをできるだけ簡潔にしつつ、処理内容のコメントも加えてください。
現状の_data/api_members.jsのコードはこちらです。

const Client = require('@notionhq/client').Client;
require('dotenv').config();
const notion = new Client({ auth: process.env.API_KEY });

module.exports = async function() {
  const memberDataResponse = await notion.databases.query({
    database_id: process.env.DATABASE_MEMBERS_ID,
    sorts: [
      {
        property: 'Name',
        direction: 'ascending',
      },
    ],
  });
  return memberDataResponse.results;
};

index.njkの処理部分のコードはこちらです。
  <section>
    <div>
      <h2>メンバー</h2>
      <ul>
        {%- for member in api_members %}
        <li>
          <p>なまえ:{{member.properties.Name.title[0].plain_text}}</p>
          <img src="{{member.properties.image.files[0].file.url}}" alt="" style="width:50px;">
        </li>
        {% endfor %}
      </ul>
    </div>
  </section>

  <section>
    <div>
      <h2>スケジュール</h2>
      <ul>
        {% for schedule in api_schedules %}
        <li>
          <p>イベント:{{schedule.properties.title.title[0].plain_text}}</p>
          <p>日にち:{{schedule.properties.date.date.start}}</p>
        </li>
        {% endfor %}
      </ul>
    </div>
  </section>

構成に合わせたコードが返ってくる

ChatGPTの回答をいくつかに区切って紹介します。
ChatGPTの回答

現在の _data/api_members.js のコードを拝見しました。
このコードに、画像のダウンロード処理とローカルパスの付与を組み込むことは可能です。
現在の構成に合わせて、Notion APIから画像を取得し、11ty のビルド成果物に
画像を含める方法を以下に整理してご案内します。

目的
- _data/api_members.js などで Notion API から記事データを取得している。
- 記事データに含まれる画像(Pre-signed S3 URL)をローカルにダウンロードしたい。
- ダウンロードした画像を 11ty の出力ディレクトリ(_site/)に含めたい。
- テンプレート(例: index.njk)では、ローカル画像を参照したい。

想定される最終構成
.
├── .env
├── _data/
│   ├── api_members.js      ← Notion APIから記事データ取得 画像ダウンロードスクリプト
│   └── api_schedules.js
├── images/                  ← ダウンロードされた画像を格納
├── index.njk
├── .eleventy.js             ← Eleventy 設定ファイル images を passthroughCopy
├── package.json
├── _site/                   ← ビルド成果物(画像も含まれる)
│   └── images/

ここまでで目的と最終構成に相違がないことを確認できました。
続きを見ていきます。

  1. _data/api_members.js のコード
    処理内容に対してコメントも付いています。
    URLから画像をダウンロードし、ファイルシステムに保存してレスポンスのjsonに保存先のパスを追加して11tyに渡しています。
api_members.js
const { Client } = require('@notionhq/client'); // Notion API クライアント
const fs = require('fs');
const path = require('path');
const fetch = require('node-fetch'); // HTTPリクエスト用
require('dotenv').config(); // .env から環境変数を読み込む

const notion = new Client({ auth: process.env.API_KEY }); // Notion クライアント初期化
const IMAGE_DIR = path.join(__dirname, '..', 'images'); // 画像保存先ディレクトリ

// 画像をローカルに保存(すでに存在する場合はスキップ)
async function downloadImageIfNeeded(url) {
  const filename = path.basename(new URL(url).pathname); // URLからファイル名を取得
  const filepath = path.join(IMAGE_DIR, filename);

  if (!fs.existsSync(filepath)) {
    const res = await fetch(url);
    if (!res.ok) throw new Error(`画像の取得に失敗: ${url}`);
    const buffer = await res.buffer();
    fs.writeFileSync(filepath, buffer);
    console.log(`画像を保存しました: ${filename}`);
  }

  return `images/${filename}`; // テンプレートで使う相対パス
}

module.exports = async function () {
  // 画像保存用ディレクトリがなければ作成
  if (!fs.existsSync(IMAGE_DIR)) {
    fs.mkdirSync(IMAGE_DIR, { recursive: true });
  }

  // Notion データベースから記事データを取得
  const response = await notion.databases.query({
    database_id: process.env.DATABASE_MEMBERS_ID,
    sorts: [{ property: 'Name', direction: 'ascending' }],
  });

  const members = response.results;

  // 各記事に画像があればローカルに保存し、localImage プロパティを追加
  for (const member of members) {
    const imageFiles = member.properties?.image?.files;
    const firstFile = imageFiles && imageFiles[0];
    const imageUrl = firstFile?.file?.url || firstFile?.external?.url;

    if (imageUrl) {
      try {
        member.localImage = await downloadImageIfNeeded(imageUrl);
      } catch (err) {
        console.error(`画像のダウンロードに失敗: ${imageUrl}`, err);
      }
    }
  }

  return members; // テンプレートで使えるように返す
};
  1. .eleventy.js を新規作成し、画像コピー設定を追加
    _siteに画像をコピーする処理が追加されました。11tyに用意されているメソッドでした。
    参考サイト:11ty Passthrough File Copy
.eleventy.js
module.exports = function(eleventyConfig) {
  eleventyConfig.addPassthroughCopy("images");

  return {
    dir: {
      input: ".",
      output: "_site"
    }
  };
};
  1. テンプレートでの使用例(index.njk)
    レスポンスに追加したローカルのパスをhtmlに追記するように修正されていました。
index.njk
{%- for member in api_members %}
<li>
  <p>なまえ:{{ member.properties.Name.title[0].plain_text }}</p>

  {# ローカル画像がある場合は表示 #}
  {% if member.localImage %}
    <img src="{{ member.localImage }}" alt="" style="width:50px;">
  {% endif %}
</li>
{% endfor %}

4.依存パッケージの確認

npm install node-fetch@2 --save-dev

実際には複数回のやり取りをしていますが、すべてを紹介すると冗長になるので、まとめるとこのような回答を得られました。
生成AIから得られたコードを反映、及びパッケージインストールをした後、 npm run dev してみたところ、ローカルに画像がダウンロードされていました。

目的は達成できましたが、このまま内容を理解せずに利用してしまうのはリスクになるので、処理の内容を確認していきます。

コードをブラッシュアップする

api_members.jsの「// 各記事に画像があればローカルに保存し、localImage プロパティを追加」の処理で複数ある画像の1つ目を取得してるようだったので、修正できそうな箇所を生成AIに質問していきます。

プロンプト

画像に関しては0〜1個の前提になります。
この場合、コードをさらにシンプルにすることはできますか?

ChatGPTの回答

はい、画像が「01個だけ存在する」前提であれば、コードをさらにシンプルにできます!
この前提があることで、ループ処理や複数ファイルの考慮が不要になるため、コードを簡潔に保てます。

現在のコード
const imageFiles = member.properties?.image?.files;
const firstFile = imageFiles && imageFiles[0];
const imageUrl = firstFile?.file?.url || firstFile?.external?.url;

シンプルに書き換えたコード
const imageUrl = member.properties?.image?.files?.[0]?.file?.url
              || member.properties?.image?.files?.[0]?.external?.url;

新たにもらったソースを採用しても動くことが確認できました。
さらに不明な箇所を質問していきます。

プロンプト

処理の内容について質問させてください。根拠となる1次ソース付きで回答してください。
const fs = require('fs'); は何をしていますか?

ChatGPTの回答

以下に、各質問に対して一次ソース(公式ドキュメントなど)を引用しながら、根拠を明示して回答します。

const fs = require('fs'); は何をしていますか?
回答:
このコードは、Node.js の組み込みモジュールである fs(File System)モジュールを読み込んで、
fs という変数に代入しています。これにより、ファイルの読み書きなどのファイルシステム操作を行う
関数が使用可能になります。

一次ソース:
Node.js 公式ドキュメント(fs モジュール)より:

To use the fs module, use require('fs'):
https://nodejs.org/api/fs.html

引用:
To use the promise-based APIs:
const fs = require('fs');

つまり、fs モジュールを require で読み込むことで、ファイルの読み書き、ディレクトリの作成・削除などの
操作が可能になります。

この要領で質問を繰り返し、処理内容の不明点を解消していきました。
生成AIの回答を鵜呑みにはできませんが、このような形で1次ソースも確認できれば安心です。
処理内容も理解できたので、ビルドしたHTMLをアップしても問題なさそうです。

まとめ

目的のコードが生成できました。
img_notion-api-11ty-cms10

_site/index.html
      <ul>
        <li>
          <p>なまえ:ちえぞう</p>
          <img src="images/img_chiezo.png" alt="" style="width:50px;">        
        </li>

        <li>
          <p>なまえ:ねこぞう</p>
          <img src="images/img_nekozo.png" alt="" style="width:50px;">
        </li>

        <li>
          <p>なまえ:ねこたろう</p>
          <img src="images/img_nekotaro.png" alt="" style="width:50px;">
        </li>
      </ul>

ファイル一式はこちらになります。

全ファイルの内容はこちら

環境構築の仕方は前記事を参考にしてください。

package.json
{
  "name": "notion-api-11ty",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "eleventy"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "description": "",
  "devDependencies": {
    "@11ty/eleventy": "^3.0.0",
    "@notionhq/client": "^2.3.0",
    "dotenv": "^16.4.7",
    "node-fetch": "^2.7.0"
  }
}
.env
//Notin APIキー
API_KEY = ntn_**********************************************

//Notion データベースID
DATABASE_MEMBERS_ID = *********************************
DATABASE_SCHEDULES_ID = *********************************
.eleventy.js
module.exports = function(eleventyConfig) {
  eleventyConfig.addPassthroughCopy("images");

  return {
    dir: {
      input: ".",
      output: "_site"
    }
  };
};
_data/api_members.js
const { Client } = require('@notionhq/client'); // Notion API クライアント
const fs = require('fs');
const path = require('path');
const fetch = require('node-fetch'); // HTTPリクエスト用
require('dotenv').config(); // .env から環境変数を読み込む

const notion = new Client({ auth: process.env.API_KEY }); // Notion クライアント初期化
const IMAGE_DIR = path.join(__dirname, '..', 'images'); // 画像保存先ディレクトリ

// 画像をローカルに保存(すでに存在する場合はスキップ)
async function downloadImageIfNeeded(url) {
  const filename = path.basename(new URL(url).pathname); // URLからファイル名を取得
  const filepath = path.join(IMAGE_DIR, filename);

  if (!fs.existsSync(filepath)) {
    const res = await fetch(url);
    if (!res.ok) throw new Error(`画像の取得に失敗: ${url}`);
    const buffer = await res.buffer();
    fs.writeFileSync(filepath, buffer);
    console.log(`画像を保存しました: ${filename}`);
  }

  return `images/${filename}`; // テンプレートで使う相対パス
}

module.exports = async function () {
  // 画像保存用ディレクトリがなければ作成
  if (!fs.existsSync(IMAGE_DIR)) {
    fs.mkdirSync(IMAGE_DIR, { recursive: true });
  }

  // Notion データベースから記事データを取得
  const response = await notion.databases.query({
    database_id: process.env.DATABASE_MEMBERS_ID,
    sorts: [{ property: 'Name', direction: 'ascending' }],
  });

  const members = response.results;

  // 各記事に画像があればローカルに保存し、localImage プロパティを追加
  for (const member of members) {
    const imageUrl = member.properties?.image?.files?.[0]?.file?.url
          || member.properties?.image?.files?.[0]?.external?.url;

    if (imageUrl) {
      try {
        member.localImage = await downloadImageIfNeeded(imageUrl);
      } catch (err) {
        console.error(`画像のダウンロードに失敗: ${imageUrl}`, err);
      }
    }
  }

  return members; // テンプレートで使えるように返す
};
_data/api_schedules.js
const Client = require('@notionhq/client').Client;
require('dotenv').config();
const notion = new Client({ auth: process.env.API_KEY });

module.exports = async function() {
  const scheduleDataResponse = await notion.databases.query({
    database_id: process.env.DATABASE_SCHEDULES_ID,
    sorts: [
      {
        property: 'date',
        direction: 'ascending',
      },
    ],
  });
  return scheduleDataResponse.results;
};
index.njk
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Notion-APIと11tyで作るサイト</title>
</head>

<body>
  <h1>Notion-APIと11tyで作るサイト</h1>

  <section>
    <div>
      <h2>メンバー</h2>
      <ul>
        {%- for member in api_members %}
        <li>
          <p>なまえ:{{member.properties.Name.title[0].plain_text}}</p>
          {# ローカル画像がある場合は表示 #}
          {% if member.localImage %}
            <img src="{{ member.localImage }}" alt="" style="width:50px;">
          {% endif %}
        </li>
        {% endfor %}
      </ul>
    </div>
  </section>

  <section>
    <div>
      <h2>スケジュール</h2>
      <ul>
        {% for schedule in api_schedules %}
        <li>
          <p>イベント:{{schedule.properties.title.title[0].plain_text}}</p>
          <p>日にち:{{schedule.properties.date.date.start}}</p>
        </li>
        {% endfor %}
      </ul>
    </div>
  </section>
</body>
</html>

前後編を通して、Notion APIと11tyを使ったHTML生成が出来るようになりました。
画像の有効期限切れ問題にも対応できたので、小規模なサイト制作に活用したいと思います。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.