ポートフォリオにQiitaの記事を乗っけよう[Next.js]

ポートフォリオ、みなさん作っていますか?

最近までポートフォリオを作っていなかったのですが、必要になる機会が増えてきたので最近気分転換に作っています。

私はよくQiitaを書いているので、それを載せたいな〜と思った時にブラウザで記事を検索したり、XでQiitaの記事を載っけるとよく下の画像が出てくると思います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3766230/3b934bd3-8365-ec59-ef3c-ff25a533e4ad.png)

このような画像をどう表示するかを今回調べてみました。

# 画像の正体はOGP画像
この画像の正体はOGP画像というものでした。

OGPとは、`Open Graph Protocol`というものでした。

OGPとは、webページがSNSなどにURLが投稿されたときに、そのURLがどこのサービスなのかという補足情報を出すための仕組みです。

補足情報には、タイトルや説明文、そして今回表示したい画像などが含まれます。

OGPは、htmlのheadタグの中に下のようにmetaタグに記述されています。

```html
<meta property="og:title" content="Taskfileについて調べてみる - Qiita">
<meta property="og:image" content="https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-user-contents.imgix.net%2Fhttps%253A%252F%252Fcdn.qiita.com%252Fassets%252Fpublic%252Farticle-ogp-background-afbab5eb44e0b055cce1258705637a91.png%3Fixlib%3Drb-4.0.0%26w%3D1200%26blend64%3DaHR0cHM6Ly9xaWl0YS11c2VyLXByb2ZpbGUtaW1hZ2VzLmltZ2l4Lm5ldC9odHRwcyUzQSUyRiUyRnMzLWFwLW5vcnRoZWFzdC0xLmFtYXpvbmF3cy5jb20lMkZxaWl0YS1pbWFnZS1zdG9yZSUyRjAlMkYzNzY2MjMwJTJGMjMwMGU5YTM4MzA5MDRiZDI0MGJjOGZhNzBkNzNkOWFhMDk2Y2QxMCUyRmxhcmdlLnBuZyUzRjE3MTI2NTU3NDg_aXhsaWI9cmItNC4wLjAmYXI9MSUzQTEmZml0PWNyb3AmbWFzaz1lbGxpcHNlJmZtPXBuZzMyJnM9ZThlNmI2MTZmNDlkNzY2ZjA5MmY1MTBlZTY0YWIzMDc%26blend-x%3D120%26blend-y%3D462%26blend-w%3D90%26blend-h%3D90%26blend-mode%3Dnormal%26mark64%3DaHR0cHM6Ly9xaWl0YS1vcmdhbml6YXRpb24taW1hZ2VzLmltZ2l4Lm5ldC9odHRwcyUzQSUyRiUyRnMzLWFwLW5vcnRoZWFzdC0xLmFtYXpvbmF3cy5jb20lMkZxaWl0YS1vcmdhbml6YXRpb24taW1hZ2UlMkZhNjg5ZWRmNzFlZjRlNTgwYzA5NWEzNTRmYjc3ZDgzOWVkODYxODQxJTJGb3JpZ2luYWwuanBnJTNGMTcyNzgyNzkxMz9peGxpYj1yYi00LjAuMCZ3PTQ0Jmg9NDQmZml0PWNyb3AmbWFzaz1jb3JuZXJzJmNvcm5lci1yYWRpdXM9OCZib3JkZXI9MiUyQ0ZGRkZGRiZmbT1wbmczMiZzPTBmMDViNDlmZGExMmQ4MGVlNzAyZGVkZTliMzE5YzNk%26mark-x%3D186%26mark-y%3D515%26mark-w%3D40%26mark-h%3D40%26s%3D158f9e65692bb63d3891aafe8faea739?ixlib=rb-4.0.0&amp;w=1200&amp;fm=jpg&amp;mark64=aHR0cHM6Ly9xaWl0YS11c2VyLWNvbnRlbnRzLmltZ2l4Lm5ldC9-dGV4dD9peGxpYj1yYi00LjAuMCZ3PTk2MCZoPTMyNCZ0eHQ9VGFza2ZpbGUlRTMlODElQUIlRTMlODElQTQlRTMlODElODQlRTMlODElQTYlRTglQUElQkYlRTMlODElQjklRTMlODElQTYlRTMlODElQkYlRTMlODIlOEImdHh0LWFsaWduPWxlZnQlMkN0b3AmdHh0LWNvbG9yPSUyMzFFMjEyMSZ0eHQtZm9udD1IaXJhZ2lubyUyMFNhbnMlMjBXNiZ0eHQtc2l6ZT01NiZ0eHQtcGFkPTAmcz0zZTA5ZWZhZjk1NWZkZjMyZDU2N2FiYTkwMjFmNDZkOA&amp;mark-x=120&amp;mark-y=112&amp;blend64=aHR0cHM6Ly9xaWl0YS11c2VyLWNvbnRlbnRzLmltZ2l4Lm5ldC9-dGV4dD9peGxpYj1yYi00LjAuMCZ3PTgzOCZoPTU4JnR4dD0lNDBtYW9vejQ0MjYmdHh0LWNvbG9yPSUyMzFFMjEyMSZ0eHQtZm9udD1IaXJhZ2lubyUyMFNhbnMlMjBXNiZ0eHQtc2l6ZT0zNiZ0eHQtcGFkPTAmcz01MjkwZWEwNjQ2M2RjMTZkMjdhMzNkZGVmOGUxYmE2Zg&amp;blend-x=242&amp;blend-y=454&amp;blend-w=838&amp;blend-h=46&amp;blend-fit=crop&amp;blend-crop=left%2Cbottom&amp;blend-mode=normal&amp;txt64=UkNDIHwg56uL5ZG96aSo44Kz44Oz44OU44Ol44O844K_44Kv44Op44OW&amp;txt-x=242&amp;txt-y=539&amp;txt-width=838&amp;txt-clip=end%2Cellipsis&amp;txt-color=%231E2121&amp;txt-font=Hiragino%20Sans%20W6&amp;txt-size=28&amp;s=00413993d768238e9e8d2ff0ac573e6b">
<meta property="og:description" content="皆さんMakefile使っていますか?長いコマンドをプロジェクト毎に短いコマンドとして定義できるツールとして、多くの開発で使われていると思います。近年、そのMakefileの上位互換と言われるT…">
<meta content="https://qiita.com/maooz4426/items/b88548c5f778de866b2c" property="og:url">
```

