💡

Playwright監視#2 Teams通知を追加し、履歴画像を7日で自動

に公開

この記事は
第1回Webの値取得→メール通知→Web表示→タスク自動化
の続編です。

できること(今回の追加点)

  • Microsoft Teams にも通知(Incoming Webhook)
  • public/shots/ に蓄積される履歴画像を7日で自動削除

置き換えるのは .envwatch.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();
  }
})();

使い方メモ(超短縮)

  1. .env を上のサンプル通り作成(必要なら TEAMS_WEBHOOK_URLPUBLIC_BASE_URL も設定)

  2. 一度 sample.html を 1500 にして実行

    cd /d D:\work\site-watcher
    npm run watch
    
  3. 期待結果

    • Teamsに通知
    • public/status.json / public/latest.png が更新
    • public/shots/ に履歴が増える(7日より古いものは自動削除)

よくあるつまずき

  • Failed to parse URL.envTEAMS_WEBHOOK_URLURL以外の文字を混ぜていないか
  • 画像がTeamsで見えない → PUBLIC_BASE_URL外部から到達可能 か(127.0.0.1 は不可)

Discussion