🍱

チーム個々人のテックブログをRSSで集約するサイトを作った(Next.js)

2020/10/24に公開
17

先日、こんな記事を見かけました。

テックブログは続かない - note.com

採用目的でテックブログを始めたものの、時間の経過とともに古い記事ばかりになる or すでに退職している社員の記事ばかりになる…というのはよく見かける光景です。

目の前のタスクが積み上がっている状況で、業務時間内にブログを書く時間を取るのはなかなか難しいと思います。

そうは言っても業務時間外に無償で会社のブログに書くのもなかなか気乗りしません。「数年以内に転職するかもしれない」という気持ちがあればなおさらです。記事が転職しても自分のものとして残るのであれば、書くモチベーションは上がるのかもしれません。


その後、こんなツイートを見かけました。

企業のテックブログと言えば「会社がひとつブログを作って、みんなでそこに投稿する」という形が当たり前になっていますが、たしかに個々人の投稿を集約する場所を用意するだけでも良いのかもしれません。

もう少し調べてみるとHERP社のテックブログはまさにそのような形になっています。メンバーは個人ブログなど自分の好きな場所に記事を投稿し、会社のテックブログはその記事へのリンクを集約して表示するハブの役割をすると。とても良いですね。

チームメンバーのテックブログをRSSで集約するサイトを作った

RSSを辿って社内メンバーの個人ブログを集約するというのは、技術的にそこまで難しくありません。最近Zennの開発ばかりやっているので、息抜きがてらスターター的なものを作ってみました。


デモサイトはこんな感じ

デモサイト →
GitHubリポジトリ →

デモサイトを見ていただければ分かると思いますが、個々人が投稿した記事へのリンクを集めた入り口のような場所になっています。

各メンバーは、Zenn、Qiita、Medium、note、はてなブログなど、自分の好きな場所に、自分の記事として投稿できます。RSSを取得できさえすれば、どこに投稿してもOKというわけです。

技術的な構成

サイトの構築にはNext.js(TypeScript)を使いました。以下のような流れで静的なサイトがビルドされるようになっています。

  1. 各メンバーのRSSのURLから投稿データのフェッチする
  2. 1をまとめて投稿データ一覧のjsonファイルを作成する
  3. Next.jsで静的サイトとしてビルドする

メンバーのRSSの登録

ソースコードを見ていただくと早いと思いますが、members.tsというファイルの中で各メンバーのプロフィールとRSSのURL一覧を登録する形になっています。

[{
  name: "メンバーの名前",
  role: "役職名",
  sources: [
    "https://zenn.dev/catnose99/feed",
    "https://medium.com/feed/@catnose99",
  ]
}]

👆sourcesの部分にRSSのURLを指定します。複数のURLを指定することもできます。

正規表現で一部の記事を除外できるように

一部の記事は会社のテックブログから除きたい(or含めたい)こともあると思うので、下記のように正規表現を指定することでフィルターをかけられるようにしました。

[{
  ...
  sources: [
    "https://zenn.dev/catnose99/feed",
    "https://medium.com/feed/@catnose99",
  ],
  includeUrlRegex: "含めたい記事のURLにマッチする正規表現",
  excludeUrlRegex: "除きたい記事のURLにマッチする正規表現"
}]

yarn build:postsが実行されたときに、指定内容をもとにRSSから投稿のメタデータ一覧をフェッチしてposts.jsonという記事の情報をまとめたファイルが生成されます。RSSのパースはrss-parserというパッケージを使うと簡単です。

Next.jsで静的サイトをビルドする

Next.jsを使えば、静的なサイトも簡単に作れます。今回は上述の通りposts.jsonという投稿データ一覧がディレクトリ内に存在するため、ここから必要なデータをimportして表示するだけです。

Next.jsでアプリを作るときに頻繁に使いがちなgetInitialPropsgetStaticPropsなどもほとんど必要ありません。

デプロイ

