見出し画像

Notion API×RSS×GASでNotionをRSSリーダー化してテックブログを作った話

こんにちは!
PharmaXエンジニアリング責任者の上野(@ueeeeniki)です!

先日公開した記事『PharmaXテックブログを刷新し、公式の発信とメンバー個人の発信をまとめて見られるようにしました』で、公式noteとメンバー個人の技術ブロクのRSSで取得した情報をNotion DBに集約させて、独自ドメインのテックブログを運用しているという話をしました。

今回は技術的にどのようにして実現しているのか?について詳しく解説します。

今回はGASを使っているので、多少コーディングの知識があれば、誰でも真似することは可能だと思います。

私たちと同じようにテックブログを作りたいというわけではなくとも、NotionをRSSリーダー化したいと考えている方には参考になると思います。
 (後ほど説明するように厳密に言えばQiitaのフィードはAtom形式なので、RSSリーダーではないのですが(笑)、RSS形式の方が知名度が高いためざっくりRSSリーダーと呼ぶことにします。)


実際の処理を手順に従って説明する

処理手順

処理手順は以下の通りです。

① Notion APIを準備をする
② 事前に設定した各ブログのRSSのURLなどを取得する
③ 各RSSから指定された日以降の記事情報を取得する
④ RSSから取得した記事情報をNotion APIでNotion DBに挿入する
⑤ SuperなどのCMSサービスを使ってNotionを外部公開する(省略)

実際にGASで処理を書くのは②〜④になります。
まずは、GASのメイン関数で処理の大枠の流れを理解するとよいでしょう。
ブログを初回登録する際は指定した日以降の記事をすべて登録することとしますが、初回登録後は毎日更新された記事だけを登録するようにしています。

// メイン関数
function main() {
  const feeds = feeds_()
  
  // ② 事前に設定した各ブログのRSSのURLなどを取得する
  feeds.forEach(feed =>{
    let checkDate;
    const firstDate = feed.firstDate ? feed.firstDate : ""
    if (feed.isFirstRegistration && !firstDate) {
      // 初回登録モード: メンバーが指定した日以降の記事をすべて登録するため
      checkDate = new Date(firstDate)
    } else {
      // 通常登録モード: 昨日新しく登録された記事を登録するため
      const today = new Date()
      checkDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1)
    }
    
    const url = feed.url
    const name = feed.name
    const type = feed.type
    // ③ 各RSSから記事情報を取得する
    getPosts(url, checkDate).forEach(post => {
      // ④ 取得した記事情報をNotion APIでNotion DBに挿入する
      registerNotion(name, type, post)
    })
  })
}

お気づきの方もいらっしゃると思いますが、この書き方では記事は公開された順番にはDBに保存されません。
しかし、Notion DB側で公開日で並び替えることが可能なため、ここでは気にしないこととします。

JavascriptではforEach内で単純にawaitを記述しても逐次的には処理されないという有名な問題もありますので、あまり深入りしないこととしましょう。

それではさっそく順を追って説明していきます。
コードを紹介しつつ、必要があればスクショも掲載しています。

① Notion APIを準備をする

Notion APIを使えるようにする

My integrations ページに行き、Create new integration をクリックします。

My integrationsページ

Integration をインストールするワークスペースを選択しますが、自身がワークスペースの Admin レベルの権限を持っている必要があるため注意してください。

名前を決めたら、次のページで機能を設定します。

これらの項目は全て後から変更可能のため、あまり心配はいりません。

インテグレーションの作成が完成すると、以下のようなページが見えるようになるため、API Keyをコピーします。

API Key確認ページ


GASにAPIのキーを設定する

今回の操作には、notion_api_keyとdatabase_idの2つが必要です。
notion_api_keyは先ほど取得した文字列、
database_idはデータベースのURLがhttps://www.notion.so/<notionのアカウントid>/<database_id>のような形式になっているため、末尾の文字列です。

コード上にAPIキーが露出してしまうのは美しくないため、GASのスクリプトプロパティに設定します。
このように一度設定してあげれば、環境変数のように別の関数からもAPIキーなどを呼び出すことが可能です。

function setVal() {
  //notion apiのキーとdatabaseのidを設定する
  PropertiesService.getScriptProperties().setProperty("notion_api_key", "<notion_api_key>");
  PropertiesService.getScriptProperties().setProperty("database_id", "<database_id>");
  
  //スクリプトプロパティを取得する
  Logger.log(PropertiesService.getScriptProperties().getProperty("notion_api_key"));
  Logger.log(PropertiesService.getScriptProperties().getProperty("database_id"));
}


② 事前に設定した各ブログのRSSのURLなどを取得する

事前に各ブログのRSS情報を手動で登録しておく必要があります。
ここだけは保守運用が必要です。
また大枠の流れでも説明したように、いつ以降の記事を登録するのかをfirstDateで設定することも可能です。

手動で登録しやすいようにメンバーごとに複数のブログを登録する形式としていますが、③以降の処理ではRSS単位で扱えるように後ろで形式を変更しています。

