8f526976bc
- Keep tab open between checks (no create/close overhead) - Poll for products every 500ms instead of fixed 10s wait - Exit early as soon as products are found - Max wait reduced to 8 seconds - Should reduce check time from ~15-20s to ~3-8s Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
390 lines
11 KiB
JavaScript
390 lines
11 KiB
JavaScript
// Pokemon Stock Monitor - Background Service Worker
|
|
|
|
const DEFAULT_CONFIG = {
|
|
discordWebhook: "",
|
|
checkIntervalMinutes: 1,
|
|
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
|
|
};
|
|
|
|
// Store known products
|
|
let knownProducts = {};
|
|
let config = DEFAULT_CONFIG;
|
|
let persistentTabId = null; // Keep tab open for faster refreshes
|
|
|
|
// Initialize
|
|
chrome.runtime.onInstalled.addListener(() => {
|
|
console.log("Pokemon Stock Monitor installed");
|
|
loadConfig();
|
|
loadProducts();
|
|
setupAlarm();
|
|
});
|
|
|
|
// Load on startup
|
|
chrome.runtime.onStartup.addListener(() => {
|
|
loadConfig();
|
|
loadProducts();
|
|
setupAlarm();
|
|
});
|
|
|
|
// Handle alarm
|
|
chrome.alarms.onAlarm.addListener((alarm) => {
|
|
if (alarm.name === "stockCheck") {
|
|
runStockCheck();
|
|
}
|
|
});
|
|
|
|
// Setup periodic alarm
|
|
function setupAlarm() {
|
|
// Chrome minimum is 0.5 minutes (30 seconds) for packed extensions
|
|
const interval = Math.max(0.5, config.checkIntervalMinutes);
|
|
chrome.alarms.create("stockCheck", {
|
|
periodInMinutes: interval
|
|
});
|
|
console.log(`Alarm set for every ${interval} minute(s) (${interval * 60} seconds)`);
|
|
}
|
|
|
|
// 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 });
|
|
setupAlarm(); // Reset alarm with new interval
|
|
}
|
|
|
|
// 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 });
|
|
}
|
|
|
|
// 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
|
|
if (config.notifyNewProducts) {
|
|
for (const product of newProducts) {
|
|
if (product.inStock) {
|
|
await sendDiscordNotification(product, "new_drop");
|
|
}
|
|
}
|
|
}
|
|
|
|
if (config.notifyRestocks) {
|
|
for (const product of restockedProducts) {
|
|
await sendDiscordNotification(product, "restock");
|
|
}
|
|
}
|
|
|
|
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
|
|
};
|
|
} else {
|
|
// Existing product - check for restock
|
|
if (product.inStock && !existing.inStock) {
|
|
restockedProducts.push(product);
|
|
existing.lastInStock = 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);
|
|
}
|
|
}
|
|
|
|
// Listen for messages from popup
|
|
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
|
|
});
|
|
} else if (message.type === "clearProducts") {
|
|
knownProducts = {};
|
|
saveProducts();
|
|
sendResponse({ success: true });
|
|
}
|
|
return true;
|
|
});
|
|
|
|
// Run initial check after a short delay
|
|
setTimeout(() => {
|
|
loadConfig().then(() => {
|
|
loadProducts().then(() => {
|
|
if (config.enabled && config.discordWebhook) {
|
|
runStockCheck();
|
|
}
|
|
});
|
|
});
|
|
}, 5000);
|