複数サイトのWordPressを統合する手順まとめ

# はじめに
ここしばらく、乱立するWordPressを統合する案件と格闘していました。その中で学んだことがとても多かったため共有しようと思います。

## 要件
- 複数のWordPressを、ひとつのWordPressに統合する
    - 旧サイトから新サイトへのリダイレクトを行う
    - 画像も全部、統合先WordPress内に移動する
- 統合時に記事を調整する
    - カテゴリを統合する
    - 内部リンクも新しいものに置き換える
- 統合元WordPressの設定はすべて異なる
    - テーマ・プラグイン・URL構造など
- ステージング環境でテスト可能にする
    - 本番を直接操作しない

## 手順
これらを満たすため、基本的には以下の手順で作業します。

1. 統合元WordPress本番を更新凍結・エクスポートする
2. 統合先WordPressステージングを本番と同期する
3. 統合先WordPressステージングにデータをインポートする
4. 統合先WordPressステージング内でデータを調整する
5. 統合先WordPress本番をステージングと同期する
6. 統合元WordPressからリダイレクトする

# 統合元WordPress本番を更新凍結・エクスポートする
更新凍結は運用担当者に連絡しておきましょう。

記事のエクスポートは、管理者であれば、「ツール」-「エクスポート」からXML形式でエクスポート可能です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9384/49a382f5-2662-f35c-acfc-6e9ee5d01a70.png)
## WP-CLIを使う
ただし、データが多い場合、PHPの最大実行時間の制約を超過する可能性があります。
`WP-CLI`があれば`wp export` コマンドを使うことで制約を回避可能です（安全に実施するためには、統合元WordPressのステージングサーバーに本番データを移してから実施したほうがいいかもしれません）

エクスポートしたデータには、画像の情報も入っています。のちに実行するインポート処理内で画像がダウンロードされるため、画像ファイル自体はエクスポートする必要がありません。

## WP-CLIがない場合
本番環境にはWP-CLIが入っていないかもしれません。その場合、直接ダウンロードして動かす方法があります。

```shell
curl https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
chmod 755 wp-cli.phar
./wp-cli.phar --info
./wp-cli.phar export --path=/path/to/wordpress
```
### PHPのバージョンが古い場合
https://analyzegear.co.jp/blog/421
https://dev.to/saintdle/download-releases-from-github-using-curl-and-wget-54fi
を参考に、過去バージョンをダウンロードします。

```shell
# PHP5.3対応のバージョン
curl -LJO https://github.com/wp-cli/wp-cli/releases/download/v1.5.1/wp-cli-1.5.1.phar
```

# 統合先WordPressステージングを本番と同期する
## DB同期
以下のようなコマンドになるでしょう。ここではRDSからエクスポートします。
※本番WordPressに、機密情報・個人情報が入っていないことを前提としています。Contact Form 7 など、問い合わせ情報を保管している場合は注意が必要です。
※本番とステージングのWordPressが同一ドメインで、hostsなどで表示を分けていることを前提にしています。ステージングが別ドメインの場合、本番→ステージング、ステージング→本番の同期時に、必ずdumpファイルの中身を置換して対応してください。

```shell
# バックアップ
mysqldump -u root -p --set-gtid-purged=OFF --single-transaction -h  [database_address] wp_before > honban-to-staging.sql

# ステージングに投入
mysql -u root -p -h  [database_address]  wp_after < honban-to-staging.sql
```
## 画像同期
WordPressでは画像は `wp-content/uploads/YYYY` 以下に保存されます。

```shell
# 本番
cd wp-content/uploads

tar czvf /tmp/honban-to-staging.tar.gz 20*

# ステージングにデータ移動後
cd wp-content/uploads
tar xzvf honban-to-staging.tar.gz
```
容量が大きすぎる場合、YYYYごとにファイルを分割するなど工夫してください。また、ディレクトリ・ファイルのユーザー・グループにも注意してください。


# 統合先WordPressステージングにデータをインポートする
たいていの問題は、実際にインポートしないと把握できません。なので、実際に移行を進める前に仮インポートをすることをお勧めします。

## インポートデータの注意点
記事のID `post_id` は、以下のような動きをします。
- 同じIDの記事が存在しない場合 -> そのIDを引き継ぐ
- 同じIDの記事が存在する場合 -> 新規採番

このため、実際にインポートしなければURLが決まりません。
後工程でリダイレクトマッピングを作るときには、複雑な作業が発生します。

