タグ: , ,

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で実行してみてください。
Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
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();
})();
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(); })();
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の素晴らしさは恐らく全ての生物が既に知っているので、ここでは取り扱いません。
Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
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();
}
})();
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(); } })();
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で定期的に叩くなどすれば、差分検知通知システムの完成です。
細かいコードの解説は省きます。皆さんも興味のあるサイト(たとえば本テックブログ!)をスクレイピングしたりして、実際に触ってみてください。

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