🐡

【GitHub API・Next.js】Zennの記事をGitHub APIで取得してみよう

に公開

はじめに

前回Zenn APIを活用して、記事データを取得しました。しかし、現状のZenn APIではTopicsを取得することはできません。
ZennはGitHubと連携して記事をデプロイすることができます。そこでGitHub APIを使用することで、Topicsを含めた記事情報を取得することができます。

https://zenn.dev/t_oishi/articles/e36041d3af5ea9

※既にブラウザ上でZennの記事を作成している場合、Zenn CLIのセットアップや過去記事のエクスポート等を行う必要があります。

また、GitHub APIの中でもREST APIGraphQL APIがあり、両方のAPIの比較検証も行いました。

https://docs.github.com/ja/rest/about-the-rest-api/comparing-githubs-rest-api-and-graphql-api?apiVersion=2022-11-28

GitHub連携 / データ移行 / Zenn CLIの導入

GitHub連携とZenn CLIの使用方法は公式の記事がありますので、そちらを参照ください。
https://zenn.dev/zenn/articles/zenn-cli-guide
https://zenn.dev/zenn/articles/install-zenn-cli
https://zenn.dev/zenn/articles/connect-to-github
https://zenn.dev/zenn/articles/setup-zenn-github-with-export

過去記事をGitHubへ移行する手順として、一括エクスポート機能があります。
こちらを使用すれば簡単に移行ができます。
https://zenn.dev/settings/export

GitHub APIとは

GitHub APIには主にREST APIとGraphQL APIの2つの形式があり、それぞれ異なる特徴があります。

REST API

各エンドポイントが固定されたデータ構造を返すため、不要な情報も含まれることがあります。
また、各記事でエンドポイントが異なる為、全記事の情報を取得するためには、記事ごとにリクエストする必要があり、レスポンスに時間がかかってしまいます。

記事のエンドポイント

https://api.github.com/repos/{githubUsername}/{repoName}/contents/{path}

https://docs.github.com/ja/rest?apiVersion=2022-11-28
https://docs.github.com/ja/rest/repos?apiVersion=2022-11-28

GraphQL API

単一のエンドポイントで、1回のクエリで必要なデータをすべて取得できます。
必要なフィールドだけを指定して取得できるため、効率的です。例えば、リポジトリ名と記事のタイトルだけが欲しい場合、それらのフィールドのみをリクエストできます。

エンドポイント

https://api.github.com/graphql

https://docs.github.com/ja/graphql/guides/forming-calls-with-graphql

実装

環境変数

.env
GITHUB_TOKEN=アクセストークン

Markdownファイルを処理するためのライブラリをインストールします。

npm install gray-matter remark remark-html

REST API

API取得 ts

markdownParser.ts
import matter from 'gray-matter';
import { remark } from 'remark';
import html from 'remark-html';

export interface ZennMarkdownData {
  slug: string;
  title: string;
  emoji: string;
  type: 'tech' | 'idea';
  topics: string[];
  published: boolean;
  published_at: string;
  content: string;
  htmlContent: string;
}

export async function getMarkdownArticles(
  githubUsername: string,
  repoName: string = 'Zenn'
): Promise<ZennMarkdownData[]> {
  const token = process.env.GITHUB_TOKEN;
  
  try {
    // GitHubのarticlesディレクトリを取得
    const response = await fetch(
      `https://api.github.com/repos/${githubUsername}/${repoName}/contents/articles`,
      {
        headers: {
          'Authorization': `Bearer ${token}`,
          'Accept': 'application/vnd.github.v3+json',
        },
      }
    );

    if (!response.ok) {
      throw new Error(`GitHub API Error: ${response.status}`);
    }

    const files = await response.json();
    const articles: ZennMarkdownData[] = [];

    // .mdファイルのみを処理
    const markdownFiles = files.filter((file: any) => 
      file.name.endsWith('.md') && file.type === 'file'
    );

    for (const file of markdownFiles) {
      try {
        // ファイル内容を取得
        const fileResponse = await fetch(file.download_url);
        const markdownContent = await fileResponse.text();
        
        // Front Matterを解析
        const { data, content } = matter(markdownContent);
        
        // MarkdownをHTMLに変換
        const processedContent = await remark()
          .use(html)
          .process(content);
        const htmlContent = processedContent.toString();

        articles.push({
          slug: file.name.replace('.md', ''),
          title: data.title || 'Untitled',
          emoji: data.emoji || '📝',
          type: data.type || 'tech',
          topics: data.topics || [],
          published: data.published !== false,
          published_at: data.published_at || '',
          content,
          htmlContent,
        });
      } catch (error) {
        console.error(`ファイル処理エラー: ${file.name}`, error);
      }
    }

    // 公開日時でソート
    return articles
      .filter(article => article.published)
      .sort((a, b) => new Date(b.published_at).getTime() - new Date(a.published_at).getTime());

  } catch (error) {
    console.error('GitHub記事取得エラー:', error);
    return [];
  }
}

