😎

LLM x microCMS でおすすめ記事をAIに決めさせる

2024/12/06に公開

私は普段からmicroCMSにお世話になっています。歴史学や社会学、美学といった人文学分野の若手研究者の集まりである「ゆまにすと」というグループのwebメディアをmicroCMSを用いて運営しています。私たちのサイトでは例えば「ソシュールは通信工学であり、パースは生成AIである」ことがわかるような記号論の記事などが投稿されています。

この記事ではLLMを用いてmicroCMSで投稿された記事本文をベクトル化し、類似度の高いほかの記事を関連記事として登録する方法を解説します。

この記事で学べること

webhookのカスタム通知を利用して、microCMSで記事が公開されると自動的に関連記事を設定する方法が学べます。

説明すること

  • microCMSとLLMによるおすすめ記事(関連記事)の自動登録の方法
  • FastAPI使ってmicroCMSのwebhookを受け取る方法
  • chromaによるベクトル類似検索の方法

説明しないこと

使用するフレームワーク等の説明は本題から外れるため説明しません。

APIキーの設定を変更

  1. 事前にmicroCMSに登録して使用するサービスコンテンツを作成します。
  2. https://[サービスID].microcms.io/apis/[コンテンツID]にアクセスします。
  3. [権限管理] を開きます。
  4. 無料版を使用している場合はデフォルトのAPIキーの [コンテンツ(API)][デフォルト権限]PATCH を追加します。有料版を使用している場合は新たに GETPATCH を許可したAPIキーを発行してください。PATCHメソッドは関連記事の設定をAIに行わせるために使います。

関連記事フィールドの作成

  1. APIキーの設定を変更と同様にご自身のサービスにアクセスします。
  2. [コンテンツ(API)] > [使用するコンテンツ] > [API設定] > [APIスキーマ] を開きます。
  3. [APIスキーマ] を一番下までスクロールして [フィールドを追加] をクリックします。
  4. [フィールド名][表示名] を入力します。ここでは [フィールド名]related_blog_post[表示名]関連する記事 とします。
  5. [種類][複数コンテンツ参照] を選択します。参照したいコンテンツにこのコンテンツ自身(現在APIスキーマを変更しているコンテンツ)を設定します。

カスタム通知の作成

  1. 事前にmicroCMSに登録して使用するサービスコンテンツを作成します。
  2. https://[サービスID].microcms.io/apis/[コンテンツID]にアクセスします。
  3. [コンテンツ(API)] > [使用するコンテンツ] > [API設定] > [Webhook] を開きます。
  4. [Webhook] 一覧を一番下までスクロールして [追加] をクリックします。
  5. [サービスの選択] から [カスタム通知] を選択します。
  6. 作成された [カスタム通知] のシェブロンボタンを押して詳細を表示します。
  7. 後述するカスタム通知を受け取るアプリケーションのエンドポイントと検証に使用する [シークレット] を設定します。
  8. [通知タイミングの設定] に以下の2つを設定します(microCMSのPATCHリクエストは公開済みのコンテンツに対してのみ行えるため)。
    • [コンテンツの公開(管理画面による操作)]
    • [コンテンツの公開(レビューによる操作)]

カスタム通知を受け取るFastAPIアプリケーションの作成

FastAPI を用いて必要最小限のWebhook検証アプリケーションを作成します。検証方法は Signatureの検証を参考にします。 SECRET_KEY に先ほどカスタム通知の作成 で設定したものを代入します。

main.py
from fastapi import FastAPI, Request, HTTPException
import hmac
import hashlib

# FastAPIアプリケーションのインスタンスを作成
app = FastAPI()

# Webhook署名を検証するための秘密鍵(シークレットキー)
# セキュリティ上の理由から、本番環境では.envファイルなどで管理し、コードに直接記述しないこと
SECRET_KEY = b"<設定したシークレット>"  

# Webhookから送信された署名を検証する関数
def verify_webhook_signature(secret_key: bytes, body: bytes, signature: str) -> bool:
    """
    Webhookリクエストの署名を検証する関数。
    サーバー側で生成した署名と、リクエストから送られた署名を比較して一致するか確認します。

    Parameters:
        secret_key (bytes): サーバー側の秘密鍵。
        body (bytes): リクエストボディの内容。
        signature (str): リクエストヘッダーから取得した署名。

    Returns:
        bool: 署名が一致している場合はTrue、一致していない場合はFalse。
    """
    # サーバー側でリクエストボディを元に署名を生成
    expected_signature = hmac.new(
        key=secret_key,  # 秘密鍵を使用
        msg=body,       # リクエストボディ
        digestmod=hashlib.sha256  # ハッシュアルゴリズムとしてSHA256を使用
    ).hexdigest()

    # 生成した署名と送られてきた署名を比較
    return hmac.compare_digest(expected_signature, signature)

