【Qiita API】Next.js でウェブサイトに Qiita の最新記事を表示してみよう【ISR】

# はじめに

どうも、 yoshii です。

Next.js の記事です。
自分のホームページとかに Qiita の最新記事へのリンクを貼れたらいいなと思ったので、簡単に作ってみます。
今回は ISR という Next.js の機能を使って、高速に、かつAPIの制限に引っかかることなく、かつ更新のたびに手動でビルドする必要なく表示します。
また、 OGP 画像も取得して表示してみます。

# この記事で作るもの

こんな感じです。
これらを `getStaticProps` から Qiita API にアクセスして取得します。

![スクリーンショット 2022-07-10 14.10.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2088889/f54f9cc3-5f63-6e25-aa3c-f41587abfeba.png)

# GitHub

コードだけ見たいって人はこちらのリポジトリを見てください。

https://github.com/yoshiishunichi/qiita-api-nextjs

クローンして動かす際は `.env` の `BEARER_TOKEN` に各自で取得したAPIアクセストークンを入れて試しましょう。

# データフェッチの方針

各方針のわかりやすい説明は賢い人が書いてくれているので読みましょう。

https://zenn.dev/akino/articles/78479998efef55

今回のパターンでは ISR(Incremental Static Regeneration) を使います。
SSR(サーバーサイドレンダリング)すると、アクセスするたびにAPIにアクセスが走るため、大して更新頻度も高くないのに悪い人がめちゃくちゃアクセスするとAPIの制限に引っかかる可能性があります。
SSG(スタティックサイトジェネレーション)だと Qiita に更新があるたびにビルドしなければなりません。
ISR なら、自分で設定した間隔でビルドを行うことが可能になります。
Qiita の記事の表示に厳密な最新情報は不要だと思うので、 ISR で問題ないでしょう。
もし Qiita の記事の投稿を監視するような仕組みを作りたいのであれば、こういうのと webhook とか組み合わせたらできるんですかね…

https://zenn.dev/noraworld/articles/github-to-qiita-by-github-actions

この辺のいいアイデアある人はコメントください。

# 実装手順

## APIアクセストークンの取得

アクセストークンを取得する手順を解説します。

### 1. Qiita の右上のアイコンをクリックして「設定」を選択

![スクリーンショット 2022-07-10 10.29.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2088889/e71dab1b-96f3-4eb1-a959-972feb8e9899.png)

### 2. 左の一覧から「アプリケーション」を選択

![スクリーンショット 2022-07-10 10.29.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2088889/13c17eae-dd5e-3b3d-2a4c-34413b24f25f.png)

個人用アクセストークンの「新しくトークンを発行する」を押しましょう。

### 3. スコープで「read_qiita」を選んで「発行する」を選択

説明は適当でいいです。
スコープは、今回記事の取得にしか使わないため、read_qiitaのみです。他のAPIも使いたい人は適宜追加してください。

![スクリーンショット 2022-07-10 10.30.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2088889/8b7b2a18-343e-d6b8-2104-70439c93c9de.png)

### 4. 個人用アクセストークンを保存

表示されてる文字列は後で使うため、メモ帳でも良いのでどこかに保存しておきましょう。

![image17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2088889/4a66e545-3b2e-f2cb-b940-9ad1ef0eee76.png)

これでアクセストークンの発行完了です。

## Next.js プロジェクトの初期化

普通に `yarn create next-app` しても良いんですが、筆者が過去に書いたこちらの記事に、筆者お気に入りの Lint 設定を追加したテンプレートプロジェクトのリポジトリへのリンクがあるので、適当に clone するなりして使用するのをおすすめします。

https://qiita.com/ganja_tuber/items/95507e658ecfef7e9457

TailwindCSS と React の相性は抜群だと思っているので、筆者が Next.js プロジェクトを初期化する際はいつもこのプロジェクトを使用しています。
よかったら使ってください。

https://github.com/yoshiishunichi/next-lint-template-tailwind

上記のプロジェクトの設定でやっているので、ベースのディレクトリが `src` になっていたりしますが、 `yarn create next-app` でやる人は適宜読み替えてください。

## ライブラリの追加

準備は整ったので、まずは最新記事を取得して表示してみましょう。
必要なライブラリを入れます。

```
yarn add ky jsdom dayjs
yarn add -D @types/jsdom
```

各ライブラリの簡単な説明を以下にまとめます。

### ky
http Client です。別に `axios` とかでもいいですが、筆者は `ky` 推しです。

https://github.com/sindresorhus/ky

### jsdom

OGP画像を取得するために使います。

https://github.com/jsdom/jsdom

以下の記事の方法でOGP画像を取得するのですが、node.jsだと `DOMParser` がないよって怒られるので `jsdom` を追加して対処します。

