// ==UserScript==
// @name Best Buy - Cart Saved Items Automation
// @namespace akito
// @version 3.3.0
// @author akito#9528 / Albert Sun
// @require https://raw.githubusercontent.com/albert-sun/tamper-scripts/bestbuy-cart_3.3/bestbuy-cart/user_interface.js
// @require https://raw.githubusercontent.com/albert-sun/tamper-scripts/bestbuy-cart_3.3/bestbuy-cart/constants.js
// @require https://cdn.jsdelivr.net/npm/simplebar@latest/dist/simplebar.min.js
// @resource css https://raw.githubusercontent.com/albert-sun/tamper-scripts/bestbuy-cart_3.3/bestbuy-cart/styling.css
// @downloadURL https://raw.githubusercontent.com/albert-sun/tamper-scripts/bestbuy-cart_3.3/bestbuy-cart/script_main.user.js
// @updateURL https://raw.githubusercontent.com/albert-sun/tamper-scripts/bestbuy-cart_3.3/bestbuy-cart/script_main.user.js
// @match https://www.bestbuy.com/cart
// @antifeature opt-in anonymous queue metrics
// @run-at document-end
// @grant GM_getResourceText
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_setClipboard
// @grant unsafeWindow
// @noframes
// ==/UserScript==
/* globals $, __META_LAYER_META_DATA, constants */
/* globals generateInterface, generateWindow, designateSettings, designateLogging*/
const scriptVersion = "3.3.0";
const scriptPrefix = "BestBuy-CartSavedItems";
const scriptText = `Best Buy - Cart Saved Items Automation v${scriptVersion} | akito#9528 / Albert Sun`;
const messageText = `Thanks and good luck! | Donate via PayPal`;
// Script-specific settings including their descriptions, types, and default values
// /!\ DO NOT MODIFY AS IT PROBABLY WON'T DO ANYTHING, use the settings popup instead /!\
const settings = {
"allowMetrics": { index: 0, description: "Allow sending of anonymous queue metrics", type: "boolean", value: false },
"autoAddClick": { index: 1, description: "Auto-click whitelisted buttons when available", type: "boolean", value: true },
"pauseWhenCarted": { index: 2, description: "Pause interval actions when cart occupied", type: "boolean", value: true },
"ignoreFailed": { index: 3, description: "Ignore cart buttons if still clickable after clicked (failed)", type: "boolean", value: false },
"refreshCartChange": { index: 4, description: "Refresh the page when cart contents change (recommended)", type: "boolean", value: true },
"clickTimeout": { index: 5, description: "Timeout between clicks to prevent rate limiting", type: "number", value: 1000 },
"globalInterval": { index: 6, description: "Global polling interval for updates (milliseconds)", type: "number", value: 250 },
"clickTimeout": { index: 7, description: "Script timeout when clicking add buttons (milliseconds)", type: "number", value: 1000 },
"autoReloadInterval": { index: 8, description: "Automatic page reloading interval (milliseconds, 0 / >= 10000)", type: "number", value: 0 },
"customNotification": { index: 9, description: "Hotlinking URL for custom notification (empty for default)", type: "string", value: constants.notificationSound },
"testNotification": { index: 10, description: "[ Press to test the current notification sound ]", type: "button", value: function() { notificationSound.play() } },
"useSKUWhitelist": { index: 11, description: "Override the keyword whitelist with the SKU whitelist", type: "boolean", value: false },
"whitelistKeywords": { index: 12, description: "Whitelisted keywords (array)", type: "array", value: constants.whitelistKeywords },
"blacklistKeywords": { index: 13, description: "Blacklisted keywords (array)", type: "array", value: constants.blacklistKeywords },
"whitelistSKUs": { index: 14, description: "Whitelisted SKUs to track (array, NOT UP-TO-DATE)", type: "array", value: constants.whitelistSKUs },
// Note: script currently ignores bundles including the PS5 bundles
};
// Script-scoped variables, again please don't modify this unless you know what you're doing
const trackedItems = {}; // button, color, description
const ignoreStatuses = {}; // false = just clicked, true = ignore
let notificationSound; // Imported from settings
let sentQueueCodes; // For analytics purposes, imported from storage
let settingsWindow, settingsDiv, loggingWindow, loggingDiv;
let loggingFunction = undefined; // Placeholder for initialization
let whitelistKeywords = [];
let blacklistKeywords = []; // Blacklist > whitelist
let whitelistSKUs = [];
// Asynchronous sleep function, fixed for Firefox?
async function sleep(ms) {
await new Promise((resolve) => { setTimeout(resolve, ms); });
}
// Initialize script user interface consisting of footer and individual windows
// In particular, initializes settings and logging window (and logging function) before others
// @returns {boolean} whether initialization succeeded or failed
async function initialize() {
// Load script-wide CSS
GM_addStyle(GM_getResourceText("css"));
// Import seen queue codes from storage
sentQueueCodes = await GM_getValue(`${scriptPrefix}_sentQueueCodes`, []);
// Generate base script footer for user interface
generateInterface(scriptText, messageText);
// Load settings from defaults or Tampermonkey storage
for(const [property, setting] of Object.entries(settings)) {
const lookupKey = `${scriptPrefix}_${property}`;
const storedValue = await GM_getValue(lookupKey, setting.value);
// Attach setter to given setting for saving any changes
setting._value = storedValue;
delete setting.value;
Object.defineProperty(setting, "value", {
get: function() { return setting._value; },
set: function(value) {
setting._value = value;
GM_setValue(lookupKey, value);
}
});
}
if(settings.customNotification.value === "") {
notificationSound = new Audio(constants.notificationSound);
} else {
notificationSound = new Audio(settings.customNotification.value);
}
// Generate footer buttons and their respective windows, then designate
[settingsWindow, settingsDiv] = generateWindow(constants.settingsIcon, "Settings (updates on reload)", 800, 400, true);
[loggingWindow, loggingDiv] = generateWindow(constants.loggingIcon, "Logging", 800, 400, true);
designateSettings(settingsWindow, settingsDiv, settings);
loggingFunction = await designateLogging(loggingWindow, loggingDiv);
loggingFunction("Finished initializing script user interface");
// Validate settings once logging function is initialized
try { // Attempt to parse and set whitelisted keywords
whitelistKeywords = settings.whitelistKeywords.value;
if(Array.isArray(whitelistKeywords) === false) { throw new Error("not an array"); }
} catch(err) {
loggingFunction(`/!\\ Error parsing whitelisted keywords: ${err.message}`);
return false;
}
try { // Attempt to parse and set blacklisted keywords
blacklistKeywords = settings.blacklistKeywords.value;
if(Array.isArray(blacklistKeywords) === false) { throw new Error("not an array"); }
} catch(err) {
loggingFunction(`/!\\ Error parsing blacklisted keywords: ${err.message}`);
return false;
}
try { // Attempt to parse and set whitelisted SKUs
whitelistSKUs = settings.whitelistSKUs.value;
if(Array.isArray(whitelistSKUs) === false) { throw new Error("not an array"); }
} catch(err) {
loggingFunction(`/!\\ Error parsing whitelisted SKUs: ${err.message}`);
return false;
}
return true
}
// Approximates the rendered background color of a given element to a given set of colors.
// Checks whether the "distance" from the element color is transparent or closest to either yellow/white/blue.
// @param {element} element
// @returns {string} color
const colors = [
{color: "yellow", r: 255, g: 224, b: 0},
{color: "blue", r: 0, g: 30, b: 115},
{color: "grey", r: 197, g: 203, b: 213},
{color: "white", r: 255, g: 255, b: 255},
];
function elementColor(element) {
// Get the rendered background color of the element
const colorText = getComputedStyle(element, null).getPropertyValue("background-color");
if(colorText.includes("rgb(0, 0, 0")) { // element has no color = transparent
return "transparent";
}
// Parse RGB value and use fancy maths to find closest color
const parsedColor = {};
const matchedColor = colorText.match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/i);
parsedColor.r = Number(matchedColor[1]); parsedColor.g = Number(matchedColor[2]); parsedColor.b = Number(matchedColor[3]);
const closest = {color: "", distance: 442}; // Default distance just slightly larger than max
for(const checkColor of colors) {
const distance = Math.sqrt((parsedColor.r - checkColor.r) ** 2 + (parsedColor.g - checkColor.g) ** 2 + (parsedColor.b - checkColor.b));
if(distance < closest.distance) {
closest.color = checkColor.color;
closest.distance = distance;
}
}
return closest.color;
}
// Saved items tracker function caching saved items elements and polling for color changes?
async function trackSaved() {
loggingFunction("Waiting until saved items elements are loaded into DOM");
// Periodically poll until saved items loaded by checking header existence
while(document.getElementsByClassName("saved-items__header").length === 0) {
await sleep(settings.globalInterval.value);
} // Then, retrieve complete list of relevant saved items information
const savedSKUs = $(".saved-items__card-wrapper").toArray()
.map(wrapperElement => wrapperElement.getAttribute("data-test-saved-sku"));
const savedDescriptions = $(".saved-items__card-wrapper .simple-item__description").toArray()
.map(descriptionElement => descriptionElement.innerText);
const savedButtons = $(".saved-items__card-wrapper .btn.btn-block").toArray();
loggingFunction(`${savedSKUs.length} saved items found, filtering through whitelist and blacklist`);
// Parse keywords / SKUs for each and splice blacklisted or non-whitelisted
let index = savedSKUs.length;
while(index--) { // Loop in reverse to allow splicing
const sku = savedSKUs[index];
const description = savedDescriptions[index];
// Verify thorugh keyword descriptions or SKU depending on setting
let valid = false; // Placeholder value
if(settings.useSKUWhitelist.value === true) {
valid = whitelistSKUs.includes(Number(sku));
} else { // if settings["useSKUWhitelist"].value === false
const containsWhitelist = whitelistKeywords.filter(
keyword => description.toLowerCase().includes(keyword.toLowerCase())
).length > 0; // Whether description contains any whitelisted keywords
const containsBlacklist = blacklistKeywords.filter(
keyword => description.toLowerCase().includes(keyword.toLowerCase())
).length > 0; // Whether description contains any blacklisted keywords
valid = containsWhitelist === true && containsBlacklist === false;
}
// If don't track item, splice from array
if(valid === false) {
loggingFunction(`Script not tracking ${description} as product is either unwhitelisted or blacklisted`);
savedSKUs.splice(index, 1);
savedDescriptions.splice(index, 1);
savedButtons.splice(index, 1);
}
}
loggingFunction(`Finished filtering whitelisted items, ${savedSKUs.length} items remaining`);
loggingFunction(`Initializing polling interval for auto-clicking items with clickable buttons`);
// Iterate through remaining and check which ones are clickable / queued
for(const index in savedSKUs) {
const sku = savedSKUs[index];
const button = savedButtons[index];
const description = savedDescriptions[index];
const buttonColor = elementColor(button);
// Check whether button currently clickable or queued by checking button text
// Honestly ignoring anything that says "Find a Store" since the script can't choose stores
if(button.innerText === "Add to Cart") {
if(buttonColor === "grey") {
loggingFunction(`Currently queued: ${description}`);
}
trackedItems[sku] = {
button: button,
color: buttonColor,
description: description,
}
}
}
// Initializing polling interval with cooldown on click
// Replace asynchronous polling with synchronous polling for delays and stuff
while(true) {
// Check whether cart contains item
if(__META_LAYER_META_DATA.order.lineItems.length > 0) {
loggingFunction(`Cart currently has item, cancelling polling interval`);
return;
}
// Iterate over trackable items, update color, and click if popped
for(const [sku, trackedInfo] of Object.entries(trackedItems)) {
trackedInfo.color = elementColor(trackedInfo.button);
if(trackedInfo.color === "white" || trackedInfo.color === "blue" || trackedInfo.color === "yellow") {
loggingFunction(`Clickable initial / popped: ${trackedInfo.description}`);
// Check current ignore status and process if enabled
// TODO: check error message popup instead of doing this ignore stuff
if(settings.ignoreFailed.value === true) {
// Undefined = nothing flagged, false = clicked, true = ignore
if(ignoreStatuses[sku] === undefined) {
ignoreStatuses[sku] = false;
} else if(ignoreStatuses[sku] === false) {
ignoreStatuses[sku] = true;
continue;
} else if(ignoreStatuses[sku] === true) {
// Flagged to ignore
continue;
}
}
trackedInfo.button.click(); // Click button obviously
await sleep(settings.clickTimeout.value);
} else {
// Remove flag from SKU because successful color flip
// Does nothing if undefined property
delete ignoreStatuses[sku];
}
}
// ANTIFEATURE: send anonymous queue data gathered through localStorage
// Leave the analytics to last in case it breaks (somehow) and throws an error which would kill the function
// Queue data can't be transported even between sessions, believe me I've tried...
if(settings.allowMetrics.value === true) {
// Retrieve current queues from page laod and send queue information
const queuesData = JSON.parse(atob(localStorage.getItem("purchaseTracker"))) || {};
for(const [sku, queueData] of Object.entries(queuesData)) {
const bundle = [sku, ...queueData]; // SKU and queue data
// Prevent duplicate requests by marking codes as seen
if(sentQueueCodes.includes(queueData[2])) {
continue;
}
sentQueueCodes.push(queueData[2]);
GM_setValue(`${scriptPrefix}_sentQueueCodes`, sentQueueCodes);
// Sending repeat queues shouldn't matter that much honestly, Cloudflare is generous?
loggingFunction(`Sending queue analytics for saved item with SKU ${sku}`);
await fetch("https://bestbuy-queue-analytics.akitocodes.workers.dev/", {
method: "POST",
body: JSON.stringify({
data: bundle,
version: scriptVersion,
}),
});
}
}
await sleep(settings.globalInterval.value)
}
}
// Main function, called using async wrapper below
async function main() {
'use strict'; // Something about ES6 syntax?
// Perform initialization separate from main
const initResult = await initialize();
if(initResult === false) { // loggingFunction should be initialized
loggingFunction("Stopping script because initialization failed");
return;
}
// Metadata includes run-at document-end, shouldn't need DOMContentLoaded event
loggingFunction("Initializing saved items queue tracker (bundles currently not supported)");
trackSaved(); // Run in parallel
loggingFunction("Initializing cart tracker to automatically refresh on contents change");
// Attach setter to cart order to receive callback whenever contents change
// Reload the page whenever the cart contents change since saved elements unload and reload
let initialCartLoad = false; // To prevent refreshing on page load
__META_LAYER_META_DATA._order = __META_LAYER_META_DATA.order;
Object.defineProperty(__META_LAYER_META_DATA, "order", {
get: function() { return __META_LAYER_META_DATA._order; } ,
set: function(newOrder) {
try {
const oldCartLength = __META_LAYER_META_DATA.order ? __META_LAYER_META_DATA.order.lineItems.length : 0;
const newCartLength = newOrder.lineItems.length;
if(newCartLength !== oldCartLength) {
// Play notification sound when item added to cart
if(newCartLength > oldCartLength) {
notificationSound.play();
}
// Only refresh page on cart change if enabled in settings
if(settings.refreshCartChange.value === true && initialCartLoad === true) {
// Timeout page reload to let notification sound play fully
setTimeout(function() { location.reload(); }, 1000);
} else if(initialCartLoad === false) {
initialCartLoad = true;
}
}
} catch(err) {
loggingFunction(`/!\\ Error from cart setter: ${err.message}`);
}
__META_LAYER_META_DATA._order = newOrder;
}
});
if(settings.autoReloadInterval.value >= 10000) {
loggingFunction(`Queued page auto-reload interval for ${settings.autoReloadInterval.value} milliseconds`);
setTimeout(function() { location.reload() }, settings.autoReloadInterval.value);
} else {
loggingFunction("Not queueing auto-reload interval because zero or too short interval");
}
}
(async function() { await main(); }());