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.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey, Greasemonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Userscripts.

За да инсталирате скрипта, трябва да инсталирате разширение като Tampermonkey.

За да инсталирате този скрипт, трябва да имате инсталиран скриптов мениджър.

(Вече имам скриптов мениджър, искам да го инсталирам!)

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

(Вече имам инсталиран мениджър на стиловете, искам да го инсталирам!)

// ==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);
    }
  });
})();