WordPressの記事入稿を自動化する手順|Markdown原稿から下書き投入・確認・予約投稿まで

WordPressの記事入稿を自動化する手順|Markdown原稿から下書き投入・確認・予約投稿まで
                                                   

Markdown原稿をWordPressに下書きで自動投入し、人がチェックリストで確認してから予約投稿する手順です。pandocの変換設定、REST APIで送るコード、見出しの改行や表の崩れなど実際につまずいた4点と直し方、内部リンクの公開順まで、当社のオウンドメディアで回している方法をそのまま書きました。


この記事は約22分で読み終わります。

WordPress で記事を週1本以上入れていると、原稿を編集画面へコピペし、見出しの改行や表の崩れを毎回手で直す作業が重くなります。

AIに任せるのは下書き投入までにします。確認と予約投稿は人が持ちます。人は、プレビューで見る場所をチェックリストに絞ります。

DP JOURNAL は WordPress で運用しているオウンドメディアです。2026年9月時点で公開記事は40本を超えています。2026年8月中旬から9月中旬のおよそ4週間半では、14本を公開しました。

この本数になると、見出しの改行、表の崩れ、要約欄の余計な記号を、人が毎回直すことになります。原稿を書く時間とは別に、入稿だけで集中力を使います。

私たちは、Markdown 原稿を WordPress に下書きで入れるところまでを Claude Code とスクリプトに任せています。確認と予約投稿は人が持ちます。この記事も、その流れで入稿しています。

原稿と画像がそろっている前提で、原稿が固まってから下書き投入・確認・予約までが、1本あたり10分ちょっとです。原稿を書く時間は含みません。

この運用を含めて、社内でAIを使う仕組みを4か月で作った記録はこちら

WordPressの記事入稿自動化は、原稿から予約投稿まで5つの工程で考える

私たちの入稿フローは、次の5つです。

工程 担当
原稿(Markdown) 人が方針を決め、Claude Code と一緒に書く
HTMLへ変換 スクリプト(pandoc)
下書き投入 Claude Code に指示し、スクリプトで REST API へ(status=draft 固定)
確認 人(プレビューでチェックリスト)
予約・公開 人(編集責任者)

原稿の正本は Markdown に置きます。WordPress の編集画面で直接直し続けると、どちらが最新か分からなくなります。直すときは Markdown 原稿を直し、もう一度変換して入れ直す運用にしています。

スクリプトからは公開を送りません。REST API で投稿を作るときも、status=draft に固定します。公開や予約の判断は編集責任者が持ちます。

よくある失敗は、便利だからといって公開までスクリプトに含めることです。本文の崩れや内部リンクの404は、公開後に気づくと読者に見えてしまいます。下書きまでで止める設計にしておくと、人が見る余地を残せます。

準備:アプリケーションパスワードと権限

WordPress REST API に投稿を作るには、認証が必要です。私たちは、アプリケーションパスワードを使います。アプリケーションパスワードは WordPress 5.6 で導入された仕組みで、連携ごとに個別に発行し、個別に取り消せます。

投入に使うユーザーの権限は、必要な範囲に絞るのが基本です。投稿の作成・予約だけなら編集者(Editor)で足ります。管理者のアプリケーションパスワードは、漏れたときの被害が大きくなります。

ユーザー名とアプリケーションパスワードは、コードに書きません。環境変数で渡します。リポジトリや共有フォルダに認証情報が残ると、後から消すのが面倒になります。

あわせて、REST API のユーザー一覧が未ログインで見える設定になっていないかも確認します。未ログインの状態で https://example.com/wp-json/wp/v2/users を開き、ユーザー名が見えないかを確認します。

準備がうまくいったかは、認証付きのリクエストで下書き作成ができるかを見ます。失敗する場合は、ユーザー名、アプリケーションパスワード、権限、REST API の認証方式を順に確認します。

よくある失敗は、テスト用に管理者の認証情報を使い、そのまま運用に残すことです。もうひとつは、パスワードをスクリプトに直書きすることです。どちらも、後から連携を止めにくくなります。

原稿の書き方:frontmatter に入稿情報を持たせる

