出張や旅行から帰ると、カメラのフォルダに数百枚の写真が残ります。これをブログの写真集記事にするのは、選ぶ・並べる・文章を書く・アップロードする、のどれもが面倒です。
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\5月Ghana
claude
② WordPress 側の準備
REST API を使うので、アプリケーションパスワードを発行します。通常のログインパスワードは使いません。
-
https://自分のサイト/wp-admin/profile.phpを開く - 「アプリケーションパスワード」欄に名前(
claude-codeなど)を入れて発行 - 表示される
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 情報がありました
[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 はこんな形式です。キャプションは画像の次の行に *…* で書くという約束にしておくと、後段のスクリプトがそのまま扱えます。
# 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日:コメンダとエルミナ

*ケープコーストの西約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
[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 を読んで、
-
を拾って画像をメディアライブラリへアップロード(タイトル・代替テキスト・キャプションも設定) - 画像の参照を、アップロード後の URL に差し替えた HTML へ変換
- 下書きとして記事を作成し、編集画面の 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(全文)
[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 '&','&' -replace '<','<' -replace '>','>'
} 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});)','&'
}
$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
# ---------------------------------------------------------------- 画像の抽出
#  の直後(空行を挟んで)に *キャプション* 行が来る前提
$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 を渡す口がなく、日英の関連付けができないためです。
- Claude Code に
article_jp.mdを英訳させ、article_en.mdを作る - WPML で英語版のひな形を作る(言語切替ボタンが自動生成されます)
-
wp-post.ps1 -Markdown .\article_en.md -DryRunでwp-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 は表示時にブロックコメントを取り除くので、レンダリング結果から保存形式は推測できません。生データを見るスクリプトを用意しておくと確実です。
[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 は使えない
r が Invoke-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 番のように、生データを見るスクリプトを作って確かめるのが結局は近道でした。