WordPress から Gatsby にブログを移行した話

December 08, 2021

背景

今まで本ブログは WordPress で運用されていましたが、自分で書いたコンポーネントをブログでも使いたい、ALB + EC2 (EBS) が割といい値段する、Amplify Console 使いたい、という様々な想いから Gatsby に移行することにしました。
移行にあたって、記事が 200 程度あり最初は手で HTML を Markdown に変換していたものの、これは人間がやる作業ではないと判断して四苦八苦したことなんかを綴っていきます。なお、世の中には既に似たようなことをされている人が山ほどいて、何なら多分それ専用のプラグインすらあったような気もしますが、どう Markdown に落とし込めるのかを割とカスタマイズしたい部分もあったので、今回は自力でやりました。

テーブル構造の確認

まずは今ある WordPress のデータがどのように MySQL データベースのテーブル上に格納されているかを確認するところからです。少なくとも自分が使っていたバージョンの WordPress では (もう消したのでバージョン忘れた) 、wp-posts というテーブルにブログポストのコンテンツが保存されていました。

$ cat 129.txt 
mysql> select * from wp_posts where ID = 129 \G;
*************************** 1. row ***************************
                   ID: 129
          post_author: 1
            post_date: 2017-07-17 22:29:22
        post_date_gmt: 2017-07-17 13:29:22
         post_content: <h2>SSL/TLS とは</h2>
<strong>拡張性</strong>: Record Protocol が担うのはデータ転送と暗号処理であり、他の機能はすべてサブプロトコルで行う。それらサブプロトコルは前述の <span style="color: #ff0000;">Handshake Protocol</span><span style="color: #ff0000;">Change Cipher Spec Protocol</span><span style="color: #ff0000;">Application Data Protocol</span><span style="color: #ff0000;">Alert Protocol</span> である。

...

           post_title: SSL / TLS ( 1 ) ~ プロトコル ~
         post_excerpt: 
          post_status: publish
       comment_status: open
          ping_status: open
        post_password: 
            post_name: ssl-tls-1-%e3%83%97%e3%83%ad%e3%83%88%e3%82%b3%e3%83%ab
              to_ping: 
               pinged: 
        post_modified: 2017-07-17 23:32:45
    post_modified_gmt: 2017-07-17 14:32:45
post_content_filtered: 
          post_parent: 0
                 guid: http://upinet2.dip.jp/wordpress/?p=129
           menu_order: 0
            post_type: post
       post_mime_type: 
        comment_count: 0
1 row in set (0.00 sec)

ここで、このテーブルには revision や attachment など様々な “ポスト” が含まれますが、post_parent が 0 且つ post_type が post であれば記事の内容に絞れそうだったのでこの手法を採用しました。基本的には以下のクエリで引っ張ってこれます。

SELECT ID, post_date, post_title, post_content FROM wp_posts WHERE post_parent = 0 AND post_type = 'post'

ただ、このテーブルにはポストに関連するタグやカテゴリの情報が含まれないので、それらについては別のテーブルから拾ってくる必要があり、対象となるのは以下の 3 テーブルです。

テーブル名 説明
wp_terms タグやカテゴリ等の定義テーブル
wp_term_taxonomy term がどういった種類 (カテゴリ、タグ 等) なのかを格納するテーブル
wp_term_relationships 各ポストがどの taxonomy に関連づけられているのかを格納するテーブル

なので具体的な流れとしては、wp_term_relationships で対象のポストにどの taxonomy が関連づけられているのか、その taxonomy はカテゴリなのか、タグなのかを wp_term_taxonomy から確認して、その taxonomy の値は何なのかを wp_terms から確認する感じです。
例えば ID が 129 のポストはカテゴリとして Security が、タグとしては SSL/TLS が指定されていますが、それは以下のように確認できます。

mysql> select * from wp_term_relationships where object_id = 129 \G;
*************************** 1. row ***************************
       object_id: 129
term_taxonomy_id: 7
      term_order: 0
*************************** 2. row ***************************
       object_id: 129
term_taxonomy_id: 8
      term_order: 0

mysql> select * from wp_term_taxonomy where term_taxonomy_id = 7 OR term_taxonomy_id = 8 \G;
*************************** 1. row ***************************
term_taxonomy_id: 7
         term_id: 7
        taxonomy: category
     description: 
          parent: 0
           count: 4
*************************** 2. row ***************************
term_taxonomy_id: 8
         term_id: 8
        taxonomy: post_tag
     description: 
          parent: 0
           count: 4
