背景
WordPress REST APIで同じファイル名の画像を再アップロードすると、上書きではなくscreenshot-2.pngのように連番が付いて増え続けます。これをMD5ハッシュベースの仕組みで解決しました。
1. 問題の原因:WordPressに「上書き」の概念がない
// 素朴な実装
const form = new FormData();
form.append('file', fs.createReadStream(imagePath));
await axios.post(`${WP_URL}/wp-json/wp/v2/media`, form, authHeader);
同じファイル名で複数回POSTすると、WordPressは誤って上書きしないための安全機構として自動的に連番を振ります。結果、screenshot.png → screenshot-2.png → screenshot-3.pngと増殖します。
2. 検討して却下した方法
-
タイムスタンプ付与:
screenshot-${Date.now()}.png→ 画像は増え続けたまま、ファイル名も分かりにくい - 既存画像を無条件で削除して再アップロード: 画像は増えないが、変更がなくても毎回削除・再アップロードが走り遅い
3. 採用した方法:内容のハッシュをファイル名に埋め込む
const crypto = require('crypto');
function calculateFileHash(filePath) {
const fileBuffer = fs.readFileSync(filePath);
const hashSum = crypto.createHash('md5');
hashSum.update(fileBuffer);
return hashSum.digest('hex').substring(0, 6); // 先頭6桁
}
同じ内容のファイルは常に同じハッシュ、1バイトでも変わればハッシュも変わるという性質を利用します。
元のファイル名: screenshot.png
↓ ハッシュ計算
ハッシュ: a3f2b1
↓
新しいファイル名: {スラッグ}-screenshot-a3f2b1.png
4. 既存画像の検索とハッシュ比較
async function findExistingImageByPattern(slug, originalFileName) {
const nameWithoutExt = path.basename(originalFileName, path.extname(originalFileName));
const searchPattern = `${slug}-${nameWithoutExt}`;
const response = await axios.get(`${WP_URL}/wp-json/wp/v2/media`, {
...authHeader,
params: { search: searchPattern, per_page: 10 }
});
for (const media of response.data) {
const fileName = path.basename(media.source_url);
if (fileName.startsWith(searchPattern)) return media;
}
return null;
}
検索でヒットした既存ファイル名にハッシュが含まれているかどうかで、「内容が同じなので再利用」か「内容が変わったので削除→再アップロード」かを分岐します。
async function uploadOrReuseImage(imagePath, fileName, slug) {
const hash = calculateFileHash(imagePath);
const newFileName = `${slug}-${path.basename(fileName, path.extname(fileName))}-${hash}${path.extname(fileName)}`;
const existingImage = await findExistingImageByPattern(slug, fileName);
if (existingImage) {
const existingFileName = path.basename(existingImage.source_url);
if (existingFileName.includes(`-${hash}`)) {
return { id: existingImage.id, url: existingImage.source_url }; // 再利用
}
await deleteImage(existingImage.id); // ハッシュ不一致→削除
}
return await uploadImage(imagePath, newFileName); // 新規アップロード
}
5. 実際の挙動
| 状況 | 動作 | 結果 |
|---|---|---|
| 初回 | ハッシュ計算→検索で見つからない | 新規アップロード |
| 2回目(画像変更なし) | ハッシュ一致 | 再利用(約10倍高速、無駄なAPI呼び出しなし) |
| 3回目(画像を差し替え) | ハッシュ不一致 | 古い画像を削除→新規アップロード |
6. ハッシュ衝突は現実的に無視できる
6桁の16進数は16^6 = 16,777,216通りです。1記事あたり10枚の画像を使うとしても、約167万記事を書くまで衝突しない計算になり、個人ブログの運用では実質起こりえません。心配な場合はsubstring(0, 8)のように桁数を増やせば済みます。
まとめ
「ファイル内容が変わったかどうか」をハッシュで機械的に判定するだけで、Media Libraryの肥大化と無駄な再アップロードの両方を同時に解決できました。画像削除のタイミング(アップロード前 or 後)などの実装判断も含めた全体設計はこちらにまとめています。