これは何ですか?

GASのスクリプトでがす。指定したホームページが更新されたら「更新されたよ」って教えてくれるし、指定キーワードがあった場合はその部分も通知してくれます。複数サイトのチェック可、複数ワードにも対応。メール以外にDiscordにも送れます。

作ってみましょう

1.GoogleDriveを開く

Google Drive

2.空白のスプレッドシートを作る

3.拡張機能→AppsScript

4.設定→プロジェクトの設定

5.スクリプトプロパティに追加

プロパティ「EMAIL_RECIPIENTS」に通知先メールアドレス 複数アドレスに送信する場合はカンマ区切り

プロパティ「FROM_EMAIL_NAME」にメール送信者名

プロパティ「DISCORD_WEBHOOK_URL」に通知を送りたいDiscordチャンネルのWebhook URL 不要な場合は空白

6.エディタ→コード.gs

下記の内容をコピペする

// --- 設定 ---
const SHEET_NAME = '設定'; // スプレッドシートのシート名
const URL_COLUMN = 1;      // URLが入力されている列番号 (A列 = 1)
const KEYWORD_COLUMN = 2; // キーワードが入力されている列番号 (B列 = 2)
const ENABLED_COLUMN = 3; // 有効フラグが入力されている列番号 (C列 = 3)
const VENUE_NAME_COLUMN = 4; // 会場名が入力されている列番号 (D列 = 4)
const ENABLED_VALUE = 'TRUE'; // 有効を示す値(大文字小文字区別なし)
const SNIPPET_LENGTH = 150; // キーワード周辺テキストの取得文字数(前後それぞれ)
const DEBUG_LOG_CONTENT = false; // ★ trueにすると取得したコンテンツの一部をログに出力(デバッグ用、実行後はfalseに戻す)
// --- 設定ここまで ---

// --- グローバル変数 ---
const SCRIPT_PROPERTIES = PropertiesService.getScriptProperties();
const EMAIL_RECIPIENTS = SCRIPT_PROPERTIES.getProperty('EMAIL_RECIPIENTS');
const DISCORD_WEBHOOK_URL = SCRIPT_PROPERTIES.getProperty('DISCORD_WEBHOOK_URL');
const FROM_EMAIL_NAME = SCRIPT_PROPERTIES.getProperty('FROM_EMAIL_NAME') || 'Website Update Checker';
const PREVIOUS_HASHES_KEY = 'previousHashes';
// --- グローバル変数ここまで ---

/**
 * メインの処理関数:トリガーから呼び出される
 */