## 同じguidの記事がアップロードされた場合
例えば、お知らせブログを複製して使い方ブログを作成し、最終的にそれらを統合する場合、同じGUIDの記事は後の方のデータで上書きされます。
歴史があるコンテンツを統合する場合、事前に記事のGUIDをチェックしておくといいでしょう。データベースにアクセスできなくても、歴史的経緯が分かると対策が取れる場合もあります。

## 移行元記事をインポートする
PHPの最大実行時間の制約により、ウェブからのインポートはほぼ失敗します。
このため、エクスポートしたXMLファイルをアップロードしたうえで、 `wp import` コマンドでインポートします。

```shell
cd wp-content/uploads
wp import WordPress.2021-11-22.xml --authors=create
```

移行元が巨大なサイトの場合、画像のダウンロードが大量に発生するため、インポート自体に10時間以上かかる場合があります。

（´-`）.｡oO（実際に18時間かかったサイトもあります……）
そのため、nohupコマンドで回しておくと便利です。

```shell
nohup wp import WordPress.2021-11-22.xml --authors=create &
```

ログはnohup.outに出ます。

### authorsオプション
WordPressの記事には投稿者が必要です。インポート時のオプションによって、記事の投稿者をどう扱うか決めます。

オプション|内容
----|---
create|xmlに指定された、移行元と同名ユーザーを作成してインポートする<br />パスワードは新規発行されていて、連絡も行かない
mapping.csv|ユーザーマッピングを定義した`mapping.csv`に従って、紐づけを変更する
skip|特に作成しない（やってみたところ、管理者に紐づくっぽい）

mappings.csvのサンプルは以下の通り。1行目のヘッダーは必須項目です。

```csv
old_user_login,new_user_login
old_author,new_author
old_author1,new_author
```
new_authorユーザーが既存の場合、old_authorとold_author1の記事は、new_authorの記事として取り込まれます。
また、createオプションで作成されたユーザーは、購読者権限で作成され、パスワードも新規発行されているため、そのままではログインできません。プロフィールも新規に作り直しです。

詳しくはこちらを参照してください。
https://developer.wordpress.org/cli/commands/import/

### 日本語のファイルを正しく取り込む
- インポート先のWordPressでWordPress Multibyte Patchが設定されている
- インポート元のWordPressで、日本語ファイル名の画像が使用されている

この条件に当てはまる場合、特殊設定が必要です。プラグイン内の設定ファイルから`wpmp-config.php`を作成し、wp-content直下に置きます。

```shell
cd wp-content/
cp -p plugins/wp-multibyte-patch/wpmp-config-sample-ja.php wpmp-config.php 
vi wpmp-config.php

diff plugins/wp-multibyte-patch/wpmp-config-sample-ja.php wpmp-config.php
104c104
< $wpmp_conf['patch_sanitize_file_name'] = true;
---
> $wpmp_conf['patch_sanitize_file_name'] = false;

```

これにより、日本語ファイル名が変換されずに取り込まれます。
※WordPress Mutilbyte Patchは日本語ファイル名をハッシュに変換しますが、このとき、記事内の画像URLは書き換えてくれません。そのため、ここでは変換処理を行わないようにするのです。

### 取り込み時に画像を圧縮する
インポート先のWordPressで EWWW Optimizerがインストール済の場合、画像インポート時に圧縮をかけてくれます。
ただし、`optipng` がインストールされていない場合、PNG画像のインポート自体に失敗します。


### 改段落を消さない
WordPress Importerでは複数行のテキストに入った改行（改段落）が消えてしまいます。
そのため、インポートロジックそのものを直します（こればかりは綺麗に直す方法がないようです）

```shell
cd wp-content/
vi plugins/Wwrdpress-importer/parsers/class-wxr-parser-regex.php

// コメントアウトして\nを常時つける
// if ( $in_multiline && ! $is_tag_line ) {
    $multiline_content .= $importline . "\n";