https://qiita.com/ksyunnnn/items/bfe2b9c568e97bb6b494

### dayjs

ISR の動作をわかりやすくするために使います。
日付のフォーマットのために使うのですが、いらない人は普通に js デフォルトの `Date` 使って良いです。

https://github.com/iamkun/dayjs

## トークンと URL を環境変数で定義

Next.js だとごちゃごちゃ設定せずに `.env` を読み込んでくれるので以下のファイルを用意するだけで良いです。

```.env
QIITA_API_URL=https://qiita.com/api/v2/authenticated_user/items
BEARER_TOKEN={トークン}
```

Next.js の環境変数について詳しく知りたい人は以下のドキュメントを読みましょう。

https://nextjs-ja-translation-docs.vercel.app/docs/basic-features/environment-variables

Qiita API の `authenticated_user/items` にアクセスすることで、認証中のユーザーの記事情報を取得できます。
API のドキュメントは以下です。

https://qiita.com/api/v2/docs#get-apiv2authenticated_useritems

## レスポンスの型を用意

Qiita API　の items エンドポイントのレスポンスの型 `QiitaItemResponse` と、レスポンスを実際に表示するのに使いそうな形に変換した　型 `ParsedQiitaItem`　を定義します。
わざわざレスポンスと表示に使うデータで型を分ける意図としては、 `rendered_body` の文字数がエグいので、これをフロントエンドに持ち込みたくないというのが主ですね。

```typescript:src/types/index.d.ts
export type QiitaItemResponse = {
  coediting: boolean;
  comments_count: number;
  created_at: string;
  id: string;
  likes_count: number;
  page_views_count: number;
  private: boolean;
  reactions_count: number;
  rendered_body: string;
  tags: { name: string; versions: [] }[];
  title: string;
  updated_at: string;
  url: string;
  user: {
    description: string;
    facebook_id: string;
    followees_count: number;
    followers_count: number;
    github_login_name: string;
    id: string;
    items_count: number;
    linkedin_id: string;
    location: string;
    name: string;
    organization: string;
    permanent_id: number;
    profile_image_url: string;
    team_only: boolean;
    twitter_screen_name: string;
    website_url: string;
  };
};

export type ParsedQiitaItem = {
  coediting: boolean;
  comments_count: number;
  created_at: string;
  id: string;
  likes_count: number;
  ogpImageUrl: string;
  page_views_count: number;
  private: boolean;
  reactions_count: number;
  tags: { name: string; versions: [] }[];
  title: string;
  updated_at: string;
  url: string;
};
```

## OGPの画像を表示できるように設定

`next/image` コンポーネントでOGP画像を表示できるように、 `next.config.js` の `images.domains` プロパティに OGP 画像のドメインを追加します。

```javascript:next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    domains: ["qiita-user-contents.imgix.net"],
  },
  reactStrictMode: true,
};

module.exports = nextConfig;
```

`next/image` の domains　については以下のドキュメントにも書かれています。

https://nextjs.org/docs/api-reference/next/image#domains

## 記事情報を取得して表示

ようやく記事情報を表示します。
コードは以下です。

```tsx:src/pages/index.tsx
import dayjs from "dayjs";
import { JSDOM } from "jsdom";
import ky from "ky";
import Image from "next/image";

import { ParsedQiitaItem, QiitaItemResponse } from "types";

import type { GetStaticProps, NextPage } from "next";

type HomeProps = {
  generatedAt: string;
  qiitaItems: ParsedQiitaItem[];
};

const Home: NextPage<HomeProps> = ({ qiitaItems, generatedAt }) => {
  return (
    <div>
      <h1>更新日時: {generatedAt}</h1>
      <div>
        {qiitaItems.map(({ id, likes_count, ogpImageUrl, url, title }) => {
          return (
            <div key={id}>
              <a href={url} rel="noreferrer" target="_blank">
                <Image
                  alt={`${title}のOGP画像`}
                  height={630}
                  layout="responsive"
                  src={ogpImageUrl}
                  width={1200}
                />
              </a>
              <h2>{likes_count} LGTM</h2>
            </div>
          );
        })}
      </div>
    </div>
  );
};

export const getStaticProps: GetStaticProps<HomeProps> = async () => {
  const jsdom = new JSDOM();
  const apiUrl = `${process.env.QIITA_API_URL}?per_page=4`;
  const res = await ky.get(apiUrl, {
    headers: {
      Authorization: `Bearer ${process.env.BEARER_TOKEN}`,
    },
  });
  const qiitaItems = (await res.json()) as QiitaItemResponse[];
  const ogpUrls: string[] = [];
  for (let i = 0; i < qiitaItems.length; i++) {
    const { url } = qiitaItems[i];
    const res = await ky.get(url);
    const text = await res.text();
    const el = new jsdom.window.DOMParser().parseFromString(text, "text/html");
    const headEls = el.head.children;
    Array.from(headEls).map((v) => {
      const prop = v.getAttribute("property");
      if (!prop) return;
      if (prop === "og:image") {
        ogpUrls.push(v.getAttribute("content") ?? "");
      }
    });
  }
  const parsedQiitaItems: ParsedQiitaItem[] = qiitaItems.map(
    (
      {
        coediting,
        comments_count,
        created_at,
        id,
        likes_count,
        page_views_count,
        tags,
        title,
        updated_at,
        url,
        reactions_count,
        private: _private,
      },
      i,
    ) => {
      const parsedItem: ParsedQiitaItem = {
        coediting,
        comments_count,
        created_at,
        id,
        likes_count,
        ogpImageUrl: ogpUrls[i],
        page_views_count,
        private: _private,
        reactions_count,
        tags,
        title,
        updated_at,
        url,
      };
      return parsedItem;
    },
  );
  const generatedAt = dayjs().format("YYYY-MM-DD HH:mm:ss");

  return {
    props: { generatedAt, qiitaItems: parsedQiitaItems },
    revalidate: 60 * 10,
  };
};

export default Home;
```

