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