【ポートフォリオ作成】自分のQiita,Zenn,noteの記事をRSSから取得して表示する

## はじめに
この記事は以前作成した自己紹介サイトを改良して、自分のZenn,Qiita,noteに投稿している各記事を取得して自分のサイトに一覧表示する実装を解説した記事になります。

https://qiita.com/app_js/items/110f7ee36eeef9790c81

実際のサイト

https://my-dq-portfolio.vercel.app/sns

## なぜこの機能を実装したかったか?
各サービスごとに自分の記事が分かれていると自分の記事を一覧で確認することができず、サービスごとに対応するuserIdのurlから見に行かないといけなくて不便だったからです

最初はapiを叩いて直接記事情報を取得しようとしました
Qiitaはapiが公開されており、apiから自分の記事を取得することができたのですが、Zennは[この記事のコメント](https://zenn.dev/catnose99/articles/cb72a73368a547756862)にurlから叩くことで記事情報をjsonで取得できるとの記述を見つけましたが、実際にアクセスしてみるとCORSエラーになってしまいました。

`Access-Control-Allow-Origin`を確認してみると設定されていなかったため、仕様を変えて記事情報を取得できないようにしたのかもしれないなと予想しています(自分のこの理解が間違っていれば教えて下さい🙏)

https://x.com/dall_develop/status/1853302346992025717

また、noteは開発者用のapiなどが提供されていないため、RSSという仕組みを使用することで各サービスごとの記事を取得することにしました

**RSSとは**
> RSS（Rich Site Summary）とは、ウェブサイトの更新状況を表示する仕組みや、その仕組みを利用したデータフォーマットです。RSSを利用することで、更新されたページや記事のタイトルや日付、ページの概要などを自動的に取得できます。﻿

## 実装した内容
以下のような形で一覧表示する事ができました

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/706631/a8034795-c9e0-f53a-f52b-a17c7010af47.png)

基本的にはこの記事の通りに`rss-parser.mjs` と `feed.json`を用意してコマンド実行することで最新のRSSデータをjsonで取得しています

https://zenn.dev/hrkmtsmt/articles/fa7d8y9crypnib

一点自分がハマった点としては、`rss-parser`はブラウザ側で実行できるものだと勘違いして最初コンポーネント側で実装してしまい、エラーが解消できず少しハマりました

対策としては`rss-parser`はNode.jsの環境でしか実行できないため、専用のコマンドを用意して、そのコマンドをgithub actionsでcron実行して対応しました。

<details>
<summary>実装詳細</summary>

```javascript:src/rss/rss-parser.mjs
import { writeFileSync } from 'fs';
import Parser from 'rss-parser';
const parser = new Parser();

(async () => {
  const rssFeed = {
    zenn: {
      label: 'Zenn',
      url: 'https://zenn.dev/dirtyman/feed',
      favicon: 'https://zenn.dev/images/logo-transparent.png',
    },
    qiita: {
      label: 'Qiita',
      url: 'https://qiita.com/app_js/feed',
      favicon:
        'https://cdn.qiita.com/assets/favicons/public/production-c620d3e403342b1022967ba5e3db1aaa.ico',
    },
    note: {
      label: 'Note',
      url: 'https://note.com/dall_develop/rss',
      favicon:
        'https://assets.st-note.com/poc-image/manual/note-common-images/production/svg/production.ico',
    },
  };

  const allArticles = [];

  for (const [site, info] of Object.entries(rssFeed)) {
    try {
      const feed = await parser.parseURL(info.url);
      const articles = feed.items.map((item) => ({
        title: item.title || '',
        url: item.link || '',
        date: item.isoDate || '',
        thumbnail: item.enclosure?.url || '',
        favicon: info.favicon,
        site,
      }));
      allArticles.push(...articles);
    } catch (error) {
      console.error(`Error fetching feed for ${site}:`, error.message);
    }
  }

  writeFileSync('src/rss/data.json', JSON.stringify(allArticles, null, 2));
})();

```

```json:package.json
  "scripts": {
    "update-rss": "node ./src/rss/rss-parser.mjs",
  },
```

コマンド実行して出力されるrssのjson

