mottox2 blog

GatsbyJSで複数のサイトの情報をまとめたRSS Feedをつくる

devgatsby

自分はWrite Blog Every Weekという週に一回ブログを書くコミュニティに所属しています。
コミュニティ内ではRSS Feedに基づいてブログを書いたかを判断するBotが整備されており、ブログ管理者がそのBotに対してRSSのURLを登録するフローを取っています。
しかし現状、1人が登録できるRSSは一つまでという制約があり、ブログとQiita、noteを併用して認識させることが難しくなっています。
正攻法で言えばプルリクエストを出そうという話になります。しかし、AWS LambdaやDynamoDBが使われておりデバッグも面倒なので(自分の登録する)RSSに複数のサイトの情報をまとめるという変則的な方法を取って解決することにしました。

その際に、Gatsbyのgatsby-plugin-feedを使って複数のサイトをまとめたのでその方法を紹介したいと思います。

該当のPull Request: https://github.com/mottox2/website/pull/41

gatsby-plugin-feedとは?

gatsby-plugin-feedGraphQLで取得したデータをもとにRSS Feedを作成、metaタグに情報を埋め込む責務をもつプラグインです。gatsbyjs/gatsbyで管理されている公式のプラグインとなっており、gatsby-starter-blogにも使用されていることから利用者の多いプラグインです。

通常の利用法では次のようになります。(以下の例はREADME.mdの例から抜粋です。)

  1. options.feeds以下のquery内でgraphqlを実行した結果がserializeにqueryとして渡されます。
  2. serializeではqueryをもとにRSSを組み立てるObjectを配列にして返します。
  3. gatsby buildを実行した際にhtmlに<link rel="alternate" type="application/rss+xml" href="/rss.xml"/>が挿入されます。
gatsby-config.js
plugins: [
  {
    resolve: `gatsby-plugin-feed`,
    options: {
        query: `
          {
            site {
              siteMetadata {
                title
                description
                siteUrl
                site_url: siteUrl
              }
            }
          }
        `,
      feeds: [
        {
          serialize: ({ query: { site, allMarkdownRemark } }) => {
            return allMarkdownRemark.edges.map(edge => {
              return Object.assign({}, edge.node.frontmatter, {
                description: edge.node.excerpt,
                date: edge.node.frontmatter.date,
                url: site.siteMetadata.siteUrl + edge.node.fields.slug,
                guid: site.siteMetadata.siteUrl + edge.node.fields.slug,
                custom_elements: [{ "content:encoded": edge.node.html }],
              })
            })
          },
          query: `
            {
              allMarkdownRemark(
                limit: 1000,
                sort: { order: DESC, fields: [frontmatter___date] },
                filter: {frontmatter: { draft: { ne: true } }}
              ) {
                edges {
                  node {
                    excerpt
                    html
                    fields { slug }
                    frontmatter {
                      title
                      date
                    }
                  }
                }
              }
            }
          `,
        },
      ],
    },
  },
]

補足: optionsの初期値にはgatsby-source-filesystem, gatsby-transformer-remarkを使った構成でのものが設定されており、単純な構成の場合optionsが必要ありません。(人のソースを参考にする場合注意してください。)

複数のサイトの情報を混ぜる

上記の例からわかるのは、「GraphQLで得られるデータをもとにRSSを組み立てる」ということです。なので、混ぜ込みたい情報をGatsbyのGraphQLで扱うためにsource-pluginを導入したり、gatsby-node.jsでcreateNode関数を実行します。

今回は自分のnote.mum, qiita.com1のRSSを混ぜ込むことにしました。

データの用意

RSSを混ぜるには gatsby-source-rss-feedを使用します。今回は以下の記述をgatsby-config.jsに追加しました。

gatsby-config.js
module.exports = {
  ...
  plugins: [
   ...,
    {
      resolve: `gatsby-source-rss-feed`,
      options: {
        url: `https://note.mu/mottox2/rss`,
        name: `NotePost`
      }
    },
    {
      resolve: `gatsby-source-rss-feed`,
      options: {
        url: `https://qiita.com/mottox2/feed`,
        name: `QiitaPost`
      }
    },
  ]
}

これでNote, Qiitaの記事をそれぞれallFeedNotePost, allFeedQiitaPostというクエリで情報が取得できるようになります。

RSSをつくる

gatsby-config.js内のgatsby-plugin-feedのoptionsにロジックを以下のコードを書きます。

  1. query内でいろんなデータソースからデータを取得
  2. queryの結果を纏めた配列をsort関数で日付順に並び替え
  3. internal.typeにデータのtypeが入っているので、それをもとにrssのitemObjectを作成、serializeの結果としてreturnする。

