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?

GPSタグ付写真フォルダを丸ごと Claude Code に渡して、WordPress の写真集記事を投稿する

0
Posted at

出張や旅行から帰ると、カメラのフォルダに数百枚の写真が残ります。これをブログの写真集記事にするのは、選ぶ・並べる・文章を書く・アップロードする、のどれもが面倒です。

Claude Code に写真(GPSタグ付)フォルダごと渡して記事を書いてもらい、PowerShell スクリプトで WordPress に一発投稿する流れを作りました。実際に運用した手順とスクリプトを備忘録としてまとめます。

  • 対象: Windows 10/11 + PowerShell 7
  • 投稿先: 自前ホスティングの WordPress(REST API 使用)
  • 追加インストール: Claude Code のみ(exiftool や ImageMagick、Python は不要)

全体の流れ

写真フォルダ(DSCN3210.JPG … 528枚)
  │
  ├─ ① exif-gps.ps1     撮影日時と GPS 座標を一覧化
  │
  ├─ ② Claude Code       写真を見て記事を書いてもらう → article_jp.md
  │
  ├─ ③ make-hq.ps1       元画像から Web 用の高解像度 JPEG を生成 → post-hq/
  │
  ├─ ④ wp-post.ps1       画像アップロード+記事作成(下書き)
  │
  └─ ⑤ 英語版            Claude Code に翻訳させ、WPML の画面へ貼り付け

日本語版はスクリプトで完全自動、英語版は翻訳 Markdown を作って貼り付け、という分担にしています。理由は後述します。

① Claude Code のインストール

PowerShell を開いて 1 行です。管理者権限は不要です。

irm https://claude.ai/install.ps1 | iex

確認します。

claude --version

Git for Windows を入れておくと Bash ツールが使えるようになります(任意)。利用には Claude の Pro / Max などのプランが必要です。

写真フォルダに移動して起動し、初回はブラウザでログインします。

cd C:\work\2023\photo\5Ghana
claude

② WordPress 側の準備

REST API を使うので、アプリケーションパスワードを発行します。通常のログインパスワードは使いません。

  1. https://自分のサイト/wp-admin/profile.php を開く
  2. 「アプリケーションパスワード」欄に名前(claude-code など)を入れて発行
  3. 表示される xxxx xxxx xxxx xxxx xxxx xxxx をコピー(スペース込みでそのまま使えます)

アプリケーションパスワードは権限を絞れず、そのアカウントの全権限を持ちます。使い捨てと割り切り、投稿が終わったら同じ画面から失効させ、次回また作るのが安全です。後述のスクリプトは、履歴に残らないよう実行時に入力を求める作りにしてあります。

③ 撮影位置を一覧にする

一部のカメラや多くのスマホは EXIF に GPS 座標を持っています。これを先に一覧にしておくと、Claude が写真を場所と時系列で整理できます。

System.Drawing だけで読むので、exiftool のインストールは不要です。

# カメラの時計が日本時間のままだったので -9 時間で現地時刻に直す
.\exif-gps.ps1 -OffsetHours -9
File         Taken            Lat     Lon     Size
----         -----            ---     ---     ----
DSCN3215.JPG 2023-05-22 09:43 5.1236  -1.2502 4608x3456
DSCN3242.JPG 2023-05-24 09:25 5.0978  -1.3184 4608x3456
...
528 枚中 476 枚に GPS 情報がありました
exif-gps.ps1
[CmdletBinding()]
param(
    [string]$Path        = '.',
    # カメラの時計と現地時刻のずれ(現地時刻 = 撮影時刻 + OffsetHours)
    [double]$OffsetHours = 0,
    [string]$Csv
)

$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Drawing

# .NET API は PowerShell の現在位置を見ないため,相対パスを絶対パスへ直す
function Resolve-FullPath([string]$p) {
    $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($p)
}
[Environment]::CurrentDirectory = $PWD.ProviderPath
$Path = Resolve-FullPath $Path

# GPS の度分秒は 8 バイトの有理数(分子 4B + 分母 4B)が 3 組並ぶ
function Get-Rational([byte[]]$b, [int]$i) {
    $num = [BitConverter]::ToUInt32($b, $i * 8)
    $den = [BitConverter]::ToUInt32($b, $i * 8 + 4)
    if ($den -eq 0) { 0 } else { $num / $den }
}
function ConvertTo-Degrees([byte[]]$b) {
    (Get-Rational $b 0) + (Get-Rational $b 1) / 60 + (Get-Rational $b 2) / 3600
}