```json:src/rss/data.json
[
  {
    "title": "【全エンジニアに告げる】エンジニア起業のススメ",
    "url": "https://zenn.dev/dirtyman/books/64592e8ad75a14",
    "date": "2023-12-11T07:21:48.000Z",
    "thumbnail": "https://res.cloudinary.com/zenn/image/upload/s--ZE1BsedQ--/g_center%2Ch_280%2Cl_fetch:aHR0cHM6Ly9zdG9yYWdlLmdvb2dsZWFwaXMuY29tL3plbm4tdXNlci11cGxvYWQvYm9va19jb3Zlci84MTI4Y2E5MzIwLmpwZWc=%2Cw_200/v1627283836/default/og-base-book_yz4z02.jpg",
    "favicon": "https://zenn.dev/images/logo-transparent.png",
    "site": "zenn"
  },
  {
    "title": "【AI自動レビューシステム】CodeRabbitを1ヶ月運用してみた感想",
    "url": "https://zenn.dev/dirtyman/articles/e0a159179ec124",
    "date": "2023-12-01T02:23:26.000Z",
    "thumbnail": "https://res.cloudinary.com/zenn/image/upload/s--JrB3l7GD--/c_fit%2Cg_north_west%2Cl_text:notosansjp-medium.otf_55:%25E3%2580%2590AI%25E8%2587%25AA%25E5%258B%2595%25E3%2583%25AC%25E3%2583%2593%25E3%2583%25A5%25E3%2583%25BC%25E3%2582%25B7%25E3%2582%25B9%25E3%2583%2586%25E3%2583%25A0%25E3%2580%2591CodeRabbit%25E3%2582%25921%25E3%2583%25B6%25E6%259C%2588%25E9%2581%258B%25E7%2594%25A8%25E3%2581%2597%25E3%2581%25A6%25E3%2581%25BF%25E3%2581%259F%25E6%2584%259F%25E6%2583%25B3%2Cw_1010%2Cx_90%2Cy_100/g_south_west%2Cl_text:notosansjp-medium.otf_37:%25E6%25A9%258B%25E7%2594%25B0%25E8%2587%25B3%2Cx_203%2Cy_121/g_south_west%2Ch_90%2Cl_fetch:aHR0cHM6Ly9zdG9yYWdlLmdvb2dsZWFwaXMuY29tL3plbm4tdXNlci11cGxvYWQvYXZhdGFyL2JjOTgwMGE1MGIuanBlZw==%2Cr_max%2Cw_90%2Cx_87%2Cy_95/v1627283836/default/og-base-w1200-v2.png",
    "favicon": "https://zenn.dev/images/logo-transparent.png",
    "site": "zenn"
  },
  {
    "title": "【Next.js】iPhoneで撮影した写真の拡張子、HEIFとHEICをJPGに変換する方法",
    "url": "https://zenn.dev/dirtyman/articles/da4cdfb3bfba0c",
    "date": "2023-11-29T02:18:30.000Z",
    "thumbnail": "https://res.cloudinary.com/zenn/image/upload/s--3-lhkN8v--/c_fit%2Cg_north_west%2Cl_text:notosansjp-medium.otf_55:%25E3%2580%2590Next.js%25E3%2580%2591iPhone%25E3%2581%25A7%25E6%2592%25AE%25E5%25BD%25B1%25E3%2581%2597%25E3%2581%259F%25E5%2586%2599%25E7%259C%259F%25E3%2581%25AE%25E6%258B%25A1%25E5%25BC%25B5%25E5%25AD%2590%25E3%2580%2581HEIF%25E3%2581%25A8HEIC%25E3%2582%2592JPG%25E3%2581%25AB%25E5%25A4%2589%25E6%258F%259B%25E3%2581%2599%25E3%2582%258B%25E6%2596%25B9%25E6%25B3%2595%2Cw_1010%2Cx_90%2Cy_100/g_south_west%2Cl_text:notosansjp-medium.otf_37:%25E6%25A9%258B%25E7%2594%25B0%25E8%2587%25B3%2Cx_203%2Cy_121/g_south_west%2Ch_90%2Cl_fetch:aHR0cHM6Ly9zdG9yYWdlLmdvb2dsZWFwaXMuY29tL3plbm4tdXNlci11cGxvYWQvYXZhdGFyL2JjOTgwMGE1MGIuanBlZw==%2Cr_max%2Cw_90%2Cx_87%2Cy_95/v1627283836/default/og-base-w1200-v2.png",
    "favicon": "https://zenn.dev/images/logo-transparent.png",
    "site": "zenn"
  },
  {
    "title": "【Next.js】チーム開発までの環境整備手順",
    "url": "https://zenn.dev/dirtyman/articles/3dbefb5a09a778",
    "date": "2023-11-27T03:44:40.000Z",
    "thumbnail": "https://res.cloudinary.com/zenn/image/upload/s--W42OXPxc--/c_fit%2Cg_north_west%2Cl_text:notosansjp-medium.otf_55:%25E3%2580%2590Next.js%25E3%2580%2591%25E3%2583%2581%25E3%2583%25BC%25E3%2583%25A0%25E9%2596%258B%25E7%2599%25BA%25E3%2581%25BE%25E3%2581%25A7%25E3%2581%25AE%25E7%2592%25B0%25E5%25A2%2583%25E6%2595%25B4%25E5%2582%2599%25E6%2589%258B%25E9%25A0%2586%2Cw_1010%2Cx_90%2Cy_100/g_south_west%2Cl_text:notosansjp-medium.otf_37:%25E6%25A9%258B%25E7%2594%25B0%25E8%2587%25B3%2Cx_203%2Cy_121/g_south_west%2Ch_90%2Cl_fetch:aHR0cHM6Ly9zdG9yYWdlLmdvb2dsZWFwaXMuY29tL3plbm4tdXNlci11cGxvYWQvYXZhdGFyL2JjOTgwMGE1MGIuanBlZw==%2Cr_max%2Cw_90%2Cx_87%2Cy_95/v1627283836/default/og-base-w1200-v2.png",
    "favicon": "https://zenn.dev/images/logo-transparent.png",
    "site": "zenn"
  },
  {
    "title": "【Next.js】middlewareでリダイレクト処理を行いつつ、動的OGPを設定する際にハマった話",
    "url": "https://zenn.dev/dirtyman/articles/9ed469e6be715f",
    "date": "2023-11-24T05:27:55.000Z",
    "thumbnail": "https://res.cloudinary.com/zenn/image/upload/s--k7hMrJP---/c_fit%2Cg_north_west%2Cl_text:notosansjp-medium.otf_55:%25E3%2580%2590Next.js%25E3%2580%2591middleware%25E3%2581%25A7%25E3%2583%25AA%25E3%2583%2580%25E3%2582%25A4%25E3%2583%25AC%25E3%2582%25AF%25E3%2583%2588%25E5%2587%25A6%25E7%2590%2586%25E3%2582%2592%25E8%25A1%258C%25E3%2581%2584%25E3%2581%25A4%25E3%2581%25A4%25E3%2580%2581%25E5%258B%2595%25E7%259A%2584OGP%25E3%2582%2592%25E8%25A8%25AD%25E5%25AE%259A%25E3%2581%2599%25E3%2582%258B%25E9%259A%259B%25E3%2581%25AB%25E3%2583%258F%25E3%2583%259E%25E3%2581%25A3%25E3%2581%259F%25E8%25A9%25B1%2Cw_1010%2Cx_90%2Cy_100/g_south_west%2Cl_text:notosansjp-medium.otf_37:%25E6%25A9%258B%25E7%2594%25B0%25E8%2587%25B3%2Cx_203%2Cy_121/g_south_west%2Ch_90%2Cl_fetch:aHR0cHM6Ly9zdG9yYWdlLmdvb2dsZWFwaXMuY29tL3plbm4tdXNlci11cGxvYWQvYXZhdGFyL2JjOTgwMGE1MGIuanBlZw==%2Cr_max%2Cw_90%2Cx_87%2Cy_95/v1627283836/default/og-base-w1200-v2.png",
    "favicon": "https://zenn.dev/images/logo-transparent.png",
    "site": "zenn"
  },
  {
    "title": "VercelにデプロイしたNext13(App Router)のRSCでのコンソールの見方",
    "url": "https://zenn.dev/dirtyman/articles/dcc68b6bb65609",
    "date": "2023-11-21T04:33:05.000Z",
    "thumbnail": "https://res.cloudinary.com/zenn/image/upload/s--Bcsq7LNM--/c_fit%2Cg_north_west%2Cl_text:notosansjp-medium.otf_55:Vercel%25E3%2581%25AB%25E3%2583%2587%25E3%2583%2597%25E3%2583%25AD%25E3%2582%25A4%25E3%2581%2597%25E3%2581%259FNext13%2528App%2520Router%2529%25E3%2581%25AERSC%25E3%2581%25A7%25E3%2581%25AE%25E3%2582%25B3%25E3%2583%25B3%25E3%2582%25BD%25E3%2583%25BC%25E3%2583%25AB%25E3%2581%25AE%25E8%25A6%258B%25E6%2596%25B9%2Cw_1010%2Cx_90%2Cy_100/g_south_west%2Cl_text:notosansjp-medium.otf_37:%25E6%25A9%258B%25E7%2594%25B0%25E8%2587%25B3%2Cx_203%2Cy_121/g_south_west%2Ch_90%2Cl_fetch:aHR0cHM6Ly9zdG9yYWdlLmdvb2dsZWFwaXMuY29tL3plbm4tdXNlci11cGxvYWQvYXZhdGFyL2JjOTgwMGE1MGIuanBlZw==%2Cr_max%2Cw_90%2Cx_87%2Cy_95/v1627283836/default/og-base-w1200-v2.png",
    "favicon": "https://zenn.dev/images/logo-transparent.png",
    "site": "zenn"
  },
  {
    "title": "VscodeのAll Changesが表示されなくなる",
    "url": "https://qiita.com/app_js/items/1cd03373e02aeb61ccec",
    "date": "2024-09-11T10:54:20.000Z",
    "thumbnail": "",
    "favicon": "https://cdn.qiita.com/assets/favicons/public/production-c620d3e403342b1022967ba5e3db1aaa.ico",
    "site": "qiita"
  },
  {
    "title": "RPG風のポートフォリオサイトを作成してみた!",
    "url": "https://qiita.com/app_js/items/110f7ee36eeef9790c81",
    "date": "2024-08-18T08:02:47.000Z",
    "thumbnail": "",
    "favicon": "https://cdn.qiita.com/assets/favicons/public/production-c620d3e403342b1022967ba5e3db1aaa.ico",
    "site": "qiita"
  },
  {
    "title": "リモートワークでやる気が出ないときの対策",
    "url": "https://qiita.com/app_js/items/ec4d13e4aa1df9cc5bd7",
    "date": "2024-08-08T11:32:09.000Z",
    "thumbnail": "",
    "favicon": "https://cdn.qiita.com/assets/favicons/public/production-c620d3e403342b1022967ba5e3db1aaa.ico",
    "site": "qiita"
  },
  {
    "title": "エンジニア歴３年目が転職活動をした結果",
    "url": "https://qiita.com/app_js/items/117d2f3a7be9eedcafcd",
    "date": "2024-01-17T10:30:26.000Z",
    "thumbnail": "",
    "favicon": "https://cdn.qiita.com/assets/favicons/public/production-c620d3e403342b1022967ba5e3db1aaa.ico",
    "site": "qiita"
  },
  {
    "title": "「読書メモ」考えすぎない人の考え方",
    "url": "https://note.com/dall_develop/n/nf10e5b364fbf",
    "date": "2024-11-03T04:46:42.000Z",
    "thumbnail": "",
    "favicon": "https://assets.st-note.com/poc-image/manual/note-common-images/production/svg/production.ico",
    "site": "note"
  },
  {
    "title": "毎日の楽しみ",
    "url": "https://note.com/dall_develop/n/nde0cf4f59ae7",
    "date": "2024-09-04T13:07:40.000Z",
    "thumbnail": "",
    "favicon": "https://assets.st-note.com/poc-image/manual/note-common-images/production/svg/production.ico",
    "site": "note"
  },
  {
    "title": "私がエンジニアになるまで",
    "url": "https://note.com/dall_develop/n/n5823e4450efb",
    "date": "2024-08-31T11:32:08.000Z",
    "thumbnail": "",
    "favicon": "https://assets.st-note.com/poc-image/manual/note-common-images/production/svg/production.ico",
    "site": "note"
  },
  {
    "title": "ラーメン二郎は最高",
    "url": "https://note.com/dall_develop/n/nceaa6f8f5fb0",
    "date": "2024-08-11T00:11:32.000Z",
    "thumbnail": "",
    "favicon": "https://assets.st-note.com/poc-image/manual/note-common-images/production/svg/production.ico",
    "site": "note"
  },
  {
    "title": "美容院の上手い下手が分からない",
    "url": "https://note.com/dall_develop/n/n23ad23f3afa7",
    "date": "2024-08-10T10:27:26.000Z",
    "thumbnail": "",
    "favicon": "https://assets.st-note.com/poc-image/manual/note-common-images/production/svg/production.ico",
    "site": "note"
  },
  {
    "title": "リモートワークでやる気が出ないとき",
    "url": "https://note.com/dall_develop/n/nbf5599bbc8bd",
    "date": "2024-08-08T11:00:55.000Z",
    "thumbnail": "",
    "favicon": "https://assets.st-note.com/poc-image/manual/note-common-images/production/svg/production.ico",
    "site": "note"
  }
]
```


