enechain Tech Blogのはてなブログ移行と運用改善

ogp

この記事は enechain Advent Calendar 2023 の23日目の記事です。

昨日は @Akizoh の「ワークフロー、タスクで見るか?データで見るか?」でした。

techblog.enechain.com

はじめに

こんにちは、enechainでSWEをしている@nakker1218です。

enechainでは、2023年の9月にテックブログのリニューアルを行いました。本記事では、2023年1月から始めたばかりのテックブログでさっそくリニューアルを行った背景や、リニューアル後の執筆体験向上の施策について紹介します。

移行の背景と課題

もともとenechainではMediumを使ってテックブログを運営していました。

SEOの設定や独自ドメインの設定ができること、デザインを設定せずとも見た目がかっこよくなることから選定されていました。

しかし、ある日突然robots.txtがブロックされ、Googlebotがサイトにアクセスできなくなりインデックスが登録されない問題が発生したり、自動生成されたURLがクロールされてしまいインデックス未登録の記事が増えたりといった問題があったことから、移行を検討しはじめました。

移行に当たって記事がGoogle検索に乗るだけでなく、執筆体験を向上し、enechainのみんながより快適に記事をかけるようにするために、執筆上の課題を洗い出しました。上がってきた課題は以下のとおりです。

  • Mediumのエディター機能が弱い
    • MarkdownがサポートされていないためEditor上で再度見た目を整える必要がある
    • 表の記法がサポートされていないため画像として貼り付ける必要がある
  • レビュー機能がない
    • NotionやGoogle Docなど別ツールで記事を執筆し、レビュー後にMediumに貼る必要がある
  • OGP作成を毎回Figmaの編集権限がある人にお願いする必要がある
  • てにおはの修正など毎回同じようなレビューが発生する

以上の課題を解決できるプラットフォームとしてはてなブログを選定しました。

はてなブログではAPIが公開されており、blogsyncというCLIクライアントを使うことでGitHub上で記事を管理することが出来ます。GitHubのRepositoryを記事のマスターとして、GitHub Actionsで自動化することで課題に感じていた運用を改善できそうです。

執筆フローとワークフロー

enechainのテックブログの執筆は以下のようなフローで行っています。

  1. アウトラインの作成
  2. アウトラインのレビュー
  3. 下書きの作成
  4. レビュー
  5. 公開

記事の執筆開始から公開まですべての作業が1つのツール上での操作で完結し、はてなブログの管理画面を執筆者が見ないですむことを目指しワークフローを整理し始めました。

記事の作成

記事の作成はGitHub Actionsのworkflow_dispatchで行い、同時にはてなブログ上に下書き記事の作成, OGPの作成, Pull Requestの作成されるようにします。

下書き生成ワークフロー

下書き記事の作成

ワークフローを実行すると、記事のpath名でブランチが生成され、以下のディレクトリ構造で記事ファイルが生成されます。 執筆者はブランチをチェックアウトし、ローカルで記事のpathディレクトリのentry.mdを編集することで執筆していきます。

記事のディレクトリ構造

entries
├── 記事のpath
│   ├── entry.md
│   └── images
│       └── .gitkeep

生成されたentryファイル

生成された記事ファイルをもとにblogsyncのpostコマンドを使い、はてなブログ上に下書き記事を作成します。

blogsync post --draft --title="$TITLE" --custom-path=${{ env.BLOG_PATH }} ${{ env.BLOG_DOMAIN }} < .github/templates/draft.md

OGPの作成

つづいてOGPの設定です。 はてなブログでは、記事の最初の画像がOGPとして自動で設定されます。記事作成時にOGPを生成し記事に埋め込むことで管理画面を触らずともOGPを設定できます。

今回画像の生成にはCloudinaryを使いました。
Cloudinaryは画像のクラウドストレージサービスで、アップロードした画像をキャッシュして配信してくれるだけでなく、画像のリサイズや文字を乗せると言った加工も簡単に行えるサービスとなっています。
Cloudinaryでは画像の加工をURL経由で行うことが出来ます。そのためURLの一部を差し替えることで動的に画像を作成できます。具体的なやり方はこちらの記事がくわしいです。

