note の RSS でクライアントから記事一覧を取得する方法【Cloud Functions for Firebase】

note の RSS でクライアントから記事一覧を取得する方法【Cloud Functions for Firebase】

先日、個人的に関わっているとあるサイト制作(Nuxt.js 製)のプロジェクトで、クライアントが運用している note のアカウントの記事一覧をそのままサイトに掲載するという実装をおこないました。

note を Webサイトのブログ代わりに使う

note は、いまや個人だけでなく企業アカウントも運用する一大プラットフォームです。note を使えば自分でレンタルサーバーを借りてブログを構築することなく、手軽に情報発信ができます。

そうしたなか、企業や個人のWebサイト上に、わざわざブログを構築するのではなく、普段運用している note の記事をそのまま外部リンクとして載せてしまえばいいのではないか?という声も少なからず耳にするようになりました。

たしかに、note はオーサーにとっても読者にとってもユーザビリティが高く、ブログコンテンツをどのメディア(ないしドメイン)で発行するかにこだわりがないユーザにとってはそれも有効な選択肢のひとつかもしれません。

今回はそうした背景もあり、先述のプロジェクトにて、ブログ機能の実装をヘッドレス CMS を使った実装から、 note の記事リンク一覧をそのまま表示する方針に舵を切ることとなりました。

具体的には、Cloud Functions と Express を使って 、note の RSS をもとに別ドメインからクライアントサイドで記事を取得するための仲介サーバを作り、そこから記事を取得し表示する方法を採用しました。

今回はその方法についてシェアしたいと思います。

前提:note の記事一覧はマガジンごとに RSS で配信されている

そもそも前提として、note は各アカウントごとの記事一覧を RSS で配信しており、外部から記事一覧を取得することができます。

note の記事一覧は RSS で取得できる

RSS の単位としては、アカウントのすべての記事、もしくはマガジンごとです。以下、私のアカウントの例です。

すべての記事の RSS
https://note.com/shimotsu_/rss

個別のマガジンの RSS
https://note.com/shimotsu_/m/m5774f6fe2c0b/rss

実際にフィードの中身を見てみると、各記事の titledescription をはじめ、thumbnailpubDatelink など、一覧表示に必要な要素はもちろん一通り揃っているので、これを取得すれば問題なさそうです。

問題:note の RSS は CORS 対応されていない

note で提供されている RSS を使えば、アカウントとマガジンに紐づく記事一覧を取得できることは分かりました。

ただし、ここでひとつ問題なのが、note の RSS が CORS 対応されていないことです。つまり、別ドメインのサイトから RSS を取得するために HTTP 通信を試みると、許可されずエラーとなってしまいます。

CORS 対応がなされていないため、別ドメインから HTTP 通信しようとするとエラーが起きる

RSS の配信サーバが CORS 対応されていない場合、クロスオリジンの制約を受けないサーバサイドで取得を実行すればよいのですが、今回のプロジェクトのように Nuxt.js 製の静的サイトだったりするとそう簡単にはいきません。

参照:オリジン間リソース共有 (CORS)

解決策:Cloud Functions + Express でクロスオリジンの制約を受けない仲介サーバを作る

そこで今回は、ひとつの解決策として、サーバレスのアプリケーションを手軽に作れる Google の Cloud Functions と Node.js のフレームワークである Express を利用し、note の RSS を単に仲介するだけのサーバを作り、そこから記事一覧を取得することでこの問題を対処しました。

要は、ブラウザから直接 RSS を読み込むのではなく、踏み台となるプログラムを作り、ブラウザはその踏み台から記事を取得できるようにするということです。今回はその踏み台のプログラムとして、Cloud Functions を利用します。

Cloud Functions を経由することで、クライアントから HTTP 通信で note の記事を取得できる

Cloud Functions 自体についての説明は割愛しますが、サービスの詳細については公式ドキュメントに記載されているのでご参照ください。

ドキュメントの「使用例」の中に「軽量 API」とありますが、今回はまさしくその例でしょう。

参照:HTTP トリガー

Firebase のプロジェクトのセットアップ

では、前置きが長くなりましたが、実際に作ってみましょう。
(※一連の操作において、Node.js および npm のインストールがされている必要がありますので、各自ご準備ください)

まずは、Firebase のプロジェクトを作る必要があるのですが、事前に Firebase のコンソールから新規プロジェクトを作成しておいてください。今回は、デモ用に mediation-server というプロジェクトを作成しました。

最初に、Firebase 関連のコマンドを有効にするための firebase-tools のバージョンを確認します。

$ firebase --version

もしインストールされていなければ、firebase-tools をグローバルにインストールします。

$ npm install -g firebase-tools

次に、プロジェクトの初期化前に作業用のディレクトリを作り、初期化を実行します。今回は cloudfunctions というディレクトリを作りました。

$ cd ~/Documents
$ mkdir cloudfunctions
$ cd cloudfunctions
$ firebase init

firebase init を実行すると、使用する Firebase の機能リストが表示されるので、今回は Functions にチェックを入れ、Enterを押します。

