require('dotenv').config(); const axios = require('axios'); const XLSX = require('xlsx'); const fs = require('fs'); const path = require('path'); const db = require('./db'); const ai = require('./ai'); // ─── CONFIG ────────────────────────────────────────────────────────────────── const CFG = { ebayClientId: process.env.EBAY_CLIENT_ID, ebayClientSecret: process.env.EBAY_CLIENT_SECRET, isSandbox: (process.env.EBAY_ENVIRONMENT || 'production') === 'sandbox', marketplace: process.env.EBAY_MARKETPLACE || 'EBAY_US', shipZip: process.env.SHIP_TO_ZIP || '10001', shipCountry: process.env.SHIP_TO_COUNTRY || 'US', itemsPerPage: parseInt(process.env.ITEMS_PER_PAGE || '50'), priceSheet: process.env.PRICE_SHEET || './prices.xlsx', priceRatio: parseFloat(process.env.PRICE_RATIO || '0.85'), rowBatchSize: parseInt(process.env.ROW_BATCH_SIZE || '5'), pageSize: parseInt(process.env.PAGE_SIZE || '200'), maxItemsPerQuery: parseInt(process.env.MAX_ITEMS_PER_QUERY || '500'), requestDelay: 100, }; const EBAY_SITES = [ { id: 'US', market: 'EBAY_US', zip: '10001', country: 'US', domain: 'ebay.com' }, { id: 'UK', market: 'EBAY_GB', zip: 'W1A 1AA', country: 'GB', domain: 'ebay.co.uk' }, { id: 'AU', market: 'EBAY_AU', zip: '2000', country: 'AU', domain: 'ebay.com.au' }, { id: 'CA', market: 'EBAY_CA', zip: 'M5H 2N2', country: 'CA', domain: 'ebay.ca' }, { id: 'DE', market: 'EBAY_DE', zip: '10115', country: 'DE', domain: 'ebay.de' }, ]; const BASE_URL = CFG.isSandbox ? 'api.sandbox.ebay.com' : 'api.ebay.com'; // ─── CURRENCY CONVERSION ───────────────────────────────────────────────────── // Fallback approximate exchange rates to USD let EXCHANGE_RATES = { 'USD': 1.0, 'GBP': 1.27, 'EUR': 1.09, 'AUD': 0.66, 'CAD': 0.74, }; async function fetchExchangeRates() { log('Fetching latest exchange rates...'); try { const res = await axios.get('https://open.er-api.com/v6/latest/USD'); if (res.data && res.data.rates) { // The API returns rates relative to 1 USD (e.g., 1 USD = 0.78 GBP) // We need the inverse: 1 GBP = X USD const rates = res.data.rates; const newRates = { 'USD': 1.0 }; const targets = ['GBP', 'EUR', 'AUD', 'CAD']; targets.forEach(curr => { if (rates[curr]) { newRates[curr] = parseFloat((1 / rates[curr]).toFixed(4)); } else { newRates[curr] = EXCHANGE_RATES[curr]; // Fallback if missing } }); EXCHANGE_RATES = newRates; log(`Rates updated: GBP=${EXCHANGE_RATES.GBP}, EUR=${EXCHANGE_RATES.EUR}, AUD=${EXCHANGE_RATES.AUD}, CAD=${EXCHANGE_RATES.CAD}`); } } catch (err) { log(`Failed to fetch live rates, using fallbacks: ${err.message}`, 'WARN'); } } function convertToUsd(amount, fromCurrency) { const rate = EXCHANGE_RATES[String(fromCurrency).toUpperCase()]; if (!rate) { // If unknown currency, log warning but return original (fail-safe) return amount; } return parseFloat((amount * rate).toFixed(2)); } // ─── LOGGER ────────────────────────────────────────────────────────────────── let startTime = Date.now(); const logLines = []; const STATS = { quoteRequests: 0, aiTokens: 0 }; function log(msg, level = 'INFO') { const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); const line = `[${elapsed}s] [${level}] ${msg}`; console.log(line); logLines.push(line); } function logSection(title) { const bar = '─'.repeat(60); log(''); log(bar); log(` ${title}`); log(bar); } // ─── UTIL ───────────────────────────────────────────────────────────────────── function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } // ─── DEFECTIVE LISTING FILTER ───────────────────────────────────────────────── const DEFECTIVE_PATTERNS = [ /\bnon[\s-]?working\b/i, /\bnot[\s-]?working\b/i, /\bno[\s-]?working\b/i, /\bfor[\s-]?parts?\b/i, /\bparts?[\s-]?only\b/i, /\bparts?[\s-]?or[\s-]?repair\b/i, /\bbroken\b/i, /\bdefective\b/i, /\bfaulty\b/i, /\bdead[\s-]?on[\s-]?arrival\b/i, /\bdoa\b/i, /\bas[\s-]?is\b/i, /\bdamaged\b/i, /\bmalfunctioning\b/i, /\bno[\s-]?post\b/i, ]; function isDefectiveListing(item) { const title = (item.title || '').toLowerCase(); return DEFECTIVE_PATTERNS.some(re => re.test(title)); } // ─── LOAD PRICE SHEET ──────────────────────────────────────────────────────── function loadPriceSheet() { const wb = XLSX.readFile(CFG.priceSheet); const ws = wb.Sheets[wb.SheetNames[0]]; const rows = XLSX.utils.sheet_to_json(ws, { header: 1 }); const headers = rows[0].map(h => String(h).trim()); const data = []; for (let i = 1; i < rows.length; i++) { const row = rows[i]; if (!row || !row.length) continue; const obj = {}; headers.forEach((h, idx) => { obj[h] = row[idx] !== undefined ? row[idx] : ''; }); const partNumber = String(obj['Part Number'] || '').trim(); const price = parseFloat(obj['Price']); if (!partNumber || isNaN(price)) continue; data.push({ specs: String(obj['Specs'] || '').trim(), manufacturer: String(obj['Manufacturer'] || '').trim(), partNumber, capacity: obj['Capacity (GB)'], rank: String(obj['Rank'] || '').trim(), speedMTs: obj['Speed (MT/s)'], speedGrade: String(obj['Speed Grade'] || '').trim(), type: String(obj['Type'] || '').trim(), price, }); } log(`Loaded ${data.length} rows from ${CFG.priceSheet}`); return data; } // ─── BUILD SEARCH QUERIES ──────────────────────────────────────────────────── function buildQueries(row) { const { manufacturer, partNumber, capacity, rank, speedMTs, speedGrade, type } = row; const capStr = capacity ? `${capacity}GB` : ''; const speedStr = speedMTs ? String(speedMTs) : ''; const q1 = partNumber; const q2Parts = [manufacturer, capStr, rank, speedStr, type].filter(Boolean); const q2 = q2Parts.join(' '); const q3Parts = [manufacturer, capStr, rank, speedGrade?.split("-")[1], type].filter(Boolean); const q3 = q3Parts.join(' '); return [ { label: 'PartNumber', query: q1 }, { label: 'SpeedMTs', query: q2 }, { label: 'SpeedGrade', query: q3 }, ].filter(q => q.query.trim().length > 0); } // ─── EBAY AUTH ─────────────────────────────────────────────────────────────── async function getEbayToken() { const creds = Buffer.from(`${CFG.ebayClientId}:${CFG.ebayClientSecret}`).toString('base64'); const res = await axios.post( `https://${BASE_URL}/identity/v1/oauth2/token`, 'grant_type=client_credentials&scope=https://api.ebay.com/oauth/api_scope', { headers: { Authorization: `Basic ${creds}`, 'Content-Type': 'application/x-www-form-urlencoded' } } ); return res.data.access_token; } // ─── EBAY SEARCH ───────────────────────────────────────────────────────────── async function searchEbay(token, keyword, site) { const headers = { Authorization: `Bearer ${token}`, 'X-EBAY-C-MARKETPLACE-ID': site.market, 'X-EBAY-C-ENDUSERCTX': `zip=${site.zip},country=${site.country}`, }; const pageSize = Math.min(CFG.pageSize, 200); const cap = CFG.maxItemsPerQuery; const exactQuery = keyword.trim().split(/\s+/).map(w => { if (w.startsWith('-')) return w; // Negative keyword return `${w}`; }).join(' '); const baseUrl = `https://${BASE_URL}/buy/browse/v1/item_summary/search` + `?q=${encodeURIComponent(exactQuery)}` + `&fieldgroups=EXTENDED` + `&filter=conditionIds:{1000|1500|2000|2500|3000|4000|5000|6000|7000}`; const all = []; let offset = 0; let total = null; do { const url = `${baseUrl}&limit=${pageSize}&offset=${offset}`; log(` [API] GET ${url}`, 'DEBUG'); try { const res = await axios.get(url, { headers }); const data = res.data; if (total === null) total = data.total || 0; const page = data.itemSummaries || []; all.push(...page); offset += pageSize; if (!page.length || all.length >= cap || offset >= total) break; await sleep(150); } catch (err) { const msg = err.response?.data?.errors?.[0]?.message || err.message; log(` eBay search error for "${keyword}" offset=${offset}: ${msg}`, 'WARN'); break; } } while (true); return all.slice(0, cap); } // ─── PRICE EXTRACTION ──────────────────────────────────────────────────────── function extractPrice(item) { const p = item.price || item.currentBidPrice || {}; return { value: parseFloat(p.value || 0), currency: p.currency || 'USD' }; } function extractShipping(item) { const opts = item.shippingOptions || []; if (!opts.length) return { value: 0, currency: 'USD', label: 'N/A' }; const first = opts[0]; const currency = first.shippingCost?.currency || 'USD'; if (first.shippingCostType === 'FREE') return { value: 0, currency, label: 'Free' }; return { value: parseFloat(first.shippingCost?.value || 0), currency, label: first.shippingCostType || '' }; } function extractQty(item) { const title = item.title || ''; const patterns = [ /\bLOT\s+OF\s+(\d+)\b/i, // LOT OF 10 /\b(\d+)\s*LOT\b/i, // 5 LOT /\b(\d+)\s*(?:PACK|PCS?|PIECES?|UNITS?|STK|COUNT|CT)\b/i, /\bQTY\s*[:\-]?\s*(\d+)\b/i, // QTY: 10 /\b(\d+)\s*-\s*PACK\b/i, // 2-PACK // Multiplier context: Qty x Capacity (e.g. 4x256GB, 4 x 512 GB) /\b(\d+)\s*[xX]\s*(?:\d+(?:\.\d+)?)\s*(?:GB|TB|MB|G|T)\b/i, ]; for (const p of patterns) { const m = title.match(p); if (m) { // For some regexes, the group we want is at index 1 or 2 const q = parseInt(m[1] || m[2]); if (q > 1 && q < 128) return q; } } return 1; } // ─── COMPARE ───────────────────────────────────────────────────────────────── function compareItem(item, row, searchLabel, site, profile) { const priceData = extractPrice(item); const shipData = extractShipping(item); const qty = extractQty(item); // Convert to USD before comparison const rawPriceUsd = convertToUsd(priceData.value, priceData.currency); const shipCostUsd = convertToUsd(shipData.value, shipData.currency); const totalUsd = parseFloat((rawPriceUsd + shipCostUsd).toFixed(2)); const avgUsd = qty > 1 ? parseFloat((totalUsd / qty).toFixed(2)) : totalUsd; // Use price_ratio from profile const ratio = profile.price_ratio || 0.85; const thresholdUsd = parseFloat((row.target_price * ratio).toFixed(2)); const pass = avgUsd <= thresholdUsd; const available = item.availableQuantity ?? 1; const itemId = (item.itemWebUrl || '').match(/\/itm\/(\d+)/)?.[1] || item.itemId; const finalUrl = `https://www.${site.domain}/itm/${itemId}`; // Heuristic extraction if missing let extractedPN = row.part_number; if (!extractedPN || extractedPN === 'N/A') { // Look for more complex PN patterns (e.g., MZ-V7S1T0, M393A2K43BB1-CTD, CT1000MX500SSD1) const pnMatch = item.title.match(/\b([A-Z0-9]{4,}-[A-Z0-9]{3,}|[A-Z0-9]{5,}\w[A-Z0-9]{2,})\b/); if (pnMatch) extractedPN = pnMatch[1]; } const itemGroupType = item.itemGroupType || ''; // SELLER_DEFINED_VARIATIONS etc. let extractedBrand = ''; const brands = ['Samsung', 'Hynix', 'Micron', 'Crucial', 'Dell', 'HP', 'Lenovo', 'Intel', 'Kingston', 'Sandisk', 'WD', 'Seagate', 'Toshiba']; for (const b of brands) { if (item.title.toLowerCase().includes(b.toLowerCase())) { extractedBrand = b; break; } } const initialImages = item.image?.imageUrl ? [item.image.imageUrl] : []; return { id: itemId, profile_id: profile.id, keyword_id: row.id, searchType: searchLabel, partNumber: extractedPN || 'N/A', manufacturer: extractedBrand || 'Generic', specs: itemGroupType === 'SELLER_DEFINED_VARIATIONS' ? 'Multi-Variation' : '', title: item.title, url: finalUrl, price: rawPriceUsd, // Always USD in DB now shipping: shipCostUsd, // Always USD in DB now shippingLabel: shipData.label, total: totalUsd, qty: qty, avgPrice: avgUsd, available: available, targetPrice: row.target_price, threshold: thresholdUsd, passFail: pass ? 'PASS' : 'FAIL', profit: pass ? parseFloat(((row.target_price - avgUsd) * available).toFixed(2)) : null, images: JSON.stringify(initialImages), detail_response: null, seller_username: item.seller?.username || null, seller_feedback_score: item.seller?.feedbackScore || null, seller_feedback_percent: item.seller?.feedbackPercentage || null }; } // ─── MAIN SEARCH LOOP ──────────────────────────────────────────────────────── async function processRow(token, row, rowIndex, totalRows, globalResultsMap, profile, onProgress) { // Use the keywords array from the DB row const queries = row.keywords.map(kw => ({ label: 'Keyword', query: kw })); if (row.part_number) { queries.unshift({ label: 'PartNumber', query: row.part_number }); } log(`[${rowIndex + 1}/${totalRows}] Processing: ${row.part_number || row.keywords[0]}`); for (const site of EBAY_SITES) { for (const { label, query } of queries) { const searchQuery = profile.common_keywords ? `${query} ${profile.common_keywords}` : query; log(` [${rowIndex + 1}] [${site.id}] [${label}] Searching: "${searchQuery}"`); const items = await searchEbay(token, searchQuery, site); let skipped = 0, cnSkipped = 0, dbSkipped = 0; for (const item of items) { const itemId = (item.itemWebUrl || '').match(/\/itm\/(\d+)/)?.[1] || item.itemId; if (!itemId) continue; const dbItem = db.getItem(itemId); if (dbItem && (dbItem.review_status === 'done' || dbItem.review_status === 'skip')) { dbSkipped++; continue; } if (isDefectiveListing(item)) { skipped++; continue; } if (item.itemGroupHref) { log(` [${rowIndex + 1}] [${site.id}] [${label}] Skipping group product: ${itemId}`, 'DEBUG'); continue; } const locationCountry = item.itemLocation?.country; if (locationCountry === 'CN') { cnSkipped++; continue; } const priority = (locationCountry === site.country) ? 2 : 1; const existing = globalResultsMap.get(itemId); if (!existing || existing._priority < priority) { const processedRow = compareItem(item, row, label, site, profile); processedRow._priority = priority; globalResultsMap.set(itemId, processedRow); } } const notes = []; if (skipped) notes.push(`${skipped} defective`); if (cnSkipped) notes.push(`${cnSkipped} CN-located`); if (dbSkipped) notes.push(`${dbSkipped} DB skipped (done/skip)`); log(` [${rowIndex + 1}] [${site.id}] [${label}] → ${items.length} raw results${notes.length ? `, skipped: ${notes.join(', ')}` : ''}`); await sleep(CFG.requestDelay); } } if (onProgress) onProgress(); } async function runSearch(token, rows, profile, onProgress) { logSection(`SEARCHING EBAY — ${rows.length} rows for Profile: ${profile.name}`); const batchSize = CFG.rowBatchSize; const totalBatch = Math.ceil(rows.length / batchSize); const globalResultsMap = new Map(); for (let b = 0; b < rows.length; b += batchSize) { const batch = rows.slice(b, b + batchSize); const batchNum = Math.floor(b / batchSize) + 1; log(`\n── Batch ${batchNum}/${totalBatch} (rows ${b + 1}–${Math.min(b + batchSize, rows.length)}) ──`); await Promise.all( batch.map((row, idx) => processRow(token, row, b + idx, rows.length, globalResultsMap, profile, onProgress)) ); } // Save map to DB - ONLY PASS ITEMS let savedCount = 0; for (const [id, r] of globalResultsMap) { if (r.passFail === 'PASS') { delete r._priority; db.saveItem(r); savedCount++; } } log(`Saved ${savedCount} PASS items to database.`); } // ─── ITEM DETAIL API ───────────────────────────────────────────────────── async function fetchItemDetail(token, itemId) { const headers = { Authorization: `Bearer ${token}`, 'X-EBAY-C-MARKETPLACE-ID': CFG.marketplace, 'X-EBAY-C-ENDUSERCTX': `zip=${CFG.shipZip},country=${CFG.shipCountry}`, }; try { const res = await axios.get( `https://${BASE_URL}/buy/browse/v1/item/v1|${itemId}|0`, { headers } ); return res.data; } catch (e) { return null; } } async function enrichPassItems(token, profileId) { // get all pass + waiting items for this profile that don't have detail_response yet let passRows = db.getWaitingPassItems(profileId).filter(r => !r.detail_response); if (!passRows.length) { log('No PASS items to enrich.'); return; } logSection(`ENRICHING ${passRows.length} PASS ITEMS — fetching Item Details`); const batchSize = 10; for (let b = 0; b < passRows.length; b += batchSize) { const batch = passRows.slice(b, b + batchSize); await Promise.all(batch.map(async row => { const itemId = String(row.id); const detail = await fetchItemDetail(token, itemId); if (detail) { const est = detail.estimatedAvailabilities?.[0]; row.available = est?.estimatedAvailableQuantity ?? detail.quantity ?? 1; // Re-read price from detail in case it's different and convert to USD const priceDataDetail = { value: parseFloat(detail.price?.value || 0), currency: detail.price?.currency || 'USD' }; const priceUsdDetail = convertToUsd(priceDataDetail.value, priceDataDetail.currency); // Shipping in detail might be structured differently const shipCostDetail = parseFloat(detail.shippingOptions?.[0]?.shippingCost?.value || 0); const shipCurrDetail = detail.shippingOptions?.[0]?.shippingCost?.currency || 'USD'; const shipUsdDetail = convertToUsd(shipCostDetail, shipCurrDetail); row.price = priceUsdDetail; row.shipping = shipUsdDetail; row.total = parseFloat((priceUsdDetail + shipUsdDetail).toFixed(2)); row.avgPrice = row.qty > 1 ? parseFloat((row.total / row.qty).toFixed(2)) : row.total; row.profit = parseFloat(((row.targetPrice - row.avgPrice) * row.available).toFixed(2)); const images = [detail.image?.imageUrl, ...(detail.additionalImages?.map(i => i.imageUrl) || [])].filter(Boolean); row.images = JSON.stringify(images); row.detail_response = JSON.stringify(detail); row.seller_username = detail.seller?.username; row.seller_feedback_score = detail.seller?.feedbackScore; row.seller_feedback_percent = detail.seller?.feedbackPercentage; STATS.quoteRequests++; // Re-save specific fields (since saveItem uses whole object, we fetch full item first) const currentDbItem = db.getItem(itemId); db.saveItem({ ...currentDbItem, ...row }); log(` Item ${itemId} → Available Qty: ${row.available}`); } else { log(` Item ${itemId} → Failed to fetch details`); } })); if (b + batchSize < passRows.length) await sleep(100); } } // ─── MAIN ──────────────────────────────────────────────────────────────────── async function runScannerCore(profileId, onProgress) { startTime = Date.now(); const runId = Date.now(); logSection(`eBay Price Scanner — Run ${runId}`); let profileIds = []; if (profileId === 'all') { profileIds = db.getProfiles().map(p => p.id); } else if (Array.isArray(profileId)) { profileIds = profileId; } else if (profileId) { profileIds = [profileId]; } if (!profileIds.length) { log('No profiles targeted for scanning.', 'ERROR'); return; } log('Getting eBay token...'); const token = await getEbayToken(); log('eBay token acquired'); await fetchExchangeRates(); for (const id of profileIds) { const profile = db.getProfile(id); if (!profile) { log(`Profile ID ${id} not found, skipping.`, 'WARN'); continue; } const keywords = db.getKeywords(id); if (!keywords.length) { log(`No keywords found for profile ${profile.name}, skipping.`, 'WARN'); continue; } log(`\n>>> SCANNING PROFILE: ${profile.name} (ID: ${id}) <<<`); let currentProcessed = 0; const totalKeywords = keywords.length; const report = () => { currentProcessed++; if (onProgress) onProgress(currentProcessed, totalKeywords, profile.name); }; await runSearch(token, keywords, profile, report); await enrichPassItems(token, id); // Update last scan time for this profile db.updateProfileScanTime(id); log(`Profile ${profile.name} scan complete.`); } // AI suggestions for missing items (global) const tokens = await ai.runAiSuggestions(); STATS.aiTokens += (tokens || 0); logSection('DONE SCANNING'); log(`Total elapsed : ${((Date.now() - startTime) / 1000).toFixed(1)}s`); log(`\n─── USAGE SUMMARY ───`); log(`Total Quote Requests: ${STATS.quoteRequests}`); log(`Total AI Tokens: ${STATS.aiTokens}`); log(`─────────────────────\n`); } module.exports = { runScannerCore }; // Standalone mode is now tricky because it needs a profileId // but I'll keep it for testing with ID 1 if (require.main === module) { runScannerCore(1).catch(err => { log(`FATAL: ${err.stack || err.message}`, 'ERROR'); process.exit(1); }); }