function checkWebsites() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName(SHEET_NAME);
  if (!sheet) {
    Logger.log(`エラー: シート "${SHEET_NAME}" が見つかりません。`);
    return;
  }

  const dataRange = sheet.getDataRange();
  const values = dataRange.getValues();
  const previousHashes = loadPreviousHashes();
  const currentHashes = {};
  let updatedPages = [];
  // キーワード情報はURLごとに集約 { url: { venueName: "会場名", keywords: ["KW1"], snippets: {"KW1": "周辺テキスト1"} } }
  let keywordFoundDetails = {};
  let errorPages = [];

  // ヘッダー行を除外してループ
  for (let i = 1; i < values.length; i++) {
    const row = values[i];
    const isEnabled = String(row[ENABLED_COLUMN - 1]).toUpperCase() === ENABLED_VALUE.toUpperCase();
    const url = row[URL_COLUMN - 1];
    const rawKeywords = row[KEYWORD_COLUMN - 1] ? String(row[KEYWORD_COLUMN - 1]).split(',').map(kw => kw.trim()).filter(kw => kw !== '') : [];
    const venueName = row[VENUE_NAME_COLUMN - 1] || '';

    if (!isEnabled || !url) {
      continue; // 無効またはURLが空ならスキップ
    }

    Logger.log(`チェック中: ${venueName ? venueName + ' (' + url + ')' : url}`);
    let currentContent = '';
    let currentHash = '';
    let errorMessage = null;
    let pageUpdated = false;

    try {
      // リダイレクトを追従し、HTTPエラー時も例外を投げないようにする
      const response = UrlFetchApp.fetch(url, { muteHttpExceptions: true, followRedirects: true });
      const responseCode = response.getResponseCode();

      if (responseCode === 200) {
        // 文字コード判定とコンテンツ取得
        let charset = 'UTF-8';
        const contentType = response.getHeaders()['Content-Type'];
        if (contentType) {
          const match = contentType.match(/charset=([^;]+)/i);
          if (match && match[1] && match[1].toUpperCase() !== 'UTF-8') {
             try {
                currentContent = response.getContentText(match[1]);
                charset = match[1];
                Logger.log(`  文字コード ${charset} で取得しました。 (${url})`);
             } catch (e) {
                 Logger.log(`  文字コード ${match[1]} での取得に失敗。UTF-8で試行します。(${url}) Error: ${e.message}`);
                 currentContent = response.getContentText('UTF-8');
             }
          } else {
              currentContent = response.getContentText('UTF-8');
          }
        } else {
            currentContent = response.getContentText('UTF-8');
        }

        if (DEBUG_LOG_CONTENT) {
            Logger.log(`  取得コンテンツ (${url}) の先頭500文字:\n${currentContent.substring(0, 500)}`);
        }

        // ハッシュ計算用の正規化 (HTMLコメント、script, style, タグ除去、空白正規化)
        const normalizedContent = currentContent
                                    .replace(/<!--.*?-->/gs, '') // HTMLコメント削除
                                    .replace(/<script[^>]*>.*?<\/script>/gis, '') // scriptタグ削除
                                    .replace(/<style[^>]*>.*?<\/style>/gis, '') // styleタグ削除
                                    .replace(/<[^>]+>/g, ' ') // 残りのHTMLタグをスペースに置換
                                    .replace(/\s+/g, ' ') // 連続する空白を1つに
                                    .trim();
        currentHash = calculateHash(normalizedContent);
        currentHashes[url] = currentHash;

        // --- 更新チェック ---
        const previousHash = previousHashes[url];
        if (!previousHash || currentHash !== previousHash) {
          Logger.log(`  更新検知: ${url}`);
          updatedPages.push({ url: url, venueName: venueName });
          pageUpdated = true;
        } else {
          Logger.log(`  変更なし: ${url}`);
          currentHashes[url] = currentHash; // 変更なくても現在のハッシュを引き継ぐ
        }

        // --- キーワードチェック (更新有無に関わらず実行) ---
        if (rawKeywords.length > 0 && currentContent) {
          // 検索対象テキスト(HTMLタグ除去済み)
          const textForSearch = normalizedContent;
          const textForSearchLower = textForSearch.toLowerCase();
          if (DEBUG_LOG_CONTENT) {
              Logger.log(`  キーワード検索対象テキスト(正規化後 ${url})の先頭300文字:\n${textForSearch.substring(0, 300)}`);
          }

          for (const keyword of rawKeywords) {
            const keywordLower = keyword.toLowerCase();
            let index = -1;
            let foundInRawContent = false; // 元のHTMLで見つかったかフラグ

            // まず正規化テキストで検索
            index = textForSearchLower.indexOf(keywordLower);

            // 見つからなければ元のHTMLでも検索(デバッグ・代替用)
            if (index === -1) {
                 const rawContentLower = currentContent.toLowerCase();
                 let rawIndex = rawContentLower.indexOf(keywordLower);
                 if (rawIndex !== -1) {
                     index = rawIndex;
                     foundInRawContent = true;
                     Logger.log(`  キーワード "${keyword}" は正規化テキストで見つからず、元のHTML内で発見 (${url})`);
                 }
            }

            if (index !== -1) {
              // このURLで初めてキーワードが見つかった場合、初期化
              if (!keywordFoundDetails[url]) {
                  keywordFoundDetails[url] = { venueName: venueName, keywords: [], snippets: {} };
              }
              // まだこのキーワードが追加されていなければ追加
              if (!keywordFoundDetails[url].keywords.includes(keyword)) {
                    keywordFoundDetails[url].keywords.push(keyword);
                    Logger.log(`  キーワード "${keyword}" を発見 (${url})`);

                    // 周辺テキストを取得 (見つかった方のテキストソースから)
                    const sourceText = foundInRawContent ? currentContent : textForSearch;
                    const startIndex = Math.max(0, index - SNIPPET_LENGTH);
                    const endIndex = Math.min(sourceText.length, index + keywordLower.length + SNIPPET_LENGTH);
                    let snippet = sourceText.substring(startIndex, endIndex);
                    // 前後の省略記号を追加
                    if (startIndex > 0) snippet = "..." + snippet;
                    if (endIndex < sourceText.length) snippet = snippet + "...";
                    // 余分な空白を整理して保存
                    keywordFoundDetails[url].snippets[keyword] = snippet.replace(/\s+/g, ' ').trim();
               }

            } else {
               // DEBUGログが有効なら、見つからなかったキーワードも記録
               if (DEBUG_LOG_CONTENT) {
                   Logger.log(`  キーワード "${keyword}" は見つかりませんでした (${url})`);
               }
            }
          } // keyword loop end
        } // keyword check end

      } else {
        // --- アクセスエラー処理 ---
        errorMessage = `アクセスエラー: ステータスコード ${responseCode} (${url})`;
        Logger.log(errorMessage);
        errorPages.push({ url: url, venueName: venueName, error: `ステータスコード ${responseCode}` });
        if (previousHashes[url]) {
             currentHashes[url] = previousHashes[url]; // エラー時は前回のハッシュを維持
        }
        continue;
      }

    } catch (e) {
      // --- 取得エラー処理 (ネットワークエラーなど) ---
      errorMessage = `取得エラー: ${e.message} (${url})`;
      Logger.log(errorMessage);
      // エラーメッセージにスタックトレースを含めるとデバッグに役立つ場合がある
      Logger.log(`Stack trace: ${e.stack}`);
      errorPages.push({ url: url, venueName: venueName, error: e.message });
       if (previousHashes[url]) {
             currentHashes[url] = previousHashes[url]; // エラー時は前回のハッシュを維持
       }
      continue;
    }
  } // End of URL loop

  // --- 新しいハッシュを保存 ---
  saveCurrentHashes(currentHashes);

  // --- 通知判定と送信 ---
  if (updatedPages.length > 0 || Object.keys(keywordFoundDetails).length > 0 || errorPages.length > 0) {
    sendNotifications(updatedPages, keywordFoundDetails, errorPages);
  } else {
    Logger.log("更新されたページ、キーワードが見つかったページ、エラーはありませんでした。通知は送信しません。");
  }

   Logger.log("チェック完了");
}

