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:
2026-03-24 12:39:09 -04:00
commit 9d49a99916
18 changed files with 2653 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
# Pokemon Stock Monitor - Chrome Extension
A Chrome extension that monitors PokemonCenter for stock changes and sends Discord notifications.
## Why a Chrome Extension?
PokemonCenter uses Imperva bot protection that blocks automated browsers (Playwright, Selenium). This extension runs **inside your real Chrome browser**, making it undetectable - to the website, it looks exactly like you browsing manually.
## Installation
1. **Add Icons** (optional but recommended):
- Create or download three PNG icons: `icon16.png`, `icon48.png`, `icon128.png`
- Place them in this folder
- You can use any Pokemon-themed icons or simple colored squares
2. **Load the Extension**:
- Open Chrome and go to `chrome://extensions`
- Enable "Developer mode" (toggle in top right)
- Click "Load unpacked"
- Select this `chrome-extension` folder
- The extension icon should appear in your toolbar
3. **Configure**:
- Click the extension icon
- Paste your Discord webhook URL
- Add URLs to monitor (default is PokemonCenter TCG)
- Set check interval (1-60 minutes)
- Click "Save Settings"
## How It Works
1. **Background Service Worker**: Runs continuously in Chrome
2. **Periodic Checks**: Uses Chrome's alarm API to check at your interval
3. **Product Parsing**: Fetches pages and parses product data from HTML
4. **Change Detection**: Compares against known products to detect:
- New product listings
- Restocks (was out of stock, now in stock)
5. **Discord Notifications**: Sends rich embeds with product info and direct links
## Features
- **Keyword Filtering**: Only track products matching specific keywords
- **New Product Alerts**: Get notified when new items appear
- **Restock Alerts**: Get notified when out-of-stock items return
- **Browser Notifications**: Local notifications in addition to Discord
- **Persistent Storage**: Remembers products across browser restarts
## Tips
- **Keep Chrome Running**: The extension only works while Chrome is open
- **First Run**: After installing, click "Check Now" to do an initial scan
- **Clear History**: Use this if you want to re-detect all products as "new"
- **Multiple URLs**: Add one URL per line in the URLs field
## Troubleshooting
**No notifications sending:**
- Check that your Discord webhook URL is correct
- Make sure "Monitor Enabled" is toggled on
- Check Chrome's console for errors (right-click extension → Inspect popup)
**Extension not loading:**
- Make sure manifest.json is valid JSON
- Check for any icon files referenced but missing
**Products not detected:**
- PokemonCenter may have changed their HTML structure
- Check the browser console for parsing errors
+317
View File
@@ -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);
+109
View File
@@ -0,0 +1,109 @@
// Pokemon Stock Monitor - Content Script
// Runs on PokemonCenter pages to extract product data
(function() {
console.log("[Pokemon Monitor] Content script loaded on:", window.location.href);
// Listen for messages from background script
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === "extractProducts") {
console.log("[Pokemon Monitor] Extracting products...");
const products = extractProducts();
console.log("[Pokemon Monitor] Found", products.length, "products");
sendResponse(products);
}
return true;
});
function extractProducts() {
const products = [];
// Group all product links by URL
const urlToLinks = {};
document.querySelectorAll('a[href*="/product/"]').forEach(link => {
const url = link.href;
if (!urlToLinks[url]) {
urlToLinks[url] = [];
}
urlToLinks[url].push(link);
});
console.log("[Pokemon Monitor] Found", Object.keys(urlToLinks).length, "unique product URLs");
// Process each unique product URL
for (const [url, links] of Object.entries(urlToLinks)) {
// Find the best name from all links to this product
let bestName = "";
let isSoldOut = false;
for (const link of links) {
const text = link.textContent.trim();
// Check if any link says "SOLD OUT"
if (text.toUpperCase().includes("SOLD OUT")) {
isSoldOut = true;
}
// Pick the longest non-"SOLD OUT" text as the name
if (text.length > bestName.length &&
!text.toUpperCase().startsWith("SOLD") &&
text.length > 10) {
bestName = text;
}
}
// Skip if we couldn't find a good name
if (!bestName || bestName.length < 10) continue;
// Clean up name - remove price if embedded
bestName = bestName.replace(/\$[\d,.]+/g, "").trim();
// Remove "Add to Cart" etc
bestName = bestName.replace(/Add to Cart/gi, "").trim();
// Clean whitespace
bestName = bestName.replace(/\s+/g, " ").trim();
// Find price from any of the links' containers
let price = null;
for (const link of links) {
let parent = link.parentElement;
for (let i = 0; i < 6 && parent && !price; i++) {
const priceMatch = parent.textContent.match(/\$[\d,]+\.?\d*/);
if (priceMatch) {
price = priceMatch[0];
break;
}
parent = parent.parentElement;
}
if (price) break;
}
// Find image from any of the links
let imageUrl = null;
for (const link of links) {
const img = link.querySelector("img") ||
link.closest("[class*='product']")?.querySelector("img");
if (img) {
imageUrl = img.src || img.dataset.src;
if (imageUrl && !imageUrl.startsWith("data:")) break;
}
}
// Extract product ID from URL
const idMatch = url.match(/\/product\/([^\/]+)/);
const productId = idMatch ? idMatch[1] : url;
products.push({
name: bestName,
url: url,
price: price,
inStock: !isSoldOut,
imageUrl: imageUrl,
productId: productId,
site: "pokemoncenter"
});
}
console.log("[Pokemon Monitor] Extracted products:", products.map(p => ({name: p.name.slice(0,40), inStock: p.inStock})));
return products;
}
})();
+30
View File
@@ -0,0 +1,30 @@
{
"manifest_version": 3,
"name": "Pokemon Stock Monitor",
"version": "1.0.1",
"description": "Monitors PokemonCenter for restocks and new drops, sends Discord notifications",
"permissions": [
"alarms",
"storage",
"notifications",
"tabs",
"scripting"
],
"host_permissions": [
"https://www.pokemoncenter.com/*",
"https://discord.com/api/*"
],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup.html"
},
"content_scripts": [
{
"matches": ["https://www.pokemoncenter.com/*"],
"js": ["content.js"],
"run_at": "document_idle"
}
]
}
+254
View File
@@ -0,0 +1,254 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {
width: 320px;
padding: 15px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #eee;
margin: 0;
}
h1 {
font-size: 16px;
margin: 0 0 15px 0;
color: #ffcb05;
display: flex;
align-items: center;
gap: 8px;
}
h1 span {
font-size: 20px;
}
.section {
margin-bottom: 15px;
}
label {
display: block;
font-size: 12px;
color: #aaa;
margin-bottom: 4px;
}
input[type="text"], input[type="number"], textarea {
width: 100%;
padding: 8px;
border: 1px solid #333;
border-radius: 4px;
background: #16213e;
color: #eee;
font-size: 13px;
box-sizing: border-box;
}
input:focus, textarea:focus {
outline: none;
border-color: #ffcb05;
}
textarea {
height: 60px;
resize: vertical;
}
.row {
display: flex;
gap: 10px;
}
.row > div {
flex: 1;
}
.toggle-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
}
.toggle-row label {
margin: 0;
color: #eee;
font-size: 13px;
}
.toggle {
position: relative;
width: 44px;
height: 24px;
}
.toggle input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #333;
transition: .3s;
border-radius: 24px;
}
.slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: #eee;
transition: .3s;
border-radius: 50%;
}
input:checked + .slider {
background-color: #00c853;
}
input:checked + .slider:before {
transform: translateX(20px);
}
button {
width: 100%;
padding: 10px;
border: none;
border-radius: 4px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-primary {
background: #ffcb05;
color: #1a1a2e;
}
.btn-primary:hover {
background: #ffd633;
}
.btn-secondary {
background: #333;
color: #eee;
margin-top: 8px;
}
.btn-secondary:hover {
background: #444;
}
.btn-danger {
background: #c62828;
color: #fff;
margin-top: 8px;
}
.btn-danger:hover {
background: #d32f2f;
}
.stats {
background: #16213e;
padding: 10px;
border-radius: 4px;
font-size: 12px;
margin-bottom: 15px;
}
.stats-row {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
}
.stats-row:last-child {
margin-bottom: 0;
}
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
}
.status-dot.active {
background: #00c853;
}
.status-dot.inactive {
background: #c62828;
}
.saved-msg {
text-align: center;
color: #00c853;
font-size: 12px;
margin-top: 8px;
opacity: 0;
transition: opacity 0.3s;
}
.saved-msg.show {
opacity: 1;
}
small {
color: #666;
font-size: 11px;
}
</style>
</head>
<body>
<h1><span>&#9889;</span> Pokemon Stock Monitor</h1>
<div class="stats" id="stats">
<div class="stats-row">
<span>Status:</span>
<span><span class="status-dot active" id="statusDot"></span><span id="statusText">Active</span></span>
</div>
<div class="stats-row">
<span>Products tracked:</span>
<span id="productCount">0</span>
</div>
</div>
<div class="section">
<label>Discord Webhook URL</label>
<input type="text" id="webhook" placeholder="https://discord.com/api/webhooks/...">
</div>
<div class="section">
<label>URLs to Monitor (one per line)</label>
<textarea id="urls" placeholder="https://www.pokemoncenter.com/category/tcg-cards"></textarea>
</div>
<div class="section">
<label>Keywords (comma separated, leave empty for all)</label>
<input type="text" id="keywords" placeholder="chaos rising, booster, etb">
</div>
<div class="row">
<div>
<label>Check Interval (min)</label>
<input type="number" id="interval" min="1" max="60" value="1">
</div>
</div>
<div class="section">
<div class="toggle-row">
<label>Monitor Enabled</label>
<div class="toggle">
<input type="checkbox" id="enabled" checked>
<span class="slider"></span>
</div>
</div>
<div class="toggle-row">
<label>Notify New Products</label>
<div class="toggle">
<input type="checkbox" id="notifyNew" checked>
<span class="slider"></span>
</div>
</div>
<div class="toggle-row">
<label>Notify Restocks</label>
<div class="toggle">
<input type="checkbox" id="notifyRestock" checked>
<span class="slider"></span>
</div>
</div>
</div>
<button class="btn-primary" id="saveBtn">Save Settings</button>
<button class="btn-secondary" id="checkNowBtn">Check Now</button>
<button class="btn-danger" id="clearBtn">Clear Product History</button>
<div class="saved-msg" id="savedMsg">Settings saved!</div>
<script src="popup.js"></script>
</body>
</html>
+75
View File
@@ -0,0 +1,75 @@
// Pokemon Stock Monitor - Popup Script
document.addEventListener("DOMContentLoaded", async () => {
// Load current config
const config = await chrome.runtime.sendMessage({ type: "getConfig" });
const stats = await chrome.runtime.sendMessage({ type: "getStats" });
// Populate form
document.getElementById("webhook").value = config.discordWebhook || "";
document.getElementById("urls").value = (config.urls || []).join("\n");
document.getElementById("keywords").value = (config.keywords || []).join(", ");
document.getElementById("interval").value = config.checkIntervalMinutes || 1;
document.getElementById("enabled").checked = config.enabled !== false;
document.getElementById("notifyNew").checked = config.notifyNewProducts !== false;
document.getElementById("notifyRestock").checked = config.notifyRestocks !== false;
// Update stats
document.getElementById("productCount").textContent = stats.totalProducts || 0;
updateStatus(stats.enabled !== false);
// Save button
document.getElementById("saveBtn").addEventListener("click", async () => {
const urlsText = document.getElementById("urls").value;
const keywordsText = document.getElementById("keywords").value;
const newConfig = {
discordWebhook: document.getElementById("webhook").value.trim(),
urls: urlsText.split("\n").map(u => u.trim()).filter(u => u),
keywords: keywordsText.split(",").map(k => k.trim()).filter(k => k),
checkIntervalMinutes: parseInt(document.getElementById("interval").value) || 1,
enabled: document.getElementById("enabled").checked,
notifyNewProducts: document.getElementById("notifyNew").checked,
notifyRestocks: document.getElementById("notifyRestock").checked
};
await chrome.runtime.sendMessage({ type: "saveConfig", config: newConfig });
updateStatus(newConfig.enabled);
showSaved();
});
// Check Now button
document.getElementById("checkNowBtn").addEventListener("click", async () => {
await chrome.runtime.sendMessage({ type: "runCheck" });
showSaved("Check started!");
});
// Clear button
document.getElementById("clearBtn").addEventListener("click", async () => {
if (confirm("Clear all tracked products? This will treat all products as new on next check.")) {
await chrome.runtime.sendMessage({ type: "clearProducts" });
document.getElementById("productCount").textContent = "0";
showSaved("History cleared!");
}
});
});
function updateStatus(enabled) {
const dot = document.getElementById("statusDot");
const text = document.getElementById("statusText");
if (enabled) {
dot.className = "status-dot active";
text.textContent = "Active";
} else {
dot.className = "status-dot inactive";
text.textContent = "Disabled";
}
}
function showSaved(msg = "Settings saved!") {
const el = document.getElementById("savedMsg");
el.textContent = msg;
el.classList.add("show");
setTimeout(() => el.classList.remove("show"), 2000);
}