Files
pokemon-stock-checker/chrome-extension/background.js
T
mmcghen 297cb9b146 Sync/dashboard, sanitize content, update config
Add local Claude permissions file; make background.js call syncToDashboard() after each run when config.syncToDashboard is enabled; improve content extraction in content.js by cloning link nodes, removing svg/style elements and stripping review-related text/counts before trimming; update config.py defaults to enable target and gamestop (remove bestbuy), set HEADLESS=True and STEALTH_HEADLESS=True to favor headless environments.
2026-03-28 10:24:25 -04:00

664 lines
19 KiB
JavaScript

// Pokemon Stock Monitor - Background Service Worker
const DEFAULT_CONFIG = {
discordWebhook: "",
checkIntervalSeconds: 90, // Minimum 60 to avoid PokemonCenter bot detection
enabled: true,
urls: [
"https://www.pokemoncenter.com/category/tcg-cards?sort=relevance"
],
keywords: [], // Empty = all products, or ["chaos rising", "booster"] etc
notifyNewProducts: true,
notifyRestocks: true,
dashboardUrl: "http://localhost:5000", // Local dashboard URL
syncToDashboard: true // Enable syncing to local dashboard
};
// Store known products
let knownProducts = {};
let config = DEFAULT_CONFIG;
let persistentTabId = null; // Keep tab open for faster refreshes
let checkLoopRunning = false; // Prevent multiple loops
let isChecking = false; // Prevent overlapping checks
let lastNotificationTime = 0; // Rate limiting for Discord
// API monitoring - track SKUs seen from backend API calls
let knownSkus = new Set();
let apiStats = {
lastApiResponse: null,
totalSkusTracked: 0,
newSkusDetected: 0,
apiResponseCount: 0
};
// Initialize
chrome.runtime.onInstalled.addListener(() => {
console.log("Pokemon Stock Monitor installed");
loadConfig().then(() => {
loadProducts();
loadKnownSkus();
startCheckLoop();
});
});
// Load on startup
chrome.runtime.onStartup.addListener(() => {
loadConfig().then(() => {
loadProducts();
loadKnownSkus();
startCheckLoop();
});
});
// Start the check loop (uses setTimeout to bypass Chrome's 30-sec alarm minimum)
function startCheckLoop() {
if (checkLoopRunning) {
console.log("Check loop already running");
return;
}
checkLoopRunning = true;
console.log(`Starting check loop: every ${config.checkIntervalSeconds} seconds`);
scheduleNextCheck();
}
function scheduleNextCheck() {
if (!config.enabled || !checkLoopRunning) {
checkLoopRunning = false;
console.log("Check loop stopped");
return;
}
const intervalMs = Math.max(60, config.checkIntervalSeconds) * 1000; // Min 60 seconds to avoid bot detection
setTimeout(async () => {
if (!isChecking && config.enabled) {
isChecking = true;
try {
await runStockCheck();
} finally {
isChecking = false;
}
}
scheduleNextCheck();
}, intervalMs);
}
function stopCheckLoop() {
checkLoopRunning = false;
console.log("Check loop stopped");
}
// Load config from storage
async function loadConfig() {
const stored = await chrome.storage.local.get("config");
if (stored.config) {
config = { ...DEFAULT_CONFIG, ...stored.config };
}
console.log("Config loaded:", config);
}
// Save config to storage
async function saveConfig() {
await chrome.storage.local.set({ config });
// Restart loop with new interval
stopCheckLoop();
if (config.enabled) {
startCheckLoop();
}
}
// Load known products from storage
async function loadProducts() {
const stored = await chrome.storage.local.get("knownProducts");
if (stored.knownProducts) {
knownProducts = stored.knownProducts;
}
console.log(`Loaded ${Object.keys(knownProducts).length} known products`);
}
// Save known products to storage
async function saveProducts() {
await chrome.storage.local.set({ knownProducts });
}
// Load known SKUs from storage (for API monitoring)
async function loadKnownSkus() {
const stored = await chrome.storage.local.get("knownSkus");
if (stored.knownSkus) {
knownSkus = new Set(stored.knownSkus);
}
console.log(`[API Monitor] Loaded ${knownSkus.size} known SKUs`);
}
// Save known SKUs to storage
async function saveKnownSkus() {
await chrome.storage.local.set({ knownSkus: Array.from(knownSkus) });
}
// Handle API data from content script interceptor
async function handleApiData(data) {
apiStats.apiResponseCount++;
apiStats.lastApiResponse = new Date().toISOString();
if (!data.skus || data.skus.length === 0) return;
const newSkus = [];
for (const sku of data.skus) {
if (!knownSkus.has(sku)) {
knownSkus.add(sku);
newSkus.push(sku);
apiStats.newSkusDetected++;
}
}
apiStats.totalSkusTracked = knownSkus.size;
if (newSkus.length > 0) {
console.log(`[API Monitor] NEW SKUs detected: ${newSkus.join(', ')}`);
// Send Discord alert for new SKUs
if (config.discordWebhook && config.notifyNewProducts) {
for (const sku of newSkus.slice(0, 3)) { // Limit to 3 to avoid spam
await sendSkuAlert(sku, data.url);
await new Promise(r => setTimeout(r, 1000));
}
}
await saveKnownSkus();
}
}
// Send alert for new SKU detected via API
async function sendSkuAlert(sku, sourceUrl) {
const productUrl = `https://www.pokemoncenter.com/product/${sku}`;
const embed = {
title: "NEW SKU DETECTED (API)",
description: `**SKU: ${sku}**\n\nDetected in backend API before public listing!`,
url: productUrl,
color: 0xFF00FF, // Magenta for API detections
fields: [
{ name: "SKU", value: sku, inline: true },
{ name: "Source", value: "API Intercept", inline: true },
{ name: "Link", value: `[VIEW PRODUCT](${productUrl})`, inline: false }
],
footer: { text: "Pokemon Monitor - API Detection" },
timestamp: new Date().toISOString()
};
try {
const response = await fetch(config.discordWebhook, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
content: "@everyone NEW SKU FROM API!",
embeds: [embed]
})
});
if (response.ok) {
console.log(`[API Monitor] Discord alert sent for SKU: ${sku}`);
chrome.notifications.create({
type: "basic",
iconUrl: "icon128.png",
title: "NEW SKU DETECTED!",
message: `SKU: ${sku} - Check Discord!`
});
}
} catch (error) {
console.error("[API Monitor] Error sending alert:", error);
}
}
// Main stock check function
async function runStockCheck() {
if (!config.enabled) {
console.log("Stock check disabled");
return;
}
console.log("Running stock check...");
for (const url of config.urls) {
try {
const products = await fetchAndParseProducts(url);
// Alert if 0 products found - likely bot protection
if (products.length === 0) {
console.warn("WARNING: 0 products found - possible bot protection!");
await sendWarningNotification("Bot Protection Detected",
"Found 0 products - you may need to manually verify you're human on PokemonCenter.com");
continue;
}
const { newProducts, restockedProducts } = processProducts(products);
// Send notifications with rate limiting
// Limit new product notifications to 5 per check to avoid Discord rate limits
const MAX_NEW_NOTIFICATIONS = 5;
let notificationsSent = 0;
if (config.notifyNewProducts && newProducts.length > 0) {
const inStockNew = newProducts.filter(p => p.inStock);
if (inStockNew.length > MAX_NEW_NOTIFICATIONS) {
console.log(`Limiting notifications: ${inStockNew.length} new products, only sending ${MAX_NEW_NOTIFICATIONS}`);
}
for (const product of inStockNew.slice(0, MAX_NEW_NOTIFICATIONS)) {
await sendDiscordNotification(product, "new_drop");
notificationsSent++;
if (notificationsSent > 1) await new Promise(r => setTimeout(r, 1000)); // 1 sec delay between notifications
}
}
// Always notify restocks (these are high priority)
if (config.notifyRestocks) {
for (const product of restockedProducts) {
await sendDiscordNotification(product, "restock");
await new Promise(r => setTimeout(r, 1000)); // 1 sec delay
}
}
console.log(`Check complete: ${products.length} products, ${newProducts.length} new, ${restockedProducts.length} restocks`);
} catch (error) {
console.error(`Error checking ${url}:`, error);
}
}
await saveProducts();
// Sync to dashboard after every run
if (config.syncToDashboard) {
await syncToDashboard();
}
}
// Fetch and parse products from a URL using content script
async function fetchAndParseProducts(url) {
return new Promise(async (resolve, reject) => {
try {
let tabId;
let isNewTab = false;
// Reuse existing tab or create new one
if (persistentTabId) {
try {
const tab = await chrome.tabs.get(persistentTabId);
tabId = tab.id;
console.log(`Reusing tab ${tabId}, refreshing...`);
await chrome.tabs.update(tabId, { url });
} catch (e) {
// Tab was closed, create new one
persistentTabId = null;
}
}
if (!persistentTabId) {
const tab = await chrome.tabs.create({ url, active: false });
tabId = tab.id;
persistentTabId = tabId;
isNewTab = true;
console.log(`Created new tab ${tabId}`);
}
// Wait for tab to finish loading
const waitForLoad = () => {
return new Promise((res) => {
const listener = (tid, changeInfo) => {
if (tid === tabId && changeInfo.status === "complete") {
chrome.tabs.onUpdated.removeListener(listener);
res();
}
};
chrome.tabs.onUpdated.addListener(listener);
setTimeout(() => {
chrome.tabs.onUpdated.removeListener(listener);
res();
}, 20000);
});
};
await waitForLoad();
console.log(`Tab ${tabId} loaded`);
// Smart wait: poll for products instead of fixed wait
let products = [];
const startTime = Date.now();
const maxWait = 8000; // Max 8 seconds
const pollInterval = 500; // Check every 500ms
while (Date.now() - startTime < maxWait) {
try {
// Inject content script if needed
try {
await chrome.scripting.executeScript({
target: { tabId },
files: ["content.js"]
});
} catch (e) { /* Already injected */ }
await new Promise(r => setTimeout(r, pollInterval));
const response = await chrome.tabs.sendMessage(tabId, { type: "extractProducts" });
products = response || [];
if (products.length > 0) {
console.log(`Found ${products.length} products after ${Date.now() - startTime}ms`);
break;
}
} catch (e) {
// Content script not ready yet
}
}
if (products.length === 0) {
console.log(`No products found after ${maxWait}ms`);
}
// Apply keyword filter
if (config.keywords && config.keywords.length > 0) {
products = products.filter(p => {
const nameLower = p.name.toLowerCase();
return config.keywords.some(kw => nameLower.includes(kw.toLowerCase()));
});
console.log(`After keyword filter: ${products.length} products`);
}
// Don't close tab - keep for reuse
resolve(products);
} catch (error) {
persistentTabId = null; // Reset on error
reject(error);
}
});
}
// Note: Product parsing is now done by the content script (content.js)
// Process products - detect new and restocked
function processProducts(products) {
const newProducts = [];
const restockedProducts = [];
const now = new Date().toISOString();
for (const product of products) {
const existing = knownProducts[product.url];
if (!existing) {
// New product
newProducts.push(product);
knownProducts[product.url] = {
...product,
firstSeen: now,
lastSeen: now,
lastInStock: product.inStock ? now : null
};
// Queue new_drop event for dashboard sync
pendingEvents.push({
type: 'new_drop',
url: product.url,
name: product.name,
price: product.price,
inStock: product.inStock,
timestamp: now
});
} else {
// Existing product - check for restock
if (product.inStock && !existing.inStock) {
restockedProducts.push(product);
existing.lastInStock = now;
// Queue restock event for dashboard sync
pendingEvents.push({
type: 'restock',
url: product.url,
name: product.name,
price: product.price,
previousPrice: existing.price,
timestamp: now
});
}
// Track when items go OUT of stock (for selling rate calculation)
if (!product.inStock && existing.inStock) {
pendingEvents.push({
type: 'out_of_stock',
url: product.url,
name: product.name,
price: product.price,
lastInStock: existing.lastInStock, // When it came in stock
timestamp: now // When it sold out
});
}
// Track price changes
if (product.price && existing.price && product.price !== existing.price) {
pendingEvents.push({
type: 'price_change',
url: product.url,
name: product.name,
oldPrice: existing.price,
newPrice: product.price,
timestamp: now
});
}
// Update
existing.inStock = product.inStock;
existing.price = product.price || existing.price;
existing.lastSeen = now;
}
}
return { newProducts, restockedProducts };
}
// Send warning notification (for bot protection, errors, etc.)
async function sendWarningNotification(title, message) {
if (!config.discordWebhook) {
console.log("No Discord webhook configured");
return;
}
const embed = {
title: `⚠️ ${title}`,
description: message,
color: 0xFF6600, // Orange for warnings
footer: { text: "Pokemon Stock Monitor (Chrome Extension)" },
timestamp: new Date().toISOString()
};
try {
const response = await fetch(config.discordWebhook, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
content: "@everyone",
embeds: [embed]
})
});
if (response.ok) {
console.log(`Warning notification sent: ${title}`);
}
} catch (error) {
console.error("Error sending warning notification:", error);
}
}
// Send Discord notification
async function sendDiscordNotification(product, alertType) {
if (!config.discordWebhook) {
console.log("No Discord webhook configured");
return;
}
const color = alertType === "restock" ? 0x00FF00 : 0x0099FF;
const title = alertType === "restock" ? "RESTOCK ALERT" : "NEW DROP";
const embed = {
title: title,
description: `**${product.name}**`,
url: product.url,
color: color,
fields: [
{ name: "Price", value: product.price || "See link", inline: true },
{ name: "Store", value: "Pokemon Center", inline: true },
{ name: "Link", value: `[BUY NOW](${product.url})`, inline: false }
],
footer: { text: "Pokemon Stock Monitor (Chrome Extension)" },
timestamp: new Date().toISOString()
};
if (product.imageUrl) {
embed.thumbnail = { url: product.imageUrl };
}
try {
const response = await fetch(config.discordWebhook, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
content: "@everyone",
embeds: [embed]
})
});
if (response.ok) {
console.log(`Discord notification sent for: ${product.name}`);
// Also show browser notification
chrome.notifications.create({
type: "basic",
iconUrl: "icon128.png",
title: title,
message: product.name
});
} else {
console.error("Discord notification failed:", response.status);
}
} catch (error) {
console.error("Error sending Discord notification:", error);
}
}
// Track pending events to sync (restocks, new drops)
let pendingEvents = [];
// Sync data to local dashboard
async function syncToDashboard() {
if (!config.syncToDashboard || !config.dashboardUrl) {
return { success: false, reason: 'sync disabled' };
}
// Build products array with all tracking data
const productsToSync = Object.values(knownProducts).map(p => ({
...p,
site: 'pokemoncenter',
sku: p.productId || extractSkuFromUrl(p.url)
}));
const syncData = {
skus: Array.from(knownSkus),
products: productsToSync,
apiStats: apiStats,
events: pendingEvents // Include pending events (restocks, new drops)
};
try {
const response = await fetch(`${config.dashboardUrl}/api/extension/sync`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(syncData)
});
if (response.ok) {
const result = await response.json();
console.log(`[Dashboard Sync] Success - ${result.total_skus} SKUs, ${result.total_products} products`);
// Clear pending events after successful sync
pendingEvents = [];
return { success: true, ...result };
} else {
console.warn(`[Dashboard Sync] Failed: ${response.status}`);
return { success: false, status: response.status };
}
} catch (error) {
// Dashboard might not be running - silently fail
console.debug(`[Dashboard Sync] Dashboard not available: ${error.message}`);
return { success: false, error: error.message };
}
}
// Helper to extract SKU from Pokemon Center URL
function extractSkuFromUrl(url) {
if (!url) return null;
const match = url.match(/\/product\/([^\/\?]+)/);
return match ? match[1] : null;
}
// Auto-sync to dashboard periodically (every 5 minutes)
setInterval(() => {
if (config.syncToDashboard) {
syncToDashboard();
}
}, 5 * 60 * 1000);
// Listen for messages from popup and content scripts
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === "getConfig") {
sendResponse(config);
} else if (message.type === "saveConfig") {
config = { ...config, ...message.config };
saveConfig();
sendResponse({ success: true });
} else if (message.type === "runCheck") {
runStockCheck();
sendResponse({ success: true });
} else if (message.type === "getStats") {
sendResponse({
totalProducts: Object.keys(knownProducts).length,
enabled: config.enabled,
apiStats: apiStats,
knownSkuCount: knownSkus.size
});
} else if (message.type === "clearProducts") {
knownProducts = {};
saveProducts();
sendResponse({ success: true });
} else if (message.type === "clearSkus") {
knownSkus = new Set();
apiStats.totalSkusTracked = 0;
saveKnownSkus();
sendResponse({ success: true });
} else if (message.type === "apiData") {
// Handle API data from content script interceptor
handleApiData(message.data);
sendResponse({ success: true });
} else if (message.type === "syncToDashboard") {
// Manual sync to local dashboard
syncToDashboard().then(result => sendResponse(result));
return true; // Keep channel open for async response
}
return true;
});
// Run initial check after a short delay
setTimeout(() => {
loadConfig().then(() => {
loadProducts();
loadKnownSkus().then(() => {
console.log("[API Monitor] Ready - will intercept API calls when you browse Pokemon Center");
if (config.enabled) {
console.log(`Check interval: ${config.checkIntervalSeconds} seconds`);
runStockCheck();
startCheckLoop();
}
});
});
}, 3000);