# Webhookのエンドポイントを定義
@app.post("/webhook")
async def webhook_handler(request: Request):
    """
    Webhookリクエストを受信し、署名を検証するエンドポイント。
    署名が正しければ成功メッセージを返します。

    Parameters:
        request (Request): FastAPIのリクエストオブジェクト。

    Returns:
        dict: 検証成功時のレスポンスメッセージ。
    """
    # リクエストボディを非同期で取得(Webhookの内容が含まれる)
    body = await request.body()

    # リクエストヘッダーから署名を取得
    signature = request.headers.get('X-MICROCMS-Signature')  # microCMSが送信する署名ヘッダー
    if not signature:
        # 署名が含まれていない場合は400エラーを返す
        raise HTTPException(status_code=400, detail="Missing signature header")

    # 署名の検証を実行
    if not verify_webhook_signature(SECRET_KEY, body, signature):
        # 署名が一致しない場合は403エラーを返す
        raise HTTPException(status_code=403, detail="Invalid signature")

    # 署名が正しい場合、成功メッセージを返す
    return {"message": "Webhook verified successfully"}

microCMSにリクエストを送る関数を実装する

MicroCMSからデータを取得したり、データの修正する関数を実装します。

GETメソッド

GET メソッドにより blog_id で指定された記事のデータを取得するコードを実装します。

main.py
import requests
import json

# 指定したブログ記事をmicroCMSのAPIを使って取得する関数
def get_blog_post(blog_id, api_key):
    """
    指定したブログIDを元にmicroCMSからブログ記事を取得する関数。

    Parameters:
        blog_id (str): 取得したいブログ記事のID。
        api_key (str): microCMS APIの認証に使用するAPIキー。

    Returns:
        dict: 取得したブログ記事のデータ(JSON形式)。

    Raises:
        Exception: APIリクエストが失敗した場合にエラーを返す。
    """

    # APIエンドポイントのURLを組み立て
    # [サービスID]と[コンテンツID]は使用するmicroCMSプロジェクトに合わせて置き換えてください。
    url = f"https://[サービスID].microcms.io/api/v1/[コンテンツID]/{blog_id}"

    # APIリクエストに必要なヘッダーを指定
    # APIキーをヘッダーに含めて認証を行う
    headers = {
        "X-MICROCMS-API-KEY": api_key
    }

    # microCMSのAPIにGETリクエストを送信
    response = requests.get(url, headers=headers)

    # リクエストが成功した場合(HTTPステータスコード200)
    if response.status_code == 200:
        # レスポンスのJSONデータをPythonの辞書型に変換して返す
        return response.json()
    else:
        # リクエストが失敗した場合、エラーメッセージを含む例外を発生させる
        raise Exception(f"Failed to get blog post: {response.status_code} {response.text}")

# 👇 省略

PATCHメソッド

それぞれの記事に対して関連記事を更新するために PATCH メソッドを利用します。

main.py
# 👆 省略

# 指定したブログ記事に関連記事を登録・更新する関数
def patch_blog_post_related_posts(blog_id, api_key, related_posts):
    """
    指定したブログ記事IDに関連記事を登録・更新する関数。

    Parameters:
        blog_id (str): 関連記事を登録・更新する対象のブログ記事ID。
        api_key (str): microCMS APIの認証に使用するAPIキー。
        related_posts (list): 関連記事として登録する記事のIDリスト。

    Returns:
        dict: 更新されたブログ記事のデータ(JSON形式)。

    Raises:
        Exception: APIリクエストが失敗した場合にエラーを返す。
    """

    # APIエンドポイントのURLを組み立て
    # [サービスID]と[コンテンツID]はmicroCMSのプロジェクトに合わせて置き換えてください。
    url = f"https://[サービスID].microcms.io/api/v1/[コンテンツID]/{blog_id}"

    # APIリクエストに必要なヘッダーを指定
    # APIキーとJSONデータを送信することを示すContent-Typeを設定
    headers = {
        "X-MICROCMS-API-KEY": api_key,  # 認証用のAPIキー
        "Content-Type": "application/json"  # データ形式をJSONとして指定
    }

    # APIに送信するデータを準備
    # "related_blog_post" フィールドに関連記事のIDリストを登録
    data = {
        "related_blog_post": related_posts
    }

    # PATCHリクエストを送信してブログ記事を更新
    response = requests.patch(url, headers=headers, data=json.dumps(data))

    # リクエストが成功した場合(HTTPステータスコード200)
    if response.status_code == 200:
        # 更新された記事のデータをPythonの辞書型として返す
        return response.json()
    else:
        # リクエストが失敗した場合、エラーメッセージを含む例外を発生させる
        raise Exception(f"Failed to patch blog post: {response.status_code} {response.text}")