$rows = foreach ($f in Get-ChildItem -LiteralPath $Path -Filter *.jpg -File) {
    $img = $null
    try {
        $img = [Drawing.Image]::FromFile($f.FullName)
        $ids = $img.PropertyIdList

        # 0x9003 DateTimeOriginal
        $taken = $null
        if ($ids -contains 36867) {
            $s = [Text.Encoding]::ASCII.GetString($img.GetPropertyItem(36867).Value).Trim([char]0)
            $taken = [datetime]::ParseExact($s, 'yyyy:MM:dd HH:mm:ss', $null).AddHours($OffsetHours)
        }

        # 0x0001 GPSLatitudeRef / 0x0002 GPSLatitude / 0x0003 Ref / 0x0004 Longitude
        $lat = $lon = $null
        if (($ids -contains 2) -and ($ids -contains 4)) {
            $lat = ConvertTo-Degrees $img.GetPropertyItem(2).Value
            $lon = ConvertTo-Degrees $img.GetPropertyItem(4).Value
            if ($ids -contains 1) {
                if ([char]$img.GetPropertyItem(1).Value[0] -eq 'S') { $lat = -$lat }
            }
            if ($ids -contains 3) {
                if ([char]$img.GetPropertyItem(3).Value[0] -eq 'W') { $lon = -$lon }
            }
        }

        # Lat/Lon は文字列にする.数値のままだと Format-Table が
        # 小数第2位までしか表示せず,座標が読み取れない.
        [pscustomobject]@{
            File  = $f.Name
            Taken = if ($taken) { $taken.ToString('yyyy-MM-dd HH:mm') } else { '' }
            Lat   = if ($null -ne $lat) { '{0:F4}' -f $lat } else { '' }
            Lon   = if ($null -ne $lon) { '{0:F4}' -f $lon } else { '' }
            Size  = '{0}x{1}' -f $img.Width, $img.Height
        }
    }
    catch { Write-Warning "$($f.Name): $($_.Exception.Message)" }
    finally { if ($img) { $img.Dispose() } }
}

$rows | Format-Table -AutoSize | Out-Host

$withGps = @($rows | Where-Object Lat).Count
Write-Host "`n$($rows.Count) 枚中 $withGps 枚に GPS 情報がありました" -ForegroundColor Cyan

if ($Csv) {
    $rows | Export-Csv -LiteralPath (Resolve-FullPath $Csv) -NoTypeInformation -Encoding UTF8
    Write-Host "CSV を保存しました: $Csv" -ForegroundColor Green
}

$rows

④ 記事を書いてもらう

写真フォルダで claude を起動し、こう頼みます。

このフォルダの写真を見て、WordPress の写真集記事を Markdown で書いてください。
- 写真は 10 枚程度
- exif-gps.ps1 で撮影位置と時刻が出ます
- 既存記事の書き方に合わせてください: https://example.com/2023/08/sample/
- Google Map を埋め込みたいです
- 出力は article_jp.md

Claude Code は JPEG を直接読めるので、実際に写真を見て選びます。528 枚から 13 枚を選び、場所ごとの見出しと本文、キャプション、座標まで入った Markdown が出てきます。

出力される Markdown はこんな形式です。キャプションは画像の次の行に *…* で書くという約束にしておくと、後段のスクリプトがそのまま扱えます。

article_jp.md
# 2023.5.20-29 ガーナ海岸出張 ― ケープコースト,アクラ,ヴォルタ州

2023年5月20日から29日まで,ガーナ共和国を訪れました.…

<iframe src="https://maps.google.com/maps?q=5.6,-0.1&z=8&output=embed"
        width="100%" height="450" style="border:0;" loading="lazy"></iframe>

---

### 5月24日:コメンダとエルミナ

![コメンダの砦跡と漁村](DSCN3271.JPG)

*ケープコーストの西約30 kmのコメンダ(Komenda).正面の黒い石積みは1682年にオランダが築いた
フォート・フレーデンブルフの跡で,そのすぐ足元まで漁村と漁船が寄せています.(5.0491, -1.4894)*

ここが一番おもしろいところで、地名の確認や、地形の説明が物理的に正しいかといった議論もその場でできます。私の場合は「突堤の風下側に砂が溜まる」と書かれていたのを、沿岸漂砂は西から東なので溜まるのは上手側(西側)だ、と直してもらいました。専門用語も、思いつきで訳語を当てていないか確認させるとよいです。