ページ表示ts

page.ts
import { getMarkdownArticles } from '@/lib/markdownParser';

export default async function GitHubArticlesPage() {
  const githubUsername = 'GitHubのユーザー名';
  const articles = await getMarkdownArticles(githubUsername);

  return (
    <main className="container mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-8">
        GitHub記事 ({articles.length})
      </h1>

      <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
        {articles.map((article) => (
          <div 
            key={article.slug} 
            className="bg-white border rounded-lg p-6 hover:shadow-lg"
          >

            <a 
              href={`https://zenn.dev/${githubUsername}/articles/${article.slug}`}
              target="_blank"
              rel="noopener noreferrer"
              className="block"
            >
              <h3 className="font-bold text-lg mb-3 hover:text-blue-600 transition-colors">
                {article.emoji} {article.title}
              </h3>

              <div className="flex flex-wrap gap-2 mb-4">
                <span className={`px-2 py-1 rounded text-xs ${
                  article.type === 'tech' 
                    ? 'bg-blue-100 text-blue-800' 
                    : 'bg-green-100 text-green-800'
                }`}>
                  {article.type}
                </span>
                
                {article.topics.map((topic, index) => (
                  <span 
                    key={index}
                    className="bg-gray-100 text-gray-700 px-2 py-1 rounded text-xs"
                  >
                    #{topic}
                  </span>
                ))}
              </div>

              <p className="text-sm text-gray-500 mb-2">
                {new Date(article.published_at).toLocaleDateString('ja-JP')}
              </p>
              
              <p className="text-blue-600 text-sm font-medium">
                記事を読む →
              </p>
            </a>
          </div>
        ))}
      </div>
    </main>
  );
}

取得できてますね!
ただ約60件で14秒くらい取得に時間が掛かっています。

GraphQL API

こちらは型定義を分けています。

型定義

github.ts
// GitHubのGraphQLレスポンス型
export interface GraphQLEntry {
  name: string;
  type: string;
  object?: {
    text?: string;
  };
}

export interface GraphQLResponse {
  data?: {
    repository?: {
      object?: {
        entries?: GraphQLEntry[];
      };
    };
  };
  errors?: Array<{ message: string }>;
}

// 記事データの型
export interface GitHubArticle {
  slug: string;
  title: string;
  emoji: string;
  type: 'tech' | 'idea';
  topics: string[];
  published: boolean;
  published_at: string;
}

// Front Matter型
export interface ZennFrontMatter {
  title?: string;
  emoji?: string;
  type?: 'tech' | 'idea';
  topics?: string[];
  published?: boolean;
  published_at?: string;
}

API取得 ts

githubGraphQL.ts
import matter from 'gray-matter';
import { 
  GitHubArticle, 
  GraphQLResponse, 
  GraphQLEntry,
  ZennFrontMatter 
} from '@/types/github';

export async function getArticlesWithGraphQL(
  username: string, 
  repo: string = 'Zenn'
): Promise<GitHubArticle[]> {
  const token = process.env.GITHUB_TOKEN;
  
  if (!token) {
    console.error('GITHUB_TOKEN が設定されていません');
    return [];
  }

  const query = `
    query($owner: String!, $name: String!) {
      repository(owner: $owner, name: $name) {
        object(expression: "HEAD:articles") {
          ... on Tree {
            entries {
              name
              type
              object {
                ... on Blob {
                  text
                }
              }
            }
          }
        }
      }
    }
  `;

  try {
    console.time('GraphQL取得');
    
    const response = await fetch('https://api.github.com/graphql', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${token}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        query,
        variables: { 
          owner: username, 
          name: repo 
        }
      })
    });

    if (!response.ok) {
      throw new Error(`GraphQL API Error: ${response.status}`);
    }

    const result: GraphQLResponse = await response.json();
    
    if (result.errors) {
      console.error('GraphQL Errors:', result.errors);
      return [];
    }

    const entries = result.data?.repository?.object?.entries || [];
    console.log(`${entries.length}個のファイルを取得`);
    
    const articles: GitHubArticle[] = entries
      .filter((entry: GraphQLEntry) => 
        entry.name.endsWith('.md') && 
        entry.type === 'blob' && 
        entry.object?.text
      )
      .map((entry: GraphQLEntry): GitHubArticle | null => {
        try {
          if (!entry.object?.text) return null;
          
          const { data: frontMatter }: { data: ZennFrontMatter } = matter(entry.object.text);
          
          return {
            slug: entry.name.replace('.md', ''),
            title: frontMatter.title || 'Untitled',
            emoji: frontMatter.emoji || '📝',
            type: frontMatter.type || 'tech',
            topics: frontMatter.topics || [],
            published: frontMatter.published !== false,
            published_at: frontMatter.published_at || '',
          };
        } catch (error) {
          console.error(`Front Matter解析エラー: ${entry.name}`, error);
          return null;
        }
      })
      .filter((article): article is GitHubArticle => 
        article !== null && article.published
      )
      .sort((a: GitHubArticle, b: GitHubArticle) => 
        new Date(b.published_at).getTime() - new Date(a.published_at).getTime()
      );

    console.timeEnd('GraphQL取得');
    console.log(`${articles.length}件の記事を処理完了`);
    
    return articles;

  } catch (error) {
    console.error('GraphQL取得エラー:', error);
    return [];
  }
}

