2025-10-16

Gist + GitHub Actions で、ブログ記事の投稿を Web で完結させる

今まで、ブログを Markdown で書いて、スクリプトで HTML に変換して Google Blogger に投稿していました。

しかし、自分の PC のスペック不足で、WSL と VS Code と Claude Code を開くと激重なんです。

Gemini に相談してみた

Web で Markdown 書けるやつと言えば Gist かなーと思って、Gist から HTML 変換を挟んで Blogger に投稿できないかと相談してみました。

返答を要約すると「Gist は Git リポジトリなので、クローンできるよ。GitHub Actions で変換と投稿させれば?」とのこと。

ということで、Claude Code に作ってもらいました。


システム設計

アーキテクチャ

以下の 2 つの投稿パターンに対応する設計とした。

パターン A: Gist 経由(GitHub Actions)

Gistで記事執筆
  ↓
GitHub Actions手動実行(Gist URL入力)
  ↓
Gist取得 → HTML変換 → Blogger投稿
  ↓
articles/にMarkdown + meta.json保存

パターン B: ローカル編集

ローカルでMarkdown作成(Claude Code使用可)
  ↓
npm run publish-from-local 実行
  ↓
HTML変換 → Blogger投稿
  ↓
meta.json保存

技術選定

Markdown→HTML 変換

要件として「数式が正しく表示される」ことが必須だったため、以下を採用した。

  • markdown-it: Markdown パーサー
  • KaTeX: 数式レンダリング(サーバーサイドで完結)
  • Prism.js: コードハイライト

KaTeX を選んだ理由は、サーバーサイドで HTML に変換できる点だ。MathJax と異なり、Blogger 投稿時に数式が既に HTML 化されているため、ブラウザ側で JavaScript 実行が不要になる。

メタデータ管理

Gist 経由の記事とローカル作成の記事を統一的に管理するため、以下の形式でメタデータを保存する。

ディレクトリ構成:

articles/
  20251010_article.md          # 記事本体
  20251010_article.meta.json   # メタデータ

meta.json の内容:

{
  "gistId": "a1b2c3d4e5f6g7h8i9j0",
  "gistUrl": "https://gist.github.com/username/...",
  "bloggerPostId": "1234567890123456789",
  "publishedAt": "2025-10-10T12:00:00Z",
  "lastUpdatedAt": "2025-10-10T15:30:00Z"
}

これにより、同じ Gist URL で再実行した場合に既存の Blogger 記事を更新できる。


実装

スクリプト構成

以下のモジュール構成で実装した。

scripts/
  fetch-gist.js           # Gist取得(GitHub API)
  parse-frontmatter.js    # Frontmatter解析
  markdown-to-html.js     # Markdown→HTML変換
  publish-blogger.js      # Blogger API投稿・更新
  manage-metadata.js      # meta.json管理
  publish-article.js      # メインスクリプト(統合)

Gist 取得スクリプト

GitHub API を使用して Gist を取得する。

function fetchGistData(gistId) {
  return new Promise((resolve, reject) => {
    const options = {
      hostname: 'api.github.com',
      path: `/gists/${gistId}`,
      method: 'GET',
      headers: {
        'User-Agent': 'neko-engineering-blog',
        Accept: 'application/vnd.github.v3+json',
      },
    };

    https
      .get(options, (res) => {
        const chunks = [];
        res.on('data', (chunk) => {
          chunks.push(chunk);
        });
        res.on('end', () => {
          const data = Buffer.concat(chunks).toString('utf8');
          if (res.statusCode === 200) {
            resolve(JSON.parse(data));
          } else {
            reject(new Error(`Gist取得失敗: ${res.statusCode}`));
          }
        });
      })
      .on('error', reject);
  });
}

認証なしで Public/Secret Gist の両方を取得できる。

Frontmatter 解析

YAML ライブラリを使わず、正規表現で解析した。