ちょっと雑な感じで、拡張する際に壊れそうな匂いのするコードですが、この状態でgatsby buildを行い生成されたRSSを見たところ期待する結果になっていました。

gatsby-config.js
        feeds: [
          {
            serialize: ({ query: { site, allEsaPost, allFeedQiitaPost, allFeedNotePost } }) => {
              return [...allEsaPost.edges, ...allFeedNotePost.edges, ...allFeedQiitaPost.edges].sort((a, b) => {
                const bDate = b.node.pubDate ? new Date(b.node.pubDate) : new Date(b.node.childPublishedDate.published_on)
                const aDate = a.node.pubDate ? new Date(a.node.pubDate) : new Date(a.node.childPublishedDate.published_on)
                return bDate - aDate
              }).map(edge => {
                const node = edge.node
                switch (node.internal.type) {
                  case 'EsaPost':
                    const day = dayjs(node.childPublishedDate.published_on)
                    return {
                      date: day.toISOString(),
                      pubDate: day.toISOString(),
                      url: `${site.siteMetadata.siteUrl}/posts/${node.number}`,
                      guid: node.number,
                      title: node.fields.title,
                      description: node.fields.excerpt
                    }
                    break;
                  case 'FeedQiitaPost':
                  case 'FeedNotePost':
                    return {
                      date: dayjs(node.pubDate).toISOString(),
                      pubDate: dayjs(node.pubDate).toISOString(),
                      url: node.link,
                      guid: node.link,
                      title: node.title,
                      description: node.contentSnippet.substring(0, 512)
                    }
                    break;
                  default:
                    throw `${node.internal.type} is unknown type`
                }
              })
            },
            query: `
              {
                allEsaPost {
                  edges {
                    node {
                      number
                      fields {
                        title
                        excerpt
                      }
                      childPublishedDate {
                        published_on
                      }
                      internal {
                        type
                      }
                    }
                  }
                }
                allFeedQiitaPost {
                  edges {
                    node {
                      title
                      pubDate
                      contentSnippet
                      link
                      internal {
                        type
                      }
                    }
                  }
                }
                allFeedNotePost {
                  edges {
                    node {
                      title
                      pubDate
                      contentSnippet
                      link
                      internal {
                        type
                      }
                    }
                  }
                }
              }
            `,

この方法の問題点

以上の方法でRSS Feedを一つにまとめることが出来ました。
しかし、静的サイトという特性上、ビルドのタイミング次第でRSSが更新されないという現象が起こりえます。ブログ記事の更新はWebhookを利用することで更新していますが、RSSの更新をhookするにはIFTTTやZapierなどの方法を別に用意する必要があります。
ただ、自分は厳密なRSSを必要としていないので、無視することにしました。どうしても気になるようであれば、スケジューラーでサイトの更新を行うと思います。

何度もいうと、複数のRSSを受け付ける実装をするのが一番いいです。

おわりに

これで、週一ブログに追われて自分のブログだけを更新する。ということはなくなると思います。
ちょっと薄めの記事はQiita、デザイナー層にリーチしたい記事はnote.mute。といった使い分けをしていけたら…という気持ちです。

また、今回は複数のデータソースをRSSにまとめるという形を取りましたが、これはRSSじゃなくてHTML上にまとめるということも可能です。GatsbyJSの強みには複数のデータソースに対してGraphQLという統一されたインターフェースでアクセスできるというものがあります。個人的には、「会社のエンジニアが運営するブログを一つのサイトにまとめて採用に使う」などといった方法で使われたりすると面白いなと思っています。

  1. QiitaのRSSを取得するには、URLの最後に/feedを付けると見れるみたいです。タグページでも大丈夫みたいです。

B!
blog

JAMstack関連の海外イベント情報

JAMstackの情報は国内には乏しいので、特に最新情報に関してはNetlifyやGatsbyの情報は海外のものを見るとよい。 その中でもJAMstack confやGatsby Daysの情報は一

gatsbyJAMstack
dev

ReactのUIコンポーネントライブラリに「Sancho UI」はいかが?

GW満喫していますか?私はNext.jsとCloud Functionsを組み合わせたり、FirebaseをバックエンドとしたSPAの検証や、SwiftでiOSアプリを書いて満喫しています。 こう

emotionreact
event

技術書典6にサークル参加したけど反省点が多すぎた

前回に引き続き、技術書典6にサークル主として参加させていただきました。 技術同人誌界隈はかつてない盛り上がりを見せており、今回のサークル当落発表の際は地獄のようなタイムラインになっていました。そうい

技術書典