次にオプションの選択が表示されるので、Use an existing project にチェックを入れて Enter を押します。

ここで、さきほど Firebase のコンソールで作成したプロジェクトを選択します。

次に言語について聞かれるので、Javascript を選択して Enter を押します(もちろん TypeScript でも OK)。

以降は ESLint の使用や、依存モジュールのインストールについて聞かれるので、いずれも Yes で回答し、Enter を押してください。

その後、 Firebase initialization complete! と表示されたらセットアップは完了です。

Express を利用して API を実装する

作業ディレクトリ内に Firebase の各種ファイルが展開されているので、 functions ディレクトリ内の index.js を開きます。

const functions = require("firebase-functions");

// // Create and Deploy Your First Cloud Functions
// // https://firebase.google.com/docs/functions/write-firebase-functions
//
// exports.helloWorld = functions.https.onRequest((request, response) => {
//   functions.logger.info("Hello logs!", {structuredData: true});
//   response.send("Hello from Firebase!");
// });

このファイルをアップデートしていくのですが、まず必要なモジュールをインストールします。

$ yarn add express request-promise-native cors

express は Node.js のフレームワーク、request-promise-native は Node.js で Promise を使うために、cors は仲介サーバ自体の CORS 対応のために、それぞれ入れておきます。

また、.eslint.rcparserOptions も少し書き換えておきます。

module.exports = {
  root: true,
  env: {
    es6: true,
    node: true,
  },
  extends: [
    "eslint:recommended",
    "google",
  ],
  parserOptions: {
    sourceType: "module",
    parser: "babel-eslint",
    ecmaVersion: 2017,
  },
  rules: {
    quotes: ["error", "double"],
  },
};

それでは用意が整ったら、index.js を以下のように修正します。

const functions = require("firebase-functions");
const requestPromise = require("request-promise-native");
const cors = require("cors");
const express = require("express");

const app = express();

// すべての外部からのリクエストを受け付ける
// 必要に応じてアクセス許可するオリジンなどの設定をしてください
// Docs: https://www.npmjs.com/package/cors
app.use(cors());

const BASE_KEY = "https://note.com/shimotsu_";
const KEY_ALL = BASE_KEY + "/rss";
const KEY_MAGAZINE = BASE_KEY + "/m/m5774f6fe2c0b/rss";

const getRSS = async (key) => {
  const result = await requestPromise(key);
  return result;
};

// エンドポイント設定
app.get("/magazine", async (req, res) => {
  res.set("Cache-Control", "public, max-age=300, s-maxage=600");
  getRSS(KEY_MAGAZINE).then((response) => res.send(response));
});
app.get("/all", async (req, res) => {
  res.set("Cache-Control", "public, max-age=300, s-maxage=600");
  getRSS(KEY_ALL).then((response) => res.send(response));
});

const api = functions.https.onRequest(app);
module.exports = { api };

それぞれ見ていきます。

const functions = require("firebase-functions");
const requestPromise = require("request-promise-native");
const cors = require("cors");
const express = require("express");

const app = express();

// すべての外部からのリクエストを受け付ける
// 必要に応じてアクセス許可するオリジンなどの設定をしてください
// Docs: https://www.npmjs.com/package/cors
app.use(cors());

ここではまず必要なモジュールを読み込んでいます。その後、読み込んだ express モジュールをインスタンス化し、app に代入します。

また今回、クライアントサイドで HTTP 通信する際に CORS のエラーが発生しないよう、cors というミドルウェアを入れています。

const BASE_KEY = "https://note.com/shimotsu_";
const KEY_ALL = BASE_KEY + "/rss";
const KEY_MAGAZINE = BASE_KEY + "/m/m5774f6fe2c0b/rss";

ここでは RSS フィードの URL を定数化しています。

const getRSS = async (key) => {
  const result = await requestPromise(key);
  return result;
};

ここで定義している getRSS 関数は、取得したいフィードの URL を引数に取り、GET した結果を Promise オブジェクトとして返します。

// エンドポイント設定
app.get("/magazine", async (req, res) => {
  res.set("Cache-Control", "public, max-age=300, s-maxage=600");
  getRSS(KEY_MAGAZINE).then((response) => res.send(response));
});
app.get("/all", async (req, res) => {
  res.set("Cache-Control", "public, max-age=300, s-maxage=600");
  getRSS(KEY_ALL).then((response) => res.send(response));
});

ここではエンドポイントを設定しています。 /magazine ではマガジンの記事一覧を、 /all ではアカウントに紐づくすべての記事一覧を取得します。また、キャッシュの設定もおこなっています。

const api = functions.https.onRequest(app);
module.exports = { api };

最後にモジュールをエクスポートしています。onRequest の引数に app を使用すると、この Express アプリ全体を HTTP 関数に渡すことができます。

参照:HTTP リクエスト経由で関数を呼び出す

これで Cloud Functions 自体の実装は完了です。

Cloud Functions アプリケーションをデプロイ

それでは、早速デプロイしてみましょう。デプロイには以下のコマンドを実行します。

