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