デザイナーが作ってくれたOGPをCloudinary上で再現し、CIでタイトルや執筆者名を差し替えることで動的にOGP画像を生成します。

enechain Tech BlogのOGPでは以下の内容を設定しています。

  1. 記事内で使用している画像(もしくはenechainオフィス画像をランダムに1枚)を背景に
  2. ブルーを乗算でのせる
  3. 記事タイトル、執筆者名(もしくはアカウント)をURLエンコードして書き換える
ベース画像 文字をのせた画像
ベース画像 生成された画像

文字を載せた画像のURL

https://res.cloudinary.com/enechain-techblog/image/upload/c_fill,g_center,h_630,l_ogp:default-images:enechain-office-02,w_1200/b_rgb:0C00C5,c_scale,e_multiply,h_630,l_ogp:ogp-overlay,w_1200/c_fit,g_north_west,h_376,l_text:notosansjp-bold.ttf_70:enechain%20%E3%81%AE%E6%8A%80%E8%A1%93%E3%82%B9%E3%82%BF%E3%83%83%E3%82%AF%250A2023%20%E5%A4%8F,co_rgb:FFFFFF,w_992,x_104,y_128/c_fit,g_south_west,h_100,l_text:notosansjp-semibold.ttf_44:%40sutochin26,co_rgb:FFFFFF,w_754,x_104,y_88/c_scale,g_south_east,h_96,l_ogp:enechain-logo,x_72,y_64/v1701080462/ogp/ogp.png

作成した画像のURLをそのまま記事に貼ってしまうと、URLにアクセスするたびに画像の生成が走ってしまうため、記事の一覧ページで読み込みが遅くなる問題が発生しました。そのため作成した画像を改めてCloudinaryに画像としてアップロードし、画像のURLを記事に記載するようにしています。

OGP追加コミット

create-ogp:
  name: Create OGP image
  needs: create-blog
  steps:
    - name: Check out
      uses: actions/checkout@v3
      with:
        ref: ${{ env.BLOG_PATH }}
        fetch-depth: 0
    - name: Setup up python
      uses: actions/setup-python@v4
      with:
        python-version: "3.10"
    - name: Install cloudinary-cli
      run: |
        pip install cloudinary-cli
    - name: Install jq
      run: |
        sudo apt-get update
        sudo apt-get install jq -y

    - name: Encode title
      id: encode-title
      run: |
        encoded_title=$(python -c "import urllib.parse; print(urllib.parse.quote('${{ inputs.title }}'))") 
        echo "TITLE=$encoded_title" >> $GITHUB_OUTPUT
    - name: Encode author
      id: encode-author
      run: |
        encoded_author=$(python -c "import urllib.parse; print(urllib.parse.quote('${{ inputs.author }}'))") 
        echo "AUTHOR=$encoded_author" >> $GITHUB_OUTPUT

    - name: Update ogp
      id: update-ogp
      env:
        CLOUDINARY_URL: 
        TITLE: ${{ steps.encode-title.outputs.TITLE }}
        AUTHOR: ${{ steps.encode-author.outputs.AUTHOR }}
      run: |
        echo "title=$TITLE"
        echo "author=$AUTHOR"
        raw_ogp_url="https://res.cloudinary.com/enechain-techblog/image/upload/c_fill,g_center,h_630,l_${{ inputs.image_id }},w_1200/b_rgb:0C00C5,c_scale,e_multiply,h_630,l_ogp:ogp-overlay,w_1200/c_fit,g_north_west,h_376,l_text:notosansjp-bold.ttf_70:${{ env.TITLE }},co_rgb:FFFFFF,w_992,x_104,y_128/c_fit,g_south_west,h_100,l_text:notosansjp-semibold.ttf_44:${{ env.AUTHOR }},co_rgb:FFFFFF,w_754,x_104,y_88/c_scale,g_south_east,h_96,l_ogp:enechain-logo,x_72,y_64/v1701080462/ogp/ogp.png"
        echo $raw_ogp_url

        uploaded_url=$(cld uploader upload $raw_ogp_url folder=${{ inputs.path }} public_id=ogp | jq -r '.secure_url')

        echo "OGP_URL=$uploaded_url" >> $GITHUB_OUTPUT

    - name: Update OGP & Commit and push
      env:
        OGP_URL: ${{ steps.upload-ogp.outputs.OGP_URL }}
      run: |
        entry_path=entries/${{ env.BLOG_PATH }}/entry.md
        awk -v ogpurl="${{ env.OGP_URL }}" '/^---$/ {c++; if (c==2) { print; print "![ogp](" ogpurl ")"; next }}1' $entry_path > temp_file && mv temp_file $entry_path

        git config --global user.name 'github-actions[bot]'
        git config --global user.email 'github-actions[bot]@users.noreply.github.com'
        git remote set-url origin https://github-actions:${{  secrets.GITHUB_TOKEN  }}@github.com/${GITHUB_REPOSITORY}

        git fetch
        git status
        git add entries 
        git commit -m "[bot-commit] Update OGP image"
        git push origin ${{ env.BLOG_PATH }}