# 今回実装したいもの

今回はポートフォリオで記事一覧を取得、そしてその記事一覧をOGPに埋め込まれている画像を使って表示するようにしたいと思います。

ポートフォリオの一覧はQiitaAPIで取得、OGP表示はそこに含まれているURLから参照したいと思います。


## QiitaAPIで記事一覧を取得する。
スクレイピングするとCORSで弾かれてしまうので、Qiitaの公式で用意されているQiitaAPIを使って一覧を取得します。

https://qiita.com/api/v2/docs

:::note
### CORSとは
`Cross-Origin Resource Sharing`というセキュリティの仕組みです。
異なるオリジン間でリソースを共有することを制限することができます。

オリジンとは、プロトコル(httpsとか)、ドメインやホスト(qiita.comとか172.0.0.1とか)、ポート番号(:3000とか)で構成されているURLのことを指します。
>例:`http://localhost:8080`とか
:::

## アクセストークンを生成する

QiitaAPIを使用するには、アクセストークンを生成します。

まず、Qiitaのアイコン画面から設定画面に移動します。

下の画像のようにアプリケーションの欄に移動します。

個人ようアクセストークンの`新しいトークンを発行する`を選択します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3766230/9d9cfdb0-6334-3483-f3dd-daac8f638ea5.png)

下の画面に遷移するので、アクセストークンの説明を入力してから発行を押します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3766230/55216d5a-d8a3-ee76-72c9-9321b966aff7.png)


するとアクセストークンが発行されてるので、それをコピーとメモして、.envファイルに記述します。

