thumbnail

なにこれ

自分専用のRSSリーダーをGatsby + Netlify + Zapierを使って作りました。Gatsbyビルド時にRSSとOGPを取得して、Webサイトに表示しています。 今回はこのWebサイトのビルドの仕組みと、GatsbyビルドでRSS+OGPを取得する方法をご紹介します。

サイトはコチラ↓


ソースはコチラ↓


ビルドの仕組み

build flow

以下時系列で説明します。

  • Zapier

    • ジョブで定義したRSSを監視
    • RSSに更新があったらNetlifyビルドをトリガー
  • Netlify

    • ビルドでGitHubからGatsbyプロジェクトのリポジトリをフェッチ
    • Gatsbyプロジェクトのビルドを実行
    • Gatsbyプロジェクトのビルドの中で、設定ファイルに定義したRSSをフェッチ
    • ビルドが終わったらCDNにホスティング

NetlifyとGitHubの連携

Netlify上でGitHubリポジトリをビルド&ホストできるようにしておきます。 連携はNelityのGUIで簡単にできます。

ZapierとNetlifyの連携

RSSリーダーは「RSSが更新されたらビルドをトリガーする」ような仕組みが必要です。この仕組みはNetlifyとZapierで実現できます。 Zapierはいろんなサービスやアプリを連携させて自動化できるWebサービスで、Netlifyにも対応しています。 Zapierの画面上で、監視したいRSSをポチポチ登録すると「RSSが更新されたらNetlifyのビルドをトリガーする」が簡単に実現できます。

RSS + OGP取得

さてビルドの仕組みを整えたので、次はGatsbyのビルドでRSSを取得する方法について説明します。

どのRSSを引っ張ってくるかは、GitHubリポジトリに設定ファイルにて定義します(実際の設定ファイル)。 ただコレ、ZapierのジョブのRSS定義と二重管理になるのが難点です。Zapierのタスクがyamlで書けるようになったら一元管理できるかもですが...

gatsby-node.js
const striptags = require('striptags');
const axios = require('axios');
const cheerio = require('cheerio');
const crypto = require("crypto");
const Parser = require('rss-parser');
const parser = new Parser({
  headers: {'User-Agent': 'something different'},
});

const INTERNAL_TYPE_BLOG = 'blog';
const INTERNAL_TYPE_BLOG_POST = 'blogPost';


// RSSのような外部リソースを
// GatsbyでGraphQLのデータとして扱うためには
// sorceNodeで処理する
exports.sourceNodes = async ({ actions, createNodeId, store, cache }) => {
  const feeds = [];
  
  // 設定ファイルかなにかしらの方法でRSSのURLを読み込む
  const blogUrls = ['rss-url-1', 'rss-url-2', ];

  for(const blogUrl of blogUrls) {
  
    // rss-parserでRSS情報を取得する
    const feed = await parser.parseURL(blogUrl).then(feed => {

      return {
        ...feed,
        items: feed.items.map(item => ({
          title: item.title,
          excerpt: excerpt(item.content, 120),
          content: item.content,
          pubDate: new Date(item.pubDate).toISOString(),
          link : item.link,
        }))
      }
    });

    feeds.push(feed);
  }


  // ブログごとにノード生成
  feeds
    .map(feed => ({
      title: feed.title,
      description: feed.description,
      link: feed.link,
      lastBuildDate: feed.lastBuildDate,
    }))
    .forEach(b => {
      const contentDigest = crypto.createHash(`md5`)
        .update(JSON.stringify(b))
        .digest('hex');
      
      actions.createNode({
        ...b,
        id: createNodeId(`${INTERNAL_TYPE_BLOG}${b.link}`),
        children: [],
        parent: `__SOURCE__`,
        internal: {
          type: INTERNAL_TYPE_BLOG,
          contentDigest,
        },
      });
    });

  // RSSに加えて、OGPを取得
  const rssPosts = feeds.map(feed => feed.items).reduce((a,b) => [...a, ...b]);
  const rssPostsWithImageUrl = [];
  // Promise.allでやると503になるのでfor文を使う
  for (const p of rssPosts) {
    const pWithImageUrl = await axios.get(p.link, {
      headers: {'User-Agent': 'something different'},
    }).then(res => {

      // 取得したHTMLからOGPを取得
      const $ = cheerio.load(res.data)
      let imageUrl;
      $('head meta').each((i, el) => {
        const property = $(el).attr('property')
        const content = $(el).attr('content')
        if (property === 'og:image') {
          imageUrl = content
        }
      });

      // 記事情報にOGPのURLを追加
      return {
        ...p,
        imageUrl,
      };
    });

    rssPostsWithImageUrl.push(pWithImageUrl);
  }


  // OGP画像のノードを生成
  // 重複を考慮してユニークにしてから処理する
  const imageUrls = _uniq(rssPostsWithImageUrl.filter(p => p.imageUrl).map(p => p.imageUrl));

  await Promise.all(imageUrls.map(async imageUrl => {
    const fileNode = await createRemoteFileNode({
      url: imageUrl,
      cache,
      store,
      createNode: actions.createNode,
      createNodeId: createNodeId,
    });

    await actions.createNodeField({
      node: fileNode,
      name: 'ThumbnailImage',
      value: 'true',
    });
    await actions.createNodeField({
      node: fileNode,
      name: 'link',
      value: imageUrl,
    });

    return fileNode;
  }));


  // ブログ記事ごとのノード生成
  rssPostsWithImageUrl.forEach(p => {
    const contentDigest = crypto.createHash(`md5`)
      .update(JSON.stringify(p))
      .digest('hex');
    
    const excerpt = 
    actions.createNode({
      ...p,
      id: createNodeId(`${INTERNAL_TYPE_BLOG_POST}${p.link}`),
      children: [],
      parent: `__SOURCE__`,
      internal: {
        type: INTERNAL_TYPE_BLOG_POST,
        contentDigest,
      },
    });
  });
};


// 記事要約は記事本文から抽出
function excerpt(html, maxLength) {
  const rowText = striptags(html, '<pre>')
    .replace(/<pre[\s\S]+?>[\s\S]+?<\/pre>/g, '')
    .replace(/\n/g, '')
    .replace(/ /g, '')
    .trim();
  return rowText.length >= maxLength
    ? rowText.substring(0, maxLength) + '...'
    : rowText;
}

OGP画像をGatsbyで扱う処理は今も参考にしてみてください。

リソース取得は間隔を置かないと503になる

ビルド時にRSSやらOGPやらを引っ張ってくるために、大量のリクエストを発行しますが、サイトによっては時間当たりのリクエスト上限に引っかかって503エラーになることがあります。 対処法としては、Promise.allで一度にリクエストを投げずに、for文でリクエスト1つずつawaitしながら投げて、適度に間隔を開けるのが良いと思います。

まとめ

RSSリーダーのようなものをSPAだけで作ろうとすると、フロント側の高負荷処理で画面描画が遅くなってしまいますが、 Gatsbyを使えばビルド時に重い処理を流せるので、パフォーマンスの向上が期待できます。 また今回のように、NetlifyやZapierなど他サービスと連携することで、作れる静的サイトの幅も広がります。 SPA(Single Page Application)でこのような静的サイトを作っている方は一度Gatsbyを導入してみてはいかがでしょうか🍅