🤖

Obsidian→WordPress自動投稿システムの設計と実装【Python + wp-cli】

に公開

Obsidian→WordPress自動投稿システムの設計と実装

今回作ったPythonプログラムはこちらで公開しています!
https://github.com/ktr17/markdown-to-wordpress

想定読者

  • Obsidian + WordPressの連携を自動化したい方

インストールと使い方

詳細な導入手順と使い方は、以下の記事で解説しています:

📝 【Obsidian】MarkdownからWordpressへアップロードするプログラムを作ってみた!

本記事では、技術的な実装の詳細に焦点を当てて解説します。

はじめに:なぜこのツールが必要だったのか

私はObsidianでPKMを管理しています。PKMをもとにWorkdpressのブログ記事を執筆しているのですが、MarkdownをWordpressに毎回コピペするという作業が発生していました。
記事内で画像がある場合は、Wordpressへアップロードしたうえで画像リンクの修正も必須でした。

このままだとWordpressのメンテナンス作業が手間になり、ブログが楽しく続けられないと感じました。

また、現状の仕組みでは記事を修正したいと思ったときに、ローカルのObsidianを編集してしまうとWordpressに部分的にマージする必要が出てきました。

なので、ローカルのObsidian上のファイルを正として、
Wordpressにそのまま記事をアップするだけという仕組みにできないかと思って、今回のツールを開発しました。

要件定義:何を実現したかったのか

機能要件

必須機能

  1. Markdown → Gutenbergブロック変換

    • 見出し、段落、リスト、コードブロック対応
    • WordPressのブロックエディタ形式で投稿
  2. 画像管理

    • ローカル画像の自動アップロード
    • 重複アップロード防止
    • ファイル名の自動リネーム(記事タイトルに連動)
  3. 記事更新対応

    • 新規投稿 vs 既存記事更新の自動判定
  4. メタデータ管理

    • カテゴリ、タグの設定
    • アイキャッチ画像設定
    • 公開/非公開制御

こだわりポイント

画像ファイル名の問題

Obsidianでスクリーンショットを大量に保存すると、
ファイル名が 2025-01-15 at 14.23.45.png などバラバラになります。

もちろんObsidianの拡張機能で自動renameをすることができるのですが、ブログにする場合は記事と画像が対応付けられているようにしておきたいと思いました。

WordPress上でこの名前のまま管理すると、どの記事の画像か
わからなくなってしまいます。

解決策

記事タイトルを使ってobsidian-wordpress-guide-01.png のようにUploadする画像はリネームします。

画像リンク管理の設計思想

もしWordPressが消滅したときのことも考慮し、 ローカルのMarkdownでは元の画像パスを保持します。

  • WordPress上:https://example.com/wp-content/uploads/image.png
  • ローカルパス:![](Attatched/記事タイトル.png)

Front-matterで画像のローカルパスとWordpress上のURLを紐付けることで、双方向の整合性を保つことにします。

例

wp_images:
  e9e1f1bb:
    id: '3159'
    url: https://<wordpress>画像.png
    original_path: Attatched/Obsidian-01.png

フロントマターに記事内容に直接関係ない情報が付与されてしまいますが、画像と記事を紐づけるためにも必須としました。

技術選定

MarkdownからGutenbergへの変換

pandoc + HTML(不採用)

最初に検討したのは、以下の構成でした。

# Markdown → HTML変換
pandoc input.md -o output.html

# サーバへ転送
scp output.html user@server:/path/

# サーバ上でWordPressにインポート
wp-cli.phar media import output.html

問題点:
HTMLをそのまま使うと、WordPress上でクラシックエディタとして
認識されてしまいました。

WordPressは将来的にクラシックエディタのサポートが終了される可能性が
発表しているため、長期運用を考えるとGutenberg形式が必須です[1]。

Pythonスクリプトを自作(採用)

最終的に、Markdownを直接Gutenberg形式にするためのPythonスクリプトをAIでコーディングしました。

その他として、Wordpressではショートコードなど自作したいことがあったのですが、pandocでは出力結果の改造が困難だったことも理由の1つでした。


Wordpressへのインポート

wp-cli(採用)

wp-cliとは、
コマンドラインでWordPressを操作できるツールです。

