// 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 isChecking = false; // Prevent overlapping checks let lastNotificationTime = 0; // Rate limiting for Discord let botBackoffUntil = 0; // Timestamp (ms) until which checks are paused after bot detection let consecutiveBotDetections = 0; // Track consecutive failures for exponential backoff let captchaTabId = null; // Tab currently showing a captcha — kept open for user to solve // 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"); chrome.storage.local.remove(["persistentTabId", "captchaTabId"]); loadConfig().then(() => { loadProducts(); loadKnownSkus(); startCheckLoop(); }); }); // Load on startup chrome.runtime.onStartup.addListener(async () => { await loadConfig(); await loadPersistentTabId(); // must complete before any check runs loadProducts(); loadKnownSkus(); startCheckLoop(); }); const ALARM_NAME = "stockCheck"; // Start the check loop using chrome.alarms so it survives service worker restarts // Adds ±15s jitter to the first delay to avoid predictable bot-like cadence function startCheckLoop() { const intervalMinutes = Math.max(1, config.checkIntervalSeconds / 60); const jitterMinutes = (Math.random() - 0.5) * 0.5; // ±15 seconds const delayMinutes = Math.max(0.1, intervalMinutes + jitterMinutes); chrome.alarms.get(ALARM_NAME, (existing) => { if (!existing) { chrome.alarms.create(ALARM_NAME, { delayInMinutes: delayMinutes, periodInMinutes: intervalMinutes }); console.log(`Check alarm created: every ${intervalMinutes} min (first in ~${delayMinutes.toFixed(2)} min)`); } }); } function stopCheckLoop() { chrome.alarms.clear(ALARM_NAME); console.log("Check alarm cleared"); } // Handle alarms chrome.alarms.onAlarm.addListener(async (alarm) => { if (alarm.name === ALARM_NAME) { if (!isChecking && config.enabled) { // Skip check if we're still in bot-detection backoff window if (Date.now() < botBackoffUntil) { const remainingSec = Math.round((botBackoffUntil - Date.now()) / 1000); console.log(`[Backoff] Skipping check — bot backoff active for ${remainingSec}s more`); return; } isChecking = true; try { // Service worker may have been killed and restarted since last check — // restore state from storage before doing anything with tabs or config. await loadConfig(); await loadPersistentTabId(); await loadBackoffState(); await loadCaptchaTabState(); await runStockCheck(); } finally { isChecking = false; } } } }); // 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 }); // Recreate alarm with updated 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/save persistent tab ID from storage so it survives service worker restarts async function loadPersistentTabId() { const stored = await chrome.storage.local.get("persistentTabId"); if (stored.persistentTabId) { try { await chrome.tabs.get(stored.persistentTabId); persistentTabId = stored.persistentTabId; console.log(`Restored persistent tab ${persistentTabId}`); } catch (e) { persistentTabId = null; await chrome.storage.local.remove("persistentTabId"); } } } async function savePersistentTabId(tabId) { persistentTabId = tabId; await chrome.storage.local.set({ persistentTabId: tabId }); } async function clearPersistentTab() { const idToRemove = persistentTabId; // Null out state immediately so a concurrent call doesn't double-remove persistentTabId = null; await chrome.storage.local.remove("persistentTabId"); if (idToRemove) { try { await chrome.tabs.remove(idToRemove); console.log(`Closed persistent tab ${idToRemove}`); } catch (e) { // Tab was already closed externally — that's fine console.log(`Tab ${idToRemove} already gone: ${e.message}`); } } } // Backoff state survives service worker restarts via storage async function loadBackoffState() { const stored = await chrome.storage.local.get(["botBackoffUntil", "consecutiveBotDetections"]); botBackoffUntil = stored.botBackoffUntil || 0; consecutiveBotDetections = stored.consecutiveBotDetections || 0; } async function saveBackoffState() { await chrome.storage.local.set({ botBackoffUntil, consecutiveBotDetections }); } async function applyBotBackoff() { consecutiveBotDetections++; // Exponential backoff: 2^n minutes, capped at 30 minutes, with ±30s jitter const backoffMinutes = Math.min(30, Math.pow(2, consecutiveBotDetections)); const jitterMs = (Math.random() - 0.5) * 60000; // ±30 seconds botBackoffUntil = Date.now() + backoffMinutes * 60000 + jitterMs; await saveBackoffState(); console.log(`[Backoff] Bot detected (${consecutiveBotDetections}x) — pausing checks for ~${backoffMinutes} min`); } function resetBotBackoff() { if (consecutiveBotDetections > 0) { console.log("[Backoff] Successful check — resetting bot detection counter"); consecutiveBotDetections = 0; botBackoffUntil = 0; chrome.storage.local.remove(["botBackoffUntil", "consecutiveBotDetections"]); } } // --- Captcha tab tracking --- // Persists the tab ID of the open captcha page across service worker restarts. async function loadCaptchaTabState() { const stored = await chrome.storage.local.get("captchaTabId"); if (stored.captchaTabId) { try { await chrome.tabs.get(stored.captchaTabId); captchaTabId = stored.captchaTabId; console.log(`[Captcha] Restored captcha tab ${captchaTabId}`); } catch (e) { captchaTabId = null; await chrome.storage.local.remove("captchaTabId"); } } } async function saveCaptchaTabId(tabId) { captchaTabId = tabId; if (tabId) { await chrome.storage.local.set({ captchaTabId: tabId }); } else { await chrome.storage.local.remove("captchaTabId"); } } // Returns 'solved' | 'still_captcha' | 'gone' async function checkCaptchaTabStatus() { if (!captchaTabId) return 'gone'; try { const tab = await chrome.tabs.get(captchaTabId); // If still loading or on a known captcha URL, not solved yet if (!tab.url || tab.status === 'loading' || isCaptchaUrl(tab.url)) { return 'still_captcha'; } // Tab is back on pokemoncenter — user likely solved it return 'solved'; } catch (e) { return 'gone'; // Tab was closed } } // 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; } // If we have a captcha tab open, check whether the user has solved it // before opening any new tabs or sending more warnings. if (captchaTabId) { const status = await checkCaptchaTabStatus(); if (status === 'still_captcha') { console.log(`[Captcha] Tab ${captchaTabId} still showing captcha — waiting for user`); await applyBotBackoff(); // Re-arm backoff silently; no Discord ping return; } if (status === 'solved') { console.log(`[Captcha] Tab ${captchaTabId} looks resolved — resuming normal checks`); // Reclaim the solved tab as the persistent tab so we don't open a new one await savePersistentTabId(captchaTabId); await saveCaptchaTabId(null); resetBotBackoff(); } else { // 'gone' — user closed the tab themselves console.log('[Captcha] Captcha tab was closed — resuming normal checks'); await saveCaptchaTabId(null); resetBotBackoff(); } } console.log("Running stock check..."); for (const url of config.urls) { try { const products = await fetchAndParseProducts(url); // Alert if 0 products found — likely captcha or bot protection if (products.length === 0) { console.warn("WARNING: 0 products found - possible bot protection or captcha!"); botProtectionDetected = true; const isFirstDetection = consecutiveBotDetections === 0; await applyBotBackoff(); if (isFirstDetection) { // Only ping Discord on the first detection — not on every retry const backoffMin = Math.round((botBackoffUntil - Date.now()) / 60000); await sendWarningNotification("Bot Protection Detected", `Found 0 products — captcha may be active. Pausing checks for ~${backoffMin} min. Check the open PokemonCenter tab and solve the captcha if prompted.`); } // Keep the tab open so the user can solve the captcha. // If fetchAndParseProducts already saved it as captchaTabId (URL-detected), // use that; otherwise promote the current persistentTabId so we track it. if (!captchaTabId && persistentTabId) { await saveCaptchaTabId(persistentTabId); } persistentTabId = null; await chrome.storage.local.remove("persistentTabId"); continue; } // Successful product load — reset any backoff state resetBotBackoff(); 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(); } } // Detect captcha/challenge pages by URL patterns (all lowercase comparisons) function isCaptchaUrl(url) { if (!url) return false; const lower = url.toLowerCase(); // Known bot-protection URL signatures if (lower.includes('_incapsula_resource')) return true; if (lower.includes('/cdn-cgi/challenge')) return true; if (lower.includes('imperva')) return true; if (lower.includes('incapsula')) return true; if (lower.includes('distil')) return true; // "challenge" or "verify" only flag if we've left the pokemoncenter domain entirely if (!lower.includes('pokemoncenter.com')) return true; return false; } // Fetch and parse products from a URL using content script async function fetchAndParseProducts(url) { let tabId; try { // Reuse existing tab or create a new one if (persistentTabId) { try { await chrome.tabs.get(persistentTabId); tabId = persistentTabId; console.log(`Reusing tab ${tabId}, navigating...`); await chrome.tabs.update(tabId, { url }); } catch (e) { // Tab is gone — clear it and fall through to create a new one await clearPersistentTab(); tabId = null; } } if (!tabId) { const tab = await chrome.tabs.create({ url, active: false }); tabId = tab.id; await savePersistentTabId(tabId); console.log(`Created new tab ${tabId}`); } // Wait for tab to finish loading await 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); }); // Check if the tab landed on a captcha/challenge page try { const tab = await chrome.tabs.get(tabId); if (isCaptchaUrl(tab.url)) { console.warn(`[Captcha] Tab ${tabId} landed on challenge page: ${tab.url}`); // Keep tab open for user; track it so we can poll for resolution await saveCaptchaTabId(tabId); persistentTabId = null; await chrome.storage.local.remove("persistentTabId"); return []; // Caller will handle backoff + single ping } } catch (e) { /* tab may have been closed */ } console.log(`Tab ${tabId} loaded`); // Poll for products — PokemonCenter is a React SPA so status:"complete" fires // on the HTML shell before products are rendered via API calls. // Inject content.js once, then poll via messaging. let products = []; const startTime = Date.now(); const maxWait = 25000; const pollInterval = 1000; // Reduced polling frequency let injected = false; while (Date.now() - startTime < maxWait) { try { if (!injected) { try { await chrome.scripting.executeScript({ target: { tabId }, files: ["content.js"] }); injected = true; } catch (e) { injected = true; // Already injected — treat as done } } 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 — assuming captcha/bot block`); // Keep the tab open and track it so runStockCheck can poll for resolution await saveCaptchaTabId(tabId); persistentTabId = null; await chrome.storage.local.remove("persistentTabId"); } // Apply keyword filter if (config.keywords && config.keywords.length > 0) { products = products.filter(p => config.keywords.some(kw => p.name.toLowerCase().includes(kw.toLowerCase())) ); console.log(`After keyword filter: ${products.length} products`); } // Keep tab open for reuse next check return products; } catch (error) { // On any error, close the tab so we don't leave an untracked orphan await clearPersistentTab(); tabId = null; throw 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 = []; let botProtectionDetected = false; // 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, bot_protection_detected: botProtectionDetected }; 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 and flags after successful sync pendingEvents = []; botProtectionDetected = false; 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; } // 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(async () => { await loadConfig(); await loadPersistentTabId(); // must complete before any check runs await loadBackoffState(); await loadCaptchaTabState(); loadProducts(); await loadKnownSkus(); console.log("[API Monitor] Ready - will intercept API calls when you browse Pokemon Center"); if (config.enabled) { console.log(`Check interval: ${config.checkIntervalSeconds} seconds`); startCheckLoop(); // Skip initial check if still in backoff window if (Date.now() < botBackoffUntil) { const remainingSec = Math.round((botBackoffUntil - Date.now()) / 1000); console.log(`[Backoff] Startup — skipping initial check, backoff active for ${remainingSec}s more`); } else if (!isChecking) { isChecking = true; runStockCheck().finally(() => { isChecking = false; }); } } }, 3000);