## 記事一覧の取得を実装

今回叩くAPIのURIは、認証ユーザーの記事一覧を`/authenticated_user/items`d取得できるので、今回はそれを使用したいと思います。

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


下のようなコードを実装してみました。

```features/type/index.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;
  };
};
```

```features/func/qiita.ts
import { QiitaItemResponse } from "@/features/blog/types";
import axios from "axios";

export const fetchMyQiitaURLs = async (page: number) => {
  const response = await axios.get(
    `https://qiita.com/api/v2/authenticated_user/items?page=${page}&per_page=6`,
    {
      headers: {
        Authorization: `Bearer ${process.env.NEXT_PUBLIC_BEARER_TOKEN}`,
      },
    },
  );
  const qiitaUrls: string[] = [];
  const qiitaItems: QiitaItemResponse[] = response.data;
  qiitaItems.map(item => {
    qiitaUrls.push(item.url);
  });
  return qiitaUrls;
};

```

型エイリアスで、取得するレスポンスを定義します。

そこからaxiosでAPIを叩き、そこからレスポンスをコード内取得とします。

QiitaAPIを使用して通信するには、先ほど発行したアクセストークンが必要です。

そのアクセストークンをheaderに入れます。

```typescript
  const response = await axios.get(
    `https://qiita.com/api/v2/authenticated_user/items?page=${page}&per_page=6`,
    {
      headers: {
        Authorization: `Bearer ${process.env.NEXT_PUBLIC_BEARER_TOKEN}`,
      },
    },
  );
```

:::note
### Next.jsでenvファイルを使うときの注意点

`NEXT_PUBLIC`を先頭に入れないとNext.jsでenvファイルを読み込めません。
```.env
NEXT_PUBLIC_BEARER_TOKEN = ********
```
:::


## OGP画像の取得方法

先述の方法で記事一覧は取得できるようになりましたが、先ほどのレスポンスにはQiitaのOGP情報は含まれていません。

先ほどのレスポンスには記事の要素にはURLが含まれています。そのURLにアクセスしてhtmlを取得することでOGP情報を取得することができます。

なので、一覧を取得した後にその要素のurlにアクセスします。

しかし、Next.jsクライアントコンポーネントでは、CORSで弾かれてしまいます。
なのでサーバーコンポーネントでアクセスし、urlを表示する必要があります。

:::note
### クライアントコンポーネントとサーバーコンポーネント

Next.jsは、サーバー機能も持ったフレームワークであるため、ブラウザ側で実行するものとサーバーで実行するもので区別する必要があります。

クライアントコンポーネントは、ブラウザ側でJavaScriptの実行、HTML、CSSの組み立てを行い、レンダリングされるコンポーネントです。
useStateとかのhook(関数名で初めにuseがつくやつ)が使えるようになります。

サーバーコンポーネントは、サーバー側でJavaScriptの実行、HTML、CSSの組み立てを行い、レンダリングされるコンポーネントです。
データフェッチなど、外部データ(DB等々)にアクセスする際に使用します。

|名前|実行側|機能
|---|---|---
|クライアントコンポーネント|ブラウザ側|hookの使用ができる
|サーバーコンポーネント|サーバー側|データフェッチが行える


:::

"use server"をつけることでサーバーコンポーネントにすることができます。

下のようにOGPを取得する関数を作成します。
link-preview-jsというメタ情報を取得しやすくしたライブラリを使用します。

https://www.npmjs.com/package/link-preview-js

```features/types/index.ts
export type OGP = {
  url?: string;
  images?: Array<string>;
  videos?: VideoData[];
  title?: string;
  siteName?: string;
  description?: string;
  mediaType?: string;
};
```

```features/funcs/ogp.ts
"use server";
import { OGP } from "@/features/blog/types";
import { getLinkPreview } from "link-preview-js";