⑤ Web 用の画像を作る

元画像(4608×3456)をそのまま上げると重いので、リサイズします。ここでも外部ツールは使いません。

長辺 2560px は WordPress の big_image_size_threshold と同じ値で、これを超えて上げても WordPress 側で 2560px に縮小されます。1440px 程度の縮小版から作り直すと再圧縮が二重にかかるので、必ず元画像から作ります

なお System.Drawing で書き出すと EXIF は引き継がれません。撮影位置は記事本文に自分で書いた座標だけになり、自宅などの GPS が画像に残らないので、公開用としてはむしろ好都合です。

.\make-hq.ps1
make-hq.ps1
[CmdletBinding()]
param(
    [string]$SrcDir  = "$PSScriptRoot",
    [string]$DstDir  = "$PSScriptRoot\post-hq",
    [int[]] $Numbers = @(3215,3242,3271,3283,3346,3428,3432,3487,3611,3499,3557,3530,3583),
    [int]   $MaxEdge = 2560,
    [int]   $Quality = 92
)

$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Drawing

function Resolve-FullPath([string]$p) {
    $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($p)
}
[Environment]::CurrentDirectory = $PWD.ProviderPath
$SrcDir = Resolve-FullPath $SrcDir
$DstDir = Resolve-FullPath $DstDir

New-Item -ItemType Directory -Force $DstDir | Out-Null

