615 lines
23 KiB
JavaScript
615 lines
23 KiB
JavaScript
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');
|
||
const discord = require('./discord');
|
||
|
||
// ─── 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);
|
||
|
||
// Send Discord notification for top deals
|
||
const topDeals = db.getTopAiItems();
|
||
if (topDeals.length > 0) {
|
||
await discord.sendDiscordSummary(topDeals);
|
||
}
|
||
|
||
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);
|
||
});
|
||
} |