// 事前に設定した各ブログのRSSのURLなどを取得する関数
function feeds_() {
  const members = [
    {
      name: "PharmaX公式ブログ", // ブログ所有者名
      sources: [
        {
          url: "https://note.com/pharmax/m/m59a0657d21e2/rss", // ブログURL
          isFirstRegistration: false, // 初回登録かどうか
        }
      ],
      firstDate: "2021/1/1", // いつから登録するか
      type: "全社ブログ", // Notionに登録するブログタイプ
    },
    {
      name: "Akihiro Ueno",
      sources: [
        {
          url: "https://ueniki.com/feed.xml",
          isFirstRegistration: true,
        },
        {
          url: "https://qiita.com/ueniki/feed.atom",
          isFirstRegistration: true,
        },
      ],
      firstDate: "2018/12/7",
      type: "個人ブログ",
    },
  ]

  // 後ろの処理で扱いやすいようにRSS単位に形式を変更
  let feeds = []
  members.forEach(member => {
    const name = member.name
    const type = member.type
    const firstDate = member.firstDate
  
    member.sources.forEach(source => {
      const feed = {
        name: name,
        url: source.url,
        type: type,
        firstDate: firstDate,
        isFirstRegistration: source.isFirstRegistration,
      };
    feeds.push(feed);
    })
  })

  return feeds
}
feeds.gs


私たちが運営するテックブログでは、下に添付のように公式のnote記事一覧とメンバー個人の記事一覧を分けて表示しているため、Notionのプロパティ用にtypeを指定します。
同じNotion DBに保存して、DBのプロパティをフィルターすることで表示を分けています。

公式noteの記事一覧だけを表示するゾーン


メンバー個人の記事一覧を表示するゾーン

③ 各RSSから指定された日以降の記事情報を取得する

ここで最も注意が必要なのは、noteはZennのフィードはRSS2.0形式ですが、QiitaはAtom形式なので記事情報の取得形式が異なります。
そのせいで少し処理が煩雑になってしまっています。

フィードの配信形式がもっとたくさんあれば、形式ごとに記事情報を取得する関数を分けることも考えましたが、(RSS1.0はあまり使われていないため)RSS2.0とAtomの2種類であることから、if文で分岐してそのまま処理を記述することとしました。

RSS形式とAtom形式の違いは下記のような記事を参考にしてください。

