0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【実践 第3回】Codexに商品・キーワードを選定させてアフィリエイト記事を作成してみる 〜商品評価・キーワード選定・記事生成・Next.jsサイトでの公開まで〜

0
Last updated at Posted at 2026-09-16

第3回 Codexに商品・キーワードを選定させてアフィリエイト記事を作成してみる

はじめに

第2回では、楽天APIから売れ筋商品を取得し、PythonのMCP Serverを経由してPostgreSQLへ保存する、商品収集・保存基盤を作りました。products テーブルに商品が蓄積された状態です。

第3回は、本シリーズの最終回です。第2回でPostgreSQLへ蓄積した商品情報を使って、Codexに商品とキーワードを選定させ、記事を生成し、Next.js(TypeScript・CSS)で作った自前のWebサイトで公開するところまでを実装します。

記事の掲載先には、WordPressではなく、Next.jsで作る通常のWebサイトを使います。

項目 WordPressを使う場合 Next.jsの自前サイトを使う場合(本記事)
記事の保存先 WordPressとPostgreSQLの2か所 PostgreSQLの1か所(articles テーブルが正本)
下書き・公開の管理 WordPressの投稿ステータス articles.status(draft / published)
必要な認証情報 WordPressのアプリケーションパスワード サイトがDBを読むための、読み取り専用のDB接続情報
失敗時の不整合 WordPress登録後にDB保存が失敗すると、片方だけに残る DBへの1回の保存だけなので、片方だけに残る問題が起きない
見た目・構成の自由度 テーマ・プラグインに依存 コンポーネントとCSSで自由に作れる

MCP Server側は、第2回で決めた方針どおり、プロジェクトのディレクトリ構造は変えず、tools/ にファイルを足すだけで進めます。Webサイトは、MCP Serverとは別のフォルダに、Next.jsのプロジェクトとして作ります。

今回、Article Toolsを追加することで、第1回で設計したAffiliate MCP Serverを完成させます。

この記事のゴール

次の流れを、MCP ServerのToolとCodex、Next.jsのサイトで実現します。

PostgreSQL(products)
 ↓
AI(商品評価・選定)
 ↓
楽天APIから商品詳細を取得
 ↓
キーワード選定
 ↓
記事生成
 ↓
articles テーブルへ下書きとして保存
 ↓
Next.jsサイトのプレビューで、人間が確認
 ↓
人間が公開(status を published に変更)
 ↓
Next.jsサイトに掲載

「Codexが全部やる」のではなく、判断・文章生成はCodex、外部APIとDBの操作はMCP Server、表示はNext.jsのサイト、公開の判断は人間という責務分離を維持します。

1. 今回追加するもの

第2回のプロジェクト構成に、次を追加します(既存ファイルの移動・リネームはしません)。

affiliate-mcp-server/            # 第2回までのMCP Server
│
├─ ...(第2回のファイルはそのまま)
├─ rakuten.py                    # to_product_detail() を追記
├─ tools/
│   ├─ __init__.py
│   ├─ product_tools.py          # get_product_detail() を追記
│   └─ article_tools.py          # 追加:記事候補・重複チェック・下書きの保存/取得/更新
└─ scripts/
    └─ publish_article.py        # 追加:人間が記事を公開/非公開にするスクリプト(MCPには登録しない)

affiliate-site/                  # 追加:記事を掲載するNext.jsのサイト
├─ app/
│   ├─ layout.tsx
│   ├─ globals.css
│   ├─ page.tsx                  # 公開記事の一覧
│   ├─ articles/[id]/page.tsx    # 公開記事の詳細
│   └─ preview/[id]/page.tsx     # 下書きのプレビュー(ローカル開発時だけ)
├─ components/
│   ├─ ArticleView.tsx           # 記事・商品情報の表示(一覧以外の2画面で共通)
│   ├─ ArticleBody.tsx           # 記事本文の表示(安全なMarkdownの一部だけを表示)
│   └─ ArticleBody.module.css
├─ lib/
│   ├─ db.ts                     # PostgreSQLへの読み取り専用の接続
│   └─ articles.ts               # 記事・商品の取得
└─ .env.local                    # 読み取り専用のDB接続情報(Gitに含めない)
場所 カテゴリ 追加するもの 役割
MCP Server Product get_product_detail() 楽天APIから商品の詳細情報(説明・画像・店舗など)を取得する
MCP Server Article list_article_candidates() 記事化候補の商品をDBから抽出する
MCP Server Article check_duplicate_article() 同じ商品・キーワードの記事が既にないか確認する
MCP Server Article save_article() 記事を下書きとして articles テーブルへ保存する
MCP Server Article get_article() 保存した記事を取得する
MCP Server Article update_draft() 下書きの記事を更新する(公開済みは更新しない)
MCP Server (スクリプト) scripts/publish_article.py 人間が記事を公開・非公開にする。MCP Toolにはしない
Next.jsサイト 画面 一覧・詳細・プレビュー 公開記事を表示する。下書きはローカル開発時だけプレビューする

本シリーズで使用する products / product_attributes / articles の3テーブルは第2回で作成済みです。第3回ではDB構成を変更せず(CREATE TABLE や ALTER TABLE を追加せず)、既存テーブルをそのまま利用します。articles テーブルの wordpress_post_id 列は、今回の構成では使わないため、常に NULL のままにします。

補足(product_attributes について): 本シリーズの記事生成は1商品を対象とするため、詳細スペックを属性ごとに保存する product_attributes は今回は使用しません。これは、複数商品を属性で比較するような発展的な記事(Affiliate MCP Serverの拡張)に備えて、第2回でスキーマだけ用意してあるテーブルです。第3回では products と articles を使います。