# 記事投稿の例
wp-cli.phar post create --post_title="タイトル" --post_content="本文"

採用理由:

  1. Gutenbergブロック形式で投稿可能
  2. SSH経由でサーバ上のWordPressを直接操作
  3. 豊富なコマンド(カテゴリ、タグ、メディア管理も可能)

参考にした記事:
Wordpressの管理画面内からwp-cliコマンドを直接実行できるようにする機能をfunction.phpに書くだけで実装する方法

システムアーキテクチャ

メインフロー

画像処理フロー

内部リンク変換フロー

Gutenberg変換フロー

Front-matter管理

フィールド 管理者 役割 例
title ユーザー 記事タイトル 記事タイトル
slug ユーザー URLスラッグ article-slug
private ユーザー 公開/非公開制御 false
tags ユーザー タグ [Python, WordPress]
categories ユーザー カテゴリ プログラミング
featured_image ユーザー アイキャッチ画像パス images/thumb.png
wp_id システム WordPress記事ID(更新判定に使用) 1234
wp_images システム 画像ハッシュ→URL/ID/パスマッピング ハッシュ値がキー
wp_images:
  abc12345:  # MD5ハッシュ値(8文字)
    id: "5678"  # WordPressメディアID
    url: "https://example.com/wp-content/uploads/article-slug-01.png"  # WordPress URL
    original_path: "images/screenshot-01.png"  # ローカルパス
  def67890:
    id: "5679"
    url: "https://example.com/wp-content/uploads/article-slug-02.png"
    original_path: "images/screenshot-02.png"

重要なポイント

  1. wp_idの有無で処理が変わる
    • wp_idなし → 新規投稿
    • wp_idあり → 既存記事更新
  2. 画像の重複防止
    • ハッシュ値で画像の内容を識別
    • wp_imagesにハッシュがあれば既存URLを使用
    • なければ新規アップロード
  3. ローカルが常に正
    • ユーザーはローカルのMarkdownだけを編集
    • システムが自動的にFront-matterを更新
    • WordPressとの整合性を自動維持

実装の難所と解決策

難所1:画像の重複防止ロジック

問題

同じ画像を何度もWordPressにアップロードすると、メディアライブラリが肥大化します。
また、ローカルのファイル名をリネームすることも考えられます。ファイル名で重複をチェックしていると、このときに同じ画像がアップロードされてしまいます。

解決策:ハッシュ値による管理

ステップ1:画像のハッシュ値を計算

import hashlib

def calculate_image_hash(image_path):
    """画像ファイルのMD5ハッシュを計算"""
    with open(image_path, 'rb') as f:
        return hashlib.md5(f.read()).hexdigest()

ステップ2:Front-matterに保存

wp_img:
  a3f5e8d9c1b2:  # ハッシュ値
    id: '1234'
    url: 'https://example.com/wp-content/uploads/image.png'
    original_path: '40-Attached/screenshot.png'

ステップ3:アップロード前にチェック

# WordPress用処理
file_hash = get_file_hash(new_abs_path)

# front-matterに画像パスがあるかを確認
if file_hash in image_map:
		# 既存画像を使用
		wp_info = image_map[file_hash]
		wp_url = wp_info['url']
		# original_path を最新に更新
		image_map[file_hash]['original_path'] = new_local_path
else:
		# 新規アップロード
		media_id, wp_url = upload_new_image(config, new_abs_path, safe_slug, file_hash)
		image_map[file_hash] = {
				'id': media_id,
				'url': wp_url,
				'original_path': new_local_path
		}

メリット:

  • ファイル名が変わっても、内容が同じなら重複と判定できる
  • 画像を編集した場合は、ハッシュ値が変わるので新規アップロード

難所2:画像ファイル名のリネーム

問題

スクリーンショットのファイル名:
CleanShot 2025-01-15 at 14.23.45.png

このままWordPressにアップロードすると管理が大変。

解決策:記事タイトルに連動してリネーム