// }
```
ソースコード上の変更箇所は
https://github.com/wordpress/wordpress-importer/blob/master/src/parsers/class-wxr-parser-regex.php#L92-L94

https://web-guided.com/1050/

# 統合先WordPressステージング内でデータを調整する

## 記事内のURLを変える
前後編の記事などが、移行元のURLを指していることがあります。また、画像はインポートできても、記事内の画像URLは変更されません。
移行先のURLに個別書き換えしましょう。

### wpコマンドでやる場合
```shell
wp search-replace '[移行元ドメイン]/wp-content/uploads/' '[移行先ドメイン]/wp-content/uploads/' --skip-columns=guid --dry-run
```
実際に実行するときは`dry-run`を外してください。
`--skip-columns=guid` が肝心な部分です。guidは一意であることを求められているため、変更しないでください。
https://capitalp.jp/2017/01/25/do-not-change-the-guid/

## 画像が表示されない
何らかの理由で画像が表示されない場合があります。その場合、手動で統合元WordPressから画像を持ってくるなどの対応をします。

### サイズ指定した画像が表示されない
WordPressでは、画像は「フル」「大」「中」「サムネイル」といったサイズ感で管理されています。
問題は、この画像ファイル名にピクセル数が入っており、その設定は環境依存であることです。
たとえば、
`https://example.com/test/wp-content/uploads/yyyy/MM/hogehoge-169x300.png`

移行時に、このサイズの画像が生成されていない可能性があります。その場合、一度
`https://example.com/test/wp-content/uploads/yyyy/MM/hogehoge.png`
のように、ファイル名の後ろについた画像サイズを消してみて、画像が表示されるか試してください。
最新版のWordPressであれば、画像内にサイズ指定しなくても横幅に応じた画像が自動選択されるため、破綻なく表示されます。

これでも画像がない場合は、取り込み時に失敗しているため、手動で持ってきましょう。

## カテゴリー・タグの処理
カテゴリー・タグも同時に取り込まれます。
同名のカテゴリがマージされることもあるため、思わぬところに記事が入り込んでいないかを確認してください。

## プラグイン・ショートコード・CSS
移行前環境に依存する記事が多々あることが考えられるため、目視確認が必要です。

### ショートコード対応
`[hoge xxx=yyy]` といった形式のテキストが記事内にあった場合、それはショートコードとしてテーマ・プラグイン・functions.phpで解釈され、展開されるパーツです。
これに該当する処理が統合先WordPressにない場合、その部品は表示されません。

プラグインやfunctions.phpを追加して対応する必要がありますが、統合元WordPressの記事を参照して、素のHTMLに置き換えることも可能です。
特に、昔のWordPressはYoutubeの動画を埋め込めなかったため、プラグインで個別対応している場合があります。その時は置換して、標準のYoutube埋め込みにしてしまいましょう。

# 統合先WordPress本番をステージングと同期する
ステージング環境で統合したデータを、本番に同期させます。
## 画像同期
```shell
# ステージング
cd wp-content/uploads

tar czvf /tmp/staging-to-honban.tar.gz 20*

# 本番にデータ移動後
cd wp-content/uploads
tar xzvf staging-to-honban.tar.gz
```

## DB同期
```shell
# バックアップ
mysqldump -u root -p --set-gtid-purged=OFF --single-transaction -h  [database_address] wp_after > staging-to-honban.sql

# 本番に投入
mysql -u root -p -h  [database_address] wp_after < staging-to-honban.sql
```
DB同期を後ろにしたのは、画像同期が完了するまでの間は画像が表示されないためです。


# 統合元WordPressからリダイレクトする
実はこれが一番大変です。
というのは、**URLにカテゴリを含む場合、記事URLはデータベースから推測できない** ためです。

この場合、最初のエクスポート時に使ったXMLから、期待する記事URLを取り出します。
`link`属性には実際のURLが入っているため、それを使います。

## XSLTを使い、マッピング元データを作る（URLにカテゴリを含む場合）
https://github.com/inoue-katsumi/wordpress/blob/master/wp-post-titles.xsl
をカスタマイズして、XSLT作成します。