ページ表示ts

page.ts
import { getArticlesWithGraphQL } from '@/lib/githubGraphQL';
import { GitHubArticle } from '@/types/github';

export default async function GraphQLArticlesPage() {
  const githubUsername = 'GitHubのユーザー名';
  
  // GraphQLで記事を取得
  const articles: GitHubArticle[] = await getArticlesWithGraphQL(githubUsername);

  return (
    <main className="container mx-auto px-4 py-8">
      <div className="flex justify-between items-center mb-8">
        <h1 className="text-3xl font-bold">
          GraphQL記事取得 ({articles.length})
        </h1>
        <span className="text-sm text-gray-500 bg-green-100 px-3 py-1 rounded">
          高速版
        </span>
      </div>

      {articles.length === 0 ? (
        <div className="text-center py-12">
          <p className="text-gray-500 mb-2">記事が見つかりませんでした</p>
          <p className="text-sm text-gray-400">
            GitHub設定を確認してください
          </p>
        </div>
      ) : (
        <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
          {articles.map((article) => (
            <div 
              key={article.slug} 
              className="bg-white border rounded-lg p-6 hover:shadow-lg transition-shadow"
            >
              <a 
                href={`https://zenn.dev/${githubUsername}/articles/${article.slug}`}
                target="_blank"
                rel="noopener noreferrer"
                className="block"
              >
                <h3 className="font-bold text-lg mb-3 hover:text-blue-600 transition-colors line-clamp-2">
                  {article.emoji} {article.title}
                </h3>

                <div className="flex flex-wrap gap-2 mb-4">
                  <span className={`px-2 py-1 rounded text-xs font-medium ${
                    article.type === 'tech' 
                      ? 'bg-blue-100 text-blue-800' 
                      : 'bg-green-100 text-green-800'
                  }`}>
                    {article.type}
                  </span>
                  
                  {article.topics.map((topic, index) => (
                    <span 
                      key={index}
                      className="bg-purple-100 text-purple-700 px-2 py-1 rounded text-xs font-medium"
                    >
                      #{topic}
                    </span>
                  ))}
                </div>

                {article.published_at && (
                  <p className="text-sm text-gray-500 mb-2">
                    {new Date(article.published_at).toLocaleDateString('ja-JP')}
                  </p>
                )}
                
                <p className="text-blue-600 text-sm font-medium">
                  記事を読む →
                </p>
              </a>
            </div>
          ))}
        </div>
      )}
    </main>
  );
}

こちらもうまく取れました!
取得時間は2秒ほどに短縮できています。

取得時間検証

取得時間を検証するため、下記のログを設定しました。

  console.time('REST API');
  const restArticles = await getMarkdownArticles(githubUsername);
  console.timeEnd('REST API');
  
  console.time('GraphQL');
  const graphqlArticles = await getArticlesWithGraphQL(githubUsername);
  console.timeEnd('GraphQL');

だいぶ差ができましたね。
シンプルな操作にはREST、複雑なデータ取得や効率性を重視する場合にはGraphQLを使うと良いと思います。

おわりに

今回はGitHub APIを使ってZennの記事データを取得し、REST APIとGraphQL APIの実装比較を行いました。
パフォーマンス面ではGraphQLが圧倒的に優秀でしたが、実装の複雑さや学習コストを考慮すると、用途に応じた選択する必要がありそうです。
REST APIはHTTPキャッシュが活用しやすく、シンプルな操作には適しています。GraphQLは複雑な関連データの取得や効率性を重視する場面で真価を発揮します。

GitHubで編集を提案

Discussion