これで、 `yarn dev` して http://localhost:3000/ にアクセスすると…

![スクリーンショット 2022-07-10 12.09.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2088889/52df00a2-02c4-51e1-9cc6-8689ded6087b.png)

ｷﾀ━━━━(ﾟ∀ﾟ)━━━━!!

処理の流れを簡単に説明します。

### 1. `getStaticProps` で Qiita API から記事の情報を取得

ここで最新記事4件の情報を配列で取得しています。

```tsx:src/pages/index.tsx
const apiUrl = `${process.env.QIITA_API_URL}?per_page=4`;
const res = await ky.get(apiUrl, {
   headers: {
      Authorization: `Bearer ${process.env.BEARER_TOKEN}`,
    },
  });
const qiitaItems = (await res.json()) as QiitaItemResponse[];
```

### 2. 各記事のOGP画像を取得

各記事のURLから `DOMParser` で `og:image` のURLを取得して、 `ogpUrls` に配列として格納しています。

```tsx:src/pages/index.tsx
const ogpUrls: string[] = [];
for (let i = 0; i < qiitaItems.length; i++) {
    const { url } = qiitaItems[i];
    const res = await ky.get(url);
    const text = await res.text();
    const el = new jsdom.window.DOMParser().parseFromString(text, "text/html");
  const headEls = el.head.children;
  Array.from(headEls).map((v) => {
      const prop = v.getAttribute("property");
      if (!prop) return;
      if (prop === "og:image") {
        ogpUrls.push(v.getAttribute("content") ?? "");
      }
  });
}
```

### 3. 表示用に記事情報を変換

APIから返却されたデータから不要なプロパティを削除して、さっき取得した OGP 画像の URL を含めたものを表示用の情報として新たに定義します。

```tsx:src/pages/index.tsx
const parsedQiitaItems: ParsedQiitaItem[] = qiitaItems.map(
    (
      {
        coediting,
        comments_count,
        created_at,
        id,
        likes_count,
        page_views_count,
        tags,
        title,
        updated_at,
        url,
        reactions_count,
        private: _private,
      },
      i,
    ) => {
      const parsedItem: ParsedQiitaItem = {
        coediting,
        comments_count,
        created_at,
        id,
        likes_count,
        ogpImageUrl: ogpUrls[i],
        page_views_count,
        private: _private,
        reactions_count,
        tags,
        title,
        updated_at,
        url,
      };
      return parsedItem;
    },
);
```

### 4. `getStaticProps` の戻り値でISRを有効化

`generatedAt` で、再生成された日時を取得しています。
`revalidate` で再生成する間隔を決めています。
60 * 10 秒ごとにアクセスがあれば再生成を行うということなので、最短でも10分以上の間隔をあけて再生成が行われるという感じです。
正直、Qiitaの記事なんてどれだけ頻繁でも1日に1記事くらいだと思うので、実際はもっと長くても良いと思いますが、今回は動作が分かりやすくなるように10分に設定しています。

```tsx:src/pages/index.tsx
const generatedAt = dayjs().format("YYYY-MM-DD HH:mm:ss");

return {
    props: { generatedAt, qiitaItems: parsedQiitaItems },
    revalidate: 60 * 10,
};
```

