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,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
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
})();
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>⚡</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>
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user