/**
 * 文字列のMD5ハッシュを計算してBase64エンコードで返す
 */
function calculateHash(text) {
  const digest = Utilities.computeDigest(Utilities.DigestAlgorithm.MD5, text, Utilities.Charset.UTF_8);
  return Utilities.base64Encode(digest);
}

/**
 * スクリプトプロパティから前回のハッシュ値を読み込む
 */
function loadPreviousHashes() {
  const hashesJson = SCRIPT_PROPERTIES.getProperty(PREVIOUS_HASHES_KEY);
  if (hashesJson) {
    try {
      return JSON.parse(hashesJson);
    } catch (e) {
      Logger.log(`前回のハッシュ値の読み込みに失敗しました: ${e}`);
      return {};
    }
  }
  return {};
}

/**
 * スクリプトプロパティに現在のハッシュ値を保存する
 */
function saveCurrentHashes(hashes) {
  try {
    const ss = SpreadsheetApp.getActiveSpreadsheet();
    const sheet = ss.getSheetByName(SHEET_NAME);
    if (!sheet) {
        Logger.log(`ハッシュ保存スキップ: シート "${SHEET_NAME}" が見つかりません。`);
        return;
    }
    const lastRow = sheet.getLastRow();
    let currentUrls = [];
    // ヘッダー行を除いてURLを取得
    if (lastRow > 1) {
        currentUrls = sheet.getRange(2, URL_COLUMN, lastRow - 1, 1)
                           .getValues()
                           .flat() // 2次元配列を1次元に
                           .filter(url => url); // 空のURLを除外
    }
    const currentUrlSet = new Set(currentUrls);
    const cleanedHashes = {};
    // 現在シートに存在するURLのハッシュのみを保持(古いデータを削除)
    for (const url in hashes) {
        if (currentUrlSet.has(url)) {
            cleanedHashes[url] = hashes[url];
        } else {
            // Logger.log(`削除されたURLのハッシュをクリーンアップ: ${url}`); // 必要ならログ出力
        }
    }

    const hashesJson = JSON.stringify(cleanedHashes);
    // サイズチェック (Script Propertiesの上限は約500KB、値は9KB、合計500KB)
    // 念のため一つの値の上限(9KB)も考慮するが、複数URLの場合合計サイズが問題になる
    if (hashesJson.length > 480000) { // 500KBに近づいたら警告・削除
         Logger.log(`警告: 保存するハッシュデータサイズ(${hashesJson.length} bytes)が上限に近いため、古いデータを削除します。`);
         SCRIPT_PROPERTIES.deleteProperty(PREVIOUS_HASHES_KEY); // 古いデータを全削除
         // 再度保存を試みる(サイズ超過でなければ)
         if (hashesJson.length < 500000) {
              SCRIPT_PROPERTIES.setProperty(PREVIOUS_HASHES_KEY, hashesJson);
              Logger.log('古いハッシュ削除後、再保存しました。');
         } else {
              Logger.log('エラー: クリーンアップ後もハッシュデータが大きすぎます。保存できません。');
              // ここで別途エラー通知を行うことも検討
         }
    } else {
         SCRIPT_PROPERTIES.setProperty(PREVIOUS_HASHES_KEY, hashesJson);
    }

  } catch (e) {
     Logger.log(`ハッシュ値の保存中にエラーが発生しました: ${e}.`);
     // 保存エラー時も古いデータを削除してリトライするロジックは維持
      try {
        Logger.log('ハッシュ保存エラーのため、古いデータを削除して再保存を試みます。');
        SCRIPT_PROPERTIES.deleteProperty(PREVIOUS_HASHES_KEY);
        // cleanedHashes は try ブロック内で定義されているため、再計算か、より広いスコープで定義が必要
        // ここでは簡易的に hashes (引数) を使うが、cleanedHashesを使うのがより正確
        const hashesJsonRetry = JSON.stringify(hashes); // 削除済みURL含む可能性あり
         if (hashesJsonRetry.length < 500000) {
            SCRIPT_PROPERTIES.setProperty(PREVIOUS_HASHES_KEY, hashesJsonRetry);
             Logger.log('古いハッシュを削除して再保存を試みました。');
         } else {
             Logger.log('エラー: リトライ時もハッシュデータが大きすぎます。保存できません。');
         }
      } catch (e2) {
          Logger.log(`ハッシュの再保存にも失敗しました: ${e2}`);
      }
  }
}


