📝

Dify で Markdown を Notion に投稿するための変換コード

2025/01/20に公開

概要

Notion は Web 上でドキュメントやデータベースを管理できる強力なツールですが、API 経由でコンテンツを投稿する際には Markdown テキストを自動でフォーマットしてくれません。そのため、Markdown を Notion がサポートしている「ブロック形式 (JSON)」 に変換する必要があります。

この記事では、Dify のカスタムアクションで受け取った Markdown テキストを Notion API に適した形式へ変換する JavaScript のサンプルコードを紹介します。

リポジトリ: onigiri24365/markdown-to-notion-block
Notion API ドキュメント: https://developers.notion.com/reference/block

なぜこのコードが必要なのか?

  • Markdown のままでは Notion API で扱えない
    Notion に直接 Markdown を投げても正しく変換してはくれません。サードパーティのサービスとしては、公式の変換機能はまだ用意されていません(2025年1月時点)。

  • Dify を使うときの利便性
    Dify のカスタムアクションで受け取ったテキストをそのまま Notion に登録すると、整形されないただの文字列として扱われてしまいます。そこで、Dify のカスタムアクション内でこの変換ロジックを組み込み、Notion API が受け取れる形(JSON ブロック)へ変換することでスムーズに投稿が可能になります。

コード全文

以下のコードは、Markdown を行単位で解析し、見出し・リスト・段落などを Notion 用のブロック形式に変換するものです。Dify のカスタムアクションで利用できるよう、main() 関数で受け取った markdown を処理し、JSON 文字列を返すようになっています。

