Pokemon Stock Monitor - Initial commit
Chrome extension for PokemonCenter monitoring with Discord notifications. Includes Python scripts for Target monitoring. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
// 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;
|
||||
|
||||
// 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.alarms.create("stockCheck", {
|
||||
periodInMinutes: config.checkIntervalMinutes
|
||||
});
|
||||
console.log(`Alarm set for every ${config.checkIntervalMinutes} minute(s)`);
|
||||
}
|
||||
|
||||
// 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);
|
||||
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 {
|
||||
// Create a tab to load the page
|
||||
const tab = await chrome.tabs.create({ url, active: false });
|
||||
|
||||
console.log(`Created tab ${tab.id} for ${url}`);
|
||||
|
||||
// Wait for tab to finish loading
|
||||
const waitForLoad = () => {
|
||||
return new Promise((res) => {
|
||||
const listener = (tabId, changeInfo) => {
|
||||
if (tabId === tab.id && changeInfo.status === "complete") {
|
||||
chrome.tabs.onUpdated.removeListener(listener);
|
||||
res();
|
||||
}
|
||||
};
|
||||
chrome.tabs.onUpdated.addListener(listener);
|
||||
|
||||
// Timeout after 30 seconds
|
||||
setTimeout(() => {
|
||||
chrome.tabs.onUpdated.removeListener(listener);
|
||||
res();
|
||||
}, 30000);
|
||||
});
|
||||
};
|
||||
|
||||
await waitForLoad();
|
||||
console.log(`Tab ${tab.id} loaded`);
|
||||
|
||||
// Give extra time for dynamic content to load
|
||||
console.log("Waiting for dynamic content...");
|
||||
await new Promise(r => setTimeout(r, 5000));
|
||||
|
||||
// Send message to content script to extract products
|
||||
let products = [];
|
||||
try {
|
||||
const response = await chrome.tabs.sendMessage(tab.id, { type: "extractProducts" });
|
||||
products = response || [];
|
||||
console.log(`Content script returned ${products.length} products`);
|
||||
} catch (e) {
|
||||
console.log("Content script not ready, injecting manually...");
|
||||
// Inject content script if not already there
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
files: ["content.js"]
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
const response = await chrome.tabs.sendMessage(tab.id, { type: "extractProducts" });
|
||||
products = response || [];
|
||||
}
|
||||
|
||||
// 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`);
|
||||
}
|
||||
|
||||
// Close the tab
|
||||
await chrome.tabs.remove(tab.id);
|
||||
console.log(`Closed tab ${tab.id}`);
|
||||
|
||||
resolve(products);
|
||||
|
||||
} catch (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 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);
|
||||
Reference in New Issue
Block a user