Slide 1

Slide 1 text

Web ページの更新を検知するツール を作った話 〜Web ページが更新されたら LINE でお知らせ!!〜 虎の穴ラボ株式会社 古賀広隆 - Copyright (C) 2022 Toranoana Inc. All Rights Reserved.

Slide 2

Slide 2 text

所属 虎の穴ラボ株式会社 とらのあな通販コスト削減チーム 主な担当 フロントエンド、サーバサイド 推し 南條愛乃さんです! 自己紹介 Copyright (C) 2022 Toranoana Inc. All Rights Reserved.

Slide 3

Slide 3 text

子供の声が入るかも 100% フルリモートワーク で、今日も三重県の自宅から配信して います。 ご了承ください Webページの更新を検知するツールを作った話 Copyright (C) 2022 Toranoana Inc. All Rights Reserved.

Slide 4

Slide 4 text

あじぇんだ! 1. なぜ作成したか? 2. 各種フレームワーク/ライブラリの選定 3. 処理の流れ 4. 実際の表示 5. 最後に Webページの更新を検知するツールを作った話 Copyright (C) 2022 Toranoana Inc. All Rights Reserved.

Slide 5

Slide 5 text

1. なぜ作成したか? Webページの更新を検知するツールを作った話 Copyright (C) 2022 Toranoana Inc. All Rights Reserved.

Slide 6

Slide 6 text

Web ページに変化があったら差分を画像で知りたいことがあった ので、個人的に作りました。 いきなり、個人 Web サイトに異常があったりとか Webページの更新を検知するツールを作った話 Copyright (C) 2022 Toranoana Inc. All Rights Reserved.

Slide 7

Slide 7 text

2. 各種フレームワーク/ライブラリの選定 Webページの更新を検知するツールを作った話 Copyright (C) 2022 Toranoana Inc. All Rights Reserved.

Slide 8

Slide 8 text

言語 Node.js & TypeScript → 得意なので Webページの更新を検知するツールを作った話 Copyright (C) 2022 Toranoana Inc. All Rights Reserved.

Slide 9

Slide 9 text

クラウド環境 Cloud Storage for Firebase → 得意なので Firebase Admin SDK → 画像をアップロード Webページの更新を検知するツールを作った話 Copyright (C) 2022 Toranoana Inc. All Rights Reserved.

Slide 10

Slide 10 text

ライブラリ LINE Bot SDK → LINE 通知に Playwright → Web サイトのスクショを撮る pngjs → 過去のスクショ(PNG)画像の読み込み pixelmatch → 過去のスクショと最新画像を比較する sharp → LINE 通知用に画像をリサイズ Webページの更新を検知するツールを作った話 Copyright (C) 2022 Toranoana Inc. All Rights Reserved.

Slide 11

Slide 11 text

RaspberryPi Cloud Storage Saturday 10:12 AM Hey man, got a sec? Hi Tim, of course, just give me a couple minutes to finish breakfast. Read Friday LINE Messaging API Web サイト等 ① スクショ取得 &保存 ② 画像の差分をとる ③ スクショと差分を アップロード (公開) ④ スクショのURL と メッセージをAPI に送 信 ⑤ スクショのURL の 内容を取得 システム構成イメージ Copyright (C) 2022 Toranoana Inc. All Rights Reserved.

Slide 12

Slide 12 text

3. 処理の流れ Webページの更新を検知するツールを作った話 Copyright (C) 2022 Toranoana Inc. All Rights Reserved.

Slide 13

Slide 13 text

3-1. Playwright で対象の Web ページを開く const browser = await playwright.chromium.launch({ args: ["--ignore-certificate-errors", "--lang=ja,en-US,en"], executablePath: "/usr/bin/chromium-browser", }); // Basic 認証がある場合 const context = await browser.newContext({ httpCredentials: { username: "xxxxxxxx", password: "xxxxxxxx", }, }); const page = await context.newPage(); await page.goto(new URL(url).href); // Web サイトのURL を指定する await page.waitForLoadState("domcontentloaded"); Webページの更新を検知するツールを作った話 Copyright (C) 2022 Toranoana Inc. All Rights Reserved.

Slide 14

Slide 14 text

3-2. スクショを撮る await page.waitForTimeout(3000); // たまにロード完了してないときがある await page.screenshot({ fullPage: true, path: `${id}.new.png`, }); Webページの更新を検知するツールを作った話 Copyright (C) 2022 Toranoana Inc. All Rights Reserved.

Slide 15

Slide 15 text