# 👇 省略

Chromaと関連記事の初期化を行う

Chroma DBの初期化と既存の記事に対して関連記事を行う initialize.py を実装します。Chroma DBは以下のコマンドによって ポート番号 9600 で稼働させています。

 chroma run --path ./chroma-data --port 9600

Chroma DBのコレクションの作成

.env ファイルなどに OPENAI_API_KEYMICROCMS_API_KEY を保存したうえで以下のように実装します。 "related-blog-post-collection" というコレクションを作成し、ここに記事のベクトルデータを保存します。また、記事の内容が変更されたときにChroma内のデータも変更するために "sqlite:///record_manager_cache.sql" という sqlite データベースによって Indexing を行います。

initialize.py
# 👆 省略

# .envファイルから環境変数を読み込む(APIキーなどの重要情報を管理するために使用)
load_dotenv()

# OpenAIのAPIキーを環境変数から取得
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")

# microCMSのAPIキーを環境変数から取得
MICROCMS_API_KEY = os.getenv("MICROCMS_API_KEY")

# Chromaクライアントを初期化
# ベクトルデータを保存するデータベースとしてChromaを使用
# 9600番ポートでローカルホスト上のChromaサーバーに接続
chroma_client = HttpClient(port=9600)

# コレクション名を定義
# コレクションは類似度検索を行う際のデータを格納する単位
collection_name = "related-blog-post-collection"

# 指定した名前のコレクションを作成または取得
# "get_or_create=True" によりコレクションが存在しない場合は新しく作成される
collection = chroma_client.create_collection(
    name=collection_name,
    get_or_create=True
)

# OpenAIの埋め込み(ベクトル化)機能を初期化
# OpenAI APIを利用して文章データを数値データ(ベクトル)に変換する
embeddings = OpenAIEmbeddings(openai_api_key=OPENAI_API_KEY)

# Chromaのベクトルストア(データベース)を初期化
# 埋め込み関数(embedding_function)としてOpenAIを指定
vector_store = Chroma(
    client=chroma_client,
    collection_name=collection_name,  # 作成したコレクションを使用
    embedding_function=embeddings    # OpenAI埋め込みを利用
)

# SQLレコードマネージャーの名前空間を設定
# Chromaのデータを管理するためのSQLiteデータベースのスキーマを定義
namespace = f"chromadb/{collection_name}"

# SQLRecordManagerのインスタンスを作成
# SQLiteを使用してインデックスやレコード管理を行う
record_manager = SQLRecordManager(
    namespace,  # 名前空間
    db_url="sqlite:///record_manager_cache.sql"  # SQLiteデータベースのパス
)

# SQLiteデータベースのスキーマを初期化
# 必要なテーブルや構造を作成
record_manager.create_schema()

# 👇 省略

既存の記事データの登録

記事データの全取得をする関数 get_alldata を用いて記事のデータをすべて取得します( get_alldataremove_html_tags の実装は省略、後述のコード全文には記載)。その後OpenAIのモデルを用いてベクトル化してChroma DBに保存します。このとき、remove_html_tags によりHTMLタグを取り除いたうえでベクトル化しています。

initialize.py
# 👆 省略

# microCMSからすべての記事データを取得
data = get_alldata(MICROCMS_API_KEY)

# 取得したデータをベクトル化するための準備
documents = []
for post in data:
    # 記事の内容からHTMLタグを削除し、記事IDとともにDocumentオブジェクトに変換
    documents.append(
        Document(page_content=remove_html_tags(post["contents"]), metadata={"id": post["id"]})
    )

# Chromaのデータを完全クリア
# `_clear()` は既存のデータを削除する内部関数
_clear()