Qiita API の利用制限については以下のドキュメントに書かれています。
認証していれば1時間に1000回呼び出せるらしいので、単純計算で `revalidate: 60 * 0.06` までは安全に使えると考えて良いと思います。
まあ、こんなギリギリを攻める意味はないですが。

https://qiita.com/api/v2/docs#%E5%88%A9%E7%94%A8%E5%88%B6%E9%99%90

### 5. `getStaticProps` から受け取った情報の表示

あとは、フロントエンドで情報を表示するのみです。
`generatedAt` に、これらの情報が更新された日時が格納されています。
ただし、ISR は production 環境でのみ有効となるため、`yarn dev` では常に現在時刻が表示されるはずです。
動作確認については後述します。

```tsx:src/pages/index.tsx
const Home: NextPage<HomeProps> = ({ qiitaItems, generatedAt }) => {
  return (
    <div>
      <h1>更新日時: {generatedAt}</h1>
      <div>
        {qiitaItems.map(({ id, likes_count, ogpImageUrl, url, title }) => {
          return (
            <div key={id}>
              <a href={url} rel="noreferrer" target="_blank">
                <Image
                  alt={`${title}のOGP画像`}
                  height={630}
                  layout="responsive"
                  src={ogpImageUrl}
                  width={1200}
                />
              </a>
              <h2>{likes_count} LGTM</h2>
            </div>
          );
        })}
      </div>
    </div>
  );
};
```

## ISR の動作確認

ISR の動作をローカルサーバーで確認するには、以下のコマンドで起動して、 http://localhost:3000/ にアクセスします。

```
yarn build
yarn start
```

すると、更新日時の表示が以下のようになるはずです。
`getStaticProps` の revalidate には 60 * 10 を指定したものとします。

1. 【初回アクセス】 ビルド時の日時が表示される
2. 【次回以降 10 分以内のアクセス】 変わらずビルド時の日時が表示される
3. 【10 分以降のアクセス】 変わらずビルド時の日時が表示される
    - ただしサーバーサイドではビルドが走っている
4. 【次のアクセス】 前回のアクセス時の日時が新たに表示される

したがって、3以降にアクセスした場合、3のタイミングの Qiita の最新情報が新たに表示されるということです。
これにより、そこそこ正確に最新情報を取得し、かつ高速に表示を行えるというわけです。

## TailwindCSS で簡単にデザインを設定

TailwindCSS で、簡単にスタイルを適用してみましょう。
まずは、Qiitaの色を `tailwind.config.js` に追加します。

```tailwind.config.js
/**
 * @type {import('@types/tailwindcss/tailwind-config').TailwindConfig}
 */
module.exports = {
  content: ["./src/pages/**/*.{js,ts,jsx,tsx}", "./src/components/**/*.{js,ts,jsx,tsx}"],
  plugins: [],
  theme: {
    extend: {
      colors: {
        qiita: "#59bb0c",
      },
    },
  },
};
```

そして、 `src/pages/index.tsx` に className を追加します。

```tsx:src/pages/index.tsx
const Home: NextPage<HomeProps> = ({ qiitaItems, generatedAt }) => {
  return (
    <div className="mx-auto max-w-screen-md">
      <h1>更新日時: {generatedAt}</h1>
      <div className="flex flex-wrap gap-y-12">
        {qiitaItems.map(({ id, likes_count, ogpImageUrl, url }, i) => {
          return (
            <div className={`w-full p-0 sm:w-1/2 ${i % 2 === 0 ? "sm:pr-2" : "sm:pl-2"}`} key={id}>
              <a
                className="block overflow-hidden rounded-lg border-2 border-gray-300 hover:opacity-50"
                href={url}
                rel="noreferrer"
                target="_blank"
              >
                <Image
                  alt="Qiita記事のogp画像"
                  height={630}
                  layout="responsive"
                  src={ogpImageUrl}
                  width={1200}
                />
              </a>
              <h2 className="mt-2 h-8 w-24 rounded-lg bg-qiita text-center font-bold leading-8 text-white">
                {likes_count} LGTM
              </h2>
            </div>
          );
        })}
      </div>
    </div>
  );
};
```

最終的にはこんな感じです。
いいですね。

![スクリーンショット 2022-07-10 14.10.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2088889/cef7ba47-6b82-a29b-dd3c-bb2b3c9b8584.png)


## 最後に

今回は ISR で自分の Qiita の記事情報を表示してみました。
自分のホームページに外部サービスの更新状況を反映させたい時は、そこまで厳密なリアルタイム性は求められないと思うので　ISR が便利なんじゃないかなあと思って書いてみました。
質問や、より良い手法等あればコメントで教えてもらえると嬉しいです。

また、友達が少ないので、よかったら Twitter で友達になってください。

https://twitter.com/ganja_tuber