2 rows in set (0.00 sec)      

mysql> select * from wp_terms where term_id = 7 or term_id = 8 \G;
*************************** 1. row ***************************
   term_id: 7
      name: Security
      slug: security
term_group: 0
*************************** 2. row ***************************
   term_id: 8
      name: SSL/TLS
      slug: ssltls
term_group: 0
2 rows in set (0.00 sec)

最終的には以下のようなクエリでポストの ID からカテゴリ・タグを抽出できます。({} はポストの ID)

SELECT taxonomy, name FROM wp_term_relationships AS relation \
  INNER JOIN wp_term_taxonomy AS taxonomy ON relation.term_taxonomy_id = taxonomy.term_taxonomy_id \
  INNER JOIN wp_terms AS terms ON taxonomy.term_id = terms.term_id \
  WHERE relation.object_id = {}

これで MySQL のテーブルからデータを引っ張ってくるところまでは準備ができました。

HTML を Markdown に変換するための準備

データが引っ張ってこれたら、次は HTML で取得できるポストデータをどう Markdown に変換するか、です。コードブロックの書き換えや HTML に特有な <> といった文字列の変換、また画像のダウンロードや <table> タグで記述されたテーブルの解釈等があたります。加えて、個人的な趣向もここに織り交ぜていきます。ざっくりリストにすると以下のような変換が必要になりました。

変換元 変換先
&lt;&gt; 等の HTML 特有文字列 <> 等、対応する記号
<h2> 等の見出しタグ ### のように Markdown 起票に変換 (見栄え上、<h2> であれば ### のように一段階レベルを下げる)
\r\n<br> 等の改行 スペース x 2
[bash][/bash] ```bash```
<table>…</table> というように記述されたテーブル 頑張って Parse して Markdown のテーブルに変換
<img> で指定された画像 <img> タグは Markdown Syntax に変換し、画像は requests モジュールでダウンロード

なお、一部正規表現は使いましたが、考えるのが面倒だったのでほとんどは replace() を羅列して解決してます。ざっくりですが、以上で変換の準備は終了です。

パスの自動生成

WordPress では Query String Parameter で ?p=xxx のようにポストが ID で指定されていましたが、Gatsby に移行するにあたってそれぞれのポストに対してパスを生成してあげる必要がありました。これも手作業ではやりたくないので、以下の要領で行いました。

  1. 日本語のタイトルを取得
  2. 余計な記号等を除く
  3. Amazon Translate の TranslateText API により英語に翻訳
  4. Lower Case にする
  5. スペースは - に変換
  6. 重複が無いことを確認

この手続きにより、例えば Amplify + React/Redux アプリ開発 ~ Emacs 使ってると Dev Server 落ちる問題について ~ というタイトルから amplifyreact-redux-app-development-emacs-about-dev-server-falling-issues というなんとな〜くそれっぽいパスが生成できます。

最終的な変換スクリプト

以上のような下準備の上で、最終的な変換用のスクリプトは以下のようになりました。これでコンテンツがパスや画像ファイルごと自動生成されるので、それを Gatsby のコンテンツ用ディレクトリにコピーしてあげれば OK です。(replace() やばい)

# -*- coding: utf-8 -*- 

import boto3
import MySQLdb
import os
import re
import requests
import sys

translate = boto3.client('translate', region_name='us-west-2')


def get_db_connection():
  db_host = "xxx.xxx.xxx.xx"
  user = "xxxxxxxx"
  passwd = "xxxxxxxx"
  db="wordpress"

  return MySQLdb.connect(
    host=db_host,
    user=user,
    passwd=passwd,
    db=db,
    use_unicode=True,
    charset="utf8"
  )


def get_raw_post_data(position=0):
  conn = get_db_connection()
  cursor = conn.cursor()
  cursor.execute("SELECT ID, post_date, post_title, post_content FROM wp_posts where post_parent = 0 AND ID > {} AND post_type = 'post'".format(position))
  rows = cursor.fetchall()
  conn.close()

  return rows


def get_raw_category_and_tag_data(post_id):
  conn = get_db_connection()
  cursor = conn.cursor()
  cursor.execute("SELECT taxonomy, name FROM wp_term_relationships as relation INNER JOIN wp_term_taxonomy as taxonomy on relation.term_taxonomy_id = taxonomy.term_taxonomy_id INNER JOIN wp_terms as terms on taxonomy.term_id = terms.term_id where relation.object_id = {}".format(post_id))
  rows = cursor.fetchall()
  conn.close()

  return rows