補足(列名の確認): 本記事のSQLは、第2回で作成した列(articles の id / product_id / title / keyword / content / status、products の id / name / price / review_count / review_average / affiliate_url など)だけを使います。ご自分の環境のテーブル定義と違う場合は、列名を合わせてください。

2. 商品詳細を取得する get_product_detail()

第2回で products テーブルには、基本情報(name / price / url / affiliate_url / genre_id / review_*)に加えて、記事生成で使う image_urls(商品画像)・catchcopy(キャッチコピー)・description(商品説明文)も保存しました。多くの記事は、この products の情報だけで生成できます。

一方で、記事生成の直前に最新の商品情報や、DBに保存していない補助情報(店舗名など)を楽天APIから取り直したいことがあります。そのために、第2回で保留していた get_product_detail() を実装します。これは楽天APIから商品説明・画像・店舗情報などをまとめて取得し、記事生成時にCodexへ渡すToolです。ここで取得した情報のうちDBに持っていない項目(店舗名など)は、その場で使うだけで保存はしません。

第2回で作成した get_product() は基本情報の取得用でした。get_product_detail() は「記事生成のために詳細情報をまとめて取得する」用途で、役割を分けています。

まず rakuten.py に、詳細情報を抽出する変換関数を用意します(search() / to_product() はそのまま使い回します)。

# rakuten.py(追記)
def to_product_detail(item: dict) -> dict:
    """楽天APIのレスポンスから、記事生成に使う詳細情報を抽出する。"""
    return {
        "item_code": item["itemCode"],
        "name": item["itemName"],
        "price": item.get("itemPrice"),
        "catch_copy": item.get("catchcopy", ""),
        "caption": item.get("itemCaption", ""),   # 商品説明文
        "shop_name": item.get("shopName", ""),
        "genre_id": str(item.get("genreId", "")),
        "review_count": item.get("reviewCount", 0),
        "review_average": item.get("reviewAverage", 0),
        "image_urls": [
            img.get("imageUrl", "")
            for img in item.get("mediumImageUrls", [])
        ],
        "affiliate_url": item.get("affiliateUrl") or item.get("itemUrl"),
    }

tools/product_tools.py に get_product_detail() を追記します(第2回のToolに続けて追加するだけ)。

# tools/product_tools.py(追記)
@mcp.tool()
def get_product_detail(item_code: str) -> dict | None:
    """item_code で楽天商品検索APIを呼び出し、記事生成用の詳細情報を返す。

    商品説明・画像・店舗などを含む。この戻り値自体はDBへ再保存せず、
    記事生成時にCodexへ渡して使う(商品説明・画像などは第2回で
    products に保存済み。店舗名などDBに無い項目はここで一時利用する)。
    """
    if not item_code.strip():
        raise ValueError("商品コード(itemCode)を指定してください。")

    data = rakuten.search(rakuten.SEARCH_URL, {"itemCode": item_code})
    items = data.get("Items", [])
    if not items:
        return None
    return rakuten.to_product_detail(items[0]["Item"])

これで、記事生成のフローは「候補選定(DBの基本情報)→ get_product_detail() で詳細取得 → Codexが記事生成」となり、事実に基づいた記事を書けるようになります。

DB(products)
 ↓
商品候補選定
 ↓
get_product_detail()
 ↓
商品詳細(説明・画像・店舗)
 ↓
Codex
 ↓
記事生成

3. 記事本文の書式を決める

WordPressでは本文をHTMLで登録しましたが、自前のサイトでAIが生成したHTMLをそのまま画面に埋め込むのは危険です。生成された本文に、意図しないスクリプトや外部へのリンクが紛れ込むおそれがあるからです。

そこで本文は、Markdownのごく一部だけを使う書式に決め、サイト側で「決めた書式以外は文字として表示する」ようにします。

書式 書き方 表示
見出し 行頭に ## 見出し(h2)
小見出し 行頭に ### 小見出し(h3)
箇条書き 行頭に - 箇条書きの項目
段落 空行で区切る 段落
リンク [文字](URL) 許可したドメイン(楽天のアフィリエイトURL)だけをリンクにする。それ以外は文字として表示

HTMLタグ、画像、表、その他のMarkdownの書式は使いません。書かれていても、サイトでは文字として表示されます。

記事の基本構成は次の通りです。

## 導入
## 商品の特徴
## メリット
## 注意点
## おすすめする人
## まとめ
[商品を楽天市場で見る](affiliate_url)

記事のタイトルは本文に含めず、articles.title に保存します。

4. Article Tools(tools/article_tools.py)

記事の候補抽出・重複チェック・下書きの保存・取得・更新を担当するToolです。DBを使うので db を、記事は articles テーブルを対象にします。

# tools/article_tools.py
from mcp_app import mcp
import db

# AIが扱えるのは下書きだけ。公開・非公開は人間が scripts/publish_article.py で行う。
DRAFT = "draft"


@mcp.tool()
def list_article_candidates(limit: int = 10) -> list[dict]:
    """記事候補の商品を、レビュー評価の高い順に返す。

    同じ商品でも異なる検索意図の記事を作れるよう、商品単位では除外しない。
    重複は check_duplicate_article()(商品ID+キーワード)で判定する。
    """
    limit = max(1, min(limit, 50))
    with db.get_connection() as conn, conn.cursor() as cur:
        cur.execute(
            """
            SELECT p.id, p.item_code, p.name, p.price,
                   p.review_count, p.review_average, p.genre_id
            FROM products p
            ORDER BY p.review_average DESC, p.review_count DESC
            LIMIT %s
            """,
            (limit,),
        )
        rows = cur.fetchall()
    return [
        {
            "product_id": r[0],
            "item_code": r[1],
            "name": r[2],
            "price": r[3],
            "review_count": r[4],
            "review_average": float(r[5]) if r[5] is not None else 0,
            "genre_id": r[6],
        }
        for r in rows
    ]