Pull Reqeustの作成

下書き記事の作成とOGP画像の作成が終わったらPull Requestを作成します。Pull Requestのテンプレートでチェックリストを用意し、公開前の確認をしやすくしています。

また、blogsync v0.17.0 からPreview用のURLが自動で発行されるようになったので、作成時にPRにコメントをつけることで執筆者やレビュアーに実際にはてなブログにアップロードされたときの様子を確認してもらっています。

Pull Request

create-pull-request:
    name: Create Pull Request
    runs-on: ubuntu-latest
    needs: [create-blog, create-ogp]
    env:
      BASE_BRANCH: main
    steps:
      - name: Check out
        uses: actions/checkout@v3
        with:
          fetch-depth: 0

      - name: Create Pull Request
        run: |
          gh pr create --base ${{ env.BASE_BRANCH}} --head ${{ env.BLOG_PATH }} --assignee ${{ github.actor }} --title "[新規] ${{ env.BLOG_TITLE }}" --body "`cat .github/templates/pull-request-body-new-entry.md`" 

      - name: Add prewview URL to PR body
        if: ${{ needs.create-blog.outputs.PREVIEW_URL != ''}}
        run: |
          gh pr comment ${{ env.BLOG_PATH }} --body "PreviewURL: ${{ needs.create-blog.outputs.PREVIEW_URL }}"

記事の更新

記事の更新はPull Requestへのpushをトリガーに行います。

画像のアップロード

画像はローカルのパスでははてなブログ上で表示することができないため、画像サーバーにアップロードすることが必要です。手動でアップロードしてURLを貼り付けることは非常に手間で執筆体験を損なうため、アップロードとURLの差し替えはCIが自動に行うようにしています。

はてなブログの画像サーバーとしてはてなフォトライフというサービスがありますが、APIや周辺のライブラリの開発が盛んでないことや、OGPの生成にCloudinaryを使っていることから画像のアップロード先としてもCloudinaryを使っています。

Markdown上に書かれた画像をCloudinaryのURLに差し替えることでローカルでのMarkdownプレビューとはてなブログ上でのプレビューを可能にしています。

Replace Image URL

- name: Upload images
  env:
    CLOUDINARY_URL: 
  run: |
    edited_paths=($(git diff --name-only origin/main --diff-filter=MA | grep '^entries/.*\/entry.md$' | sed 's/entries\/\(.*\)\/entry\.md/\1/'))

    for path in "${edited_paths[@]}"; do
      entry_path="entries/${path}/entry.md"

      for image in $(grep -Eo '\.\/images\/.*\.(jpg|jpeg|gif|png|bmp)' $entry_path | sort | uniq); do
        file_name=$(echo $image | sed 's/\.\/images\///')
        image_path="entries/$path/images/$file_name"

        upload_url=$(cld uploader upload $image_path folder=$path | jq -r '.secure_url')

        if [ -z "$upload_url" ]; then
          echo "Upload failed: upload_url is empty"
          exit 1
        fi
        echo "Upload url: $upload_url"

        sed -i "s#$image#$upload_url#g" "$entry_path"
      done
    done