$codec = [Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() |
         Where-Object MimeType -eq 'image/jpeg'
$encParams = [Drawing.Imaging.EncoderParameters]::new(1)
$encParams.Param[0] = [Drawing.Imaging.EncoderParameter]::new(
    [Drawing.Imaging.Encoder]::Quality, [int64]$Quality)

foreach ($n in $Numbers) {
    $src = Join-Path $SrcDir "DSCN$n.JPG"
    if (-not (Test-Path $src)) { Write-Warning "見つかりません: $src"; continue }

    $img = [Drawing.Image]::FromFile($src)
    try {
        # EXIF Orientation を反映(縦位置で撮影された写真への保険)
        if ($img.PropertyIdList -contains 274) {
            switch ([BitConverter]::ToUInt16($img.GetPropertyItem(274).Value, 0)) {
                3 { $img.RotateFlip('Rotate180FlipNone') }
                6 { $img.RotateFlip('Rotate90FlipNone')  }
                8 { $img.RotateFlip('Rotate270FlipNone') }
            }
        }

        $scale = $MaxEdge / [Math]::Max($img.Width, $img.Height)
        if ($scale -gt 1) { $scale = 1 }          # 拡大はしない
        $w = [int][Math]::Round($img.Width  * $scale)
        $h = [int][Math]::Round($img.Height * $scale)

        $bmp = [Drawing.Bitmap]::new($w, $h, [Drawing.Imaging.PixelFormat]::Format24bppRgb)
        $g   = [Drawing.Graphics]::FromImage($bmp)
        $g.InterpolationMode  = 'HighQualityBicubic'
        $g.SmoothingMode      = 'HighQuality'
        $g.PixelOffsetMode    = 'HighQuality'
        $g.CompositingQuality = 'HighQuality'
        # 縁のにじみを防ぐ
        $attr = [Drawing.Imaging.ImageAttributes]::new()
        $attr.SetWrapMode([Drawing.Drawing2D.WrapMode]::TileFlipXY)
        $g.DrawImage($img, [Drawing.Rectangle]::new(0,0,$w,$h),
                     0, 0, $img.Width, $img.Height, [Drawing.GraphicsUnit]::Pixel, $attr)
        $g.Dispose(); $attr.Dispose()

        $dst = Join-Path $DstDir "DSCN$n.JPG"
        $bmp.Save($dst, $codec, $encParams)
        $bmp.Dispose()

        '{0}  {1}x{2}  ->  {3}x{4} ({5} KB)' -f "DSCN$n", $img.Width, $img.Height,
            $w, $h, [int]((Get-Item $dst).Length / 1KB)
    }
    finally { $img.Dispose() }
}

⑥ WordPress へ投稿する

本体です。Markdown を読んで、

  1. ![alt](FILE.JPG) を拾って画像をメディアライブラリへアップロード(タイトル・代替テキスト・キャプションも設定)
  2. 画像の参照を、アップロード後の URL に差し替えた HTML へ変換
  3. 下書きとして記事を作成し、編集画面の URL を表示

します。コピペは一切ありません。

.\wp-post.ps1 -Markdown .\article_jp.md -ImageDir .\post-hq `
              -SlugPrefix ghana-2023 -Site https://example.com

# 本文だけ直して既存記事を差し替える(画像は再利用されます)
.\wp-post.ps1 -Markdown .\article_jp.md -ImageDir .\post-hq -UpdatePostId 7263

# 生成される HTML だけ確認する
.\wp-post.ps1 -Markdown .\article_jp.md -DryRun

ユーザー名とアプリケーションパスワードは実行時に訊かれます。-AsSecureString で受けるので、画面にもシェル履歴にも残りません。

wp-post.ps1(全文)
wp-post.ps1
[CmdletBinding()]
param(
    [string]$User,
    [string]$AppPassword,

    [string]$Site        = 'https://example.com',
    [string]$Markdown    = "$PSScriptRoot\article_jp.md",
    [string]$ImageDir    = "$PSScriptRoot\post-hq",

    # アップロード時のファイル名 <prefix>-01.jpg, -02.jpg ...
    [string]$SlugPrefix  = 'photo',

    # アイキャッチにする画像の元ファイル名(空ならアイキャッチなし)
    [string]$FeaturedImage = '',

    [ValidateSet('draft','publish','private')]
    [string]$Status      = 'draft',

    # 既存記事を差し替える場合はその記事 ID(省略時は新規作成)
    [int]$UpdatePostId,

    # 差し替え成功後に削除する旧メディアの ID
    [int[]]$DeleteMediaIds,

    # アップロード前に,同じ SlugPrefix の既存メディアを削除する.
    # 失敗した実行をやり直すときに -01-1.jpg のような重複が生じるのを防ぐ.
    [switch]$PurgeExisting,

    # 同名のメディアが既にあれば再アップロードせず使い回す(既定 ON)
    [bool]$ReuseExisting = $true,

    # 画像をクリックすると原寸が開くようにする(既定 ON)
    [bool]$LinkImages = $true,

    # classic : 素の HTML(既定・推奨)
    # blocks  : Gutenberg ブロック(<!-- wp:paragraph --> 等)
    [ValidateSet('classic','blocks')]
    [string]$Format = 'classic',

    [switch]$DryRun
)

$ErrorActionPreference = 'Stop'
$Site = $Site.TrimEnd('/')

# .NET の API は PowerShell の現在位置ではなくプロセス既定のディレクトリを
# 基準にするため,ここで必ず絶対パスに直しておく.
function Resolve-FullPath([string]$p) {
    $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($p)
}
[Environment]::CurrentDirectory = $PWD.ProviderPath
$Markdown = Resolve-FullPath $Markdown
$ImageDir = Resolve-FullPath $ImageDir

if (-not (Test-Path -LiteralPath $ImageDir)) { throw "画像フォルダが見つかりません: $ImageDir" }
if (-not (Test-Path -LiteralPath $Markdown)) { throw "Markdown が見つかりません: $Markdown" }

if (-not $DryRun) {
    if (-not $User) { $User = Read-Host 'WordPress ユーザー名' }
    if (-not $AppPassword) {
        # コマンドラインに書かず,ここで訊く(画面にもシェル履歴にも残らない)
        $sec = Read-Host 'アプリケーションパスワード' -AsSecureString
        $AppPassword = [Runtime.InteropServices.Marshal]::PtrToStringBSTR(
            [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec))
    }
    if (-not $User -or -not $AppPassword) { throw 'ユーザー名とアプリケーションパスワードが必要です.' }
}

$auth = 'Basic ' + [Convert]::ToBase64String(
    [Text.Encoding]::UTF8.GetBytes("${User}:${AppPassword}"))

function Invoke-WpJson {
    param([string]$Path, [string]$Method = 'Get', $Body)
    $p = @{
        Uri     = "$Site/wp-json/wp/v2/$Path"
        Method  = $Method
        Headers = @{ Authorization = $auth }
    }
    if ($null -ne $Body) {
        $p.ContentType = 'application/json; charset=utf-8'
        $p.Body        = [Text.Encoding]::UTF8.GetBytes(($Body | ConvertTo-Json -Depth 6 -Compress))
    }
    Invoke-RestMethod @p
}

function ConvertTo-Html([string]$s) {
    if ($Format -eq 'blocks') {
        $s = $s -replace '&','&amp;' -replace '<','&lt;' -replace '>','&gt;'
    } else {
        # classic では本文中に HTML を直接書けるようにするため < > は素通しし,
        # 実体参照になっていない & だけをエスケープする
        $s = $s -replace '&(?!(?:[A-Za-z][A-Za-z0-9]{1,10}|#\d{1,6}|#[xX][0-9A-Fa-f]{1,5});)','&amp;'
    }
    $s = $s -replace '\*\*([^*]+)\*\*','<strong>$1</strong>'
    # 斜体は _..._ を使う(* はキャプション行の区切りに使っているため)
    $s = $s -replace '(?<![A-Za-z0-9_])_([^_\s][^_]*?)_(?![A-Za-z0-9_])','<em>$1</em>'
    $s = $s -replace '\[([^\]]+)\]\(([^)]+)\)','<a href="$2">$1</a>'
    $s
}

$lines = Get-Content $Markdown -Encoding UTF8

# ---------------------------------------------------------------- 画像の抽出
# ![alt](FILE.JPG) の直後(空行を挟んで)に *キャプション* 行が来る前提
$images = @()
for ($i = 0; $i -lt $lines.Count; $i++) {
    if ($lines[$i] -match '^!\[(?<alt>.*?)\]\((?<file>[^)]+)\)\s*$') {
        # $Matches は後続の -match で上書きされるので,先に退避する
        $alt  = $Matches.alt
        $file = $Matches.file

        $cap = ''; $capLine = -1
        for ($j = $i + 1; $j -lt [Math]::Min($i + 4, $lines.Count); $j++) {
            if ($lines[$j] -match '^\*(?<c>.+)\*\s*$') { $cap = $Matches.c; $capLine = $j; break }
        }
        $images += [pscustomobject]@{
            Line = $i; CapLine = $capLine; Alt = $alt; File = $file; Caption = $cap
        }
    }
}
Write-Host "画像: $($images.Count) 枚" -ForegroundColor Cyan

# ---------------------------------------------------------------- 既存メディアの掃除
if ($PurgeExisting -and -not $DryRun) {
    $stale = Invoke-WpJson "media?search=$SlugPrefix&per_page=100&_fields=id,source_url"
    $stale = @($stale | Where-Object { [IO.Path]::GetFileName($_.source_url) -like "$SlugPrefix-*" })
    foreach ($m in $stale) {
        $null = Invoke-RestMethod -Uri "$Site/wp-json/wp/v2/media/$($m.id)?force=true" `
                    -Method Delete -Headers @{ Authorization = $auth }
        Write-Host "  削除: $($m.id)"
    }
}