@mcp.tool()
def check_duplicate_article(product_id: int, keyword: str) -> dict:
    """同じ商品+キーワードの記事が既にないか確認する。"""
    if not keyword.strip():
        raise ValueError("キーワードを指定してください。")
    keyword = keyword.strip()
    with db.get_connection() as conn, conn.cursor() as cur:
        cur.execute(
            """
            SELECT id, title, status FROM articles
            WHERE product_id = %s AND keyword = %s
            """,
            (product_id, keyword),
        )
        rows = cur.fetchall()
    return {
        "exists": len(rows) > 0,
        "articles": [
            {"id": r[0], "title": r[1], "status": r[2]} for r in rows
        ],
    }


@mcp.tool()
def save_article(product_id: int, title: str, keyword: str, content: str) -> dict:
    """記事を下書きとして articles テーブルへ保存する(公開はしない)。"""
    if not title.strip():
        raise ValueError("記事タイトルを指定してください。")
    if not keyword.strip():
        raise ValueError("キーワードを指定してください。")
    if not content.strip():
        raise ValueError("記事本文を指定してください。")
    with db.get_connection() as conn, conn.cursor() as cur:
        cur.execute(
            """
            INSERT INTO articles (product_id, title, keyword, content, status)
            VALUES (%s, %s, %s, %s, %s)
            RETURNING id
            """,
            (product_id, title.strip(), keyword.strip(), content, DRAFT),
        )
        article_id = cur.fetchone()[0]
        conn.commit()
    return {"article_id": article_id, "status": DRAFT}


@mcp.tool()
def get_article(article_id: int) -> dict | None:
    """記事を取得する。"""
    with db.get_connection() as conn, conn.cursor() as cur:
        cur.execute(
            """
            SELECT id, product_id, title, keyword, content, status
            FROM articles WHERE id = %s
            """,
            (article_id,),
        )
        r = cur.fetchone()
    if r is None:
        return None
    return {
        "article_id": r[0],
        "product_id": r[1],
        "title": r[2],
        "keyword": r[3],
        "content": r[4],
        "status": r[5],
    }


@mcp.tool()
def update_draft(article_id: int, title: str | None = None,
                 content: str | None = None) -> dict:
    """下書きの記事を更新する(公開済みの記事は更新しない)。"""
    fields: dict = {}
    if title is not None:
        if not title.strip():
            raise ValueError("記事タイトルを空にはできません。")
        fields["title"] = title.strip()
    if content is not None:
        if not content.strip():
            raise ValueError("記事本文を空にはできません。")
        fields["content"] = content
    if not fields:
        raise ValueError("更新するタイトルまたは本文を指定してください。")

    sets = ", ".join(f"{name} = %s" for name in fields)
    with db.get_connection() as conn, conn.cursor() as cur:
        # 下書きだけを更新する。公開済みの記事は条件に一致せず、更新されない。
        cur.execute(
            f"UPDATE articles SET {sets} WHERE id = %s AND status = %s RETURNING id",
            (*fields.values(), article_id, DRAFT),
        )
        updated = cur.fetchone()
        conn.commit()
    if updated is None:
        raise ValueError("下書きの記事が見つかりません(公開済みの記事は更新できません)。")
    return {"article_id": article_id, "status": DRAFT}

ポイントは次の3つです。

  • save_article() は、ステータスを引数で受け取らず、必ず draft(下書き)で保存します。AIが記事を直接公開できないようにするためです。
  • update_draft() は、WHERE status = 'draft' を付けて更新します。公開済みの記事は、条件に一致しないので書き換わりません。WordPress版のように「取得してから確認して更新する」2段階にしないので、その間に状態が変わる心配もありません。
  • 公開の操作は、MCP Toolとして登録しません。Toolにしなければ、Codexから公開されることはありません。

5. 人間が公開するスクリプト(scripts/publish_article.py)

記事の公開と非公開は、人間がプレビューで確認してから、このスクリプトで行います。MCP Serverには登録しないので、Codexからは使えません。

# scripts/publish_article.py
"""記事を公開・非公開にする(人間が実行する。MCP Toolにはしない)。

使い方:
    python -m scripts.publish_article publish 12
    python -m scripts.publish_article unpublish 12
"""
import sys
import db

TRANSITIONS = {
    "publish": ("draft", "published"),    # 下書き → 公開
    "unpublish": ("published", "draft"),  # 公開 → 下書きに戻す
}


def main() -> None:
    if len(sys.argv) != 3 or sys.argv[1] not in TRANSITIONS or not sys.argv[2].isdigit():
        print(__doc__)
        sys.exit(1)
    action, article_id = sys.argv[1], int(sys.argv[2])
    current, new = TRANSITIONS[action]

    with db.get_connection() as conn, conn.cursor() as cur:
        cur.execute(
            "SELECT title, status FROM articles WHERE id = %s", (article_id,)
        )
        row = cur.fetchone()
        if row is None:
            sys.exit(f"記事 {article_id} が見つかりません。")
        title, status = row
        if status != current:
            sys.exit(f"記事 {article_id} は status={status} のため、{action} できません。")

        answer = input(f"「{title}」を {new} にします。よろしいですか? [y/N] ")
        if answer.strip().lower() != "y":
            sys.exit("中止しました。")

        cur.execute(
            "UPDATE articles SET status = %s WHERE id = %s AND status = %s",
            (new, article_id, current),
        )
        conn.commit()
    print(f"記事 {article_id} を {new} にしました。")


