0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GitHub APIとTypeScriptでMarkdown記事を自動管理して、無限のコンテンツ更新ループを構築した話

0
Posted at

AIを活用したメディア運営を極限まで自動化している皆さん、こんにちは!Futuristic Imagination LLC 代表の佐藤琢也です。

僕の会社では、現在1人でAIオウンドメディア18サイト(9言語展開・年間6,570記事)をNext.js + Gemini API + Vercel Cronで毎日自動運用しています。記事の自動生成はもちろんのこと、SEO対策や公開プロセスの自動化は、もはや生存戦略の核となっています。

そんな中、今回直面した課題は「生成した大量のMarkdown記事を、どうやって効率的に管理し、公開していくか」というものでした。手動でのコミット&プッシュは、もはや現実的ではありません。そこで、GitHub APIをTypeScriptで叩いて、Markdown記事の保存・更新・公開を完全にプログラム化する仕組みを構築しました。

この記事では、僕が実際に実装したGitHub APIを使ったMarkdown記事の自動管理について、その具体的な方法とコードを徹底解説します。「GitHubでのコンテンツ管理で消耗している人」や「メディア運用を自動化して睡眠時間を確保したい」と思っている方は、ぜひ最後までお読みください。

この記事でわかること

  • GitHub APIを使ってプログラムからファイルを作成・更新する方法
  • GitHubのPersonal Access Token(PAT)の安全な管理方法
  • TypeScriptでのGitHub APIクライアントの実装例
  • 大量のMarkdown記事の自動公開・更新パイプラインの構築ヒント
  • Next.jsとGitHubを連携させたコンテンツ管理のベストプラクティス

なんでGitHubで記事管理するの?

「なんで記事をGitHubで管理するの?」って思う人もいるかもしれませんね。僕の答えはシンプルです。

  1. バージョン管理の堅牢性: 記事の変更履歴がすべて残るため、いつでも過去の状態に戻せる。これはAIによる自動生成の試行錯誤で特に重要です。
  2. CI/CDとの相性: GitHubにコミットされたら、VercelなどのCI/CDパイプラインが自動で発火し、デプロイされる。Next.jsサイトとの連携は最高です。
  3. チーム開発のしやすさ: 将来的にライターやエディターが加わった際、Pull Requestベースでのレビューフローを構築しやすい。
  4. オープンソースとの親和性: GitHub Pagesなどで静的サイトとして公開する選択肢も広がる。
  5. 信頼性: GitHubのインフラは非常に堅牢で、SLAも高い。

特に、Next.jsのgetStaticPathsgetStaticPropsと組み合わせることで、GitHub上のMarkdownファイルを直接読み込んで静的サイトを生成できるのは強力です。これこそが、僕が「補給不要の自販機型SaaS」を目指す上で、コンテンツ管理のバックボーンにGitHubを選んだ理由です。

GitHub APIの認証準備:Personal Access Token (PAT) を取得する

まず、プログラムからGitHub APIを叩くには認証が必要です。今回は、最もシンプルで効果的なPersonal Access Token (PAT) を使います。

