⏲️ Notionの記事をHugoで使えるMarkdownに変換する

最近重い腰を上げてNotionを使い始めました。噂には聞いてましたが使い勝手が非常に良さそうです。

Notionでブログが書けるようにしておいたらここも気軽に投稿できるようになって良さそうなので、Notion上の記事をHugoで使えるマークダウンファイルに変換すべく、APIを触ったりJSを書いてゴニョゴニョしてみました。

やった内容

Notion側

まずはNotion側でAPIで記事を取得できるようにするために以下の作業を行いました。

  1. 記事一覧のデータベースを作成
  2. インテグレーションを作成し、トークンを取得
  3. 本文以外に、タイトルやタグ、URLや絵文字など、ブログに合わせて設定しておきたい情報があるのでPropertyを追加してTagやらStatusやらを登録

この辺は探せば色々でてくるので細かい方法は割愛。

サイト(JS?)側

サイト側ではHugoの入っているディレクトリに、新たにマークダウン変換用のJavascriptを作成します。

APIで記事を取得し、ページごとに必要な情報をまとめて任意のディレクトリに .md で保存する、みたいな感じで(変換用のJSを書き足すだけなのでHugoはあんまり関係無いかも…)以下の手順で行いました。

  1. /tools/notionyarn initしてからindex.js を作成
  2. APIとのやりとりは @notionhq/client パッケージを利用して記事を取得
  3. 本文部分は n2m パッケージを利用してマークダウンに変換
  4. Hugo用のFront Matterを作成。TagやらStatusやらの追加の情報を取得してyaml に変換
  5. Front Matterと本文を結合してファイルに保存。記事の状態によってファイルを保存するディレクトリも変更

まずは.mdに変換したい記事のidの一覧を @notionhq/client を使用して引っ張ってきます。

Notion上に登録している記事全てをとってきてしまうと記事数が増えた時に大変なので、 Property filter object を使用して、Notion上で特定のStatusに設定しておいた記事だけをとってくるようにしました。

require("dotenv").config();
const { Client } = require("@notionhq/client");

async function fetchPageIds() {
  const databaseId = "xxxxx";
  const response = await notion.databases.query({
    database_id: databaseId,
    filter: {
      or: [
        {
          property: "Status",
          select: {
            equals: "Draft",
          },
        },
        {
          property: "Status",
          select: {
            equals: "Publish",
          },
        },
      ],
    },
  });
  return response.results.map((row) => {
    return row.id;
  });
}

const notion = new Client({ auth: process.env.NOTION_TOKEN });
const pageIds = await fetchPageIds();
console.log(pageIds);

次に取得した記事のidごとにデータを問い合わせて、それをもとにマークダウンに変換していきます。

for (let i = 0; i < pageIds.length; i++) {
    const pageId = pageIds[i];
    await convertPage(pageId);
}

async function convertPage(pageId) {
  const page = await notion.pages.retrieve({ page_id: pageId });
  const block = await notion.blocks.retrieve({ block_id: pageId });

	//ここで記事を変換したり保存したり
}

.mdの中身に記載するのは本文だけではありません。

タイトルや日付、記事アイコン、タグなどの捕捉情報をFront Matterとしてyamlでまとめた情報を追加しておく必要があります。

なのでちょっと面倒ですが以下みたいな感じで変換しています。

async function createFrontMatter(pageId, page, block) {
  const title = block.child_page.title;
  const emoji = page.icon ? page.icon.emoji : "";
  const last_edited_time = page.last_edited_time;
  const updated = dayjs(last_edited_time);
  const tags = await getTags(pageId, page.properties.Tags.id);
  const categories = await getTags(pageId, page.properties.Categories.id);
  const status = await notion.pages.properties.retrieve({
    page_id: pageId,
    property_id: page.properties.Status.id,
  });

  let result = `---\n`;
  result += `title: ${title}\n`;
  result += `emoji: ${emoji}\n`;
  result += `date: ${updated.format("YYYY-MM-DD hh:mm:ss")}\n`;
  result += `blog/archives:\n`;
  result += `- ${updated.format("YYYY/MM")}\n`;
  result += `blog/categories:\n`;
  for (let i = 0; i < categories.length; i++) {
    const category = categories[i];
    if (category != "Draft" && category != "Archived") {
      result += `- ${category}\n`;
    }
  }
  result += `blog/tags:\n`;
  for (let i = 0; i < tags.length; i++) {
    const tag = tags[i];
    if (tag != "Draft" && tag != "Archived") {
      result += `- ${tag}\n`;
    }
  }
  if (status.select && status.select.name == "Draft") {
    result += `draft: true\n`;
  }
  result += `---\n`;
  return result;
}

