Other files
This commit is contained in:
+7
-1
@@ -18,6 +18,10 @@ data/*.pkl
|
|||||||
|
|
||||||
# Debug artifacts
|
# Debug artifacts
|
||||||
debug_*.png
|
debug_*.png
|
||||||
|
debug_*.html
|
||||||
|
|
||||||
|
# Browser session state — contains cookies/tokens, never commit
|
||||||
|
data/browser_states/
|
||||||
|
|
||||||
# Large analysis files
|
# Large analysis files
|
||||||
*.har
|
*.har
|
||||||
@@ -26,10 +30,12 @@ debug_*.png
|
|||||||
# Database
|
# Database
|
||||||
*.db
|
*.db
|
||||||
|
|
||||||
# IDE
|
# IDE / AI tools
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
.claude/
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
.env.example
|
||||||
|
|||||||
@@ -135,3 +135,53 @@ python src/discord_bot.py
|
|||||||
```bash
|
```bash
|
||||||
python -m pytest tests/
|
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/<user_id>/<site>.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/<user_id>/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
|
||||||
|
```
|
||||||
|
|||||||
@@ -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
|
CAPTCHA_WAIT_TIMEOUT = 120 # Seconds to wait for manual CAPTCHA solve
|
||||||
PAUSE_ON_CAPTCHA = True # Pause monitoring when CAPTCHA detected (requires manual 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
|
# Logging
|
||||||
LOG_LEVEL = "INFO"
|
LOG_LEVEL = "INFO"
|
||||||
|
|
||||||
|
|||||||
@@ -1139,3 +1139,155 @@ def get_user_stores(user_id):
|
|||||||
'location': {'zip_code': user['zip_code'], 'radius_miles': user.get('radius_miles', 25)},
|
'location': {'zip_code': user['zip_code'], 'radius_miles': user.get('radius_miles', 25)},
|
||||||
'stores': result
|
'stores': result
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Checkout Profile Routes
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@api_bp.route('/users/<int:user_id>/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/<int:user_id>/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/<int:user_id>/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/<int:user_id>/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/<int:user_id>/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/<int:user_id>/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})
|
||||||
|
|||||||
+193
-7
@@ -1219,10 +1219,10 @@ async function loadUsers() {
|
|||||||
const data = await api('/users');
|
const data = await api('/users');
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
renderUsersList(data.users || []);
|
await renderUsersList(data.users || []);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderUsersList(users) {
|
async function renderUsersList(users) {
|
||||||
const container = document.getElementById('usersList');
|
const container = document.getElementById('usersList');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
@@ -1231,24 +1231,210 @@ function renderUsersList(users) {
|
|||||||
return;
|
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
|
||||||
|
? `<span class="badge badge-success">🔓 Unlocked ${maskedCard ? '· ' + escapeHtml(maskedCard) : ''}</span>`
|
||||||
|
: hasProfile
|
||||||
|
? `<span class="badge badge-warning">🔒 Locked</span>`
|
||||||
|
: `<span class="badge badge-muted">No profile</span>`;
|
||||||
|
|
||||||
|
const profileBtns = hasProfile
|
||||||
|
? isUnlocked
|
||||||
|
? `<button class="btn-secondary btn-small" onclick="openProfileModal(${user.id}, '${escapeHtml(user.name)}')">Edit Profile</button>
|
||||||
|
<button class="btn-secondary btn-small" onclick="lockProfile(${user.id})">Lock</button>
|
||||||
|
<button class="btn-danger btn-small" onclick="deleteProfile(${user.id})">Delete Profile</button>`
|
||||||
|
: `<button class="btn-primary btn-small" onclick="openUnlockModal(${user.id}, '${escapeHtml(user.name)}')">Unlock</button>
|
||||||
|
<button class="btn-secondary btn-small" onclick="openProfileModal(${user.id}, '${escapeHtml(user.name)}')">Edit Profile</button>
|
||||||
|
<button class="btn-danger btn-small" onclick="deleteProfile(${user.id})">Delete Profile</button>`
|
||||||
|
: `<button class="btn-primary btn-small" onclick="openProfileModal(${user.id}, '${escapeHtml(user.name)}')">Set Up Profile</button>`;
|
||||||
|
|
||||||
|
return `
|
||||||
<div class="user-card" data-user-id="${user.id}">
|
<div class="user-card" data-user-id="${user.id}">
|
||||||
<div class="user-header">
|
<div class="user-header">
|
||||||
<span class="user-name">${escapeHtml(user.name)}</span>
|
<span class="user-name">${escapeHtml(user.name)}</span>
|
||||||
<span class="user-location">${user.zip_code ? `${user.zip_code} (${user.radius_miles}mi)` : 'No location set'}</span>
|
<span class="user-location">${user.zip_code ? `${user.zip_code} (${user.radius_miles}mi)` : 'No location set'}</span>
|
||||||
|
${profileBadge}
|
||||||
</div>
|
</div>
|
||||||
<div class="user-details">
|
<div class="user-details">
|
||||||
<div class="form-inline">
|
<div class="form-inline">
|
||||||
<input type="text" class="user-zip-input" placeholder="Zip Code" value="${user.zip_code || ''}" maxlength="10">
|
<input type="text" class="user-zip-input" placeholder="Zip Code" value="${user.zip_code || ''}" maxlength="10">
|
||||||
<input type="number" class="user-radius-input" placeholder="Radius" value="${user.radius_miles || 25}" min="5" max="100" style="width: 80px;">
|
<input type="number" class="user-radius-input" placeholder="Radius" value="${user.radius_miles || 25}" min="5" max="100" style="width: 80px;">
|
||||||
<button class="btn-secondary btn-small" onclick="updateUserLocation(${user.id})">Update</button>
|
<button class="btn-secondary btn-small" onclick="updateUserLocation(${user.id})">Update Location</button>
|
||||||
<button class="btn-secondary btn-small" onclick="viewUserStores(${user.id})">View Stores</button>
|
<button class="btn-secondary btn-small" onclick="viewUserStores(${user.id})">View Stores</button>
|
||||||
<button class="btn-danger btn-small" onclick="deleteUser(${user.id})">Delete</button>
|
<button class="btn-danger btn-small" onclick="deleteUser(${user.id})">Delete User</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-inline" style="margin-top:.5rem;">
|
||||||
|
${profileBtns}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-stores" id="userStores-${user.id}" style="display: none;"></div>
|
<div class="user-stores" id="userStores-${user.id}" style="display: none;"></div>
|
||||||
</div>
|
</div>`;
|
||||||
`).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() {
|
async function addUser() {
|
||||||
|
|||||||
@@ -922,6 +922,9 @@ h3 {
|
|||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal.show {
|
.modal.show {
|
||||||
@@ -931,25 +934,84 @@ h3 {
|
|||||||
.modal-content {
|
.modal-content {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 30px;
|
max-width: 520px;
|
||||||
max-width: 500px;
|
width: 100%;
|
||||||
width: 90%;
|
|
||||||
position: relative;
|
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 {
|
.modal-close {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 15px;
|
top: 16px;
|
||||||
right: 20px;
|
right: 18px;
|
||||||
font-size: 24px;
|
font-size: 22px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
line-height: 1;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-close:hover {
|
.modal-close:hover {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row .form-group {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Loading */
|
/* Loading */
|
||||||
.loading {
|
.loading {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -337,14 +337,14 @@
|
|||||||
<!-- Users Page -->
|
<!-- Users Page -->
|
||||||
<section id="page-users" class="page">
|
<section id="page-users" class="page">
|
||||||
<h1>Users</h1>
|
<h1>Users</h1>
|
||||||
<p class="help-text">Manage users and their store locations for local stock checking</p>
|
<p class="help-text">Manage users, store locations, and encrypted checkout profiles</p>
|
||||||
|
|
||||||
<div class="users-section">
|
<div class="users-section">
|
||||||
<div class="add-user-form">
|
<div class="add-user-form">
|
||||||
<h3>Add New User</h3>
|
<h3>Add New User</h3>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="newUserName">Name</label>
|
<label for="newUserName">Name</label>
|
||||||
<input type="text" id="newUserName" placeholder="e.g., John">
|
<input type="text" id="newUserName" placeholder="e.g., Michael">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="newUserZip">Zip Code</label>
|
<label for="newUserZip">Zip Code</label>
|
||||||
@@ -363,6 +363,94 @@
|
|||||||
<p class="loading">Loading users...</p>
|
<p class="loading">Loading users...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Checkout Profile Modal -->
|
||||||
|
<div id="profileModal" class="modal" style="display:none;">
|
||||||
|
<div class="modal-content modal-lg">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="profileModalTitle">Checkout Profile</h3>
|
||||||
|
<button class="modal-close" onclick="closeProfileModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="help-text" style="margin-bottom:1rem;">
|
||||||
|
Your card and shipping data is encrypted with your password before saving.
|
||||||
|
It is never stored in plaintext and is never sent back to the browser.
|
||||||
|
</p>
|
||||||
|
<h4>Shipping Address</h4>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>First Name</label><input type="text" id="pf-first-name" placeholder="Michael"></div>
|
||||||
|
<div class="form-group"><label>Last Name</label><input type="text" id="pf-last-name" placeholder="Smith"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>Address</label><input type="text" id="pf-address1" placeholder="123 Main St"></div>
|
||||||
|
<div class="form-group"><label>Address Line 2 (optional)</label><input type="text" id="pf-address2" placeholder="Apt 4B"></div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>City</label><input type="text" id="pf-city" placeholder="Dallas"></div>
|
||||||
|
<div class="form-group" style="max-width:80px;"><label>State</label><input type="text" id="pf-state" placeholder="TX" maxlength="2"></div>
|
||||||
|
<div class="form-group" style="max-width:120px;"><label>Zip</label><input type="text" id="pf-zip" placeholder="75201" maxlength="10"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>Phone</label><input type="tel" id="pf-phone" placeholder="214-555-0100"></div>
|
||||||
|
<div class="form-group"><label>Email</label><input type="email" id="pf-email" placeholder="you@email.com"></div>
|
||||||
|
</div>
|
||||||
|
<h4 style="margin-top:1.5rem;">Payment</h4>
|
||||||
|
<div class="form-group"><label>Card Number</label><input type="text" id="pf-card-number" placeholder="4111 1111 1111 1111" maxlength="19" autocomplete="cc-number"></div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group" style="max-width:100px;"><label>Exp Month</label><input type="text" id="pf-expiry-month" placeholder="01" maxlength="2"></div>
|
||||||
|
<div class="form-group" style="max-width:110px;"><label>Exp Year</label><input type="text" id="pf-expiry-year" placeholder="2027" maxlength="4"></div>
|
||||||
|
<div class="form-group" style="max-width:90px;"><label>CVV</label><input type="password" id="pf-cvv" placeholder="•••" maxlength="4" autocomplete="cc-csc"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>Name on Card</label><input type="text" id="pf-card-name" placeholder="Michael Smith" autocomplete="cc-name"></div>
|
||||||
|
<h4 style="margin-top:1.5rem;">Site Logins (optional but recommended)</h4>
|
||||||
|
<p class="help-text">Used to log in automatically at checkout. Leave blank to check out as guest.</p>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>Target Email</label><input type="email" id="pf-target-email" placeholder="you@email.com"></div>
|
||||||
|
<div class="form-group"><label>Target Password</label><input type="password" id="pf-target-password" placeholder="••••••••"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>Best Buy Email</label><input type="email" id="pf-bestbuy-email" placeholder="you@email.com"></div>
|
||||||
|
<div class="form-group"><label>Best Buy Password</label><input type="password" id="pf-bestbuy-password" placeholder="••••••••"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>GameStop Email</label><input type="email" id="pf-gamestop-email" placeholder="you@email.com"></div>
|
||||||
|
<div class="form-group"><label>GameStop Password</label><input type="password" id="pf-gamestop-password" placeholder="••••••••"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 style="margin-top:1.5rem;">Profile Password</h4>
|
||||||
|
<p class="help-text">Encrypts your profile. You'll need this to unlock before each buy session.</p>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>Password (min 8 chars)</label><input type="password" id="pf-password" placeholder="••••••••••••"></div>
|
||||||
|
<div class="form-group"><label>Confirm Password</label><input type="password" id="pf-password-confirm" placeholder="••••••••••••"></div>
|
||||||
|
</div>
|
||||||
|
<div id="profileModalError" class="error-message" style="display:none; color:#e74c3c; margin-top:.5rem;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn-secondary" onclick="closeProfileModal()">Cancel</button>
|
||||||
|
<button class="btn-primary" onclick="saveProfile()">Encrypt & Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Unlock Modal -->
|
||||||
|
<div id="unlockModal" class="modal" style="display:none;">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="unlockModalTitle">Unlock Profile</h3>
|
||||||
|
<button class="modal-close" onclick="closeUnlockModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="help-text">Enter your password to load your checkout data into memory for this session.</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Password</label>
|
||||||
|
<input type="password" id="unlock-password" placeholder="••••••••••••" autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<div id="unlockModalError" class="error-message" style="display:none; color:#e74c3c; margin-top:.5rem;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn-secondary" onclick="closeUnlockModal()">Cancel</button>
|
||||||
|
<button class="btn-primary" onclick="submitUnlock()">Unlock</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Settings Page -->
|
<!-- Settings Page -->
|
||||||
|
|||||||
@@ -24,11 +24,15 @@ from config import (
|
|||||||
NEW_DROP_NOTIFICATIONS,
|
NEW_DROP_NOTIFICATIONS,
|
||||||
PRICE_CHANGE_NOTIFICATIONS,
|
PRICE_CHANGE_NOTIFICATIONS,
|
||||||
MAX_PAGES,
|
MAX_PAGES,
|
||||||
|
AUTO_BUY_ENABLED,
|
||||||
|
AUTO_BUY_SITES,
|
||||||
|
AUTO_BUY_DRY_RUN,
|
||||||
)
|
)
|
||||||
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
|
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
|
# Note: Pokemon Center uses Chrome Extension (chrome-extension/), not a Python scraper
|
||||||
from scrapers import (
|
from scrapers import (
|
||||||
TargetScraper,
|
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():
|
def check_target():
|
||||||
"""Check Target for restocks and new drops"""
|
"""Check Target for restocks and new drops"""
|
||||||
logger.info("Checking Target...")
|
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})")
|
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
|
# Log stats
|
||||||
stats = tracker.get_stats()
|
stats = tracker.get_stats()
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -220,6 +272,9 @@ def check_gamestop():
|
|||||||
)
|
)
|
||||||
logger.info(f"Sent notification for price change: {product.name} ({old_price} -> {new_price})")
|
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
|
# Log stats
|
||||||
stats = tracker.get_stats()
|
stats = tracker.get_stats()
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -311,6 +366,9 @@ def check_bestbuy():
|
|||||||
)
|
)
|
||||||
logger.info(f"Sent notification for price change: {product.name} ({old_price} -> {new_price})")
|
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
|
# Log stats
|
||||||
stats = tracker.get_stats()
|
stats = tracker.get_stats()
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -12,5 +12,6 @@ selenium>=4.15.0
|
|||||||
setuptools>=70.0.0 # Required for Python 3.13+ (distutils compatibility)
|
setuptools>=70.0.0 # Required for Python 3.13+ (distutils compatibility)
|
||||||
discord.py>=2.3.0 # Discord bot for location-based store search
|
discord.py>=2.3.0 # Discord bot for location-based store search
|
||||||
nltk>=3.8.0 # Sentiment analysis using VADER
|
nltk>=3.8.0 # Sentiment analysis using VADER
|
||||||
|
cryptography>=42.0.0 # AES encryption for checkout profiles
|
||||||
pytest>=8.0.0 # Testing framework
|
pytest>=8.0.0 # Testing framework
|
||||||
pytest-cov>=4.0.0 # Coverage reporting
|
pytest-cov>=4.0.0 # Coverage reporting
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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/<user_id>/<site>.json
|
||||||
|
# Falls back to shared data/browser_states/<site>.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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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 ""
|
||||||
@@ -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
|
# 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_site ON products(site)")
|
||||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_products_category ON products(category)")
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_products_category ON products(category)")
|
||||||
@@ -1068,6 +1079,49 @@ class Database:
|
|||||||
"""Update user's location settings"""
|
"""Update user's location settings"""
|
||||||
return self.update_user(user_id, zip_code=zip_code, radius_miles=radius_miles)
|
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 ====================
|
# ==================== Stats Summary ====================
|
||||||
|
|
||||||
def get_dashboard_stats(self) -> Dict:
|
def get_dashboard_stats(self) -> Dict:
|
||||||
|
|||||||
@@ -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
|
||||||
+190
@@ -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 <site> <product_url>")
|
||||||
|
print(" python test_buy.py --setup <site>")
|
||||||
|
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")
|
||||||
Reference in New Issue
Block a user