PATの生成手順

  1. GitHubにログインし、右上のプロフィール画像をクリック
  2. SettingsDeveloper settingsPersonal access tokensTokens (classic) へ移動
  3. Generate new tokenGenerate new token (classic) をクリック
  4. Note (トークンの説明) を入力(例: article-automation-script
  5. Expiration (有効期限) を設定(セキュリティのため短めに設定するか、必要に応じて調整)
  6. Select scopes で必要な権限を選択
    • ファイルを操作するためには、repo スコープのチェックが必要です。これにはrepo:status, repo_deployment, public_repo, repo:invite, security_eventsなども含まれます。
    • 必要最小限の権限を与えるのがセキュリティのベストプラクティスですが、今回はリポジトリ全体を操作するためrepoを選びます。

生成されたトークンは一度しか表示されません。必ず控えて、環境変数として安全に管理しましょう。コードに直接ハードコードするのは絶対にNGです。

# .env.local (Next.jsの場合)
GITHUB_PAT=YOUR_GENERATED_PAT
GITHUB_OWNER=your-github-username
GITHUB_REPO=your-repository-name

TypeScriptでGitHub APIクライアントを実装する

さて、PATの準備ができたら、TypeScriptで実際にGitHub APIを叩いてみましょう。今回はoctokit/rest(GitHub公式のTypeScript向けREST APIクライアント)を使います。これを使うと、型安全にAPIリクエストを構築できます。

依存関係のインストール

npm install @octokit/rest
# または
yarn add @octokit/rest

GitHub APIクライアントの作成

// src/lib/github-client.ts
import { Octokit } from "@octokit/rest";

// 環境変数からGitHubの情報を取得
const GITHUB_PAT = process.env.GITHUB_PAT;
const GITHUB_OWNER = process.env.GITHUB_OWNER;
const GITHUB_REPO = process.env.GITHUB_REPO;

if (!GITHUB_PAT || !GITHUB_OWNER || !GITHUB_REPO) {
  throw new Error("GitHub environment variables are not set.");
}

const octokit = new Octokit({
  auth: GITHUB_PAT,
});

/**
 * 指定されたパスのファイル内容をBase64エンコードして返す
 * @param path ファイルパス
 * @returns Base64エンコードされたファイル内容
 */
const getFileContent = async (path: string): Promise<string | undefined> => {
  try {
    const { data } = await octokit.repos.getContent({
      owner: GITHUB_OWNER,
      repo: GITHUB_REPO,
      path,
    });

    if ("content" in data && typeof data.content === "string") {
      return data.content;
    }
    return undefined;
  } catch (error) {
    if (error.status === 404) {
      console.log(`File not found: ${path}`);
      return undefined; // ファイルが存在しない場合はundefinedを返す
    }
    console.error(`Failed to get file content for ${path}:`, error);
    throw error;
  }
};

/**
 * GitHubにファイルをコミットする関数
 * @param path ファイルパス
 * @param content ファイル内容 (UTF-8文字列)
 * @param message コミットメッセージ
 */
export const commitFileToGitHub = async (
  path: string,
  content: string,
  message: string
) => {
  const encodedContent = Buffer.from(content, "utf8").toString("base64");
  let sha: string | undefined;

  try {
    // まず既存ファイルがあるかチェックし、あればSHAを取得
    const existingFileContent = await getFileContent(path);
    if (existingFileContent) {
      const { data } = await octokit.repos.getContent({
        owner: GITHUB_OWNER,
        repo: GITHUB_REPO,
        path,
      });
      if ("sha" in data) {
        sha = data.sha;
      }
    }

    // ファイルの作成または更新
    const { data } = await octokit.repos.createOrUpdateFileContents({
      owner: GITHUB_OWNER,
      repo: GITHUB_REPO,
      path,
      message,
      content: encodedContent,
      sha, // 既存ファイル更新の場合のみ必要
      branch: "main", // コミット先のブランチ名
    });

    console.log(`Successfully committed file: ${path} at ${data.commit.sha}`);
    return data;
  } catch (error) {
    console.error(`Failed to commit file ${path}:`, error);
    throw error;
  }
};

/**
 * GitHubからファイルを削除する関数
 * @param path ファイルパス
 * @param message コミットメッセージ
 */
export const deleteFileFromGitHub = async (
  path: string,
  message: string
) => {
  try {
    const existingFileContent = await getFileContent(path);
    if (!existingFileContent) {
      console.log(`File not found, skipping deletion: ${path}`);
      return; // ファイルが存在しない場合は何もしない
    }

    const { data } = await octokit.repos.getContent({
      owner: GITHUB_OWNER,
      repo: GITHUB_REPO,
      path,
    });

    if (!("sha" in data)) {
      throw new Error(`Could not get SHA for file: ${path}`);
    }

    const { data: deleteData } = await octokit.repos.deleteFile({
      owner: GITHUB_OWNER,
      repo: GITHUB_REPO,
      path,
      message,
      sha: data.sha,
      branch: "main",
    });

    console.log(`Successfully deleted file: ${path} at ${deleteData.commit.sha}`);
    return deleteData;
  } catch (error) {
    console.error(`Failed to delete file ${path}:`, error);
    throw error;
  }
};

コードのポイント

  • @octokit/rest: GitHub APIとの通信を強力にサポートしてくれるライブラリです。型定義も充実しているので、補完が効いて開発効率が爆上がりします。
  • 環境変数: PATやリポジトリ情報は.envファイルに記述し、process.env経由でアクセスしています。これにより、機密情報の漏洩リスクを低減し、環境ごとの設定変更も容易になります。
  • getFileContent: repos.getContentを使ってファイルの内容を取得します。ファイルが存在しない場合は404エラーを返し、それも考慮して処理しています。
  • commitFileToGitHub: repos.createOrUpdateFileContentsを使ってファイルをコミットします。
    • 既存ファイルを更新する場合は、shaパラメータが必要です。これはGitHubがファイルのバージョンを識別するためのハッシュ値です。事前にgetContentで取得しておく必要があります。
    • ファイルの内容はBase64エンコードして送信する必要があります。Buffer.from(content, "utf8").toString("base64") で簡単に変換できます。
    • branch: "main" はコミット先のブランチを指定しています。
  • deleteFileFromGitHub: repos.deleteFileを使ってファイルを削除します。これも同様にshaが必要です。

実際にMarkdown記事を自動保存・公開してみる

このcommitFileToGitHub関数を使えば、あとは任意のタイミングで呼び出すだけです。僕の運用では、以下のようなフローで利用しています。

  1. Vercel Cronで定期実行: 毎日特定の時間にVercel Cronが発火し、記事生成スクリプトを起動。
  2. Gemini APIで記事生成: 生成AI(現在はGemini 2.5 Flash)を使ってSEOキーワードに基づいたMarkdown記事を生成。
  3. 重複チェック・品質評価: 生成された記事が既存のものと重複していないか、品質が一定基準を満たしているか(Lighthouse等で評価)をチェック。
  4. GitHubにコミット: commitFileToGitHub関数を使って、生成されたMarkdownファイルを指定のGitHubリポジトリのパスにコミット。
    • 例: posts/YYYY/MM/DD/slug.md のようなパスに保存。
  5. Vercel自動デプロイ: GitHubにコミットされたことをトリガーに、Vercelが自動でビルド&デプロイ。
  6. IndexNowで即時インデックス促し: デプロイ後、新記事のURLをIndexNow API経由で検索エンジンに通知。
  7. SNS自動投稿: 記事公開後、生成AIで要約したものをX (旧Twitter) やDiscordに自動投稿。
// src/scripts/generate-and-publish-article.ts
import { commitFileToGitHub } from '../lib/github-client';
import { generateArticleContent } from '../lib/gemini-ai-service'; // 仮のAI記事生成関数
import { format } from 'date-fns';

const run = async () => {
  try {
    console.log("記事生成と公開プロセスを開始します...");

    // 1. AIで記事コンテンツを生成 (実際にはSEOキーワード分析なども入る)
    const { title, content } = await generateArticleContent("最新のAIトレンド");

    const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-*|-*$/g, '');
    const today = format(new Date(), 'yyyy/MM/dd');
    const filePath = `articles/${today}/${slug}.md`;

    const markdownContent = `---
title: "${title}"
date: "${new Date().toISOString()}"
author: "AI-Writer"
---

${content}
`;

    // 2. GitHubにMarkdownファイルをコミット
    await commitFileToGitHub(
      filePath,
      markdownContent,
      `feat: Add new article - ${title}`
    );

    console.log(`記事「${title}」をGitHubにコミットしました: ${filePath}`);

    // ここにIndexNowやSNS自動投稿のロジックが続く...
    // await sendIndexNowNotification(filePath);
    // await postToSocialMedia(title, filePath);

    console.log("記事生成と公開プロセスが完了しました。");
  } catch (error) {
    console.error("記事生成と公開中にエラーが発生しました:", error);
    process.exit(1);
  }
};

