WordPressの記事投稿を自動化する方法|書いた原稿をコマンド1つで下書きにする(Python)

WordPressの記事投稿を自動化する方法|書いた原稿をコマンド1つで下書きにする(Python)
当サイトはアフィリエイト広告を利用しています。

手元で書いたテキスト原稿は、コマンド1回でWordPressの下書きにできます。
原稿はMarkdown(行頭の「##」や「-」で見出しと箇条書きを表す書き方。メモ帳で書けます)で用意します。

使うのはWordPress標準のREST API(外部プログラムから投稿するための窓口)と、Pythonの標準ライブラリだけです。
slug(記事URLの文字列)・カテゴリ・抜粋も原稿と一緒に設定でき、投稿IDを指定すれば上書き更新もできます。

この記事で作るもの

  • Markdown原稿をブロック形式に変換し、REST APIで下書き投稿するPythonスクリプト
  • 必要なもの: httpsで公開しているWordPress/Python 3(確かめ方は手順4)/アプリケーションパスワード(発行は手順2)
  • 追加の費用とインストール: なし
この記事に出てくる用語
  • 標準ライブラリ … Pythonに最初から入っている部品集。追加インストールなしで使える
  • アプリケーションパスワード … 外部プログラム専用に発行する合鍵。ログイン用とは別物で、1本ずつ無効化できる
  • ブロックエディタ … WordPress標準の編集画面。本文を「段落」「見出し」などのブロックの積み重ねとして扱う

コピペ投稿をやめる理由

ブロックエディタにも、Markdownをプレーンテキストとして貼る(Ctrl + Shift + V)と変換する機能はあります。
ただし表とコードブロックの変換は安定せず、slug・カテゴリ・抜粋・アイキャッチの設定は毎回手作業のまま残ります。

REST APIならこれがまとめてコマンド1回になり、同じ原稿からは毎回同じ結果が出ます。

手順

コードが出てくるのは手順4からです。
上から順に写せば、手順5の動作確認のところで自分のサイトに下書きが1件入ります。

1. REST APIが使えることを確認する

ブラウザで「サイトURL/wp-json/」を開きます。

ここでパーマリンクが「基本」だと分かったら、記事が増える前に「投稿名」へ変えておくほうが後が楽です。
変え方はWordPressインストール直後にやる初期設定8項目の項目1にあります。
波括弧で始まるデータ(JSON)が表示されれば、窓口は開いています。

パーマリンク(記事URLの形式の設定)が初期値の「基本」のままだと、窓口が生きていてもこのURLは404になります。
その場合は「サイトURL/?rest_route=/」で確認します。

どちらもエラーになる場合は、セキュリティプラグインが塞いでいる可能性があります(後半の「つまずきやすい箇所」参照)。

2. アプリケーションパスワードを発行する

管理画面の ユーザー → プロフィール を開き、最下部の「アプリケーションパスワード」欄で、用途が分かる名前(例: 投稿スクリプト)を入れて追加します。
WordPress 5.6以降に標準で入っています。

ログイン用パスワードアプリケーションパスワード
用途人が管理画面に入る外部プログラムがAPIを使う
無効化変えるとログイン自体に影響する管理画面から1本ずつ取り消せる
発行数1つ用途ごとに何本でも発行できる
  • パスワードは24文字で、4文字ずつ空白区切りで表示されます。表示はこの一度だけなので、画面を閉じる前に控えます。控えそこねたら、削除して発行し直せば済みます
  • 空白は含めても含めなくても認証は通ります
  • サイトがhttpsでない場合、この発行欄は表示されません(手元のPC内だけのローカル環境は例外)
  • 合鍵で開く範囲は、発行したユーザーができること全部です。投稿だけが目的なら、投稿者権限の専用ユーザーで発行すると、漏れたときの被害を狭められます

3. 認証情報を .env に置く

スクリプトと同じフォルダに .env というファイルを作り、3行書きます。

WP_URL=https://example.com
WP_USER=ユーザー名
WP_APP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx

メモ帳で作る場合は「名前を付けて保存」で、ファイルの種類を「すべてのファイル」に変えてから .env と入力します。
文字コードは「UTF-8」のままにします。
種類を変えずに保存すると .env.txt という名前になり、実行時に FileNotFoundError で止まります。
Windowsは拡張子を隠す設定が既定なので、見た目では気づけません。

= の前後に空白を入れないでください。
WP_URL = https://… のように書くと、読み出しに失敗します。

注意:認証情報の置き場所

  • スクリプト本体にパスワードを直接書かない。コードは共有・バックアップで人の目に触れる前提で扱う
  • gitで管理しているなら .gitignore に .env を入れる。コミットしてしまったら、後から消しても履歴に残るので、管理画面からそのパスワードを削除して発行し直す
  • サーバーの公開ディレクトリ(public_html の下など)に置かない。ブラウザから読めてしまう。このスクリプトは手元のPCで動かす前提

4. 認証ヘッダを組み立てる

Pythonが入っているかは、コマンドプロンプト(スタートメニューで「cmd」と検索)で python --version と打てば分かります。
Python 3.10.11 のような数字が返れば入っています。
無ければ python.org から入れます。
配布ページの大きいボタンは新しい導入ツールなので、その下の「standalone installer」の行末にあるバージョン番号を押して従来型を入手します。
従来型のインストーラー最初の画面の「Add python.exe to PATH」に必ずチェックを入れます。

認証はBasic認証です。
「ユーザー名:アプリケーションパスワード」をBase64に変換し、Authorizationヘッダに載せます。

import base64

def auth_header(user, app_password):
    # 「ユーザー名:アプリケーションパスワード」をBase64文字列にする
    token = f"{user}:{app_password}".encode("utf-8")
    return "Basic " + base64.b64encode(token).decode("ascii")

Base64は暗号化ではなく、元に戻せる変換です。
通信そのものがhttpsで暗号化されていることが前提になります。

5. 投稿リクエストを送る

使うエンドポイント(窓口のURL)は次のとおりです。
GET が「読み取る」、POST が「書き込む」です。

やることメソッドとURL
新規投稿POST /wp-json/wp/v2/posts
既存投稿の更新POST /wp-json/wp/v2/posts/投稿ID
カテゴリを探すGET /wp-json/wp/v2/categories?search=名前
カテゴリを作るPOST /wp-json/wp/v2/categories
画像アップロードPOST /wp-json/wp/v2/media

新規投稿のコードです。

import json
import urllib.request
import urllib.error

def post_article(site_url, user, app_password, title, content_html, slug):
    url = site_url.rstrip("/") + "/wp-json/wp/v2/posts"
    payload = {
        "title": title,
        "content": content_html,   # ブロック形式に変換済みの本文
        "slug": slug,
        "status": "draft",         # 既定は下書き。公開なら "publish"
    }
    req = urllib.request.Request(
        url, data=json.dumps(payload).encode("utf-8"), method="POST")
    req.add_header("Authorization", auth_header(user, app_password))
    req.add_header("Content-Type", "application/json; charset=utf-8")
    req.add_header("User-Agent", "my-wp-poster/1.0")   # 名乗り。無いと403の原因になる
    try:
        with urllib.request.urlopen(req, timeout=30) as res:
            return res.status, json.loads(res.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        return e.code, e.read().decode("utf-8", errors="replace")

省くと原因の分かりにくいエラーになる行が2つあります。

  • User-Agent … 無いと、既定の名前を機械的なアクセスとみなして403で弾くサーバーやプラグインがあります。名乗る名前は何でも構いません
  • try/except … 例外をここで受けて「(ステータス, 本文)」の形にそろえると、手順7の終了コードにそのままつながります

成功するとステータスは201(更新は200)で、返ってきたJSONに投稿ID(id)が入っています。
このIDを表の「既存投稿の更新」のURLに入れれば、上書きできます。

auth_header と post_article を1つのファイル(例: toukou.py)に写し、末尾に次を足します。
.env は同じフォルダに置きます。

# .env を読む(1行ずつ読み、最初の「=」で名前と値に分ける)
env = {}
with open(".env", encoding="utf-8-sig") as f:   # utf-8-sig: 先頭に付くことがある目印(BOM)を無視して読む
    for line in f:
        if "=" in line:
            name, value = line.strip().split("=", 1)
            env[name] = value

status, body = post_article(
    env["WP_URL"], env["WP_USER"], env["WP_APP_PASSWORD"],
    "テスト投稿", "<p>動作確認用の下書きです。</p>", "test-post")
print(status, body)

実行は、ファイルを置いたフォルダをエクスプローラーで開き、アドレス欄に「cmd」と打ってEnterを押します。
開いたコマンドプロンプトで python toukou.py と打ちます。

うまくいったかは、次の2つで確かめます。
画面の表示が 201 で始まり、id という項目と数字が出ていること。
管理画面の 投稿 → 投稿一覧 に「テスト投稿」という下書きが増えていることです。
確認できたら、この下書きは削除して構いません。

失敗したときの表示原因
FileNotFoundError.env のファイル名が .env.txt になっている(手順3)
URLError.env に書いたURLの打ち間違い
401 で始まるユーザー名かアプリケーションパスワードの間違い
403 で始まる窓口が塞がれている(手順1と、後半のセキュリティプラグインの項)

カテゴリを付けるときは、先に categories?search=名前 でIDを探します。
日本語名をそのままURLに入れると、通信の前に UnicodeEncodeError で落ちるので、URL用の表記に変換します。

import urllib.parse

# 日本語名はそのままURLに入れると UnicodeEncodeError で落ちる
q = urllib.parse.quote("手順・解決")
path = "/wp-json/wp/v2/categories?search=" + q

search は部分一致なので、返ってきた名前が完全一致か確認してから使います。
見つからなければPOSTで作り、payload に "categories": [ID] を足します(IDは配列で渡します)。
カテゴリの新規作成には「編集者」以上の権限が要ります。
投稿者権限で動かすなら、カテゴリは管理画面で先に作り、スクリプトは割り当てだけにします。

抜粋は payload に “excerpt” キーを足すだけです。
アイキャッチは2段階で、画像を media にPOSTし、返ってきたIDを投稿の featured_media に渡します。
このPOSTでは Content-Disposition(ファイル名)と Content-Type(image/png など)の両ヘッダが必須で、欠けると400が返ります。

要点:到達点は「公開」ではなく「下書き」

  • 自動で公開まで行く設計にすると、間違いに気づく機会が消える
  • 下書きで止めて、人がプレビューを見てから公開ボタンを押す
  • “status” を “publish” にすれば公開までできるが、既定にはしない

6. Markdownをブロック形式に変換する

素のHTMLでも表示はされますが、エディタで開くと本文全体が1つの「クラシックブロック」になり、後から一部だけ直しにくくなります。

ブロックエディタは、本文を「HTMLコメントで囲まれたHTML」として保存しています。

<!-- wp:heading -->
<h2>見出しの例</h2>
<!-- /wp:heading -->

<!-- wp:paragraph -->
<p>段落の例です。</p>
<!-- /wp:paragraph -->

変換は、Markdownを行単位で読み、行頭の記号(##、-、1.、>、| など)で種類を判定し、HTMLにしてこのコメントで包むだけです。

ブロックの種類によっては、コメントの中にブロック属性というJSONが必須です。
包む関数は、属性を渡せる形で作ります。

import json

def block(name, html, attrs=None):
    """HTMLをブロックコメントで包む。attrs はブロック属性(JSONとしてコメントに入る)"""
    a = " " + json.dumps(attrs) if attrs else ""
    return f"<!-- wp:{name}{a} -->\n{html}\n<!-- /wp:{name} -->"

# h2 は属性なしで通る
block("heading", "<h2>見出し</h2>")

# h3 以降の見出しは {"level": 3} が必須
block("heading", "<h3>小見出し</h3>", {"level": 3})

# 番号リストは {"ordered": true} が必須(Pythonの True が JSONの true になる)
block("list", "<ol><li>手順1</li><li>手順2</li></ol>", {"ordered": True})

h2と普通の箇条書きは属性なしで通ります。
h3以降の見出しと番号リストは、属性が無いと編集画面で「このブロックには、想定されていないか無効なコンテンツが含まれています」という警告になります。
属性の要らないブロックだけでテストすると全部通ってしまうので、h3と番号リストを含めて確認します。

どの属性が要るかは、管理画面で目的の形のブロックを手で1つ作り、エディタをコードエディター表示に切り替えて、保存されている形を見るのが確実です。

対応する記法は手元の原稿に出てくるものだけで構いませんが、見出し・段落・箇条書き・番号リスト・コードブロック・引用・表・区切り線まであれば、技術記事はほぼ書けます。

7. 終了コードを3値にする

終了コード意味例
0成功下書き投稿が完了した
1失敗認証エラー・記事ファイルの不備
2設定不足.env が未記入・見つからない

ステータスが200番台なら0、それ以外なら1、.env が読めなければ通信せずに2を返します。
要点は、2を成功として扱わないことです。
「設定が無くて何もしなかった」を成功に混ぜると、投稿されていないのに正常終了に見えるからです。

つまずきやすい箇所

テーマ独自の装飾ボックスは崩れる

Cocoonの「タブ付きボックス」のようなテーマ独自の装飾は、その構造をブロックの中に直接書いて投稿すると崩れます。
ラベルが空のタブになり、テキストは枠の中に平文で並びます。
ブロックの内側に、ブロックコメントで囲まれていない生の要素を置くと、ブロックとして扱われないためです。

対処は、タブ付きの形の再現をやめて、タイトルを枠内の最初の段落ブロック(太字)として入れることです。
見た目は一段地味になりますが、崩れません。
クリックで開閉する折りたたみなら、WordPress標準の details ブロックで作れます(WordPress 6.3以降)。

画像のファイル名が記号列になる/同じ画像が積み上がる

日本語のファイル名のままアップロードすると、URLが「%E3%82%A2…」のような長い記号列になります。
記事のslugから「記事slug-eyecatch.png」のような名前を組み立てると、英数字になります。

REST APIでは、同じファイルを送れば送った回数だけ別のメディアとして登録されるので、再投稿のたびに画像も送ると、同じ画像が積み上がります。
アップロード済みのメディアIDと更新日時をJSONファイルに控えておき、変わっていなければ送信を飛ばして控えたIDを使い回します。

セキュリティプラグインがREST APIを塞いでいることがある

塞がれた状態では、このスクリプトは認証以前の段階で失敗します。
手順1の「/wp-json/ をブラウザで開く」確認はこのためのものです。
外部から投稿する運用を続けるなら、REST APIの無効化はオフのままにします。
そのうえで、ログインURLの変更や試行回数制限といった他の防御で守ります。

このやり方でできないこと

  • 更新する投稿のIDは人が控えて指定します。原稿と投稿IDの対応表を自動管理する仕組みは扱っていません
  • ネストしたリスト(字下げした子項目)には対応していません。原稿側でリストを1段に保って回避します
  • 本文のコードで動くのは、下書き1件の投稿までです
  • 手順6の変換の本体(原稿を1行ずつ読んで block() に渡す部分)・原稿ファイル名の指定(コマンドライン引数)・手順7の入り口は、自分で組み立てる部分として残っています

この記事の確認範囲

この記事のスクリプトは、Python 3.10 と エックスサーバー上の WordPress 7.0.4(テーマ Cocoon) で実際に動かし、投稿できることを確かめたものです。
実行環境は Windows 11 Home(バージョン10.0.26200)1台 です。

  • サーバーやセキュリティ系プラグインの設定で、REST APIが止められていることがあります(その場合はこの方法自体が使えません)
  • WordPressのバージョンによって、アプリケーションパスワードの画面の場所が変わることがあります
  • 確かめたのは下書き投稿までです。予約投稿と、原稿と投稿IDの対応の自動管理は扱っていません

コメント

タイトルとURLをコピーしました