🔔

Playwright監視#1 Webの値取得→メール通知→Web表示→タスク自動

に公開

目的(ゴール)

  • 指定ページの値を Playwright で取得
  • 閾値を超えたらメール通知(Gmail SMTP)
  • 最新状態をWebに表示(status.json + 画像)
  • Windows タスク スケジューラで定期実行

フォルダ構成

D:\work\site-watcher
  ├─ watch.js                 # 監視本体(最終版)
  ├─ .env                     # 環境変数(秘密情報を含む)
  ├─ sample.html              # ローカル動作確認用のテストHTML
  ├─ public\
  │   ├─ index.html
  │   ├─ status.json
  │   ├─ latest.png
  │   └─ shots\                 # ← 履歴フォルダ(自動生成)
  │       └─ screenshot-*.png   # 自動生成(履歴が増えていく)
  ├─ logs\
  │   └─ watch.log            # タスク実行ログ(任意)
  ├─ cooldown.json            # 再通知のクールダウン用(自動生成)
  └─ run_watch.bat            # タスク用バッチ(任意)

1) 前提ソフト(Windows)

  • Node.js v18 以上(v22でもOK)
    インストール後に確認:

    node -v
    npm -v
    where node
    
  • (任意)VS Code

2) プロジェクト作成

mkdir D:\work\site-watcher
cd /d D:\work\site-watcher
npm init -y
npm i playwright nodemailer dotenv
npx playwright install

任意:http-server を開発依存に入れておくと npm run preview が使えて便利です。

npm i -D http-server

package.json の scripts を追加(npm run watch を使うため)

{
  "scripts": {
    "watch": "node watch.js",
    "preview": "http-server ./public -p 8080"
  }
}

3) テスト用 HTML(サーバ不要の動作確認)

sample.html

<!doctype html><meta charset="utf-8">
<h1>テストページ</h1>
<div id="price">1234</div>

4) .env(Gmail SMTP・アプリパスワードを使用)

Gmail は 通常パスワード不可/アプリパスワード(16桁)必須
Googleアカウントで二段階認証を有効化 → アプリパスワードを発行し、空白を除いた16桁を入れてください。
.env=の前後に空白禁止右側にコメントを書かない
.envTARGET_SELECTOR は # を含むためコメントと誤解される可能性があるので、必ず引用する。

.env

# 監視対象(まずはローカルHTMLでテスト)
TARGET_URL=file:///D:/work/site-watcher/sample.html
TARGET_SELECTOR="#price"
THRESHOLD=1000
COOLDOWN_MIN=60

# Gmail SMTP (465/SSL)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=465
SMTP_SECURE=true
SMTP_USER=あなたのgmail@gmail.com
SMTP_PASS=16桁のアプリパスワード
MAIL_FROM=あなたのgmail@gmail.com
MAIL_TO=通知を受け取りたいメールアドレス

MAIL_FROM は SMTP_USER と同じにする(Gmailでは推奨)

読み込み確認(任意):

node -e "require('dotenv').config();console.log(process.env.TARGET_URL, process.env.TARGET_SELECTOR);console.log('PASS len=',(process.env.SMTP_PASS||'').length)"

PASS len= 16 になっていればOK。

5) watch.js — 値取得+メール通知+Web出力+クールダウン+時刻付きログ

watch.js

// watch.js
require('dotenv').config({ path: require('path').join(__dirname, '.env') });

// ---- console にJSTタイムスタンプ付与 ----
const stamp = () => new Date().toLocaleString('ja-JP', { timeZone: 'Asia/Tokyo', hour12: false });
for (const fn of ['log', 'info', 'warn', 'error']) {
  const orig = console[fn].bind(console);
  console[fn] = (...args) => orig(`[${stamp()}]`, ...args);
}

const { chromium } = require('playwright');
const nodemailer   = require('nodemailer');
const fs           = require('fs/promises');
const path         = require('path');
const { fileURLToPath } = require('url');

const env    = (k, d = '') => (process.env[k] ?? d).trim();
const nowTs  = () => new Date().toISOString().replace(/[:.]/g, '-');

const PUBLIC_DIR = path.join(__dirname, 'public');
const SHOTS_DIR  = path.join(PUBLIC_DIR, 'shots');   // ← 履歴保存先(ここだけに保存)

