Hatena-Blog-Workflows-Boilerplate を使って記事をリポジトリ管理し、レビュープロセスや入稿作業を効率化した話

こんにちは!ブログ編集チームの @_kimuson です。

我々はエス・エム・エス テックブログをはてなブログで運用しており、従来はGoogle Documentやesa*1で下書きを書いて入稿をしていました。

今回、はてなさんが公開している Hatena-Blog-Workflows-Boilerplate を一部利用しつつ、我々のワークフローに合わせてカスタマイズすることでブログの入稿やブログの公開に伴う様々な作業を自動化することで記事管理がかなり楽になったので事例を紹介させていただきます!

これまでのフローのペイン

入稿フローの複雑さ

入稿は執筆者の好みでGoogle Documentあるいはesa(markdown)に書いてもらったものをレビューしていましたが、それぞれ入稿やレビュープロセスにペインがありました。

  • Google Documentの場合: 入稿時のセマンティックを正しく反映したMarkdownへ変換したり、見た目の調整をしたりが大変
  • esaの場合:
    • エンジニアが慣れ親しんだmarkdownで書きやすいが、行レベルコメントが利用できないのでレビュープロセスが大変
    • markdownも方言が異なるのでそのまま入稿できることはほとんどない

執筆者が実際のデザインで記事を見られない

執筆者が記事を書いてから下書きとしてはてなブログに入稿されるまでにラグがあり

  • 執筆者が自分のブログデザインの崩れに気づけない(気づくのが遅くなる)
  • 実際に当てはめてみて読んでみることができない

というペインもありました。

品質チェックの負荷

記事の公開前には常に広報ガイドラインに則ったチェックとフォーマットのチェックを手動で行っており、これも負担になっていました。

  • 広報のガイドライン準拠チェック
  • 英数字の前後空白など、スタイルルールの確認

チャットベースのLLMを活用した一部効率化は行っていたものの、コピペや修正の手間は依然として残っており大変でした。

アイキャッチ作成の手間

記事ごとにアイキャッチ画像(OGP画像)を作成して設定していましたが、これもKeynoteで調整して書き出しており労力がかかっていました。

  • 適切な改行位置の決定
  • SNS投稿時の見切れを防ぐ文字サイズ調整

記事をリポジトリで管理することで、こういった面倒な作業を自動化しやすくなります。これらのペインを解消すべく取り組みました。

Hatena-Blog-Workflows-Boilerplate

はてなブログ用の記事管理をリポジトリでやるなら、はてなさんが提供する hatena/Hatena-Blog-Workflows-Boilerplate を利用すると便利です。

このリポジトリをテンプレートにして記事管理リポジトリを作成することで、ボイラープレートに用意されているGitHub Actionsワークフローを利用できます。

  • create-draft: 手動実行でドラフト記事を作成
  • pull-draft: ドラフト記事をpushした際に、はてなブログ側へ変更を反映する
  • pull: はてなブログから公開済みの記事のみを取得
  • push-draft: はてなブログから特定のタイトルの下書き記事を取得
  • push-when-publishing-from-draft: ドラフト記事を公開ステータスでpushすると公開
  • push: 公開済みの記事を更新し、 はてなブログに反映

すでに公開されている記事の同期から、新しいmarkdown記事の作成・入稿・公開まで一通りの機能がオールインワンで提供されており、基本的にはこれをそのまま使用すれば記事管理を行うことができます。

予約投稿機能が使えない

非常に便利なHatena-Blog-Workflows-Boilerplateですが、予約投稿機能を利用できないという点で困りました。

我々は記事の公開は基本的に予約投稿機能を利用しています。

しかしながら

  • 予約投稿がサポートされていない
  • frontmatterのdraftプロパティで公開/非公開が制御される
  • 公開済み記事は draft_entries から entries ディレクトリへの移動される

という仕様になっていました。

Hatena-Blog-Workflows-Boilerplateを利用した上で予約投稿も併用すると、予約投稿によって公開された記事が draft_entries に残り、他記事のリポジトリ操作ではてなブログ側へ意図しない状態変更が起きるリスクもありそうです。

