【Firebase, Cloud Functions, TypeScript】定期実行を利用してRSSからデータを取得しよう！

Yoki(@enyyokii)と申します。

今回はRSS記事を取得し、FireStoreに定期的に格納していくコードを書いてみました。（動作確認済み）

## 環境

使用言語：Typescript
主な使用ライブラリ：rss-parser

## やること

- RSSから記事情報を取得し、Firestoreに格納する
- 新しい記事がある場合のみ追加する
- 12時間ごとに定期実行する

## Firestoreの構造

FireStoreのデータ構造は下記のようになっています。

- Articles
  - 記事情報一覧（全記事一覧の意。RSSから取得した記事のみならず、それ以外から取得した記事も格納される。）
- RSS
  - 各RSSに分け、それぞれで取得してきた記事が格納される

```
├── Articles（コレクション）
│   ├── 111111（自動生成ID）（ドキュメント）
│       ├── title: "タイトルです"
│       ├── imgUrl: "https://image.png"
│       ├── summary: "aaaaaaaa"
│       ├── url: "https://urlurl"
│       ├── date: 2019年1月1日 11:11 UTC+9
│       └── category: ["カテゴリ名"]
│   └── 222222
│       └── （省略）
│
├── RSS（コレクション）
│   ├── CNET（ドキュメント）
│       └── Items（サブコレクション）
│           ├── 333333（自動生成ID）（ドキュメント）
│               ├── title: "タイトルです"
│               ├── imgUrl: "https://image.png"
│               ├── summary: "aaaaaaaa"
│               ├── url: "https://urlurl"
│               ├── date: 2019年1月1日 11:11 UTC+9
│               └── category: ["カテゴリ名"]
│           └── 444444（自動生成ID）（ドキュメント）
│               └── （省略）
│       └── Hatena
│           └── （省略）
```

## 記事取得

パーサーとしてrss-parserを利用しています。

処理の流れとしては、

RSSより記事取得
→ 最新の記事を取得
→ FireStoreのRSSに格納されている最新の記事取得
→ FireStoreに最新の記事がなければRSSのItemsに書き込み処理
→ Articlesコレクションにも追加

となっています。

```ts
const fetchColumn = async (rssName: string, urlString: string) => {
  const items: rssParser.Item[] = [];

  const parser = new rssParser();
  const feed = await parser.parseURL(urlString);

  if (feed && feed.items) {
    feed.items.forEach(item => {
      items.push(item)
    });
  }

  const firstItem = items[0]
  const postData = postToFireStoreData(firstItem);
  const itemsRef = admin
    .firestore()
    .collection("Rss")
    .doc(`${rssName}`)
    .collection("Items");

  const querySnapShot = await itemsRef
    .orderBy("date", "desc")
    .limit(1)
    .get()
    .catch((error: Error) => {
      console.log("エラー　アイテム取得:　", error);
    });

  const latestItem: any | null = querySnapShot ? querySnapShot.docs[0] : null
  const latestUrl = latestItem ? latestItem.data().url : "";

  if (String(latestUrl) !== firstItem.link) {
    await itemsRef
      .add(postData)
      .catch(error => {
        console.log("エラー　Document書き込み:", error);
      });

    // Articlesにデータを追加
    await addArticle(postData)
  }
};

```

## スケジュール設定について

スケジュールの設定方法はcronのスケジュール設定方式に従います。
以下のような設定が可能です。

|          | 種類       | 例                                                           |
| -------- | ---------- | ------------------------------------------------------------ |
| 繰り返し | 毎日       | every 5 mins（5分毎に）、every 12 hours（12時間毎）、every 1 hours from 08:00 to 16:00（毎日 08:00～16:00 に 1 時間ごとに） |
|          | 特定の日時 | 1, 15 of month 09:00（1日と15日の9時に）<br />日付の他にも曜日、月を指定することも可能 |