Markdown 原稿の先頭には、frontmatter を置きます。frontmatter は、本文とは別にメタ情報を書くための領域です。タイトル、スラッグ、要約、カテゴリ、タグを、本文と同じファイルで管理します。

---
記事タイトル: 記事のタイトル
slug: example-article-slug
excerpt: 記事一覧や検索結果に出る要約文(80〜120字)
カテゴリ: コラム
タグ: [タグA, タグB]
---

この形にしておくと、入稿時に「タイトルはどこに書いたか」「要約は別ファイルだったか」と探さなくて済みます。原稿を直すときも、本文と入稿情報を一緒に見直せます。

本文に入れない編集メモがある場合は、投入前に除きます。frontmatter も本文からは外します。WordPress に送る本文は、読者に見える内容だけにします。

うまくいっているかは、変換前の段階で確認します。frontmatter の開始と終了がそろっているか、タイトルやスラッグが空になっていないかを見ます。要約欄に入れる文は、WordPress の抜粋として見えてよい形にします。

よくある失敗は、excerpt を引用符で囲み、その引用符が要約欄に残ることです。スクリプト側で剥がす処理を入れていない場合、引用符ごと入ります。原稿の書き方をそろえるか、投入前に取り除く処理を入れます。

カテゴリやタグは、名前ではなくタームのID(整数)を送ります。名前しか手元にない場合は、GET /wp-json/wp/v2/categories?search=<名前> でカテゴリを引きます。タグは /wp-json/wp/v2/tags?search=<名前> で引き、IDに変換してから送ります。

WordPressへの下書き投入:Markdownを変換してstatus=draftで入れる

Markdown から HTML への変換には pandoc を使います。pandoc は既定で72桁で行を折り返します。私たちは、見出しや段落の途中に折り返し改行を入れないために --wrap=none を付けています。

変換前には、frontmatter と編集メモを本文から除きます。先頭の --- で囲まれたブロックを取り除いた一時ファイルを作り、それを pandoc に渡します。frontmatter の中身は、入稿情報(title/slug/excerpt/カテゴリ/タグ)として別に読みます。編集メモは原稿に “ の形で書いておき、同じ前処理で取り除きます。

import re
from pathlib import Path

source = Path("article.md").read_text(encoding="utf-8")

body = re.sub(r"\A---\s*\n.*?\n---\s*\n", "", source, flags=re.DOTALL)
body = re.sub(r"", "", body, flags=re.DOTALL)

Path("article.body.md").write_text(body, encoding="utf-8")

変換コマンドは次の形です。

pandoc article.body.md -f markdown-native_divs -t html --wrap=none -o article.html

--wrap=none は、見出しや段落の途中に折り返し改行を入れない指定です。-f markdown-native_divs は、原稿に書いた <div> を pandoc の Div として解釈させないために付けています。表を囲むタグをそのまま扱いたいときに効きます。

変換後には、HTML を機械でチェックします。<div</div> の数が一致するか、見出しタグの中に改行や <br> が残っていないかを見ます。

下書き投入は、REST API の POST /wp-json/wp/v2/posts に送ります。titleslugexcerptcontentcategoriestagsstatus=draft を送る形です。認証はアプリケーションパスワードの Basic 認証を使い、値は環境変数から読みます。

以下は、ダミーのサイト URL を使った Python の例です。article.html を本文として送り、下書きを作ります。カテゴリとタグは環境変数で渡す形にしています。requests を使うため、先に pip install requests を実行します。このコードは post_draft.py として保存します。

import base64
import json
import os
import re
import sys
from pathlib import Path

import requests


SITE_URL = os.environ.get("WP_SITE_URL", "https://example.com").rstrip("/")
WP_USER = os.environ["WP_USER"]
WP_APP_PASSWORD = os.environ["WP_APP_PASSWORD"]

TITLE = os.environ["WP_POST_TITLE"]
SLUG = os.environ["WP_POST_SLUG"]
EXCERPT = os.environ["WP_POST_EXCERPT"]
CATEGORY_IDS = json.loads(os.environ.get("WP_CATEGORY_IDS", "[]"))
TAG_IDS = json.loads(os.environ.get("WP_TAG_IDS", "[]"))

HTML_PATH = Path(os.environ.get("WP_HTML_PATH", "article.html"))