def rename_local_image(original_path, new_filename):
    """ローカル画像ファイルをリネーム"""
    original_dir = os.path.dirname(original_path)
    new_path = os.path.join(original_dir, new_filename)

    # 既に同名ファイルが存在する場合は何もしない
    if original_path == new_path:
        return new_path

    # リネーム実行
    if os.path.exists(original_path):
        # 移行先に既にファイルが存在する場合の対処
        if os.path.exists(new_path):
            # ハッシュを比較して同じファイルかチェック
            if get_file_hash(original_path) == get_file_hash(new_path):
                os.remove(original_path)
                return new_path
            else:
                return original_path
        else:
            shutil.move(original_path, new_path)
            return new_path

    return original_path

実装時の注意:

  • 既にリネーム済みの画像は処理をスキップ
  • ローカルファイルもリネームする(Markdownリンクも更新)

難所3:Obsidian内部リンクのWordPress化

問題

Obsidianでは[リンクテキスト](記事.md)という形式で内部リンクを作成しますが、
このままWordPressに投稿すると、リンクが機能しません。

解決策:Gutenbergカスタムブロックへの変換

処理フロー:

  1. Markdown内の内部リンク [text](article.md) を検出
  2. リンク先の.mdファイルを解決(Obsidianパス解決ロジック使用)
  3. リンク先のFront-matterからwp_idを取得
  4. Gutenbergカスタムブロック wp:loos/post-link に変換

実装例:

def process_internal_links(config, md_file, text):
    """内部リンクをWordPressブロックに変換"""
    link_pattern = r'\[([^\]]+)\]\(([^)]+)\)'
    
    for match in re.finditer(link_pattern, text):
        link_text = match.group(1)
        link_path = match.group(2)
        
        # .mdファイルへのリンクのみ処理
        if not link_path.endswith('.md'):
            continue
            
        # リンク先ファイルを解決
        abs_link_path = resolve_markdown_link_path(md_file, link_path)
        if not abs_link_path:
            continue
            
        # リンク先のwp_idを取得
        link_data = get_wordpress_link_data_from_md(config, abs_link_path)
        if not link_data:
            continue
            
        # Gutenbergブロックを生成
        gutenberg_link = f'<!-- wp:loos/post-link {{"linkData":{json.dumps(link_data)}}} /-->'
        text = text.replace(match.group(0), gutenberg_link)
    
    return text

メリット:

  • ObsidianのリンクがWordPress上でも機能する
  • SEO対策として内部リンクを適切に保持
  • リンク先の記事が未投稿の場合は自動でスキップ

難所4:脚注のGutenberg標準形式対応

問題

Markdownの脚注記法 [^1] と脚注定義 [^1]: 内容 をWordPressで
正しく表示する必要があります。

解決策:WordPress標準の脚注ブロック使用

処理フロー:

  1. 脚注定義 [^id]: 内容 を抽出して本文から削除
  2. 本文中の脚注参照 [^id] を検出
  3. 各脚注にUUID v4を割り当て
  4. wp:footnotes ブロックを文末に追加
  5. post_metaのfootnotesフィールドにJSON形式で保存

実装例:

def extract_footnotes(markdown_content: str) -> Tuple[str, Dict[str, str]]:
    """脚注定義を抽出"""
    footnotes = {}
    lines = markdown_content.split('\n')
    content_lines = []
    
    for line in lines:
        match = re.match(r'^\[\^([^\]]+)\]:\s*(.+)$', line)
        if match:
            footnote_id = match.group(1)
            footnote_text = match.group(2)
            footnotes[footnote_id] = footnote_text
        else:
            content_lines.append(line)
    
    return '\n'.join(content_lines), footnotes

def create_footnotes_block(footnotes, footnote_order):
    """WordPress標準の脚注ブロックを生成"""
    return '<!-- wp:footnotes /-->'

WordPress側の設定:

# post_meta に脚注データを保存
footnotes_meta = [
    {
        'id': 'uuid-1234-5678',
        'content': '脚注の内容'
    }
]
footnotes_json = json.dumps(footnotes_meta, ensure_ascii=False)
subprocess.run(f"wp post meta set {post_id} footnotes '{footnotes_json}'")

メリット:

  • WordPress標準機能を使用(プラグイン不要)
  • テーマ変更にも対応
  • 脚注内のURLも自動リンク化

難所5:Obsidianの柔軟なパス解決

Obsidianは複数の画像パス形式をサポートしているため、
それらすべてに対応する必要があります。