if __name__ == "__main__":
    main()

実行前にタイトルを表示して確認を求めるので、番号の打ち間違いで別の記事を公開してしまうことを防げます。

6. Toolを登録する(server.py)

第2回の server.py に、追加したToolモジュールのimportを足すだけです。Toolはimportされた時点で @mcp.tool() により登録されます。

# server.py
from mcp_app import mcp
import db

# import した時点で @mcp.tool() が実行され、Toolが登録される
import tools.product_tools    # noqa: F401  第2回
import tools.article_tools    # noqa: F401  第3回で追加

if __name__ == "__main__":
    db.init_db()
    mcp.run()

これで、第1回で設計したAffiliate MCP Serverの主要機能がそろいました。Codexから使えるToolは次のようになります(第2回の5つ+今回追加する6つ)。

Affiliate MCP Server
├─ Product Tools
│   ├─ search_products
│   ├─ get_ranking_products
│   ├─ get_product
│   ├─ save_product
│   ├─ list_saved_products
│   └─ get_product_detail       ← 第3回で追加
└─ Article Tools(第3回)
    ├─ list_article_candidates
    ├─ check_duplicate_article
    ├─ save_article             ← 必ず下書きで保存
    ├─ get_article
    └─ update_draft             ← 下書きだけ更新

(MCPに登録しないもの)
scripts/publish_article.py      ← 人間が公開・非公開を行う

7. 記事を掲載するNext.jsのサイトを作る

7.1 プロジェクトを作る

MCP Serverとは別のフォルダに、Next.jsのプロジェクトを作ります。

cd C:\work
npx create-next-app@latest affiliate-site

質問には、次のように答えます。

TypeScript        → Yes
ESLint            → Yes
Tailwind CSS      → No(通常のCSSを使う)
src/ directory    → No
App Router        → Yes
Import alias      → No(既定の @/* を使う)

PostgreSQLへの接続には、pg を使います。

cd C:\work\affiliate-site
npm install pg
npm install -D @types/pg

7.2 読み取り専用のDB接続

サイトは記事と商品を読むだけです。DBへの書き込みは、MCP Serverと公開スクリプトだけが行います。そこで、サイト用に読み取り専用のDBユーザーを用意します(PostgreSQLで実行します)。

-- サイト用の読み取り専用ユーザー(パスワードは自分で決めた値にする)
CREATE ROLE affiliate_site_reader LOGIN PASSWORD 'ここに強いパスワード';
GRANT CONNECT ON DATABASE affiliate TO affiliate_site_reader;
GRANT USAGE ON SCHEMA public TO affiliate_site_reader;
GRANT SELECT ON articles, products TO affiliate_site_reader;

補足(DB名・権限): affiliate は第2回で作成したDB名に置き換えてください。テーブルの構成は変えず、読み取りの権限を付けるだけです。サイトのコードに不具合があっても、このユーザーではデータを書き換えられません。

接続情報は affiliate-site/.env.local に置きます。create-next-app が作る .gitignore に .env* が含まれているので、Gitには入りません。

# affiliate-site/.env.local
DATABASE_URL=postgresql://affiliate_site_reader:パスワード@localhost:5432/affiliate
# 下書きのプレビューを表示するか(ローカルでの確認用。公開する環境では設定しない)
ENABLE_PREVIEW=true

NEXT_PUBLIC_ で始まる名前にしないことが大切です。NEXT_PUBLIC_ の値はブラウザーに送られてしまうため、DBの接続情報には使いません。

lib/db.ts で、接続を1つにまとめます。

// lib/db.ts
import "server-only";
import { Pool } from "pg";

const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
  throw new Error("DATABASE_URL が未設定です。.env.local を確認してください。");
}

// 開発中の再読み込みで接続が増え続けないよう、1つの Pool を使い回す。
const globalForPg = globalThis as unknown as { pgPool?: Pool };
export const pool = globalForPg.pgPool ?? new Pool({ connectionString, max: 5 });
if (process.env.NODE_ENV !== "production") globalForPg.pgPool = pool;

import "server-only"; を書いておくと、このファイルを誤ってブラウザー側のコンポーネントから読み込んだときに、ビルドがエラーになります。DBの接続情報がブラウザーに漏れることを防ぎます。

補足: server-only が見つからないと表示された場合は、npm install server-only を実行してください。

7.3 記事と商品の取得(lib/articles.ts)

// lib/articles.ts
import "server-only";
import { pool } from "./db";

export type ArticleSummary = {
  id: number;
  title: string;
  keyword: string;
};

export type ArticleDetail = ArticleSummary & {
  content: string;
  status: string;
  product: {
    name: string;
    price: number | null;
    reviewCount: number | null;
    reviewAverage: number | null;
    affiliateUrl: string | null;
  };
};

/** 公開済みの記事の一覧(新しい順)。 */
export async function listPublishedArticles(): Promise<ArticleSummary[]> {
  const { rows } = await pool.query(
    `SELECT id, title, keyword FROM articles
     WHERE status = 'published'
     ORDER BY id DESC`,
  );
  return rows.map((r) => ({ id: r.id, title: r.title, keyword: r.keyword }));
}

/** 記事を1件取得する。status を指定し、それ以外の状態の記事は返さない。 */
export async function getArticle(
  id: number,
  status: "published" | "draft",
): Promise<ArticleDetail | null> {
  const { rows } = await pool.query(
    `SELECT a.id, a.title, a.keyword, a.content, a.status,
            p.name, p.price, p.review_count, p.review_average, p.affiliate_url
     FROM articles a
     JOIN products p ON p.id = a.product_id
     WHERE a.id = $1 AND a.status = $2`,
    [id, status],
  );
  const r = rows[0];
  if (!r) return null;
  return {
    id: r.id,
    title: r.title,
    keyword: r.keyword,
    content: r.content,
    status: r.status,
    product: {
      name: r.name,
      price: r.price,
      reviewCount: r.review_count,
      reviewAverage: r.review_average === null ? null : Number(r.review_average),
      affiliateUrl: r.affiliate_url,
    },
  };
}

SQLの値はすべて $1 などのプレースホルダーで渡し、文字列をつなげてSQLを作りません。公開ページでは status = 'published' の記事だけを取得するので、URLの番号を書き換えても下書きは見えません。

7.4 記事本文の表示(components/ArticleBody.tsx)

3章で決めた書式だけを表示する部品です。HTMLとして埋め込まず(dangerouslySetInnerHTML を使わず)、Reactの要素として組み立てるので、本文に何が書かれていても、スクリプトとして動くことはありません。

// components/ArticleBody.tsx
import type { ReactNode } from "react";
import styles from "./ArticleBody.module.css";

// リンクにしてよいドメイン(楽天のアフィリエイトURL・商品ページ)。
const ALLOWED_LINK_HOSTS = new Set(["hb.afl.rakuten.co.jp", "item.rakuten.co.jp"]);

function isAllowedLink(url: string): boolean {
  try {
    const u = new URL(url);
    return u.protocol === "https:" && ALLOWED_LINK_HOSTS.has(u.hostname);
  } catch {
    return false;
  }
}

/** 1行の中の [文字](URL) を、許可したURLだけリンクにする。それ以外は文字のまま。 */
function renderInline(text: string): ReactNode[] {
  const nodes: ReactNode[] = [];
  const pattern = /\[([^\]]+)\]\(([^)\s]+)\)/g;
  let last = 0;
  for (const match of text.matchAll(pattern)) {
    const [whole, label, url] = match;
    const start = match.index ?? 0;
    nodes.push(text.slice(last, start));
    nodes.push(
      isAllowedLink(url) ? (
        <a key={start} href={url} rel="sponsored nofollow noopener" target="_blank">
          {label}
        </a>
      ) : (
        whole
      ),
    );
    last = start + whole.length;
  }
  nodes.push(text.slice(last));
  return nodes;
}