$ firebase deploy

【注意】このとき、Firebase のプロジェクトのプランが無料の Spark プランだとデプロイに失敗するので、従量課金の Blaze プランにアップデートしておいてください。

デプロイが完了すると以下のような表示になります。

【注意】デプロイにコケる場合は、①料金プランが “Blaze” になっていない、②リンターのリントでエラーが出ている、③Node.js のバージョンが不適切、などいくつか可能性が考えられるので、アラートを読んで対処してください。

実際に Firebase のコンソール画面で Cloud Functions のページを見ると、このように新しい関数が登録され、HTTP リクエストのためのトリガー URL が設定されていることが分かります。

試しに、 curl コマンドで HTTP 関数に定義されている /all エンドポイントを叩いてみましょう。

$ curl https://us-central1-mediation-server.cloudfunctions.net/api/all

…すると、記事一覧が XML 形式で取得できています。

クライアント(Nuxt.js)から取得してみる

最後に、今回の目的であるクライアントからの取得を試してみましょう。トップページ(index.vue)を以下のように編集します。

<template>
  <div class="wrapper">
    <h1>All</h1>
    <ul>
      <li v-for="(article, index) in articlesAll" :key="index">
        <a :href="article.link">{{ article.title[0] }}</a>
      </li>
    </ul>
    <h1>Magazine</h1>
    <ul>
      <li v-for="(article, index) in articlesMagazine" :key="index">
        <a :href="article.link">{{ article.title[0] }}</a>
      </li>
    </ul>
  </div>
</template>

<script>
import xml2js from 'xml2js'

export const processors = xml2js.processors
export const xmlParser = xml2js.Parser({
  tagNameProcessors: [processors.stripPrefix],
})

export const BASE_KEY =
  'https://us-central1-mediation-server.cloudfunctions.net/api'
export const KEY_ALL = BASE_KEY + '/all'
export const KEY_MAGAZINE = BASE_KEY + '/magazine'

export default {
  data() {
    return {
      articlesMagazine: [],
      articlesAll: [],
    }
  },
  async mounted() {
    ;[this.articlesMagazine, this.articlesAll] = await Promise.all([
      await this.fetchArticles(KEY_ALL),
      await this.fetchArticles(KEY_MAGAZINE),
    ])
  },
  methods: {
    async fetchArticles(KEY) {
      let result = []
      const articles = await this.$axios.get(KEY).then((response) => {
        xmlParser.parseString(response.data, (messages, xmlres) => {
          result = xmlres.rss.channel[0].item
        })
        return result
      })
      return articles
    },
  },
}
</script>

<style>
.wrapper {
  padding: 20px;
}

h1 {
  margin-bottom: 10px;
}

ul {
  margin-bottom: 25px;
  padding-left: 20px;
}
</style>

今回、データ処理のしやすさのために XML から javascript オブジェクトに変換するモジュール xml2js を使用しています。

また、note は記事サムネイルやクリエイター名などについて <media:thumbnail><note:creatorName> といった独自タグを設定しているので、それらを取得するために、22行目で xml2js.Parser() メソッドを利用しています。

参照:Processing attribute, tag names and values

index.vue を上記のように編集した結果、以下のようにクライアントサイドで note の記事一覧が取得できました!

こうして、Cloud Functions と Express を活用し、クロスオリジンの制約を受けずにクライアントサイドで note の記事一覧が取得できるようになりました。note の記事を自在に取得できるようになると小規模サイト制作の幅も広がるので、必要な方はぜひ試してみてください。

なお、今回デモ用に作成したコードは以下のリポジトリにアップしていますので、ご参考ください。
https://github.com/shimotsu4431/note-rss/

Appendix:Cloud Functions のコールドスタート対応

Cloud Functions には、しばらくトリガーが使われていないとスリープし、その後最初のリクエストで処理に時間がかかる性質があるようです。

参照:コールド スタート

そこで今回はコールドスタートの対策として、定義したエンドポイントを定期的(5分おき)に叩くだけの関数を作成しました。Cloud Functions では、こうした定期実行関数も容易に作れます。

参照:関数のスケジュール設定

const CLOUD_BASE_KEY = "https://us-central1-mediation-server.cloudfunctions.net/api"
const CLOUD_KEY_ALL = CLOUD_BASE_KEY + "/all"
const CLOUD_KEY_MAGAZINE = CLOUD_BASE_KEY + "/magazine"

// Cloud Functions に定義されたエンドポイントを5分おきに叩くだけの関数
const scheduledRun = functions.pubsub.schedule("every 5 minutes").onRun(async () => {
  await requestPromise(CLOUD_KEY_ALL)
  await requestPromise(CLOUD_KEY_MAGAZINE)

  return null;
});

トリガーを実行する回数は増えてしまいますが、これで常にフレッシュな状態を保てるので、スリープ後にリクエストに時間がかかってしまう問題を解決できました。


【謝辞】本件の実装アイデアは、同じくプロジェクトを一緒に進めていた @gonshi_com に提供していただきました。感謝!