3-3. 前回実行時の画像ファイルがあったら PNG を読み込む if (fs.existsSync(`${id}.old.png`) && fs.existsSync(`${id}.new.png`)) { // 新旧ファイルが揃っているときは、比較する const file1 = fs.readFileSync(`${id}.old.png`) const file2 = fs.readFileSync(`${id}.new.png`) const img1 = PNG.sync.read(file1) const img2 = PNG.sync.read(file2) const { width, height } = img2 const diff2 = new PNG({ width, height }) Webページの更新を検知するツールを作った話 Copyright (C) 2022 Toranoana Inc. All Rights Reserved.

Slide 16

Slide 16 text

3-4. 比較して、比較結果を画像ファイルで書き出 す const result2 = pixelmatch(img1.data, img2.data, diff2.data, width, height); fs.writeFileSync(`${id}.diff2.png`, PNG.sync.write(diff2)); Webページの更新を検知するツールを作った話 Copyright (C) 2022 Toranoana Inc. All Rights Reserved.

Slide 17

Slide 17 text

比較結果画像のサンプル Copyright (C) 2022 Toranoana Inc. All Rights Reserved.

Slide 18

Slide 18 text

3-5. 閾値以上の差分があったら、サムネイル画像 作成 if (result2 > threshold) { // サムネイル向けの画像を作る sharp(`${id}.new.png`) .resize(512) // 横幅512px にリサイズ .toFile(`${id}.new.512.png`, (err, info) => { if (err) { throw err } console.log(info) }) Webページの更新を検知するツールを作った話 Copyright (C) 2022 Toranoana Inc. All Rights Reserved.

Slide 19

Slide 19 text

3-6. 差分ファイルと最新のファイルを Firebase にアップロード // 最新のスクショ画像をアップロード const respNew = await getStorage() .bucket() .upload(`${id}.new.png`, { destination: `${id}-${YYYYMMDDHHmmss}-new.png`, }); // 最新のスクショ画像のサムネイルをアップロード const respNew512 = await getStorage() .bucket() .upload(`${id}.new.512.png`, { destination: `${id}-${YYYYMMDDHHmmss}-new-512.png`, }); Webページの更新を検知するツールを作った話 Copyright (C) 2022 Toranoana Inc. All Rights Reserved.

Slide 20

Slide 20 text

3-7. LINE に通知を送る const client = new line.Client(clientConfig.default); const lineRespNew = await client.broadcast({ type: "image", originalContentUrl: respNew[0].publicUrl(), previewImageUrl: respNew512[0].publicUrl(), }); const lineRespText = await client.broadcast({ type: "text", text: url, }); Webページの更新を検知するツールを作った話 Copyright (C) 2022 Toranoana Inc. All Rights Reserved.

Slide 21

Slide 21 text

3-8. 旧ファイルを削除、最新ファイルをリネーム して保存する fs.unlinkSync(`${id}.old.png`); fs.renameSync(`${id}.new.png`, `${id}.old.png`); Webページの更新を検知するツールを作った話 Copyright (C) 2022 Toranoana Inc. All Rights Reserved.

Slide 22

Slide 22 text

4. 実際の表示 Webページの更新を検知するツールを作った話 Copyright (C) 2022 Toranoana Inc. All Rights Reserved.

Slide 23

Slide 23 text

4. サンプルサイト:変更前 Copyright (C) 2022 Toranoana Inc. All Rights Reserved.

Slide 24

Slide 24 text

4. サンプルサイト:変更後 Copyright (C) 2022 Toranoana Inc. All Rights Reserved.

Slide 25

Slide 25 text

4. サンプルサイト:実際の表示 Copyright (C) 2022 Toranoana Inc. All Rights Reserved.

Slide 26

Slide 26 text

5. さいごに! LINE Messaging API は、登録すると無料で使えます。 また、友達や家族との共有も QR コードや URL でできます。 他にも、LINE Messaging API を使って作りたい。 Webページの更新を検知するツールを作った話 Copyright (C) 2022 Toranoana Inc. All Rights Reserved.

Slide 27

Slide 27 text

5. さいごに! LINE 通知は他にも、とらのあな通販の告知とかセール、新発売、 配送状況、購入通知の通知などの機能開発に使えるかも?と思い ました。 画像の差分もわかりやすくて、家族には好評でした。 Webページの更新を検知するツールを作った話 Copyright (C) 2022 Toranoana Inc. All Rights Reserved.

Slide 28

Slide 28 text

ご清聴、ありがとうございました! Webページの更新を検知するツールを作った話 Copyright (C) 2022 Toranoana Inc. All Rights Reserved.

Slide 29

Slide 29 text

import * as playwright from "playwright-chromium"; import * as fs from "fs"; import { PNG } from "pngjs"; import * as pixelmatch from "pixelmatch"; import { exit } from "process"; import { initializeApp, applicationDefault } from "firebase-admin/app"; import { getStorage } from "firebase-admin/storage"; import * as line from "@line/bot-sdk"; import * as clientConfig from "./channel-access-token"; import * as sharp from "sharp"; const getNewScreenShot = async (id: string, url: string) => { const browser = await playwright.chromium.launch({ args: ["--ignore-certificate-errors", "--lang=ja,en-US,en"], executablePath: "/usr/bin/chromium-browser", }); const context = await browser.newContext({ httpCredentials: { username: "xxxxxxx", password: "xxxxxxx", }, }); const page = await context.newPage(); await page.goto(new URL(url).href); await page.waitForLoadState("domcontentloaded"); await page.waitForTimeout(3000); await page.screenshot({ fullPage: true, path: `${id}.new.png`, }); }; Appendix:ソースコード全録 Copyright (C) 2022 Toranoana Inc. All Rights Reserved.

Slide 30

Slide 30 text

const sendLineMessage = async (id: string, url: string) => { sharp(`${id}.new.png`) .resize(512) .toFile(`${id}.new.512.png`, (err, info) => { if (err) { throw err } console.log(info) }) sharp(`${id}.diff2.png`) .resize(512) .toFile(`${id}.diff2.512.png`, (err, info) => { if (err) { throw err } console.log(info) }) const YYYYMMDDHHmmss = new Date() .toISOString() .replace(/[^\d]/g, '') .slice(0, 14) initializeApp({ credential: applicationDefault(), storageBucket: 'change-web-page-reminder.appspot.com', }) Appendix:ソースコード全録 Copyright (C) 2022 Toranoana Inc. All Rights Reserved.

Slide 31

Slide 31 text

const respOld = await getStorage() .bucket() .upload(`${id}.old.png`, { destination: `${id}-${YYYYMMDDHHmmss}-old.png`, }); console.log("old publicUrl", respOld[0].publicUrl()); const respNew = await getStorage() .bucket() .upload(`${id}.new.png`, { destination: `${id}-${YYYYMMDDHHmmss}-new.png`, }); console.log("new publicUrl", respNew[0].publicUrl()); const respNew512 = await getStorage() .bucket() .upload(`${id}.new.512.png`, { destination: `${id}-${YYYYMMDDHHmmss}-new-512.png`, }); console.log("new publicUrl", respNew512[0].publicUrl()); const respDiff = await getStorage() .bucket() .upload(`${id}.diff2.png`, { destination: `${id}-${YYYYMMDDHHmmss}-diff2.png`, }); console.log("diff publicUrl", respDiff[0].publicUrl()); const respDiff512 = await getStorage() .bucket() .upload(`${id}.diff2.512.png`, { destination: `${id}-${YYYYMMDDHHmmss}-diff2-512.png`, }); console.log("diff publicUrl", respDiff512[0].publicUrl()); Appendix:ソースコード全録 Copyright (C) 2022 Toranoana Inc. All Rights Reserved.

Slide 32

Slide 32 text

const client = new line.Client(clientConfig.default) const lineRespNew = await client.broadcast({ type: 'image', originalContentUrl: respNew[0].publicUrl(), previewImageUrl: respNew512[0].publicUrl(), }) console.log(lineRespNew) const lineRespDiff = await client.broadcast({ type: 'image', originalContentUrl: respDiff[0].publicUrl(), previewImageUrl: respDiff512[0].publicUrl(), }) console.log(lineRespDiff) const lineRespText = await client.broadcast({ type: 'text', text: url, }) console.log(lineRespText) } Appendix:ソースコード全録 Copyright (C) 2022 Toranoana Inc. All Rights Reserved.

Slide 33

Slide 33 text

const main = async (id: string, url: string, threshold = 0) => { // url をヘッドレスブラウザで開いて、新しくスクリーンショットを取る await getNewScreenShot(id, url) if (fs.existsSync(`${id}.old.png`) && fs.existsSync(`${id}.new.png`)) { // 新旧ファイルが揃っているときは、比較する const file1 = fs.readFileSync(`${id}.old.png`) const file2 = fs.readFileSync(`${id}.new.png`) const img1 = PNG.sync.read(file1) const img2 = PNG.sync.read(file2) const { width, height } = img2 const diff2 = new PNG({ width, height }) console.log('pixelmatch2', img1.width, img1.height, img2.width, img2.height) const result2 = pixelmatch( img1.data, img2.data, diff2.data, width, height, { // threshold: 0.1, // includeAA: true, // alpha: 0.5, // aaColor: [255, 255, 0], // diffColor: [255, 0, 0], // diffColorAlt: [0, 0, 255], // diffMask: false, }, ) console.log('pixelmatch2', result2) fs.writeFileSync(`${id}.diff2.png`, PNG.sync.write(diff2)) Appendix:ソースコード全録 Copyright (C) 2022 Toranoana Inc. All Rights Reserved.

Slide 34

Slide 34 text

if (result2 > threshold) { // 画像をクラウドにアップロードして、LINE にメッセージ送信する await sendLineMessage(id, url) } // ファイル名を変更 fs.unlinkSync(`${id}.old.png`) } fs.renameSync(`${id}.new.png`, `${id}.old.png`) } const all = async () => { await main( 'id1', 'url1', ) await main( 'id2', 'url2', ) await main('id3', 'url3', 8) } Promise.all([all()]) .then((resutls) => { console.log('OK!', resutls) }) .catch((errors) => { console.error('ERROR.', errors) exit() }) .finally(() => { console.log('EXIT') exit() }) Appendix:ソースコード全録 Copyright (C) 2022 Toranoana Inc. All Rights Reserved.