対応パス形式:

  1. Vault内絶対パス: images/screenshot.png
  2. 相対パス: ./images/screenshot.png または ../images/screenshot.png
  3. URLエンコード: Pasted%20image%2020240210.png

解決アルゴリズム:

def resolve_image_path(md_file, img_path):
    """Obsidianの画像パスを解決"""
    # URLデコード
    decoded_path = unquote(img_path)
    
    # 試行するパス
    paths_to_try = [img_path, decoded_path]
    
    for current_path in paths_to_try:
        # 1. 相対パスとして試行
        if current_path.startswith('./') or current_path.startswith('../'):
            abs_path = os.path.normpath(os.path.join(md_dir, current_path))
            if os.path.exists(abs_path):
                return abs_path
        
        # 2. Vault内絶対パスとして試行
        vault_root = find_obsidian_vault_root(md_file)
        if vault_root:
            vault_abs_path = os.path.join(vault_root, current_path)
            if os.path.exists(vault_abs_path):
                return vault_abs_path
        
        # 3. 通常の相対パス
        normal_relative = os.path.join(md_dir, current_path)
        if os.path.exists(normal_relative):
            return normal_relative
    
    return None

Vault rootの検出:

def find_obsidian_vault_root(md_file):
    """`.obsidian`フォルダを探してVault rootを特定"""
    current_dir = os.path.dirname(os.path.abspath(md_file))
    
    while current_dir != os.path.dirname(current_dir):
        obsidian_dir = os.path.join(current_dir, '.obsidian')
        if os.path.exists(obsidian_dir):
            return current_dir
        current_dir = os.path.dirname(current_dir)
    
    return None

Front-matterによるメタデータ管理

設計思想

Markdownファイル自体に、WordPressとの紐付け情報を保存することで、
「ローカルが真実の源泉」を実現します。

フィールド設計

ユーザー設定項目

---
title: "記事タイトル"
slug: "article-slug"  # URLスラッグ
private: false  # true = 非公開
categories:
  - Obsidian
  - WordPress
tags:
  - 自動化
  - Python
featured_image: "40-Attached/thumbnail.png"  # アイキャッチ
---

システム管理項目(自動更新)

wp_id: 1234  # WordPress記事ID
wp_img:
  a3f5e8d9c1b2:  # 画像ハッシュ
    id: '5678'
    url: 'https://example.com/wp-content/uploads/image.png'
    original_path: '40-Attached/screenshot.png'

実装

Front-matterの読み込み:

def parse_frontmatter(md_file):
    """Markdownファイルのフロントマターを解析"""
    with open(md_file, encoding="utf-8") as f:
        text = f.read()
    m = re.match(r"---\n(.*?)\n---", text, re.S)
    return yaml.safe_load(m.group(1)) if m else {}

Front-matterの更新:

def write_frontmatter(md_file, fm):
    """フロントマターをMarkdownファイルに書き込み"""
    with open(md_file, encoding="utf-8") as f:
        text = f.read()
    new_fm = yaml.dump(fm, allow_unicode=True, sort_keys=False)
    if text.startswith("---"):
        new_text = re.sub(r"---\n.*?\n---", f"---\n{new_fm}---", text, flags=re.S)
    else:
        new_text = f"---\n{new_fm}---\n\n{text}"
    with open(md_file, "w", encoding="utf-8") as f:
        f.write(new_text)

工夫点:

  • 画像リンクなどのYAMLフォーマットを保持する
  • ユーザーが手動で編集することも考慮
  • 既存のフィールドは削除しない

作ってみての感想

  • 今回はAIで全部のコーディングをしてもらった
  • かなり細かいところまでこだわった仕様にしていたので、3日ほどはかかったけどゼロから作るよりは圧倒的に早いと思う
  • 無料版のChatGPTとClaudeの両方を使ってみたけど、もっと使いやすくしたいと思ってAIへ課金するきっかけになった
  • 実際の使用感や導入手順はこちらで詳しく解説しています

関連記事

脚注
  1. https://ja.wordpress.org/plugins/classic-editor/
    クラシックエディタは2024年または必要なくなるまでサポートするとありますが、具体的にいつサポートが切れるかわからないため、使用は避けたいです。 ↩︎

Discussion