# すべての記事データをインデックスに登録
result = index(
    documents,                 # ベクトル化するドキュメントのリスト
    record_manager,            # データを管理するSQLRecordManager
    vector_store,              # ベクトルデータを保存するChroma
    cleanup="full",            # "full" を指定するとインデックスを完全に再作成
    source_id_key="id"         # 記事IDをインデックスのキーとして利用
)

# 👇 省略

既存のデータの関連する記事を登録する。

既存のすべての記事に対してそれぞれベクトル類似度の高いものを3件だけ関連記事として登録します。

initialize.py
# 👆 省略

# 既存の記事データに対して関連記事を登録
for post in data:
    # 現在処理している記事のベクトルデータを取得
    this_doc = vector_store._collection.get(
        where={"id": post["id"]}  # 記事IDを指定して該当するデータをフィルタリング
    )

    # ベクトル類似度が高い記事を最大4件検索
    # 現在のドキュメントを基準に類似する記事を探す
    results = vector_store.similarity_search(query=this_doc["documents"][0], k=4)

    # 現在のドキュメント(記事ID)を除外して関連記事として登録
    related_posts = [result.metadata["id"] for result in results if result.metadata["id"] != this_doc["metadatas"][0]["id"]]

    # 記事に関連記事を登録
    # microCMSのPATCHリクエストを使用して関連記事を登録または更新
    patch_blog_post_related_posts(post["id"], MICROCMS_API_KEY, related_posts)

新規記事の関連記事を設定する処理を実装する。

main.py にて先ほど実装したwebhookを受け取るエンドポイントに、新規記事の関連記事を設定する処理を追加します。新規記事だけでなく、新規記事に内容が近いもの9件に対しても関連記事を更新します(関連記事として新規記事を表示させるため行います、記事の総数が多ければ9件より少なくてもよいと思います)。

main.py
@app.post("/webhook")
async def webhook_handler(request: Request):
   """
   Webhookのエンドポイント。
   新しい記事が公開された際に、関連記事を自動的に更新する処理を実行します。

   Parameters:
       request (Request): FastAPIのリクエストオブジェクト。Webhookのデータが含まれています。

   Returns:
       dict: 処理成功時のレスポンスメッセージ。
   """

   # 👈 省略

   # リクエストボディのデータをJSON形式で取得
   # microCMSから送られたデータには、新しい記事のIDなどが含まれている
   data = await request.json()
   id = data["id"]  # 記事のIDを取得

   # microCMSから新しく公開された記事のデータを取得
   new_content = get_blog_post(id, MICROCMS_API_KEY)

   # 取得した記事データをドキュメントオブジェクトに変換
   # HTMLタグを取り除き、記事IDをメタデータとして設定
   data_doc = Document(
       page_content=remove_html_tags(new_content["contents"]),
       metadata={"id": data["id"]}
   )

   # 新しい記事をインデックスに追加
   # cleanup="incremental" により、既存のデータを削除せずに追加・更新
   index(
       [data_doc],
       record_manager,
       vector_store,
       cleanup="incremental",
       source_id_key="id"
   )

   # 新しい記事の内容に基づいて類似記事を検索
   # 類似度の高い記事を最大10件取得
   related_content_ids = [
       related_content.metadata["id"]
       for related_content in vector_store.similarity_search(
           remove_html_tags(new_content["contents"]), k=10
       )
   ]

   # 類似記事に対して関連記事を登録または更新
   for related_content_id in related_content_ids:
       # 類似記事のデータを取得
       this_doc = vector_store._collection.get(
           where={"id": related_content_id}  # 記事IDをフィルタ条件として指定
       )

       # 現在の記事を基準にさらに類似度の高い記事を検索(最大4件)
       results = vector_store.similarity_search(
           query=this_doc["documents"][0], k=4
       )

       # 現在の記事を除外して関連記事リストを作成
       related_posts = [
           result.metadata["id"]
           for result in results
           if result.metadata["id"] != this_doc["metadatas"][0]["id"]
       ]

       # microCMSのAPIを使って関連記事を更新
       patch_blog_post_related_posts(
           related_content_id, MICROCMS_API_KEY, related_posts
       )

   # 処理成功時のレスポンスメッセージを返す
   return {"message": "Webhook verified successfully"}

デプロイする

