💡
Playwright監視#2 Teams通知を追加し、履歴画像を7日で自動
この記事は
第1回Webの値取得→メール通知→Web表示→タスク自動化
の続編です。
できること(今回の追加点)
- Microsoft Teams にも通知(Incoming Webhook)
-
public/shots/
に蓄積される履歴画像を7日で自動削除
置き換えるのは
.env
とwatch.js
だけ。
Node.js は v18 以上(fetch
が標準で使えます)。
.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
# 二段階認証を有効化して発行(空白なし16桁)
SMTP_PASS=16桁のアプリパスワード
# From は USER と同じに(まずは一致推奨)
MAIL_FROM=あなたのgmail@gmail.com
MAIL_TO=通知を受け取りたいメールアドレス
# --- Teams 通知 ---
# Teams の Incoming Webhook URL(URL以外の文字を入れないこと!)
TEAMS_WEBHOOK_URL=
# --- ステータス公開URL(任意)。例: https://example.com/site-watcher/ ※末尾スラッシュ推奨 ---
PUBLIC_BASE_URL=
# --- 履歴の自動削除(既定 7 日保持)---
SHOTS_KEEP_DAYS=7
TARGET_SELECTOR
は#
を含むため 必ず引用 してください(例:"#price"
)。
Teams の Webhook は URLだけ をそのまま貼る(前後に文字を足さない)。
watch.js(全文)
require('dotenv').config({ path: require('path').join(__dirname, '.env') });
/* ========================
* ログに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'); // 履歴ディレクトリ
const TEAMS_WEBHOOK_URL = env('TEAMS_WEBHOOK_URL');
const PUBLIC_BASE_URL = env('PUBLIC_BASE_URL'); // 例: https://example.com/site-watcher/
const SHOTS_KEEP_DAYS = Number(env('SHOTS_KEEP_DAYS', '7')); // 既定: 7日保持
/* =======================
* Teams へ通知(任意設定)
* ======================= */
async function notifyTeams({ numeric, threshold, targetUrl, selector, jpNow, shotName }) {
if (!TEAMS_WEBHOOK_URL) {
console.log('[INFO] TEAMS_WEBHOOK_URL 未設定のため Teams 通知をスキップ');
return;
}
// 公開URLがある場合のみリンク/画像を付与
const statusUrl = PUBLIC_BASE_URL ? new URL('index.html', PUBLIC_BASE_URL).toString() : '';
const imageUrl = (PUBLIC_BASE_URL && shotName)
? new URL(`shots/${encodeURIComponent(shotName)}`, PUBLIC_BASE_URL).toString()
: (PUBLIC_BASE_URL ? new URL('latest.png', PUBLIC_BASE_URL).toString() : '');
// Office 365 Connector の MessageCard 形式
const card = {
'@type': 'MessageCard',
'@context': 'https://schema.org/extensions',
summary: 'SiteWatcher Alert',
themeColor: 'C30000',
title: `【監視】閾値超え: ${numeric} (しきい値: ${threshold})`,
sections: [{
facts: [
{ name: '時刻(JST)', value: jpNow },
{ name: 'URL', value: targetUrl },
{ name: 'セレクタ', value: selector },
{ name: '取得値/閾値', value: `${numeric} / ${threshold}` },
],
...(imageUrl ? { images: [{ image: imageUrl, title: 'latest' }] } : {}),
}],
...(statusUrl ? {
potentialAction: [{
'@type': 'OpenUri',
name: 'ステータスを見る',
targets: [{ os: 'default', uri: statusUrl }],
}],
} : {}),
};
try {
const res = await fetch(TEAMS_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(card),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
console.error('[ERROR] Teams Webhook 失敗:', res.status, text);
} else {
console.log('[INFO] Teams に通知しました');
}
} catch (e) {
console.error('[ERROR] Teams 通知で例外:', (e && e.message) || e);
}
}
/* ===========================
* Web出力(status.json のみ)
* =========================== */
async function writeWebOutput({ numeric, threshold, url, selector }) {
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');
}
/* ==============================
* 古いスクショを自動削除(履歴掃除)
* ============================== */
async function pruneOldShots(dir, keepDays) {
try {
const files = await fs.readdir(dir);
const cutoff = Date.now() - keepDays * 24 * 60 * 60 * 1000;
for (const f of files) {
const full = path.join(dir, f);
try {
const st = await fs.stat(full);
if (st.isFile() && st.mtimeMs < cutoff) {
await fs.unlink(full);
}
} catch { /* ignore per-file errors */ }
}
} catch { /* ignore directory errors */ }
}
/* =====
* Main
* ===== */
(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}`);
// --- スクリーンショット(Bufferで取得→履歴保存&latest.png更新)
const ts = nowTs();
const snapBuf = await page.screenshot({ fullPage: true }); // ← path を渡さない
await fs.mkdir(SHOTS_DIR, { recursive: true });
const shotName = `screenshot-${ts}.png`;
const shotPath = path.join(SHOTS_DIR, shotName);
await fs.writeFile(shotPath, snapBuf); // 履歴として保存
await fs.writeFile(path.join(PUBLIC_DIR, 'latest.png'), snapBuf); // 表示用に上書き
// --- ステータス(JSON)を書き出し
await writeWebOutput({
numeric, threshold: THRESHOLD, url: TARGET_URL, selector: TARGET_SELECTOR,
});
// --- 閾値判定
const shouldNotify = numeric >= THRESHOLD;
if (!shouldNotify) {
console.log(`[INFO] 閾値(${THRESHOLD})未満のため通知なし`);
return;
}
// --- クールダウン(再通知抑止)
const COOLDOWN_MIN = Number(env('COOLDOWN_MIN', '60'));
const cdFile = path.join(__dirname, '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: `screenshot-${ts}.png`, content: snapBuf },
// インライン表示したい場合は以下(HTML側で <img src="cid:snap">)
// { filename: `screenshot-${ts}.png`, content: snapBuf, cid: 'snap' },
],
});
console.log('[INFO] 通知メールを送信しました (MessageID):', info.messageId);
// --- Teams通知(設定時のみ)。画像は履歴ファイルのURLを参照
await notifyTeams({
numeric, threshold: THRESHOLD,
targetUrl: TARGET_URL, selector: TARGET_SELECTOR, jpNow,
shotName, // 公開URLがあれば「/shots/…」で参照
});
// --- クールダウン更新
await fs.writeFile(cdFile, JSON.stringify({ sentAt: Date.now() }, null, 2));
} 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 {
// どの分岐でも最後に履歴掃除を実行
try { await pruneOldShots(SHOTS_DIR, SHOTS_KEEP_DAYS); } catch {}
await browser.close();
}
})();
使い方メモ(超短縮)
-
.env
を上のサンプル通り作成(必要ならTEAMS_WEBHOOK_URL
とPUBLIC_BASE_URL
も設定) -
一度
sample.html
を 1500 にして実行cd /d D:\work\site-watcher npm run watch
-
期待結果
- Teamsに通知
-
public/status.json
/public/latest.png
が更新 -
public/shots/
に履歴が増える(7日より古いものは自動削除)
よくあるつまずき
-
Failed to parse URL
→.env
のTEAMS_WEBHOOK_URL
に URL以外の文字を混ぜていないか - 画像がTeamsで見えない →
PUBLIC_BASE_URL
が 外部から到達可能 か(127.0.0.1
は不可)
Discussion