// ==UserScript==
// @name GC - Virtupets Data Collector
// @namespace https://greasyfork.org/en/users/1278031-crystalflame
// @match *://*.grundos.cafe/island/tradingpost/browse/
// @match *://*.grundos.cafe/island/tradingpost/lot/user/*
// @match *://*.grundos.cafe/island/tradingpost/lot/*
// @match *://*.grundos.cafe/island/tradingpost/quickbuy/*
// @match *://*.grundos.cafe/island/tradingpost/acceptoffer/*
// @match *://*.grundos.cafe/island/tradingpost/viewoffers/*
// @match *://*.grundos.cafe/island/tradingpost/
// @match *://*.grundos.cafe/market/wizard/*
// @run-at document-end
// @icon https://www.google.com/s2/favicons?sz=64&domain=grundos.cafe
// @grant GM.info
// @grant GM.getValue
// @grant GM.setValue
// @license MIT
// @version 0.16
// @author CrystalFlame
// @description Collects shop wizard and trading post history for Virtupets.net
// ==/UserScript==
const DEBUG = false;
function extractTradeInformation(yourLots = false) {
const tradeObjects = [];
try {
const lotElements = document.querySelectorAll('.trade-lot');
lotElements.forEach((lotElement) => {
const tradeObject = {};
const lotNumberMatch = lotElement.querySelector(yourLots ? '.flex.space-between strong' : 'span strong').innerText.trim().match(/Lot #(\d+)/);
tradeObject.id = parseInt(lotNumberMatch[1]);
const username = yourLots ? document.querySelector('#userinfo a[href^="/userlookup/?user="]').innerText.trim() : lotElement.querySelector('span strong + a').innerText.trim();
tradeObject.username = username;
const listedOnElement = lotElement.querySelector(yourLots ? 'span:nth-of-type(3)' : 'span:nth-child(2)');
tradeObject.time = createTimestamp(listedOnElement.innerText.trim().replace(/Listed On\s*:\s*/, ''));
const itemElements = lotElement.querySelectorAll('.trade-item');
tradeObject.items = [];
itemElements.forEach((itemElement) => {
tradeObject.items.push(
itemElement.querySelector('.item-info span:nth-child(1)').innerText.trim()
);
});
const wishlist = lotElement.querySelector('span[id^="wishlist-text"]');
tradeObject.wishlist = wishlist.innerText.trim();
const quicksale = lotElement.querySelector('span[id^="quicksale-text"]');
if (quicksale) {
tradeObject.quicksale = parseInt(quicksale.innerText.trim().replace(/\D/g, ''), 10);
}
tradeObjects.push(tradeObject);
});
log(tradeObjects);
} catch (error) {
const message = "Unable to read the TP, there might be a conflicting script or an update to the layout.";
logError(error, message, "extractTradeInformation");
throw new Error(message);
}
return tradeObjects;
}
function constructMessage(message, error) {
const firstMessage = "<b>Failed to upload data to <a href=\"https://virtupets.net\">Virtupets</a>. </b>";
const lastMessage = "Always make sure your <a href=\"https://greasyfork.org/en/scripts/490596-gc-virtupets-data-collector\">script</a> is the latest version. "
return `${error ? firstMessage : ""}${message} ${lastMessage}`;
}
async function displayMessage(message, error = true) {
message = message.replace(/^Error:\s*/, '');
if (await shouldDisplayMessage(message)) {
try {
const element = document.querySelector('h1') || document.getElementById('page_banner');
if (element) {
const messageElement = document.createElement('div');
messageElement.innerHTML = constructMessage(message, error);
messageElement.style.cssText = `margin-block-end: 7px; margin-block-start: 7px; background-color: ${error ? "#ffe5e5" : "#f9ff8f"}; border: 2px solid #000; border-radius: 5px; padding: 10px; color: #000;`;
element.insertAdjacentElement('afterend', messageElement);
const dismissElement = document.createElement('a');
dismissElement.href = "#";
dismissElement.textContent = "[Dismiss]";
dismissElement.addEventListener('click', () => dismissMessage(messageElement, message));
messageElement.appendChild(dismissElement);
}
}
catch (error){
logError(error, `Failed to display message: ${message}.`, "displayMessage");
}
}
}
async function dismissMessage(element, message) {
await GM.setValue(message, new Date().getTime());
element.remove();
}
async function shouldDisplayMessage(message) {
try {
const lastDismissed = await GM.getValue(message);
if (!lastDismissed || new Date().getTime() - lastDismissed > 7 * 24 * 60 * 60 * 1000) {
return true;
}
return false;
}
catch (error) {
logError(error, "Failed to check if message should display.", "shouldDisplayMessage");
return true;
}
}
async function logError(error, message, operation, statusCode = undefined) {
try {
log(message);
const errorBody = {
message: `${message}. Error: ${error.message.replace(/^Error:\s*/, '')}`,
status_code: statusCode,
route: `collector::${operation}`
};
fetch(formatUrl('errors'), createPostRequest("0.1", GM.info.script.version, errorBody));
} catch (error) {
console.error('Error sending error report:', error);
}
}
function validateTable() {
const header = document.querySelectorAll('.market_grid .header');
const check = [ 'owner', 'item', 'stock', 'price'];
if(check.length != header.length) return false;
for (let i = 0; i < header.length; i += 1) {
const title = header[i].querySelector('strong').textContent.toLowerCase();
if(check[i] != title) {
const message = `Unknown header named "${title}" in position ${i+1}, expected "${check[i]}".`;
const error = new Error(message);
logError(error, "Validation Error.", "validateTable");
throw error;
}
}
return true;
}
function validateSearchRange() {
if (document.querySelector('main .center .mt-1 span')?.textContent?.toLowerCase() == '(searching between 1 and 99,999 np)') {
return true;
}
return false;
}
function validateUnbuyable() {
const notFoundMsg = "i did not find anything. :( please try again, and i will search elsewhere!";
const wrongHeaders = document.querySelectorAll('.market_grid .header').length > 0;
const wrongMessage = document.querySelector('main p.center').textContent.toLowerCase() != notFoundMsg;
if (wrongHeaders || wrongMessage) {
return false;
}
return true;
}
function log(message) {
if ((DEBUG) == true) {
console.log(message);
}
}
function extractShopPrices() {
try {
const tokens = document?.querySelector('.mt-1 strong')?.textContent?.split(" ... ");
let body;
const itemName = tokens?.length >= 2 ? tokens[1]?.trim() : undefined;
if(!validateSearchRange() || !itemName) {
log("Not a valid search!");
return body;
}
else if(validateTable())
{
log("Valid search");
const dataElements = document.querySelectorAll('.market_grid .data');
const i32Max = 2147483647;
let lowestPrice = i32Max;
let totalStock = 0;
let totalShops = 0;
for (let i = 0; i < dataElements.length; i += 4) {
//const owner = dataElements[i].querySelector('a').textContent;
//const item = dataElements[i + 1].querySelector('span').textContent;
const stock = parseInt(dataElements[i + 2].querySelector('span').textContent);
const price = parseInt(dataElements[i + 3].querySelector('strong').textContent.replace(/[^0-9]/g, ''));
lowestPrice = Math.min(price, lowestPrice);
totalStock += stock;
totalShops += 1;
}
if(lowestPrice < i32Max && totalStock > 0 && dataElements.length > 0) {
body = {
item_name: itemName,
price: lowestPrice,
total_stock: totalStock,
total_shops: totalShops
}
return body;
}
}
else if (validateUnbuyable()) {
log("Valid unbuyable");
body = {
item_name: itemName,
total_stock: 0,
total_shops: 0
}
}
return body;
}
catch (error) {
const message = "Unable to read the SW, there might be a conflicting script or an update to the layout.";
logError(error, message, "extractShopPrices");
throw new Error(message);
}
}
const monthMap = {jan: "1", feb: "2", mar: "3", apr: "4", may: "5", jun: "6", jul: "7", aug: "8", sep: "9", oct: "10", nov: "11", dec: "12"};
function createTimestamp(str) {
try {
const parts = str.split(" ");
const month = monthMap[parts[0].slice(0, 3).toLowerCase()].padStart(2, '0');
const day = parts[1].padStart(2, '0');
const time = parts[3].split(':');
const ampm = parts[4].toLowerCase();
let hour = parseInt(time[0]);
if (ampm === "pm" && hour < 12) {
hour += 12;
} else if (ampm === "am" && hour === 12) {
hour = 0;
}
const convertedHour = String(hour).padStart(2, '0');
const minutes = time[1].padStart(2, '0');
const currentYear = new Date().getFullYear();
const currentMonth = new Date().getMonth();
const year = month == "12" && currentMonth == 0 ? currentYear - 1 : currentYear;
return `${year}-${month}-${day}T${convertedHour}:${minutes}:00.000`;
} catch (error) {
const message = "Failed to create timestamp.";
logError(error, message, "createTimestamp");
throw new Error(message);
}
}
function wait(delay){
return new Promise((resolve) => setTimeout(resolve, delay));
}
function formatUrl(route) {
return `https://virtupets.net/${route}`;
}
async function sendData(route, fetchOptions = {}, delay = 1000, tries = 4) {
const url = formatUrl(route);
async function onError(error) {
if (tries > 0 && error.cause != 500) {
return new Promise(resolve => {
setTimeout(() => {
resolve(sendData(route, fetchOptions, delay, tries - 1));
}, delay * 2 ** (5 - tries));
});
} else {
throw error;
}
}
try {
const response = await fetch(url, fetchOptions);
if (response.status == 500) {
const body = await response.text();
console.error(`Data upload error: ${body}`);
throw new Error(body, { cause: response.status });
} else if (!response.ok) {
const body = await response.text();
if (route != 'health') {
console.error(`Data upload error (retry ${5 - tries}/5): ${body}`);
const healthCheck = await fetch(formatUrl('health'), { method: 'GET' });
if (!healthCheck.ok) {
console.log("https://virtupets.net may be down for maintenance or you are experiencing connectivity issues, no data was sent.");
return { ok: true, body: '', down: true };
}
}
throw new Error(body, { cause: response.status });
}
const body = await response.text();
return { ok: true, body };
} catch (error) {
return onError(error);
}
}
function createPostRequest(apiVersion, clientVersion, body) {
return {
method: "POST",
headers: {
"Content-Type": "application/json",
"Version": apiVersion,
"ClientVersion": clientVersion
},
body: JSON.stringify(body),
}
}
function extractTradeLotIDFromUrl(url) {
const match = url.match(/tradingpost\/.*\/(\d+)\//);
if (match) {
const trade_lot = parseInt(match[1]);
return trade_lot;
}
}
function setupQuickBuyListeners(route, apiVersion, clientVersion) {
const forms = document.querySelectorAll('form[action*="/island/tradingpost/quickbuy"]');
forms.forEach(form => {
const originalOnSubmit = form.onsubmit;
form.onsubmit = null;
form.addEventListener('submit', async function(event) {
event.preventDefault();
if (originalOnSubmit) {
const confirmed = originalOnSubmit.call(form, event);
if (!confirmed) {
return;
}
} else {
const message = "Unrecognized TP layout, action was prevented as a safeguard.";
logError(new Error(message), "Failed to setup quickbuy.", "setupQuickBuyListeners");
displayMessage(`${message}. Disable the script if needed until there is an update.`, true);
return;
}
try {
const actionUrl = form.getAttribute('action');
const match = actionUrl.trim().match(/\/island\/tradingpost\/quickbuy\/(\d+)\//);
const formData = new FormData(this);
if (match) {
const trade_lot_id = parseInt(match[1]);
const neopoints = parseInt(formData.get('price'), 10);
if (!isNaN(trade_lot_id) && !isNaN(neopoints)) {
const body = { id: trade_lot_id, neopoints };
await sendRequest(route, apiVersion, clientVersion, body);
}
}
} catch(e) {
logError(e, "Failed to setup quickbuy listeners.", "setupQuickBuyListeners");
}
this.submit();
});
});
}
function setupAcceptOfferListeners(route, apiVersion, clientVersion) {
const forms = document.querySelectorAll('form[action*="/island/tradingpost/acceptoffer"]');
const trade_lot_id = extractTradeLotIDFromUrl(window.location.href);
forms.forEach(form => {
const originalOnSubmit = form.onsubmit;
form.onsubmit = null;
form.addEventListener('submit', async function(event) {
event.preventDefault();
if (originalOnSubmit) {
const confirmed = originalOnSubmit.call(form, event);
if (!confirmed) {
return;
}
} else {
const message = "Unrecognized TP layout, action was prevented as a safeguard.";
logError(new Error(message), "Failed to setup accept offers.", "setupAcceptOfferListeners");
displayMessage(`${message}. Disable the script if needed until there is an update.`, true);
return;
}
try {
const offer = this.parentElement.previousElementSibling.querySelector('.offer');
const tradeItems = offer.querySelectorAll('.trade-item');
const offer_id = parseInt(this.parentElement.parentElement.querySelector('strong > a').textContent.trim());
let items = [];
let neopoints;
tradeItems.forEach(tradeItem => {
const itemInfo = tradeItem.querySelector('.item-info');
if (itemInfo) {
const span = itemInfo.querySelector('span');
if (span) {
items.push(span.textContent);
} else {
const strong = itemInfo.querySelector('strong');
if (strong) {
const match = strong.textContent.trim().match(/[\d,]+/);
if (match) {
neopoints = parseInt(match[0].replace(/,/g, ''), 10);
}
}
}
}
});
if (!isNaN(trade_lot_id) && (neopoints || items.length > 0)) {
const body = { id: trade_lot_id, neopoints, items, offer_id };
await sendRequest(route, apiVersion, clientVersion, body);
}
} catch(e) {
logError(e, "Failed to setup accept offer listeners.", "setupAcceptOfferListeners");
}
this.submit();
});
});
}
async function sendRequest(route, apiVersion, clientVersion, body) {
return new Promise((resolve, reject) => {
let promises = [];
if(Array.isArray(body)) {
if (body.length == 0) return;
const size = 100;
const numBatches = Math.ceil(body.length / size);
for (let i = 0; i < numBatches; i++) {
const startIndex = i * size;
const endIndex = Math.min((i + 1) * size, body.length);
const batchObjects = body.slice(startIndex, endIndex);
promises.push(sendData(route, createPostRequest(apiVersion, clientVersion, batchObjects)));
}
}
else {
promises.push(sendData(route, createPostRequest(apiVersion, clientVersion, body)));
}
Promise.all(promises.map(p => p.catch(error => ({ ok: false, body: error })))).then(responses => {
const warningResponses = responses.filter(response => response.ok && response.body.trim() != '');
const successfulResponses = responses.filter(response => response.ok);
const offlineResponses = responses.filter(response => response.down);
const errors = responses.filter(response => !response.ok);
if (errors.length > 0) {
reject(new Error(errors[0].body));
} else if (warningResponses.length > 0) {
displayMessage(warningResponses[0].body, false);
}
if (successfulResponses.length > 0 && offlineResponses.length == 0) {
console.log("Data uploaded to https://virtupets.net");
}
resolve();
});
});
}
async function main() {
'use strict';
let route;
let body;
let apiVersion;
const sw = /market\/wizard/.test(window.location.href);
const tp_qb_error = /tradingpost\/quickbuy/.test(window.location.href);
const tp_ao_error = /tradingpost\/acceptoffer/.test(window.location.href);
const tp_vo = /tradingpost\/viewoffers/.test(window.location.href);
const tp_yl = window.location.href.endsWith('/island/tradingpost/')
const clientVersion = GM.info.script.version;
try {
if (sw) {
route = "shop-prices";
body = extractShopPrices();
apiVersion = "0.11";
} else if (tp_qb_error) {
route = "trade-lots/confirmations";
body = { id: extractTradeLotIDFromUrl(window.location.href), error: true };
apiVersion = "0.1";
} else if (tp_ao_error) {
route = "trade-lots/confirmations";
body = { offer_id: extractTradeLotIDFromUrl(window.location.href), error: true };
apiVersion = "0.1";
} else if (tp_vo) {
route = "trade-lots/confirmations";
setupAcceptOfferListeners("trade-lots/confirmations", "0.1", clientVersion);
}
else {
route = "trade-lots";
body = extractTradeInformation(tp_yl);
apiVersion = "0.11";
if (!tp_yl) setupQuickBuyListeners("trade-lots/confirmations", "0.1", clientVersion);
}
if (route && body && apiVersion && clientVersion) {
await sendRequest(route, apiVersion, clientVersion, body);
}
}
catch (error) {
displayMessage(error.message);
}
};
main();