export default function ArticleBody({ content }: { content: string }) {
  const blocks: ReactNode[] = [];
  let paragraph: string[] = [];
  let list: string[] = [];

  const flush = () => {
    if (paragraph.length) {
      blocks.push(<p key={blocks.length}>{renderInline(paragraph.join(" "))}</p>);
      paragraph = [];
    }
    if (list.length) {
      blocks.push(
        <ul key={blocks.length}>
          {list.map((item, i) => <li key={i}>{renderInline(item)}</li>)}
        </ul>,
      );
      list = [];
    }
  };

  for (const raw of content.split(/\r?\n/)) {
    const line = raw.trim();
    if (line === "") {
      flush();
    } else if (line.startsWith("### ")) {
      flush();
      blocks.push(<h3 key={blocks.length}>{line.slice(4)}</h3>);
    } else if (line.startsWith("## ")) {
      flush();
      blocks.push(<h2 key={blocks.length}>{line.slice(3)}</h2>);
    } else if (line.startsWith("- ")) {
      if (paragraph.length) flush();
      list.push(line.slice(2));
    } else {
      if (list.length) flush();
      paragraph.push(line);
    }
  }
  flush();

  return <div className={styles.body}>{blocks}</div>;
}

アフィリエイトリンクには rel="sponsored" を付けます。広告のリンクであることを検索エンジンに伝えるための属性です。

/* components/ArticleBody.module.css */
.body h2 {
  margin: 2rem 0 0.75rem;
  padding-bottom: 0.25rem;
  border-bottom: 2px solid var(--accent);
  font-size: 1.35rem;
}

.body h3 {
  margin: 1.5rem 0 0.5rem;
  font-size: 1.1rem;
}

.body p,
.body ul {
  margin: 0 0 1rem;
  line-height: 1.8;
}

.body ul {
  padding-left: 1.5rem;
}

.body a {
  color: var(--accent);
  font-weight: 600;
}

7.5 共通のレイアウトとCSS

// app/layout.tsx
import type { Metadata } from "next";
import Link from "next/link";
import "./globals.css";

export const metadata: Metadata = {
  title: "おすすめ商品レビュー",
  description: "楽天市場の商品を、レビューと商品情報をもとに紹介します。",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ja">
      <body>
        <header className="site-header">
          <Link href="/" className="site-title">おすすめ商品レビュー</Link>
        </header>
        <main className="container">{children}</main>
        <footer className="site-footer">
          <p>当サイトはアフィリエイト広告(楽天アフィリエイト)を利用しています。</p>
        </footer>
      </body>
    </html>
  );
}
/* app/globals.css */
:root {
  --text: #1f2933;
  --muted: #616e7c;
  --accent: #b42318;
  --border: #e4e7eb;
  --background: #ffffff;
  --surface: #f5f7fa;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  color: var(--text);
  background: var(--background);
  font-family: system-ui, -apple-system, "Hiragino Sans", "Noto Sans JP", sans-serif;
}

.container {
  max-width: 760px;
  margin: 0 auto;
  padding: 1.5rem 1rem 3rem;
}

