859 lines
27 KiB
JavaScript
859 lines
27 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 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);
|