// ---- Web出力(status.json と latest.png)----
async function writeWebOutput({ numeric, threshold, url, selector, shotPath }) {
  await fs.mkdir(PUBLIC_DIR, { recursive: true });
  const status = {
    timeUtc:  new Date().toISOString(),
    timeJst:  new Date().toLocaleString('ja-JP', { timeZone: 'Asia/Tokyo' }),
    url, selector, value: numeric, threshold,
    ok: numeric < threshold,
    cooldownMin: Number(env('COOLDOWN_MIN', '60')),
  };
  await fs.writeFile(path.join(PUBLIC_DIR, 'status.json'), JSON.stringify(status, null, 2), 'utf8');

  // 最新画像にコピー(履歴の実体は shots/… にのみ置く)
  try {
    await fs.copyFile(shotPath, path.join(PUBLIC_DIR, 'latest.png'));
  } catch {}
}

(async () => {
  const TARGET_URL      = env('TARGET_URL');
  const TARGET_SELECTOR = env('TARGET_SELECTOR');
  const THRESHOLD       = Number(env('THRESHOLD', '0'));
  if (!TARGET_URL || !TARGET_SELECTOR) {
    console.error('[CONFIG ERROR] .env の TARGET_URL / TARGET_SELECTOR が空です');
    process.exit(1);
  }

  const browser = await chromium.launch({ headless: true });
  const context = await browser.newContext();
  const page    = await context.newPage();

  try {
    page.setDefaultTimeout(30_000);

    // ---- ページ読み込み(file:/// や D:\... にも対応)----
    if (TARGET_URL.startsWith('file:///')) {
      const fp = fileURLToPath(TARGET_URL);
      const html = await fs.readFile(fp, 'utf8');
      await page.setContent(html, { waitUntil: 'load' });
    } else if (/^[a-zA-Z]:[\\/]/.test(TARGET_URL)) {
      const html = await fs.readFile(TARGET_URL, 'utf8');
      await page.setContent(html, { waitUntil: 'load' });
    } else {
      await page.goto(TARGET_URL, { waitUntil: 'load' });
    }

    // ---- 値の取得 ----
    await page.waitForSelector(TARGET_SELECTOR, { state: 'visible' });
    const rawText = await page.textContent(TARGET_SELECTOR);
    const numeric = Number(String(rawText).replace(/[^\d.-]/g, ''));
    if (Number.isNaN(numeric)) throw new Error(`数値に変換できません: "${rawText}"`);
    console.log(`[INFO] value=${numeric}`);

    // ---- スクリーンショット(履歴保存) & Web出力 ----
    await fs.mkdir(SHOTS_DIR, { recursive: true });
    const ts       = nowTs();
    const fileName = `screenshot-${ts}.png`;
    const shotPath = path.join(SHOTS_DIR, fileName); // ← ここにだけ置く
    await page.screenshot({ path: shotPath, fullPage: true });

    await writeWebOutput({ numeric, threshold: THRESHOLD, url: TARGET_URL, selector: TARGET_SELECTOR, shotPath });

    // ---- 閾値判定 ----
    const shouldNotify = numeric >= THRESHOLD;
    if (!shouldNotify) {
      console.log(`[INFO] 閾値(${THRESHOLD})未満のため通知なし`);
      return;
    }

    // ---- クールダウン(再通知抑止)----
    const COOLDOWN_MIN = Number(env('COOLDOWN_MIN', '60'));
    const cdFile = 'cooldown.json';
    let last = { sentAt: 0 };
    try { last = JSON.parse(await fs.readFile(cdFile, 'utf8')); } catch {}
    const now = Date.now();
    const inCooldown = (now - last.sentAt) < COOLDOWN_MIN * 60 * 1000;
    if (inCooldown) {
      const remainMin = Math.ceil((COOLDOWN_MIN * 60 * 1000 - (now - last.sentAt)) / 60000);
      console.log(`[INFO] クールダウン中のためメール送信スキップ(残り 約${remainMin} 分)`);
      return;
    }

    // ---- メール送信(Gmail/汎用SMTP)----
    const MAIL_FROM        = env('MAIL_FROM');
    const MAIL_TO          = env('MAIL_TO');
    const SMTP_HOST        = env('SMTP_HOST');
    const SMTP_PORT        = Number(env('SMTP_PORT', '465'));
    const SMTP_SECURE      = env('SMTP_SECURE').toLowerCase() === 'true'; // 465:true / 587:false
    const SMTP_REQUIRE_TLS = env('SMTP_REQUIRE_TLS').toLowerCase() === 'true';
    const SMTP_AUTH_METHOD = env('SMTP_AUTH_METHOD') || undefined;        // 例: LOGIN(必要な場合のみ)
    const SMTP_USER        = env('SMTP_USER');
    const SMTP_PASS        = env('SMTP_PASS');
    const fromAddr         = MAIL_FROM || SMTP_USER;

    const transporter = nodemailer.createTransport({
      host: SMTP_HOST,
      port: SMTP_PORT,
      secure: SMTP_SECURE,
      requireTLS: SMTP_REQUIRE_TLS,
      authMethod: SMTP_AUTH_METHOD,
      auth: { user: SMTP_USER, pass: SMTP_PASS },
      logger: false,
      debug:  false,
      tls: { servername: SMTP_HOST },
    });

    const jpNow = new Date().toLocaleString('ja-JP', { timeZone: 'Asia/Tokyo' });

    const info = await transporter.sendMail({
      from: fromAddr,
      to:   MAIL_TO,
      envelope: { from: fromAddr, to: MAIL_TO },
      subject: `【監視】閾値超え: ${numeric} (しきい値: ${THRESHOLD})`,
      html: `
        <p>時刻(JST): ${jpNow}</p>
        <p>URL: ${TARGET_URL}</p>
        <p>セレクタ: <code>${TARGET_SELECTOR}</code></p>
        <p>取得値: <b>${numeric}</b> / 閾値: ${THRESHOLD}</p>
        <p>スクリーンショットを添付しています。</p>
      `,
      attachments: [
        { filename: fileName, path: shotPath },
        // インライン表示したい場合は以下を添付に差し替え
        // { filename: fileName, path: shotPath, cid: 'snap' },
      ],
    });

    await fs.writeFile(cdFile, JSON.stringify({ sentAt: Date.now() }, null, 2));
    console.log('[INFO] 通知メールを送信しました (MessageID):', info.messageId);

  } catch (err) {
    console.error('[ERROR]', err.message);
    try {
      // エラー時スクショも履歴フォルダに保存(直下に置かない)
      await fs.mkdir(SHOTS_DIR, { recursive: true });
      const errShot = path.join(SHOTS_DIR, `error-${nowTs()}.png`);
      await page.screenshot({ path: errShot, fullPage: true });
      console.log('[INFO] エラー時スクリーンショットを保存:', errShot);
    } catch (e) {
      console.error('[WARN] エラー時スクショ保存に失敗:', (e && e.message) || e);
    }
    process.exitCode = 1;
  } finally {
    await browser.close();
  }
})();