def read_html(path: Path) -> str:
    if not path.exists():
        raise FileNotFoundError(f"HTML file not found: {path}")
    return path.read_text(encoding="utf-8")


def check_html(html: str) -> None:
    open_divs = len(re.findall(r"<div\b", html, flags=re.IGNORECASE))
    close_divs = len(re.findall(r"</div>", html, flags=re.IGNORECASE))

    if open_divs != close_divs:
        raise ValueError(f"div tag count mismatch: open={open_divs}, close={close_divs}")

    headings = re.findall(
        r"<h[1-6][^>]*>(.*?)</h[1-6]>",
        html,
        flags=re.IGNORECASE | re.DOTALL,
    )

    for heading in headings:
        if "\n" in heading or re.search(r"<br\s*/?>", heading, flags=re.IGNORECASE):
            raise ValueError("newline or br found inside a heading tag")


def build_headers() -> dict:
    token = base64.b64encode(f"{WP_USER}:{WP_APP_PASSWORD}".encode("utf-8")).decode("ascii")
    return {
        "Authorization": f"Basic {token}",
        "Content-Type": "application/json",
    }


def create_draft(html: str) -> dict:
    payload = {
        "title": TITLE,
        "slug": SLUG,
        "excerpt": EXCERPT.strip().strip('"'),
        "content": html,
        "categories": CATEGORY_IDS,
        "tags": TAG_IDS,
        "status": "draft",
    }

    response = requests.post(
        f"{SITE_URL}/wp-json/wp/v2/posts",
        headers=build_headers(),
        data=json.dumps(payload),
        timeout=30,
    )

    if response.status_code >= 400:
        raise RuntimeError(f"WordPress API error: {response.status_code} {response.text}")

    return response.json()