# ---------------------------------------------------------------- 既存メディアの索引
$existing = @{}
if ($ReuseExisting -and -not $PurgeExisting -and -not $DryRun) {
    $found = Invoke-WpJson "media?search=$SlugPrefix&per_page=100&_fields=id,source_url"
    foreach ($m in @($found)) {
        $existing[[IO.Path]::GetFileName($m.source_url)] = @{ id = $m.id; url = $m.source_url }
    }
}

# ---------------------------------------------------------------- アップロード
$uploaded = @{}
$n = 0
foreach ($im in $images) {
    $n++
    $src = Join-Path $ImageDir $im.File
    if (-not (Test-Path $src)) { throw "画像が見つかりません: $src" }
    if ($uploaded.ContainsKey($im.File)) { continue }

    $newName = '{0}-{1:d2}{2}' -f $SlugPrefix, $n, [IO.Path]::GetExtension($im.File).ToLower()

    if ($DryRun) {
        $uploaded[$im.File] = @{ id = 0; url = "https://example.invalid/$newName" }
        continue
    }
    if ($existing.ContainsKey($newName)) {
        $uploaded[$im.File] = $existing[$newName]
        Write-Host "  再利用 ($n/$($images.Count)): $newName" -ForegroundColor DarkCyan
        continue
    }

    Write-Host "  アップロード中 ($n/$($images.Count)): $($im.File) -> $newName" -ForegroundColor Yellow

    # multipart/form-data で送る.raw バイナリを POST ボディに載せると
    # レンタルサーバの WAF が画像のバイト列を誤検知して 403 を返すことがある.
    # 送信ファイル名がそのままメディア名になるため,一時ファイルにコピーして名前を与える.
    $tmp = Join-Path ([IO.Path]::GetTempPath()) $newName
    Copy-Item -LiteralPath $src -Destination $tmp -Force
    try {
        $media = Invoke-RestMethod -Uri "$Site/wp-json/wp/v2/media" -Method Post `
            -Headers @{ Authorization = $auth } `
            -Form @{ file = Get-Item -LiteralPath $tmp }
    } finally {
        Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
    }

    $null = Invoke-WpJson "media/$($media.id)" 'Post' @{
        title = $im.Alt; alt_text = $im.Alt; caption = $im.Caption
    }
    $uploaded[$im.File] = @{ id = $media.id; url = $media.source_url }
}

