Firebase と Node.js を使用して簡単的なクローラーを作成

Firebase の Firestore, Functions, Pub/Sub triggers を使用して、定期実行できる簡易的なクローラーを作成します。エミュレーター環境で実行させるまでとなります。

注意事項

クローラーはアクセス頻度を気を付けないとアクセス先に迷惑をかけることがあります。最悪アクセス先のサイトを停止させ、賠償金を支払うことになるので十分気を付けてください。

また、実行回数やデータ量が増えれば増えるほど Firebase の請求額も増えるので注意してください。

もちろん、当方は本稿のコード実行に生じた事象について責任を負いません。

下準備

バージョン情報

  • firebase-tools@13.0.3
  • node@18.19.1

firebase プロジェクトの作成~エミュレーター環境構築

適当なディレクトリで firebase init を実行して下記の通り選択。

? Which Firebase features do you want to set up for this directory? Press Space to select features, then Enter to confirm your choices.
 ◉ Firestore: Configure security rules and indexes files for Firestore
 ◉ Functions: Configure a Cloud Functions directory and its files
 ◯ Hosting: Configure files for Firebase Hosting and (optionally) set up GitHub Action deploys
 ◯ Hosting: Set up GitHub Action deploys
 ◯ Storage: Configure a security rules file for Cloud Storage
 ◉ Emulators: Set up local emulators for Firebase products
 ◯ Remote Config: Configure a template file for Remote Config

=== Project Setup

? Please select an option:
❯ Use an existing project
  Create a new project
  Add Firebase to an existing Google Cloud Platform project
  Don't set up a default project
※ここはお好みで

=== Firestore Setup

? What file should be used for Firestore Rules?
  firestore.rules

? What file should be used for Firestore indexes?
  firestore.indexes.json

? What language would you like to use to write Cloud Functions? TypeScript
? Do you want to use ESLint to catch probable bugs and enforce style? (Y/n)

=== Functions Setup

? What language would you like to use to write Cloud Functions?
  TypeScript

? Do you want to use ESLint to catch probable bugs and enforce style?
  Yes

? Do you want to install dependencies with npm now?
  Yes

=== Emulators Setup

 ◯ Authentication Emulator
 ◉ Functions Emulator
 ◉ Firestore Emulator
 ◯ Database Emulator
 ◯ Hosting Emulator
 ◉ Pub/Sub Emulator
 ◯ Storage Emulator

? Which port do you want to use for the functions emulator?
  5001

? Which port do you want to use for the firestore emulator?
  8080

? Which port do you want to use for the pubsub emulator?
  8085

? Would you like to enable the Emulator UI?
  Yes

? Which port do you want to use for the Emulator UI (leave empty to use any available port)?

? Would you like to download the emulators now?
  Yes

ファイル構成, パッケージ

プロジェクト構築時点で下記のようなファイル構成になっています。

.
├── firebase.json
├── .firebaserc
├── firestore.indexes.json
├── firestore.rules
├── functions
│   ├── .eslintrc.js
│   ├── .gitignore
│   ├── package.json
│   ├── package-lock.json
│   ├── src
│   │   └── index.ts
│   ├── tsconfig.dev.json
│   └── tsconfig.json
└── .gitignore

最終的に functions/src ディレクトリ配下が下記のような構成になります。

functions/src
├── crawler.ts // Firestore との接続、ページへのアクセスと、スクレイピング処理をまとめたクラス
├── firebase.ts // Firebase への接続情報
├── index.ts // Functions の設定ファイル

functions/package.json の内容も記しておきます。

@firebase/firestore-types, jsdom, @types/jsdomを追加でインストールしています。

{
  "name": "functions",
  "scripts": {
    "lint": "eslint --ext .js,.ts .",
    "build": "tsc",
    "build:watch": "tsc --watch",
    "serve": "npm run build && firebase emulators:start --only functions",
    "shell": "npm run build && firebase functions:shell",
    "start": "npm run shell",
    "deploy": "firebase deploy --only functions",
    "logs": "firebase functions:log"
  },
  "engines": {
    "node": "18"
  },
  "volta": {
    "node": "18.19.1"
  },
  "main": "lib/index.js",
  "dependencies": {
    "firebase-admin": "^11.8.0",
    "firebase-functions": "^4.3.1"
  },
  "devDependencies": {
    "@firebase/firestore-types": "^3.0.0",
    "@types/jsdom": "^21.1.6",
    "@typescript-eslint/eslint-plugin": "^5.12.0",
    "@typescript-eslint/parser": "^5.12.0",
    "eslint": "^8.9.0",
    "eslint-config-google": "^0.14.0",
    "eslint-plugin-import": "^2.25.4",
    "firebase-functions-test": "^3.1.0",
    "jsdom": "^24.0.0",
    "typescript": "^4.9.0"
  },
  "private": true
}

コーディング

firebase.ts

Firestore への接続情報の記載と初期化までを行っています。接続情報は公開されても基本的に問題ないもので且つ試用であれば直接記述したほうが都合よいですが、実際には dotenv などを使って環境変数でまとめる形になると思います。

import * as admin from "firebase-admin";

const firebaseConfig = {
  apiKey: ***,
  authDomain: ***,
  projectId: ***,
  storageBucket: ***,
  messagingSenderId: ***,
  appId: ***,
  measurementId: ***,
};

const app = admin.initializeApp(firebaseConfig);

export const firestore = app.firestore();

index.ts