.site-header {
  border-bottom: 1px solid var(--border);
  padding: 1rem;
  text-align: center;
}

.site-title {
  color: var(--text);
  font-weight: 700;
  text-decoration: none;
}

.site-footer {
  border-top: 1px solid var(--border);
  padding: 1.5rem 1rem;
  color: var(--muted);
  font-size: 0.85rem;
  text-align: center;
}

.ad-label {
  display: inline-block;
  margin-bottom: 0.75rem;
  padding: 0.1rem 0.5rem;
  border: 1px solid var(--muted);
  border-radius: 4px;
  color: var(--muted);
  font-size: 0.8rem;
}

.preview-banner {
  margin-bottom: 1rem;
  padding: 0.75rem 1rem;
  border-radius: 6px;
  background: #fff4e5;
  color: #8a4b00;
  font-weight: 600;
}

.product-box {
  margin: 1.5rem 0;
  padding: 1rem;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--surface);
}

.product-box dl {
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: 0.25rem 1rem;
  margin: 0 0 0.75rem;
}

.product-box dt {
  color: var(--muted);
}

.product-box dd {
  margin: 0;
}

.button {
  display: inline-block;
  padding: 0.6rem 1.2rem;
  border-radius: 6px;
  background: var(--accent);
  color: #fff;
  font-weight: 700;
  text-decoration: none;
}

.article-list {
  margin: 0;
  padding: 0;
  list-style: none;
}

.article-list li {
  padding: 1rem 0;
  border-bottom: 1px solid var(--border);
}

.article-list a {
  color: var(--text);
  font-weight: 600;
}

.keyword {
  margin-top: 0.25rem;
  color: var(--muted);
  font-size: 0.85rem;
}

7.6 公開記事の一覧(app/page.tsx)

// app/page.tsx
import Link from "next/link";
import { listPublishedArticles } from "@/lib/articles";

// 記事を公開したらすぐに一覧へ反映させるため、リクエストのたびにDBを読む。
export const dynamic = "force-dynamic";

export default async function HomePage() {
  const articles = await listPublishedArticles();

  if (articles.length === 0) {
    return <p>公開している記事はまだありません。</p>;
  }

  return (
    <>
      <h1>記事一覧</h1>
      <ul className="article-list">
        {articles.map((article) => (
          <li key={article.id}>
            <Link href={`/articles/${article.id}`}>{article.title}</Link>
            <div className="keyword">{article.keyword}</div>
          </li>
        ))}
      </ul>
    </>
  );
}

7.7 記事の詳細と、下書きのプレビュー

公開記事のページと、下書きのプレビューのページは、同じ表示を使います。共通の部品を作ります。

// components/ArticleView.tsx
import type { ArticleDetail } from "@/lib/articles";
import ArticleBody from "./ArticleBody";

const yen = new Intl.NumberFormat("ja-JP", { style: "currency", currency: "JPY" });

export default function ArticleView({ article }: { article: ArticleDetail }) {
  const { product } = article;
  return (
    <article>
      <span className="ad-label">広告</span>
      <h1>{article.title}</h1>

      <section className="product-box" aria-label="商品情報">
        <dl>
          <dt>商品名</dt>
          <dd>{product.name}</dd>
          <dt>価格</dt>
          <dd>{product.price === null ? "—" : yen.format(product.price)}</dd>
          <dt>レビュー</dt>
          <dd>
            {product.reviewAverage === null ? "—" : `★${product.reviewAverage.toFixed(2)}`}
            {product.reviewCount === null ? "" : `(${product.reviewCount}件)`}
          </dd>
        </dl>
        {product.affiliateUrl && (
          <a className="button" href={product.affiliateUrl} rel="sponsored nofollow noopener" target="_blank">
            楽天市場で見る
          </a>
        )}
        <p className="keyword">価格・レビューは、商品情報を取得した時点のものです。</p>
      </section>

      <ArticleBody content={article.content} />
    </article>
  );
}

補足(「広告」の表示): アフィリエイト記事は広告です。記事の冒頭とサイトのフッターに、広告であることを表示します(ステルスマーケティングの規制に対応するため)。表示の方法は、利用するアフィリエイトサービスの規約と、関係する法令の最新情報を確認してください。

公開記事のページです。公開済みの記事だけを表示し、それ以外は「見つかりません」にします。

// app/articles/[id]/page.tsx
import { notFound } from "next/navigation";
import ArticleView from "@/components/ArticleView";
import { getArticle } from "@/lib/articles";

export const dynamic = "force-dynamic";

export default async function ArticlePage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  if (!/^\d+$/.test(id)) notFound();

  const article = await getArticle(Number(id), "published");
  if (!article) notFound();

  return <ArticleView article={article} />;
}

下書きのプレビューのページです。環境変数 ENABLE_PREVIEW が true のときだけ表示します。公開する環境ではこの値を設定しないので、外部の人が下書きを見ることはできません。

// app/preview/[id]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import ArticleView from "@/components/ArticleView";
import { getArticle } from "@/lib/articles";

export const dynamic = "force-dynamic";

// 下書きを検索エンジンに登録させない。
export const metadata: Metadata = { robots: { index: false, follow: false } };

export default async function PreviewPage({ params }: { params: Promise<{ id: string }> }) {
  if (process.env.ENABLE_PREVIEW !== "true") notFound();

  const { id } = await params;
  if (!/^\d+$/.test(id)) notFound();

  const article = await getArticle(Number(id), "draft");
  if (!article) notFound();

  return (
    <>
      <p className="preview-banner">下書きのプレビューです(公開されていません)</p>
      <ArticleView article={article} />
    </>
  );
}