github actionsでcron実行

```yml:.github/workflows/update-rss.yml
name: Update RSS JSON file

on:
  push:
    branches:
      - main
  schedule:
    - cron: '0 0 * * *' # 1日1回 (午前0時) に実行

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
        with:
          fetch-depth: 0 # すべての履歴をフェッチして変更を記録

      - name: Use Node.js 20.x
        uses: actions/setup-node@v1
        with:
          node-version: 20.x # プロジェクトに合わせて Node.js のバージョンを変更

      - name: Install dependencies
        run: npm install

      - name: Update RSS Feeds
        run: npm run update-rss

      - name: Commit and push RSS update
        run: |
          git config --local user.email "action@github.com"
          git config --local user.name "GitHub Action"
          git add src/rss/data.json
          if [ -n "$(git status -s)" ]; then
            git commit -m "Update RSS JSON file $(date +'%Y-%m-%d %H:%M:%S')"
            git push https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git HEAD:main
          fi

```

記事一覧表示コンポーネント

```tsx:Articles.tsx
import articlesData from '../../rss/data.json';
import { ArticleList } from './ArticleList';

export type Article = {
  title: string;
  url: string;
  date: string;
  thumbnail?: string;
  favicon: string;
  site: string;
};

export const Articles = () => {
  const articles = articlesData as Article[];
  const articlesBySite = {
    zenn: articles.filter((article) => article.site === 'zenn'),
    qiita: articles.filter((article) => article.site === 'qiita'),
    note: articles.filter((article) => article.site === 'note'),
  };

  return (
    <div className="flex flex-col items-center space-y-4">
      <h2 className="text-xl font-bold mt-4">記事一覧</h2>
      <div className="grid gap-4">
        <ArticleList title="Zenn" articles={articlesBySite.zenn} />
        <ArticleList title="Qiita" articles={articlesBySite.qiita} />
        <ArticleList title="note" articles={articlesBySite.note} />
      </div>
    </div>
  );
};

```