run();

ポイント

  • generateArticleContentは、Gemini APIを使って記事本文を生成する僕の自社開発関数を想定しています。
  • date-fnsを使って日付ベースのファイルパスを生成することで、コンテンツが増えても管理しやすくしています。
  • フロントマター(---で囲まれた部分)を含めることで、Next.jsのgray-matterなどのライブラリで記事のメタデータを簡単にパースできます。

この自動化されたパイプラインのおかげで、僕は「コンテンツのアイデア出し」や「システム全体の監視・改善」といった、人間がより創造的な仕事に集中できるようになりました。まさに「極限の少数精鋭・完全自動化モデル」の実現に一歩近づいたと言えるでしょう。

Next.jsでのMarkdownコンテンツの読み込み

GitHubに保存したMarkdownファイルは、Next.jsで簡単に読み込むことができます。これはStatic Site Generation (SSG) の強力なユースケースです。

// pages/articles/[...slug].tsx (例)
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import { GetStaticProps, GetStaticPaths } from 'next';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkHtml from 'remark-html';

interface ArticleProps {
  contentHtml: string;
  data: {
    title: string;
    date: string;
    author: string;
    // 他のフロントマター
  };
}

// すべてのMarkdownファイルのパスを取得
export const getStaticPaths: GetStaticPaths = async () => {
  const articlesDirectory = path.join(process.cwd(), 'articles'); // GitHubリポジトリをクローンしたパスを想定
  // 実際にはGitHub APIを使ってファイルツリーを取得し、ダウンロードするか、
  // ビルド時にCI/CDでリポジトリをクローンする。
  // 簡単のため、ここではローカルにあると仮定。

  const filePaths: string[] = [];
  const walkDir = (dir: string) => {
    const files = fs.readdirSync(dir);
    for (const file of files) {
      const fullPath = path.join(dir, file);
      if (fs.statSync(fullPath).isDirectory()) {
        walkDir(fullPath);
      } else if (file.endsWith('.md')) {
        filePaths.push(fullPath);
      }
    }
  };
  walkDir(articlesDirectory);

  const paths = filePaths.map((filePath) => {
    const relativePath = path.relative(articlesDirectory, filePath);
    const slug = relativePath.replace(/\.md$/, '').split('/');
    return {
      params: { slug },
    };
  });

  return {
    paths,
    fallback: false,
  };
};