記事の更新

記事の更新はblogsyncのpushコマンドを使って行います。画像はアップロードされたURLになっているので、そのままはてなブログにアップロードすれば表示されるようになっています。

update-blog:
    name: Update Hatena draft blog
    if: ${{ !cancelled() && !failure() }}
    needs: upload-image
    steps:
      - name: Check out
        uses: actions/checkout@v3
        with:
          fetch-depth: 0

      - name: Setup Hatena blog CLI
        uses: x-motemen/blogsync@v0
        with:
          version: v0.20.1

      - name: Setup git
        run: |
          git config --global user.name 'github-actions[bot]'
          git config --global user.email 'github-actions[bot]@users.noreply.github.com'
          git remote set-url origin https://github-actions:${{ env.GH_TOKEN }}@github.com/${GITHUB_REPOSITORY}

          git fetch
          git checkout ${{ github.head_ref }}
          git pull

      - name: Update draft entry
        if: steps.check_publish_status.outputs.publish_status != 'unpublished' || steps.check_publish_status.outputs.publish_status != 'published'
        run: |
          cp -f ${{ env.LOCAL_ENTRY_PATH }} ${{ env.ENTRY_PATH }}
          blogsync push ${{ env.ENTRY_PATH }}

記事のレビュー

記事のレビューはPull Requestのレビュー機能を使って行います。GitHubのPull Requestはエンジニアは日常的に使いなれていること、行単位でフィードバックや議論ができることで効率的にレビューができるようになりました。また、記事に対してPull Requestが紐づいているのであとから記事が執筆されたときの背景ややり取りを確認することも簡単です。

また、Commit suggestionを使うことでtypoや言い回しの修正を取り込むことが簡単になり執筆体験が向上しました。

Commit suggestion

記事の公開

blogsyncでは記事ファイル冒頭のメタデータにあるDraft:をfalseを設定した状態でpushすると記事が公開されます。

そのため記事の公開はレビュアーのApproveをトリガーにDraft状態を解除、マージをトリガーに公開のワークフローを走らせることで実現しています。

これによってレビューが通っていない状態で意図せず公開されてしまうことを防いでいます。

LGTM

name: Ready to publish Hatena Deraft Blog

on:
  pull_request_review:
    types: [submitted]
  workflow_dispatch:

jobs:
  approved:
    steps:
      - name: Check out
        uses: actions/checkout@v3
        with:
          fetch-depth: 0

      - name: Remove draft tag
        run: |
          edited_entries=($(git diff --name-only origin/main --diff-filter=MA | grep '^entries/.*\/entry.md$'))

          for entry in "${edited_entries[@]}"; do
            echo "Edited entry: $entry"

            sed -i 's/Draft: false/Draft:/g' $entry
          done

      - name: Commit and push
        run: |
          git config --global user.name 'github-actions[bot]'
          git config --global user.email 'github-actions[bot]@users.noreply.github.com'
          git remote set-url origin https://github-actions:${{ env.GH_TOKEN }}@github.com/${GITHUB_REPOSITORY}

          git fetch
          git checkout ${{ env.BLOG_PATH }}
          git add entries/${{ env.BLOG_PATH }}
          git commit -m "[bot-commit] Update ${{ env.BLOG_PATH }} entry to be release-ready :rocket:"
          git push origin ${{ env.BLOG_PATH }}
name: Release Hatena Blog

on:
  pull_request:
    branches:
      - main
    types: [closed]
  workflow_dispatch:

jobs:
  release-blog:
    name: Release Hatena blog
    if: github.event.pull_request.merged == true
    steps:
      - name: Check out
        uses: actions/checkout@v3
        with:
          fetch-depth: 0

      - name: Setup Hatena blog CLI
        uses: x-motemen/blogsync@v0
        with:
          version: v0.20.1

      - name: Check if entry is releasable
        id: check-releasable
        run: |
          if grep -q 'Draft: false' ${{ env.LOCAL_ENTRY_PATH }}; then
            echo "Entry ${{ env.BLOG_PATH}} is in a Draft"
            exit 1
          fi

      - name: Push entry
        run: |
          blogsync pull ${{ env.BLOG_DOMAIN }}

          cp -f ${{ env.LOCAL_ENTRY_PATH }} ${{ env.ENTRY_PATH }}
          sed -i 's/techblog.enechain.com/techblog.enechain.com/g' ${{ env.ENTRY_PATH }}
          blogsync push ${{ env.ENTRY_PATH }}

レビュー後に記事が変更されてpushされると、Draft:falseの状態でupdate-blogのワークフローが発火し、意図せずに記事が公開されてしまいかねないので、変更を検知してDraftのstatusを変更する必要があることは注意です。

update-blog:
    name: Update Hatena draft blog
    if: ${{ !cancelled() && !failure() }}
    needs: upload-image
    steps:
      # ...中略
      - name: Check if entry already exists
        id: check_entry
        run: |
          blogsync pull ${{ env.BLOG_DOMAIN }}

          if [ -f ${{ env.ENTRY_PATH }} ]; then
            echo "is_entry_exist=true" >> $GITHUB_OUTPUT
          else
            echo "is_entry_exist=false" >> $GITHUB_OUTPUT
          fi

      - name: Check publish status
        id: check_publish_status
        run: |
          if [ '${{ steps.check_entry.outputs.is_entry_exist }}' == 'false' ]; then
            echo 'publish_status=unpublished'
            echo "publish_status=unpublished" >> $GITHUB_OUTPUT
          elif grep -q 'Draft:false' ${{ env.LOCAL_ENTRY_PATH }} && grep -q 'Draft: false' ${{ env.ENTRY_PATH }}; then
            echo 'publish_status=release_ready'
            echo "publish_status=release_ready" >> $GITHUB_OUTPUT
          elif grep -q 'Draft: false' ${{ env.LOCAL_ENTRY_PATH }} && grep -q 'Draft: false' ${{ env.ENTRY_PATH }}; then
            echo 'publish_status=draft'
            echo "publish_status=draft" >> $GITHUB_OUTPUT
          else
            echo 'publish_status=published'
            echo "publish_status=published" >> $GITHUB_OUTPUT
          fi
      - name: Replace draft
        if: steps.check_publish_status.outputs.publish_status == 'release_ready'
        run: |
          sed -i 's/Draft:false/Draft: false/g' ${{ env.LOCAL_ENTRY_PATH }}

          git add entries/${{ env.BLOG_PATH }}
          git commit -m "[bot-commit] Update ${{ env.BLOG_PATH }} entry to be Draft"
          git push origin ${{ github.head_ref }}
      - name: Update draft entry
      # ...

enechain TechBlogの今後

本記事ではenechain TechBlogのはてなブログ移行に伴う執筆環境改善を行った話をご紹介しました。

冒頭であげた課題のうち、レビューや執筆体験については大幅に改善することが出来ました。しかし「てにおは」などの毎回同じようなレビューが発生してしまう問題についてはまだ未解決です。人の目だと見落としや、見る人によってのばらつきが発生するため、今後textlintやChatGPTを用いて校正作業の自動化をおこなっていこうと思っています。

現在hatenaが公式にHatena-Blog-Workflows-Boilerplatehatenablog-workflowsを公開しています。ワークフローを構築した時点ではまだ公開されていなかったためenechainでは使っていませんが、こちらを取り入れることでより簡単にGitHubでの管理が可能になると思います。

今後も引き続き執筆体験を改善していくことで、社外への発信を促し、会社のValueであるSocial goodを体現していきたいと思います。

次回 Advent Calendar 24日目は @tanaka-yui による「リッチテキストエディタの概念と採用したEditor.jsの話」です! 乞うご期待!

enechainでは、共にプロダクトを共創していく仲間を募集しています。要項は以下からご確認ください!

herp.careers