```tsx:ArticleList.tsx
import type { Article } from './Articles';
import { FaBookOpen } from 'react-icons/fa';

type ArticleListProps = {
  title: string;
  articles: Article[];
};

export const ArticleList = ({ title, articles }: ArticleListProps) => {
  return (
    <div className="bg-black border-2 border-white rounded-md p-6 w-72 mt-2">
      <div className="flex flex-col items-center justify-center mb-4">
        <FaBookOpen className="w-8 h-8" />
        <h3 className="text-xl font-bold ml-2 mb-2">{title}の記事</h3>
        <ul className="space-y-4 overflow-y-auto max-h-64 w-full">
          {articles.map((article, index) => (
            <a
              key={`${article.site}-${index}`}
              href={article.url}
              target="_blank"
              rel="noopener noreferrer"
              className="block bg-gray-800 rounded-lg p-4 shadow-lg hover:bg-gray-700 transition-colors duration-200"
            >
              <div className="flex items-center">
                <img
                  src={article.favicon}
                  alt={`${article.site} Icon`}
                  className="w-6 h-6 mr-2"
                />
                <span>{article.title}</span>
              </div>
              <p className="text-gray-400 text-sm mt-2">
                更新日: {new Date(article.date).toLocaleDateString()}
              </p>
            </a>
          ))}
        </ul>
      </div>
    </div>
  );
};


```


</details>

## 記事一覧は表示可能になったが、、、

これで自分の記事の一覧を表示できましたが、RSSから返される記事数には上限があるので、自分の投稿記事をすべて表示することができていません

この解決策はapiが提供されていない場合、直接スクレイピングする以外の方法が思いつかないので、一旦RSSで渡される記事のみ表示する形にしています。

次はQiitaやGitHubのコントリビューション数を表示するようにしたいと思います。

## 参考記事

先人に感謝(T_T)

https://zenn.dev/hrkmtsmt/articles/fa7d8y9crypnib

https://zenn.dev/nikaera/articles/hugo-react-dev

## 最後に
私のXアカウントもフォローしていただけるとすごく嬉しいです！
あと記事内で間違っている内容があればコメントいただけるととても助かります

https://x.com/dall_develop