補足(Next.jsのバージョン): ページの params を Promise として受け取る書き方は、Next.js 15以降のものです。古いバージョンでは params: { id: string } として受け取ってください。

7.8 サイトを起動する

cd C:\work\affiliate-site
npm run dev

ブラウザーで http://localhost:3000 を開くと、公開記事の一覧が表示されます。まだ記事を公開していないので、「公開している記事はまだありません。」と表示されれば正常です。

8. Codexに商品・キーワードを選定させて記事を作る

ここからが第3回の主眼です。Codexに、商品評価・キーワード選定・記事生成・下書き保存を任せます。

商品を評価・選定させる

まずは記事化候補を出し、その中から記事にする商品をCodexに選ばせます。判断材料は、価格・レビュー件数・レビュー平均・ジャンルです(products テーブルに保存している値)。同じ商品でも別キーワードで複数記事を作れるよう、list_article_candidates() は商品単位では除外しません。重複は、このあとの check_duplicate_article() で「商品ID+キーワード」を基準に判定します。第2回同様、使うMCP Toolを明示します。

affiliate MCP Serverの list_article_candidates ツールで記事化候補を取得し、
レビュー件数と評価、価格のバランスから、
記事化に適した商品を1つ選んでください。
選定理由も教えてください。

実行結果
image.png

Codexは list_article_candidates() の結果(=DBに保存された事実)をもとに選定します。LLMの記憶ではなく、DBの数値を根拠にさせるのがポイントです。

商品詳細を取得する

選んだ商品について、記事に必要な詳細情報(商品説明・画像・店舗など)を get_product_detail() で取得します。DBの基本情報だけでは「特徴」「注意点」を事実ベースで書けないため、ここで楽天の商品情報を取り寄せます。

affiliate MCP Serverの get_product_detail ツールで、
選んだ商品の詳細情報(商品説明・画像・店舗など)を取得してください。

実行結果
image.png

キーワードを選定させる

取得した商品詳細をもとに、検索キーワードを決めさせます。

取得した商品詳細から、商品の特徴・想定読者・検索意図を踏まえて、
記事で狙う検索キーワードを1つ提案してください。

実行結果
image.png

重複をチェックする

記事を書く前に、同じ商品・キーワードの記事が既にないかを確認します。

affiliate MCP Serverの check_duplicate_article ツールで、
選んだ商品IDとキーワードの記事が既に存在しないか確認してください。

実行結果
image.png

check_duplicate_article() が exists: true を返した場合は、同じ商品・キーワードの組み合わせですでに記事が作成されているため、別の商品または別のキーワードを検討します(Governanceの「重複記事防止」)。

記事を生成させる

DBの基本情報と get_product_detail() の詳細、選定キーワードをもとに、記事を生成させます。3章で決めた書式を、指示に含めます。

選んだ商品について、
価格・レビューなどはDBに保存された商品情報を使用し、
商品の特徴・商品説明などは get_product_detail ツールで取得した
楽天の商品情報を根拠にして、アフィリエイト記事の下書きを作成してください。

本文の書式は次のとおりです。これ以外の書式(HTMLタグ、画像、表など)は使わないでください。
- 見出しは行頭に「## 」、小見出しは「### 」
- 箇条書きは行頭に「- 」
- 段落は空行で区切る
- リンクは [文字](URL) の形で、商品へのリンクには affiliate_url を使う

構成は「導入」「商品の特徴」「メリット」「注意点」「おすすめする人」「まとめ」とし、
最後に [商品を楽天市場で見る](affiliate_url) を置いてください。
記事のタイトルは本文に含めず、別に提案してください。
取得できていない情報を推測で補完しないでください。

実行結果
(ここに実行結果の画像を貼る)

補足(リンクは affiliate_url): 第2回で保存したとおり、products には url と affiliate_url があります。成果を計測するため、記事に掲載する商品リンクには必ず affiliate_url を使用します。サイト側は、許可したドメインのURLだけをリンクとして表示します。

補足(「比較」を入れない理由): 今回は比較対象の商品情報を取得していないため、構成に「比較」は入れません。入れるとCodexが他商品との比較を推測で書いてしまう恐れがあります。今回は1商品を対象とした記事生成に絞ります。複数商品を対象とした比較記事は、Affiliate MCP Serverの発展的な拡張として扱えます。

重要(AI生成コンテンツ): 第1回で強調したとおり、価格・レビューはDB、特徴・商品説明は get_product_detail() が返した楽天の商品情報を根拠にさせ、LLMの記憶や推測で書かせません。生成後も、事実誤認・誇張・広告表示などは人間が確認します。

下書きとしてDBへ保存する

生成した記事を、articles テーブルへ下書きとして保存します。

生成した記事を、affiliate MCP Serverの save_article ツールで、
商品ID・タイトル・キーワード・記事本文を指定して、articles テーブルへ保存してください。
保存した記事のIDを教えてください。

実行結果
(ここに実行結果の画像を貼る)

save_article() は必ず draft(下書き)で保存するので、この時点ではサイトの一覧には表示されません。

一連の流れは次のようになります。

自然言語
   ↓
Codex
   ↓ MCP
Affiliate MCP Server
   ├─ list_article_candidates()  ← 候補取得
   ├─ get_product_detail()       ← 商品詳細取得
   ├─ check_duplicate_article()  ← 重複確認
   └─ save_article()             ← articles へ下書きとして保存
        ↓
PostgreSQL(articles:status = draft)

9. 人間がプレビューで確認して、公開する

9.1 プレビューで確認する