デモサイトはVercelにデプロイしました。npm run buildyarn build)さえホスティング前に実行できれば、デプロイ先はどこでもOKです。

チームで運営する場合には、CI/CD環境を整えると管理しやすそうです。新しく社員が入ってきたときに「個人ブログのRSSのURLを追加してプルリク投げておいて」とお願いできるとお互い楽ですね。

そういう意味でリポジトリとの連携がしやすいVercelやNetlifyなどがおすすめです。

定期的に自動ビルドする

実際の運用では、投稿一覧を更新するために、定期的に(1日に1回など)自動でビルドを行う必要があります。

  • Vercelの場合、GitHub Actionsの「cron」を使えば、定期的な自動デプロイを楽に設定できます。詳しくはGitHubのDiscussionが参考になると思います。

  • Netlifyの場合はAuto trigger deploys on Netlifyのような方法で自動デプロイできます。

ライセンス

今回作ったものはオープンソースです。Forkしてご自由にお使いください。

チームでなくとも個人で使っていただくのも良いかもしれません。たとえば、noteとMediumとZennに投稿している方は、同じ仕組みを使って一箇所に投稿一覧をまとめることができます。

※ 真っ先に変えたいのは配色だと思います。使用する色の数を抑えつつ、カラーコードはCSS変数で管理しているため、比較的変更しやすいと思います。

https://github.com/catnose99/team-blog-hub

Discussion

坦々狸坦々狸

質問ってここで良いのでしょうか?

私もこれを使って自分のブログを1箇所で見れるようにしてみました。

https://blog-hub.vercel.app/

ありがとうございます。

質問というのは最初の頃はうまく動いてるように見えたんですが最近zennへの投稿数と取得されてる記事の数が合わない事に気づいたのです

自分でも調べてみたのですがJS系の開発経験もなく原因がよくわかりませんでした。

私がやったのはposts.tsのfetchFeedItems関数に

posts.ts
console.log(feed?.items?.length);

を追加してビルド時に取得できた記事の数を表示させてzennの自分の記事数と比較したり

posts.ts
  const test = await parser.parseURL("https://zenn.dev/他の人のID/feed");
  console.log(test?.items?.length);

として他の人のRSSではどうかと確認したりしたんですがどちらも記事数が合わなくなってしまいました。

またrss-parserという外部モジュールが記事を認識できていないのが原因かと思い
package.jsonのバージョン指定の部分を

package.json
    "rss-parser": "^3.12.0",

に書き換えて最新のものを取得し直してみたりしましたが結果が変わりませんでした。

この段階で手詰まり状態に陥ってしまったのですが
なにか他に試せるようなことがあればご教授願えないでしょうか?

坦々狸坦々狸

すいません自己解決しました
zennのRSSは最新15件だけ返却されるんですね😓
自分のTOPページに出てるArticlesの数と比較してたので気づきませんでした
ふと実際のRSSのitemフィード数えたら15件だったので表示出来てました
お騒がせしました😖

catnosecatnose

ありがとうございます。

もしかするとなのですが、RSSの最大表示件数によるものかもしれません。Zennに関わらずほとんどの投稿サービスではRSSの最大表示件数が10〜20程度に制限されています。RSSリーダーが最大10程度までしかフェッチを行わないため、パフォーマンスのために制限を設ける形になっています。

ZennでもRSSに載るアイテム数は最大15となっています。noteだと25みたいですね。Zennの方でURLに?limitをつけることで最大アイテム数を変えられるようにできないか検討してみますね。

noteの方はどうにもできませんが…

catnosecatnose

あ、コメントを見逃していました!ちなみに、以下のURLを叩くとtantan_tanukiさんの全ての記事一覧が取得できます。jsonをいじる必要は出てきますが、それでもよければご活用ください🙏

https://zenn.dev/api/articles?username=tantan_tanuki&order=latest
坦々狸坦々狸

