この記事は enechain Advent Calendar 2023 の23日目の記事です。
昨日は @Akizoh の「ワークフロー、タスクで見るか?データで見るか?」でした。
はじめに
こんにちは、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つのツール上での操作で完結し、はてなブログの管理画面を執筆者が見ないですむことを目指しワークフローを整理し始めました。
記事の作成
記事の作成はGitHub Actionsのworkflow_dispatchで行い、同時にはてなブログ上に下書き記事の作成, OGPの作成, Pull Requestの作成されるようにします。
下書き記事の作成
ワークフローを実行すると、記事のpath名でブランチが生成され、以下のディレクトリ構造で記事ファイルが生成されます。 執筆者はブランチをチェックアウトし、ローカルで記事のpathディレクトリのentry.mdを編集することで執筆していきます。
記事のディレクトリ構造
entries ├── 記事のpath │ ├── entry.md │ └── images │ └── .gitkeep
生成された記事ファイルをもとに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では以下の内容を設定しています。
- 記事内で使用している画像(もしくはenechainオフィス画像をランダムに1枚)を背景に
- ブルーを乗算でのせる
- 記事タイトル、執筆者名(もしくはアカウント)を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を記事に記載するようにしています。
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にコメントをつけることで執筆者やレビュアーに実際にはてなブログにアップロードされたときの様子を確認してもらっています。
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プレビューとはてなブログ上でのプレビューを可能にしています。
- 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や言い回しの修正を取り込むことが簡単になり執筆体験が向上しました。
記事の公開
blogsyncでは記事ファイル冒頭のメタデータにあるDraft:
をfalseを設定した状態でpushすると記事が公開されます。
そのため記事の公開はレビュアーのApproveをトリガーにDraft状態を解除、マージをトリガーに公開のワークフローを走らせることで実現しています。
これによってレビューが通っていない状態で意図せず公開されてしまうことを防いでいます。
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-Boilerplateやhatenablog-workflowsを公開しています。ワークフローを構築した時点ではまだ公開されていなかったためenechainでは使っていませんが、こちらを取り入れることでより簡単にGitHubでの管理が可能になると思います。
今後も引き続き執筆体験を改善していくことで、社外への発信を促し、会社のValueであるSocial goodを体現していきたいと思います。
次回 Advent Calendar 24日目は @tanaka-yui による「リッチテキストエディタの概念と採用したEditor.jsの話」です! 乞うご期待!
enechainでは、共にプロダクトを共創していく仲間を募集しています。要項は以下からご確認ください!