Cleanups for scrapers
This commit is contained in:
@@ -15,7 +15,57 @@
|
|||||||
"Bash(PYTHON=\"/c/Users/hocke/Documents/GitHub/pokemon-stock-checker/.venv/Scripts/python.exe\")",
|
"Bash(PYTHON=\"/c/Users/hocke/Documents/GitHub/pokemon-stock-checker/.venv/Scripts/python.exe\")",
|
||||||
"Bash(\"$PYTHON\" -c \":*)",
|
"Bash(\"$PYTHON\" -c \":*)",
|
||||||
"Bash(1 grep:*)",
|
"Bash(1 grep:*)",
|
||||||
"Bash(taskkill /F /IM python.exe)"
|
"Bash(taskkill /F /IM python.exe)",
|
||||||
|
"Bash(python -m http.server 8080)",
|
||||||
|
"Bash(py -m http.server 8080 --directory \"C:\\\\Users\\\\hocke\\\\Documents\\\\GitHub\\\\pokemon-stock-checker\")",
|
||||||
|
"Bash(where python:*)",
|
||||||
|
"Bash(where python3:*)",
|
||||||
|
"Bash(where py:*)",
|
||||||
|
"Read(//c/Users/hocke/AppData/Local/Programs/**)",
|
||||||
|
"Read(//c/Users/hocke/AppData/Local/Microsoft/WindowsApps/**)",
|
||||||
|
"Read(//c/Users/hocke/AppData/Local//**)",
|
||||||
|
"Read(//c/ProgramData//**)",
|
||||||
|
"Read(//c/Program Files//**)",
|
||||||
|
"Read(//c/Program Files \\(x86\\)//**)",
|
||||||
|
"Read(//proc/**)",
|
||||||
|
"Bash(echo \"PATH: $PATH\")",
|
||||||
|
"Bash(netstat -ano)",
|
||||||
|
"Read(//c/Users/hocke/Documents/GitHub/pokemon-stock-checker/**)",
|
||||||
|
"Bash(taskkill /PID 21060 /F)",
|
||||||
|
"Bash(cmd /c \"taskkill /PID 21060 /F\")",
|
||||||
|
"Bash(cmd /c taskkill /PID 21060 /F)",
|
||||||
|
"Bash(where python3.13)",
|
||||||
|
"Bash(where python3.12)",
|
||||||
|
"Bash(ls /c/Python*)",
|
||||||
|
"Bash(cmd //c \"where py\")",
|
||||||
|
"Bash(cmd //c \"py --version\")",
|
||||||
|
"Bash(cmd //c \"python --version\")",
|
||||||
|
"Bash(\"/c/Users/hocke/.local/bin/python3.14.exe\" -c \":*)",
|
||||||
|
"Bash(find /c/Users/hocke -maxdepth 6 -type d -name *undetected*)",
|
||||||
|
"Bash(find /c/Users/hocke -maxdepth 6 -type d -name *chrome*profile*)",
|
||||||
|
"Bash(grep -v \"^$\")",
|
||||||
|
"Bash(cmd /c \".venv\\\\Scripts\\\\python.exe -c \"\"import sqlite3; conn = sqlite3.connect\\(''stats.db''\\); conn.row_factory = sqlite3.Row; cur = conn.cursor\\(\\); cur.execute\\(\\\\\"\"SELECT name, site, image_url FROM products WHERE site IN \\(''target'', ''gamestop''\\) LIMIT 10\\\\\"\"\\); [print\\(f''[{r[\\\\\"\"site\\\\\"\"]}] {r[\\\\\"\"name\\\\\"\"][:40]} -> {r[\\\\\"\"image_url\\\\\"\"]}''\\) for r in cur.fetchall\\(\\)]\"\"\")",
|
||||||
|
"Bash(python3 -c \":*)",
|
||||||
|
"Bash(python -c \":*)",
|
||||||
|
"Bash(cmd /c \".venv\\\\Scripts\\\\python.exe -c \"\"import json; f=open\\(''data/products.json''\\); p=json.load\\(f\\); gs=[\\(v[''name''][:50],v.get\\(''image_url''\\)\\) for v in p.values\\(\\) if v.get\\(''site''\\)==''gamestop'']; [print\\(n,''->'',i\\) for n,i in gs[:5]]\"\"\")",
|
||||||
|
"WebSearch",
|
||||||
|
"WebFetch(domain:scrapfly.io)",
|
||||||
|
"WebFetch(domain:scrape.do)",
|
||||||
|
"WebFetch(domain:docs.unwrangle.com)",
|
||||||
|
"WebFetch(domain:oxylabs.io)",
|
||||||
|
"WebFetch(domain:github.com)",
|
||||||
|
"WebFetch(domain:www.bestbuy.com)",
|
||||||
|
"WebFetch(domain:scrapeops.io)",
|
||||||
|
"Bash(find /c/Users/hocke -maxdepth 8 -name python*.exe)",
|
||||||
|
"Bash(\"/c/Users/hocke/.local/bin/python3.14.exe\":*)",
|
||||||
|
"Bash(.venv/Scripts/python.exe -c \":*)",
|
||||||
|
"Bash(unzip -q \"3.73 Obf FREE.zip\" -d /tmp/extension_temp)",
|
||||||
|
"Read(//tmp/**)",
|
||||||
|
"Bash(find /c/Users/hocke/Downloads -name *3.73* -o -name *Obf*)",
|
||||||
|
"Bash(unzip -q \"/c/Users/hocke/Downloads/3.73 Obf FREE.zip\")",
|
||||||
|
"Bash(wc -l \"/tmp/3.73 Obf FREE/background.js\" \"/tmp/3.73 Obf FREE/common\"/*.js \"/tmp/3.73 Obf FREE/monitors\"/*.js \"/tmp/3.73 Obf FREE/sites/target\"/*.js \"/tmp/3.73 Obf FREE/ui/popup\"/*.js \"/tmp/3.73 Obf FREE/ui/options\"/*.js)",
|
||||||
|
"Bash(ls -lh \"/tmp/3.73 Obf FREE\"/*.js \"/tmp/3.73 Obf FREE/common\"/*.js)",
|
||||||
|
"Bash(grep -o \"pac_script\\\\|pacScript\\\\|''''PROXY \\\\|proxy:\" \"/tmp/3.73 Obf FREE/\"*.js \"/tmp/3.73 Obf FREE/common/\"*.js)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+277
-82
@@ -18,9 +18,11 @@ const DEFAULT_CONFIG = {
|
|||||||
let knownProducts = {};
|
let knownProducts = {};
|
||||||
let config = DEFAULT_CONFIG;
|
let config = DEFAULT_CONFIG;
|
||||||
let persistentTabId = null; // Keep tab open for faster refreshes
|
let persistentTabId = null; // Keep tab open for faster refreshes
|
||||||
let checkLoopRunning = false; // Prevent multiple loops
|
|
||||||
let isChecking = false; // Prevent overlapping checks
|
let isChecking = false; // Prevent overlapping checks
|
||||||
let lastNotificationTime = 0; // Rate limiting for Discord
|
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
|
// API monitoring - track SKUs seen from backend API calls
|
||||||
let knownSkus = new Set();
|
let knownSkus = new Set();
|
||||||
@@ -34,6 +36,7 @@ let apiStats = {
|
|||||||
// Initialize
|
// Initialize
|
||||||
chrome.runtime.onInstalled.addListener(() => {
|
chrome.runtime.onInstalled.addListener(() => {
|
||||||
console.log("Pokemon Stock Monitor installed");
|
console.log("Pokemon Stock Monitor installed");
|
||||||
|
chrome.storage.local.remove(["persistentTabId", "captchaTabId"]);
|
||||||
loadConfig().then(() => {
|
loadConfig().then(() => {
|
||||||
loadProducts();
|
loadProducts();
|
||||||
loadKnownSkus();
|
loadKnownSkus();
|
||||||
@@ -42,51 +45,60 @@ chrome.runtime.onInstalled.addListener(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Load on startup
|
// Load on startup
|
||||||
chrome.runtime.onStartup.addListener(() => {
|
chrome.runtime.onStartup.addListener(async () => {
|
||||||
loadConfig().then(() => {
|
await loadConfig();
|
||||||
|
await loadPersistentTabId(); // must complete before any check runs
|
||||||
loadProducts();
|
loadProducts();
|
||||||
loadKnownSkus();
|
loadKnownSkus();
|
||||||
startCheckLoop();
|
startCheckLoop();
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start the check loop (uses setTimeout to bypass Chrome's 30-sec alarm minimum)
|
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() {
|
function startCheckLoop() {
|
||||||
if (checkLoopRunning) {
|
const intervalMinutes = Math.max(1, config.checkIntervalSeconds / 60);
|
||||||
console.log("Check loop already running");
|
const jitterMinutes = (Math.random() - 0.5) * 0.5; // ±15 seconds
|
||||||
return;
|
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)`);
|
||||||
}
|
}
|
||||||
checkLoopRunning = true;
|
});
|
||||||
console.log(`Starting check loop: every ${config.checkIntervalSeconds} seconds`);
|
|
||||||
scheduleNextCheck();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleNextCheck() {
|
function stopCheckLoop() {
|
||||||
if (!config.enabled || !checkLoopRunning) {
|
chrome.alarms.clear(ALARM_NAME);
|
||||||
checkLoopRunning = false;
|
console.log("Check alarm cleared");
|
||||||
console.log("Check loop stopped");
|
}
|
||||||
|
|
||||||
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const intervalMs = Math.max(60, config.checkIntervalSeconds) * 1000; // Min 60 seconds to avoid bot detection
|
|
||||||
|
|
||||||
setTimeout(async () => {
|
|
||||||
if (!isChecking && config.enabled) {
|
|
||||||
isChecking = true;
|
isChecking = true;
|
||||||
try {
|
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();
|
await runStockCheck();
|
||||||
} finally {
|
} finally {
|
||||||
isChecking = false;
|
isChecking = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
scheduleNextCheck();
|
}
|
||||||
}, intervalMs);
|
});
|
||||||
}
|
|
||||||
|
|
||||||
function stopCheckLoop() {
|
|
||||||
checkLoopRunning = false;
|
|
||||||
console.log("Check loop stopped");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load config from storage
|
// Load config from storage
|
||||||
async function loadConfig() {
|
async function loadConfig() {
|
||||||
@@ -100,7 +112,7 @@ async function loadConfig() {
|
|||||||
// Save config to storage
|
// Save config to storage
|
||||||
async function saveConfig() {
|
async function saveConfig() {
|
||||||
await chrome.storage.local.set({ config });
|
await chrome.storage.local.set({ config });
|
||||||
// Restart loop with new interval
|
// Recreate alarm with updated interval
|
||||||
stopCheckLoop();
|
stopCheckLoop();
|
||||||
if (config.enabled) {
|
if (config.enabled) {
|
||||||
startCheckLoop();
|
startCheckLoop();
|
||||||
@@ -121,6 +133,114 @@ async function saveProducts() {
|
|||||||
await chrome.storage.local.set({ knownProducts });
|
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)
|
// Load known SKUs from storage (for API monitoring)
|
||||||
async function loadKnownSkus() {
|
async function loadKnownSkus() {
|
||||||
const stored = await chrome.storage.local.get("knownSkus");
|
const stored = await chrome.storage.local.get("knownSkus");
|
||||||
@@ -219,20 +339,60 @@ async function runStockCheck() {
|
|||||||
return;
|
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...");
|
console.log("Running stock check...");
|
||||||
|
|
||||||
for (const url of config.urls) {
|
for (const url of config.urls) {
|
||||||
try {
|
try {
|
||||||
const products = await fetchAndParseProducts(url);
|
const products = await fetchAndParseProducts(url);
|
||||||
|
|
||||||
// Alert if 0 products found - likely bot protection
|
// Alert if 0 products found — likely captcha or bot protection
|
||||||
if (products.length === 0) {
|
if (products.length === 0) {
|
||||||
console.warn("WARNING: 0 products found - possible bot protection!");
|
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",
|
await sendWarningNotification("Bot Protection Detected",
|
||||||
"Found 0 products - you may need to manually verify you're human on PokemonCenter.com");
|
`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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Successful product load — reset any backoff state
|
||||||
|
resetBotBackoff();
|
||||||
|
|
||||||
const { newProducts, restockedProducts } = processProducts(products);
|
const { newProducts, restockedProducts } = processProducts(products);
|
||||||
|
|
||||||
// Send notifications with rate limiting
|
// Send notifications with rate limiting
|
||||||
@@ -275,37 +435,48 @@ async function runStockCheck() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Fetch and parse products from a URL using content script
|
||||||
async function fetchAndParseProducts(url) {
|
async function fetchAndParseProducts(url) {
|
||||||
return new Promise(async (resolve, reject) => {
|
|
||||||
try {
|
|
||||||
let tabId;
|
let tabId;
|
||||||
let isNewTab = false;
|
try {
|
||||||
|
// Reuse existing tab or create a new one
|
||||||
// Reuse existing tab or create new one
|
|
||||||
if (persistentTabId) {
|
if (persistentTabId) {
|
||||||
try {
|
try {
|
||||||
const tab = await chrome.tabs.get(persistentTabId);
|
await chrome.tabs.get(persistentTabId);
|
||||||
tabId = tab.id;
|
tabId = persistentTabId;
|
||||||
console.log(`Reusing tab ${tabId}, refreshing...`);
|
console.log(`Reusing tab ${tabId}, navigating...`);
|
||||||
await chrome.tabs.update(tabId, { url });
|
await chrome.tabs.update(tabId, { url });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Tab was closed, create new one
|
// Tab is gone — clear it and fall through to create a new one
|
||||||
persistentTabId = null;
|
await clearPersistentTab();
|
||||||
|
tabId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!persistentTabId) {
|
if (!tabId) {
|
||||||
const tab = await chrome.tabs.create({ url, active: false });
|
const tab = await chrome.tabs.create({ url, active: false });
|
||||||
tabId = tab.id;
|
tabId = tab.id;
|
||||||
persistentTabId = tabId;
|
await savePersistentTabId(tabId);
|
||||||
isNewTab = true;
|
|
||||||
console.log(`Created new tab ${tabId}`);
|
console.log(`Created new tab ${tabId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for tab to finish loading
|
// Wait for tab to finish loading
|
||||||
const waitForLoad = () => {
|
await new Promise((res) => {
|
||||||
return new Promise((res) => {
|
|
||||||
const listener = (tid, changeInfo) => {
|
const listener = (tid, changeInfo) => {
|
||||||
if (tid === tabId && changeInfo.status === "complete") {
|
if (tid === tabId && changeInfo.status === "complete") {
|
||||||
chrome.tabs.onUpdated.removeListener(listener);
|
chrome.tabs.onUpdated.removeListener(listener);
|
||||||
@@ -318,26 +489,41 @@ async function fetchAndParseProducts(url) {
|
|||||||
res();
|
res();
|
||||||
}, 20000);
|
}, 20000);
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
await waitForLoad();
|
// 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`);
|
console.log(`Tab ${tabId} loaded`);
|
||||||
|
|
||||||
// Smart wait: poll for products instead of fixed wait
|
// 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 = [];
|
let products = [];
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const maxWait = 8000; // Max 8 seconds
|
const maxWait = 25000;
|
||||||
const pollInterval = 500; // Check every 500ms
|
const pollInterval = 1000; // Reduced polling frequency
|
||||||
|
|
||||||
|
let injected = false;
|
||||||
while (Date.now() - startTime < maxWait) {
|
while (Date.now() - startTime < maxWait) {
|
||||||
try {
|
try {
|
||||||
// Inject content script if needed
|
if (!injected) {
|
||||||
try {
|
try {
|
||||||
await chrome.scripting.executeScript({
|
await chrome.scripting.executeScript({ target: { tabId }, files: ["content.js"] });
|
||||||
target: { tabId },
|
injected = true;
|
||||||
files: ["content.js"]
|
} catch (e) {
|
||||||
});
|
injected = true; // Already injected — treat as done
|
||||||
} catch (e) { /* Already injected */ }
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await new Promise(r => setTimeout(r, pollInterval));
|
await new Promise(r => setTimeout(r, pollInterval));
|
||||||
|
|
||||||
@@ -354,26 +540,30 @@ async function fetchAndParseProducts(url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (products.length === 0) {
|
if (products.length === 0) {
|
||||||
console.log(`No products found after ${maxWait}ms`);
|
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
|
// Apply keyword filter
|
||||||
if (config.keywords && config.keywords.length > 0) {
|
if (config.keywords && config.keywords.length > 0) {
|
||||||
products = products.filter(p => {
|
products = products.filter(p =>
|
||||||
const nameLower = p.name.toLowerCase();
|
config.keywords.some(kw => p.name.toLowerCase().includes(kw.toLowerCase()))
|
||||||
return config.keywords.some(kw => nameLower.includes(kw.toLowerCase()));
|
);
|
||||||
});
|
|
||||||
console.log(`After keyword filter: ${products.length} products`);
|
console.log(`After keyword filter: ${products.length} products`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't close tab - keep for reuse
|
// Keep tab open for reuse next check
|
||||||
resolve(products);
|
return products;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
persistentTabId = null; // Reset on error
|
// On any error, close the tab so we don't leave an untracked orphan
|
||||||
reject(error);
|
await clearPersistentTab();
|
||||||
|
tabId = null;
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: Product parsing is now done by the content script (content.js)
|
// Note: Product parsing is now done by the content script (content.js)
|
||||||
@@ -548,6 +738,7 @@ async function sendDiscordNotification(product, alertType) {
|
|||||||
|
|
||||||
// Track pending events to sync (restocks, new drops)
|
// Track pending events to sync (restocks, new drops)
|
||||||
let pendingEvents = [];
|
let pendingEvents = [];
|
||||||
|
let botProtectionDetected = false;
|
||||||
|
|
||||||
// Sync data to local dashboard
|
// Sync data to local dashboard
|
||||||
async function syncToDashboard() {
|
async function syncToDashboard() {
|
||||||
@@ -566,7 +757,8 @@ async function syncToDashboard() {
|
|||||||
skus: Array.from(knownSkus),
|
skus: Array.from(knownSkus),
|
||||||
products: productsToSync,
|
products: productsToSync,
|
||||||
apiStats: apiStats,
|
apiStats: apiStats,
|
||||||
events: pendingEvents // Include pending events (restocks, new drops)
|
events: pendingEvents,
|
||||||
|
bot_protection_detected: botProtectionDetected
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -579,8 +771,9 @@ async function syncToDashboard() {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
console.log(`[Dashboard Sync] Success - ${result.total_skus} SKUs, ${result.total_products} products`);
|
console.log(`[Dashboard Sync] Success - ${result.total_skus} SKUs, ${result.total_products} products`);
|
||||||
// Clear pending events after successful sync
|
// Clear pending events and flags after successful sync
|
||||||
pendingEvents = [];
|
pendingEvents = [];
|
||||||
|
botProtectionDetected = false;
|
||||||
return { success: true, ...result };
|
return { success: true, ...result };
|
||||||
} else {
|
} else {
|
||||||
console.warn(`[Dashboard Sync] Failed: ${response.status}`);
|
console.warn(`[Dashboard Sync] Failed: ${response.status}`);
|
||||||
@@ -600,12 +793,6 @@ function extractSkuFromUrl(url) {
|
|||||||
return match ? match[1] : null;
|
return match ? match[1] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-sync to dashboard periodically (every 5 minutes)
|
|
||||||
setInterval(() => {
|
|
||||||
if (config.syncToDashboard) {
|
|
||||||
syncToDashboard();
|
|
||||||
}
|
|
||||||
}, 5 * 60 * 1000);
|
|
||||||
|
|
||||||
|
|
||||||
// Listen for messages from popup and content scripts
|
// Listen for messages from popup and content scripts
|
||||||
@@ -648,16 +835,24 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Run initial check after a short delay
|
// Run initial check after a short delay
|
||||||
setTimeout(() => {
|
setTimeout(async () => {
|
||||||
loadConfig().then(() => {
|
await loadConfig();
|
||||||
|
await loadPersistentTabId(); // must complete before any check runs
|
||||||
|
await loadBackoffState();
|
||||||
|
await loadCaptchaTabState();
|
||||||
loadProducts();
|
loadProducts();
|
||||||
loadKnownSkus().then(() => {
|
await loadKnownSkus();
|
||||||
console.log("[API Monitor] Ready - will intercept API calls when you browse Pokemon Center");
|
console.log("[API Monitor] Ready - will intercept API calls when you browse Pokemon Center");
|
||||||
if (config.enabled) {
|
if (config.enabled) {
|
||||||
console.log(`Check interval: ${config.checkIntervalSeconds} seconds`);
|
console.log(`Check interval: ${config.checkIntervalSeconds} seconds`);
|
||||||
runStockCheck();
|
|
||||||
startCheckLoop();
|
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);
|
}, 3000);
|
||||||
|
|||||||
@@ -18,10 +18,14 @@
|
|||||||
if (event.data?.source !== 'pokemon-api-interceptor') return;
|
if (event.data?.source !== 'pokemon-api-interceptor') return;
|
||||||
|
|
||||||
// Forward API data to background script
|
// Forward API data to background script
|
||||||
|
try {
|
||||||
chrome.runtime.sendMessage({
|
chrome.runtime.sendMessage({
|
||||||
type: 'apiData',
|
type: 'apiData',
|
||||||
data: event.data.data
|
data: event.data.data
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
} catch (e) {
|
||||||
|
// Extension context invalidated (tab closing or extension reloaded)
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Inject the interceptor
|
// Inject the interceptor
|
||||||
|
|||||||
@@ -3,10 +3,21 @@ Configuration for Pokemon Stock Monitor
|
|||||||
Update DISCORD_WEBHOOK_URL with your actual webhook URL
|
Update DISCORD_WEBHOOK_URL with your actual webhook URL
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
try:
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
except ImportError:
|
||||||
|
pass # python-dotenv not installed, fall back to env vars or hardcoded values
|
||||||
|
|
||||||
|
def _env(key, default=""):
|
||||||
|
return os.environ.get(key) or default
|
||||||
|
|
||||||
# Discord webhook URL - GET THIS FROM YOUR DISCORD SERVER
|
# Discord webhook URL - GET THIS FROM YOUR DISCORD SERVER
|
||||||
# Server Settings -> Integrations -> Webhooks -> New Webhook -> Copy Webhook URL
|
# Server Settings -> Integrations -> Webhooks -> New Webhook -> Copy Webhook URL
|
||||||
# This is the default/fallback webhook for anything not specifically routed below
|
# This is the default/fallback webhook for anything not specifically routed below
|
||||||
DISCORD_WEBHOOK_URL = "https://discord.com/api/webhooks/1488982276455796756/4HEzQDBF-Hs4a9Iyd7yvj7-SfP0BwrbQGdGBmQ9TL9urWzkTRaHelkvrqX4v9NTf2ZJ_"
|
# Set DISCORD_WEBHOOK_DEFAULT in .env to override
|
||||||
|
DISCORD_WEBHOOK_URL = _env("DISCORD_WEBHOOK_DEFAULT", "https://discord.com/api/webhooks/1488982276455796756/4HEzQDBF-Hs4a9Iyd7yvj7-SfP0BwrbQGdGBmQ9TL9urWzkTRaHelkvrqX4v9NTf2ZJ_")
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# MULTI-CHANNEL WEBHOOK ROUTING (Optional)
|
# MULTI-CHANNEL WEBHOOK ROUTING (Optional)
|
||||||
@@ -21,24 +32,26 @@ DISCORD_WEBHOOK_URL = "https://discord.com/api/webhooks/1488982276455796756/4HEz
|
|||||||
# 2. sites (all alerts for a specific store)
|
# 2. sites (all alerts for a specific store)
|
||||||
# 3. types (all alerts of a specific type)
|
# 3. types (all alerts of a specific type)
|
||||||
# 4. default (DISCORD_WEBHOOK_URL)
|
# 4. default (DISCORD_WEBHOOK_URL)
|
||||||
|
#
|
||||||
|
# Override any value by setting the corresponding env var in .env
|
||||||
|
|
||||||
DISCORD_WEBHOOKS = {
|
DISCORD_WEBHOOKS = {
|
||||||
# Per-site channels - all alerts from a specific store go here first
|
# Per-site channels - all alerts from a specific store go here first
|
||||||
"sites": {
|
"sites": {
|
||||||
"target": "https://discord.com/api/webhooks/1488982720901025852/O0KkVFHrQ6-k6FmCqQE4QpuWF6YFJ8YyFgojBPKcQu9g0UHzTpnAU9G1gbhOftARS11R",
|
"target": _env("DISCORD_WEBHOOK_TARGET", "https://discord.com/api/webhooks/1488982720901025852/O0KkVFHrQ6-k6FmCqQE4QpuWF6YFJ8YyFgojBPKcQu9g0UHzTpnAU9G1gbhOftARS11R") or None,
|
||||||
"bestbuy": "https://discord.com/api/webhooks/1488982786781216798/AtG6x7FWzJjOsmosq9oPMuwlPrSwNHZJJTUuB3AApjyT7LIOxmEcYeiMFP7HiA_Ov1pn",
|
"bestbuy": _env("DISCORD_WEBHOOK_BESTBUY", "https://discord.com/api/webhooks/1488982786781216798/AtG6x7FWzJjOsmosq9oPMuwlPrSwNHZJJTUuB3AApjyT7LIOxmEcYeiMFP7HiA_Ov1pn") or None,
|
||||||
"gamestop": "https://discord.com/api/webhooks/1488982854036619355/NWZtydrKEhj-kxKbFi78f1Gpf3h16W_1kAJEcSuIgtjJUoAO8Sk7inOI2T8fb9GXo9xY",
|
"gamestop": _env("DISCORD_WEBHOOK_GAMESTOP", "https://discord.com/api/webhooks/1488982854036619355/NWZtydrKEhj-kxKbFi78f1Gpf3h16W_1kAJEcSuIgtjJUoAO8Sk7inOI2T8fb9GXo9xY") or None,
|
||||||
"walmart": "https://discord.com/api/webhooks/1488982929450340375/0g16tJXw8f9UtUrRrLd2jDPWWD__LTDYO_X2VT2WL4F5GBYB8ykVxrjV-bCjzKKjSA5C",
|
"walmart": _env("DISCORD_WEBHOOK_WALMART", "https://discord.com/api/webhooks/1488982929450340375/0g16tJXw8f9UtUrRrLd2jDPWWD__LTDYO_X2VT2WL4F5GBYB8ykVxrjV-bCjzKKjSA5C") or None,
|
||||||
"pokemoncenter": "https://discord.com/api/webhooks/1488983050090975364/DwY9QQvBZa6V-zz8mIg302sukbISSbkXOhDwe1Q6yDG0zAwvaGQcn8iTzNB4D676e3cA",
|
"pokemoncenter": _env("DISCORD_WEBHOOK_POKEMONCENTER", "https://discord.com/api/webhooks/1488983050090975364/DwY9QQvBZa6V-zz8mIg302sukbISSbkXOhDwe1Q6yDG0zAwvaGQcn8iTzNB4D676e3cA") or None,
|
||||||
},
|
},
|
||||||
|
|
||||||
# Per-type channels - used when no site-specific webhook is set
|
# Per-type channels - used when no site-specific webhook is set
|
||||||
"types": {
|
"types": {
|
||||||
"restock": "https://discord.com/api/webhooks/1488982403048542269/s1J_p8R6IMGQGypPh_ZYTHHBD0cMnd_BjLGggVWaL2OcyDQAR42KS-CyuHcVr5s21vl3",
|
"restock": _env("DISCORD_WEBHOOK_RESTOCK", "https://discord.com/api/webhooks/1488982403048542269/s1J_p8R6IMGQGypPh_ZYTHHBD0cMnd_BjLGggVWaL2OcyDQAR42KS-CyuHcVr5s21vl3") or None,
|
||||||
"new_drop": "https://discord.com/api/webhooks/1488982511613771898/RLDoHxNjsAx0PfHfT0kZe6-IW3g9PR_ckX_sI-CALcv4Lt0USslvI0rqePqW9gVP7D7v",
|
"new_drop": _env("DISCORD_WEBHOOK_NEW_DROP", "https://discord.com/api/webhooks/1488982511613771898/RLDoHxNjsAx0PfHfT0kZe6-IW3g9PR_ckX_sI-CALcv4Lt0USslvI0rqePqW9gVP7D7v") or None,
|
||||||
"price_change": "https://discord.com/api/webhooks/1488982573723025633/J864UL4vk_q9Rmj4i4wtkGfzB3nKAOc0y7jzBFDgUbhwg4XKm1Z7sWduqRc6x_ThWetc",
|
"price_change": _env("DISCORD_WEBHOOK_PRICE_CHANGE", "https://discord.com/api/webhooks/1488982573723025633/J864UL4vk_q9Rmj4i4wtkGfzB3nKAOc0y7jzBFDgUbhwg4XKm1Z7sWduqRc6x_ThWetc") or None,
|
||||||
"error": "https://discord.com/api/webhooks/1488983128641900657/RP5FRSWwbVaRAf7RdXPs66ETbY8ziI1ekIx7lrHqxp8n200KpzUiX_EylCxXBH5nJD3B",
|
"error": _env("DISCORD_WEBHOOK_ERROR", "https://discord.com/api/webhooks/1488983128641900657/RP5FRSWwbVaRAf7RdXPs66ETbY8ziI1ekIx7lrHqxp8n200KpzUiX_EylCxXBH5nJD3B") or None,
|
||||||
"captcha": "https://discord.com/api/webhooks/1488983128641900657/RP5FRSWwbVaRAf7RdXPs66ETbY8ziI1ekIx7lrHqxp8n200KpzUiX_EylCxXBH5nJD3B",
|
"captcha": _env("DISCORD_WEBHOOK_ERROR", "https://discord.com/api/webhooks/1488983128641900657/RP5FRSWwbVaRAf7RdXPs66ETbY8ziI1ekIx7lrHqxp8n200KpzUiX_EylCxXBH5nJD3B") or None,
|
||||||
},
|
},
|
||||||
|
|
||||||
# Override specific site + type combinations (highest priority)
|
# Override specific site + type combinations (highest priority)
|
||||||
@@ -62,7 +75,8 @@ RESTOCK_COOLDOWN_HOURS = 6 # Minimum hours between restock alerts for same prod
|
|||||||
# Discord Bot Token (for interactive bot commands)
|
# Discord Bot Token (for interactive bot commands)
|
||||||
# Create a bot at https://discord.com/developers/applications
|
# Create a bot at https://discord.com/developers/applications
|
||||||
# Required for !setlocation, !stores, !stock commands
|
# Required for !setlocation, !stores, !stock commands
|
||||||
DISCORD_BOT_TOKEN = "MTQ4NjQ2ODM2NDkyMTczNzM3OQ.Gxl8pq.wIvK2eVbp1G0SJhW4XTYD3zopR1gohaNv5sxlQ"
|
# Set DISCORD_BOT_TOKEN in .env to override
|
||||||
|
DISCORD_BOT_TOKEN = _env("DISCORD_BOT_TOKEN", "MTQ4NjQ2ODM2NDkyMTczNzM3OQ.Gxl8pq.wIvK2eVbp1G0SJhW4XTYD3zopR1gohaNv5sxlQ")
|
||||||
|
|
||||||
# How often to check each site (in seconds)
|
# How often to check each site (in seconds)
|
||||||
CHECK_INTERVAL_SECONDS = 60
|
CHECK_INTERVAL_SECONDS = 60
|
||||||
@@ -83,7 +97,7 @@ SORT_BY_NEWEST = True
|
|||||||
# Which sites to monitor
|
# Which sites to monitor
|
||||||
# Note: PokemonCenter has strong bot protection (Imperva) - may need manual workarounds
|
# Note: PokemonCenter has strong bot protection (Imperva) - may need manual workarounds
|
||||||
# Note: Walmart has aggressive bot protection (PerimeterX) - may need stealth mode
|
# Note: Walmart has aggressive bot protection (PerimeterX) - may need stealth mode
|
||||||
SITES_ENABLED = ["target", "bestbuy", "gamestop"] # Options: "pokemoncenter", "target", "gamestop", "bestbuy" (disabled - needs rework), "walmart"
|
SITES_ENABLED = ["target", "gamestop", "bestbuy"] # Options: "pokemoncenter", "target", "gamestop", "bestbuy" (disabled - needs rework), "walmart"
|
||||||
|
|
||||||
# PokemonCenter URLs to monitor
|
# PokemonCenter URLs to monitor
|
||||||
POKEMON_CENTER_URLS = [
|
POKEMON_CENTER_URLS = [
|
||||||
@@ -146,6 +160,8 @@ USE_STEALTH_BROWSER = True
|
|||||||
|
|
||||||
# Stealth browser settings
|
# Stealth browser settings
|
||||||
STEALTH_HEADLESS = True # Set True for headless/no-display environments
|
STEALTH_HEADLESS = True # Set True for headless/no-display environments
|
||||||
|
GAMESTOP_HEADLESS = False # GameStop (Cloudflare) hard-blocks headless Chrome - must stay False
|
||||||
|
BESTBUY_HEADLESS = False # BestBuy detects headless and blocks GraphQL product loading - must stay False
|
||||||
STEALTH_SESSION_NAME = "pokemoncenter" # Name for cookie/session persistence
|
STEALTH_SESSION_NAME = "pokemoncenter" # Name for cookie/session persistence
|
||||||
|
|
||||||
# Human-like behavior settings
|
# Human-like behavior settings
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from flask import Blueprint, jsonify, request
|
|||||||
from src.database import get_database
|
from src.database import get_database
|
||||||
from src.favorites import get_favorites_manager
|
from src.favorites import get_favorites_manager
|
||||||
from src.scraper_state import scraper_state
|
from src.scraper_state import scraper_state
|
||||||
|
from src.discord_notifier import send_error_notification
|
||||||
|
|
||||||
api_bp = Blueprint('api', __name__)
|
api_bp = Blueprint('api', __name__)
|
||||||
|
|
||||||
@@ -796,6 +797,14 @@ def sync_extension_data():
|
|||||||
db.update_product_price(product_id, new_price)
|
db.update_product_price(product_id, new_price)
|
||||||
events_processed += 1
|
events_processed += 1
|
||||||
|
|
||||||
|
# Check for bot protection - extension explicitly reports when a check returned 0 products
|
||||||
|
if data.get('bot_protection_detected'):
|
||||||
|
send_error_notification(
|
||||||
|
"Pokemon Center extension detected possible bot protection — 0 products found on last check. "
|
||||||
|
"Check the extension tab manually and verify you can browse PokemonCenter.com.",
|
||||||
|
site="pokemoncenter"
|
||||||
|
)
|
||||||
|
|
||||||
# Update API stats
|
# Update API stats
|
||||||
if 'apiStats' in data:
|
if 'apiStats' in data:
|
||||||
extension_data['api_stats'] = data['apiStats']
|
extension_data['api_stats'] = data['apiStats']
|
||||||
|
|||||||
@@ -878,7 +878,7 @@ function renderScrapersGrid(scrapers) {
|
|||||||
data.enabled ? '<span class="status-enabled">● Enabled</span>' :
|
data.enabled ? '<span class="status-enabled">● Enabled</span>' :
|
||||||
'<span class="status-disabled">○ Disabled</span>'}
|
'<span class="status-disabled">○ Disabled</span>'}
|
||||||
</div>
|
</div>
|
||||||
${data.last_run ? `<div class="scraper-last-run">Last: ${formatTime(new Date(data.last_run))}</div>` : ''}
|
<div class="scraper-last-run">Last: ${data.last_run ? formatTime(new Date(data.last_run)) : 'Never'}</div>
|
||||||
${data.last_error ? `<div class="scraper-error">${data.last_error}</div>` : ''}
|
${data.last_error ? `<div class="scraper-error">${data.last_error}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<label class="toggle-switch">
|
<label class="toggle-switch">
|
||||||
|
|||||||
+607
-281
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -28,6 +28,7 @@ from config import (
|
|||||||
from src.browser import get_browser, shutdown_browser
|
from src.browser import get_browser, shutdown_browser
|
||||||
from src.product_tracker import ProductTracker
|
from src.product_tracker import ProductTracker
|
||||||
from src.discord_notifier import send_stock_alert, send_startup_notification, send_error_notification, send_price_change_alert
|
from src.discord_notifier import send_stock_alert, send_startup_notification, send_error_notification, send_price_change_alert
|
||||||
|
from src.scraper_state import scraper_state
|
||||||
# Note: Pokemon Center uses Chrome Extension (chrome-extension/), not a Python scraper
|
# Note: Pokemon Center uses Chrome Extension (chrome-extension/), not a Python scraper
|
||||||
from scrapers import (
|
from scrapers import (
|
||||||
TargetScraper,
|
TargetScraper,
|
||||||
@@ -139,6 +140,7 @@ def check_target():
|
|||||||
# Log successful check to database
|
# Log successful check to database
|
||||||
duration_ms = int((time.time() - start_time) * 1000)
|
duration_ms = int((time.time() - start_time) * 1000)
|
||||||
tracker.log_check("target", total_products, total_new, total_restocks, duration_ms, success=True)
|
tracker.log_check("target", total_products, total_new, total_restocks, duration_ms, success=True)
|
||||||
|
scraper_state.record_check("target", success=True)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error checking Target: {e}")
|
logger.error(f"Error checking Target: {e}")
|
||||||
@@ -146,6 +148,7 @@ def check_target():
|
|||||||
# Log failed check to database
|
# Log failed check to database
|
||||||
duration_ms = int((time.time() - start_time) * 1000)
|
duration_ms = int((time.time() - start_time) * 1000)
|
||||||
tracker.log_check("target", total_products, total_new, total_restocks, duration_ms, success=False, error_message=str(e))
|
tracker.log_check("target", total_products, total_new, total_restocks, duration_ms, success=False, error_message=str(e))
|
||||||
|
scraper_state.record_check("target", success=False, error=str(e))
|
||||||
|
|
||||||
|
|
||||||
def check_gamestop():
|
def check_gamestop():
|
||||||
@@ -228,6 +231,7 @@ def check_gamestop():
|
|||||||
# Log successful check to database
|
# Log successful check to database
|
||||||
duration_ms = int((time.time() - start_time) * 1000)
|
duration_ms = int((time.time() - start_time) * 1000)
|
||||||
tracker.log_check("gamestop", total_products, total_new, total_restocks, duration_ms, success=True)
|
tracker.log_check("gamestop", total_products, total_new, total_restocks, duration_ms, success=True)
|
||||||
|
scraper_state.record_check("gamestop", success=True)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error checking GameStop: {e}")
|
logger.error(f"Error checking GameStop: {e}")
|
||||||
@@ -235,6 +239,7 @@ def check_gamestop():
|
|||||||
# Log failed check to database
|
# Log failed check to database
|
||||||
duration_ms = int((time.time() - start_time) * 1000)
|
duration_ms = int((time.time() - start_time) * 1000)
|
||||||
tracker.log_check("gamestop", total_products, total_new, total_restocks, duration_ms, success=False, error_message=str(e))
|
tracker.log_check("gamestop", total_products, total_new, total_restocks, duration_ms, success=False, error_message=str(e))
|
||||||
|
scraper_state.record_check("gamestop", success=False, error=str(e))
|
||||||
|
|
||||||
|
|
||||||
def check_bestbuy():
|
def check_bestbuy():
|
||||||
@@ -317,6 +322,7 @@ def check_bestbuy():
|
|||||||
# Log successful check to database
|
# Log successful check to database
|
||||||
duration_ms = int((time.time() - start_time) * 1000)
|
duration_ms = int((time.time() - start_time) * 1000)
|
||||||
tracker.log_check("bestbuy", total_products, total_new, total_restocks, duration_ms, success=True)
|
tracker.log_check("bestbuy", total_products, total_new, total_restocks, duration_ms, success=True)
|
||||||
|
scraper_state.record_check("bestbuy", success=True)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error checking Best Buy: {e}")
|
logger.error(f"Error checking Best Buy: {e}")
|
||||||
@@ -324,6 +330,7 @@ def check_bestbuy():
|
|||||||
# Log failed check to database
|
# Log failed check to database
|
||||||
duration_ms = int((time.time() - start_time) * 1000)
|
duration_ms = int((time.time() - start_time) * 1000)
|
||||||
tracker.log_check("bestbuy", total_products, total_new, total_restocks, duration_ms, success=False, error_message=str(e))
|
tracker.log_check("bestbuy", total_products, total_new, total_restocks, duration_ms, success=False, error_message=str(e))
|
||||||
|
scraper_state.record_check("bestbuy", success=False, error=str(e))
|
||||||
|
|
||||||
|
|
||||||
def check_walmart():
|
def check_walmart():
|
||||||
@@ -406,6 +413,7 @@ def check_walmart():
|
|||||||
# Log successful check to database
|
# Log successful check to database
|
||||||
duration_ms = int((time.time() - start_time) * 1000)
|
duration_ms = int((time.time() - start_time) * 1000)
|
||||||
tracker.log_check("walmart", total_products, total_new, total_restocks, duration_ms, success=True)
|
tracker.log_check("walmart", total_products, total_new, total_restocks, duration_ms, success=True)
|
||||||
|
scraper_state.record_check("walmart", success=True)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error checking Walmart: {e}")
|
logger.error(f"Error checking Walmart: {e}")
|
||||||
@@ -413,6 +421,7 @@ def check_walmart():
|
|||||||
# Log failed check to database
|
# Log failed check to database
|
||||||
duration_ms = int((time.time() - start_time) * 1000)
|
duration_ms = int((time.time() - start_time) * 1000)
|
||||||
tracker.log_check("walmart", total_products, total_new, total_restocks, duration_ms, success=False, error_message=str(e))
|
tracker.log_check("walmart", total_products, total_new, total_restocks, duration_ms, success=False, error_message=str(e))
|
||||||
|
scraper_state.record_check("walmart", success=False, error=str(e))
|
||||||
|
|
||||||
|
|
||||||
def run_checks():
|
def run_checks():
|
||||||
@@ -440,6 +449,11 @@ def graceful_shutdown(signum, frame):
|
|||||||
"""Handle shutdown gracefully"""
|
"""Handle shutdown gracefully"""
|
||||||
logger.info("Shutting down...")
|
logger.info("Shutting down...")
|
||||||
shutdown_browser()
|
shutdown_browser()
|
||||||
|
scraper_state.state["monitor_running"] = False
|
||||||
|
scraper_state.state["monitor_pid"] = None
|
||||||
|
for site in scraper_state.state["scrapers"]:
|
||||||
|
scraper_state.state["scrapers"][site]["running"] = False
|
||||||
|
scraper_state._save_state()
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
@@ -469,6 +483,15 @@ def main():
|
|||||||
print(" playwright install chromium")
|
print(" playwright install chromium")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Mark monitor as running in state file
|
||||||
|
import os
|
||||||
|
scraper_state.state["monitor_running"] = True
|
||||||
|
scraper_state.state["monitor_pid"] = os.getpid()
|
||||||
|
for site in SITES_ENABLED:
|
||||||
|
if site in scraper_state.state["scrapers"]:
|
||||||
|
scraper_state.state["scrapers"][site]["running"] = True
|
||||||
|
scraper_state._save_state()
|
||||||
|
|
||||||
# Send startup notification
|
# Send startup notification
|
||||||
send_startup_notification()
|
send_startup_notification()
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
playwright>=1.45.0
|
playwright>=1.45.0
|
||||||
|
python-dotenv>=1.0.0
|
||||||
playwright-stealth>=1.0.6
|
playwright-stealth>=1.0.6
|
||||||
requests>=2.31.0
|
requests>=2.31.0
|
||||||
schedule>=1.2.1
|
schedule>=1.2.1
|
||||||
|
|||||||
+138
-47
@@ -12,6 +12,7 @@ from typing import List, Optional
|
|||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
from .base import BaseScraper, Product
|
from .base import BaseScraper, Product
|
||||||
|
from config import BESTBUY_HEADLESS
|
||||||
|
|
||||||
# Add parent directory to path for tools import
|
# Add parent directory to path for tools import
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
@@ -37,7 +38,7 @@ class BestBuyScraper(BaseScraper):
|
|||||||
# Check if we need to create a new browser
|
# Check if we need to create a new browser
|
||||||
if self._stealth_browser is None:
|
if self._stealth_browser is None:
|
||||||
logger.info("Creating new Best Buy stealth browser...")
|
logger.info("Creating new Best Buy stealth browser...")
|
||||||
self._stealth_browser = StealthBrowser(headless=False, session_name="bestbuy")
|
self._stealth_browser = StealthBrowser(headless=BESTBUY_HEADLESS, session_name="bestbuy")
|
||||||
self._stealth_browser.start()
|
self._stealth_browser.start()
|
||||||
self._restart_attempts = 0
|
self._restart_attempts = 0
|
||||||
return self._stealth_browser
|
return self._stealth_browser
|
||||||
@@ -63,7 +64,11 @@ class BestBuyScraper(BaseScraper):
|
|||||||
Returns True if browser was restarted and operation should be retried.
|
Returns True if browser was restarted and operation should be retried.
|
||||||
"""
|
"""
|
||||||
error_msg = str(error).lower()
|
error_msg = str(error).lower()
|
||||||
session_errors = ["invalid session id", "session deleted", "no such session", "browser has closed"]
|
session_errors = [
|
||||||
|
"invalid session id", "session deleted", "no such session", "browser has closed",
|
||||||
|
"newconnectionerror", "connection refused", "winerror 10061",
|
||||||
|
"failed to establish a new connection", "max retries exceeded",
|
||||||
|
]
|
||||||
|
|
||||||
if any(err in error_msg for err in session_errors):
|
if any(err in error_msg for err in session_errors):
|
||||||
logger.warning(f"Session error detected: {error}")
|
logger.warning(f"Session error detected: {error}")
|
||||||
@@ -90,11 +95,6 @@ class BestBuyScraper(BaseScraper):
|
|||||||
"""
|
"""
|
||||||
all_products = []
|
all_products = []
|
||||||
|
|
||||||
# Add sort by newest if not already in URL
|
|
||||||
if "sort=" not in url:
|
|
||||||
separator = "&" if "?" in url else "?"
|
|
||||||
url = f"{url}{separator}sp=-releasedate"
|
|
||||||
|
|
||||||
# Use stealth browser
|
# Use stealth browser
|
||||||
try:
|
try:
|
||||||
browser = self._get_stealth_browser()
|
browser = self._get_stealth_browser()
|
||||||
@@ -113,22 +113,26 @@ class BestBuyScraper(BaseScraper):
|
|||||||
try:
|
try:
|
||||||
logger.info(f"Navigating to: {page_url}")
|
logger.info(f"Navigating to: {page_url}")
|
||||||
browser.driver.get(page_url)
|
browser.driver.get(page_url)
|
||||||
time.sleep(8) # Wait longer for initial page load
|
time.sleep(15) # Wait for React hydration and GraphQL fetches to complete
|
||||||
|
|
||||||
# Scroll for lazy loading - wait longer between scrolls
|
# Scroll to trigger any remaining lazy-loaded products
|
||||||
browser.driver.execute_script("window.scrollTo(0, 500)")
|
scroll_pause = 1.5
|
||||||
|
scroll_step = 800
|
||||||
|
current_pos = 0
|
||||||
|
for _ in range(20):
|
||||||
|
current_pos += scroll_step
|
||||||
|
browser.driver.execute_script(f"window.scrollTo(0, {current_pos})")
|
||||||
|
time.sleep(scroll_pause)
|
||||||
|
page_height = browser.driver.execute_script("return document.body.scrollHeight")
|
||||||
|
if current_pos >= page_height:
|
||||||
|
break
|
||||||
|
browser.driver.execute_script("window.scrollTo(0, 0)")
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
browser.driver.execute_script("window.scrollTo(0, 1500)")
|
|
||||||
time.sleep(2)
|
|
||||||
browser.driver.execute_script("window.scrollTo(0, 3000)")
|
|
||||||
time.sleep(2)
|
|
||||||
browser.driver.execute_script("window.scrollTo(0, 0)") # Scroll back to top
|
|
||||||
time.sleep(3)
|
|
||||||
|
|
||||||
# Wait for products to load (check for content)
|
# Wait for products to load (check for content)
|
||||||
for _ in range(10):
|
for _ in range(10):
|
||||||
html = browser.driver.page_source
|
html = browser.driver.page_source
|
||||||
if "sku-item" in html or "sku-title" in html or "priceView" in html:
|
if any(kw in html for kw in ["sku-item", "sku-title", "priceView", "product-list-item", "ApolloSSRDataTransport", '"pdp":']):
|
||||||
logger.info("Product content detected")
|
logger.info("Product content detected")
|
||||||
break
|
break
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
@@ -136,10 +140,12 @@ class BestBuyScraper(BaseScraper):
|
|||||||
# Get HTML after waiting
|
# Get HTML after waiting
|
||||||
html = browser.driver.page_source
|
html = browser.driver.page_source
|
||||||
|
|
||||||
# Save debug screenshot
|
# Save debug screenshot and HTML for analysis
|
||||||
try:
|
try:
|
||||||
browser.driver.save_screenshot("debug_bestbuy.png")
|
browser.driver.save_screenshot("debug_bestbuy.png")
|
||||||
logger.info("Saved debug screenshot to debug_bestbuy.png")
|
with open("debug_bestbuy.html", "w", encoding="utf-8") as f:
|
||||||
|
f.write(html)
|
||||||
|
logger.info("Saved debug screenshot and HTML to debug_bestbuy.*")
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -274,6 +280,45 @@ class BestBuyScraper(BaseScraper):
|
|||||||
logger.info(f"Scraped {len(unique_products)} unique products from Best Buy")
|
logger.info(f"Scraped {len(unique_products)} unique products from Best Buy")
|
||||||
return unique_products
|
return unique_products
|
||||||
|
|
||||||
|
def _extract_from_graphql_response(self, data: dict) -> List[Product]:
|
||||||
|
"""Extract products from BestBuy's intercepted GraphQL search response"""
|
||||||
|
products = []
|
||||||
|
try:
|
||||||
|
search = data.get('searchResult') or data.get('searchResults') or {}
|
||||||
|
items = search.get('products') or search.get('items') or []
|
||||||
|
logger.info(f"GraphQL response has {len(items)} products")
|
||||||
|
for item in items:
|
||||||
|
product = item.get('product') or item
|
||||||
|
name = (product.get('name') or {}).get('short') or (product.get('name') or {}).get('title') or product.get('displayName') or ''
|
||||||
|
if not name or len(name) < 5:
|
||||||
|
continue
|
||||||
|
sku_id = str(product.get('skuId') or '')
|
||||||
|
url_obj = product.get('url') or {}
|
||||||
|
url = url_obj.get('skuSpecificUrl') or url_obj.get('pdp') or (f"{self.base_url}/site/{sku_id}.p" if sku_id else None)
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
image = (product.get('primaryImage') or {}).get('piscesHref')
|
||||||
|
price = None
|
||||||
|
price_info = product.get('priceInfo') or product.get('price') or {}
|
||||||
|
if isinstance(price_info, dict):
|
||||||
|
raw = price_info.get('currentPrice') or price_info.get('regularPrice')
|
||||||
|
if isinstance(raw, (int, float)):
|
||||||
|
price = f"${raw:.2f}"
|
||||||
|
# Check stock via button state
|
||||||
|
in_stock = True
|
||||||
|
fulfillment = product.get('fulfillmentOptions') or {}
|
||||||
|
btn_states = fulfillment.get('buttonStates') or []
|
||||||
|
if btn_states:
|
||||||
|
state = btn_states[0].get('buttonState', '')
|
||||||
|
in_stock = state in ('ADD_TO_CART', 'PRE_ORDER', 'CHECK_STORES')
|
||||||
|
products.append(Product(
|
||||||
|
name=name, url=url, price=price, in_stock=in_stock,
|
||||||
|
image_url=image, site=self.site_name, product_id=sku_id,
|
||||||
|
))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error parsing GraphQL response: {e}")
|
||||||
|
return products
|
||||||
|
|
||||||
def _extract_from_page_data(self, soup, browser) -> List[Product]:
|
def _extract_from_page_data(self, soup, browser) -> List[Product]:
|
||||||
"""Extract products from page data attributes and evaluate JS if needed"""
|
"""Extract products from page data attributes and evaluate JS if needed"""
|
||||||
products = []
|
products = []
|
||||||
@@ -298,7 +343,58 @@ class BestBuyScraper(BaseScraper):
|
|||||||
except (json.JSONDecodeError, TypeError) as e:
|
except (json.JSONDecodeError, TypeError) as e:
|
||||||
logger.debug(f"Could not parse __NEXT_DATA__ from HTML: {e}")
|
logger.debug(f"Could not parse __NEXT_DATA__ from HTML: {e}")
|
||||||
|
|
||||||
# Method 3: Try multiple JS state sources via browser execution
|
# Method 3: Extract from live Apollo client cache (captures client-side loaded products)
|
||||||
|
# BestBuy SSRs only ~4 featured products; the rest load via client-side GraphQL.
|
||||||
|
# The Apollo client cache holds all of them after hydration.
|
||||||
|
apollo_cache_script = """
|
||||||
|
try {
|
||||||
|
var client = window.__APOLLO_CLIENT__;
|
||||||
|
if (!client) return null;
|
||||||
|
var cache = client.cache.extract();
|
||||||
|
var products = [];
|
||||||
|
for (var key in cache) {
|
||||||
|
var obj = cache[key];
|
||||||
|
if (obj && obj.__typename === 'Product' && obj.skuId && obj.name && obj.name.short) {
|
||||||
|
products.push({
|
||||||
|
skuId: obj.skuId,
|
||||||
|
name: obj.name.short || obj.name.title || '',
|
||||||
|
pdp: obj.url ? obj.url.pdp || obj.url.skuSpecificUrl : null,
|
||||||
|
price: obj.priceAndAvailabilityInfo ? obj.priceAndAvailabilityInfo.currentPrice : null,
|
||||||
|
inStock: obj.fulfillmentOptions ? !!(obj.fulfillmentOptions.buttonStates || []).find(function(b){ return b.buttonState === 'ADD_TO_CART'; }) : true,
|
||||||
|
image: obj.primaryImage ? obj.primaryImage.piscesHref : null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return products.length > 0 ? JSON.stringify(products) : null;
|
||||||
|
} catch(e) { return null; }
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
apollo_json = browser.driver.execute_script(apollo_cache_script)
|
||||||
|
if apollo_json:
|
||||||
|
apollo_products = json.loads(apollo_json)
|
||||||
|
logger.info(f"Extracted {len(apollo_products)} products from Apollo client cache")
|
||||||
|
for item in apollo_products:
|
||||||
|
if not item.get('name') or len(item.get('name', '')) < 5:
|
||||||
|
continue
|
||||||
|
sku_id = str(item.get('skuId', ''))
|
||||||
|
pdp = item.get('pdp')
|
||||||
|
url = pdp if pdp else (f"{self.base_url}/site/{sku_id}.p" if sku_id else None)
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
price = item.get('price')
|
||||||
|
products.append(Product(
|
||||||
|
name=item['name'],
|
||||||
|
url=url,
|
||||||
|
price=f"${price:.2f}" if isinstance(price, (int, float)) else str(price) if price else None,
|
||||||
|
in_stock=item.get('inStock', True),
|
||||||
|
image_url=item.get('image'),
|
||||||
|
site=self.site_name,
|
||||||
|
product_id=sku_id,
|
||||||
|
))
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not extract from Apollo client cache: {e}")
|
||||||
|
|
||||||
|
# Method 4: Try multiple JS state sources via browser execution
|
||||||
if not products:
|
if not products:
|
||||||
state_scripts = [
|
state_scripts = [
|
||||||
"return window.__NEXT_DATA__ ? JSON.stringify(window.__NEXT_DATA__) : null",
|
"return window.__NEXT_DATA__ ? JSON.stringify(window.__NEXT_DATA__) : null",
|
||||||
@@ -428,21 +524,18 @@ class BestBuyScraper(BaseScraper):
|
|||||||
if image_matches:
|
if image_matches:
|
||||||
image_url = image_matches[0]
|
image_url = image_matches[0]
|
||||||
|
|
||||||
# Try to determine stock status from Apollo data context
|
# Determine stock status by anchoring to the SKU ID in the Apollo data,
|
||||||
in_stock = True # Default to True if no stock info found
|
# then searching for buttonState within 500 chars after it.
|
||||||
if url_pos > 0:
|
# This is more reliable than looking near the URL (which bleeds across products).
|
||||||
# Look for availability info near this product's URL
|
in_stock = True
|
||||||
context_start = max(0, url_pos - 3000)
|
if sku_id:
|
||||||
context_end = min(len(script_content), url_pos + 500)
|
sku_anchor = f'"skuId":"{sku_id}"'
|
||||||
product_context = script_content[context_start:context_end]
|
sku_pos = script_content.find(sku_anchor)
|
||||||
|
if sku_pos >= 0:
|
||||||
# Check for explicit out of stock indicators
|
sku_context = script_content[sku_pos:sku_pos + 500]
|
||||||
if '"isAvailable":false' in product_context or '"available":false' in product_context:
|
btn_match = re.search(r'"buttonState":"([^"]+)"', sku_context)
|
||||||
in_stock = False
|
if btn_match:
|
||||||
elif '"soldOut":true' in product_context or '"outOfStock":true' in product_context:
|
in_stock = btn_match.group(1) in ("ADD_TO_CART", "PRE_ORDER", "CHECK_STORES")
|
||||||
in_stock = False
|
|
||||||
elif 'sold out' in product_context.lower() or 'out of stock' in product_context.lower():
|
|
||||||
in_stock = False
|
|
||||||
|
|
||||||
if name and len(name) > 5:
|
if name and len(name) > 5:
|
||||||
products.append(Product(
|
products.append(Product(
|
||||||
@@ -789,12 +882,14 @@ class BestBuyScraper(BaseScraper):
|
|||||||
break
|
break
|
||||||
parent = parent.find_parent()
|
parent = parent.find_parent()
|
||||||
|
|
||||||
# Check stock
|
# Check stock — "add to cart" takes priority over "unavailable"
|
||||||
in_stock = True
|
in_stock = True
|
||||||
parent = link.find_parent()
|
parent = link.find_parent()
|
||||||
for _ in range(5):
|
for _ in range(5):
|
||||||
if parent:
|
if parent:
|
||||||
text = parent.get_text().lower()
|
text = parent.get_text().lower()
|
||||||
|
if any(phrase in text for phrase in ["add to cart", "add to bag", "pre-order"]):
|
||||||
|
break # purchasable — in_stock stays True
|
||||||
if any(phrase in text for phrase in ["sold out", "out of stock", "unavailable"]):
|
if any(phrase in text for phrase in ["sold out", "out of stock", "unavailable"]):
|
||||||
in_stock = False
|
in_stock = False
|
||||||
break
|
break
|
||||||
@@ -818,6 +913,13 @@ class BestBuyScraper(BaseScraper):
|
|||||||
"""Check if a product card indicates in-stock status"""
|
"""Check if a product card indicates in-stock status"""
|
||||||
card_text = card.get_text().lower() if hasattr(card, "get_text") else str(card).lower()
|
card_text = card.get_text().lower() if hasattr(card, "get_text") else str(card).lower()
|
||||||
|
|
||||||
|
# "Add to Cart" takes priority — a card can show "Unavailable" for in-store/delivery
|
||||||
|
# while still being purchasable online, so check purchasable state first.
|
||||||
|
if "add to cart" in card_text or "add to bag" in card_text:
|
||||||
|
return True
|
||||||
|
if "pre-order" in card_text:
|
||||||
|
return True
|
||||||
|
|
||||||
# Check for disabled add to cart button
|
# Check for disabled add to cart button
|
||||||
add_btn = card.select_one(".add-to-cart-button")
|
add_btn = card.select_one(".add-to-cart-button")
|
||||||
if add_btn and "btn-disabled" in add_btn.get("class", []):
|
if add_btn and "btn-disabled" in add_btn.get("class", []):
|
||||||
@@ -835,17 +937,6 @@ class BestBuyScraper(BaseScraper):
|
|||||||
if phrase in card_text:
|
if phrase in card_text:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
in_stock_phrases = [
|
|
||||||
"add to cart",
|
|
||||||
"add to bag",
|
|
||||||
"available",
|
|
||||||
"in stock",
|
|
||||||
]
|
|
||||||
|
|
||||||
for phrase in in_stock_phrases:
|
|
||||||
if phrase in card_text:
|
|
||||||
return True
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def check_product_stock(self, product_url: str) -> tuple[bool, Optional[str]]:
|
def check_product_stock(self, product_url: str) -> tuple[bool, Optional[str]]:
|
||||||
|
|||||||
+42
-30
@@ -12,7 +12,7 @@ from bs4 import BeautifulSoup
|
|||||||
|
|
||||||
from .base import BaseScraper, Product
|
from .base import BaseScraper, Product
|
||||||
from src.browser import get_browser
|
from src.browser import get_browser
|
||||||
from config import CAPTCHA_WAIT_TIMEOUT
|
from config import CAPTCHA_WAIT_TIMEOUT, GAMESTOP_HEADLESS
|
||||||
|
|
||||||
# Add parent directory to path for tools import
|
# Add parent directory to path for tools import
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
@@ -20,6 +20,20 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_img_url(img_tag) -> Optional[str]:
|
||||||
|
"""Extract real image URL from an img tag, skipping lazy-load placeholders"""
|
||||||
|
if not img_tag:
|
||||||
|
return None
|
||||||
|
for attr in ("src", "data-src", "data-lazy", "data-lazy-src", "data-srcset", "srcset"):
|
||||||
|
val = img_tag.get(attr, "")
|
||||||
|
if not val:
|
||||||
|
continue
|
||||||
|
url = val.split()[0].rstrip(",")
|
||||||
|
if url and not url.startswith("data:") and len(url) > 20:
|
||||||
|
return url
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def warmup_gamestop():
|
def warmup_gamestop():
|
||||||
"""
|
"""
|
||||||
Warmup function to solve Cloudflare CAPTCHA manually.
|
Warmup function to solve Cloudflare CAPTCHA manually.
|
||||||
@@ -38,7 +52,7 @@ def warmup_gamestop():
|
|||||||
logger.info("Starting GameStop warmup with undetected-chromedriver...")
|
logger.info("Starting GameStop warmup with undetected-chromedriver...")
|
||||||
|
|
||||||
# Use stealth browser instead of Playwright
|
# Use stealth browser instead of Playwright
|
||||||
browser = StealthBrowser(headless=False, session_name="gamestop")
|
browser = StealthBrowser(headless=GAMESTOP_HEADLESS, session_name="gamestop")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
browser.start()
|
browser.start()
|
||||||
@@ -117,7 +131,7 @@ class GameStopScraper(BaseScraper):
|
|||||||
# Check if we need to create a new browser
|
# Check if we need to create a new browser
|
||||||
if self._stealth_browser is None:
|
if self._stealth_browser is None:
|
||||||
logger.info("Creating new GameStop stealth browser...")
|
logger.info("Creating new GameStop stealth browser...")
|
||||||
self._stealth_browser = StealthBrowser(headless=False, session_name="gamestop")
|
self._stealth_browser = StealthBrowser(headless=GAMESTOP_HEADLESS, session_name="gamestop")
|
||||||
self._stealth_browser.start()
|
self._stealth_browser.start()
|
||||||
self._restart_attempts = 0
|
self._restart_attempts = 0
|
||||||
return self._stealth_browser
|
return self._stealth_browser
|
||||||
@@ -194,6 +208,14 @@ class GameStopScraper(BaseScraper):
|
|||||||
browser.driver.get(page_url)
|
browser.driver.get(page_url)
|
||||||
time.sleep(5) # Wait for page load
|
time.sleep(5) # Wait for page load
|
||||||
|
|
||||||
|
# Scroll to trigger lazy-loaded images
|
||||||
|
browser.driver.execute_script("window.scrollTo(0, document.body.scrollHeight / 2)")
|
||||||
|
time.sleep(1)
|
||||||
|
browser.driver.execute_script("window.scrollTo(0, document.body.scrollHeight)")
|
||||||
|
time.sleep(1)
|
||||||
|
browser.driver.execute_script("window.scrollTo(0, 0)")
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
html = browser.driver.page_source
|
html = browser.driver.page_source
|
||||||
break # Success, exit retry loop
|
break # Success, exit retry loop
|
||||||
|
|
||||||
@@ -223,19 +245,15 @@ class GameStopScraper(BaseScraper):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if is_cloudflare:
|
if is_cloudflare:
|
||||||
logger.warning("Cloudflare challenge detected! Waiting for manual solve...")
|
logger.warning("Cloudflare challenge detected!")
|
||||||
start_time = time.time()
|
solved = browser.resolve_captcha_interactively(page_url, CAPTCHA_WAIT_TIMEOUT)
|
||||||
while time.time() - start_time < CAPTCHA_WAIT_TIMEOUT:
|
if not solved:
|
||||||
time.sleep(5)
|
|
||||||
html = browser.driver.page_source
|
|
||||||
html_lower = html.lower()
|
|
||||||
title = browser.driver.title.lower()
|
|
||||||
if "just a moment" not in title and "cf-challenge" not in html_lower:
|
|
||||||
logger.info("Cloudflare challenge solved!")
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
logger.error("Cloudflare challenge not solved in time")
|
logger.error("Cloudflare challenge not solved in time")
|
||||||
return all_products
|
return all_products
|
||||||
|
# Re-navigate in headless mode with fresh cookies
|
||||||
|
browser.driver.get(page_url)
|
||||||
|
time.sleep(5)
|
||||||
|
html = browser.driver.page_source
|
||||||
|
|
||||||
# Save debug screenshot
|
# Save debug screenshot
|
||||||
try:
|
try:
|
||||||
@@ -371,10 +389,7 @@ class GameStopScraper(BaseScraper):
|
|||||||
in_stock = self._check_card_stock_status(card)
|
in_stock = self._check_card_stock_status(card)
|
||||||
|
|
||||||
# Get image
|
# Get image
|
||||||
img = card.select_one("img")
|
image_url = _extract_img_url(card.select_one("img"))
|
||||||
image_url = None
|
|
||||||
if img:
|
|
||||||
image_url = img.get("src") or img.get("data-src") or img.get("data-lazy")
|
|
||||||
if image_url and not image_url.startswith("http"):
|
if image_url and not image_url.startswith("http"):
|
||||||
image_url = f"{self.base_url}{image_url}"
|
image_url = f"{self.base_url}{image_url}"
|
||||||
|
|
||||||
@@ -437,10 +452,9 @@ class GameStopScraper(BaseScraper):
|
|||||||
parent = link.find_parent()
|
parent = link.find_parent()
|
||||||
for _ in range(5):
|
for _ in range(5):
|
||||||
if parent:
|
if parent:
|
||||||
img = parent.select_one("img")
|
image_url = _extract_img_url(parent.select_one("img"))
|
||||||
if img:
|
if image_url:
|
||||||
image_url = img.get("src") or img.get("data-src")
|
if not image_url.startswith("http"):
|
||||||
if image_url and not image_url.startswith("http"):
|
|
||||||
image_url = f"{self.base_url}{image_url}"
|
image_url = f"{self.base_url}{image_url}"
|
||||||
break
|
break
|
||||||
parent = parent.find_parent()
|
parent = parent.find_parent()
|
||||||
@@ -474,28 +488,26 @@ class GameStopScraper(BaseScraper):
|
|||||||
"""Check if a product card indicates in-stock status"""
|
"""Check if a product card indicates in-stock status"""
|
||||||
card_text = card.get_text().lower() if hasattr(card, "get_text") else str(card).lower()
|
card_text = card.get_text().lower() if hasattr(card, "get_text") else str(card).lower()
|
||||||
|
|
||||||
out_of_stock_phrases = [
|
out_of_stock_phrases = {
|
||||||
"sold out",
|
"sold out",
|
||||||
"out of stock",
|
"out of stock",
|
||||||
"not available",
|
"not available",
|
||||||
"unavailable",
|
"unavailable",
|
||||||
"currently unavailable",
|
"currently unavailable",
|
||||||
]
|
}
|
||||||
|
|
||||||
for phrase in out_of_stock_phrases:
|
if any(phrase in card_text for phrase in out_of_stock_phrases):
|
||||||
if phrase in card_text:
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
in_stock_phrases = [
|
in_stock_phrases = {
|
||||||
"add to cart",
|
"add to cart",
|
||||||
"add to bag",
|
"add to bag",
|
||||||
"available",
|
"available",
|
||||||
"buy now",
|
"buy now",
|
||||||
"in stock",
|
"in stock",
|
||||||
]
|
}
|
||||||
|
|
||||||
for phrase in in_stock_phrases:
|
if any(phrase in card_text for phrase in in_stock_phrases):
|
||||||
if phrase in card_text:
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Default: assume in stock if listed
|
# Default: assume in stock if listed
|
||||||
|
|||||||
+57
-10
@@ -14,6 +14,50 @@ from src.browser import get_browser
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_image_in_json(data, depth=0) -> Optional[str]:
|
||||||
|
"""Recursively search a JSON dict for a Target image URL"""
|
||||||
|
if depth > 6:
|
||||||
|
return None
|
||||||
|
if isinstance(data, str):
|
||||||
|
if 'scene7.com' in data and not data.endswith('/'):
|
||||||
|
return data
|
||||||
|
return None
|
||||||
|
if isinstance(data, dict):
|
||||||
|
# Check known Target image keys first
|
||||||
|
for key in ('primary_image_url', 'base_url', 'url', 'src'):
|
||||||
|
val = data.get(key)
|
||||||
|
if isinstance(val, str) and 'scene7.com' in val:
|
||||||
|
return val
|
||||||
|
# Recurse, prioritizing enrichment/images paths
|
||||||
|
for key in ('enrichment', 'images', 'image'):
|
||||||
|
if key in data:
|
||||||
|
result = _find_image_in_json(data[key], depth + 1)
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
for val in data.values():
|
||||||
|
result = _find_image_in_json(val, depth + 1)
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
if isinstance(data, list) and data:
|
||||||
|
return _find_image_in_json(data[0], depth + 1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_img_url(img_tag) -> Optional[str]:
|
||||||
|
"""Extract real image URL from an img tag, skipping lazy-load placeholders"""
|
||||||
|
if not img_tag:
|
||||||
|
return None
|
||||||
|
for attr in ("src", "data-src", "data-lazy-src", "data-srcset", "srcset"):
|
||||||
|
val = img_tag.get(attr, "")
|
||||||
|
if not val:
|
||||||
|
continue
|
||||||
|
# srcset may contain multiple URLs - take the first
|
||||||
|
url = val.split()[0].rstrip(",")
|
||||||
|
if url and not url.startswith("data:") and len(url) > 20:
|
||||||
|
return url
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class TargetScraper(BaseScraper):
|
class TargetScraper(BaseScraper):
|
||||||
"""Scraper for Target.com"""
|
"""Scraper for Target.com"""
|
||||||
|
|
||||||
@@ -178,9 +222,8 @@ class TargetScraper(BaseScraper):
|
|||||||
if fulfillment:
|
if fulfillment:
|
||||||
in_stock = fulfillment.get("is_out_of_stock_in_all_store_locations", True) is False
|
in_stock = fulfillment.get("is_out_of_stock_in_all_store_locations", True) is False
|
||||||
|
|
||||||
# Get image
|
# Get image - search all known Target JSON paths
|
||||||
images = data.get("images", [])
|
image_url = _find_image_in_json(data)
|
||||||
image_url = images[0].get("base_url") if images else None
|
|
||||||
|
|
||||||
return Product(
|
return Product(
|
||||||
name=name,
|
name=name,
|
||||||
@@ -247,11 +290,16 @@ class TargetScraper(BaseScraper):
|
|||||||
break
|
break
|
||||||
parent = parent.find_parent()
|
parent = parent.find_parent()
|
||||||
|
|
||||||
# Try to find image
|
# Try to find image - check inside link first, then walk up to parents
|
||||||
image_url = None
|
image_url = _extract_img_url(link.select_one("img"))
|
||||||
img = link.select_one("img")
|
if not image_url:
|
||||||
if img:
|
parent = link.find_parent()
|
||||||
image_url = img.get("src") or img.get("data-src")
|
for _ in range(4):
|
||||||
|
if parent:
|
||||||
|
image_url = _extract_img_url(parent.select_one("img"))
|
||||||
|
if image_url:
|
||||||
|
break
|
||||||
|
parent = parent.find_parent()
|
||||||
|
|
||||||
# Check stock (assume in stock unless we see otherwise)
|
# Check stock (assume in stock unless we see otherwise)
|
||||||
in_stock = True
|
in_stock = True
|
||||||
@@ -307,8 +355,7 @@ class TargetScraper(BaseScraper):
|
|||||||
in_stock = not out_of_stock
|
in_stock = not out_of_stock
|
||||||
|
|
||||||
# Get image
|
# Get image
|
||||||
img = card.select_one("img")
|
image_url = _extract_img_url(card.select_one("img"))
|
||||||
image_url = img.get("src") if img else None
|
|
||||||
|
|
||||||
# Extract product ID from URL
|
# Extract product ID from URL
|
||||||
product_id = ""
|
product_id = ""
|
||||||
|
|||||||
@@ -68,16 +68,20 @@ class ScraperStateManager:
|
|||||||
return DEFAULT_STATE.copy()
|
return DEFAULT_STATE.copy()
|
||||||
|
|
||||||
def _save_state(self):
|
def _save_state(self):
|
||||||
"""Save state to file"""
|
"""Save state to file using atomic write to prevent corruption"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
try:
|
try:
|
||||||
with open(STATE_FILE, 'w') as f:
|
tmp_file = STATE_FILE.with_suffix('.json.tmp')
|
||||||
|
with open(tmp_file, 'w') as f:
|
||||||
json.dump(self.state, f, indent=2, default=str)
|
json.dump(self.state, f, indent=2, default=str)
|
||||||
|
os.replace(tmp_file, STATE_FILE)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error saving scraper state: {e}")
|
print(f"Error saving scraper state: {e}")
|
||||||
|
|
||||||
def get_state(self) -> dict:
|
def get_state(self) -> dict:
|
||||||
"""Get current state"""
|
"""Get current state, reloading from file to pick up changes from other processes"""
|
||||||
|
self.state = self._load_state()
|
||||||
|
|
||||||
# Check if monitor is still running
|
# Check if monitor is still running
|
||||||
if self.state["monitor_pid"]:
|
if self.state["monitor_pid"]:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -117,6 +117,10 @@ class StealthBrowser:
|
|||||||
options.add_argument("--no-sandbox")
|
options.add_argument("--no-sandbox")
|
||||||
options.add_argument("--disable-infobars")
|
options.add_argument("--disable-infobars")
|
||||||
|
|
||||||
|
# Headless mode (must be set via options in uc >= 3.5)
|
||||||
|
if self.headless:
|
||||||
|
options.add_argument("--headless=new")
|
||||||
|
|
||||||
# Window size (realistic resolution)
|
# Window size (realistic resolution)
|
||||||
options.add_argument("--window-size=1920,1080")
|
options.add_argument("--window-size=1920,1080")
|
||||||
|
|
||||||
@@ -130,10 +134,6 @@ class StealthBrowser:
|
|||||||
if proxy_str:
|
if proxy_str:
|
||||||
options.add_argument(f"--proxy-server={proxy_str}")
|
options.add_argument(f"--proxy-server={proxy_str}")
|
||||||
|
|
||||||
# Headless mode (note: more detectable)
|
|
||||||
if self.headless:
|
|
||||||
options.add_argument("--headless=new")
|
|
||||||
|
|
||||||
return options
|
return options
|
||||||
|
|
||||||
def _format_proxy(self, proxy: Dict) -> Optional[str]:
|
def _format_proxy(self, proxy: Dict) -> Optional[str]:
|
||||||
@@ -164,6 +164,7 @@ class StealthBrowser:
|
|||||||
# This prevents "ChromeDriver only supports Chrome version X" errors
|
# This prevents "ChromeDriver only supports Chrome version X" errors
|
||||||
self.driver = uc.Chrome(
|
self.driver = uc.Chrome(
|
||||||
options=options,
|
options=options,
|
||||||
|
headless=self.headless,
|
||||||
use_subprocess=True,
|
use_subprocess=True,
|
||||||
version_main=146, # Match user's Chrome version
|
version_main=146, # Match user's Chrome version
|
||||||
)
|
)
|
||||||
@@ -207,7 +208,13 @@ class StealthBrowser:
|
|||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = str(e).lower()
|
error_msg = str(e).lower()
|
||||||
if "invalid session id" in error_msg or "session deleted" in error_msg or "no such session" in error_msg:
|
dead_session_errors = [
|
||||||
|
"invalid session id", "session deleted", "no such session",
|
||||||
|
"newconnectionerror", "connection refused", "winerror 10061",
|
||||||
|
"failed to establish a new connection", "max retries exceeded",
|
||||||
|
"browser has closed",
|
||||||
|
]
|
||||||
|
if any(err in error_msg for err in dead_session_errors):
|
||||||
logger.warning(f"Browser session invalid: {e}")
|
logger.warning(f"Browser session invalid: {e}")
|
||||||
return False
|
return False
|
||||||
# Other errors might be temporary
|
# Other errors might be temporary
|
||||||
@@ -430,6 +437,64 @@ class StealthBrowser:
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def resolve_captcha_interactively(self, url: str, timeout: int = 120) -> bool:
|
||||||
|
"""
|
||||||
|
When running headless and a CAPTCHA is detected, temporarily pop a visible browser
|
||||||
|
window for the user to solve it, then return to headless mode with the updated cookies.
|
||||||
|
"""
|
||||||
|
if not self.headless:
|
||||||
|
# Already visible - just wait in place
|
||||||
|
return self.wait_for_captcha_solve(timeout)
|
||||||
|
|
||||||
|
logger.info("CAPTCHA detected in headless mode - switching to visible browser for manual solve...")
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("CAPTCHA DETECTED - Opening visible browser window")
|
||||||
|
print("Please solve the challenge, then it will return to headless.")
|
||||||
|
print("=" * 60 + "\n")
|
||||||
|
|
||||||
|
# Save current cookies before restarting
|
||||||
|
self._save_cookies()
|
||||||
|
|
||||||
|
# Stop headless driver
|
||||||
|
if self.driver:
|
||||||
|
try:
|
||||||
|
self.driver.quit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.driver = None
|
||||||
|
self._setup_complete = False
|
||||||
|
|
||||||
|
# Restart visible
|
||||||
|
self.headless = False
|
||||||
|
solved = False
|
||||||
|
try:
|
||||||
|
self.start()
|
||||||
|
self.driver.get(url)
|
||||||
|
time.sleep(3)
|
||||||
|
solved = self.wait_for_captcha_solve(timeout)
|
||||||
|
if solved:
|
||||||
|
self._save_cookies()
|
||||||
|
logger.info("CAPTCHA solved - saving session and returning to headless mode")
|
||||||
|
print("\n>>> CAPTCHA solved! Returning to headless mode...\n")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error during interactive CAPTCHA solve: {e}")
|
||||||
|
finally:
|
||||||
|
# Always return to headless
|
||||||
|
if self.driver:
|
||||||
|
try:
|
||||||
|
self.driver.quit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.driver = None
|
||||||
|
self._setup_complete = False
|
||||||
|
self.headless = True
|
||||||
|
try:
|
||||||
|
self.start()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to restart headless browser after CAPTCHA solve: {e}")
|
||||||
|
|
||||||
|
return solved
|
||||||
|
|
||||||
def wait_for_captcha_solve(self, timeout: int = 120):
|
def wait_for_captcha_solve(self, timeout: int = 120):
|
||||||
"""
|
"""
|
||||||
Wait for user to solve CAPTCHA manually.
|
Wait for user to solve CAPTCHA manually.
|
||||||
|
|||||||
Reference in New Issue
Block a user