def main() -> None:
    html = read_html(HTML_PATH)

    check_html(html)

    created = create_draft(html)

    returned_content = created.get("content", {}).get("rendered", "")
    if returned_content:
        check_html(returned_content)

    print(json.dumps({
        "id": created.get("id"),
        "status": created.get("status"),
        "slug": created.get("slug"),
        "link": created.get("link"),
    }, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    try:
        main()
    except Exception as error:
        print(f"ERROR: {error}", file=sys.stderr)
        sys.exit(1)

実行前に、必要な環境変数を渡します。値は自社サイト用に置き換えます。認証情報は、コマンド履歴や共有ファイルに残さない運用にしてください。

export WP_SITE_URL="https://example.com"
export WP_USER="your-wordpress-user"
export WP_APP_PASSWORD="your-application-password"
export WP_POST_TITLE="記事のタイトル"
export WP_POST_SLUG="example-article-slug"
export WP_POST_EXCERPT="記事一覧や検索結果に出る要約文"
export WP_CATEGORY_IDS="[]"
export WP_TAG_IDS="[]"
export WP_HTML_PATH="article.html"

python post_draft.py

うまくいったかは、出力された statusdraft になっているかで確認します。WordPress の投稿一覧でも、下書きとして作られているかを見ます。プレビューを開き、本文が入っているかも確認します。

よくある失敗は、投入前の HTML だけ見て安心することです。WordPress に入った後の本文でも、同じチェックをかけます。返ってきた本文で見出し内の改行や <br>、div の数を確認すると、変換後と投入後の差に気づけます。

人が見るのはここだけ:下書き確認のチェックリスト

下書きが入ったら、人がプレビューを見ます。見る場所は絞ります。毎回すべてを読み直すのではなく、機械で崩れやすい場所と、公開判断に関わる場所を確認します。

誰が確認して誰が公開を決めるかの線引きは、SNS運用の承認フローと同じ考え方です

確認する項目は次のとおりです。

確認項目 見ること 崩れるとどこに出るか 機械と人の分担
見出し 途中で改行されていないか 見出しの番号と本文 改行や <br> は機械で止め、人がプレビューで読む
崩れていないか、スマホ幅で横スクロールできるか 本文中の表と周辺の余白 div の数は機械で止め、スマホ幅の見え方は人が見る
要約欄(抜粋) 引用符や不要な記号が混ざっていないか 記事一覧や検索結果 前後の引用符は機械で整え、文として自然かは人が見る
記事下部 サイドバーや新着一覧が崩れていないか 本文の下のサイドバーや新着一覧 タグ数は機械で止め、ページ下部の表示は人が見る
内部リンク 行き先がすべて公開済みか リンク先ページ 公開済みかどうかは人が判断する
アイキャッチ 本文と合っているか、alt テキストが合っているか 記事上部や一覧 人が見る
入稿情報 タイトル・スラッグ・カテゴリ・タグが合っているか 管理画面、一覧、URL IDの形式は機械で止め、意図に合うかは人が見る

見出しは、番号と本文が離れていないかを見ます。表は、横幅が広いときにスマホ幅で表示が崩れやすい要素です。プレビューを狭い幅でも確認します。

要約欄は、本文より見落としやすい場所です。frontmatter の引用符や記号が残っていないかを見ます。記事一覧や検索結果に出る文として不自然でないかも確認します。

記事本文の下も見ます。本文だけが正しく見えていても、閉じタグの不整合でサイドバーや新着一覧が崩れることがあります。ページ下部までスクロールし、幅いっぱいに広がっていないかを確認します。

うまくいった状態は、本文、周辺パーツ、要約欄、内部リンクがすべて意図どおり見える状態です。確認が終わったら、予約投稿へ進みます。

よくある失敗は、本文の上部だけ見て予約することです。表や記事下部の崩れは、最後まで見ないと気づけません。チェックリストにしておくと、確認者が変わっても見る場所をそろえられます。

WordPress REST APIで予約投稿し、内部リンクの公開順をそろえる

確認が済んだら、予約投稿にします。REST API では、投稿の statusfuture を指定できます。公開日時は date で指定できます。管理画面から予約しても構いません。

私たちの運用では、確認して予約を押すのは編集責任者です。スクリプトは下書きまでです。予約や公開の判断を人が持つことで、最後に内部リンクや表示崩れを確認できます。

予約投稿で特に気をつけるのは、内部リンクの公開順です。リンク先が予約中の記事だと、リンク元が先に公開された時点で404になります。公開済みの記事にだけリンクを張るか、リンク先を先に公開する順に並べます。

後から公開する記事へリンクしたい場合は、いったん入れません。リンク先の記事が公開された後に、張り返します。私たちは実際に内部リンクで404を出したことがあるため、いまはこの運用にしています。

うまくいったかは、予約済みの記事のリンク先を確認します。プレビュー上でリンクを開き、公開済みの記事だけに向いているかを見ます。未公開の記事へ向いている場合は、公開後に張り返すメモを残します。

私たちがつまずいた4つのこと

見出しの番号と本文が改行で割れた

最初につまずいたのは、見出しの途中に改行が入ることでした。見出しの番号と本文が分かれて見えました。原因は、pandoc の既定の折り返し改行を WordPress の自動整形が <br> にしていたことです。

直し方は、変換時に --wrap=none を付けることでした。さらに、投入後に見出し内の改行を機械でチェックするようにしました。人がプレビューで気づく前に、スクリプトで止めるためです。

確認方法は、変換後の HTML と WordPress から返ってきた本文の両方を見ることです。<h2><h3> の中に改行や <br> が残っていないかを確認します。プレビューでは、見出しの番号と本文が同じ流れで読めるかを見ます。

要約欄の前後にダブルクォートが入った

公開後に、要約欄の前後にダブルクォートが入っていることに気づきました。原因は、原稿の frontmatter に入れた引用符を投入スクリプトが剥がしていなかったことです。本文ではなく、抜粋側の問題でした。

直し方は、投入スクリプトで要約の前後にある引用符を取り除くことでした。同じ症状だった公開済みの13本も一括で直しました。公開済みの記事にも影響が出るため、変換や投入の設定変更後は過去分も見ます。

確認方法は、投稿本文だけでなく、要約欄を見ることです。記事一覧や検索結果に出る文に、余計な引用符や記号が混ざっていないかを確認します。本文プレビューだけでは気づけない場合があります。

表を囲む div の扱いで記事下部が崩れた

記事本文の下で、サイドバーと新着一覧が画面幅いっぱいに広がったことがありました。原因は、表を囲む <div> を pandoc が自分の Div として解釈し、文書の末尾に閉じタグを余分に出していたことです。本文の途中ではなく、記事下部に影響が出ました。

直し方は、変換コマンドに -f markdown-native_divs を付けることでした。さらに、投入前に <div</div> の数を比べるようにしました。数が合わなければ、下書き投入の前に止めます。

確認方法は、HTML のタグ数を見ることと、プレビューで記事の下まで見ることです。表の直後だけでなく、サイドバーや新着一覧の表示も確認します。ページ下部の崩れは、本文の読み直しだけでは見落とします。

修正前に予約した記事で同じ崩れが再発した

div の問題を直した2日後に、同じ崩れが別の記事で再発しました。原因は、修正前に作って予約投稿に積んでいた記事が、壊れたまま公開日を迎えたことです。変換設定の修正は、これから入れる記事にしか効きません。

それ以来、変換の設定を触ったら、公開・予約・下書きの全記事を検算しています。すでに WordPress に入っている記事も対象です。予約中の記事は特に見落としやすいため、公開前に確認します。

確認方法は、現在の下書きだけでなく、予約済みの記事も同じチェックに通すことです。見出し内改行、div の数、要約欄、記事下部を見ます。設定を直した後ほど、過去に入れた記事を疑います。

運用して分かったこと

原稿と画像がそろっている前提で、原稿が固まってから下書き投入・確認・予約までが、1本あたり10分ちょっとです。原稿を書く時間は含みません。手作業でコピペ入稿していた頃との比較は、測っていないため書けません。

時間が短くなった理由は、すべてを機械に任せたからではありません。機械に任せる場所と、人が見る場所を分けたからです。人が毎回迷う範囲を、チェックリストに絞りました。

特に効いたのは、崩れやすい場所を先に決めたことです。見出しの改行、表を囲むタグ、要約欄は、起きる問題が決まっていました。決まっている問題は、投入前後に機械で検出できます。

一方で、予約投稿や公開判断は人が持っています。内部リンクの公開順、本文とアイキャッチの合い方、カテゴリやタグの意図は、最後に人が見る必要があります。私たちは、ここを自動で通す運用にはしていません。

制作を外部に委託する場合に、入稿や確認の範囲を仕様書でどう決めるか

未検証の環境もあります。WordPress.com やヘッドレス構成で同じ流れがそのまま動くかは未検証です。導入する場合は、公式情報で確認してください。

最初の1本でやること

最初から運用全体を作り込む必要はありません。まずは、Markdown 原稿を HTML に変換し、WordPress に status=draft で入れるところだけを自動化します。公開や予約は、これまでどおり人が操作します。

最初の1本では、frontmatter を用意し、本文用の一時ファイルを作ってから pandoc article.body.md -f markdown-native_divs -t html --wrap=none -o article.html で変換します。投入前に div の数と見出し内改行を確認します。下書き投入後も、返ってきた本文で同じ確認をします。

そのうえで、プレビューをチェックリストに沿って見ます。見出し、表、要約欄、記事下部、内部リンク、アイキャッチ、タイトル・スラッグ・カテゴリ・タグを確認します。問題がなければ、人が予約投稿に進みます。

変換の設定を直したら、これから入れる記事だけでなく、公開済み・予約済み・下書きの記事も検算します。すでに予約に積んだ記事には、修正後の設定が自動では反映されません。ここを忘れると、同じ崩れが公開時に出ます。

社内でAIを使った運用の仕組みづくりを相談したい方は、お問い合わせからご相談ください。記事制作、入稿、確認、予約投稿までの流れを整理し、自社で回せる形にするところから一緒に進めます。

参照した資料

WordPress アプリケーションパスワード
https://developer.wordpress.org/advanced-administration/security/application-passwords/

WordPress REST API の認証
https://developer.wordpress.org/rest-api/using-the-rest-api/authentication/

WordPress REST API 投稿エンドポイント
https://developer.wordpress.org/rest-api/reference/posts/

WordPress REST API カテゴリエンドポイント
https://developer.wordpress.org/rest-api/reference/categories/

pandoc Manual
https://pandoc.org/MANUAL.html

Latest Posts