function parseFrontmatter(markdown) {
  const frontmatterRegex = /^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/;
  const match = markdown.match(frontmatterRegex);

  if (!match) {
    throw new Error('Frontmatterが見つかりません');
  }

  const frontmatterText = match[1];
  const content = match[2].trim();

  // titleとtagsを抽出
  const titleMatch = frontmatterText.match(/title:\s*["'](.+?)["']/);
  const tagsMatch = frontmatterText.match(/tags:\s*\[(.*?)\]/);

  return {
    title: titleMatch[1],
    tags: tagsMatch
      ? tagsMatch[1].split(',').map((t) => t.trim().replace(/["']/g, ''))
      : [],
    content,
  };
}

依存関係を減らすため、シンプルな実装とした。

Markdown→HTML 変換

KaTeX と Prism.js を統合した変換処理。

const MarkdownIt = require('markdown-it');
const mk = require('markdown-it-katex');
const Prism = require('prismjs');

function markdownToHtml(markdown) {
  const md = new MarkdownIt({
    html: true,
    linkify: true,
    typographer: false,
    highlight: function (str, lang) {
      if (lang && Prism.languages[lang]) {
        return `<pre class="language-${lang}"><code class="language-${lang}">
          ${Prism.highlight(str, Prism.languages[lang], lang)}
        </code></pre>`;
      }
      return `<pre><code>${md.utils.escapeHtml(str)}</code></pre>`;
    },
  });

  md.use(mk); // KaTeX有効化
  return md.render(markdown);
}

KaTeX の CSS は CDN 経由で読み込む前提とし、HTML 内に埋め込んだ。

Blogger API 投稿

既存記事の更新と新規投稿を統一的に処理する。

async function publishOrUpdate({ title, content, labels = [], postId = null }) {
  const config = await loadConfig();
  const auth = await createAuthClient(config);
  const blogger = google.blogger({ version: 'v3', auth });

  const post = { kind: 'blogger#post', title, content, labels };

  if (postId) {
    // 更新
    const response = await blogger.posts.update({
      blogId: config.blogger.blogId,
      postId,
      resource: post,
    });
    return response.data.id;
  } else {
    // 新規投稿(下書き)
    const response = await blogger.posts.insert({
      blogId: config.blogger.blogId,
      resource: post,
      isDraft: true,
    });
    return response.data.id;
  }
}

メインスクリプト

Gist 経由とローカル実行の両方に対応する。

async function publishFromGist(gistUrl) {
  // 1. Gist取得
  const { gistId, filename, content } = await fetchGist(gistUrl);

  // 2. 既存記事を検索(meta.jsonからgistIdで検索)
  const existingArticlePath = await findArticleByGistId(gistId);
  const existingMetadata = existingArticlePath
    ? await loadMetadata(existingArticlePath)
    : null;

  // 3. Frontmatter解析
  const { title, tags, content: markdownContent } = parseFrontmatter(content);

  // 4. HTML変換
  const html = markdownToHtml(markdownContent);

  // 5. Blogger投稿または更新
  const postId = existingMetadata?.bloggerPostId || null;
  const bloggerPostId = await publishOrUpdate({
    title,
    content: html,
    labels: tags,
    postId,
  });

  // 6. 記事ファイル保存
  // 7. meta.json保存
}

GitHub Actions

手動トリガーで Gist URL を入力する形式とした。

name: Gist to Blogger

on:
  workflow_dispatch:
    inputs:
      gist_url:
        description: 'Gist URL'
        required: true
        type: string

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm install
      - env:
          BLOGGER_CLIENT_ID: ${{ secrets.BLOGGER_CLIENT_ID }}
          BLOGGER_CLIENT_SECRET: ${{ secrets.BLOGGER_CLIENT_SECRET }}
          BLOGGER_BLOG_ID: ${{ secrets.BLOGGER_BLOG_ID }}
          BLOGGER_TOKEN: ${{ secrets.BLOGGER_TOKEN }}
        run: |
          echo '${{ secrets.BLOGGER_TOKEN }}' > blogger-token.json
          node scripts/publish-article.js --gist-url "${{ inputs.gist_url }}"
      - run: |
          git config --local user.email "action@github.com"
          git config --local user.name "GitHub Action"
          git add articles/
          git diff --quiet && git diff --staged --quiet || (git commit -m "記事投稿: Gist経由 [skip ci]" && git push)

GitHub Secrets に以下を設定する必要がある:

  • BLOGGER_CLIENT_ID
  • BLOGGER_CLIENT_SECRET
  • BLOGGER_BLOG_ID
  • BLOGGER_TOKEN (blogger-token.json の内容を JSON 文字列として)

使い方

ローカル実行

npm run publish-from-local articles/20251010_test-article.md

Gist 経由(GitHub Actions)

  1. Gist で記事作成(Frontmatter 形式)
  2. GitHub Actions → "Gist to Blogger" → "Run workflow"
  3. Gist URL を入力して実行

まとめ

  • WSL 起動不要: Gist 経由ならブラウザだけで記事投稿が完結
  • 柔軟な執筆環境: ローカル(Claude Code 使用)と Gist の両方に対応
  • 既存記事の更新: meta.json で記事を管理し、再実行で更新
  • 数式・コード対応: KaTeX + Prism.js でリッチな記事を作成

ということで、だいぶラクできる環境を作れたかなと思います。

0 件のコメント:

コメントを投稿

ラビット・チャレンジ - Stage 3. 深層学習 前編 (Day 1)

提出したレポートです。 絶対書きすぎですが、行間を埋めたくなるので仕方ない。 Rabbit Challenge - Stage 3. 深層学習 前編 (Day 1) 0. 深層学習とは何か この講義(Day1)の内容では、ニューラルネットワークを用いた学習方法として、順...