puppeteerでスクレイピングしてブログの投稿を検知する

こんにちは、イメージ・マジックの池田です。
今回はタイトルにもある通り、puppeteerのスクレイピング(とついでにブログの投稿検知)についてやっていきます。なぜなら最近プライベートで触ったからという、とても個人的な理由です。

puppeteerとは?

puppeteerというのは、Node.jsを用いてスクレイピングをするライブラリです。
はい、死ぬほど雑な説明をしました。違うわ!とまではいかないまでもお叱りを受けそうですね。詳しくは公式サイトをご覧ください。公式の英語サイトに投げる暴挙。とにかくNode.jsでスクレイピングのできるライブラリです。しかもChromeを作っているGoogle製。使わない理由はありませんね。

どうやって使うの?

  1. Node.jsの最新版をインストールします
  2. 好きな場所にフォルダを作り、npm init -yと打ち、npm i puppeteerします
用意はこれだけです。試しに公式サイトに載っているコードを実行してみましょう。同じフォルダにexample.jsを作成し、以下のコードを打ち込み、PowerShellなどからnode example.jsで実行してみてください。
const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto('https://example.com');
  await page.screenshot({ path: 'example.png' });

  await browser.close();
})();
同じフォルダにexample.pngというファイルが作成されたと思います。ファイルには見知らぬサイトのスクリーンショットが。puppeteerがブラウザを画面に出したりせずに撮ってきてくれたスクリーンショットです。実際にこのページにアクセスしてみると、画像そのままのサイトが表示されたと思います。
これを応用することで、ブログの投稿を検知することが可能となります。RSSがあればこういうことをしなくても大丈夫だったりしますが、そもそもRSSが下火だったり、RSSを提供するようなサイトじゃなかったりもしますからね。

プログラムの流れ

前提条件:昨日や一週間前に取ってきたタイトル一覧を手元に持っている(持っていなければ、下記手順の1~2、5だけを行います)
  1. ブログにアクセスする
  2. タイトル一覧を取得する
  3. 手元で持っているタイトル一覧と照合する
  4. 手元にないタイトルがあれば、そのタイトルを通知する
  5. 最新のタイトル一覧を手元に保存する
  6. おわり
簡単ですね。これをTypeScriptで書いたコードが下記です。私はVisual Studio Codeと組み合わせて使うTypeScriptが大好きなので、本来ならばこれの宣伝もしたいのですが、TypeScriptの素晴らしさは恐らく全ての生物が既に知っているので、ここでは取り扱いません。
import puppeteer, { Page } from "puppeteer";
import { readFileSync, writeFileSync } from "fs";
import csvParse = require("csv-parse/lib/sync")
import csvStringify = require("csv-stringify/lib/sync")
import { difference } from "./setUtils";

const CURRENT_FILE_NAME = 'current.csv';
const TITLE_SELECTOR = '.entry-title';

const readCurrentCsv = (): Set<string> | null => {
  try {
    const file = readFileSync(CURRENT_FILE_NAME);
    return new Set((csvParse(file) as Array<Array<string>>).flat());
  } catch (error) {
    // Error NO ENTryらしいです
    if (error.code === 'ENOENT') {
      return null;
    } else {
      throw new Error("知らんエラー。君誰?");
    }
  }
}

const fetchTitles = async (page: Page): Promise<Set<string>> => {
  const elements = await page.$$(TITLE_SELECTOR);
  return new Set(await Promise.all(elements.map(async ele => (await ele.getProperty('textContent'))!.jsonValue())) as Array<string>);
}

const writeCsv = (newTitles: Set<string>) => {
  writeFileSync(CURRENT_FILE_NAME, csvStringify([...newTitles].map(title => [title])));
}

const notify = (diffTitles: Set<string>) => {
  console.info(`
「${[...diffTitles].join('、')}」が開発者ブログに投稿されました。見てみましょう!
https://techblog.imagemagic.jp/
`.trim());
  // みたいのを通知したりしたいよね
}

const main = async (page: Page) => {
  const currentTitles = readCurrentCsv();
  const newTitles = await fetchTitles(page);

  if (currentTitles === null) {
    writeCsv(newTitles);
    return;
  }

  const diffTitles = difference(newTitles, currentTitles);
  if (!diffTitles.size) {
    return;
  }

  notify(diffTitles);
  writeCsv(newTitles);
}

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto('https://techblog.imagemagic.jp/');

  try {
    await main(page);
  } finally {
    await browser.close();
  }
})();
main関数の中身を見れば、先程のプログラムの流れと大体同じなことがわかると思います。このプログラムをたとえばAWSのLambdaに乗せて、CloudWatchで定期的に叩くなどすれば、差分検知通知システムの完成です。
細かいコードの解説は省きます。皆さんも興味のあるサイト(たとえば本テックブログ!)をスクレイピングしたりして、実際に触ってみてください。

※スクレイピングは少し油断するとアクセス負荷を相手のサーバーにかけてしまったり、そもそもスクレイピングを許可していないサイトもあります。使い方を間違えれば違法行為です。この辺りのサイトを見つつ、用法用量を守って正しくお使いください。