ただの勘違いだったのに丁寧な返信ありがとうございます!
全記事取得するAPIがあるんですか
フロントエンド周りはかなりスキル低いので頑張ってみます😆

catnosecatnose

遅くなってしまいましたが、フィードページにおいてhttps://zenn.dev/ユーザー名/feed?all=1のようにall=1というクエリ文字列を指定することで全ての投稿アイテムを出力できるようになりました。

坦々狸坦々狸

おぉ!ありがとうございます
こういう隠しコマンドみたいなの面白いですw

坦々狸坦々狸

早速取り込みました
記事数が多くなるとLOAD MOREボタンで記事追加読み込み出来るの初めて知りましたw

坦々狸坦々狸

何度もすいません。
記事内容と違う内容の相談になってきてしまうのですが

私の感じてた違和感がなんとなく掴めてきたのでご相談させて下さい。

https://zenn.dev/tantan_tanuki/articles/4660bcd088f5ac

この記事の内容なのですが
継続して確認していた所たまに過去のRSSが取得される事象が起きて
それが反映されているのではないかという気がします。

以下を見ていただきたいのですが

$ xmllint --xpath "/rss/channel/lastBuildDate/text()" <(wget -O - -q https://zenn.dev/catnose99/feed)
Thu, 08 Apr 2021 00:50:21 GMT
$ xmllint --xpath "/rss/channel/lastBuildDate/text()" <(curl -s https://zenn.dev/catnose99/feed)                                                                                   
Wed, 07 Apr 2021 21:15:41 GMT

丁度catnoseさんのRSSがずれていたので確認用に使わせていただきました🙇

こんな感じでcurlとwgetで同じRSSにアクセスするとかなり前のフィードが取得される事があります

curlとwgetの違いを確認した所リクエストヘッダに
Accept-Encoding:identity
が付くようでcurlにこのヘッダを付けてリクエストするとwgetと同じ記事が取れるようになったりもします。

1度発生するとこの手法で再現は結構簡単なのですがこの問題が発生する人としない人がまちまちなので確実な再現方法というのが確立できていません恐らくRSSに頻繁にアクセスされる人の方が発生しやすいのではないかと思っています。

そこで昨日一日なにも更新せずに以下のような手法で確認していたのですが

while sleep 3600;do xmllint --xpath "/rss/channel/lastBuildDate/text()" <(wget -O - -q https://zenn.dev/tantan_tanuki/feed); done

午前中は日付を跨いだ瞬間に1件だけ違うパターンが紛れ込みましたが大体1時間位ずれた2パターンのfeedがランダムに取得されるという挙動になりました。

また私ではないのですが他の人のRSSでこの揺れの間に記事を更新されている方の記事が巻き戻っている事も確認できました。(ログが残ってないので申し訳ない)

そこで更に確認のため以下のように確認してみました

$ while sleep 3;do xmllint --xpath "/rss/channel/lastBuildDate/text()" <(curl -s https://zenn.dev/catnose99/feed); done                                                         
Wed, 07 Apr 2021 21:15:41 GMT
Wed, 07 Apr 2021 14:18:55 GMT
Wed, 07 Apr 2021 21:15:41 GMT
Wed, 07 Apr 2021 14:18:55 GMT

$ while sleep 3;do xmllint --xpath "/rss/channel/lastBuildDate/text()" <(wget -O - -q https://zenn.dev/catnose99/feed); done                                                    
Thu, 08 Apr 2021 00:50:21 GMT
Thu, 08 Apr 2021 00:49:54 GMT
Thu, 08 Apr 2021 00:50:21 GMT
Thu, 08 Apr 2021 00:49:54 GMT

ここでcurlとwgetで取れる記事が違いさらにそれぞれで2パターンの記事が取得されるようですので
少なくとも4パターンのRSSが取得される可能性がありそうです。

Wed, 07 Apr 2021 14:18:55 GMT
Wed, 07 Apr 2021 21:15:41 GMT
Thu, 08 Apr 2021 00:49:54 GMT
Thu, 08 Apr 2021 00:50:21 GMT

内部の構造がわからないので憶測になりますが
この返却のパターンから考えてLBの裏に2台RSSを配信しているサーバがいて
そのサーバ毎にキャッシュされているRSSが違うのではないか?
そしてそのキャッシュはリクエストヘッダによって変わるレスポンス内容毎に別々にキャッシュを保存しているのではないか?

というのが私の見解なのですがこれ以上ちょっと調べるのが難しいのと
そもそも全然見当違いの推測をしているかもしれないので
申し訳ありませんが何かわかることがあればご教授願えないでしょうか?

catnosecatnose

すみません、見落としてました。
こちらのキャッシュの問題についても解消済みです。

うーたんうーたん

こちらのteam-blog-hubを使いたいと思い。試してみたのですが、以下のようなエラーが出てしまいました。
私の力では解決できなかったので、お力添えいただけますでしょうか。

Browserslist: caniuse-lite is outdated. Please run:
npx browserslist@latest --update-db
Failed to compile.
./src/pages\index.tsx:4:19
Type error: Cannot find module '@.contents/posts.json' or its corresponding type declarations.

  2 | import Link from "next/link";
  3 | 
> 4 | import posts from "@.contents/posts.json";
    |                   ^
  5 | import { config } from "@site.config";
  6 | import { PostItem } from "@src/types";
  7 | import { ScrollableMembers } from "@src/components/ScrollableMembers";
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
ERROR: "build:next" exited with 1.
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

npx browserslist@latest --update-dbのコマンドで解消されるか試しましたが、解消されませんでした。

catnosecatnose

yarn devを実行する前に一度yarn buildの実行が必要です(yarn buildを実行するとposts.jsonが生成されます)。

うーたんうーたん

ありがとうございます。yarn buildを実行後にエラーが出てしまいました。説明が不足していました。

エラーの内容のすべてです。

$ yarn build
yarn run v1.22.10
$ run-s build:posts build:next
$ ts-node --project tsconfig.builder.json ./src/builder/posts.ts
(node:7212) UnhandledPromiseRejectionWarning: Error: Status code 404
    at ClientRequest.<anonymous> (C:\Users\team-blog-hub\node_modules\rss-parser\lib\parser.js:88:25)
    at Object.onceWrapper (events.js:422:26)
    at ClientRequest.emit (events.js:315:20)
    at ClientRequest.EventEmitter.emit (domain.js:483:12)
    at HTTPParser.parserOnIncomingClient [as onIncoming] (_http_client.js:596:27)
    at HTTPParser.parserOnHeadersComplete (_http_common.js:119:17)
    at TLSSocket.socketOnData (_http_client.js:469:22)
    at TLSSocket.emit (events.js:315:20)
    at TLSSocket.EventEmitter.emit (domain.js:483:12)
    at addChunk (_stream_readable.js:295:12)
(node:7212) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). To terminate the node process on unhandled promise rejection, use the CLI flag `--unhandled-rejections=strict` (see https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). (rejection id: 2)
(node:7212) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.
$ next build
Browserslist: caniuse-lite is outdated. Please run:
npx browserslist@latest --update-db
Failed to compile.

./src/pages\index.tsx:4:19
Type error: Cannot find module '@.contents/posts.json' or its corresponding type declarations.

  2 | import Link from "next/link";
  3 | 
> 4 | import posts from "@.contents/posts.json";
    |                   ^
  5 | import { config } from "@site.config";
  6 | import { PostItem } from "@src/types";
  7 | import { ScrollableMembers } from "@src/components/ScrollableMembers";
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
ERROR: "build:next" exited with 1.
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

./src/builder/posts.tsでエラーが生じており、posts.jsonを生成することができませんでした。
何度も申し訳ありません。

うーたんうーたん

ありがとうございます。動作しました。対応していただきありがとうございました。