// 各記事のデータを取得
export const getStaticProps: GetStaticProps<ArticleProps> = async ({ params }) => {
  const articlesDirectory = path.join(process.cwd(), 'articles');
  const slug = Array.isArray(params.slug) ? params.slug.join('/') : params.slug;
  const fullPath = path.join(articlesDirectory, `${slug}.md`);

  const fileContents = fs.readFileSync(fullPath, 'utf8');

  // フロントマターとMarkdown本文をパース
  const { data, content } = matter(fileContents);

  // MarkdownをHTMLに変換
  const processedContent = await unified()
    .use(remarkParse)
    .use(remarkHtml)
    .process(content);
  const contentHtml = processedContent.toString();

  return {
    props: {
      contentHtml,
      data: data as ArticleProps['data'],
    },
  };
};

// 記事表示コンポーネント
const ArticlePage: React.FC<ArticleProps> = ({ contentHtml, data }) => {
  return (
    <article>
      <h1>{data.title}</h1>
      <p>公開日: {new Date(data.date).toLocaleDateString()}</p>
      <p>著者: {data.author}</p>
      <div dangerouslySetInnerHTML={{ __html: contentHtml }} />
    </article>
  );
};

export default ArticlePage;

ポイント

  • getStaticPathsで、すべてのMarkdownファイルのパスを取得し、動的ルーティングのパスを生成します。
  • getStaticPropsで、各Markdownファイルの内容を読み込みます。
    • gray-matterでフロントマター(YAML形式のメタデータ)と本文を分離。
    • remarkParseremarkHtml (unifiedエコシステム) でMarkdownをHTMLに変換。
  • 本番環境では、VercelがGitHubリポジトリをクローンしてビルドするため、process.cwd()でローカルファイルとしてアクセスできることを前提としています。もしランタイムでGitHubから直接取得したい場合は、getFileContentを使ってAPIから直接Markdownをフェッチすることも可能です(ただし、ビルド時に行うのがパフォーマンス的には有利)。

このように、GitHubをコンテンツの「真実の源泉(Source of Truth)」として活用することで、AIが生成した記事であっても、堅牢かつスケーラブルな運用が可能になります。

まとめ

この記事では、GitHub APIとTypeScriptを組み合わせることで、Markdown記事の生成・保存・公開を完全に自動化する方法について、僕の実体験ベースで解説しました。

  • Personal Access Token (PAT) を使ってGitHub APIの認証を行う
  • @octokit/restライブラリを使ってTypeScriptでGitHub APIを叩く
  • createOrUpdateFileContentsでファイルをコミット、deleteFileで削除
  • 生成AIと組み合わせることで、記事コンテンツの自動生成から公開までの一連のパイプラインを構築
  • Next.jsのSSGと連携し、GitHub上のMarkdownファイルをコンテンツソースとして利用

この仕組みを導入してから、僕のメディア運用は劇的に効率化されました。「同じ手作業を二度繰り返さない」という僕のポリシーが、ここでも最大限に活かされています。AIエージェントが文字通り「部下」として、コンテンツ生成と管理の大部分を担ってくれるため、僕はより本質的な「事業戦略の立案」や「新しい技術の探索」に集中できています。

もし、この記事で紹介したようなGitHubとAIを連携したコンテンツ自動化や、Next.jsへの移行、SNS自動化、そしてGemini APIを活用したデータパイプライン構築にご興味がある方は、ぜひお気軽にご相談ください。僕の会社Futuristic Imagination LLCでは、今回紹介したようなシステムの構築代行も行っています。

Futuristic Imagination LLC サービス紹介

また、僕のYouTubeチャンネルでは、転職・副業・キャリアに関するShorts動画を毎日配信しています。AI時代のキャリア戦略について深掘りしているので、こちらもぜひチェックしてみてください!

佐藤琢也のYouTubeチャンネル

この情報が、皆さんの開発やメディア運用の一助となれば幸いです。LGTMいただけると嬉しいです!

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?