実行:

cd /d D:\work\site-watcher
npm run watch

想定ログ:

[YYYY/MM/DD HH:MM:SS] [INFO] value=1234
[YYYY/MM/DD HH:MM:SS] [INFO] 通知メールを送信しました (MessageID): <...>

クイック動作確認(早見チェック)

まずは通知&表示が一度で確認できるように、テスト値を閾値超えにして実行します。

<!-- sample.html を一時的に閾値超えにする -->
<div id="price">1500</div>
cd /d D:\work\site-watcher
npm run watch   :: 1500 >= 1000 なのでメール通知されるはず
npm run preview :: http://127.0.0.1:8080/ を開き、status.json / latest.png を確認

期待結果:

  • 受信箱に「【監視】閾値超え: 1500 …」のメールが届く

  • ステータスページに 取得値: 1500 / 判定: ALERT が表示され、最新スクショも更新されている

  • 代替案:sample.html をいじりたくない場合は .env の THRESHOLD=100 など閾値を下げるでもOK

6) Webステータスページ

public/index.html

<!doctype html><meta charset="utf-8">
<title>Site Watcher Status</title>
<style>
  :root { --ok:#0a0; --ng:#c00; }
  body{font:16px/1.6 system-ui,-apple-system,"Segoe UI",Roboto,"Noto Sans JP",Meiryo,sans-serif;margin:24px}
  .card{border:1px solid #ddd;border-radius:12px;padding:16px;max-width:880px;box-shadow:0 4px 12px rgba(0,0,0,.06)}
  .grid{display:grid;grid-template-columns:160px 1fr;gap:8px 12px;margin:8px 0 16px}
  .label{color:#555}
  .ok{color:var(--ok);font-weight:bold}
  .ng{color:var(--ng);font-weight:bold}
  code{background:#f6f8fa;padding:2px 6px;border-radius:6px}
  img{max-width:860px;border:1px solid #ddd;border-radius:8px}
  small{color:#777}
</style>
<div class="card">
  <h2>監視ステータス</h2>
  <div id="content">Loading...</div>
  <small>10秒ごとに自動更新します。</small>
</div>
<script>
async function load(){
  const res = await fetch('status.json?ts='+Date.now());
  if(!res.ok){ document.getElementById('content').textContent='status.json がありません。先に監視を一度実行してください。'; return; }
  const s = await res.json();
  const st = s.ok ? '<span class="ok">OK</span>' : '<span class="ng">ALERT</span>';
  document.getElementById('content').innerHTML = `
    <div class="grid">
      <div class="label">時刻(JST)</div><div>${s.timeJst}</div>
      <div class="label">URL</div><div><a href="${s.url}" target="_blank" rel="noopener">${s.url}</a></div>
      <div class="label">セレクタ</div><div><code>${s.selector}</code></div>
      <div class="label">取得値</div><div><b>${s.value}</b></div>
      <div class="label">閾値</div><div>${s.threshold}</div>
      <div class="label">判定</div><div>${st}</div>
      ${s.cooldownMin ? `<div class="label">再通知抑止</div><div>${s.cooldownMin} 分</div>` : ''}
    </div>
    <p><img src="latest.png?ts=${Date.now()}" alt="latest screenshot"></p>`;
}
load(); setInterval(load, 10000);
</script>

配信:

# どちらか
npm run preview
# または
cd /d D:\work\site-watcher\public
npx http-server -p 8080
  • 初回は Windows ファイアウォールの許可が必要
  • EADDRINUSE: 8080 が出たら -p 8081 に変更
  • ブラウザで http://127.0.0.1:8080/ を開く

7) タスク スケジューラで自動実行

バッチ(任意)

run_watch.bat

@echo off
cd /d D:\work\site-watcher
if not exist logs mkdir logs
"C:\Program Files\nodejs\node.exe" watch.js >> "D:\work\site-watcher\logs\watch.log" 2>&1

(タスクスケジューラ単体でも設定は可能だが、稼働が不安定だったため、今回はバッチを使用)

GUI手順

  1. タスク スケジューラ → タスクの作成

  2. 全般

    • 名前:SiteWatcher
    • 「最上位の特権で実行」✅
    • 「ユーザーがログオンしているかどうかにかかわらず実行」→ 保存時にパスワード入力
  3. トリガー:毎日/繰り返し 5分/無期限

  4. 操作

    • プログラム:D:\work\site-watcher\run_watch.bat
    • 開始(作業フォルダー):D:\work\site-watcher
  5. 設定

    • 「スケジュールされた開始が行われなかった場合はできるだけ早く実行」✅
    • 「タスクが既に実行中の場合:新しいインスタンスを開始しない」

ヒント:動かないときは「最後の実行結果」のコード、logs\watch.log を確認。0x1 は作業フォルダ/パスのミスが多い。

8) トラブルシューティング(よくある)

症状 原因 対処
net::ERR_CONNECTION_REFUSED URLにサーバ不在 まずは file:///.../sample.html でテスト
「.envに設定してください」 キー名の誤り/未設定 TARGET_URL / TARGET_SELECTOR を見直し
値がNaN セレクタ誤り/文字整形不足 セレクタをDevToolsで再取得、数値整形を調整
メールが届かない Gmail通常PW使用 アプリパスワード16桁 使用必須
535 authentication failed 空白/行末コメント/From不一致 .envの空白削除、MAIL_FROM=SMTP_USER、行末にコメントを書かない
status.json がない まだ一度も実行してない/出力位置が後ろ スクショ直後・判定前で Web出力、npm run watch を一度実行
タスクが動かない 作業フォルダ未指定/資格情報未保存 「開始(作業フォルダ)」を設定、保存時にWindowsパスワード入力

9) セキュリティ運用

  • .envGit管理しない(秘密情報)
  • ログやスクショに機微情報が含まれる可能性 → 保管/共有は慎重に
  • Gmailは送信上限あり。本番運用は 自社SMTP/Microsoft 365 への切替を推奨

10) 仕上げ:実行コマンドまとめ(最小セット)

cd /d D:\work\site-watcher
npm run watch

cd public
npx http-server -p 8080

付録:
.gitignore

.env
node_modules/
logs/
cooldown.json
public/status.json
public/latest.png
public/shots/          # 履歴画像を全部無視
# 必要なら個別に例外解除して使う

微メモ

  • gitignore の .png は全PNGが無視されます。後々 public に手動で置くPNGをコミットしたくなる可能性があるなら、screenshot-.png と error-*.png のみに絞るのも手です(今回はこのままでもOK)。
  • run_watch.bat で Node のフルパス指定は堅牢ですが、将来 Node の場所が変わると失敗します。PATH が通っている環境なら node watch.js でも可(現状のままでもOK)。

11) 次の拡張(必要になったら)

  • Teams通知(チャネル:Incoming Webhook/個人宛:Activity Feed通知)
  • 複数URL/複数セレクタの監視 → 配列化してループ
  • 履歴を残してグラフ化 → history.json を追記&表示ページでグラフ

Discussion