解決アプローチ

考え方を少し変え、「記事の執筆から入稿・公開・公開後の修正まですべて管理する」のではなく、「下書き記事の作成から入稿までを管理する」 という方針に変更しました。

我々のペインは入校後の記事管理にはほとんと存在せず、下書き記事の執筆から入稿までの間に集約されていました。

公開済みの記事管理までスコープを広げて変に複雑にするより、下書き記事の入稿までに振り切る方がシンプルで運用しやすいと考えたため、この方針を採用しました。公開した記事の内容を変更したり調整することもないわけではありませんが、頻度が多いわけではないので割り切っています。

具体的には以下のように実現します:

  • draft_entries ディレクトリのみを使用し、公開済み記事は管理しない(entriesディレクトリ不使用)
    • entriesに関連するワークフローは削除し、一部のワークフローのみ利用(create-draft, push-draftのみ)
  • 記事PRマージ後にファイルを自動で削除する
  • 公開済みの記事について編集する場合はリポジトリを介さずに調整する

この方針により、下書きから入稿までの諸々の手間はリポジトリに寄せて自動化しつつ、下書き→公開のプロセスにはリポジトリは関与させないことで、責務を明確に分離できました。

機能が不足している予約投稿やカテゴリ設定といった公開に関するオペレーションは従来の方針を維持して運用できています。

親子アカウント非対応問題

Hatena-Blog-Workflows-Boilerplateのセットアップは基本的には公式READMEに従って簡単に設定できましたが、一部問題が発生しました。

はてなブログでは親子アカウント機能を利用でき、我々は強すぎる権限を持たせないようにしないため子アカウントを普段使いしています。

しかし、Hatena-Blog-Workflows-Boilerplateでは親子アカウントに対応しておらず、親アカウントのトークンが必要でした。 このため、初期設定時のみ親アカウントを使用してトークンを発行する必要がありました。

編集 URL の修正

Hatena-Blog-Workflows-Boilerplateでは create-draft ワークフローを利用することで記事の下書きファイルとPRを作成し、対応するはてなブログ上のエントリの作成・PR Descriptionにプレビューや編集のURLを添付するところまでやってくれます。