def convert_post_content(post_content):
  return post_content.replace("&nbsp;", " ").replace("&gt;", ">").replace("&lt;", "<").replace("&quot;", '"').replace("&apos;", "'").replace("\r\n", "  \n").replace("・", "* ").replace("<h2>", "### ").replace("</h2>","").replace("<h3>", "#### ").replace("</h3>", "").replace("<h4>", "##### ").replace("</h4>", "").replace("( ", "(").replace(" )", ")").replace("<strong>", "**").replace("</strong>", "**").replace("[bash]", "```bash").replace("[/bash]", "```").replace("[c]", "```c").replace("[/c]", "```").replace("[php]", "```php").replace("[/php]", "```").replace("[java]", "```java").replace("[/java]", "```").replace("[javascript]", "```javascript").replace("[/javascript]", "```").replace("[js]", "```javascript").replace("[/js]", "```").replace("[xml]", "```xml").replace("[/xml]", "```").replace("<br/>", "  \n").replace("<br>", "  \n").replace('<figure class="wp-block-image">',"").replace('</figure>', "")


def get_category_and_tags(post_id):
  category_list = []
  tag_list = []
  rows = get_raw_category_and_tag_data(post_id=post_id)
  
  for row in rows:
    if row[0] == "category":
      if row[1] != "未分類":
        category_list.append(row[1])
    elif row[0] == "post_tag":
      tag_list.append(row[1])
    else:
      continue

  # For 929
  if post_id == 929:
    category_list.remove("Database")

  # For 1126
  if post_id == 1126:
    category_list.remove("AWS")

  # For 1215
  if post_id == 1215:
    tag_list.append("delete-public-path")

  # For 1261
  if post_id == 1261:
    tag_list.append("cors")

  if len(category_list) != 1:
    raise ValueError("The number of categories is not 1. Post ID: {}".format(post_id))
    sys.exit()

  if len(tag_list) < 1:
    raise ValueError("No tag associated. Post ID: {}".format(post_id))
    sys.exit()
  
  return {
    "Category": category_list[0],
    "Tags": tag_list
  }


def get_posts(position=0):
  rows = get_raw_post_data(position=position)
  post_list = []
  for row in rows:
    post_id = row[0]
    category_tags = get_category_and_tags(post_id=post_id)
    
    post = {
      "PostId": post_id,
      "PostDate": row[1].strftime("%Y-%m-%dT%H:%M:%S.000Z"),
      "PostTitle": row[2],
      "PostContent": convert_post_content(row[3]),
      "Category": category_tags["Category"],
      "Tags": category_tags["Tags"]
    }
    post_list.append(post)

  return post_list


def generate_content_path(post):
  formatted_original_title = post["PostTitle"].replace(")", "").replace("(", "").replace("~", "")
  response = translate.translate_text(
    Text=formatted_original_title,
    SourceLanguageCode="ja",
    TargetLanguageCode="en"
  )

  content_path = response["TranslatedText"].strip().replace("'", "").replace("[", "").replace("]", "").replace(" ", "-").replace("/", "-").replace("+", "").replace(":","").replace("--", "-").lower()
  return content_path


def check_duplicate(content_path_list):
  return len(content_path_list) != len(set(content_path_list))


def has_image(post):
  img_tag_pattern = r"<img .*/>"
  img_tag_list = re.findall(
    img_tag_pattern,
    post["PostContent"]
  )

  if len(img_tag_list) > 0:
    return True
  else:
    return False  


def get_image_path_list(post):
  img_tag_pattern = r"<img .*/>"
  img_tag_list = re.findall(
    img_tag_pattern,
    post["PostContent"]
  )
  img_path_list = []
  
  if len(img_tag_list) > 0:
    for img_tag in img_tag_list:
      url_pattern = r"https?://[\w/:%#\$&\?\(\)~\.=\+\-]+"
      url_list = re.findall(url_pattern, img_tag)

      if len(url_list) != 1:
        raise ValueError("The number of image URL is not 1. Post ID: {}".format(post["PostId"]))
        sys.exit()

      img_url = url_list[0]
      splitted_img_url = img_url.split("/")
      uploads_index = splitted_img_url.index("uploads")
      img_path = ""
      for element in splitted_img_url[uploads_index:]:
        img_path += element + "/"

      img_path = img_path[:-1]
      img_path_list.append(img_path)

  return img_path_list