// 各フィードから指定された日(checkDate)以降に公開された記事情報だけ取得して配列にして返す
function getPosts(url, checkDate) {
  const xml = UrlFetchApp.fetch(url).getContentText()
  const root = XmlService.parse(xml).getRootElement()

  // 元の記事でカバー画像が設定されていないときに表示するためのカバー画像のURLを設定
  const coverUrls = coverUrls_()
  let coverUrl = coverUrls[Math.floor(Math.random() * coverUrls.length)];

  // ブログの媒体によってフィードから情報を取り出す処理が異なるので、urlからhost名を抽出
  const hostname = url.match(/^https?:\/{2,}(.*?)(?:\/|\?|#|$)/)[1];

  // 記事情報を取得する
  let posts = []
  if (hostname == "qiita.com") { // QiitaはAtomフィード形式
    const atomNs = XmlService.getNamespace('http://www.w3.org/2005/Atom')
    
    posts = root.getChildren('entry', atomNs).map(entry => {
      return {
        pubDate: entry.getChildText('published', atomNs),
        link: entry.getChildText('url', atomNs),
        title: entry.getChildText('title', atomNs),
        coverUrl: coverUrl
      };
    });
  } else { // noteやZennなどはRSSフィード形式
    const mediaNs = XmlService.getNamespace('http://search.yahoo.com/mrss/')
    
    posts = root.getChildren("channel")[0].getChildren("item").map(item => {
      const mediaThumbnail = item.getChild('thumbnail', mediaNs)
      coverUrl = mediaThumbnail ? mediaThumbnail.getText() : coverUrl // カバー画像がある場合にはurlを上書きする
      return {
        pubDate: item.getChildText('pubDate'),
        link: item.getChildText('link'),
        title: item.getChildText('title'),
        coverUrl: coverUrl
      };
    });
  }

  // 指定された日(checkDate)以降に公開された記事情報だけ配列にして返す
  return posts.filter(post => {
    const dateStr = post.pubDate
    if (dateStr) {
      const date = new Date(dateStr)
      return date >= checkDate
    } else {
      return false
    }
  })
}

ここでのもう一つの工夫は、記事によってはカバー画像がないことも考えられるため、デフォルトのカバー画像を3種類からランダムで設定するようにしました。

// デフォルトのカバー画像のURL一覧の配列を返す関数
function coverUrls_() {
  const coverUrls = [
    "https://lh3.googleusercontent.com/pw/AJFCJaUDksK84YwjRnHrgHBgklroepshsL_l8lY68KPNmGGL88qXTGWRYOgq-5lmZ7je4ZDfqVkQJoPZr03iTOYAy8Eon_AUO8LoFleMxc7w4EEqXmGpUOYQl-BHTtKC0-Cy4XRAbGgvrM1LJj2aKa-Ul6Iy=w1802-h944-s-no?authuser=0",
    "https://lh3.googleusercontent.com/pw/AJFCJaXk_F44YEKGfaUTUiUOgS_PSLwTMjASNNBMBYW3kLFpMzcUSVG-mZhxoYvaIwEevlx9EF_yzbcA1YmyxbWs8pD4Mn8CdklBfZAPMc2e5oHQLCH4k-f4adDtuJoOsr4jt1gZbPYZXM0LDRmGmEkK2iFO=w1802-h944-s-no?authuser=0",
    "https://lh3.googleusercontent.com/pw/AJFCJaWDb2caUwVzp25PYMGkYhDmswNRRVEZzHl0HxApJIJoC2IjZ-sBuDAVgnovn98FErZ_Nsmi0iP7bZcwNEH0S6NGRHANuxcWN3O402Ly4AbhqMwhx-94sWw9trVHoXkVdD2Nx0PCVs1Je4cnNXhLUxr3=w1802-h944-s-no?authuser=0"
  ]

  return coverUrls
}
元の記事にカバー画像がない場合にはデフォルト画像が設定される

画像を保存するサービスはなんでもいいのですが、ここではGoogleフォトで公開しています。
これはただのオシャレ機能で深い意味はありません(笑)

④ RSSから取得した記事情報をNotion APIでNotion DBに挿入する

次にNotionAPIでDBに情報を挿入する処理を記述します。

// NOTION_API_TOKEN を取得
function notionApiToken() {
  return PropertiesService.getScriptProperties().getProperty("notion_api_key")
}

// DATABASE_ID を取得
function databaseId() {
  return PropertiesService.getScriptProperties().getProperty("database_id")
}

// feedで取得したpostの情報をNotion DBに登録する
function registerNotion(name, type, post) {
  const coverUrl = post.coverUrl
  const title = post.title
  const link = post.link
  const date = new Date(post.pubDate)

  // Notion DBに保存する情報を作成する
  const payload = {
    "parent": {
      "database_id": databaseId(),
    },
    "cover": {
      "type": "external",
      "external": {
        "url": coverUrl
      }
    },
    "properties": {
      "title": {
        "title": [
          {
            text: {
              content: title
            },
            href: link
          }
        ]
      },
      "ブログ所有者名": {
        "rich_text": [
          {
            "text": {
              "content": name
            }
          }
        ]
      },
      "公開日": {
        "date": {
          "start": Utilities.formatDate(date, "JST", "yyyy-MM-dd")
        }
      },
      "ブログタイプ": {
        "select": {
          "name": type
        }
      },
      "super:Link": { // super用リダイレクトリンク
        url: link
      }
    }
  }
  const options = {
    "method": "POST",
    "headers": {
      "Content-type": "application/json",
      "Authorization": "Bearer " + notionApiToken(),
      "Notion-Version": "2022-02-22",
    },
    "payload": JSON.stringify(payload)
  }

  // Notion APIでDBに登録する
  return JSON.parse(UrlFetchApp.fetch("https://api.notion.com/v1/pages", options))
}

DBに必要なプロパティを設定して登録していきます。
ご覧の通り、fetch処理です。

⑤ SuperなどのCMSサービスを使ってNotionを外部公開する(省略)

最後にSuperやWraptasなどのサードパーティ製のサービスを使って、Notionを外部公開します。

ここも気になる方はいらっしゃると思いますが、SuperやWraptasによるNotionの公開の仕方については他にも記事を書かれている方がたくさんいらっしゃるので、ここでは省略したいと思います。

まとめ

今回は、PharmaXのテックブログのように、公式noteとメンバー個人の技術ブロクのフィードで取得した情報をNotion DBに集約させる技術的方法を紹介しました。

サイトごとにフィードの形式が異なる点は注意が必要です。
個人的にはNotion APIの仕組みなどを学ぶことができたので、社内ツールなど色んなものを作ってみたいなと考えています。

Notion DBにRSSなどのフィードを集約したい方がいらっしゃれば参考にしてみていただけると嬉しいです!!

参考記事

今回は似たようなことをしている記事をたくさん見つけることができたため、下記のような記事を参考にしました。

Notion DBにRSSで取得した保存する方法

・『Notion を通知機能付き RSS フィードリーダーぽくする GAS ライブラリーを作ってみた
RSSを取得してNotionデータベースへ自動保存(前半)
・『RSS から Notion に登録(1) : Notion 解説(44)

Notion APIの使い方

・『Notion APIとNotionのデータテーブルを使ってCMSを作りベトナム語の単語学習アプリを作成した
・『Notion API を使用してデータベースを操作する

最後に

もしより詳しく聞きたいという方がいれば、YOUTRUSTまたはTwitter DMからお気軽にご連絡ください!

この記事が気に入ったらサポートをしてみませんか?