[参考：cron.yaml リファレンス](https://cloud.google.com/appengine/docs/standard/python/config/cronref?hl=ja#end-time)

定期実行の処理は以下のように簡単にかけます！

```ts
exports.fetchData = functions.pubsub
    .schedule("every 12 hours")
    .onRun(async context => {
  
  			// 定期実行の処理
        return 0
    });
```

`return 0`としていますが、これは`int`を返さないと
`Function returned undefined, expected Promise or value`
という警告が出るのを回避するためです。
[参考：Cloud Functions for FirebaseでPubSubをトリガしてみる](https://qiita.com/bathtimefish/items/2ffc5ab6c6db8e59eb66)

## ソースコード全体

index.js

```ts
import * as functions from "firebase-functions";
import * as rss from "./rss";
import * as admin from "firebase-admin";
import * as rssItem from "./Constant/rssItem";

admin.initializeApp();

exports.fetchData = functions.pubsub
    .schedule("every 12 hours")
    .onRun(async context => {
        await rss.fetchColumn(rssItem.cnet[0], rssItem.cnet[1]);
        await rss.fetchColumn(rssItem.cnn[0], rssItem.cnn[1]);
        await rss.fetchColumn(rssItem.gigazine[0], rssItem.gigazine[1]);
        await rss.fetchColumn(rssItem.hatena[0], rssItem.hatena[1]);
        await rss.fetchColumn(rssItem.huffpost[0], rssItem.huffpost[1]);
        await rss.fetchColumn(rssItem.lifehacker[0], rssItem.lifehacker[1]);
        await rss.fetchColumn(rssItem.netlab[0], rssItem.netlab[1]);
        await rss.fetchColumn(rssItem.techCrunch[0], rssItem.techCrunch[1]);
        await rss.fetchColumn(rssItem.toyoKeizai[0], rssItem.toyoKeizai[1]);
        await rss.fetchColumn(rssItem.wired[0], rssItem.wired[1]);

        // intを返さないと警告が出る https://qiita.com/bathtimefish/items/2ffc5ab6c6db8e59eb66
        return 0
    });
```

rss.ts

```ts
import admin = require("firebase-admin");
import rssParser = require("rss-parser");

/**
 * パースしたアイテムをfirestoreで保存するデータに変換
 * @param parsedItem パースしたアイテム
 */
const postToFireStoreData = (parsedItem: rssParser.Item): {} => {

  let imageUrl = ""
  // 正規表現でsrc内のurlを取得
  if (parsedItem.content) {
    const res = parsedItem.content.match("<img.*src\s*=\s*[\"|\'](.*?)[\"|\'].*>")
    imageUrl = res ? res[1] : ""
  }

  return {
    title: parsedItem.title || "",
    summary: parsedItem.contentSnippet || "",
    url: parsedItem.link || "",
    date: parsedItem.isoDate ? new Date(parsedItem.isoDate) : "" ,
    imgUrl: imageUrl,
    category: parsedItem.categories || ""
  };
};

const addArticle = async (articleData: {}) => {
  const itemsRef = admin.firestore().collection("Articles");
  await itemsRef.add(articleData).catch((error) => {
    console.error("エラー Article書き込み：", error);
  })
};

const fetchColumn = async (rssName: string, urlString: string) => {
  const items: rssParser.Item[] = [];

  const parser = new rssParser();
  const feed = await parser.parseURL(urlString);

  if (feed && feed.items) {
    feed.items.forEach(item => {
      items.push(item)
    });
  }

  const firstItem = items[0]
  const postData = postToFireStoreData(firstItem);
  const itemsRef = admin
    .firestore()
    .collection("Rss")
    .doc(`${rssName}`)
    .collection("Items");

  const querySnapShot = await itemsRef
    .orderBy("date", "desc")
    .limit(1)
    .get()
    .catch((error: Error) => {
      console.log("エラー　アイテム取得:　", error);
    });

  const latestItem: any | null = querySnapShot ? querySnapShot.docs[0] : null
  const latestUrl = latestItem ? latestItem.data().url : "";

  if (String(latestUrl) !== firstItem.link) {
    await itemsRef
      .add(postData)
      .catch(error => {
        console.log("エラー　Document書き込み:", error);
      });

    // Articlesにデータを追加
    await addArticle(postData)
  }
};

export { fetchColumn };

```

Constant/rssItem.ts の例

```ts
// RSS情報

// はてな：総合
const hatena: [string, string] = [
  "Hatena",
  `http://feeds.feedburner.com/hatena/b/hotentry`
];

// ねとらぼ：今気になる・人に話したい旬のネタをお届けするネットニュース
const netlab: [string, string] = [
  "Netlab",
  `https://rss.itmedia.co.jp/rss/2.0/netlab.xml`
];

export {
  hatena,
  netlab
};
```