```wp-post-titles.xsl
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
                xmlns:wp="http://wordpress.org/export/1.2/"
                version='1.0'>
  <xsl:output method='text' encoding='utf-8'/>
  <!-- Set tab as character delimiter -->
  <xsl:variable name="delimiter" select="'&#009;'"/>

  <xsl:template match="/">
    <xsl:apply-templates select="/rss/channel/item[wp:post_type = 'post']"/>
  </xsl:template>

  <xsl:template match="/rss/channel/item">
    <!--  <xsl:if test="string-length(pubDate)!=0">-->
    <!--  <xsl:if test="wp:status!='draft' and wp:status!='publish'">-->
    <xsl:if test="wp:status='publish'">
      <xsl:value-of select="wp:post_id/text()"/>
      <xsl:value-of select="$delimiter"/>
      <xsl:value-of select="wp:post_name/text()"/>
      <xsl:value-of select="$delimiter"/>
      <xsl:value-of select="title/text()"/>
      <xsl:value-of select="$delimiter"/>
      <!--<xsl:text>: </xsl:text>-->
      <xsl:value-of select="link/text()"/>
      <xsl:value-of select="$delimiter"/>
      <xsl:value-of select="wp:post_date/text()"/>
      <xsl:text>&#10;</xsl:text>
    </xsl:if>
  </xsl:template>
</xsl:stylesheet>
```
上記XSLTを使ってXMLを変換すると、タブ区切りの記事一覧が取れます。

```shell
sudo apt install xsltproc
xsltproc wp-post-titles.xsl Downloads/WordPress.2021-02-24.xml > result.csv
cat result.csv

6679	longterm-storage	長期在庫保管手数料とは	http://example.com/column/longterm-storage/	2014-08-18 21:03:25
```

統合元・統合先双方で「エクスポート」して、両方のファイルをMicrosoft ExcelやGoogle Spreadsheetで読み込ませ、2列目（longterm-storage）をキーにしてvlookup()関数で結合し、マッピングを作ります。
それを`.htaccess`ファイルに変換することで、リダイレクトマッピングが完成します。

```
...
Redirect permanent /column/longterm-storage/ http://example.com/archives/832
...
```

理論上は`join`コマンドや `paste`コマンドで直接結合できるはずなのですが、重複するデータがあったりしてうまくいかないことが多いです。

## データベースを使い、マッピング元データを作る
移行元データベースと移行先データベースが同じデータベース上にある場合、SQLでjoinさせることができます。

URLにカテゴリを含まず、かつ移行中にGUIDを上書きしていなければ、以下のSQLでマッピングを作成できます。

```sql
select
  af.ID,
  af.post_date,
  af.post_modified,
  concat("https://example.com/archives/", af.ID) as after_url,
  af.post_title,
  bf.ID,
  bf.post_date,
  bf.post_modified,
  bf.guid as before_url,
  bf.post_title
from
  after_site.wp_posts as af
join before_site.wp_posts as bf
  on af.guid = bf.guid
where
  af.post_status = 'publish'
  and af.post_type in ('post', 'page')
order by
  af.post_date
```

ここでは`after_url` 列と `before_url` 列が対になってマッピングになっていることを想定していますが、実際には日付などからURLが作られる可能性もありますね。wp_postsテーブルのデータで作れる範囲なら、SQL内でマッピングが作成できます。

## リダイレクトマッピングを作る
以下の前提で作っていきます。

- 以下のリダイレクトは行わない
    - 投稿者ページ (author)
    - 年月アーカイブ
    - タグ
    - カテゴリ
- 管理画面にとりあえずアクセスできるようにする

### 日本語URLにまつわる問題
Apacheの設定ファイルはパーセントエンコーディングを受け付けないため、UTF-8の日本語テキストとしてマッピングを作る必要があります。
ただし、Mac環境で生成される `%e2%80%8e` (ノーブレークスペース)がURLに含まれる場合、設定ファイルに記載する方法がないため、ワイルドカードや設定ファイルの正規表現でないとマッチングできません。注意が必要です。
https://httpd.apache.org/docs/2.2/ja/mod/mod_alias.html#redirect

この挙動はNginxでも同じはずです。

### .htaccess(Apache)
WordPress直下の `.htaccess`の前に追記します。

```.htaccess
# 標準のfeedをリダイレクトさせる
# permanentは301リダイレクトを示す
Redirect permanent /feed https://[移行先ドメイン]/feed

Redirect permanent /example https://[移行先ドメイン]/archives/1
# パーセントエンコーディングの日本語URLは、デコードして記載する
Redirect permanent /日本語URL https://[移行先ドメイン]/archives/2

# 念のため管理画面ログインは可能にするが、それ以外は全部トップにリダイレクトする
RedirectMatch permanent ^/((?!(wp-login.php|wp-admin)).*)$ https://[移行先ドメイン]

# --- ここからWordPress標準で生成される---
# BEGIN WordPress
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
</IfModule>

# END WordPress
```

### 設定ファイル(Nginx)
Nginx環境では .htaccessが使えないため、設定ファイルに直接記載します。
たぶんこんな感じになるかと思います。

