はじめに
この記事は以前作成した自己紹介サイトを改良して、自分のZenn,Qiita,noteに投稿している各記事を取得して自分のサイトに一覧表示する実装を解説した記事になります。
実際のサイト
なぜこの機能を実装したかったか?
各サービスごとに自分の記事が分かれていると自分の記事を一覧で確認することができず、サービスごとに対応するuserIdのurlから見に行かないといけなくて不便だったからです
最初はapiを叩いて直接記事情報を取得しようとしました
Qiitaはapiが公開されており、apiから自分の記事を取得することができたのですが、Zennはこの記事のコメントにurlから叩くことで記事情報をjsonで取得できるとの記述を見つけましたが、実際にアクセスしてみるとCORSエラーになってしまいました。
Access-Control-Allow-Origin
を確認してみると設定されていなかったため、仕様を変えて記事情報を取得できないようにしたのかもしれないなと予想しています(自分のこの理解が間違っていれば教えて下さい🙏)
また、noteは開発者用のapiなどが提供されていないため、RSSという仕組みを使用することで各サービスごとの記事を取得することにしました
RSSとは
RSS(Rich Site Summary)とは、ウェブサイトの更新状況を表示する仕組みや、その仕組みを利用したデータフォーマットです。RSSを利用することで、更新されたページや記事のタイトルや日付、ページの概要などを自動的に取得できます。
実装した内容
以下のような形で一覧表示する事ができました
基本的にはこの記事の通りにrss-parser.mjs
と feed.json
を用意してコマンド実行することで最新のRSSデータをjsonで取得しています
一点自分がハマった点としては、rss-parser
はブラウザ側で実行できるものだと勘違いして最初コンポーネント側で実装してしまい、エラーが解消できず少しハマりました
対策としてはrss-parser
はNode.jsの環境でしか実行できないため、専用のコマンドを用意して、そのコマンドをgithub actionsでcron実行して対応しました。
実装詳細
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));
})();
"scripts": {
"update-rss": "node ./src/rss/rss-parser.mjs",
},
コマンド実行して出力されるrssの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": "エンジニア歴3年目が転職活動をした結果",
"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実行
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
記事一覧表示コンポーネント
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>
);
};
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>
);
};
記事一覧は表示可能になったが、、、
これで自分の記事の一覧を表示できましたが、RSSから返される記事数には上限があるので、自分の投稿記事をすべて表示することができていません
この解決策はapiが提供されていない場合、直接スクレイピングする以外の方法が思いつかないので、一旦RSSで渡される記事のみ表示する形にしています。
次はQiitaやGitHubのコントリビューション数を表示するようにしたいと思います。
参考記事
先人に感謝(T_T)
最後に
私のXアカウントもフォローしていただけるとすごく嬉しいです!
あと記事内で間違っている内容があればコメントいただけるととても助かります