🚥

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) 動作確認(クイック)

  1. sample.html<div id="price">1500</div> に変更(閾値1000以上に)

  2. 実行:npm run watch

  3. 期待:

    • メールが届く(スクショ添付)
    • 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.csvindex.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