const markdownToBlocks = (markdown) => {
  if (!markdown) return [];

  // 行ごとに分割
  const lines = markdown.split('\n');
  const blocks = [];
  let currentListItems = [];
  let isInList = false;

  for (let i = 0; i < lines.length; i++) {
    const line = lines[i];
    const trimmedLine = line.trim();
    
    // 空行の処理
    if (!trimmedLine) {
      if (isInList) {
        blocks.push(...processListItems(currentListItems));
        currentListItems = [];
        isInList = false;
      }
      continue;
    }

    // リスト項目の処理
    if (trimmedLine.startsWith('* ') || trimmedLine.startsWith('- ')) {
      isInList = true;
      const indent = line.search(/\S/); // インデントレベルを計算
      currentListItems.push({
        content: processInlineContent(trimmedLine.substring(2)),
        indent: Math.floor(indent / 2) // 2スペースごとに1レベル
      });
      
      // 最後の行の場合はリストを確定
      if (i === lines.length - 1) {
        blocks.push(...processListItems(currentListItems));
      }
      continue;
    }

    // リスト処理中に非リスト行が来た場合、現在のリストを確定
    if (isInList) {
      blocks.push(...processListItems(currentListItems));
      currentListItems = [];
      isInList = false;
    }

    // 見出しの処理
    if (trimmedLine.startsWith('#')) {
      const level = trimmedLine.match(/^#+/)[0].length;
      const text = trimmedLine.substring(level).trim();
      blocks.push(createHeadingBlock(text, level));
      continue;
    }

    // 通常のテキスト
    blocks.push(createParagraphBlock(processInlineContent(trimmedLine)));
  }

  return blocks;
};

// リスト項目の処理
const processListItems = (items) => {
  return items.map(item => ({
    type: 'bulleted_list_item',
    bulleted_list_item: {
      rich_text: item.content,
      ...(item.indent > 0 && { indent: item.indent })
    }
  }));
};

// インライン要素(太字など)の処理
const processInlineContent = (text) => {
  const parts = [];
  let currentIndex = 0;
  
  while (currentIndex < text.length) {
    const nextBoldStart = text.indexOf('**', currentIndex);
    
    if (nextBoldStart === -1) {
      // 残りのテキストを通常テキストとして追加
      if (currentIndex < text.length) {
        parts.push({
          type: 'text',
          text: { content: text.substring(currentIndex) },
          annotations: { bold: false }
        });
      }
      break;
    }
    
    // 太字開始前のテキストを追加
    if (nextBoldStart > currentIndex) {
      parts.push({
        type: 'text',
        text: { content: text.substring(currentIndex, nextBoldStart) },
        annotations: { bold: false }
      });
    }
    
    // 次の太字終了位置を探す
    const nextBoldEnd = text.indexOf('**', nextBoldStart + 2);
    
    if (nextBoldEnd === -1) {
      // 閉じタグがない場合は、開始タグを含めて残りを通常テキストとして追加
      parts.push({
        type: 'text',
        text: { content: text.substring(nextBoldStart) },
        annotations: { bold: false }
      });
      break;
    }
    
    // 太字テキストを追加
    const boldText = text.substring(nextBoldStart + 2, nextBoldEnd);
    if (boldText) {
      parts.push({
        type: 'text',
        text: { content: boldText },
        annotations: { bold: true }
      });
    }
    
    currentIndex = nextBoldEnd + 2;
  }
  
  return parts;
};

// 見出しブロックの作成
const createHeadingBlock = (text, level) => {
  // h4をh3として処理
  if (level === 4) {
    level = 3;
  }
  // h5以降は太字の段落として処理
  if (level >= 5) {
    return {
      type: 'paragraph',
      paragraph: {
        rich_text: [{
          type: 'text',
          text: { content: text },
          annotations: { bold: true }
        }]
      }
    };
  }
  return {
    type: `heading_${level}`,
    [`heading_${level}`]: {
      rich_text: [{
        type: 'text',
        text: { content: text }
      }]
    }
  }
};

// 段落ブロックの作成
const createParagraphBlock = (richText) => ({
  type: 'paragraph',
  paragraph: {
    rich_text: richText
  }
});

function main({markdown}) {
  return {
    result: JSON.stringify(markdownToBlocks(markdown))
  }
}

変換結果のイメージ

上記コードを通すと、次のような JSON 形式が返されます。これをそのまま Notion API(create a page エンドポイント など)に渡すことで、Markdown の構造が再現されたドキュメントが作成されます。

[
  {
    "type": "heading_1",
    "heading_1": {
      "rich_text": [
        {
          "type": "text",
          "text": {
            "content": "タイトル"
          }
        }
      ]
    }
  },
  {
    "type": "paragraph",
    "paragraph": {
      "rich_text": [
        {
          "type": "text",
          "text": {
            "content": "これは"
          },
          "annotations": {
            "bold": false
          }
        },
        {
          "type": "text",
          "text": {
            "content": "強調"
          },
          "annotations": {
            "bold": true
          }
        },
        {
          "type": "text",
          "text": {
            "content": "された文章です。"
          },
          "annotations": {
            "bold": false
          }
        }
      ]
    }
  },
  ...
]

対応している記法

  • 見出し: #, ##, ### (さらに ####### 扱い、 ##### 以降は太字段落扱い)
  • リスト: * または -
    • インデントを 2 スペース刻みで入れるとネストリストを作れる
  • 太字: **text**
  • 段落テキスト

エッジケース

  • ネストリストを正しく反映
    インデントを計算し、階層構造を保つようにする
  • 不完全な太字記法への対応
    **開始タグ** があって閉じタグがない場合でもそのままテキストとして取り扱う
  • 複数連続する太字も順番に処理

Dify での利用例

Dify でカスタムアクションを作成し、受け取ったパラメータ markdown を変換したい場合、下記のように main() 関数を定義し、アクションのレスポンスとして JSON を返します。

function main({ markdown }) {
  return {
    // `result` というキーで返すと、そのままDify内で利用できます
    result: JSON.stringify(markdownToBlocks(markdown))
  }
}

この結果を、そのまま Notion API のボディに組み込むことで、自動的にブロックが生成されます。

まとめ

  • Notion は API 経由で投稿する際に Markdown を自動変換してくれない
  • そのため、Markdown → Notion ブロック(JSON) への変換が必須。
  • Dify のカスタムアクションで受け取った Markdown を簡単に Notion に投稿できるようにするため、この 変換ロジック が役立つ。

興味があれば是非試してみてください。PR や Issue 大歓迎です!

リンク

Discussion