def replace_img_tag_with_md_syntax(post):
  post_content = post["PostContent"]
  img_path_list = get_image_path_list(post)

  replaced_post_content = post_content
  for img_path in img_path_list:
    img_file = img_path.split("/")[-1]
    replaced_post_content = re.sub(r"<img .*" + re.escape(img_path) + r".*/>", "![f:id:shiro_kochi:2018××××××××:plain:w100:left](./{})".format(img_file), replaced_post_content)

  return replaced_post_content


def has_table(post):
  table_pattern = "<table>.*?</table>"
  table_list = re.findall(
    table_pattern,
    post["PostContent"],
    flags=re.DOTALL
  )
  
  if len(table_list) > 0:
    return True
  else:
    return False


def replace_table_with_md_syntax(post):
  post_content = post["PostContent"]
  table_pattern = "<table>.*?</table>"
  table_list = re.findall(
    table_pattern,
    post_content,
    flags=re.DOTALL
  )

  md_table_list = []
  for table in table_list:
    table_elem_list = []
    tr_pattern = "<tr.*?</tr>"
    tr_list = re.findall(
      tr_pattern,
      table,
      flags=re.DOTALL
    )
    
    for tr in tr_list:
      tr_elem_list = []
      td_pattern = "<td>.*?</td>"
      td_list = re.findall(
        td_pattern,
        tr,
        flags=re.DOTALL
      )
      for td in td_list:
        tr_elem = td.replace("<td>", "").replace("</td>", "").replace("\n","").replace("\r","")
        tr_elem_list.append(tr_elem)
      
      table_elem_list.append(tr_elem_list)
    md_table_list.append(table_elem_list)

  replaced_post_content = post_content
  for table_index, md_table in enumerate(md_table_list):
    table_str = ""
    for row, table_elem in enumerate(md_table):
      table_str += "|  "
      tmp_row = "|"
      for tr_elem in table_elem:
        table_str += tr_elem + "  |  "
        if row == 0:
          tmp_row += " ---- | "

      table_str += "\n"
      if row == 0:
        tmp_row += "\n"
        table_str += tmp_row

    replaced_post_content = replaced_post_content.replace(table_list[table_index], table_str)

  return replaced_post_content


def make_content_dir(base_dir, content_path):
  os.makedirs(base_dir + content_path)
  return base_dir + content_path


def download_images(base_dir, content_path, image_path_list):
  base_url = "https://blog.longest-road.com/contents/wp-content/"
  
  for image_path in image_path_list:
    image_file_name = image_path.split("/")[-1]
    response = requests.get(base_url + image_path)
    image = response.content

    with open(base_dir + content_path + "/" + image_file_name, "wb") as writer:
      writer.write(image)


def build_description(post_content):
  return post_content[:200].replace("#", "").replace('"', "").replace("*", "").replace("```", "").replace(":", "").replace(";", "")
      

def build_tag_string(tag_list):
  tag_str = "["
  for tag in tag_list:
    tag_str += '"' + tag + '",'
  
  tag_str = tag_str[:-1] + "]"
  return tag_str


def build_md_header(post):
  md_header = '---\ntitle: "'  + post["PostTitle"] + '"\ndate: "' + post["PostDate"] + '"\ndescription: "' + build_description(post["PostContent"]) + '"\ncategory: "' + post["Category"] + '"\ntags: ' + build_tag_string(post["Tags"]) + '\n---\n\n'
  return md_header


def write_md_file(base_dir, content_path, post):
  with open(base_dir + content_path + "/index.md", mode="w") as md_file:
    md_header = build_md_header(post)
    post_content = post["PostContent"]
    md_content = md_header + post_content
    md_file.write(md_content)
    

def main():
  post_list = get_posts(position=550)
  base_dir = "./content/"

  for post in post_list:
    content_path = generate_content_path(post=post)
    make_content_dir(base_dir=base_dir, content_path=content_path)

    if has_image(post):
      image_path_list = get_image_path_list(post=post)
      post["PostContent"] = replace_img_tag_with_md_syntax(post=post)
      download_images(
        base_dir=base_dir,
        content_path=content_path,
        image_path_list=image_path_list
      )
    
    if has_table(post):
      post["PostContent"] = replace_table_with_md_syntax(post=post)

    write_md_file(
      base_dir=base_dir,
      content_path=content_path,
      post=post
    )


if __name__ == "__main__":
  main()

おわりに

色々頑張っても最後はどうしても手でやらなきゃ修正ができない部分はあったものの、一つ一つ手動でコピー & ペーストするよりは楽にできたんじゃないかと思います。今後はこのブログサイトにもちゃんと投稿をしようと思います多分。


 © 2023, Dealing with Ambiguity