クローラーを実行させる方法として HTTPS リクエストから URL を指定する方法(crawler)と、Pub/Sub の提供するスケジューラーで定期実行する方法(crawlJob)とを記載しています。

定期実行のほうは every 5 minutes とある通り、5分ごとに実行されるようにしています。

import * as functions from "firebase-functions";
import Crawler from "./crawler";
import { firestore } from "./firebase";

exports.crawler = functions
  .region("asia-northeast1")
  .https.onRequest((request, response) => {
    const { url } = request.body;

    new Crawler(firestore).exec(url);

    response.status(200).send("");
  });

exports.crawlJob = functions
  .region("asia-northeast1")
  .pubsub.schedule("every 5 minutes")
  .onRun(() => {
    new Crawler(firestore).exec();

    return null;
  });

crawler.ts

先に説明した3ファイルの内、クローラーの実処理は crawler.ts にまとまっています。

おおよその流れは下記のとおりです。

  1. exec() 実行によって、 与えられた url 引数をもとに pages コレクションへドキュメント登録
  2. 対象 url へアクセスして title とリンク群を取得
  3. title の値を pages コレクションに登録したドキュメントのレコードへ登録
  4. 取得したリンク群を、1リンクごとに pages コレクションへドキュメント登録

exec() メソッドに url 引数を渡さない場合には、登録されたリンク群(未アクセス)のうち1つを対象に上記の流れを実行します。

index.tscrawlJob() では url 引数を指定していないので、定期実行時にはまだスクレイピングされていないものが選択されます。

import * as admin from "firebase-admin";
import { JSDOM } from "jsdom";

type Page = {
  url: string;
  scrapedAt?: string | null;
};

export default class Crawler {
  private firestore: FirebaseFirestore.Firestore;
  private pagesColRef: admin.firestore.CollectionReference<Page>;

  constructor(firestore: FirebaseFirestore.Firestore) {
    this.firestore = firestore;
    this.pagesColRef = this.firestore.collection(
      "pages"
    ) as admin.firestore.CollectionReference<Page>;
  }

  async exec(url?: string) {
    if (!url) {
      const unScrapedPageRef = await this.getUnScrapedPageRef();
      if (!unScrapedPageRef) return;

      const unScrapedUrl = (await unScrapedPageRef.get()).data()?.url;
      if (!unScrapedUrl) return;

      url = unScrapedUrl;
    }

    const pageRef = await this.getPageRef(url);

    await this.scrape(url, pageRef);
  }

  async getPageRef(url: string) {
    const query = this.pagesColRef;

    const snapshot = await query.where("url", "==", url).limit(1).get();

    return snapshot.docs[0]
      ? snapshot.docs[0].ref
      : await this.pagesColRef.add({ url });
  }

  private async getUnScrapedPageRef() {
    const query = this.pagesColRef;

    const snapshot = await query.where("scrapedAt", "==", null).limit(1).get();

    return snapshot.docs[0] ? snapshot.docs[0].ref : null;
  }

  async scrape(
    url: string,
    pageRef: FirebaseFirestore.DocumentReference<FirebaseFirestore.DocumentData>
  ) {
    let res;

    try {
      res = await fetch(url, {
        headers: { "content-type": "text/plain;charset=utf-8" },
      });
    } catch (error) {
      throw new Error(`'${url}' is invaild url.`);
    }

    const html = await res.text();
    const title = this.getTitle(html);
    await pageRef.update({ title, scrapedAt: Date.now() });

    const links = this.getLinks(html);
    await Promise.all(
      links.map(async (url) => {
        await this.pagesColRef.add({ url, scrapedAt: null });
      })
    );
  }

  private getTitle(html: string): string {
    const jsdom = new JSDOM(html);
    const document = jsdom.window.document;
    const title = document.querySelector("title");
    return title && title.textContent ? title.textContent : "";
  }

  private getLinks(html: string): string[] {
    const jsdom = new JSDOM(html);
    const document = jsdom.window.document;
    const links = document.querySelectorAll("a");
    return links ? [...new Set(Array.from(links).map((a) => a.href))] : [];
  }
}

実行テスト

ここまでコードを準備したところで、エミュレーター環境を開始して試してみます。

firebase emulators:start --project {プロジェクトID} によってしばらくするとエミュレーターが起動します。http://127.0.0.1:4000/firestore/data で空っぽの Firestore のウェブ管理画面にアクセスできます。

この状態で例えば次の HTTPS リクエストを実行するコマンド curl -X POST http://127.0.0.1:5001/{プロジェクID}/asia-northeast1/crawler --data-urlencode "url=https://example.com/" を実行し、 pages コレクションにドキュメントが追加されたことを確認します。または http://127.0.0.1:4000/logs にアクセスすると Finished "*-crawler" in 26.695678ms のように処理の完了を確認できます。これで HTTPS リクエストのほうはOKです。

Pub/Sub のスケジュールによる定期実行のほうですが、実はエミュレーターが対応していないので、まず firebase functions:shell --project {プロジェクトID} を実行してジョブを直接実行できるようにします。しばらくすると firebase > というプロンプトが表示されるので、その状態で crawlJob() を実行することで、定期実行する関数を直接実行して確認することができます。先ほどの Firestore ウェブ管理画面上でデータが追加されていることが確認できます。

本番環境ではスケジュールも登録されるので、5分ごとに未スクレイピングのドキュメントがなくなるまで繰り返し crawlJob() が実行されます。

参考サイト