/**
 * 通知を送信する
 */
function sendNotifications(updatedPages, keywordFoundDetails, errorPages) {
  const subject = `【Web更新通知】${new Date().toLocaleString('ja-JP')}`;
  let emailBody = `<html><body style="font-family: sans-serif;"><h2>Webサイト更新チェック結果 (${new Date().toLocaleString('ja-JP')})</h2>`;
  let discordBaseContent = `**Webサイト更新チェック結果 (${new Date().toLocaleString('ja-JP')})**\n\n`; // Discord分割送信用

  // --- 更新されたページ ---
  let updateSectionEmail = "";
  let updateSectionDiscord = "";
  if (updatedPages.length > 0) {
    updateSectionEmail += "<h3 style=\"color: #0056b3;\">■ 更新が検知されたページ</h3><ul>";
    updateSectionDiscord += "**■ 更新が検知されたページ**\n";
    updatedPages.forEach(info => {
      const displayName = info.venueName ? `${info.venueName} (${info.url})` : info.url;
      const displayLink = info.venueName ? `<a href="${info.url}" target="_blank" rel="noopener noreferrer">${info.venueName}</a>` : `<a href="${info.url}" target="_blank" rel="noopener noreferrer">${info.url}</a>`;
      updateSectionEmail += `<li style="margin-bottom: 5px;">${displayLink}${info.venueName ? ` (<a href="${info.url}" target="_blank" rel="noopener noreferrer" style="color:#555; text-decoration:none;">${info.url}</a>)`: ''}</li>`;
      updateSectionDiscord += `- ${displayName}\n`;
    });
    updateSectionEmail += "</ul>";
    updateSectionDiscord += "\n";
  }

  // --- キーワードが見つかったページ ---
  let keywordSectionEmail = "";
  let keywordSectionDiscord = "";
  const keywordUrls = Object.keys(keywordFoundDetails);
  if (keywordUrls.length > 0) {
    keywordSectionEmail += "<h3 style=\"color: #d9534f;\">■ 指定キーワードが発見されたページ</h3>";
    keywordSectionDiscord += "**■ 指定キーワードが発見されたページ**\n";

    for (const url of keywordUrls) {
        const details = keywordFoundDetails[url];
        const displayName = details.venueName ? `${details.venueName}` : url;
        const displayLink = `<a href="${url}" target="_blank" rel="noopener noreferrer">${displayName}</a>`;

        keywordSectionEmail += `<div style="margin-bottom: 15px; border-left: 3px solid #d9534f; padding-left: 10px;">`;
        keywordSectionEmail += `<p style="margin-bottom: 5px;"><b>会場/URL:</b> ${displayLink}${details.venueName ? ` (<a href="${url}" target="_blank" rel="noopener noreferrer" style="color:#555; text-decoration:none;">${url}</a>)`: ''}</p>`;
        keywordSectionEmail += `<p style="margin-top: 0; margin-bottom: 8px;"><b>発見されたキーワード:</b> ${details.keywords.map(kw => escapeHtml(kw)).join(', ')}</p>`;
        keywordSectionEmail += `<div style="font-size: 0.9em;">`; // 周辺テキストのコンテナ

        keywordSectionDiscord += `*   **会場/URL:** ${details.venueName ? details.venueName + ' ('+ url + ')' : url}\n`;
        keywordSectionDiscord += `*   **発見されたキーワード:** ${details.keywords.join(', ')}\n`; // Discordはエスケープ不要

        for (const keyword of details.keywords) {
            const snippet = details.snippets[keyword] || '(周辺テキスト取得失敗)';
            // HTML用にエスケープし、キーワードを太字にする試み(単純置換)
            const escapedSnippet = escapeHtml(snippet);
            // 大文字小文字区別せずにキーワードを太字にする正規表現
            const keywordRegex = new RegExp(`(${escapeHtml(keyword).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
            const highlightedSnippet = escapedSnippet.replace(keywordRegex, '<b>$1</b>');

            keywordSectionEmail += `<div style="margin-left: 15px; margin-bottom: 8px; border: 1px solid #eee; padding: 8px; background-color:#fdfdfd;">`;
            keywordSectionEmail += `<div><b>"${escapeHtml(keyword)}":</b></div>`;
            keywordSectionEmail += `<pre style="white-space: pre-wrap; word-wrap: break-word; margin-top: 4px; margin-bottom: 0; font-size: 0.95em; color: #333;">${highlightedSnippet}</pre>`; // preタグで表示
            keywordSectionEmail += `</div>`;


            // Discord用に整形 (長すぎる場合は省略)
            let discordSnippetText = `"${keyword}":\n${snippet}`; // 元のスニペットを使用
             // 各スニペットの長さを制限(全体で2000文字超えないように、かつ個々も長くならないように)
             const maxSnippetLength = Math.max(100, Math.floor(1800 / details.keywords.length) - keyword.length - 10);
            if (discordSnippetText.length > maxSnippetLength) {
                 discordSnippetText = discordSnippetText.substring(0, maxSnippetLength) + "...(省略)";
            }
            keywordSectionDiscord += `\`\`\`\n${discordSnippetText}\n\`\`\`\n`;
        }
         keywordSectionEmail += `</div></div>`; // 周辺テキストコンテナとURLごとのdivを閉じる
         keywordSectionDiscord += "\n";
    }
  }

  // --- エラーが発生したページ ---
  let errorSectionEmail = "";
  let errorSectionDiscord = "";
  if (errorPages.length > 0) {
    errorSectionEmail += "<h3 style='color: #c9302c;'>■ エラーが発生したページ</h3><ul>";
    errorSectionDiscord += "**■ エラーが発生したページ**\n";
    errorPages.forEach(info => {
       const displayName = info.venueName ? `${info.venueName} (${info.url})` : info.url;
       const escapedDisplayName = escapeHtml(displayName);
       const escapedError = escapeHtml(info.error);
      errorSectionEmail += `<li style="margin-bottom: 5px;">${escapedDisplayName} - <span style="color:#c9302c;">${escapedError}</span></li>`;
      errorSectionDiscord += `- ${displayName} - ${info.error}\n`; // Discordはエスケープ不要
    });
    errorSectionEmail += "</ul>";
     errorSectionDiscord += "\n";
  }

  // --- メール本文の結合 ---
  emailBody += updateSectionEmail + keywordSectionEmail + errorSectionEmail + "</body></html>";

  // --- Discord本文の結合 ---
  const discordContent = discordBaseContent + updateSectionDiscord + keywordSectionDiscord + errorSectionDiscord;

  // --- メール送信 ---
  if (EMAIL_RECIPIENTS && (updatedPages.length > 0 || keywordUrls.length > 0 || errorPages.length > 0)) {
    try {
      Logger.log(`メール送信先: ${EMAIL_RECIPIENTS}`);
      MailApp.sendEmail({
        to: EMAIL_RECIPIENTS,
        subject: subject,
        htmlBody: emailBody,
        name: FROM_EMAIL_NAME
      });
      Logger.log("メール通知を送信しました。");
    } catch (e) {
      Logger.log(`メール送信エラー: ${e}`);
    }
  } else if (!EMAIL_RECIPIENTS) {
    Logger.log("メール送信先が設定されていません。");
  }

  // --- Discord送信 (分割対応) ---
  if (DISCORD_WEBHOOK_URL && (updatedPages.length > 0 || keywordUrls.length > 0 || errorPages.length > 0)) {
    sendToDiscordWebhook(discordContent);
  } else if (!DISCORD_WEBHOOK_URL) {
       Logger.log("Discord Webhook URLが設定されていません。");
  }
}


/**
 * Discord Webhookにメッセージを送信する(2000文字超えで分割)
 * @param {string} content 送信するメッセージ全体
 */
function sendToDiscordWebhook(content) {
    const MAX_LENGTH = 2000;
    const chunks = [];
    let currentChunk = "";

    // メッセージを行単位で処理し、チャンクを作成
    const lines = content.split('\n');
    for (let i = 0; i < lines.length; i++) {
        const line = lines[i];
        // 現在のチャンクに次の行を追加しても上限を超えないかチェック
        if (currentChunk.length + line.length + 1 <= MAX_LENGTH) {
            currentChunk += line + "\n";
        } else {
            // 上限を超える場合、現在のチャンクを確定し、新しいチャンクを開始
            // ただし、1行だけで上限を超える場合は、その行を強制的に分割する必要がある(ここでは単純に切り捨て)
            if (line.length > MAX_LENGTH) {
                 Logger.log(`警告: 1行が長すぎるためDiscordメッセージを切り捨てます: ${line.substring(0, 100)}...`);
                 // 長すぎる行を分割するロジックを追加することも可能
                 if (currentChunk) chunks.push(currentChunk); // 前のチャンクがあれば追加
                 chunks.push(line.substring(0, MAX_LENGTH - 10) + "...(省略)\n"); // 切り捨てて追加
                 currentChunk = ""; // 新しいチャンクは空
            } else {
                chunks.push(currentChunk); // 現在のチャンクを追加
                currentChunk = line + "\n"; // 新しいチャンクを開始
            }
        }
         // 最後の行の場合、現在のチャンクを追加
         if (i === lines.length - 1 && currentChunk) {
            chunks.push(currentChunk);
        }
    }

    Logger.log(`Discord通知を ${chunks.length} 個のチャンクに分割して送信します。`);

    for (let i = 0; i < chunks.length; i++) {
        const chunk = chunks[i].trim(); // 前後の空白を削除
        if (chunk === "") continue; // 空のチャンクはスキップ

        const payload = JSON.stringify({ content: chunk });
        const options = {
            method: 'post',
            contentType: 'application/json',
            payload: payload,
            muteHttpExceptions: true // Discordからのエラーでスクリプト全体を止めない
        };

        try {
            const response = UrlFetchApp.fetch(DISCORD_WEBHOOK_URL, options);
            const responseCode = response.getResponseCode();
            Logger.log(`Discord通知送信結果 (Chunk ${i + 1}/${chunks.length}): ${responseCode}`);
            if (responseCode >= 300) {
                Logger.log(`Discord応答 (Chunk ${i + 1}): ${response.getContentText()}`);
                // レート制限(429)の場合は少し長く待つ
                if (responseCode === 429) {
                     Logger.log("Discordレート制限のため、5秒待機します...");
                     Utilities.sleep(5000);
                }
            } else {
                 Logger.log(`Discord通知 (Chunk ${i + 1}) を送信しました。`);
            }
            // 連続送信を避けるための短い待機 (レート制限対策)
            if (chunks.length > 1 && i < chunks.length - 1) {
                 Utilities.sleep(1100); // 1.1秒待機
            }
        } catch (e) {
          Logger.log(`Discord送信エラー (Chunk ${i + 1}): ${e}`);
           // エラーが発生した場合も少し待機してから次へ
           Utilities.sleep(1500);
        }
    } // end of chunk loop
}


/**
 * HTMLエスケープを行うヘルパー関数
 */
function escapeHtml(str) {
  if (!str) return '';
  return str.replace(/&/g, '&')
            .replace(/</g, '<')
            .replace(/>/g, '>')
            .replace(/"/g, '"');
            //.replace(/'/g, ''');
            // ★ preタグや```を使うので、改行の<br>変換は除去
}

/**
 * 手動実行用(テスト目的)
 */
function manualCheckAndNotify() {
  checkWebsites();
}

/**
 * 古いハッシュデータを手動でクリアする関数(デバッグ用)
 */
function clearPreviousHashes() {
  try {
    SCRIPT_PROPERTIES.deleteProperty(PREVIOUS_HASHES_KEY);
    Logger.log(`スクリプトプロパティ "${PREVIOUS_HASHES_KEY}" を削除しました。`);
  } catch (e) {
    Logger.log(`スクリプトプロパティ "${PREVIOUS_HASHES_KEY}" の削除中にエラーが発生しました: ${e}`);
  }
}

7.プロジェクトに名前をつけて保存

8.スプレッドシートに戻り項目を追加する

・スプレッドシートに任意のタイトルをつける

・A列にチェックするURL *必須

・B列に検知するキーワード *任意 キーワードは複数指定可(キーワード1,キーワード2,キーワード3)

・C列に「TRUE」と記載されたらその行を有効とする *任意 それ以外(空白)の場合は無効となりチェックされない

・D列にはサイト名 *任意

・シートの名前を「設定」にする

9.スクリプトに戻りとりあえず実行を押す

10.プロジェクトを承認する

「権限を確認」

自分のアカウントを選ぶ

「詳細」を押す

「~~~(安全ではないページ)に移動」を押す

すべてを選択して続行

11.動作結果を確認する

前回チェック時から内容が変わったサイトを「更新が検知されたサイト」としてリストアップします。

また、指定したページに指定キーワードがある場合は、更新されていない場合でもリストアップします。

定期的に実行するようにする

13.トリガー

14.トリガーを追加

15.自動実行するタイミングをセット

・実行する関数 → checkWebsites

・実行するデプロイ → Head

・イベントのソースを選択 → 時間主導型

・時間ベースのトリガーのタイプを選択 → 日付ベースのタイマー

・時刻を選択 → 午後7時~8時

・エラー通知設定 → 1週間おきに通知を受け取る

これで、毎日午後7~8時の間にスクリプトが自動実行されます。

チェック内容を初期化する

16.「clearPreviousHashes」を実行する