🚥
Playwright監視#3 履歴CSV出力
この記事は
第1回Webの値取得→メール通知→Web表示→タスク自動化
第2回Teams通知+履歴画像の7日削除
の続編です。
できること(今回の追加点)
- 取得結果を
public/history.csvに毎回追記(時刻・値・閾値・判定・URL・セレクタ・スクショパス) - コードコメントのスタイル統一(読みやすさ向上)
フォルダ構成
D:\work\site-watcher
├─ watch.js
├─ .env
├─ sample.html
├─ public\
│ ├─ index.html
│ ├─ status.json
│ ├─ latest.png
│ ├─ history.csv # ← 追加:履歴CSV(自動生成・追記)
│ └─ shots\ # ← 履歴フォルダ(自動生成)
│ └─ screenshot-*.png
├─ logs\
│ └─ watch.log
├─ cooldown.json
└─ run_watch.bat
1) 前提(第1回・第2回と同じ)
- Node.js v18 以上(v22でもOK)
- 依存:
playwright/nodemailer/dotenv - 初回のみ
npx playwright install
2) watch.js(全文)
コメントスタイルを統一し、CSV追記を内包した完成版です。
/**
* =========================================================
* Site Watcher (Playwright × Mail × Teams × Web出力)
* - 値取得(Playwright)
* - しきい値判定 & メール通知(Gmail SMTP)
* - Teams にも通知(Incoming Webhook が設定されていれば)
* - Web 出力(status.json / latest.png)
* - スクリーンショット履歴(public/shots)+ 7日で自動削除
* - 履歴の CSV 追記(public/history.csv)
*
* 前提: Node.js v18+(fetch 利用のため)
* =========================================================
*/
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 CSV_PATH = path.join(PUBLIC_DIR, 'history.csv'); // 履歴CSV
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 へ通知(設定時のみ)
* ============================ */
/**
* Teams に MessageCard を送信
* @param {object} p
* @param {number} p.numeric
* @param {number} p.threshold
* @param {string} p.targetUrl
* @param {string} p.selector
* @param {string} p.jpNow
* @param {string} p.shotName
*/
async function notifyTeams({ numeric, threshold, targetUrl, selector, jpNow, shotName }) {
if (!TEAMS_WEBHOOK_URL) {
console.log('[INFO] TEAMS_WEBHOOK_URL 未設定のため Teams 通知をスキップ');
return;
}
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() : '');
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 {}
}
} catch {}
}
/* ==========================
* CSV(history.csv)に追記
* ========================== */
const csvQuote = (v) => `"${String(v).replace(/"/g, '""')}"`;
async function appendCsvRow({ timeUtc, timeJst, numeric, threshold, ok, url, selector, shotName }) {
await fs.mkdir(PUBLIC_DIR, { recursive: true });
const header = 'timeUtc,timeJst,value,threshold,ok,url,selector,shot\n';
const line = [
csvQuote(timeUtc),
csvQuote(timeJst),
numeric,
threshold,
ok,
csvQuote(url),
csvQuote(selector),
csvQuote(shotName ? `shots/${shotName}` : ''),
].join(',') + '\n';
try {
await fs.access(CSV_PATH);
await fs.appendFile(CSV_PATH, line, 'utf8');
} catch {
await fs.writeFile(CSV_PATH, header + line, 'utf8'); // BOM が必要なら '\uFEFF' を先頭に
}
}
/* =====
* 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更新)
const ts = nowTs();
const snapBuf = await page.screenshot({ fullPage: true }); // path を渡さず Buffer で取得
await fs.mkdir(SHOTS_DIR, { recursive: true });
const shotName = `screenshot-${ts}.png`;
const shotPath = path.join(SHOTS_DIR, shotName);
await fs.writeFile(shotPath, snapBuf); // 履歴(shots/)
await fs.writeFile(path.join(PUBLIC_DIR, 'latest.png'), snapBuf); // 表示用(latest.png)
// --- ステータスJSONとCSVを書き出し(毎回)
await writeWebOutput({ numeric, threshold: THRESHOLD, url: TARGET_URL, selector: TARGET_SELECTOR });
await appendCsvRow({
timeUtc : new Date().toISOString(),
timeJst : new Date().toLocaleString('ja-JP', { timeZone: 'Asia/Tokyo' }),
numeric,
threshold: THRESHOLD,
ok: numeric < THRESHOLD,
url: TARGET_URL,
selector: TARGET_SELECTOR,
shotName,
});
// --- 閾値判定(未満ならここで終了)
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 }, // Buffer から直接添付
],
});
console.log('[INFO] 通知メールを送信しました (MessageID):', info.messageId);
// --- Teams 通知(設定時のみ)
await notifyTeams({
numeric, threshold: THRESHOLD,
targetUrl: TARGET_URL, selector: TARGET_SELECTOR, jpNow,
shotName,
});
// --- クールダウン更新
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 {
// --- 終了処理(ブラウザを閉じ、古い履歴を掃除)
await browser.close();
await pruneOldShots(SHOTS_DIR, SHOTS_KEEP_DAYS);
}
})();
3) index.html(第1回と同じでOK/任意でCSVリンクを追加)
<!-- 省略:第1回の index.html そのままでOK。CSVを見せたい場合は下記リンクを追記 -->
<p><a href="history.csv" download>履歴CSVをダウンロード</a></p>
4) 動作確認(クイック)
-
sample.htmlを<div id="price">1500</div>に変更(閾値1000以上に) -
実行:
npm run watch -
期待:
- メールが届く(スクショ添付)
-
public/status.json更新、public/latest.png更新 -
public/shots/screenshot-*.pngが増える -
public/history.csvに1行追記(Excelで開ける)
5) よくあるハマり
-
CSVが文字化け:Excelで直接開くと文字コード判定に失敗することがあります。必要に応じて
history.csvの先頭に BOM (\uFEFF) を付ける実装に変更してください(コード中のコメント参照)。
6) 運用Tips
- 履歴は
shots/のPNGとhistory.csvの両方が残ります。PNGは7日間保存ですが、CSVは保存期間の制限なし。 - グラフ化したい場合は、次回以降で
history.csvをindex.htmlから読み込んで描画するだけでOK(Chart.js や Recharts 等)。
7) 付録:.gitignore(例)
.env
node_modules/
logs/
cooldown.json
public/status.json
public/latest.png
public/history.csv
public/shots/
Discussion