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は =の前後に空白禁止、右側にコメントを書かない。
.envのTARGET_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 = ``;
}
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手順
-
タスク スケジューラ → タスクの作成
-
全般:
- 名前:
SiteWatcher - 「最上位の特権で実行」✅
- 「ユーザーがログオンしているかどうかにかかわらず実行」→ 保存時にパスワード入力
- 名前:
-
トリガー:毎日/繰り返し 5分/無期限
-
操作:
- プログラム:
D:\work\site-watcher\run_watch.bat - 開始(作業フォルダー):
D:\work\site-watcher
- プログラム:
-
設定:
- 「スケジュールされた開始が行われなかった場合はできるだけ早く実行」✅
- 「タスクが既に実行中の場合:新しいインスタンスを開始しない」
ヒント:動かないときは「最後の実行結果」のコード、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) セキュリティ運用
-
.envは Git管理しない(秘密情報) - ログやスクショに機微情報が含まれる可能性 → 保管/共有は慎重に
- 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