```/etc/nginx/conf.d/php.conf
    # 念のため管理画面だけはリダイレクトしない
    location ~* /wp-login\.php|/wp-admin/((?!admin-ajax\.php).)*$ {
# この行は / と同じtry_file
        try_files $uri $uri/ /index.php?q=$uri&$args;
# 以下の行は phpと同じ設定 
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass unix:/path/to/php/php-fpm.sock;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }

    location / {
        try_files $uri $uri/ /index.php?q=$uri&$args;

        # www/.htaccess directives
        rewrite ^/example https://[移行先ドメイン]/archives/1 permanent;
# 日本語の場合もそのままで
        rewrite ^/日本語URL https://[移行先ドメイン]/archives/2 permanent;
...

        rewrite ^/feed https://[移行先ドメイン]/feed permanent;
        rewrite ^/ https://[移行先ドメイン]/ permanent;
    }


# プラグイン固有の設定
    # .htaccess directives
    location ~* /wp-content/plugins/akismet/akismet\.(css|js)$ {
        allow all;
    }
    location /wp-content/plugins/akismet/akismet {
        deny all;
    }

    location ~* \favicon.ico$ {
        access_log off;
        expires 30d;
        add_header Cache-Control public;
    }

    location ~* \.(html|jpg|jpeg|gif|css|png|js|ico|swf|woff)$ {
        gzip_static always;
        gunzip on;
        add_header Cache-Control public;
        etag off;
    }

# php実行時の設定
    location ~ \.php$ {
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass unix:/path/to/php/php-fpm.sock;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
```

# トラブルシューティング
## SEOのdescriptionが引き継がれない
移行元・移行先のプラグイン・テーマによって設定が異なります。

## インポートに失敗する場合
テキストに不正な文字が入っている場合があります。

```shell
tr -d '\000-\010\013\014\016-\037\177' < site.WordPress.2021-07-02.000.xml > sanitized-000.xml
```

## WordPress 3系から移行する場合
WordPress 3系には標準のエクスポートプログラムがないため、`DeMomentSomTres Export`などでエクスポートすることになります。
しかし上記のエクスポーターではXMLの中身が微妙に異なり、WordPress5系でインポートすると中身が崩れます。
そのため、一度まっさらなWordPress5環境を立て、`mysqldump`で3系からデータを移し、その後エクスポートすることをお勧めします。
ただし、環境を移したことにより、URLが別環境URLベースに変更されてしまうため、後工程で使う際には注意しましょう。

## カスタム投稿タイプの記事を移行する場合
同様にまっさらなWordPress環境を立てたうえ、テーブルのデータを書き換えます。

```sql
UPDATE wp_posts SET post_type = 'post' WHERE post_type = 'customposts';
UPDATE wp_term_taxonomy SET taxonomy = 'post_tag' WHERE taxonomy = 'customtag';
UPDATE wp_term_taxonomy SET taxonomy = 'category' WHERE taxonomy = 'customcategory';
```
書き換え後に同名のタグができる可能性を考えると、これも別環境で実施後、エクスポートして取り込むのがいいでしょう。

## カスタム投稿タイプの記事のguid
カスタム投稿タイプの記事は、ほかの記事と違ったguidを生成します。
`https://example.com/?post_type=customposts&#038;p=12345` といった形式です。
しかし、この記事を**エクスポートしてインポートする**と、以下の形式になります。
`https://example.com/?post_type=customposts&amp;p=12345`

エクスポートしたファイルは前のままだったので、インポート時にサニタイズなどで変換されているのでしょう。
結果として、[上に記載した比較SQL](https://qiita.com/drafts/6ea24981e18a10b044da/edit#%E3%83%87%E3%83%BC%E3%82%BF%E3%83%99%E3%83%BC%E3%82%B9%E3%82%92%E4%BD%BF%E3%81%84%E3%83%9E%E3%83%83%E3%83%94%E3%83%B3%E3%82%B0%E5%85%83%E3%83%87%E3%83%BC%E3%82%BF%E3%82%92%E4%BD%9C%E3%82%8B) が正しく結合できません。

この場合、以下のように変更します。

```sql
join before_site.wp_posts as bf
  on af.guid = bf.guid
->
join before_site.wp_posts as bf
  on af.guid = replace(bf.guid,'&#038;', '&amp;')
```
置換して比較するだけなので、DBの中身は変更されません。