Website Changes Monitor

A powerful and flexible monitor that automatically detects changes on any website. Including support for POST requests and even complex pages that require dynamic security tokens (nonces/CSRF) to view content.

// ==UserScript==
// @name         Website Changes Monitor
// @namespace    https://greasyfork.org/en/users/670188-hacker09
// @version      1
// @description  A powerful and flexible monitor that automatically detects changes on any website. Including support for POST requests and even complex pages that require dynamic security tokens (nonces/CSRF) to view content.
// @author       hacker09
// @match        *://*/*
// @icon         https://i.imgur.com/0kx5i9q.png
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_openInTab
// @grant        GM_listValues
// @grant        GM_deleteValue
// @grant        GM.xmlHttpRequest
// @grant        GM_registerMenuCommand
// @connect      *
// ==/UserScript==

(function() {
  'use strict';
  const now = Date.now(); //Capture a single timestamp for this run to prevent race conditions between tabs
  GM_registerMenuCommand('Add/Remove Website', () => { GM_openInTab('https://cyber-sec0.github.io/monitor/', { active: true }); });
  unsafeWindow.gm_storage = { setValue: GM_setValue, getValue: GM_getValue, deleteValue: GM_deleteValue, listValues: GM_listValues }; //Expose the TM Storage

  const performCheck = (key, dataForRequest, originalData) => { //key: The storage key; dataForRequest: Data for the HTTP call (may include a token); originalData: The original stored data to modify upon completion
    GM.xmlHttpRequest({
      method: dataForRequest.body ? 'POST' : 'GET',
      url: key.replace(/Counter\d+/, ''),
      headers: dataForRequest.header || {},
      data: dataForRequest.body || null,
      onload: (response) => {
        if (response.status < 200 || response.status > 299) { return; } //Ignore failed requests

        const { responseText } = response;
        const oldContent = originalData.content || originalData.HTML || '';
        const urlToOpen = originalData.website || key.replace(/Counter\d+/, '');
        let contentForStorage = responseText, triggerAlert = false;
        const parser = new DOMParser(), doc = parser.parseFromString(responseText, 'text/html');

        if (originalData.comparisonMethod === 'new_items' && originalData.selector && originalData.idAttribute) { //Comparison Method 1: Check for new items in a list based on a unique ID

          const oldIdSet = new Set(oldContent ? oldContent.split(',') : []);
          //FIX: Support using 'innerText' as a special case for the ID, otherwise use getAttribute. Trim the result for consistency.
          const newIds = Array.from(doc.querySelectorAll(originalData.selector)).map(el => (originalData.idAttribute.toLowerCase() === 'innertext' ? el.innerText : el.getAttribute(originalData.idAttribute))?.trim()).filter(Boolean);
          if (oldContent && newIds.some(id => !oldIdSet.has(id))) { triggerAlert = true; } //Trigger if any new item's ID is not found in the old set of IDs
          contentForStorage = newIds.join(',');

        } else if (originalData.comparisonMethod === 'order' && originalData.selector && originalData.idAttribute) { //Comparison Method 2: Check if the order of items in a list has changed

          const newIdString = Array.from(doc.querySelectorAll(originalData.selector)).map(el => (originalData.idAttribute.toLowerCase() === 'innertext' ? el.innerText : el.getAttribute(originalData.idAttribute))?.trim()).filter(Boolean).join(',');
          if (oldContent && oldContent !== newIdString) { triggerAlert = true; }
          contentForStorage = newIdString;

        } else { //Default Comparison Method: Check for any change in the selected element's HTML or the full page

          if (originalData.selector) {
            if (doc.querySelector(originalData.selector)) { contentForStorage = doc.querySelector(originalData.selector).innerHTML; } //If specified, only use the user's chosen selector to compare
          }
          const sanitize = (html) => { if (!html) return ''; const t = document.createElement('div'); t.innerHTML = html; return (t.textContent || t.innerText || "").replace(/\s+/g, ' ').trim();}; //Helper function to strip HTML tags and normalize whitespace for a more reliable text comparison
          if (oldContent && sanitize(oldContent) !== sanitize(contentForStorage)) { triggerAlert = true; }

        }

        if (triggerAlert) {
          if (Date.now() - GM_getValue(`lock_open_${urlToOpen}`, 0) > 15000) { //Prevent re-opening the same URL for 15s
            GM_setValue(`lock_open_${urlToOpen}`, Date.now()); //Set the lock immediately to prevent other tabs from opening the URL
            GM_openInTab(urlToOpen, { active: false, insert: false });
          }
        }
        GM_setValue(key, { ...originalData, content: contentForStorage, lastChecked: now }); //Save the new content, preserving the timestamp that was set before the check
      },
    });
  };

  GM_listValues().forEach(key => {
    if (/^check_interval_ms$|^lock_/.test(key)) { return; } //Skip looping through special config/lock keys

    const storedData = GM_getValue(key, {});
    if (!storedData || storedData.isPaused) { return; }
    if (now - (storedData.lastChecked || 0) < GM_getValue('check_interval_ms', 60000)) { return; }
    GM_setValue(key, { ...storedData, lastChecked: now }); //Immediately update the timestamp in storage to prevent other tabs from running the same check

    if (storedData.tokenEnabled && storedData.tokenUrl && storedData.tokenSelector && storedData.tokenPlaceholder) { //Handle token-based requests
      GM.xmlHttpRequest({
        method: 'GET', url: storedData.tokenUrl,
        onload: (response) => {
          if (response.status < 200 || response.status > 299) { return; } //Ignore failed requests
          const { responseText } = response, parser = new DOMParser(), doc = parser.parseFromString(responseText, 'text/html'), elements = doc.querySelectorAll(storedData.tokenSelector);
          let nonce = null;
          for (const element of elements) { //Search for the token using the specified method (RegEx, attribute, or text content)
            if (storedData.tokenRegEx) { const match = element.textContent.match(new RegExp(storedData.tokenRegEx)); if (match && match[1]) { nonce = match[1]; break; } }
            else { nonce = storedData.tokenAttribute ? element.getAttribute(storedData.tokenAttribute) : element.textContent.trim(); if(nonce) break; }
          }
          if (nonce) {
            const dataWithToken = JSON.parse(JSON.stringify(storedData)); //Deep clone the data object so the original isn't modified by token injection
            const placeholder = new RegExp(storedData.tokenPlaceholder.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'), 'g'); //Create a RegExp from the placeholder, escaping regex chars
            if (dataWithToken.body) { dataWithToken.body = dataWithToken.body.replace(placeholder, nonce); }
            if (dataWithToken.header) { for (const hKey in dataWithToken.header) { if (typeof dataWithToken.header[hKey] === 'string') { dataWithToken.header[hKey] = dataWithToken.header[hKey].replace(placeholder, nonce); } } }
            performCheck(key, dataWithToken, storedData); //Pass both the temp data for the request and the original data for saving
          }
        },
      });
    } else { //Handle non-token-based requests
      performCheck(key, storedData, storedData);
    }
  });
})();