# ---------------------------------------------------------------- 本文の変換
$title  = ''
$blocks = [Collections.Generic.List[string]]::new()
$para   = [Collections.Generic.List[string]]::new()

function Flush-Para {
    if ($para.Count -gt 0) {
        $t = ConvertTo-Html ($para -join '')
        if ($Format -eq 'blocks') {
            $blocks.Add("<!-- wp:paragraph -->`n<p>$t</p>`n<!-- /wp:paragraph -->")
        } else {
            # 段落タグは WordPress の wpautop が付けるので素のテキストのまま置く
            $blocks.Add($t)
        }
        $para.Clear()
    }
}

$skip = @{}
foreach ($im in $images) { $skip[$im.Line] = $true; if ($im.CapLine -ge 0) { $skip[$im.CapLine] = $true } }

$inHtml = $false
$htmlBuf = [Collections.Generic.List[string]]::new()

for ($i = 0; $i -lt $lines.Count; $i++) {
    $l = $lines[$i]

    if ($skip.ContainsKey($i)) {
        if ($l -match '^!\[') {
            Flush-Para
            $im  = $images | Where-Object Line -eq $i | Select-Object -First 1
            $u   = $uploaded[$im.File]
            $alt = ConvertTo-Html $im.Alt
            $cap = ConvertTo-Html $im.Caption
            # class="wp-image-<ID>" を必ず付ける.これが無いと WordPress が
            # srcset / sizes を自動生成できず,小さい版が表示されてしまう.
            $tag = "<img src=`"$($u.url)`" alt=`"$alt`" class=`"wp-image-$($u.id)`" />"
            if ($LinkImages) { $tag = "<a href=`"$($u.url)`">$tag</a>"; $dest = 'media' }
            else             { $dest = 'none' }

            if ($Format -eq 'blocks') {
                $fig = if ($cap) { "<figcaption class=`"wp-element-caption`">$cap</figcaption>" } else { '' }
                $blocks.Add(
                    "<!-- wp:image {`"id`":$($u.id),`"sizeSlug`":`"large`",`"linkDestination`":`"$dest`"} -->`n" +
                    "<figure class=`"wp-block-image size-large`">$tag$fig</figure>`n" +
                    "<!-- /wp:image -->")
            } else {
                # 画像の次の行にキャプションを置くだけ.
                # 改行は wpautop が <br /> に,前後の空行が <p> になる.
                $blocks.Add($(if ($cap) { "$tag`n$cap" } else { $tag }))
            }
        }
        continue
    }

    # 生 HTML(iframe など)はそのまま通す
    if ($l -match '^\s*<') { $inHtml = $true }
    if ($inHtml) {
        $htmlBuf.Add($l)
        if ($l -match '>\s*$' -and $l -match '</|/>') {
            Flush-Para
            if ($Format -eq 'blocks') {
                $blocks.Add("<!-- wp:html -->`n$($htmlBuf -join "`n")`n<!-- /wp:html -->")
            } else {
                $blocks.Add($htmlBuf -join "`n")
            }
            $htmlBuf.Clear(); $inHtml = $false
        }
        continue
    }

    switch -Regex ($l) {
        '^#\s+(.+)$'        { $title = $Matches[1].Trim(); break }
        '^(#{2,4})\s+(.+)$' {
            Flush-Para
            $lvl = $Matches[1].Length
            $tx  = ConvertTo-Html $Matches[2].Trim()
            if ($Format -eq 'blocks') {
                $blocks.Add("<!-- wp:heading {`"level`":$lvl} -->`n<h$lvl>$tx</h$lvl>`n<!-- /wp:heading -->")
            } else {
                $blocks.Add("<h$lvl>$tx</h$lvl>")
            }
            break
        }
        '^\s*---\s*$'       {
            Flush-Para
            if ($Format -eq 'blocks') {
                $blocks.Add("<!-- wp:separator -->`n<hr class=`"wp-block-separator`"/>`n<!-- /wp:separator -->")
            } else {
                $blocks.Add('<hr />')
            }
            break
        }
        '^\s*$'             { Flush-Para; break }
        default             { $para.Add($l.Trim()); break }
    }
}
Flush-Para

$content = $blocks -join "`n`n"

# ---------------------------------------------------------------- 出力・投稿
$preview = Join-Path $PSScriptRoot 'wp-post-preview.html'
[IO.File]::WriteAllText($preview, $content, [Text.UTF8Encoding]::new($false))
Write-Host "`n生成した記事 HTML: $preview" -ForegroundColor Cyan

if ($DryRun) { return }

$body = @{ title = $title; content = $content }
if ($FeaturedImage -and $uploaded.ContainsKey($FeaturedImage)) {
    $body.featured_media = $uploaded[$FeaturedImage].id
}

if ($UpdatePostId) {
    # 差し替え:公開状態は現状のまま(status は送らない)
    $post = Invoke-WpJson "posts/$UpdatePostId" 'Post' $body
    Write-Host "`n記事 $UpdatePostId を差し替えました" -ForegroundColor Green
} else {
    $body.status = $Status
    $post = Invoke-WpJson 'posts' 'Post' $body
    Write-Host "`n完了しました(status: $Status)" -ForegroundColor Green
}

Write-Host "  編集画面 : $Site/wp-admin/post.php?post=$($post.id)&action=edit"
Write-Host "  記事     : $($post.link)"

if ($DeleteMediaIds) {
    foreach ($id in $DeleteMediaIds) {
        try {
            # メディアはゴミ箱に入らないため force=true が必須
            $null = Invoke-RestMethod -Uri "$Site/wp-json/wp/v2/media/${id}?force=true" `
                        -Method Delete -Headers @{ Authorization = $auth }
            Write-Host "  削除: $id"
        } catch { Write-Warning "  削除失敗: $id" }
    }
}

あとはブラウザで編集画面を開き、公開ボタンを押すだけです。

⑦ 英語版

WPML を使っている場合、翻訳は貼り付けが確実です。REST API には wpml_language は渡せますが、翻訳グループを結びつける trid を渡す口がなく、日英の関連付けができないためです。

  1. Claude Code に article_jp.md を英訳させ、article_en.md を作る
  2. WPML で英語版のひな形を作る(言語切替ボタンが自動生成されます)
  3. wp-post.ps1 -Markdown .\article_en.md -DryRunwp-post-preview.html を作り、その中身を英語版の編集画面へ貼り付ける

画像は日本語版と同じものを参照するので、アップロードし直す必要はありません。

翻訳を頼むときは、キャプションの区切りに * を使っている関係で、斜体は _…_ で書くよう指示しておきます。

ハマったところ

1. Markdown を REST API で送っても Markdown のままにはならない

これに一番時間を使いました。

このサイトでは編集画面に ## 見出し と書けばちゃんと見出しになります。ところが同じ Markdown を REST API で送ると、そのまま文字として表示されてしまいます。

context=edit で保存されている生データを取って、ようやく理由がわかりました。

  • 編集画面で ## test と入力して保存 → DB には <h2>test</h2> が入っている
  • REST API で ## test を送信 → DB には ## test がそのまま入る

つまり Markdown → HTML の変換は編集画面の保存時に走っており、DB に入るのは常に HTML です。REST API はその変換を通らないので、スクリプト側で HTML を作って送るのが正解でした(-Format classic)。

紛らわしいのは、content.rendered を見ても判別できない点です。WordPress は表示時にブロックコメントを取り除くので、レンダリング結果から保存形式は推測できません。生データを見るスクリプトを用意しておくと確実です。

wp-dump.ps1
[CmdletBinding()]
param(
    [Parameter(Mandatory)][int]$PostId,
    [string]$User,
    [string]$AppPassword,
    [string]$Site  = 'https://example.com',
    [int]   $Lines = 20
)

$ErrorActionPreference = 'Stop'
$Site = $Site.TrimEnd('/')

if (-not $User) { $User = Read-Host 'WordPress ユーザー名' }
if (-not $AppPassword) {
    $sec = Read-Host 'アプリケーションパスワード' -AsSecureString
    $AppPassword = [Runtime.InteropServices.Marshal]::PtrToStringBSTR(
        [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec))
}
$auth = 'Basic ' + [Convert]::ToBase64String(
    [Text.Encoding]::UTF8.GetBytes("${User}:${AppPassword}"))

$p = Invoke-RestMethod -Uri "$Site/wp-json/wp/v2/posts/$PostId`?context=edit" `
        -Headers @{ Authorization = $auth }
$raw = $p.content.raw

Write-Host "投稿 $PostId : $($p.title.raw)" -ForegroundColor Cyan
Write-Host "本文の長さ: $($raw.Length) 文字`n"

$checks = [ordered]@{
    '<!-- wp: ブロックコメント' = ([regex]::Matches($raw,'<!-- wp:')).Count
    '<p> タグ'                  = ([regex]::Matches($raw,'<p>')).Count
    '<h2>/<h3> タグ'            = ([regex]::Matches($raw,'<h[23]')).Count
    '行頭の ## 見出し'          = ([regex]::Matches($raw,'(?m)^#{1,6}\s')).Count
    '<img タグ'                 = ([regex]::Matches($raw,'<img')).Count
}
foreach ($k in $checks.Keys) { '  {0,-26} : {1}' -f $k, $checks[$k] }

Write-Host "`n---- 保存されている本文の先頭 $Lines 行 ----" -ForegroundColor Cyan
($raw -split "`r?`n" | Select-Object -First $Lines) | ForEach-Object { "  |$_" }

2. class="wp-image-<ID>" を付けないと画像が汚くなる

<img src="..."> だけ書くと、WordPress が srcset / sizes を自動生成できません。すると高解像度ディスプレイでも小さい版が拡大表示され、明らかに眠い絵になります。このクラス 1 個で解決します。

3. 画像最適化プラグインが勝手に縮小する

EWWW Image Optimizer などは、アップロードされた画像を既定で長辺 1920px・品質 82 程度に再エンコードします。「元画像を上げたのに変わらない」ときはこれを疑ってください。プラグイン側の「アップロード時にリサイズ」の設定を確認します。

4. レンタルサーバの WAF に弾かれる

さくらインターネットで、画像アップロードが 403 になりました。サイズやレート制限ではなく、raw バイナリを POST ボディに載せると WAF がバイト列を誤検知していたのが原因です。multipart/form-data で送る(Invoke-RestMethod -Form)と管理画面のアップローダと同じ形になり、通るようになりました。

本文でも、英語の記事を投稿しようとすると 403 になることがあります。こちらはスコア型の判定らしく、英文が一定量を超えると弾かれ、書き換えでは回避できませんでした(日本語では起きません)。英語版を貼り付けにしているのは、この理由もあります。

5. .NET の相対パスは PowerShell の現在位置を見ない

Test-Path .\post-hq\a.jpg は通るのに [IO.File]::ReadAllBytes('.\post-hq\a.jpg')
C:\Program Files\PowerShell\7\post-hq\a.jpg を探しに行って落ちました。.NET は Environment.CurrentDirectory を基準にするためです。各スクリプトの冒頭で絶対パスに直しています。

function Resolve-FullPath([string]$p) {
    $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($p)
}
[Environment]::CurrentDirectory = $PWD.ProviderPath

6. 編集画面のタブを開いたままスクリプトを実行しない

古い内容を持ったままの編集タブが自動保存されると、スクリプトの更新を上書きしてしまいます。実行前にタブを閉じておきます。

7. PowerShell で関数名 R は使えない

rInvoke-History の組み込みエイリアスなので、定義したはずの関数が呼ばれずに履歴が実行されます。原因がわかりにくいので、1 文字の関数名は避けます。

おわりに

一度この形にしてしまえば、次の記事は

cd C:\work\2024\photo\どこか
claude          # 記事を書いてもらう
.\make-hq.ps1
.\wp-post.ps1 -Markdown .\article_jp.md -ImageDir .\post-hq -SlugPrefix somewhere-2024

で下書きまで終わります。写真を選んで文章を書く部分と、アップロードして体裁を整える部分の両方が、それぞれ一番面倒だったところです。

スクリプト自体も Claude Code に書かせています。うまくいかないときは、エラーをそのまま貼れば直してくれます。ただし WordPress の挙動のように外から推測できない仕様については、推測で答えが返ってくることがあります。上の 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?