ebayDeepScan/scanner.js

607 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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) {
log(` [${rowIndex + 1}] [${site.id}] [${label}] Searching: "${query}"`);
const items = await searchEbay(token, query, 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);
});
}