async function getTags(pageId, propertyId) {
  const response = await notion.pages.properties.retrieve({
    page_id: pageId,
    property_id: propertyId,
  });
  return response.multi_select.map((x) => x.name);
}

あとは n2m で変換した本文と先ほどのFront Matterを結合。

const page = await notion.pages.retrieve({ page_id: pageId });
const block = await notion.blocks.retrieve({ block_id: pageId });
const frontMatter = await createFrontMatter(pageId, page, block);
const mdblocks = await n2m.pageToMarkdown(pageId);
const mdString = n2m.toMarkdownString(mdblocks);
const body = frontMatter + "\r" + mdString;

最後にファイル名を指定したり、下書きやら公開やらの情報を加味して、それぞれ必要なディレクトリに保存します。

//ファイル名を取得
const slug = await notion.pages.properties.retrieve({
  page_id: pageId,
  property_id: page.properties.Slug.id,
});
let filename = pageId;
if (slug.results[0].rich_text.plain_text) {
  filename = slug.results[0].rich_text.plain_text;
}

//ステータスを取得
const status = await notion.pages.properties.retrieve({
  page_id: pageId,
  property_id: page.properties.Status.id,
})

//古いファイルがあれば削除
const last_edited_time = page.last_edited_time;
const updated = dayjs(last_edited_time);
const archivePath = updated.format("YYYY/MM");
await deleteFile(`${contentBasePath}/drafts`, `${filename}.md`);
await deleteFile(`${contentBasePath}/${archivePath}`, `${filename}.md`);

//ファイルを生成
if (status.select && status.select.name == "Draft") {
  await saveFile(body, `${contentBasePath}/drafts`, `${filename}.md`);
} else {
  await saveFile(body, `${contentBasePath}/${archivePath}`, `${filename}.md`);
}

async function deleteFile(dir, filename) {
  if (fs.existsSync(`${dir}/${filename}`)) {
    fs.unlinkSync(`${dir}/${filename}`);
  }
}

async function saveFile(data, dir, filename) {
  if (!fs.existsSync(dir)) {
    fs.mkdirSync(dir, { recursive: true });
  }
  fs.writeFileSync(`${dir}/${filename}`, data);
}

といった感じで .mdのファイルを必要なディレクトリに保存する事ができました。

あとはコマンドを叩いたら以下の一連のデプロイに必要な処理が実行されるようにしておいて作業完了。

  1. workflow_run経由でGithub Actionsが実行されてこのスクリプトを実行し
  2. 出来上がったマークダウンファイルを元にHugoのビルドを行い
  3. その後差分ファイルでプルリクを作って
  4. developブランチにマージし
  5. ステージングに反映する

毎回ターミナルを開くのが面倒なのでそのうちSlackのSlashコマンドか何かで反映できるようにしたいところですがそれはまた今度にしよう…

所感

JamstackはWordPressと比べると設置もカスタマイズも柔軟で簡単だし、静的なファイルを生成するのでセキュリティのリスクも少ないし、さらにサーバーエラーでページが表示できなくなるなんて事も無さそうなので非常に魅力的…と感じていたのですが、肝心のヘッドレスCMSがなかなか決め手のものが出てこなくて二の足を踏んでおりました。

Notionを使った方法は導入も簡単で手離れも良さそうだし、今後は仕事でも内容によってはこちらを推せそうな気配がしています。

Profile

石原 悠 / Yu Ishihara

デザインとプログラミングと編み物とヨーグルトが好きです。