以上のpythonスクリプトをVPSなどのサーバー上にデプロイします。デプロイ後のエンドポイントを以下のようにmicroCMSのカスタム通知に設定します。

  1. https://[サービスID].microcms.io/apis/[コンテンツID]にアクセスします。
  2. [コンテンツ(API)] > [使用するコンテンツ] > [API設定] > [Webhook] を開きます。
  3. [Webhook] 一覧を一番下までスクロールして [追加] をクリックします。
  4. [サービスの選択] から [カスタム通知] を選択します。
  5. 作成された [カスタム通知] のシェブロンボタンを押して詳細を表示します。
  6. カスタム通知を受け取るアプリケーションのエンドポイントを設定します(例: https://example.com/webhook )。

Chroma と FastAPIの両方を稼働させたうえで、 initialize.py を実行してください。その後、 main.py のFastAPI アプリケーションを uvicorn 等で起動します。
もし、すぐにデプロイできるサーバーが用意できない場合は ngrok 等でローカルホストをトンネリングしてwebhookを受け取ってみます。

AIによる関連記事自動設定をした結果。

これにより関連した記事どうしを紐づけることができます。例えば「【コラム】ヴァールブルクとムッソリーニ(1)」という記事ではAIの導入前と後では後のほうがより関連した記事が表示されています(導入前は執筆者やカテゴリータグから関連記事を表示していました)。

1枚目が導入前の画像、2枚目が導入後の画像です。導入後では同じトピックについての連載が関連記事として表示されています。
AI導入前の関連記事。あまり関連していない。
AI導入前の関連記事。あまり関連していない。
AI導入後の関連記事。同じトピックについての連載が関連記事として表示されている。
AI導入後の関連記事。同じトピックについての連載が関連記事として表示されている。

すべてのコード

ℹ️コードの全文を見る👀:`initialize.py`
initialize.py
from dotenv import load_dotenv
import os
from chromadb import HttpClient
from langchain_openai import OpenAIEmbeddings
from langchain.indexes import SQLRecordManager, index
from langchain_core.documents import Document
from langchain_chroma import Chroma
import requests
from bs4 import BeautifulSoup
import json
def _clear():
    index([], record_manager, vector_store, cleanup="full", source_id_key="id")

load_dotenv()
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
MICROCMS_API_KEY = os.getenv("MICROCMS_API_KEY")
chroma_client = HttpClient(port = 9600)
collection_name = "related-blog-post-collection"
collection = chroma_client.create_collection(
        name=collection_name,
        get_or_create=True
)
embeddings = OpenAIEmbeddings(openai_api_key=OPENAI_API_KEY)
vector_store = Chroma(
    client=chroma_client,
    collection_name=collection_name,
    embedding_function=embeddings
)
namespace = f"chromadb/{collection_name}"
record_manager = SQLRecordManager(
    namespace, db_url="sqlite:///record_manager_cache.sql"
)
record_manager.create_schema()

def get_alldata(api_key): # 再帰的にすべてのデータを取得する関数
    base_url = "https://your-service.microcms.io/api/v1/your-endpoint/"
    headers = {
        "X-MICROCMS-API-KEY": api_key
    }
    all_data = []
    offset = 0
    limit = 100  
    total_count = None

    while total_count is None or offset < total_count:
        params = {
            "offset": offset,
            "limit": limit
        }
        response = requests.get(base_url, headers=headers, params=params)
        if response.status_code == 200:
            data = response.json()
            all_data.extend(data.get('contents', []))  # 'contents'にデータが格納されている前提
            total_count = data.get('totalCount')
            offset += limit
        else:
            raise Exception(f"Failed to get blog posts: {response.status_code} {response.text}")

    return all_data

def patch_blog_post_related_posts(blog_id, api_key, related_posts):
    url = f"https://your-service.microcms.io/api/v1/your-endpoint/{blog_id}"
    headers = {
        "X-MICROCMS-API-KEY": api_key,
        "Content-Type": "application/json"
    }
    data = {
        "related_blog_post": related_posts
    }
    response = requests.patch(url, headers=headers, data=json.dumps(data))
    if response.status_code == 200:
        return response.json()
    else:
        raise Exception(f"Failed to patch blog post: {response.status_code} {response.text}")

def remove_html_tags(html_string):
    soup = BeautifulSoup(html_string, "html.parser")
    return soup.get_text()

data=get_alldata(MICROCMS_API_KEY)

documents = []
for post in data:
    documents.append(
        Document(page_content=remove_html_tags(post["contents"]), metadata={"id": post["id"]})
    )
_clear()
result = index(
    documents,
    record_manager,
    vector_store,
    cleanup="full",
    source_id_key="id"
)
for post in data:
    this_doc = vector_store._collection.get(
    where={"id": post["id"]}  # `id` をフィルタ条件として指定
    )
    results = vector_store.similarity_search(query=this_doc["documents"][0],k=4)
    related_posts = [result.metadata["id"] for result in results if result.metadata["id"] != this_doc["metadatas"][0]["id"]]
    patch_blog_post_related_posts(post["id"],MICROCMS_API_KEY,related_posts)
ℹ️コードの全文を見る👀:`main.py`
main.py
# chroma run --path ./chroma-data --port 9600 > /dev/null &
# を実行すること。
from dotenv import load_dotenv
import os
from chromadb import HttpClient
from langchain_openai import OpenAIEmbeddings
from langchain.indexes import SQLRecordManager, index
from langchain_core.documents import Document
from langchain_chroma import Chroma

def _clear():
    """Hacky helper method to clear content. See the `full` mode section to to understand why it works."""
    index([], record_manager, vector_store, cleanup="full", source_id_key="id")

# 環境変数をロード
load_dotenv()
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")

# Chromaクライアントの初期化
# 新しいクライアント構成
# 実際の環境では全部入りのクライアントを作成
chroma_client = HttpClient(port = 9600)

# コレクション名を指定
collection_name = "related-blog-post-collection"

# コレクションの存在確認と作成
collection = chroma_client.create_collection(
        name=collection_name,
        get_or_create=True
)

# LangChain埋め込みモデルの初期化
embeddings = OpenAIEmbeddings(openai_api_key=OPENAI_API_KEY)

# コレクションの設定
vector_store = Chroma(
    client=chroma_client,
    collection_name=collection_name,
    embedding_function=embeddings
)

# レコードマネージャーの設定
namespace = f"chromadb/{collection_name}"
record_manager = SQLRecordManager(
    namespace, db_url="sqlite:///record_manager_cache.sql"
)
record_manager.create_schema()

from fastapi import FastAPI, Request, HTTPException
import hmac
import hashlib

app = FastAPI()

SECRET_KEY = b"your-secret-key"  # サーバー側で設定した秘密のキー、本番環境では.envなどに保存してコード内に直書きしないでください。

def verify_webhook_signature(secret_key: bytes, body: bytes, signature: str) -> bool:
    # サーバー側で署名を生成
    expected_signature = hmac.new(
        key=secret_key,
        msg=body,
        digestmod=hashlib.sha256
    ).hexdigest()

    # 署名を比較
    return hmac.compare_digest(expected_signature, signature)

@app.post("/webhook")
async def webhook_handler(request: Request):
    # リクエストボディの取得
    body = await request.body()

    # クライアントから送信された署名の取得
    signature = request.headers.get('X-MICROCMS-Signature')
    if not signature:
        raise HTTPException(status_code=400, detail="Missing signature header")

    # 署名の検証
    if not verify_webhook_signature(SECRET_KEY, body, signature):
        raise HTTPException(status_code=403, detail="Invalid signature")

    # データの取得
    data=await request.json()
    id=data["id"]
    new_content=get_blog_post(id,MICROCMS_API_KEY)

    # データの設定
    data_doc=Document(page_content=remove_html_tags(new_content["contents"]), metadata={"id": data["id"]})
    # データのインデックス
    index(
        [data_doc],
        record_manager,
        vector_store,
        cleanup="incremental",
        source_id_key="id"
    )

    # 類似コンテンツの検索
    related_content_ids=[ related_content.metadata["id"] for related_content in vector_store.similarity_search(remove_html_tags(new_content["contents"]),k=10)]
    print(len(related_content_ids))
    # 類似コンテンツに対して関連記事を更新
    for related_content_id in related_content_ids:
        this_doc = vector_store._collection.get(
        where={"id": related_content_id}  # `id` をフィルタ条件として指定
        )
        results = vector_store.similarity_search(query=this_doc["documents"][0],k=4)
        related_posts = [result.metadata["id"] for result in results if result.metadata["id"] != this_doc["metadatas"][0]["id"]]
        patch_blog_post_related_posts(related_content_id,MICROCMS_API_KEY,related_posts)

    return {"message": "Webhook verified successfully"}

import requests
from bs4 import BeautifulSoup
import json


MICROCMS_API_KEY = os.getenv("MICROCMS_API_KEY")

def get_blog_post(blog_id, api_key):
    """
    GETリクエストを用いて指定したブログポストを取得する関数。
    
    Parameters:
        blog_id (str): 取得したいブログポストのID。
        api_key (str): MicroCMS APIのAPIキー。
    
    Returns:
        dict: 取得したブログポストのデータ。
    """
    url = f"https://your-service.microcms.io/api/v1/your-endpoint/{blog_id}"
    headers = {
        "X-MICROCMS-API-KEY": api_key
    }
    response = requests.get(url, headers=headers)
    if response.status_code == 200:
        return response.json()
    else:
        raise Exception(f"Failed to get blog post: {response.status_code} {response.text}")

def patch_blog_post_related_posts(blog_id, api_key, related_posts):
    """
    PATCHリクエストを用いてブログポストのrelated_blog_postを更新する関数。
    
    Parameters:
        blog_id (str): 更新したいブログポストのID。
        api_key (str): MicroCMS APIのAPIキー。
        related_posts (list of str): 更新するrelated_blog_postのIDリスト。
    
    Returns:
        dict: 更新したいブログポストのID。
    """
    url = f"https://your-service.microcms.io/api/v1/your-endpoint/{blog_id}"
    headers = {
        "X-MICROCMS-API-KEY": api_key,
        "Content-Type": "application/json"
    }
    data = {
        "related_blog_post": related_posts
    }
    response = requests.patch(url, headers=headers, data=json.dumps(data))
    if response.status_code == 200:
        return response.json()
    else:
        raise Exception(f"Failed to patch blog post: {response.status_code} {response.text}")

def remove_html_tags(html_string):
    if not html_string:
        return "" 
    soup = BeautifulSoup(html_string, "html.parser")
    return soup.get_text()    

余談

microCMSの無料枠を使っている場合に外部の第三者からPATCHメソッドを使われないようにする。

無料枠を使っている場合、APIごとにAPIキーを一つしか使えないためそのままだと、クライアント側で入手できるAPIキーを利用して外部の第三者がコンテンツの変更してしまう危険性があります。クライアント側にはAPIキーを渡さず、自作のAPIプロキシを経由してmicroCMSにアクセスすることで、この問題を解決できます。以下はCloudflare上で動作する Hono.js APIプロキシの例です。クライアント側からのリクエストをAPIキーを付与してmicroCMSのAPIに渡します。CORSの設定により自社のサイトのみにアクセスを許しています。

index.ts
import { Hono, Context } from 'hono';

const app = new Hono();

const API_BASE_URLS = {
	main: 'https://your-service-1.microcms.io/api/v1',
	other: 'https://your-service-2.microcms.io/api/v1',
};

app.options('/:api/*', (c) => {
	const headers = new Headers();
	headers.set('Access-Control-Allow-Origin', 'https://your-site-domain');
	headers.set('Access-Control-Allow-Methods', 'GET, OPTIONS');
	headers.set('Access-Control-Allow-Headers', 'Content-Type, X-MICROCMS-API-KEY');
	return new Response(null, { status: 204, headers });
});

app.all('/:api/*', async (c: Context<{ Bindings: { API_KEY_MAIN: string; API_KEY_OTHER: string } }>) => {
	if (c.req.method !== 'GET') {
		return c.text('Method Not Allowed', 405);
	}
	const API_KEYS = {
		main: c.env?.API_KEY_MAIN,
		other: c.env?.API_KEY_OTHER,
	};
	const api = c.req.param('api') as keyof typeof API_KEYS; 
	const remainingPath = c.req.path.slice(c.req.path.indexOf(api) + api.length + 1); 
	if (!api || !remainingPath) {
		return c.text('Invalid Path', 400);
	}
	const apiKey = API_KEYS[api];
	const baseUrl = API_BASE_URLS[api];
	if (!apiKey || !baseUrl) {
		return c.text('API not found', 404);
	}
	const url = new URL(c.req.url);
	const targetUrl = `${baseUrl}/${remainingPath}${url.search}`;
	const headers = new Headers(c.req.header());
	headers.set('X-MICROCMS-API-KEY', apiKey);
	const fetchOptions: RequestInit = {
		method: 'GET',
		headers,
		body: undefined,
	};
	const response = await fetch(targetUrl, fetchOptions);
	const responseHeaders = new Headers(response.headers);
	responseHeaders.set('Access-Control-Allow-Origin', 'https://your-site-domain'); 
	responseHeaders.set('Access-Control-Allow-Methods', 'GET, OPTIONS'); 
	responseHeaders.set('Access-Control-Allow-Headers', 'Content-Type, X-MICROCMS-API-KEY'); 
	return new Response(response.body, {
		status: response.status,
		headers: responseHeaders,
	});
});

export default app;

私たちがmicroCMSを導入した理由

この記事はmicroCMSのアドベントカレンダーの一つとして書かれているため、多くの方の目に留まると思い、私たちがmicroCMSを導入した理由にも軽く触れておきます。

導入の背景

microCMSを利用したwebサイトを作るにあたり、以下のような背景がありました。

  • 「自分たちの活動内容や活動場所、使用しているSNSなどすべてにアクセス可能なハブ的なサイトが欲しい」という要望
  • 文学フリマという同人誌即売会で人文学の評論文を売るサークルであったこと
  • サークル自体が映画監督や藝大生、博士課程の研究者といった”ものかき”の集まりだったこと
  • プログラミングの経験のある人間が私だけであったこと
  • webサイトの構想当初は今後どのような活動をしていくかも手探りであったこと
  • ブランドとしての方向性がまだ明確ではなかったこと

要件

背景からwebサイトの要件が以下のように導かれました。

  • 今後どのようなものを発信するにしても、適切に対応できる必要がある。具体的には以下のようなケースすべてに対応できる必要がある。
    • アナログの書籍のみを販売する
    • Youtubeやニコニコ動画、Tiktokのような動画プラットフォームで動画のみを投稿する
    • Podcastによるラジオの配信する
    • ツイキャスやTwitchなどで生配信する。サイトには生配信の予定などを掲載する
    • ボードゲームやTRPGなどの玩具の販売する
  • 文章の執筆や動画の企画や収録に集中するために、校正や組版、配信のための作業を限りなく0にすること
  • 属人化を避け、私が抜けても継続可能な状態にすること
  • ブランドの方向性が変化しても、それに合わせてサイトのリブランディングを行えること

代案や選択肢

背景や要件から以下のような選択肢が挙がりました。

  • WordPress
    • 良い点:APIアクセスによる自動化が可能、日本語のドキュメントが豊富
    • 悪い点:管理画面が使いにくい、アドオンの増加による属人化、ホスティングにお金がかかる
  • Wixなどのノーコードツール
    • 良い点:開発工数がかからない、簡単にそれっぽいサイトができる、
    • 悪い点:自由度に限界がある、どの程度自動化に組み込めるかが不明瞭、リブランディングが難しい。商用利用に制限がある。
  • microCMS
    • 良い点:APIによるアクセスを前提として作られているため自動化が容易、ドキュメントが豊富。使いやすいUI。無料で利用できる。柔軟なフィールド設計。Webhookによる他サービスとの連携による自動化が可能。
    • 悪い点:フロントエンドの実装は自分で行わなければならない。フロントエンドの属人化。
  • Adobe Experience Manager
    • 良い点:Adobeユーザーがサークルに多いためAdobe製品とのシームレスな接続は非常に魅力的
    • 悪い点:金銭的コストがかかりすぎる

microCMSに決めた理由

以上のような代案の中からmicroCMSを選んだ理由はその柔軟さと拡張性の高さ、自動化の容易さからです。フロントエンドの属人化は、ChatGPTによるweb開発の容易化を鑑みて問題ないと判断しました。とはいっても、属人化によるリスクが0ではないので、サイトの作成や自動化にあたってフォルトトレラントな設計を心がけています。また、Cloudflare Pages & Workers でフロントエンドをホストし、無料でなおかつ広告を掲載しても問題ない構成にしています(Vercelは広告をつけられないので使用しませんでした)。

microCMSによる自動化事例

今回の記事の「LLMによるおすすめ記事の設定」のほかにも1年にわたるmicroCMSの運用の中で2つほど実現した自動化事例があるため軽くご紹介させていただきます。

公開記事の X(Twitter)自動投稿

記事が公開されるとそれを伝える旨のツイートをします。Twitter API + GitHubActions +microCMSで実現されています。
Twitterの自動投稿
Twitterの自動投稿

記事の自動校正

記事が下書き保存されるとTextLintによって自動校正され、その結果がDiscordとGitHubのissueに通知されます。Discordのリンクを開くと校正結果を確認できます。けのびさんによる以下の記事でわかりやすく解説されています。
校正結果の画面
校正結果の画面

今後行う予定の(現在進行中の)自動化

より一層記事が増えてきたらweb記事をもとにして組版から製本までの編集プロセスをすべて自動化する予定です。これによって、ジャンルごと、執筆者ごとの特集号を手軽に作成でき、より読者それぞれに最適化された書籍の発売が可能になると考えています。

Discussion