ただし、記事の編集URLがAtomPubのURL形式(https://blog.hatena.ne.jp/bm-sms/sms-tech.hatenablog.com/atom/entry/<id>)で生成されるのですが、これを参照するには親アカウントでないと閲覧できないという問題がありました。

これを解決するため、create-draftのワークフローを拡張し、後続のJobで通常の編集URL形式(https://blog.hatena.ne.jp/bm-sms/sms-tech.hatenablog.com/edit?entry=<id>)に変換するワークアラウンドを実装しました。

name: create draft

on:
  workflow_dispatch:
    inputs:
      title:
        description: "Title"
        required: true

jobs:
  create-draft:
    uses: hatena/hatenablog-workflows/.github/workflows/create-draft.yaml@ce4c0e01255ad9348842e5ce09809c3ec499e43d # v2.0.5
    with:
      title: ${{ github.event.inputs.title }}
      draft: true
      BLOG_DOMAIN: ${{ vars.BLOG_DOMAIN }}
    secrets:
      OWNER_API_KEY: ${{ secrets.OWNER_API_KEY }}

  fix-edit-url:
    # 編集ページ URL が AtomPub ベースの URL になっていて編集チームでアクセスできないので、普段使っている URL 形式に変換する
    # before: https://blog.hatena.ne.jp/bm-sms/tech-bm-sms.hatenablog.com/atom/entry/<ID>
    # after : https://blog.hatena.ne.jp/bm-sms/tech-bm-sms.hatenablog.com/edit?entry=<ID>
    needs: create-draft
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

      - name: Get PR number from create-draft job
        id: get-pr
        run: |
          # 記事タイトルをもとに該当するPRを検索し、最新のものを取得
          PR_NUMBER=$(gh pr list --repo ${{ github.repository }} --author github-actions[bot] --search "in:title \"${{ github.event.inputs.title }}\"" --limit 1 --json number --jq '.[0].number')
          echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT
        env:
          GH_TOKEN: ${{ github.token }}

      - name: Update PR description with correct edit URL and issue link
        run: |
          # 現在のPRのdescriptionを取得
          CURRENT_DESCRIPTION=$(gh pr view ${{ steps.get-pr.outputs.pr_number }} --repo ${{ github.repository }} --json body --jq '.body')
          
          # URLを置換(atom/entry/ → edit?entry=)
          UPDATED_DESCRIPTION=$(echo "$CURRENT_DESCRIPTION" | sed 's|/atom/entry/|/edit?entry=|g')
          
          # PRのdescriptionを更新
          gh pr edit ${{ steps.get-pr.outputs.pr_number }} --repo ${{ github.repository }} --body "$UPDATED_DESCRIPTION"
        env:
          GH_TOKEN: ${{ github.token }}

これにより、子アカウントを利用していても編集URLへアクセスできるようになりました。

不要なワークフローの削除とお掃除機能の実装

下書きの入稿までをスコープとするため、PRをマージした時点で下書き記事は削除を行います。 これはHatena-Blog-Workflows-Boilerplateではサポートされない独自のワークフローなので自前でGitHub Actionsを実装しました。

name: cleanup draft entries after merge

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

jobs:
  cleanup-draft-entries:
    # PRがマージされた場合のみ実行
    if: github.event.pull_request.merged == true
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout repository
        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
        with:
          # PRがマージされた後の最新のmainブランチをチェックアウト
          ref: main
          token: ${{ secrets.GITHUB_TOKEN }}

      - name: Get changed draft files
        id: get-changed-files
        run: |
          # マージされたPRで変更されたdraft_entriesディレクトリ内のファイルを取得
          CHANGED_FILES=$(gh api /repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files \
            --jq '.[] | select(.filename | startswith("draft_entries/")) | .filename' \
            | tr '\n' ' ')
          
          echo "changed_files=$CHANGED_FILES" >> $GITHUB_OUTPUT
          echo "Changed draft files: $CHANGED_FILES"
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - name: Delete draft files
        if: steps.get-changed-files.outputs.changed_files != ''
        run: |
          # 変更されたdraft_entriesディレクトリのファイルを削除
          for file in ${{ steps.get-changed-files.outputs.changed_files }}; do
            if [ -f "$file" ]; then
              echo "Deleting $file"
              rm "$file"
            else
              echo "File $file not found, skipping"
            fi
          done

      - name: Commit and push changes
        if: steps.get-changed-files.outputs.changed_files != ''
        run: |
          # Gitの設定
          git config --local user.email "action@github.com"
          git config --local user.name "GitHub Action"
          
          # 変更をコミット
          git add -A
          
          # 削除されたファイルがある場合のみコミット
          if ! git diff --cached --exit-code > /dev/null; then
            git commit -m "chore: cleanup draft entries after merge of PR #${{ github.event.pull_request.number }}"
            git push origin main
          else
            echo "No changes to commit"
          fi

執筆・入稿作業の効率化

ここまでの対応でリポジトリを用いた記事管理ができるようになりました!

本来やりたかったのは「記事をリポジトリで管理すること」で、ローカル向けのツールやGitHub Actionsを用いた品質管理やレビュープロセスの効率化なので、実際に運用している効率化の仕組みも紹介させていただきます。

textlint を使った文章校正

人間によるレビューで発見される内容を極力減らすため、また「英数字の前後には空白を置く/置かない」と言った機械的なルールを適用するため textlint を導入しています。

日本語関係のルールや、フォーマットに関するルールを追加し、日々運用しながらルールを調整しています。

文法上の細かい指摘などはVSCodeで書くと同時にtextlintの拡張機能によりフィードバックされるので、一部の不適切な記述は執筆者が自分で修正できるようになりました。

GitHub Actions + actions/ai-inference を活用した広報ガイドラインチェック

我々はテックブログを公開するための広報ガイドラインを持っており、公開前に必ずガイドライン違反する記述や内容がないかを編集チームがチェックしています。

このプロセスの負荷を軽減するため、LLMを用いたガイドラインチェックを追加しました。

actions/ai-inference を利用することで、GitHub Actions上で手軽にGitHub ModelsのLLMを利用できます。PRがReady For Reviewになったらワークフローが起動し、LLMがガイドラインと記事の内容を照らし合わせて問題のある箇所をPR上にコメントしてくれるようになっています。

OGP 画像生成の効率化

記事のOGP画像の生成もコマンドラインツール化しました。

完全な自動化は適切な改行位置の設定をすることが難しいため、一部手動で変更するオプションを残した上で自動化をしています。

デフォルトの改行位置の決定には google/budoux を利用しています。budouxに記事ファイルのfrontmatterから取得したタイトルを食わせることで、タイトルを適切な改行可能な位置で分割してくれます。そして予め決めておいた文字数や行数の上限を超えないようにまとめます。

import { loadDefaultJapaneseParser } from "budoux";

const MAX_CHARS_PER_LINE = 30;
const MAX_LINES = 6;

export const separateTitle = (title: string): string[] => {
  // BudouXを使って日本語文章を適切な位置で分割
  const parser = loadDefaultJapaneseParser();
  const segments = parser.parse(title);

  const lines: string[] = [];
  let currentLine = "";

  for (const segment of segments) {
    // 現在の行に新しいセグメントを追加しても30文字以内の場合
    if ((currentLine + segment).length <= MAX_CHARS_PER_LINE) {
      currentLine += segment;
    } else {
      // 30文字を超える場合、現在の行を確定して新しい行を開始
      if (currentLine) {
        lines.push(currentLine);
        currentLine = segment;
      } else {
        // currentLineが空の場合(segmentが30文字を超える場合)
        currentLine = segment;
      }
    }
  }

  // 最後の行を追加
  if (currentLine) {
    lines.push(currentLine);
  }

  // 行数制限の処理
  if (lines.length <= MAX_LINES) {
    return lines;
  }

  // 6行を超える場合、最初の5行を取り、残りを最後の行にまとめる
  const result = lines.slice(0, MAX_LINES - 1);
  const remainingLines = lines.slice(MAX_LINES - 1);
  const lastLine = remainingLines.join("");
  result.push(lastLine);

  return result;
};

適切な改行位置での分解ができれば、あとはOGP画像を生成するだけです。これは前例となる記事がたくさん世に出ているので割愛しますが、node-canvas を用いて、文字が見切れない横幅に収まる範囲で最大のフォントサイズが適用されるように実装しました。

例えば今回の記事タイトルを渡した場合、初回では以下のようになります。

eyecatch_01

今回に関してはやや文字が小さいので手動で調整します。

分割の情報はtemporaryなjsonファイルへ書き出すようにしており、これを変更して再実行することで区切り位置を変更します。

 {
   "lines": [
-    "Hatena-Blog-Workflows-Boilerplate を",
+    "Hatena-Blog-Workflows-Boilerplate",
-    "使って記事をリポジトリ管理し、レビュープロセスや入稿作業を",
+    "を使って記事をリポジトリ管理し、",
-    "効率化した話"
+    "レビュープロセスや入稿作業を効率化した話"
   ]
 }

再実行することで、再度生成されます。

eyecatch_02

良い感じになりました。

こういった形でOGPの画像も手間なく作成できるようにしました。

まとめ

Hatena-Blog-Workflows-Boilerplateをベースに、我々の運用に合わせたカスタマイズを行うことで、テックブログの執筆・レビュープロセスを効率化した事例を紹介させていただきました!

予約投稿やカテゴリ・タグ設定といった公開に関する機能との兼ね合いで、あくまで下書きの入稿までを行う仕組みとして導入を行いましたが、かなり上手くワークしていると感じています。編集チーム内からの評判も良く、テックブログ運営が楽になりました。

ブログ編集チームは各々が開発チームに所属しながら有志として活動しているようなチームなので、こういった効率化を行って運用負荷を下げていくことは今後もやっていきたいなと考えています。

ブログ編集チームの取り組みについて紹介した記事も出ているので良ければ併せて御覧ください!

*1:社内で利用しているMarkdownベースのナレッジSaaS