diff --git a/.gitignore b/.gitignore index b319d77..07b2c78 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,10 @@ data/*.pkl # Debug artifacts debug_*.png +debug_*.html + +# Browser session state — contains cookies/tokens, never commit +data/browser_states/ # Large analysis files *.har @@ -26,10 +30,12 @@ debug_*.png # Database *.db -# IDE +# IDE / AI tools .vscode/ .idea/ +.claude/ # OS .DS_Store Thumbs.db +.env.example diff --git a/CLAUDE.md b/CLAUDE.md index e287d64..5f8ad95 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -135,3 +135,53 @@ python src/discord_bot.py ```bash python -m pytest tests/ ``` + +## Auto-Buy System + +### Architecture +- `src/buyers/base_buyer.py` — Abstract base, browser launch, shared utilities +- `src/buyers/target_buyer.py` — Target checkout flow +- `src/buyers/bestbuy_buyer.py` — BestBuy checkout flow +- `src/buyers/gamestop_buyer.py` — GameStop checkout flow (Selenium) +- `src/profile_store.py` — AES-256 encrypted user checkout profiles +- `data/browser_states//.json` — Saved browser sessions per user + +### Multi-User Browser Session Setup (Target) + +Target blocks automated logins with a server-side error. Each user must save a browser session once by logging in manually. Sessions are stored per-user per-site. + +**One-time setup per user:** +```bash +# User 1 logs into Target +python test_buy.py --setup target --user-id 1 + +# User 2 logs into Target +python test_buy.py --setup target --user-id 2 +``` + +A Chrome window opens on the Target account page. The user logs in normally, then presses Enter in the terminal. Session saved to `data/browser_states//target.json`. + +**Sessions expire** when the Target login cookie expires (typically 30–90 days). Re-run `--setup` for that user if checkout starts failing. + +**The same pattern applies to BestBuy and GameStop:** +```bash +python test_buy.py --setup bestbuy --user-id 1 +python test_buy.py --setup gamestop --user-id 1 +``` + +### Dry-Run Testing +```bash +# Non-interactive (set TEST_USER_ID and TEST_PROFILE_PASSWORD in .env) +python test_buy.py target https://www.target.com/p/... + +# Interactive (prompts for user selection and profile password) +python test_buy.py target https://www.target.com/p/... +``` + +### Enabling Real Purchases +In `config.py`: +```python +AUTO_BUY_ENABLED = True +AUTO_BUY_DRY_RUN = False # default True — must explicitly disable +AUTO_BUY_MAX_PRICE = 60.00 # price ceiling per item +``` diff --git a/config.py b/config.py index c408329..47d9eb4 100644 --- a/config.py +++ b/config.py @@ -178,6 +178,33 @@ USE_PROXIES = False # Set to True to enable proxy rotation CAPTCHA_WAIT_TIMEOUT = 120 # Seconds to wait for manual CAPTCHA solve PAUSE_ON_CAPTCHA = True # Pause monitoring when CAPTCHA detected (requires manual solve) +# ============================================================================= +# AUTO-BUY SETTINGS +# ============================================================================= +# Credentials are loaded from .env — never put card/login data in this file. +# Copy .env.example to .env and fill in your details. + +# Master kill switch — must be True AND the site must be in AUTO_BUY_SITES +AUTO_BUY_ENABLED = False + +# Which sites to attempt auto-buy on (subset of SITES_ENABLED) +# Options: "target", "bestbuy", "gamestop" +AUTO_BUY_SITES: list[str] = [] + +# Dry-run: go through the entire checkout flow but stop before clicking Place Order +# Set to False only when you are ready to make real purchases +AUTO_BUY_DRY_RUN = True + +# Price ceiling — skip auto-buy if detected price exceeds this (USD) +AUTO_BUY_MAX_PRICE = 60.00 + +# Maximum quantity to add to cart per product +AUTO_BUY_MAX_QUANTITY = 1 + +# Seconds to wait between browser actions during checkout (humanizes behavior) +AUTO_BUY_ACTION_DELAY_MIN = 0.8 +AUTO_BUY_ACTION_DELAY_MAX = 2.0 + # Logging LOG_LEVEL = "INFO" diff --git a/dashboard/api.py b/dashboard/api.py index 567302d..3206f87 100644 --- a/dashboard/api.py +++ b/dashboard/api.py @@ -1139,3 +1139,155 @@ def get_user_stores(user_id): 'location': {'zip_code': user['zip_code'], 'radius_miles': user.get('radius_miles', 25)}, 'stores': result }) + + +# ============================================================================= +# Checkout Profile Routes +# ============================================================================= + +@api_bp.route('/users//profile/data', methods=['GET']) +def get_profile_data(user_id): + """ + Return non-sensitive profile fields for pre-filling the edit form. + Only works if the profile is already unlocked (in memory). + Card number is returned masked. Passwords are never returned. + """ + from src.profile_store import get_unlocked_profile, masked_card + db = get_database() + if not db.get_user(user_id): + return jsonify({'error': 'User not found'}), 404 + + profile = get_unlocked_profile(user_id) + if not profile: + return jsonify({'error': 'Profile not unlocked'}), 403 + + shipping = profile.get('shipping', {}) + creds = profile.get('site_credentials', {}) + card = masked_card(user_id) + + return jsonify({ + 'shipping': shipping, + 'masked_card': card, + 'site_credentials': { + 'target_email': creds.get('target_email', ''), + 'bestbuy_email': creds.get('bestbuy_email', ''), + 'gamestop_email': creds.get('gamestop_email', ''), + # passwords intentionally omitted + } + }) + + +@api_bp.route('/users//profile', methods=['GET']) +def get_profile_status(user_id): + """ + Return profile metadata — never returns card data or decrypted fields. + Frontend uses this to show lock/unlock state and masked card info. + """ + from src.profile_store import is_unlocked, masked_card + db = get_database() + if not db.get_user(user_id): + return jsonify({'error': 'User not found'}), 404 + + has_profile = db.user_has_profile(user_id) + unlocked = is_unlocked(user_id) + + return jsonify({ + 'has_profile': has_profile, + 'is_unlocked': unlocked, + 'masked_card': masked_card(user_id) if unlocked else None, + }) + + +@api_bp.route('/users//profile', methods=['POST']) +def save_profile(user_id): + """ + Save (or replace) an encrypted checkout profile. + Expects JSON: { password, shipping: {...}, payment: {...} } + Card data is encrypted immediately — never stored in plaintext. + """ + from src.profile_store import save_profile as _save + db = get_database() + if not db.get_user(user_id): + return jsonify({'error': 'User not found'}), 404 + + data = request.get_json() + if not data: + return jsonify({'error': 'No data provided'}), 400 + + password = data.get('password', '').strip() + if len(password) < 8: + return jsonify({'error': 'Password must be at least 8 characters'}), 400 + + shipping = data.get('shipping', {}) + payment = data.get('payment', {}) + site_credentials = data.get('site_credentials', {}) + + required_shipping = ['first_name', 'last_name', 'address1', 'city', 'state', 'zip', 'email'] + missing = [f for f in required_shipping if not shipping.get(f)] + if missing: + return jsonify({'error': f'Missing shipping fields: {", ".join(missing)}'}), 400 + + # If card fields are blank and a profile already exists, keep the existing card data + if not payment.get('card_number') and db.user_has_profile(user_id): + from src.profile_store import get_unlocked_profile + existing = get_unlocked_profile(user_id) + if existing: + for k in ['card_number', 'expiry_month', 'expiry_year', 'cvv', 'name_on_card']: + if not payment.get(k): + payment[k] = existing.get('payment', {}).get(k, '') + + required_payment = ['card_number', 'expiry_month', 'expiry_year', 'cvv'] + missing = [f for f in required_payment if not payment.get(f)] + if missing: + return jsonify({'error': f'Missing payment fields: {", ".join(missing)}'}), 400 + + try: + ok = _save(user_id, password, shipping, payment, db, site_credentials=site_credentials) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + if not ok: + return jsonify({'error': 'Encryption failed — is the cryptography package installed? Run: pip install cryptography'}), 500 + + return jsonify({'success': True, 'is_unlocked': True}) + + +@api_bp.route('/users//profile/unlock', methods=['POST']) +def unlock_profile(user_id): + """Decrypt profile into memory. Expects JSON: { password }""" + from src.profile_store import unlock_profile as _unlock + db = get_database() + if not db.get_user(user_id): + return jsonify({'error': 'User not found'}), 404 + + if not db.user_has_profile(user_id): + return jsonify({'error': 'No profile saved for this user'}), 404 + + data = request.get_json() + password = (data or {}).get('password', '') + + if not _unlock(user_id, password, db): + return jsonify({'error': 'Incorrect password'}), 401 + + from src.profile_store import masked_card + return jsonify({'success': True, 'masked_card': masked_card(user_id)}) + + +@api_bp.route('/users//profile/lock', methods=['POST']) +def lock_profile(user_id): + """Clear profile from memory.""" + from src.profile_store import lock_profile as _lock + _lock(user_id) + return jsonify({'success': True}) + + +@api_bp.route('/users//profile', methods=['DELETE']) +def delete_profile(user_id): + """Permanently delete a user's saved profile.""" + from src.profile_store import lock_profile as _lock + db = get_database() + if not db.get_user(user_id): + return jsonify({'error': 'User not found'}), 404 + _lock(user_id) + db.delete_user_profile(user_id) + return jsonify({'success': True}) diff --git a/dashboard/static/app.js b/dashboard/static/app.js index 0c0e427..18af81d 100644 --- a/dashboard/static/app.js +++ b/dashboard/static/app.js @@ -1219,10 +1219,10 @@ async function loadUsers() { const data = await api('/users'); if (!data) return; - renderUsersList(data.users || []); + await renderUsersList(data.users || []); } -function renderUsersList(users) { +async function renderUsersList(users) { const container = document.getElementById('usersList'); if (!container) return; @@ -1231,24 +1231,210 @@ function renderUsersList(users) { return; } - container.innerHTML = users.map(user => ` + // Fetch profile status for all users in parallel + const profileStatuses = await Promise.all( + users.map(u => api(`/users/${u.id}/profile`).catch(() => null)) + ); + + container.innerHTML = users.map((user, i) => { + const ps = profileStatuses[i] || {}; + const hasProfile = ps.has_profile; + const isUnlocked = ps.is_unlocked; + const maskedCard = ps.masked_card || ''; + + const profileBadge = isUnlocked + ? `🔓 Unlocked ${maskedCard ? '· ' + escapeHtml(maskedCard) : ''}` + : hasProfile + ? `🔒 Locked` + : `No profile`; + + const profileBtns = hasProfile + ? isUnlocked + ? ` + + ` + : ` + + ` + : ``; + + return `
${escapeHtml(user.name)} ${user.zip_code ? `${user.zip_code} (${user.radius_miles}mi)` : 'No location set'} + ${profileBadge}
- + - + +
+
+ ${profileBtns}
-
- `).join(''); + `; + }).join(''); +} + +// ---- Profile modal ---- +let _profileUserId = null; + +async function openProfileModal(userId, userName) { + _profileUserId = userId; + document.getElementById('profileModalTitle').textContent = `Checkout Profile — ${userName}`; + document.getElementById('profileModalError').style.display = 'none'; + + // Clear all fields first + ['pf-first-name','pf-last-name','pf-address1','pf-address2','pf-city','pf-state', + 'pf-zip','pf-phone','pf-email','pf-card-number','pf-expiry-month','pf-expiry-year', + 'pf-cvv','pf-card-name','pf-target-email','pf-target-password', + 'pf-bestbuy-email','pf-bestbuy-password','pf-gamestop-email','pf-gamestop-password', + 'pf-password','pf-password-confirm'].forEach(id => { + const el = document.getElementById(id); + if (el) el.value = ''; + }); + + // Pre-fill from unlocked profile if available + const data = await api(`/users/${userId}/profile/data`).catch(() => null); + if (data && data.shipping) { + const s = data.shipping; + const set = (id, val) => { const el = document.getElementById(id); if (el && val) el.value = val; }; + set('pf-first-name', s.first_name); + set('pf-last-name', s.last_name); + set('pf-address1', s.address1); + set('pf-address2', s.address2); + set('pf-city', s.city); + set('pf-state', s.state); + set('pf-zip', s.zip); + set('pf-phone', s.phone); + set('pf-email', s.email); + set('pf-target-email', data.site_credentials?.target_email); + set('pf-bestbuy-email', data.site_credentials?.bestbuy_email); + set('pf-gamestop-email', data.site_credentials?.gamestop_email); + // Show masked card as placeholder so user knows card is saved + if (data.masked_card) { + const cardEl = document.getElementById('pf-card-number'); + if (cardEl) cardEl.placeholder = `${data.masked_card} (leave blank to keep)`; + } + } + + document.getElementById('profileModal').style.display = 'flex'; +} + +function closeProfileModal() { + document.getElementById('profileModal').style.display = 'none'; + _profileUserId = null; +} + +async function saveProfile() { + const errEl = document.getElementById('profileModalError'); + errEl.style.display = 'none'; + + const password = document.getElementById('pf-password').value; + const confirm = document.getElementById('pf-password-confirm').value; + + if (password.length < 8) { + errEl.textContent = 'Password must be at least 8 characters.'; + errEl.style.display = 'block'; + return; + } + if (password !== confirm) { + errEl.textContent = 'Passwords do not match.'; + errEl.style.display = 'block'; + return; + } + + const shipping = { + first_name: document.getElementById('pf-first-name').value.trim(), + last_name: document.getElementById('pf-last-name').value.trim(), + address1: document.getElementById('pf-address1').value.trim(), + address2: document.getElementById('pf-address2').value.trim(), + city: document.getElementById('pf-city').value.trim(), + state: document.getElementById('pf-state').value.trim().toUpperCase(), + zip: document.getElementById('pf-zip').value.trim(), + phone: document.getElementById('pf-phone').value.trim(), + email: document.getElementById('pf-email').value.trim(), + }; + const payment = { + card_number: document.getElementById('pf-card-number').value.replace(/\s/g, ''), + expiry_month: document.getElementById('pf-expiry-month').value.trim(), + expiry_year: document.getElementById('pf-expiry-year').value.trim(), + cvv: document.getElementById('pf-cvv').value.trim(), + name_on_card: document.getElementById('pf-card-name').value.trim(), + }; + const site_credentials = { + target_email: document.getElementById('pf-target-email')?.value.trim(), + target_password: document.getElementById('pf-target-password')?.value, + bestbuy_email: document.getElementById('pf-bestbuy-email')?.value.trim(), + bestbuy_password: document.getElementById('pf-bestbuy-password')?.value, + gamestop_email: document.getElementById('pf-gamestop-email')?.value.trim(), + gamestop_password: document.getElementById('pf-gamestop-password')?.value, + }; + + const result = await api(`/users/${_profileUserId}/profile`, { + method: 'POST', + body: JSON.stringify({ password, shipping, payment, site_credentials }) + }); + + if (result && result.success) { + closeProfileModal(); + loadUsers(); + } else { + errEl.textContent = result?.error || 'Failed to save profile.'; + errEl.style.display = 'block'; + } +} + +// ---- Unlock modal ---- +let _unlockUserId = null; + +function openUnlockModal(userId, userName) { + _unlockUserId = userId; + document.getElementById('unlockModalTitle').textContent = `Unlock Profile — ${userName}`; + document.getElementById('unlock-password').value = ''; + document.getElementById('unlockModalError').style.display = 'none'; + document.getElementById('unlockModal').style.display = 'flex'; + setTimeout(() => document.getElementById('unlock-password').focus(), 100); +} + +function closeUnlockModal() { + document.getElementById('unlockModal').style.display = 'none'; + _unlockUserId = null; +} + +async function submitUnlock() { + const errEl = document.getElementById('unlockModalError'); + const password = document.getElementById('unlock-password').value; + + const result = await api(`/users/${_unlockUserId}/profile/unlock`, { + method: 'POST', + body: JSON.stringify({ password }) + }); + + if (result && result.success) { + closeUnlockModal(); + loadUsers(); + } else { + errEl.textContent = result?.error || 'Incorrect password.'; + errEl.style.display = 'block'; + } +} + +async function lockProfile(userId) { + await api(`/users/${userId}/profile/lock`, { method: 'POST' }); + loadUsers(); +} + +async function deleteProfile(userId) { + if (!confirm('Delete this checkout profile? This cannot be undone.')) return; + await api(`/users/${userId}/profile`, { method: 'DELETE' }); + loadUsers(); } async function addUser() { diff --git a/dashboard/static/style.css b/dashboard/static/style.css index 5a690f7..63bb10f 100644 --- a/dashboard/static/style.css +++ b/dashboard/static/style.css @@ -922,6 +922,9 @@ h3 { z-index: 1000; align-items: center; justify-content: center; + padding: 20px; + box-sizing: border-box; + overflow-y: auto; } .modal.show { @@ -931,25 +934,84 @@ h3 { .modal-content { background: var(--bg-secondary); border-radius: 12px; - padding: 30px; - max-width: 500px; - width: 90%; + max-width: 520px; + width: 100%; position: relative; + display: flex; + flex-direction: column; + max-height: 90vh; + margin: auto; +} + +.modal-lg { + max-width: 680px; +} + +.modal-header { + padding: 20px 24px 16px; + border-bottom: 1px solid var(--border-color); + flex-shrink: 0; +} + +.modal-header h3 { + margin: 0; + padding-right: 32px; +} + +.modal-body { + padding: 20px 24px; + overflow-y: auto; + flex: 1; +} + +.modal-body h4 { + margin: 1.2rem 0 0.6rem; + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); +} + +.modal-body h4:first-child { + margin-top: 0; +} + +.modal-footer { + padding: 16px 24px; + border-top: 1px solid var(--border-color); + display: flex; + justify-content: flex-end; + gap: 10px; + flex-shrink: 0; } .modal-close { position: absolute; - top: 15px; - right: 20px; - font-size: 24px; + top: 16px; + right: 18px; + font-size: 22px; cursor: pointer; color: var(--text-secondary); + line-height: 1; + background: none; + border: none; } .modal-close:hover { color: var(--text-primary); } +.form-row { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.form-row .form-group { + flex: 1; + min-width: 120px; +} + /* Loading */ .loading { text-align: center; diff --git a/dashboard/templates/index.html b/dashboard/templates/index.html index 94f4f88..e24e939 100644 --- a/dashboard/templates/index.html +++ b/dashboard/templates/index.html @@ -337,14 +337,14 @@

Users

-

Manage users and their store locations for local stock checking

+

Manage users, store locations, and encrypted checkout profiles

Add New User

- +
@@ -363,6 +363,94 @@

Loading users...

+ + + + + +
diff --git a/main.py b/main.py index 2ddaa35..5383a9f 100644 --- a/main.py +++ b/main.py @@ -24,11 +24,15 @@ from config import ( NEW_DROP_NOTIFICATIONS, PRICE_CHANGE_NOTIFICATIONS, MAX_PAGES, + AUTO_BUY_ENABLED, + AUTO_BUY_SITES, + AUTO_BUY_DRY_RUN, ) from src.browser import get_browser, shutdown_browser 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.scraper_state import scraper_state +from src.buyers import get_buyer # Note: Pokemon Center uses Chrome Extension (chrome-extension/), not a Python scraper from scrapers import ( TargetScraper, @@ -60,6 +64,51 @@ scrapers = { } +def attempt_auto_buy(site: str, products: list): + """ + Attempt to purchase in-stock products on the given site. + Only runs when AUTO_BUY_ENABLED=True and site is in AUTO_BUY_SITES. + Logs and notifies on every attempt — never silently buys anything. + """ + if not AUTO_BUY_ENABLED: + return + if site not in AUTO_BUY_SITES: + return + if not products: + return + + buyer = get_buyer(site) + if not buyer: + logger.warning(f"[auto-buy] No buyer implemented for {site}") + return + + mode = "DRY RUN" if AUTO_BUY_DRY_RUN else "LIVE" + for product in products: + if not product.in_stock: + continue + logger.info(f"[auto-buy] {mode} — {site} — {product.name}") + try: + result = buyer.buy_product(product) + if result.success: + logger.info(f"[auto-buy] SUCCESS ({mode}): {product.name} — {result.message}") + send_stock_alert( + product_name=product.name, + product_url=product.url, + price=product.price or "See link", + site=site, + alert_type="auto_buy_success" if not result.dry_run else "auto_buy_dry_run", + image_url=product.image_url, + ) + else: + logger.warning(f"[auto-buy] FAILED: {product.name} — {result.message}") + send_error_notification( + f"Auto-buy failed for {product.name} on {site}: {result.message}", + site.title(), + ) + except Exception as e: + logger.error(f"[auto-buy] Error buying {product.name} on {site}: {e}", exc_info=True) + + def check_target(): """Check Target for restocks and new drops""" logger.info("Checking Target...") @@ -129,6 +178,9 @@ def check_target(): ) logger.info(f"Sent notification for price change: {product.name} ({old_price} -> {new_price})") + # Auto-buy in-stock new drops and restocks + attempt_auto_buy("target", [p for p in new_products if p.in_stock] + restocked_products) + # Log stats stats = tracker.get_stats() logger.info( @@ -220,6 +272,9 @@ def check_gamestop(): ) logger.info(f"Sent notification for price change: {product.name} ({old_price} -> {new_price})") + # Auto-buy in-stock new drops and restocks + attempt_auto_buy("gamestop", [p for p in new_products if p.in_stock] + restocked_products) + # Log stats stats = tracker.get_stats() logger.info( @@ -311,6 +366,9 @@ def check_bestbuy(): ) logger.info(f"Sent notification for price change: {product.name} ({old_price} -> {new_price})") + # Auto-buy in-stock new drops and restocks + attempt_auto_buy("bestbuy", [p for p in new_products if p.in_stock] + restocked_products) + # Log stats stats = tracker.get_stats() logger.info( diff --git a/requirements.txt b/requirements.txt index 7177093..6143f13 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,5 +12,6 @@ selenium>=4.15.0 setuptools>=70.0.0 # Required for Python 3.13+ (distutils compatibility) discord.py>=2.3.0 # Discord bot for location-based store search nltk>=3.8.0 # Sentiment analysis using VADER +cryptography>=42.0.0 # AES encryption for checkout profiles pytest>=8.0.0 # Testing framework pytest-cov>=4.0.0 # Coverage reporting diff --git a/src/buyers/__init__.py b/src/buyers/__init__.py new file mode 100644 index 0000000..043af83 --- /dev/null +++ b/src/buyers/__init__.py @@ -0,0 +1,29 @@ +""" +Auto-buyer package. + +Usage: + from src.buyers import get_buyer + buyer = get_buyer("target") + if buyer: + result = buyer.buy_product(product) +""" + +from .base_buyer import BuyResult +from .target_buyer import TargetBuyer +from .bestbuy_buyer import BestBuyBuyer +from .gamestop_buyer import GameStopBuyer + +_BUYERS = { + "target": TargetBuyer, + "bestbuy": BestBuyBuyer, + "gamestop": GameStopBuyer, +} + + +def get_buyer(site: str): + """Return an instantiated buyer for the given site, or None if unsupported.""" + cls = _BUYERS.get(site.lower()) + return cls() if cls else None + + +__all__ = ["get_buyer", "BuyResult", "TargetBuyer", "BestBuyBuyer", "GameStopBuyer"] diff --git a/src/buyers/base_buyer.py b/src/buyers/base_buyer.py new file mode 100644 index 0000000..e0cafa0 --- /dev/null +++ b/src/buyers/base_buyer.py @@ -0,0 +1,347 @@ +""" +Base auto-buyer — shared utilities for all site-specific buyers. + +Security design: +- Credentials loaded from environment variables at runtime only +- Card number and CVV are never logged (masked in all log output) +- No external network calls — all traffic goes only to the retail site +- DRY_RUN mode is on by default; real purchases require explicit opt-in +""" + +import os +import time +import random +import logging +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Optional + +from playwright.sync_api import Page, Frame, TimeoutError as PlaywrightTimeout + +from scrapers.base import Product +import config + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Result type +# --------------------------------------------------------------------------- + +@dataclass +class BuyResult: + success: bool + product: Product + message: str + order_number: Optional[str] = None + dry_run: bool = False + + +# --------------------------------------------------------------------------- +# Credential loader — reads .env values, never logs sensitive fields +# --------------------------------------------------------------------------- + +def _env(key: str) -> str: + return os.environ.get(key, "").strip() + + +def load_shipping() -> dict: + return { + "first_name": _env("SHIPPING_FIRST_NAME"), + "last_name": _env("SHIPPING_LAST_NAME"), + "address1": _env("SHIPPING_ADDRESS1"), + "address2": _env("SHIPPING_ADDRESS2"), + "city": _env("SHIPPING_CITY"), + "state": _env("SHIPPING_STATE"), + "zip": _env("SHIPPING_ZIP"), + "phone": _env("SHIPPING_PHONE"), + "email": _env("SHIPPING_EMAIL"), + } + + +def load_payment() -> dict: + """Load payment info from env. Never logged.""" + return { + "card_number": _env("CARD_NUMBER"), + "expiry_month": _env("CARD_EXPIRY_MONTH"), + "expiry_year": _env("CARD_EXPIRY_YEAR"), + "cvv": _env("CARD_CVV"), + "name_on_card": _env("CARD_NAME"), + } + + +def load_from_profile_store() -> Optional[dict]: + """ + Return the full unlocked profile dict {shipping, payment, site_credentials}, + or None if no profiles are unlocked. + Profile store takes priority over .env credentials. + """ + try: + from src.profile_store import get_any_unlocked_profile + return get_any_unlocked_profile() + except Exception: + pass + return None + + +def _mask(value: str) -> str: + """Return last 4 chars only, for safe logging.""" + if not value or len(value) < 4: + return "****" + return "*" * (len(value) - 4) + value[-4:] + + +# --------------------------------------------------------------------------- +# Base buyer +# --------------------------------------------------------------------------- + +class BaseBuyer(ABC): + """ + Abstract base for site-specific checkout automation. + Subclasses implement `_add_to_cart` and `_complete_checkout`. + """ + + site_name: str = "" + + def __init__(self): + # Profile store (dashboard) takes priority over .env + profile = load_from_profile_store() + if profile: + self.shipping = profile.get("shipping", {}) + self.payment = profile.get("payment", {}) + self._profile_creds = profile.get("site_credentials", {}) + logger.info(f"[{self.site_name}] Using unlocked dashboard profile") + else: + self.shipping = load_shipping() + self.payment = load_payment() + self._profile_creds = {} + logger.info(f"[{self.site_name}] Using .env credentials") + + # Set by caller (test_buy.py / main.py) to select the right browser session. + # None → falls back to the legacy shared session file (backward compat). + self.user_id: Optional[int] = None + + self._validate_credentials() + + def _validate_credentials(self): + missing = [] + required_shipping = ["first_name", "last_name", "address1", "city", "state", "zip", "email"] + for key in required_shipping: + if not self.shipping.get(key): + missing.append(f"SHIPPING_{key.upper()}") + payment_env_keys = { + "card_number": "CARD_NUMBER", + "expiry_month": "CARD_EXPIRY_MONTH", + "expiry_year": "CARD_EXPIRY_YEAR", + "cvv": "CARD_CVV", + } + for key, env_key in payment_env_keys.items(): + if not self.payment.get(key): + missing.append(env_key) + if missing: + logger.warning(f"[{self.site_name}] Missing credentials: {', '.join(missing)}") + + def credentials_complete(self) -> bool: + return bool( + self.shipping.get("first_name") + and self.shipping.get("address1") + and self.shipping.get("email") + and self.payment.get("card_number") + and self.payment.get("cvv") + ) + + # ------------------------------------------------------------------ + # Public entry point + # ------------------------------------------------------------------ + + def buy_product(self, product: Product) -> BuyResult: + dry_run = getattr(config, "AUTO_BUY_DRY_RUN", True) + + logger.info( + f"[{self.site_name}] {'DRY RUN - ' if dry_run else ''}Attempting purchase: {product.name}" + ) + + if not self.credentials_complete(): + return BuyResult( + success=False, + product=product, + message="Incomplete credentials — check .env file", + dry_run=dry_run, + ) + + # Price ceiling check + max_price = getattr(config, "AUTO_BUY_MAX_PRICE", 60.00) + if product.price: + try: + price_val = float(product.price.replace("$", "").replace(",", "")) + if price_val > max_price: + return BuyResult( + success=False, + product=product, + message=f"Price {product.price} exceeds ceiling ${max_price:.2f}", + dry_run=dry_run, + ) + except ValueError: + pass # Can't parse price — proceed anyway + + from playwright.sync_api import sync_playwright + from playwright_stealth import Stealth + + stealth = Stealth() + + # Browser session state — per-user per-site, saved via --setup. + # Path: data/browser_states//.json + # Falls back to shared data/browser_states/.json for backward compat. + state_base = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", "data", "browser_states")) + if self.user_id is not None: + state_file = os.path.join(state_base, str(self.user_id), f"{self.site_name}.json") + else: + state_file = os.path.join(state_base, f"{self.site_name}.json") + + with sync_playwright() as pw: + browser = pw.chromium.launch( + channel="chrome", + headless=False, + args=["--disable-blink-features=AutomationControlled"], + ) + ctx_kwargs = dict( + viewport={"width": 1920, "height": 1080}, + user_agent=( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/131.0.0.0 Safari/537.36" + ), + locale="en-US", + timezone_id="America/New_York", + ) + if os.path.exists(state_file): + logger.info(f"[{self.site_name}] Loading saved browser session from {state_file}") + ctx_kwargs["storage_state"] = state_file + else: + logger.info(f"[{self.site_name}] No saved session — run with --setup to log in once") + + context = browser.new_context(**ctx_kwargs) + stealth.apply_stealth_sync(context) + page = context.new_page() + + try: + result = self._run_purchase(page, product, dry_run) + except Exception as e: + logger.error(f"[{self.site_name}] Purchase error: {e}", exc_info=True) + result = BuyResult( + success=False, + product=product, + message=f"Unexpected error: {e}", + dry_run=dry_run, + ) + finally: + try: + page.close() + context.close() + browser.close() + except Exception: + pass + + return result + + @abstractmethod + def _run_purchase(self, page: Page, product: Product, dry_run: bool) -> BuyResult: + """Site-specific checkout flow. Implemented by each subclass.""" + ... + + # ------------------------------------------------------------------ + # Shared browser utilities + # ------------------------------------------------------------------ + + def _delay(self): + """Random human-like delay between actions.""" + lo = getattr(config, "AUTO_BUY_ACTION_DELAY_MIN", 0.8) + hi = getattr(config, "AUTO_BUY_ACTION_DELAY_MAX", 2.0) + time.sleep(random.uniform(lo, hi)) + + def _click(self, page: Page, selector: str, timeout: int = 10_000) -> bool: + """ + Click an element using three fallback strategies. + Returns True on success, False if element not found. + """ + try: + el = page.wait_for_selector(selector, timeout=timeout, state="visible") + if not el: + return False + el.scroll_into_view_if_needed() + self._delay() + try: + el.click() + return True + except Exception: + pass + try: + page.evaluate("el => el.dispatchEvent(new MouseEvent('click', {bubbles:true}))", el) + return True + except Exception: + pass + try: + el.focus() + page.keyboard.press("Enter") + return True + except Exception: + pass + except PlaywrightTimeout: + pass + return False + + def _fill(self, page: Page, selector: str, value: str, timeout: int = 10_000) -> bool: + """ + Fill an input field and dispatch the events React/Vue expect. + Never logs the value — callers are responsible for masking. + """ + try: + el = page.wait_for_selector(selector, timeout=timeout, state="visible") + if not el: + return False + el.scroll_into_view_if_needed() + self._delay() + el.click() + el.fill("") + el.type(value, delay=random.randint(40, 120)) + # Dispatch events that SPA frameworks listen to + page.evaluate( + """(selector) => { + const el = document.querySelector(selector); + if (!el) return; + ['input', 'change', 'blur'].forEach(name => + el.dispatchEvent(new Event(name, { bubbles: true })) + ); + }""", + selector, + ) + return True + except PlaywrightTimeout: + return False + + def _fill_in_frame(self, frame: Frame, selector: str, value: str) -> bool: + """Fill a field inside a payment iframe.""" + try: + el = frame.wait_for_selector(selector, timeout=8_000, state="visible") + if not el: + return False + el.click() + el.fill("") + el.type(value, delay=random.randint(50, 130)) + return True + except PlaywrightTimeout: + return False + + def _wait_for_url_contains(self, page: Page, fragment: str, timeout: int = 15_000) -> bool: + try: + page.wait_for_url(f"**{fragment}**", timeout=timeout) + return True + except PlaywrightTimeout: + return False + + def _is_visible(self, page: Page, selector: str, timeout: int = 3_000) -> bool: + try: + el = page.wait_for_selector(selector, timeout=timeout, state="visible") + return el is not None + except PlaywrightTimeout: + return False diff --git a/src/buyers/bestbuy_buyer.py b/src/buyers/bestbuy_buyer.py new file mode 100644 index 0000000..128a24d --- /dev/null +++ b/src/buyers/bestbuy_buyer.py @@ -0,0 +1,197 @@ +""" +Best Buy auto-buyer. + +Checkout flow: + Product page → Add to Cart → Cart → Checkout → Guest or account login + → Shipping → Payment → Place Order +""" + +import os +import logging + +from playwright.sync_api import Page, TimeoutError as PlaywrightTimeout + +from scrapers.base import Product +from .base_buyer import BaseBuyer, BuyResult, _mask +import config + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Selectors +# --------------------------------------------------------------------------- + +# Product page +SEL_ADD_TO_CART = ".add-to-cart-button, [data-button-state='ADD_TO_CART'], button.btn-primary.add-to-cart" +SEL_OOS_SIGNALS = ".btn-disabled.add-to-cart-button, [data-button-state='SOLD_OUT'], [data-button-state='COMING_SOON']" +SEL_CART_ICON = ".go-to-cart-button, a[href='/cart']" + +# Cart / checkout +SEL_CHECKOUT_BTN = ".checkout-buttons__checkout, button.btn-lg.btn-block, [data-track='Checkout - Top']" + +# Auth +SEL_CONTINUE_GUEST = "button.btn-secondary, [data-track='Guest Checkout Button'], .cia-guest-content button" +SEL_EMAIL_INPUT = "#email, input[name='email'], input[type='email']" +SEL_PASSWORD_INPUT = "#password, input[name='password'], input[type='password']" +SEL_SIGN_IN_BTN = "button[data-track='Sign In'], .cia-form__controls button[type='submit']" + +# Shipping +SEL_SHIP_FIRST = "#firstName, input[name='firstName']" +SEL_SHIP_LAST = "#lastName, input[name='lastName']" +SEL_SHIP_ADDR1 = "#street, input[name='street']" +SEL_SHIP_CITY = "#city, input[name='city']" +SEL_SHIP_STATE = "select#state, select[name='state']" +SEL_SHIP_ZIP = "#zipcode, input[name='zipcode']" +SEL_SHIP_PHONE = "#phone, input[name='phone']" +SEL_SHIP_EMAIL = "#email, input[name='email']" +SEL_SHIP_CONTINUE = "button.btn-primary[data-track='Continue - Delivery'], .order-summary__continue button" + +# Payment +SEL_CARD_NUM = "#credit-card-number, input[id*='credit-card'], input[autocomplete='cc-number']" +SEL_CARD_IFRAME = "iframe[id*='credit-card'], iframe[title*='Credit Card Number']" +SEL_EXPIRY = "#expiration-date, input[id*='expiration']" +SEL_CVV = "#cvv, input[id*='cvv'], input[autocomplete='cc-csc']" +SEL_CVV_IFRAME = "iframe[id*='cvv'], iframe[title*='CVV']" +SEL_PAY_CONTINUE = "button[data-track='Continue - Payment'], .payment-summary__continue button" + +# Place order +SEL_PLACE_ORDER = ".btn-place-order, button[data-track='Place Your Order'], button.btn-lg.place-order" + +OOS_SIGNALS = [".btn-disabled.add-to-cart-button", "[data-button-state='SOLD_OUT']"] + + +class BestBuyBuyer(BaseBuyer): + site_name = "bestbuy" + + def _site_email(self) -> str: + return os.environ.get("BESTBUY_EMAIL", "").strip() + + def _site_password(self) -> str: + return os.environ.get("BESTBUY_PASSWORD", "").strip() + + def _run_purchase(self, page: Page, product: Product, dry_run: bool) -> BuyResult: + logger.info(f"[bestbuy] Navigating to {product.url}") + page.goto(product.url, wait_until="domcontentloaded", timeout=30_000) + page.wait_for_timeout(3_000) + + # Check OOS + for sel in OOS_SIGNALS: + if self._is_visible(page, sel, timeout=2_000): + return BuyResult(False, product, "Product is out of stock", dry_run=dry_run) + + # Add to cart + if not self._click(page, SEL_ADD_TO_CART): + return BuyResult(False, product, "Could not click Add to Cart", dry_run=dry_run) + logger.info("[bestbuy] Clicked Add to Cart") + page.wait_for_timeout(2_500) + + # Go to cart + if not self._click(page, SEL_CART_ICON, timeout=8_000): + page.goto("https://www.bestbuy.com/cart", wait_until="domcontentloaded", timeout=20_000) + page.wait_for_timeout(2_000) + + # Checkout + if not self._click(page, SEL_CHECKOUT_BTN, timeout=8_000): + return BuyResult(False, product, "Could not click Checkout", dry_run=dry_run) + logger.info("[bestbuy] Proceeding to checkout") + page.wait_for_timeout(3_000) + + # Auth — try guest, fall back to login + self._handle_auth(page) + page.wait_for_timeout(2_000) + + # Shipping + if not self._fill_shipping(page): + return BuyResult(False, product, "Could not fill shipping", dry_run=dry_run) + + # Payment + if not self._fill_payment(page): + return BuyResult(False, product, "Could not fill payment", dry_run=dry_run) + + if dry_run: + logger.info("[bestbuy] DRY RUN — stopping before Place Order") + return BuyResult(True, product, "Dry run complete — checkout reached, order NOT placed", dry_run=True) + + if not self._click(page, SEL_PLACE_ORDER, timeout=10_000): + return BuyResult(False, product, "Could not click Place Order", dry_run=dry_run) + + logger.info("[bestbuy] Order placed!") + page.wait_for_timeout(4_000) + return BuyResult(True, product, "Order placed successfully") + + # ------------------------------------------------------------------ + + def _handle_auth(self, page: Page): + # Try guest checkout first + if self._is_visible(page, SEL_CONTINUE_GUEST, timeout=4_000): + logger.info("[bestbuy] Continuing as guest") + self._click(page, SEL_CONTINUE_GUEST) + return + + # Try account login + email = self._site_email() + password = self._site_password() + if email and password and self._is_visible(page, SEL_EMAIL_INPUT, timeout=4_000): + logger.info(f"[bestbuy] Logging in as {email}") + self._fill(page, SEL_EMAIL_INPUT, email) + self._fill(page, SEL_PASSWORD_INPUT, password) + self._click(page, SEL_SIGN_IN_BTN) + page.wait_for_timeout(3_000) + + def _fill_shipping(self, page: Page) -> bool: + if not self._is_visible(page, SEL_SHIP_FIRST, timeout=6_000): + logger.info("[bestbuy] Shipping form not visible — pre-filled or skipped") + return True + s = self.shipping + logger.info("[bestbuy] Filling shipping") + self._fill(page, SEL_SHIP_FIRST, s["first_name"]) + self._fill(page, SEL_SHIP_LAST, s["last_name"]) + self._fill(page, SEL_SHIP_ADDR1, s["address1"]) + self._fill(page, SEL_SHIP_CITY, s["city"]) + try: + page.select_option(SEL_SHIP_STATE, value=s["state"]) + except Exception: + pass + self._fill(page, SEL_SHIP_ZIP, s["zip"]) + if s.get("phone"): + self._fill(page, SEL_SHIP_PHONE, s["phone"]) + if s.get("email"): + self._fill(page, SEL_SHIP_EMAIL, s["email"]) + self._click(page, SEL_SHIP_CONTINUE) + page.wait_for_timeout(2_000) + return True + + def _fill_payment(self, page: Page) -> bool: + p = self.payment + logger.info(f"[bestbuy] Filling payment (card ending {_mask(p['card_number'])})") + + # Card number — iframe or plain + card_ok = self._fill_payment_iframe_or_plain(page, SEL_CARD_IFRAME, SEL_CARD_NUM, p["card_number"]) + if not card_ok: + logger.warning("[bestbuy] Could not fill card number") + return False + + # Expiry (BestBuy uses a single MM/YY input) + expiry = f"{p['expiry_month'].zfill(2)}/{p['expiry_year'][-2:]}" + self._fill(page, SEL_EXPIRY, expiry, timeout=5_000) + + # CVV — iframe or plain + cvv_ok = self._fill_payment_iframe_or_plain(page, SEL_CVV_IFRAME, SEL_CVV, p["cvv"]) + if not cvv_ok: + logger.warning("[bestbuy] Could not fill CVV") + return False + + self._click(page, SEL_PAY_CONTINUE, timeout=6_000) + page.wait_for_timeout(2_000) + return True + + def _fill_payment_iframe_or_plain(self, page: Page, iframe_sel: str, plain_sel: str, value: str) -> bool: + try: + frame_el = page.wait_for_selector(iframe_sel, timeout=4_000) + if frame_el: + frame = frame_el.content_frame() + if frame and self._fill_in_frame(frame, "input", value): + return True + except PlaywrightTimeout: + pass + return self._fill(page, plain_sel, value, timeout=5_000) diff --git a/src/buyers/gamestop_buyer.py b/src/buyers/gamestop_buyer.py new file mode 100644 index 0000000..b254a28 --- /dev/null +++ b/src/buyers/gamestop_buyer.py @@ -0,0 +1,301 @@ +""" +GameStop auto-buyer. + +Uses the existing undetected-chromedriver stealth browser (Selenium) since +GameStop has Cloudflare — the same session that scraping already bypassed. + +Checkout flow: + Product page → Add to Cart → Checkout → Login or Guest + → Shipping → Payment → Place Order +""" + +import os +import logging +import time +import random + +from scrapers.base import Product +from .base_buyer import BaseBuyer, BuyResult, _mask +import config + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Selectors (Selenium By.CSS_SELECTOR style strings) +# --------------------------------------------------------------------------- + +SEL_ADD_TO_CART = ".add-to-cart, .btn-add-to-cart, [data-testid='add-to-cart-btn'], button.primary-button" +SEL_OOS = ".sold-out, .out-of-stock, [class*='soldOut'], [class*='outOfStock']" +SEL_CART_BTN = "a[href='/cart'], .cart-link, [data-testid='cart-icon']" +SEL_CHECKOUT_BTN = ".checkout-btn, button.primary-button[data-testid*='checkout'], a[href*='checkout']" + +# Auth +SEL_GUEST_BTN = ".guest-checkout-btn, button[data-testid*='guest'], [class*='guestCheckout']" +SEL_EMAIL = "#email, input[name='email'], input[type='email']" +SEL_PASSWORD = "#password, input[name='password'], input[type='password']" +SEL_SIGN_IN = "button[type='submit'], .login-btn, [data-testid='sign-in-btn']" + +# Shipping +SEL_SHIP_FIRST = "#firstName, input[name='firstName']" +SEL_SHIP_LAST = "#lastName, input[name='lastName']" +SEL_SHIP_ADDR = "#address1, input[name='address1']" +SEL_SHIP_CITY = "#city, input[name='city']" +SEL_SHIP_STATE = "select#state, select[name='state']" +SEL_SHIP_ZIP = "#zipCode, input[name='zipCode'], input[name='zip']" +SEL_SHIP_PHONE = "#phone, input[name='phone']" +SEL_SHIP_CONTINUE = "button.primary-button[type='submit'], .continue-btn" + +# Payment +SEL_CARD_NUM = "#cardNumber, input[name='cardNumber'], input[autocomplete='cc-number']" +SEL_EXPIRY_MONTH = "select[name='expMonth'], select[id*='expMonth']" +SEL_EXPIRY_YEAR = "select[name='expYear'], select[id*='expYear']" +SEL_CVV = "#cvv, input[name='cvv'], input[autocomplete='cc-csc']" +SEL_PAY_CONTINUE = "button.primary-button[type='submit'], .payment-continue" + +SEL_PLACE_ORDER = ".place-order-btn, button[data-testid*='place-order'], .submit-order" + + +class GameStopBuyer(BaseBuyer): + """ + GameStop buyer re-uses the scraper's stealth Selenium browser so + Cloudflare cookies already exist in the session. + """ + + site_name = "gamestop" + + def _site_email(self) -> str: + return os.environ.get("GAMESTOP_EMAIL", "").strip() + + def _site_password(self) -> str: + return os.environ.get("GAMESTOP_PASSWORD", "").strip() + + # ------------------------------------------------------------------ + # Override buy_product to use Selenium instead of Playwright + # ------------------------------------------------------------------ + + def buy_product(self, product: Product) -> BuyResult: + dry_run = getattr(config, "AUTO_BUY_DRY_RUN", True) + + if not self.credentials_complete(): + return BuyResult(False, product, "Incomplete credentials — check .env", dry_run=dry_run) + + max_price = getattr(config, "AUTO_BUY_MAX_PRICE", 60.00) + if product.price: + try: + price_val = float(product.price.replace("$", "").replace(",", "")) + if price_val > max_price: + return BuyResult(False, product, f"Price {product.price} exceeds ceiling ${max_price:.2f}", dry_run=dry_run) + except ValueError: + pass + + from tools.stealth_browser import StealthBrowser + from config import GAMESTOP_HEADLESS + + browser = StealthBrowser(headless=GAMESTOP_HEADLESS, session_name="gamestop_buyer") + browser.start() + driver = browser.driver + + try: + result = self._run_selenium_purchase(driver, product, dry_run) + except Exception as e: + logger.error(f"[gamestop] Purchase error: {e}", exc_info=True) + result = BuyResult(False, product, f"Unexpected error: {e}", dry_run=dry_run) + finally: + try: + browser.stop() + except Exception: + pass + + return result + + # Playwright not used for GameStop — override with no-op + def _run_purchase(self, page, product, dry_run): + return BuyResult(False, product, "Should not be called for GameStop", dry_run=dry_run) + + # ------------------------------------------------------------------ + # Selenium purchase flow + # ------------------------------------------------------------------ + + def _run_selenium_purchase(self, driver, product: Product, dry_run: bool) -> BuyResult: + from selenium.webdriver.common.by import By + from selenium.webdriver.support.ui import WebDriverWait, Select + from selenium.webdriver.support import expected_conditions as EC + from selenium.common.exceptions import TimeoutException, NoSuchElementException + + wait = WebDriverWait(driver, 10) + + logger.info(f"[gamestop] Navigating to {product.url}") + driver.get(product.url) + self._sel_sleep() + + # OOS check + try: + driver.find_element(By.CSS_SELECTOR, SEL_OOS) + return BuyResult(False, product, "Product is out of stock", dry_run=dry_run) + except NoSuchElementException: + pass + + # Add to cart + if not self._sel_click(driver, SEL_ADD_TO_CART): + return BuyResult(False, product, "Could not click Add to Cart", dry_run=dry_run) + logger.info("[gamestop] Clicked Add to Cart") + self._sel_sleep() + + # Navigate to cart + driver.get("https://www.gamestop.com/cart") + self._sel_sleep() + + # Checkout + if not self._sel_click(driver, SEL_CHECKOUT_BTN): + return BuyResult(False, product, "Could not click Checkout", dry_run=dry_run) + logger.info("[gamestop] Proceeding to checkout") + self._sel_sleep(2) + + # Auth + self._sel_handle_auth(driver, wait) + self._sel_sleep() + + # Shipping + if not self._sel_fill_shipping(driver, wait): + return BuyResult(False, product, "Could not fill shipping", dry_run=dry_run) + + # Payment + if not self._sel_fill_payment(driver, wait): + return BuyResult(False, product, "Could not fill payment", dry_run=dry_run) + + if dry_run: + logger.info("[gamestop] DRY RUN — stopping before Place Order") + return BuyResult(True, product, "Dry run complete — checkout reached, order NOT placed", dry_run=True) + + if not self._sel_click(driver, SEL_PLACE_ORDER): + return BuyResult(False, product, "Could not click Place Order", dry_run=dry_run) + + logger.info("[gamestop] Order placed!") + self._sel_sleep(4) + return BuyResult(True, product, "Order placed successfully") + + # ------------------------------------------------------------------ + # Selenium helpers + # ------------------------------------------------------------------ + + def _sel_sleep(self, base: float = 1.5): + lo = getattr(config, "AUTO_BUY_ACTION_DELAY_MIN", 0.8) + hi = getattr(config, "AUTO_BUY_ACTION_DELAY_MAX", 2.0) + time.sleep(random.uniform(lo, hi) * (base / 1.5)) + + def _sel_click(self, driver, selector: str, timeout: int = 10) -> bool: + from selenium.webdriver.common.by import By + from selenium.webdriver.support.ui import WebDriverWait + from selenium.webdriver.support import expected_conditions as EC + from selenium.common.exceptions import TimeoutException + try: + el = WebDriverWait(driver, timeout).until( + EC.element_to_be_clickable((By.CSS_SELECTOR, selector)) + ) + driver.execute_script("arguments[0].scrollIntoView({block:'center'})", el) + self._sel_sleep(0.5) + try: + el.click() + return True + except Exception: + driver.execute_script("arguments[0].click()", el) + return True + except TimeoutException: + return False + + def _sel_fill(self, driver, selector: str, value: str, timeout: int = 10) -> bool: + from selenium.webdriver.common.by import By + from selenium.webdriver.support.ui import WebDriverWait + from selenium.webdriver.support import expected_conditions as EC + from selenium.common.exceptions import TimeoutException + from selenium.webdriver.common.keys import Keys + try: + el = WebDriverWait(driver, timeout).until( + EC.visibility_of_element_located((By.CSS_SELECTOR, selector)) + ) + el.click() + el.send_keys(Keys.CONTROL + "a") + el.send_keys(Keys.DELETE) + for char in value: + el.send_keys(char) + time.sleep(random.uniform(0.04, 0.12)) + driver.execute_script( + "arguments[0].dispatchEvent(new Event('input',{bubbles:true}));" + "arguments[0].dispatchEvent(new Event('change',{bubbles:true}));", + el + ) + return True + except TimeoutException: + return False + + def _sel_select(self, driver, selector: str, value: str) -> bool: + from selenium.webdriver.common.by import By + from selenium.webdriver.support.ui import Select + from selenium.common.exceptions import NoSuchElementException + try: + el = driver.find_element(By.CSS_SELECTOR, selector) + Select(el).select_by_value(value) + return True + except NoSuchElementException: + return False + + def _sel_handle_auth(self, driver, wait): + from selenium.webdriver.common.by import By + from selenium.common.exceptions import NoSuchElementException + + # Try guest checkout first + if self._sel_click(driver, SEL_GUEST_BTN, timeout=4): + logger.info("[gamestop] Continuing as guest") + return + + # Account login + email = self._site_email() + password = self._site_password() + try: + driver.find_element(By.CSS_SELECTOR, SEL_EMAIL) + if email and password: + logger.info(f"[gamestop] Logging in as {email}") + self._sel_fill(driver, SEL_EMAIL, email) + self._sel_fill(driver, SEL_PASSWORD, password) + self._sel_click(driver, SEL_SIGN_IN) + self._sel_sleep(3) + except NoSuchElementException: + pass + + def _sel_fill_shipping(self, driver, wait) -> bool: + from selenium.webdriver.common.by import By + from selenium.common.exceptions import NoSuchElementException + try: + driver.find_element(By.CSS_SELECTOR, SEL_SHIP_FIRST) + except NoSuchElementException: + logger.info("[gamestop] Shipping form not visible — likely pre-filled") + return True + + s = self.shipping + logger.info("[gamestop] Filling shipping") + self._sel_fill(driver, SEL_SHIP_FIRST, s["first_name"]) + self._sel_fill(driver, SEL_SHIP_LAST, s["last_name"]) + self._sel_fill(driver, SEL_SHIP_ADDR, s["address1"]) + self._sel_fill(driver, SEL_SHIP_CITY, s["city"]) + self._sel_select(driver, SEL_SHIP_STATE, s["state"]) + self._sel_fill(driver, SEL_SHIP_ZIP, s["zip"]) + if s.get("phone"): + self._sel_fill(driver, SEL_SHIP_PHONE, s["phone"]) + self._sel_click(driver, SEL_SHIP_CONTINUE) + self._sel_sleep(2) + return True + + def _sel_fill_payment(self, driver, wait) -> bool: + p = self.payment + logger.info(f"[gamestop] Filling payment (card ending {_mask(p['card_number'])})") + if not self._sel_fill(driver, SEL_CARD_NUM, p["card_number"]): + logger.warning("[gamestop] Could not fill card number") + return False + self._sel_select(driver, SEL_EXPIRY_MONTH, p["expiry_month"].zfill(2)) + self._sel_select(driver, SEL_EXPIRY_YEAR, p["expiry_year"]) + if not self._sel_fill(driver, SEL_CVV, p["cvv"]): + logger.warning("[gamestop] Could not fill CVV") + return False + self._sel_click(driver, SEL_PAY_CONTINUE) + self._sel_sleep(2) + return True diff --git a/src/buyers/target_buyer.py b/src/buyers/target_buyer.py new file mode 100644 index 0000000..577ab12 --- /dev/null +++ b/src/buyers/target_buyer.py @@ -0,0 +1,310 @@ +""" +Target.com auto-buyer. + +Checkout flow: + Product page → Add to Cart → Cart → Checkout (login if needed) + → Shipping (pre-filled if logged in) → Payment (saved card or form) → Place Order + +Login notes: + Target blocks automated logins with a server-side error. + Use a saved browser session instead — see docs/auto-buy-setup.md. +""" + +import os +import logging + +from playwright.sync_api import Page, TimeoutError as PlaywrightTimeout + +from scrapers.base import Product +from .base_buyer import BaseBuyer, BuyResult, _mask +import config + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Selectors — organized by checkout stage +# --------------------------------------------------------------------------- + +# Product page +SEL_ADD_TO_CART = "[data-test='add-to-cart-button'], [data-test='shippingButton'], button[data-test*='add-to-cart'], button[aria-label*='Add to cart' i]" +SEL_CHECKOUT_BTN = "[data-test='checkout-button']" + +# Login — only used as fallback when browser session is missing/expired +SEL_SIGN_IN_BTN = "[data-test='checkout-sign-in-btn'], button[data-test*='sign-in'], [data-test='accountNav-signIn'], button[id='account-sign-in']" +SEL_EMAIL_INPUT = "input[id='username'], input[name='username'], input[id='email'], input[type='email'][autocomplete*='email'], input[autocomplete='username']" +SEL_PASSWORD_INPUT = "input[id='password'], input[name='password'], input[type='password']" +SEL_LOGIN_SUBMIT = "[data-test='sign-in-button'], [data-test='continue-button'], [data-test='signIn-submit'], [data-test='continueButton'], button[id='login']" +SEL_USE_PASSWORD_BTN = "text=Enter your password" + +# Shipping — usually pre-filled when logged in +SEL_SHIP_FIRST = "input[id='firstName'], [data-test='firstName']" +SEL_SHIP_LAST = "input[id='lastName'], [data-test='lastName']" +SEL_SHIP_ADDR1 = "input[id='address'], [data-test='address']" +SEL_SHIP_CITY = "input[id='city'], [data-test='city']" +SEL_SHIP_STATE = "select[id='state'], [data-test='state']" +SEL_SHIP_ZIP = "input[id='zip'], [data-test='zip']" +SEL_SHIP_PHONE = "input[id='phone'], [data-test='phone']" +SEL_SHIP_CONTINUE = "[data-test='save-and-continue-button'], button[type='submit']" + +# Payment +SEL_PAY_CONTINUE = "[data-test='pay-and-continue'], [data-test='payment-continue']" +SEL_CARD_IFRAME = "iframe[name*='credit-card-number'], iframe[id*='card-number'], iframe[title*='Card Number']" +SEL_CVV_IFRAME = "iframe[name*='credit-card-cvv'], iframe[id*='cvv'], iframe[title*='CVV']" +SEL_CARD_INPUT = "input[id*='cardNumber'], input[name*='cardNumber'], input[autocomplete='cc-number']" +SEL_CVV_INPUT = "input[id*='cvv'], input[name*='cvv'], input[autocomplete='cc-csc']" +SEL_EXPIRY_MONTH = "select[name='expMonth'], select[id*='expMonth']" +SEL_EXPIRY_YEAR = "select[name='expYear'], select[id*='expYear']" +SEL_NAME_ON_CARD = "input[id*='nameOnCard'], input[autocomplete='cc-name']" + +# Place order +SEL_PLACE_ORDER = "[data-test='place-order-btn'], button[data-test='placeOrder']" + +# Popups / modals to dismiss +SEL_DECLINE_PROTECT = "button[data-test='decline-button'], [data-test='declineButton']" +SEL_POPUP_CONTINUE = "[data-test='continue-button'], [data-test='continueButton']" +SEL_POPUP_OK = "button[data-test='OK'], [data-test='okButton']" + +# Out-of-stock signals on product page +OOS_SELECTORS = [ + "[data-test='outOfStockMessage']", + "[data-test='soldOutMessage']", + "button[data-test='add-to-cart-button'][disabled]", +] + + +class TargetBuyer(BaseBuyer): + site_name = "target" + + def _site_email(self) -> str: + return os.environ.get("TARGET_EMAIL", "").strip() + + def _site_password(self) -> str: + return os.environ.get("TARGET_PASSWORD", "").strip() + + # ------------------------------------------------------------------ + # Main purchase flow + # ------------------------------------------------------------------ + + def _run_purchase(self, page: Page, product: Product, dry_run: bool) -> BuyResult: + # 1. Navigate to product + logger.info(f"[target] Navigating to {product.url}") + page.goto(product.url, wait_until="domcontentloaded", timeout=30_000) + page.wait_for_timeout(3_000) + + # 2. Check it's actually in stock + for oos_sel in OOS_SELECTORS: + if self._is_visible(page, oos_sel, timeout=2_000): + return BuyResult(False, product, "Product is out of stock on page", dry_run=dry_run) + + # 3. Add to cart — also matches "1 in cart" button when already added + SEL_ADD_OR_IN_CART = SEL_ADD_TO_CART + ", [data-test='cart-button'], button[data-test*='cart']" + if not self._click(page, SEL_ADD_OR_IN_CART): + # Recover by going straight to cart + page.goto("https://www.target.com/cart", wait_until="domcontentloaded", timeout=20_000) + page.wait_for_timeout(2_000) + if self._is_visible(page, "text=Your cart is empty", timeout=3_000): + return BuyResult(False, product, "Could not add to cart", dry_run=dry_run) + logger.info("[target] Item already in cart — proceeding directly to checkout") + else: + logger.info("[target] Clicked Add to Cart") + page.wait_for_timeout(2_000) + + # 4. Dismiss any upsell popup + self._dismiss_popups(page) + + # 5. Go to checkout + if not self._click(page, SEL_CHECKOUT_BTN, timeout=8_000): + page.goto("https://www.target.com/cart", wait_until="domcontentloaded", timeout=20_000) + page.wait_for_timeout(2_000) + if not self._click(page, SEL_CHECKOUT_BTN, timeout=8_000): + return BuyResult(False, product, "Could not reach checkout", dry_run=dry_run) + logger.info("[target] Proceeding to checkout") + page.wait_for_timeout(3_000) + + # 6. Login if needed (fallback — normally handled by saved browser session) + self._login_if_needed(page) + page.wait_for_timeout(3_000) + + # 7. After login Target returns to /cart — click checkout again + if "/cart" in page.url: + logger.info("[target] Back on cart after login — clicking checkout again") + if not self._click(page, SEL_CHECKOUT_BTN, timeout=8_000): + return BuyResult(False, product, "Could not proceed to checkout after login", dry_run=dry_run) + page.wait_for_timeout(4_000) + + # 8. Shipping — fill if form is present (not pre-filled from account) + self._fill_shipping_if_needed(page) + + # 9. Payment — skip if saved card already selected + if not self._payment_already_selected(page): + if not self._fill_payment(page): + return BuyResult(False, product, "Could not fill payment details", dry_run=dry_run) + + # 10. Dismiss any final popups + self._dismiss_popups(page) + + # 11. Place order (or stop here for dry run) + if dry_run: + logger.info("[target] DRY RUN — stopping before Place Order") + return BuyResult(True, product, "Dry run complete — checkout reached, order NOT placed", dry_run=True) + + if not self._click(page, SEL_PLACE_ORDER, timeout=10_000): + return BuyResult(False, product, "Could not click Place Order", dry_run=dry_run) + + logger.info("[target] Order placed!") + page.wait_for_timeout(4_000) + order_number = self._extract_order_number(page) + return BuyResult(True, product, "Order placed successfully", order_number=order_number) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _login_if_needed(self, page: Page): + """Handle Target's checkout sign-in modal (fallback — prefer saved session). + + Target login has up to three stages: + Stage A — email input visible → fill email + Enter + Stage B — password-options screen → click "Enter your password" + Stage C — password input → fill + submit + """ + email = self._profile_creds.get("target_email") or self._site_email() + password = self._profile_creds.get("target_password") or self._site_password() + + if not email or not password: + return + + # Stage A — fill email if visible + email_visible = self._is_visible(page, SEL_EMAIL_INPUT, timeout=5_000) + if not email_visible and self._is_visible(page, SEL_SIGN_IN_BTN, timeout=3_000): + self._click(page, SEL_SIGN_IN_BTN) + page.wait_for_timeout(2_000) + email_visible = self._is_visible(page, SEL_EMAIL_INPUT, timeout=5_000) + + if email_visible: + logger.info(f"[target] Filling email: {email}") + self._fill(page, SEL_EMAIL_INPUT, email) + page.wait_for_timeout(600) + try: + page.locator(SEL_EMAIL_INPUT).press("Enter") + except Exception: + self._click(page, SEL_LOGIN_SUBMIT) + page.wait_for_timeout(3_000) + else: + logger.info("[target] Email input not visible — checking for password options screen") + + # Stage B — click "Enter your password" accordion + if self._is_visible(page, SEL_USE_PASSWORD_BTN, timeout=4_000): + logger.info("[target] Clicking 'Enter your password' option") + self._click(page, SEL_USE_PASSWORD_BTN) + page.wait_for_timeout(1_500) + + # Stage C — fill password + if not self._is_visible(page, SEL_PASSWORD_INPUT, timeout=5_000): + logger.info("[target] No password field — assuming already logged in") + return + + logger.info("[target] Filling password") + self._fill(page, SEL_PASSWORD_INPUT, password) + page.wait_for_timeout(400) + + submitted = self._click(page, SEL_LOGIN_SUBMIT, timeout=4_000) + if not submitted: + submitted = self._click(page, "text=Sign in with password", timeout=4_000) + if not submitted: + try: + page.locator(SEL_PASSWORD_INPUT).press("Enter") + except Exception: + pass + + page.wait_for_timeout(5_000) + logger.info(f"[target] Login submitted — now at: {page.url}") + + def _fill_shipping_if_needed(self, page: Page): + if not self._is_visible(page, SEL_SHIP_FIRST, timeout=4_000): + logger.info("[target] Shipping form not visible — likely pre-filled from account") + return + s = self.shipping + logger.info("[target] Filling shipping form") + self._fill(page, SEL_SHIP_FIRST, s["first_name"]) + self._fill(page, SEL_SHIP_LAST, s["last_name"]) + self._fill(page, SEL_SHIP_ADDR1, s["address1"]) + self._fill(page, SEL_SHIP_CITY, s["city"]) + try: + page.select_option(SEL_SHIP_STATE, value=s["state"]) + except Exception: + pass + self._fill(page, SEL_SHIP_ZIP, s["zip"]) + if s.get("phone"): + self._fill(page, SEL_SHIP_PHONE, s["phone"]) + self._click(page, SEL_SHIP_CONTINUE) + page.wait_for_timeout(2_000) + + def _payment_already_selected(self, page: Page) -> bool: + """Return True if Target has a saved card pre-selected on the checkout page.""" + for sel in ["[data-test='payment-method']", "[data-test='saved-credit-card']", + "text=Visa *", "text=Mastercard *", "text=Amex *"]: + if self._is_visible(page, sel, timeout=2_000): + logger.info("[target] Saved payment method detected — skipping payment fill") + return True + return False + + def _fill_payment(self, page: Page) -> bool: + p = self.payment + logger.info(f"[target] Filling payment (card ending {_mask(p['card_number'])})") + + if self._is_visible(page, SEL_NAME_ON_CARD, timeout=3_000): + self._fill(page, SEL_NAME_ON_CARD, + p["name_on_card"] or f"{self.shipping['first_name']} {self.shipping['last_name']}") + + card_ok = self._fill_payment_field(page, SEL_CARD_IFRAME, SEL_CARD_INPUT, p["card_number"]) + if not card_ok: + logger.warning("[target] Could not fill card number") + return False + + try: + page.select_option(SEL_EXPIRY_MONTH, value=p["expiry_month"].zfill(2)) + except Exception: + pass + try: + page.select_option(SEL_EXPIRY_YEAR, value=p["expiry_year"]) + except Exception: + pass + + cvv_ok = self._fill_payment_field(page, SEL_CVV_IFRAME, SEL_CVV_INPUT, p["cvv"]) + if not cvv_ok: + logger.warning("[target] Could not fill CVV") + return False + + self._click(page, SEL_PAY_CONTINUE, timeout=6_000) + page.wait_for_timeout(2_000) + return True + + def _fill_payment_field(self, page: Page, iframe_sel: str, fallback_sel: str, value: str) -> bool: + """Fill a field that may be inside a payment iframe.""" + for sel in iframe_sel.split(","): + sel = sel.strip() + try: + frame_el = page.wait_for_selector(sel, timeout=3_000) + if frame_el: + frame = frame_el.content_frame() + if frame and self._fill_in_frame(frame, "input", value): + return True + except PlaywrightTimeout: + continue + return self._fill(page, fallback_sel, value, timeout=5_000) + + def _dismiss_popups(self, page: Page): + for sel in [SEL_DECLINE_PROTECT, SEL_POPUP_CONTINUE, SEL_POPUP_OK]: + if self._is_visible(page, sel, timeout=2_000): + self._click(page, sel) + page.wait_for_timeout(800) + + def _extract_order_number(self, page: Page) -> str: + try: + el = page.query_selector("[data-test='order-number'], .order-number, [class*='orderNumber']") + if el: + return el.inner_text().strip() + except Exception: + pass + return "" diff --git a/src/database.py b/src/database.py index 5666844..9de4483 100644 --- a/src/database.py +++ b/src/database.py @@ -150,6 +150,17 @@ class Database: ) """) + # Checkout profiles table — stores AES-encrypted shipping + payment data + cursor.execute(""" + CREATE TABLE IF NOT EXISTS user_profiles ( + user_id INTEGER PRIMARY KEY, + salt BLOB NOT NULL, + ciphertext BLOB NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """) + # Create indexes for common queries cursor.execute("CREATE INDEX IF NOT EXISTS idx_products_site ON products(site)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_products_category ON products(category)") @@ -1068,6 +1079,49 @@ class Database: """Update user's location settings""" return self.update_user(user_id, zip_code=zip_code, radius_miles=radius_miles) + # ==================== Checkout Profile Methods ==================== + + def save_user_profile(self, user_id: int, salt: bytes, ciphertext: bytes): + """Save or replace an encrypted checkout profile for a user.""" + with self.get_connection() as conn: + conn.execute( + """INSERT INTO user_profiles (user_id, salt, ciphertext, updated_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(user_id) DO UPDATE SET + salt=excluded.salt, + ciphertext=excluded.ciphertext, + updated_at=CURRENT_TIMESTAMP""", + (user_id, salt, ciphertext), + ) + + def get_user_profile(self, user_id: int) -> Optional[Dict]: + """Return {salt, ciphertext} for a user, or None if no profile saved.""" + with self.get_connection() as conn: + conn.row_factory = sqlite3.Row + row = conn.execute( + "SELECT salt, ciphertext, updated_at FROM user_profiles WHERE user_id = ?", + (user_id,), + ).fetchone() + if not row: + return None + return {"salt": row["salt"], "ciphertext": row["ciphertext"], "updated_at": row["updated_at"]} + + def delete_user_profile(self, user_id: int) -> bool: + """Delete a user's checkout profile.""" + with self.get_connection() as conn: + cursor = conn.execute( + "DELETE FROM user_profiles WHERE user_id = ?", (user_id,) + ) + return cursor.rowcount > 0 + + def user_has_profile(self, user_id: int) -> bool: + """Return True if a saved (encrypted) profile exists for this user.""" + with self.get_connection() as conn: + row = conn.execute( + "SELECT 1 FROM user_profiles WHERE user_id = ?", (user_id,) + ).fetchone() + return row is not None + # ==================== Stats Summary ==================== def get_dashboard_stats(self) -> Dict: diff --git a/src/profile_store.py b/src/profile_store.py new file mode 100644 index 0000000..e92517c --- /dev/null +++ b/src/profile_store.py @@ -0,0 +1,147 @@ +""" +Encrypted checkout profile storage. + +Security design: +- AES-128-CBC + HMAC-SHA256 via Fernet (cryptography library) +- Key derived from user's password with PBKDF2-SHA256 + random salt (100k iterations) +- Encrypted blob stored in SQLite — only the salt + ciphertext ever touch disk +- Plaintext profiles kept in memory only, cleared on lock or server restart +- Card number and CVV never returned to the frontend — only masked versions +- Password is never stored anywhere + +Each cousin: + 1. Sets up their profile in the dashboard (shipping + card) + 2. Enters their password — data encrypted and saved + 3. Before a buy session, clicks "Unlock" and enters password + 4. Decrypted profile lives in memory; auto-buyer uses it + 5. Clicking "Lock" or restarting the server clears it +""" + +import os +import json +import base64 +import hashlib +import logging +from typing import Optional + +logger = logging.getLogger(__name__) + +try: + from cryptography.fernet import Fernet, InvalidToken + from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + from cryptography.hazmat.primitives import hashes + _CRYPTO_AVAILABLE = True +except ImportError: + _CRYPTO_AVAILABLE = False + logger.error("cryptography package not installed. Run: pip install cryptography") + + +# --------------------------------------------------------------------------- +# In-memory unlock cache {user_id: {shipping: ..., payment: ...}} +# Cleared on server restart — intentional. +# --------------------------------------------------------------------------- +_unlocked: dict[int, dict] = {} + + +def _derive_key(password: str, salt: bytes) -> bytes: + """Derive a 32-byte Fernet key from password + salt using PBKDF2.""" + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=salt, + iterations=100_000, + ) + return base64.urlsafe_b64encode(kdf.derive(password.encode("utf-8"))) + + +def encrypt_profile(password: str, shipping: dict, payment: dict, site_credentials: dict = None) -> tuple[bytes, bytes]: + """ + Encrypt a checkout profile. + Returns (salt, ciphertext) — both must be stored together. + """ + if not _CRYPTO_AVAILABLE: + raise RuntimeError("cryptography package not installed") + salt = os.urandom(16) + key = _derive_key(password, salt) + f = Fernet(key) + plaintext = json.dumps({ + "shipping": shipping, + "payment": payment, + "site_credentials": site_credentials or {}, + }).encode() + return salt, f.encrypt(plaintext) + + +def decrypt_profile(password: str, salt: bytes, ciphertext: bytes) -> Optional[dict]: + """ + Decrypt a profile. Returns dict with 'shipping' and 'payment' keys, + or None if the password is wrong. + """ + if not _CRYPTO_AVAILABLE: + raise RuntimeError("cryptography package not installed") + try: + key = _derive_key(password, salt) + f = Fernet(key) + plaintext = f.decrypt(ciphertext) + return json.loads(plaintext.decode()) + except (InvalidToken, Exception): + return None # Wrong password or corrupted data + + +# --------------------------------------------------------------------------- +# High-level API used by the rest of the app +# --------------------------------------------------------------------------- + +def save_profile(user_id: int, password: str, shipping: dict, payment: dict, db, site_credentials: dict = None) -> bool: + """Encrypt and persist a profile to the database. Raises on error.""" + salt, ciphertext = encrypt_profile(password, shipping, payment, site_credentials) + db.save_user_profile(user_id, salt, ciphertext) + _unlocked[user_id] = {"shipping": shipping, "payment": payment} + logger.info(f"Profile saved and unlocked for user {user_id}") + return True + + +def unlock_profile(user_id: int, password: str, db) -> bool: + """Decrypt profile from DB and cache it in memory. Returns True on success.""" + row = db.get_user_profile(user_id) + if not row: + return False + profile = decrypt_profile(password, row["salt"], row["ciphertext"]) + if profile is None: + return False # Wrong password + _unlocked[user_id] = profile + logger.info(f"Profile unlocked for user {user_id}") + return True + + +def lock_profile(user_id: int): + """Clear profile from memory.""" + _unlocked.pop(user_id, None) + logger.info(f"Profile locked for user {user_id}") + + +def get_unlocked_profile(user_id: int) -> Optional[dict]: + """Return the in-memory decrypted profile, or None if locked.""" + return _unlocked.get(user_id) + + +def is_unlocked(user_id: int) -> bool: + return user_id in _unlocked + + +def masked_card(user_id: int) -> Optional[str]: + """Return masked card number for display (e.g. '**** **** **** 4242').""" + profile = _unlocked.get(user_id) + if not profile: + return None + num = profile.get("payment", {}).get("card_number", "") + if len(num) >= 4: + return "**** **** **** " + num[-4:] + return None + + +def get_any_unlocked_profile() -> Optional[dict]: + """Return the first unlocked profile (for single-buyer mode).""" + for profile in _unlocked.values(): + return profile + return None diff --git a/test_buy.py b/test_buy.py new file mode 100644 index 0000000..0546626 --- /dev/null +++ b/test_buy.py @@ -0,0 +1,190 @@ +""" +Quick auto-buyer test — runs a dry-run checkout on any product URL. + +Usage (interactive): + python test_buy.py target https://www.target.com/p/... + python test_buy.py bestbuy https://www.bestbuy.com/site/... + python test_buy.py gamestop https://www.gamestop.com/products/... + +Non-interactive (set in .env or environment): + TEST_USER_ID=1 + TEST_PROFILE_PASSWORD=yourpassword + +One-time login setup per user (saves browser session so future runs skip sign-in): + python test_buy.py --setup target --user-id 1 + python test_buy.py --setup target --user-id 2 +""" + +import os +import sys +import logging +import getpass +from dotenv import load_dotenv +load_dotenv() + +logging.basicConfig( + level=logging.INFO, + format="%(name)s — %(message)s", + handlers=[logging.StreamHandler()] +) + +# ── Setup mode ──────────────────────────────────────────────────────────────── +if len(sys.argv) >= 3 and sys.argv[1] == "--setup": + site = sys.argv[2].lower() + + # Optional --user-id N + _uid = None + if "--user-id" in sys.argv: + _uid = int(sys.argv[sys.argv.index("--user-id") + 1]) + + if _uid is not None: + state_dir = os.path.join("data", "browser_states", str(_uid)) + else: + state_dir = os.path.join("data", "browser_states") + state_file = os.path.join(state_dir, f"{site}.json") + os.makedirs(state_dir, exist_ok=True) + + SITE_URLS = { + "target": "https://www.target.com/account", + "bestbuy": "https://www.bestbuy.com/identity/signin", + "gamestop": "https://www.gamestop.com/sign-in", + } + login_url = SITE_URLS.get(site, f"https://www.{site}.com") + + uid_label = f"user {_uid}" if _uid is not None else "shared (no user)" + print(f"\n[setup] Opening {site} login page for {uid_label}.") + print(f"[setup] Log in manually in the browser window, then come back here and press Enter.") + print(f"[setup] Session will be saved to: {state_file}\n") + + from playwright.sync_api import sync_playwright + with sync_playwright() as pw: + browser = pw.chromium.launch(channel="chrome", headless=False) + context = browser.new_context(viewport={"width": 1920, "height": 1080}) + page = context.new_page() + page.goto(login_url) + input("[setup] Press Enter once you're logged in... ") + context.storage_state(path=state_file) + print(f"[setup] Session saved to {state_file}.") + browser.close() + sys.exit(0) +# ───────────────────────────────────────────────────────────────────────────── + +if len(sys.argv) < 3: + print("Usage: python test_buy.py ") + print(" python test_buy.py --setup ") + print(" e.g: python test_buy.py target https://www.target.com/p/pokemon-tcg-...") + sys.exit(1) + +site = sys.argv[1].lower() +url = sys.argv[2] + +# Force dry-run regardless of config +import config +config.AUTO_BUY_DRY_RUN = True + +# ------------------------------------------------------------------ +# Load credentials — prefer dashboard profile, fall back to .env +# ------------------------------------------------------------------ +from src.database import get_database +from src.profile_store import decrypt_profile + +db = get_database() +users = db.get_all_users() + +shipping = None +payment = None + +# Non-interactive mode: TEST_USER_ID + TEST_PROFILE_PASSWORD in env/.env +_env_user_id = os.environ.get("TEST_USER_ID", "").strip() +_env_password = os.environ.get("TEST_PROFILE_PASSWORD", "").strip() + +if _env_user_id and _env_password: + user_id = int(_env_user_id) + if db.user_has_profile(user_id): + row = db.get_user_profile(user_id) + profile = decrypt_profile(_env_password, row['salt'], row['ciphertext']) + if profile is None: + print(f"[test_buy] TEST_PROFILE_PASSWORD is wrong for user {user_id}.") + sys.exit(1) + from src.profile_store import _unlocked + _unlocked[user_id] = profile + shipping = profile['shipping'] + payment = profile['payment'] + print(f"[test_buy] Non-interactive: loaded profile for user {user_id}.") + else: + print(f"[test_buy] No profile saved for TEST_USER_ID={user_id} — falling back to .env") + +elif users: + # Interactive mode + print("\nAvailable users:") + for u in users: + has = db.user_has_profile(u['id']) + print(f" [{u['id']}] {u['name']}" + (" (profile saved)" if has else " (no profile)")) + + choice = input("\nEnter user ID to use their profile (or press Enter to use .env): ").strip() + + if choice: + user_id = int(choice) + if db.user_has_profile(user_id): + password = getpass.getpass(f"Password for user {user_id}: ") + row = db.get_user_profile(user_id) + profile = decrypt_profile(password, row['salt'], row['ciphertext']) + if profile is None: + print("Incorrect password.") + sys.exit(1) + from src.profile_store import _unlocked + _unlocked[user_id] = profile + shipping = profile['shipping'] + payment = profile['payment'] + print(f"Profile unlocked for user {user_id}.") + else: + print(f"No saved profile for user {user_id} — falling back to .env") + +if not shipping: + from src.buyers.base_buyer import load_shipping, load_payment + shipping = load_shipping() + payment = load_payment() + print("Using .env credentials.") + +# ------------------------------------------------------------------ +# Run the buyer +# ------------------------------------------------------------------ +from scrapers.base import Product +from src.buyers import get_buyer + +buyer = get_buyer(site) +if not buyer: + print(f"No buyer for site: {site} (supported: target, bestbuy, gamestop)") + sys.exit(1) + +# Inject credentials directly so the buyer uses them regardless of source +buyer.shipping = shipping +buyer.payment = payment +if _env_user_id: + buyer.user_id = int(_env_user_id) +elif 'user_id' in dir(): # set during interactive mode + buyer.user_id = user_id # type: ignore[possibly-undefined] + +print(f"\n{'='*60}") +print(f" DRY RUN — {site.upper()}") +print(f" {url}") +print(f"{'='*60}\n") + +product = Product( + name="Test Product", + url=url, + price="$19.99", + in_stock=True, + image_url=None, + site=site, + product_id="test", +) + +result = buyer.buy_product(product) + +print(f"\n{'='*60}") +print(f" Result : {'SUCCESS' if result.success else 'FAILED'}") +print(f" Message: {result.message}") +if result.order_number: + print(f" Order# : {result.order_number}") +print(f"{'='*60}\n")