export const fetchOgp = async (url: string) => {
  const data = await getLinkPreview(url, {
    followRedirects: "follow",
  });
  const ogps: OGP = data;
  return ogps;
};
```

## page.tsxで使用する
page.tsxでは下記のように使用しました。

```page.tsx
"use client";
import { Button } from "@/components/ui/button";
import { BlogCard } from "@/features/blog/components";
import { fetchOgp } from "@/features/blog/funcs/ogp";
import { fetchMyQiitaURLs } from "@/features/blog/funcs/qiita";
import { OGP } from "@/features/blog/types";
import { AnimatePresence } from "framer-motion";
import React from "react";
import style from "./style.module.scss";

const BlogsPage = () => {
  const [ogps, setOgps] = React.useState<OGP[]>([]);
  const [page, setPage] = React.useState(1);
  const [_loading, setLoading] = React.useState(true);

  const onUpdateClick = async () => {
    setPage(prev => prev + 1);
    const urls = await fetchMyQiitaURLs(page);

    const ogpPromises = urls.map(url => fetchOgp(url));
    const results = await Promise.all(ogpPromises);
    setOgps(prev => [...prev, ...results]);
  };

  React.useEffect(() => {
    const fetch = async () => {
      try {
        setLoading(true);
        const urls = await fetchMyQiitaURLs(page);

        const ogpPromises = urls.map(url => fetchOgp(url));
        const results = await Promise.all(ogpPromises);

        setOgps(results);
      } catch (error) {
        console.error("Error fetching OGPs:", error);
      } finally {
        setLoading(false);
      }
    };
    fetch();
  }, []);

  return (
    <>
      <AnimatePresence mode="wait">
        <div className={style.titleContainer}>
          <p className={style.title}>
            B<span>L</span>O<span>G</span>S
          </p>
        </div>
        <div className={style.blogsContainer}>
          <div className={style.blogs}>
            {ogps?.map((ogp, index) => {
              if (!ogp?.images?.[0]) return null;

              return <BlogCard key={index} ogp={ogp} />;
            })}
          </div>
        </div>
      </AnimatePresence>
      <div className={style.updateButtonContainer}>
        <Button variant="outline" onClick={onUpdateClick} className={style.updateButton}>
          さらに記事を見る
        </Button>
      </div>
    </>
  );
};

export default BlogsPage;

```

```style.module.scss
@use "@/styles/theme";
@use "@/styles/animation";
.titleContainer{
  display: flex;
  justify-content: center;
  align-items: center;
}
.title{
  width: auto;
  font-size: 3rem ;
  color: theme.$hotblue;
  text-shadow: 0 0 2rem theme.$hotblue;

  border-bottom: 0.1rem solid theme.$hotblue;
  padding: 0 1rem;
  margin: 1rem;
}
.title span{
  @include animation.blinkText
}

.blogsContainer{
  display: flex;
  justify-content: center;
  align-items: center;
}

.blogs {
  display: grid;
  grid-template-columns: 1fr 1fr 1fr;
  gap: 1rem;
}


.blogTitle{
  display: flex;
  flex-wrap: wrap;
}

.card{
  background: theme.$foreground;
  color: theme.$white;
  width: 30rem;
  height: 19rem;
  overflow: hidden;
}

.updateButtonContainer{
  display: flex;
  justify-content: center;
  align-content: center;
}

.updateButton{
  margin: 1rem 0;
  background: theme.$foreground;
}
```

ブラウザがで動的に取得したいため、"use client"を記述しています。
useEffectでレンダリング時にQiitaAPIから一覧で取得、そこからOGPの中に入ってる画像をもらうようにしています。
useStateでロードを管理をしているのは、取得結果を受け取ってそれがうまくレンダリングされなかったため、finallyでuseStateのセッターを使用することでデーターをすべて受け取ってからのレンダリングを促しています。

また、ボタンを押して更新して取れるようにしています。現在のページ数はuseStateで保持するようにするようにして追加取得できるようにしています。

# 最後に

use serverを使うと静的エクスポートができず、GithubPages等の静的ホスティングできるわけではありません。
上のコードでホスティングするには、vercel等のPaasでホスティングするか、use server部分を別でデプロイ・ホスティングする必要があります。

ぜひ参考にしてください!