Help us understand the problem. What is going on with this article?

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

どうも!

Yoki(@enyyokii)と申します。

渋谷のIT企業でアプリエンジニアしている26才です。
仕事では iOS、Android、Webフロントエンドなど色々しており、週末は勉強を兼ねて個人開発したりしています。

今回はみんな大好きFirebaseの中でも、Cloud Functionsの定期実行についてです🔥

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コレクションにも追加

となっています。

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時に)
日付の他にも曜日、月を指定することも可能

参考:cron.yaml リファレンス

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

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をトリガしてみる

ソースコード全体

index.js

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

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 の例

// 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
};
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away