.env.local に ENABLE_PREVIEW=true を設定した状態でサイトを起動し、保存した記事のIDでプレビューを開きます。

http://localhost:3000/preview/記事ID

次の点を確認します。

  • 価格・レビューの値が、商品情報と一致しているか
  • 商品の特徴・説明に、事実と違う内容や誇張がないか
  • 「広告」の表示があるか
  • 「楽天市場で見る」のリンク先が、正しい商品の affiliate_url か
  • 本文に、意図しない文字(崩れた書式など)が表示されていないか

9.2 直したいところがあれば、Codexに直させる

affiliate MCP Serverの update_draft ツールで、記事ID ○ の本文の「注意点」に、
商品説明に書かれている保管方法の注意を追記してください。
商品説明に書かれていない内容は追加しないでください。

update_draft() は下書きだけを更新します。プレビューを再読み込みすると、更新した内容が表示されます。

9.3 公開する

内容を確認できたら、人間が公開スクリプトを実行します。

cd C:\work\affiliate-mcp-server
python -m scripts.publish_article publish 記事ID

確認のメッセージに y と答えると、記事が公開されます。http://localhost:3000 の一覧に記事が表示され、/articles/記事ID で読めるようになります。

公開をやめたいときは、次のコマンドで下書きに戻します。

python -m scripts.publish_article unpublish 記事ID

10. Affiliate MCP Serverと記事サイトの完成状態

第3回で Article Tools と記事サイトを追加したことで、第1回で設計したAffiliate MCP Serverが完成しました。Codexが判断・生成を担当し、Affiliate MCP Serverが楽天APIとPostgreSQLとの境界を担当し、Next.jsのサイトが公開記事の表示を担当します。

Codex
 │
 │ MCP
 ▼
Affiliate MCP Server ─────────────── 公開スクリプト(人間が実行)
 │                                         │
 ├─ Product Tools                          │
 │    ├─ 楽天API                           │
 │    └─ PostgreSQL(products)            │
 │                                         │
 └─ Article Tools                          │
      └─ PostgreSQL(articles) ◀──────────┘
               │
               │ 読み取り専用
               ▼
        Next.jsサイト(一覧・記事・プレビュー)

これを、次のローカル環境で動かします。

Windows 11
├─ Codex CLI
├─ Affiliate MCP Server(Python / venv)
├─ Docker Desktop
│    └─ PostgreSQL
└─ Next.jsサイト(Node.js:npm run dev)

まとめ

第3回では、商品情報をもとにAIが記事を作り、人間が確認したうえで、Next.jsの自前サイトに公開するまでを実装しました。

項目 ポイント
何を作ったか 商品選定 → 商品詳細取得 → キーワード選定 → 記事生成 → 下書き保存 → プレビューで確認 → 人間が公開 → Next.jsサイトに掲載
追加したTool get_product_detail(Product)、Article Tools(list_article_candidates / check_duplicate_article / save_article / get_article / update_draft)
追加したサイト Next.js(App Router)+ TypeScript + 通常のCSS。一覧・記事・下書きプレビューの3画面
構成の変更 MCP Serverのディレクトリは変えず tools/article_tools.py と scripts/publish_article.py を追加、product_tools.py と rakuten.py に追記、server.py にimportを追記。サイトは別フォルダに作成
記事の正本 PostgreSQLの articles テーブル。保存先が1か所なので、片方だけに残る不整合が起きない
Governance AIは下書きの保存・更新だけ。公開はMCPに登録しないスクリプトで、人間がプレビューで確認してから行う。重複記事は check_duplicate_article で防止
安全な表示 本文はHTMLとして埋め込まず、決めた書式だけを表示。リンクは許可したドメインだけ。広告の表示と rel="sponsored"
事実の担保 価格・レビューはDB、特徴・商品説明は get_product_detail の楽天情報を根拠にし、取得できていない情報を推測で補完しない
認証情報 サイトは読み取り専用のDBユーザーで接続し、接続情報は .env.local に保存(Gitに含めない、NEXT_PUBLIC_ にしない)

これで、楽天の商品情報取得・PostgreSQLへの保存・商品選定・記事生成・自前サイトでの公開までを、Codexと自作MCP Server、Next.jsでつなぐことができました。第1回で設計したAffiliate MCP Serverが、第2回・第3回の実装をもって完成です。

完成したMCP Serverと記事サイトをLinux VPSへ配置し、OpenAI APIとAutomation Workerを組み合わせて記事作成を自動化する方法は、別シリーズ「AIアフィリエイト自動運用編」で扱います。さらに、公開後の成果データの収集・分析・記事改善についても、別シリーズとして扱う予定です。

シリーズ記事一覧

LinuxにWordPressを構築して独自ドメインでHTTPS公開するまで ― インストール手順とシェルスクリプトによる自動化
【実践 第1回】Codex + MCPでAIアフィリエイト支援システムを設計してみる 〜商品情報取得・DB蓄積・記事作成・WordPress連携〜
【実践 第2回】楽天商品をPythonのMCP Serverで取得・DB蓄積してみる 〜商品検索・ランキング取得・保存まで〜
【実践 第3回】Codexに商品・キーワードを選定させてアフィリエイト記事を作成してみる 〜商品評価・キーワード選定・記事生成・Next.jsサイトでの公開まで〜

参考

※ Next.js・楽天APIの仕様や規約、関係する法令は更新されることがあります。実装前に公式の最新情報を確認してください。掲載コードは学習用のサンプルです。実運用ではエラーハンドリング・リトライ・ログ出力(第1回の非機能要件)を必要に応じて追加してください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?