first commit

This commit is contained in:
Joseph 2026-03-24 10:01:07 +07:00
commit ec9e80d36f
15 changed files with 4144 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

23
.env.example Normal file
View File

@ -0,0 +1,23 @@
EBAY_CLIENT_ID=apactech-
EBAY_CLIENT_SECRET=PRD-1c822
EBAY_ENVIRONMENT=production
OPENAI_API_KEY=sk-proj-_Hty3Bv8wVCtG9DCXl
OPENAI_MODEL=gpt-4o-mini
# Files
KEYWORDS_FILE=./keywords.txt
PRICE_SHEET=./prices.xlsx
SYSTEM_PROMPT_FILE=./system_prompt.txt
# Output dir
OUTPUT_DIR=./output
# eBay search
EBAY_MARKETPLACE=EBAY_US
SHIP_TO_ZIP=10001
SHIP_TO_COUNTRY=US
ITEMS_PER_PAGE=200
ROW_BATCH_SIZE=10
MAX_ITEMS_PER_QUERY=100000

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules
.env

BIN
Archive.zip Normal file

Binary file not shown.

76
README.md Normal file
View File

@ -0,0 +1,76 @@
# eBay MPN Matcher
Multi-step pipeline: eBay search → exact MPN match → AI match → price comparison.
## Setup
```bash
npm install
cp .env.example .env
# Fill in EBAY_CLIENT_ID, EBAY_CLIENT_SECRET, OPENAI_API_KEY
```
## Files required
| File | Description |
|------|-------------|
| `keywords.txt` | One search keyword per line |
| `prices.xlsx` | Col A: partnumber, Col B: price (USD) |
| `system_prompt.txt` | AI system prompt for this product profile |
## Run
```bash
node index.js
```
Output saved to `./output/`:
- `results_<runId>.xlsx` — full results with PASS/FAIL
- `results_<runId>.json` — same data as JSON
- `run_<runId>.log` — full run log with timing
- `ai_cache.json` — cached AI matches (reused on next run)
## Flow
```
keywords.txt
▼ Step 1: eBay search (all keywords, dedup by URL item ID)
▼ Step 2: Exact MPN match (regex + normalized)
│ ├─ matched → list A
│ └─ unmatched → Step 3
▼ Step 3: AI match (gpt-4o-mini, batch 40 items)
│ ├─ GOOD_MATCH / VARIANT_MISMATCH → list B
│ ├─ INSUFFICIENT_DATA → fetch detail → retry AI
│ └─ UNRELATED_PRODUCT → skip
▼ Step 4: Merge A+B → compare price → PASS/FAIL → export
```
## Match methods in output
| Method | Description |
|--------|-------------|
| `exact` | Regex found MPN in title/specs |
| `ai_exact` | AI matched with GOOD_MATCH |
| `ai_variant` | AI matched with VARIANT_MISMATCH |
| `ai_detail` | AI matched after fetching detail page |
| `ai_cached` | From previous run cache |
## Config (.env)
| Key | Default | Description |
|-----|---------|-------------|
| `AI_BATCH_SIZE` | 40 | Listings per AI request |
| `AI_CONFIDENCE_THRESHOLD` | 50 | Min confidence to count as PASS |
| `OPENAI_MODEL` | gpt-4o-mini | OpenAI model |
| `EBAY_MARKETPLACE` | EBAY_US | eBay marketplace ID |
## Adding a new product profile
1. Create new `keywords_<profile>.txt`
2. Create new `system_prompt_<profile>.txt` explaining the product domain
3. Update `.env` to point to new files
4. Run — AI cache is per-itemId so it auto-separates

88
ai.js Normal file
View File

@ -0,0 +1,88 @@
require('dotenv').config();
const { OpenAI } = require('openai');
const db = require('./db');
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
async function getAiSuggestion(item) {
try {
const prompt = `
Bạn một chuyên gia thẩm định hàng hoá điện tử trên eBay.
Hãy kiểm tra các thông tin dưới đây để trả lời 3 câu hỏi:
1. Item bị ảo / lừa đảo (fake) không?
2. Seller uy tín không? (so sánh feedback score, percent)
3. Dữ liệu hiện tại đã đúng sản phẩm chưa? (Part Number, Specs)
THÔNG TIN TÌM KIẾM:
- Part Number: ${item.partNumber}
- Specs mục tiêu: ${item.specs}
THÔNG TIN EBAY (Tìm được):
- Tiêu đề: ${item.title}
- Seller: ${item.seller_username} (Score: ${item.seller_feedback_score}, Positive: ${item.seller_feedback_percent}%)
- Price: ${item.price}
- Phân tích JSON chi tiết từ API:
${item.detail_response ? JSON.stringify(item.detail_response).substring(0, 1500) : 'Không có dữ liệu chi tiết'}
YÊU CẦU ĐẦU RA (Quan trọng!):
Chỉ đưa ra kết luận DUY NHẤT 1 câu ngắn gọn. ( dụ: "Hãy mua ngay, seller uy tín và đúng chuẩn sản phẩm." HOẶC "Cẩn thận, seller ít feedback và tiêu đề không rõ ràng.")
Khong giải thích dài dòng!
`;
const response = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{ role: "system", content: "Bạn là trợ lý AI chuyên thẩm định eBay. Chỉ trả về 1 câu ngắn gọn." },
{ role: "user", content: prompt }
],
max_tokens: 100,
temperature: 0.3
});
return {
suggestion: response.choices[0].message.content.trim(),
usage: response.usage.total_tokens
};
} catch (error) {
console.error(`AI Error for item ${item.id}:`, error.message);
return { suggestion: "Lỗi khi gọi AI.", usage: 0 };
}
}
async function runAiSuggestions() {
console.log("Bat dau chay AI Suggestions...");
const items = db.getMissingAiSuggestionItems();
console.log(`Tim thay ${items.length} item can check AI.`);
let totalTokens = 0;
const batchSize = 10;
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
const results = await Promise.all(batch.map(async item => {
console.log(`Dang check AI cho item: ${item.id}`);
const { suggestion, usage } = await getAiSuggestion(item);
db.updateAiSuggestion(item.id, suggestion);
console.log(`Ket qua AI cho ${item.id}: ${suggestion} (Tokens: ${usage})`);
return usage;
}));
totalTokens += results.reduce((a, b) => a + b, 0);
// Minimal delay between batches to respect rate limits
if (i + batchSize < items.length) {
await new Promise(r => setTimeout(r, 500));
}
}
console.log(`Hoan thanh AI Suggestions. Total tokens: ${totalTokens}`);
return totalTokens;
}
module.exports = {
runAiSuggestions,
getAiSuggestion
};
// Allow standalone run
if (require.main === module) {
runAiSuggestions().catch(console.error);
}

BIN
data/ebay_items.db Normal file

Binary file not shown.

214
db.js Normal file
View File

@ -0,0 +1,214 @@
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
// Ensure data directory exists
const dbDir = path.join(__dirname, 'data');
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
const db = new Database(path.join(dbDir, 'ebay_items.db'));
// Create tables
function initDb() {
db.exec(`
CREATE TABLE IF NOT EXISTS profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
price_ratio REAL DEFAULT 0.85,
last_scan_time DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS search_keywords (
id INTEGER PRIMARY KEY AUTOINCREMENT,
profile_id INTEGER NOT NULL,
part_number TEXT,
keywords TEXT NOT NULL, -- JSON array
target_price REAL NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS items (
id TEXT PRIMARY KEY,
profile_id INTEGER,
keyword_id INTEGER,
searchType TEXT,
partNumber TEXT,
manufacturer TEXT,
specs TEXT,
title TEXT,
url TEXT,
price REAL,
shipping REAL,
shippingLabel TEXT,
total REAL,
qty INTEGER,
avgPrice REAL,
available INTEGER,
targetPrice REAL,
threshold REAL,
passFail TEXT,
profit REAL,
images TEXT, -- JSON
detail_response TEXT, -- JSON
seller_username TEXT,
seller_feedback_score INTEGER,
seller_feedback_percent REAL,
review_status TEXT DEFAULT 'waiting', -- 'waiting', 'done', 'skip'
ai_suggestion TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE
);
`);
// Migration: Add columns if they don't exist
try { db.exec("ALTER TABLE profiles ADD COLUMN last_scan_time DATETIME;"); } catch(e){}
try { db.exec("ALTER TABLE items ADD COLUMN profile_id INTEGER;"); } catch(e){}
try { db.exec("ALTER TABLE items ADD COLUMN keyword_id INTEGER;"); } catch(e){}
}
initDb();
const stmts = {
// Profiles
getProfiles: db.prepare('SELECT * FROM profiles ORDER BY name'),
getProfile: db.prepare('SELECT * FROM profiles WHERE id = ?'),
insertProfile: db.prepare('INSERT INTO profiles (name, price_ratio) VALUES (?, ?)'),
deleteProfile: db.prepare('DELETE FROM profiles WHERE id = ?'),
updateProfile: db.prepare('UPDATE profiles SET name = ?, price_ratio = ? WHERE id = ?'),
updateProfileScanTime: db.prepare('UPDATE profiles SET last_scan_time = CURRENT_TIMESTAMP WHERE id = ?'),
// Search Keywords
getKeywordsByProfile: db.prepare('SELECT * FROM search_keywords WHERE profile_id = ?'),
insertKeyword: db.prepare('INSERT INTO search_keywords (profile_id, part_number, keywords, target_price) VALUES (?, ?, ?, ?)'),
deleteKeyword: db.prepare('DELETE FROM search_keywords WHERE id = ?'),
updateKeyword: db.prepare('UPDATE search_keywords SET part_number = ?, keywords = ?, target_price = ? WHERE id = ?'),
// Items
insertOrUpdateItem: db.prepare(`
INSERT INTO items (
id, profile_id, keyword_id, searchType, partNumber, manufacturer, specs, title, url,
price, shipping, shippingLabel, total, qty, avgPrice, available,
targetPrice, threshold, passFail, profit, images, detail_response,
seller_username, seller_feedback_score, seller_feedback_percent,
review_status
) VALUES (
@id, @profile_id, @keyword_id, @searchType, @partNumber, @manufacturer, @specs, @title, @url,
@price, @shipping, @shippingLabel, @total, @qty, @avgPrice, @available,
@targetPrice, @threshold, @passFail, @profit, @images, @detail_response,
@seller_username, @seller_feedback_score, @seller_feedback_percent,
COALESCE((SELECT review_status FROM items WHERE id = @id), 'waiting')
)
ON CONFLICT(id) DO UPDATE SET
profile_id=excluded.profile_id,
keyword_id=excluded.keyword_id,
searchType=excluded.searchType,
partNumber=excluded.partNumber,
manufacturer=excluded.manufacturer,
specs=excluded.specs,
title=excluded.title,
url=excluded.url,
price=excluded.price,
shipping=excluded.shipping,
shippingLabel=excluded.shippingLabel,
total=excluded.total,
qty=excluded.qty,
avgPrice=excluded.avgPrice,
available=excluded.available,
targetPrice=excluded.targetPrice,
threshold=excluded.threshold,
passFail=excluded.passFail,
profit=excluded.profit,
images=excluded.images,
detail_response=excluded.detail_response,
seller_username=excluded.seller_username,
seller_feedback_score=excluded.seller_feedback_score,
seller_feedback_percent=excluded.seller_feedback_percent,
updated_at=CURRENT_TIMESTAMP
`),
getItem: db.prepare('SELECT * FROM items WHERE id = ?'),
getWaitingPassItemsByProfile: db.prepare(`
SELECT * FROM items
WHERE passFail = 'PASS' AND review_status = 'waiting' AND profile_id = ?
ORDER BY profit DESC
`),
getWaitingPassItemsAll: db.prepare(`
SELECT * FROM items
WHERE passFail = 'PASS' AND review_status = 'waiting'
ORDER BY profit DESC
`),
updateReviewStatus: db.prepare('UPDATE items SET review_status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'),
updateAiSuggestion: db.prepare('UPDATE items SET ai_suggestion = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'),
getAllMissingAiSuggestion: db.prepare(`
SELECT * FROM items
WHERE passFail = 'PASS' AND review_status = 'waiting' AND ai_suggestion IS NULL AND detail_response IS NOT NULL
`)
};
module.exports = {
db,
// Profiles
getProfiles() { return stmts.getProfiles.all(); },
getProfile(id) { return stmts.getProfile.get(id); },
addProfile(name, ratio) { return stmts.insertProfile.run(name, ratio); },
deleteProfile(id) { return stmts.deleteProfile.run(id); },
updateProfile(id, name, ratio) { return stmts.updateProfile.run(name, ratio, id); },
updateProfileScanTime(id) { return stmts.updateProfileScanTime.run(id); },
// Keywords
getKeywords(profileId) {
return stmts.getKeywordsByProfile.all(profileId).map(kw => ({
...kw,
keywords: JSON.parse(kw.keywords)
}));
},
addKeyword(profileId, partNumber, keywords, targetPrice) {
return stmts.insertKeyword.run(profileId, partNumber, JSON.stringify(keywords), targetPrice);
},
deleteKeyword(id) { return stmts.deleteKeyword.run(id); },
updateKeyword(id, partNumber, keywords, targetPrice) {
return stmts.updateKeyword.run(partNumber, JSON.stringify(keywords), targetPrice, id);
},
// Items
saveItem(itemData) {
return stmts.insertOrUpdateItem.run(itemData);
},
getItem(id) {
return stmts.getItem.get(id);
},
getWaitingPassItems(profileId) {
const items = profileId
? stmts.getWaitingPassItemsByProfile.all(profileId)
: stmts.getWaitingPassItemsAll.all();
return items.map(item => ({
...item,
images: item.images ? JSON.parse(item.images) : [],
detail_response: item.detail_response ? JSON.parse(item.detail_response) : null
}));
},
updateReviewStatus(id, status) {
return stmts.updateReviewStatus.run(status, id);
},
updateAiSuggestion(id, suggestion) {
return stmts.updateAiSuggestion.run(suggestion, id);
},
getMissingAiSuggestionItems() {
return stmts.getAllMissingAiSuggestion.all().map(item => ({
...item,
images: item.images ? JSON.parse(item.images) : [],
detail_response: item.detail_response ? JSON.parse(item.detail_response) : null
}));
}
};

556
index.js Normal file
View File

@ -0,0 +1,556 @@
require('dotenv').config();
const axios = require('axios');
const XLSX = require('xlsx');
const fs = require('fs');
const path = require('path');
// ─── 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',
outputDir: process.env.OUTPUT_DIR || './output',
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'),
};
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';
// ─── LOGGER ──────────────────────────────────────────────────────────────────
const startTime = Date.now();
const logLines = [];
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)); }
function ensureDir(dir) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
// ─── DEFECTIVE LISTING FILTER ─────────────────────────────────────────────────
// Patterns that indicate a listing is broken / for-parts / non-functional
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 ────────────────────────────────────────────────────────
/**
* Reads prices.xlsx and returns an array of row objects.
* Expected columns (row 0 = header):
* Specs | Manufacturer | Part Number | Capacity (GB) | Rank |
* Speed (MT/s) | Speed Grade | Type | Price
*/
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 ────────────────────────────────────────────────────
/**
* Returns 3 search query strings for a price sheet row:
* 1. Part Number only
* 2. Full spec with Speed (MT/s): e.g. "Samsung 64GB 2Rx4 4800 RDIMM"
* 3. Full spec with Speed Grade: e.g. "Samsung 64GB 2Rx4 PC5-38400 RDIMM"
*/
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;
// e.g. "Samsung 64GB 2Rx4 4800 RDIMM"
const q2Parts = [manufacturer, capStr, rank, speedStr, type].filter(Boolean);
const q2 = q2Parts.join(' ');
// e.g. "Samsung 64GB 2Rx4 PC5-38400 RDIMM"
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 (paginated) ─────────────────────────────────────────────────
// eBay US category ID for Computer Memory — scoping here forces eBay's engine
// to return only memory listings that actually match the searched keywords.
// const MEMORY_CATEGORY_ID = '170083';
/**
* Paginated eBay search.
* Scoped by the specific site marketplace and location context.
*/
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;
// Convert "Micron 64GB 4DRx4 2933 LRDIMM" → '"Micron" "64GB" "4DRx4" "2933" "LRDIMM"'
// This explicitly forces eBay Browse API to treat each word as an exact match term.
const exactQuery = keyword.trim().split(/\s+/).map(w => `"${w}"`).join(' ');
const baseUrl = `https://${BASE_URL}/buy/browse/v1/item_summary/search`
+ `?q=${encodeURIComponent(exactQuery)}`
// + `&category_ids=${MEMORY_CATEGORY_ID}`
+ `&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); // polite inter-page delay
} 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 parseFloat(p.value || 0);
}
function extractShipping(item) {
const opts = item.shippingOptions || [];
if (!opts.length) return { cost: 0, label: 'N/A' };
const first = opts[0];
if (first.shippingCostType === 'FREE') return { cost: 0, label: 'Free' };
return { cost: parseFloat(first.shippingCost?.value || 0), label: first.shippingCostType || '' };
}
/**
* Detect quantity in listing (lot size / pack quantity).
* Checks availableQuantity first, then title patterns.
*/
function extractQty(item) {
const title = item.title || '';
const patterns = [
/\bLOT\s+OF\s+(\d+)\b/i,
/\b(\d+)\s*(?:PACK|PCS?|PIECES?|UNITS?)\b/i,
/\bQTY\s*[:\-]?\s*(\d+)\b/i,
/\b(\d+)\s*-\s*PACK\b/i,
/^(\d+)x\s/i,
/\bX(\d+)\b/i,
];
for (const p of patterns) {
const m = title.match(p);
if (m && parseInt(m[1]) > 1) return parseInt(m[1]);
}
return 1;
}
// ─── COMPARE ─────────────────────────────────────────────────────────────────
/**
* For a given eBay item and row, compute avg price per unit and compare with target.
*/
function compareItem(item, row, searchLabel, site) {
const rawPrice = extractPrice(item);
const { cost: shipCost, label: shipLabel } = extractShipping(item);
const qty = extractQty(item);
const total = parseFloat((rawPrice + shipCost).toFixed(2));
const avg = qty > 1 ? parseFloat((total / qty).toFixed(2)) : total;
const threshold = parseFloat((row.price * CFG.priceRatio).toFixed(2));
const pass = avg <= threshold;
const available = item.availableQuantity ?? 1;
const itemId = (item.itemWebUrl || '').match(/\/itm\/(\d+)/)?.[1] || item.itemId;
const location = item.itemLocation?.country || 'Unknown';
// Force the domain to the prioritized site domain so the link matches the search marketplace
const finalUrl = `https://www.${site.domain}/itm/${itemId}`;
return {
'Search Type': searchLabel,
'Part Number': row.partNumber,
'Manufacturer': row.manufacturer,
'Specs': row.specs,
'eBay Title': item.title,
'Item ID': itemId,
'Item Location': location,
'eBay URL': finalUrl,
'eBay Price (USD)': rawPrice,
'Shipping (USD)': shipCost,
'Shipping Label': shipLabel,
'Total (USD)': total,
'Qty in Listing': qty,
'Avg Price/Unit': avg,
'Available Qty': available,
'Target Price': row.price,
[`Target × ${CFG.priceRatio}`]: threshold,
'Pass/Fail': pass ? 'PASS' : 'FAIL',
'Profit (est.)': pass ? parseFloat(((row.price - avg) * available).toFixed(2)) : null,
};
}
// ─── MAIN SEARCH LOOP ────────────────────────────────────────────────────────
/**
* Process a single price-sheet row: run 3 eBay queries across 5 sites natively,
* filter defective and CN listings, dedupe globally by priority, and build results map.
*/
async function processRow(token, row, rowIndex, totalRows, globalResultsMap) {
const queries = buildQueries(row);
log(`[${rowIndex + 1}/${totalRows}] ${row.partNumber}${row.manufacturer} ${row.specs}`);
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;
for (const item of items) {
const itemId = (item.itemWebUrl || '').match(/\/itm\/(\d+)/)?.[1] || item.itemId;
if (!itemId) continue;
// Skip broken / non-working / for-parts listings
if (isDefectiveListing(item)) { skipped++; continue; }
// Skip China listings
const locationCountry = item.itemLocation?.country;
if (locationCountry === 'CN') { cnSkipped++; continue; }
// Determine priority: 2 if item natively located in the searched site, 1 otherwise
const priority = (locationCountry === site.country) ? 2 : 1;
// Dedupe globally with priority overwrite
const existing = globalResultsMap.get(itemId);
if (!existing || existing._priority < priority) {
const processedRow = compareItem(item, row, label, site);
processedRow._priority = priority; // tag internal state
globalResultsMap.set(itemId, processedRow);
}
}
const notes = [];
if (skipped) notes.push(`${skipped} defective`);
if (cnSkipped) notes.push(`${cnSkipped} CN-located`);
log(` [${rowIndex + 1}] [${site.id}] [${label}] → ${items.length} raw results${notes.length ? `, skipped: ${notes.join(', ')}` : ''}`);
await sleep(CFG.requestDelay);
}
}
}
/**
* Run all rows in concurrent batches of CFG.rowBatchSize.
*/
async function runSearch(token, rows) {
logSection(`SEARCHING EBAY — ${rows.length} rows, batch size ${CFG.rowBatchSize} across ${EBAY_SITES.length} sites`);
const batchSize = CFG.rowBatchSize;
const totalBatch = Math.ceil(rows.length / batchSize);
// We use a Map to keep track of the absolute best-priority matching row for each itemId globally
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))
);
}
// Convert Map values to flat array and remove internal _priority field
return Array.from(globalResultsMap.values()).map(r => {
delete r._priority;
return r;
});
}
// ─── ITEM DETAIL API ─────────────────────────────────────────────────────
/**
* Fetch real available quantity from the item detail endpoint.
* Returns null on error.
*/
async function fetchAvailableQty(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 }
);
const data = res.data;
// Prefer estimatedAvailabilities, fall back to quantity fields
const est = data.estimatedAvailabilities?.[0];
if (est?.estimatedAvailableQuantity != null) return est.estimatedAvailableQuantity;
if (data.quantity != null) return data.quantity;
return null;
} catch {
return null;
}
}
/**
* For every PASS row, concurrently fetch the real Available Qty from eBay
* item detail API and update the row in-place.
* Runs in batches of 5 to avoid hammering the API.
*/
async function enrichPassItems(token, results) {
const passRows = results.filter(r => r['Pass/Fail'] === 'PASS');
if (!passRows.length) { log('No PASS items to enrich.'); return; }
logSection(`ENRICHING ${passRows.length} PASS ITEMS — fetching Available Qty`);
const batchSize = 5;
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['Item ID']);
const qty = await fetchAvailableQty(token, itemId);
if (qty !== null) {
row['Available Qty'] = qty;
// Recompute profit with real qty
const avg = row['Avg Price/Unit'];
const target = row['Target Price'];
row['Profit (est.)'] = parseFloat(((target - avg) * qty).toFixed(2));
}
log(` Item ${itemId} → Available Qty: ${qty ?? '(failed)'}`);
}));
if (b + batchSize < passRows.length) await sleep(200);
}
}
// ─── OUTPUT ───────────────────────────────────────────────────────────────────
function applyHyperlinks(ws, headerName, totalRows) {
const range = XLSX.utils.decode_range(ws['!ref'] || 'A1');
let urlCol = -1;
for (let c = range.s.c; c <= range.e.c; c++) {
if (ws[XLSX.utils.encode_cell({ r: 0, c })]?.v === headerName) { urlCol = c; break; }
}
if (urlCol < 0) return;
for (let r = 1; r <= totalRows; r++) {
const addr = XLSX.utils.encode_cell({ r, c: urlCol });
const cell = ws[addr];
if (!cell?.v || !String(cell.v).startsWith('http')) continue;
const url = String(cell.v);
delete cell.f; delete cell.z;
cell.t = 's'; cell.v = 'View on eBay';
cell.l = { Target: url, Tooltip: url };
}
}
function saveOutputs(rows, runId) {
ensureDir(CFG.outputDir);
// Sort: PASS first → profit desc → avg price asc
rows.sort((a, b) => {
if (a['Pass/Fail'] !== b['Pass/Fail']) return a['Pass/Fail'] === 'PASS' ? -1 : 1;
const profitA = a['Profit (est.)'] ?? -Infinity;
const profitB = b['Profit (est.)'] ?? -Infinity;
if (profitA !== profitB) return profitB - profitA;
return (a['Avg Price/Unit'] || 0) - (b['Avg Price/Unit'] || 0);
});
const passCount = rows.filter(r => r['Pass/Fail'] === 'PASS').length;
log(`Total rows : ${rows.length}`);
log(`PASS : ${passCount}`);
log(`FAIL : ${rows.length - passCount}`);
// Excel
const xlsxPath = path.join(CFG.outputDir, `results_${runId}.xlsx`);
const wb = XLSX.utils.book_new();
const ws = XLSX.utils.json_to_sheet(rows);
applyHyperlinks(ws, 'eBay URL', rows.length);
ws['!cols'] = [
{ wch: 12 }, // Search Type
{ wch: 22 }, // Part Number
{ wch: 14 }, // Manufacturer
{ wch: 24 }, // Specs
{ wch: 50 }, // eBay Title
{ wch: 14 }, // Item ID
{ wch: 14 }, // Item Location
{ wch: 16 }, // eBay URL
{ wch: 15 }, // eBay Price
{ wch: 13 }, // Shipping
{ wch: 13 }, // Shipping Label
{ wch: 13 }, // Total
{ wch: 12 }, // Qty
{ wch: 14 }, // Avg Price/Unit
{ wch: 12 }, // Available
{ wch: 13 }, // Target Price
{ wch: 14 }, // Target × ratio
{ wch: 10 }, // Pass/Fail
{ wch: 13 }, // Profit
];
XLSX.utils.book_append_sheet(wb, ws, 'Results');
XLSX.writeFile(wb, xlsxPath);
log(`Excel saved : ${xlsxPath}`);
// JSON
const jsonPath = path.join(CFG.outputDir, `results_${runId}.json`);
fs.writeFileSync(jsonPath, JSON.stringify(rows, null, 2));
log(`JSON saved : ${jsonPath}`);
// Log file
const logPath = path.join(CFG.outputDir, `run_${runId}.log`);
fs.writeFileSync(logPath, logLines.join('\n'));
log(`Log saved : ${logPath}`);
return { xlsxPath, jsonPath, logPath };
}
// ─── MAIN ────────────────────────────────────────────────────────────────────
async function main() {
const runId = Date.now();
logSection(`eBay Price Scanner — Run ${runId}`);
// Validate required env
const missing = ['EBAY_CLIENT_ID', 'EBAY_CLIENT_SECRET'].filter(k => !process.env[k]);
if (missing.length) {
log(`Missing env vars: ${missing.join(', ')}`, 'ERROR');
process.exit(1);
}
// Load price sheet
const priceRows = loadPriceSheet();
if (!priceRows.length) {
log('No valid rows found in price sheet. Exiting.', 'ERROR');
process.exit(1);
}
// Get eBay token
log('Getting eBay token...');
const token = await getEbayToken();
log('eBay token acquired');
// Search + compare
const results = await runSearch(token, priceRows);
// Enrich PASS items with real Available Qty from detail API
await enrichPassItems(token, results);
// Save outputs
logSection('SAVING OUTPUTS');
saveOutputs(results, runId);
logSection('DONE');
log(`Total elapsed : ${((Date.now() - startTime) / 1000).toFixed(1)}s`);
log(`Run ID : ${runId}`);
}
main().catch(err => {
log(`FATAL: ${err.stack || err.message}`, 'ERROR');
process.exit(1);
});

1544
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "ebaydeepscan",
"version": "1.0.0",
"description": "Multi-step pipeline: eBay search → exact MPN match → AI match → price comparison.",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"axios": "^1.13.6",
"better-sqlite3": "^12.8.0",
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"fs": "^0.0.1-security",
"openai": "^6.32.0",
"path": "^0.12.7",
"xlsx": "^0.18.5"
}
}

729
public/index.html Normal file
View File

@ -0,0 +1,729 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>eBay Scanner Dashboard</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0b0f19;
--surface: #161b2a;
--surface-light: #1e2539;
--primary: #3b82f6;
--primary-glow: rgba(59, 130, 246, 0.4);
--success: #10b981;
--danger: #ef4444;
--warning: #f59e0b;
--text: #f8fafc;
--text-muted: #94a3b8;
--border: #2d3748;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', sans-serif;
background-color: var(--bg);
color: var(--text);
line-height: 1.5;
overflow-x: hidden;
}
header {
background: var(--surface);
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 100;
}
header h1 {
font-size: 1.3rem;
font-weight: 700;
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.nav-tabs {
display: flex; gap: 0.5rem; padding: 1rem 2rem;
background: #0f1422; border-bottom: 1px solid var(--border);
overflow-x: auto;
}
.tab {
padding: 0.6rem 1.2rem; border-radius: 8px; cursor: pointer;
color: var(--text-muted); transition: all 0.2s; white-space: nowrap;
border: 1px solid transparent;
}
.tab:hover { background: var(--surface-light); color: var(--text); }
.tab.active {
background: var(--primary); color: white;
box-shadow: 0 4px 12px var(--primary-glow);
}
.tab-add { border: 1px dashed var(--border); color: var(--primary); }
.action-bar {
padding: 0.75rem 2rem; border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 1rem;
background: #0f1422;
}
.status-badge {
display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem;
}
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-muted); }
.status-dot.active {
background: var(--warning);
box-shadow: 0 0 8px var(--warning);
animation: pulse 1.5s infinite;
}
@keyframes pulse { 0% { opacity: 1; } 50% { opacity: 0.4; } 100% { opacity: 1; } }
button {
padding: 0.5rem 1rem; border: none; border-radius: 6px;
font-weight: 600; cursor: pointer; transition: all 0.2s;
font-family: inherit; font-size: 0.85rem;
}
button:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: var(--primary); color: white; }
.btn-success { background: var(--success); color: white; }
.btn-danger { background: var(--danger); color: white; }
.btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); }
.btn-outline:hover { background: var(--border); }
main { padding: 1.5rem 2rem; max-width: 1600px; margin: 0 auto; }
/* Search & Filter */
.toolbar {
display: flex; gap: 1rem; margin-bottom: 1.5rem; align-items: center;
}
.toolbar input {
background: var(--surface); border: 1px solid var(--border);
color: var(--text); padding: 0.6rem 1rem; border-radius: 8px;
font-size: 0.9rem; flex: 1; outline: none; transition: border-color 0.2s;
}
.toolbar input:focus { border-color: var(--primary); }
/* Table */
.card { background: var(--surface); border-radius: 12px; border: 1px solid var(--border); overflow: hidden; }
table { width: 100%; border-collapse: collapse; min-width: 1000px; }
th, td { padding: 1rem; text-align: left; border-bottom: 1px solid var(--border); }
th { background: rgba(0,0,0,0.2); font-size: 0.8rem; text-transform: uppercase; color: var(--text-muted); letter-spacing: 0.05em; }
tr:last-child td { border-bottom: none; }
tr:hover { background: rgba(255,255,255,0.02); }
.td-img { width: 64px; height: 64px; object-fit: cover; border-radius: 6px; cursor: zoom-in; }
.td-title { font-weight: 600; font-size: 0.95rem; line-height: 1.3; color: var(--primary); cursor: pointer; }
.td-title:hover { text-decoration: underline; }
.td-sub { font-size: 0.8rem; color: var(--text-muted); margin-top: 4px; }
.price-lg { font-size: 1.1rem; font-weight: 700; color: var(--text); }
.profit-badge { color: var(--success); font-weight: 700; }
/* Modals */
.modal-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.85); backdrop-filter: blur(8px);
display: flex; justify-content: center; align-items: center;
z-index: 1000; opacity: 0; pointer-events: none; transition: opacity 0.3s;
}
.modal-overlay.active { opacity: 1; pointer-events: all; }
.modal-content {
background: var(--surface); border: 1px solid var(--border);
width: 95vw; max-width: 1400px; max-height: 95vh; border-radius: 16px;
overflow-y: auto; position: relative; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.5);
}
.modal-close {
position: absolute; top: 1.5rem; right: 1.5rem; font-size: 1.5rem;
cursor: pointer; background: none; border: none; color: var(--text-muted);
}
.modal-padd { padding: 2.5rem; }
form div { margin-bottom: 1rem; }
label { display: block; margin-bottom: 0.4rem; font-size: 0.9rem; color: var(--text-muted); }
input[type="text"], input[type="number"], textarea {
width: 100%; background: #0f1422; border: 1px solid var(--border);
color: var(--text); padding: 0.75rem; border-radius: 8px; font-family: inherit;
}
.keyword-pill {
display: inline-block; padding: 0.2rem 0.5rem; background: var(--surface-light);
border-radius: 4px; font-size: 0.75rem; margin-right: 4px; margin-bottom: 4px;
border: 1px solid var(--border);
}
/* Zoom */
.zoom-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.95); z-index: 3000;
display: flex; justify-content: center; align-items: center;
opacity: 0; pointer-events: none; transition: 0.2s;
}
.zoom-overlay.active { opacity: 1; pointer-events: all; }
.zoom-overlay img { max-width: 95vw; max-height: 95vh; object-fit: contain; }
.empty-state {
text-align: center; padding: 4rem 2rem; color: var(--text-muted);
}
/* Side actions in modal */
.m-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; }
.m-grid > div { min-width: 0; }
.ai-box {
background: rgba(59, 130, 246, 0.05); border-left: 3px solid var(--primary);
padding: 1rem; border-radius: 4px; font-size: 0.9rem; margin-bottom: 1.5rem;
}
.progress-wrapper {
flex: 1; max-width: 400px; background: var(--surface-light);
height: 8px; border-radius: 4px; overflow: hidden; display: none;
border: 1px solid var(--border);
}
.progress-fill {
height: 100%; background: var(--primary); width: 0%; transition: width 0.3s;
box-shadow: 0 0 10px var(--primary-glow);
}
</style>
</head>
<body>
<header>
<h1>eBay Deep Scan Dashboard</h1>
<div style="display:flex; gap: 0.5rem">
<button class="btn-outline" onclick="triggerScan('all')">🌐 Scan All Profiles</button>
<button class="btn-outline" onclick="openProfileModal()">⚙️ Manage Profiles</button>
<button class="btn-primary" id="btn-scan" onclick="triggerScan()">🚀 Start Scan</button>
</div>
</header>
<div class="nav-tabs" id="profile-tabs">
<!-- Tabs injected here -->
</div>
<div class="action-bar">
<div class="status-badge">
<span class="status-dot" id="status-dot"></span>
<span id="status-text">Ready</span>
</div>
<div class="progress-wrapper" id="progress-wrapper">
<div class="progress-fill" id="progress-fill"></div>
</div>
<div id="progress-info" style="font-size: 0.8rem; color: var(--text-muted); display: none;"></div>
<div style="margin-left: auto; display: flex; gap: 1rem; align-items: center; font-size: 0.85rem">
<span id="last-run-label">Last scan: <b id="last-run-time">-</b></span>
<button class="btn-outline" onclick="openKeywordsModal()" id="btn-manage-kw">📝 Manage Keywords</button>
</div>
</div>
<main>
<div class="toolbar">
<input type="text" id="searchInput" placeholder="Filter by title, part number, seller..." oninput="renderItems()">
<div id="item-count" style="font-size: 0.85rem; color: var(--text-muted)"></div>
</div>
<div class="card">
<table>
<thead>
<tr>
<th width="80">Img</th>
<th>Product Info</th>
<th>Market Price</th>
<th>Profit (Est)</th>
<th>AI Analysis</th>
<th width="150">Review</th>
</tr>
</thead>
<tbody id="item-tbody"></tbody>
</table>
</div>
</main>
<!-- Profile Management Modal -->
<div class="modal-overlay" id="modal-profiles">
<div class="modal-content">
<button class="modal-close" onclick="closeModals()">×</button>
<div class="modal-padd">
<h2 style="margin-bottom: 1.5rem">Manage Scan Profiles</h2>
<div id="profile-list" style="margin-bottom: 2rem"></div>
<h3 style="margin-bottom: 1rem; font-size: 1rem">Create New Profile</h3>
<div class="card" style="padding: 1.5rem">
<div class="m-grid">
<div>
<label>Profile Name</label>
<input type="text" id="new-profile-name" placeholder="e.g. DDR4 RAM, 3060 GPUs">
</div>
<div>
<label>Price Ratio (e.g. 0.85)</label>
<input type="number" id="new-profile-ratio" value="0.85" step="0.01">
</div>
</div>
<button class="btn-primary" style="width: 100%" id="btn-save-profile" onclick="saveProfile()">Add Profile</button>
</div>
</div>
</div>
</div>
<!-- Keywords Management Modal -->
<div class="modal-overlay" id="modal-keywords">
<div class="modal-content" style="max-width: 1100px;">
<button class="modal-close" onclick="closeModals()">×</button>
<div class="modal-padd">
<h2 id="kw-modal-title" style="margin-bottom: 1.5rem">Keywords for ...</h2>
<div class="card" style="padding: 1.5rem; margin-bottom: 2rem; background: var(--surface-light)">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem">
<h3 style="font-size: 0.95rem">Add Search Target</h3>
<button class="btn-outline" onclick="toggleBulkImport()">Toggle Bulk Import</button>
</div>
<div id="single-import-form">
<div style="display: flex; gap: 1rem; flex-wrap: wrap;">
<div style="flex: 1; min-width: 200px">
<label>Part Number (Optional)</label>
<input type="text" id="kw-part" placeholder="e.g. M393A2K43BB1-CTD">
</div>
<div style="flex: 2; min-width: 300px">
<label>Keywords (Comma separated)</label>
<input type="text" id="kw-list" placeholder="e.g. 16GB DDR4 2666 ECC, Samsung 16GB RDIMM">
</div>
<div style="flex: 0.5; min-width: 120px">
<label>Target Price ($)</label>
<input type="number" id="kw-price" placeholder="25.00">
</div>
</div>
<button class="btn-success" style="margin-top: 1rem" id="btn-add-kw" onclick="addKeyword()">Add Target to Profile</button>
</div>
<div id="bulk-import-form" style="display:none">
<label>Paste lines: <code>PartNumber | Keywords | Price</code></label>
<textarea id="bulk-text" rows="8" style="width:100%; background:#0f1422; border:1px solid var(--border); color:white; padding:0.75rem; border-radius:8px; font-family:monospace" placeholder="M393A... | 16GB DDR4..., Samsung... | 25.00"></textarea>
<button class="btn-success" style="margin-top: 1rem; width:100%" onclick="processBulkKeywords()">Import All Lines</button>
</div>
</div>
<div class="card">
<table>
<thead>
<tr><th>Part #</th><th>Search Keywords</th><th>Target $</th><th>Action</th></tr>
</thead>
<tbody id="kw-tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Detail Modal -->
<div class="modal-overlay" id="modal-detail">
<div class="modal-content" style="max-width: 1000px;">
<button class="modal-close" onclick="closeModals()">×</button>
<div class="modal-padd" id="detail-body"></div>
</div>
</div>
<!-- Zoom -->
<div class="zoom-overlay" id="zoom-overlay" onclick="this.classList.remove('active')">
<img id="zoom-img">
</div>
<script>
let profiles = [];
let currentProfileId = null;
let items = [];
let isScanning = false;
const fmat = (v) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(v || 0);
async function init() {
await fetchProfiles();
if (profiles.length > 0) {
switchProfile(profiles[0].id);
}
fetchStatus();
setInterval(fetchStatus, 3000);
}
async function fetchProfiles() {
const res = await fetch('/api/profiles');
profiles = await res.json();
renderTabs();
}
function renderTabs() {
const container = document.getElementById('profile-tabs');
container.innerHTML = profiles.map(p => `
<div class="tab ${currentProfileId == p.id ? 'active' : ''}" onclick="switchProfile(${p.id})">${p.name}</div>
`).join('') + `<div class="tab tab-add" onclick="openProfileModal()">+ New Profile</div>`;
}
async function switchProfile(id) {
if (!id) return;
currentProfileId = id;
renderTabs();
const p = profiles.find(x => x.id == id);
if (p) {
document.getElementById('last-run-time').innerText = p.last_scan_time ? new Date(p.last_scan_time).toLocaleString() : 'Never';
}
await fetchItems();
}
async function fetchItems() {
if (!currentProfileId) return;
const res = await fetch(`/api/items?profile_id=${currentProfileId}`);
items = await res.json();
renderItems();
}
function renderItems() {
const q = document.getElementById('searchInput').value.toLowerCase();
const filtered = items.filter(i =>
i.title.toLowerCase().includes(q) ||
(i.partNumber && i.partNumber.toLowerCase().includes(q)) ||
(i.seller_username && i.seller_username.toLowerCase().includes(q))
);
document.getElementById('item-count').innerText = `Found ${filtered.length} items`;
const tbody = document.getElementById('item-tbody');
tbody.innerHTML = filtered.length ? filtered.map(item => {
const img = item.images && item.images.length > 0 ? item.images[0] : 'https://via.placeholder.com/64';
return `
<tr>
<td><img src="${img}" class="td-img" onclick="openZoom('${img}')"></td>
<td>
<div class="td-title" onclick="openDetail('${item.id}')">${item.title}</div>
<div class="td-sub">
ID: ${item.id} | PN: ${item.partNumber || 'N/A'} | <b>${item.manufacturer || 'Generic'}</b>
${item.specs ? `<br><span style="color:var(--warning); font-size:0.75rem">⚠️ ${item.specs}</span>` : ''}
</div>
</td>
<td>
<div class="price-lg">${fmat(item.avgPrice)}</div>
<div class="td-sub">Qty: ${item.available} | Ship: ${fmat(item.shipping)}</div>
</td>
<td><span class="profit-badge">+${fmat(item.profit)}</span></td>
<td style="font-size: 0.8rem; max-width: 250px; color: #bae6fd">
${item.ai_suggestion ? '🤖 ' + item.ai_suggestion : '<i>Pending...</i>'}
</td>
<td>
<select onchange="updateStatus('${item.id}', this.value)" style="background:#0f1422; color:white; border:1px solid var(--border); padding:0.3rem; border-radius:4px; font-size:0.8rem">
<option value="waiting" selected>Waiting</option>
<option value="done">Done</option>
<option value="skip">Skip</option>
</select>
</td>
</tr>
`;
}).join('') : `<tr><td colspan="6" class="empty-state">No PASS items found for this profile.</td></tr>`;
}
async function fetchStatus() {
const res = await fetch('/api/status');
const data = await res.json();
isScanning = data.isScanning;
document.getElementById('status-dot').className = `status-dot ${isScanning ? 'active' : ''}`;
document.getElementById('status-text').innerText = isScanning ? 'Searching eBay...' : 'Ready';
document.getElementById('btn-scan').disabled = isScanning;
const progWrap = document.getElementById('progress-wrapper');
const progInfo = document.getElementById('progress-info');
const progFill = document.getElementById('progress-fill');
if (isScanning && data.scanProgress && data.scanProgress.total > 0) {
progWrap.style.display = 'block';
progInfo.style.display = 'block';
const pct = Math.round((data.scanProgress.current / data.scanProgress.total) * 100);
progFill.style.width = `${pct}%`;
progInfo.innerText = `Scanning ${data.scanProgress.profileName}: ${data.scanProgress.current}/${data.scanProgress.total} (${pct}%)`;
if (pct === 100) {
progInfo.innerText = "Finishing scan...";
}
} else {
progWrap.style.display = 'none';
progInfo.style.display = 'none';
}
}
async function triggerScan(target) {
const pid = target === 'all' ? 'all' : currentProfileId;
if (!pid) return;
await fetch('/api/scan', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ profile_id: pid })
});
fetchStatus();
}
async function updateStatus(id, status) {
await fetch(`/api/items/${id}/status`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ status })
});
items = items.filter(i => i.id !== id);
renderItems();
}
// Modal Control
function closeModals() {
document.querySelectorAll('.modal-overlay').forEach(m => m.classList.remove('active'));
}
function openZoom(url) {
document.getElementById('zoom-img').src = url;
document.getElementById('zoom-overlay').classList.add('active');
}
let editingProfileId = null;
let editingKeywordId = null;
// Profile Management
function openProfileModal() {
editingProfileId = null;
document.getElementById('new-profile-name').value = '';
document.getElementById('new-profile-ratio').value = '0.85';
document.getElementById('btn-save-profile').innerText = 'Add Profile';
document.getElementById('modal-profiles').classList.add('active');
renderProfileList();
}
function renderProfileList() {
document.getElementById('profile-list').innerHTML = profiles.map(p => {
const lastScan = p.last_scan_time ? new Date(p.last_scan_time).toLocaleString() : 'Never';
return `
<div style="display:flex; justify-content:space-between; align-items:center; padding:0.75rem; border-bottom:1px solid var(--border)">
<div>
<b>${p.name}</b> (Ratio: ${p.price_ratio})<br>
<small style="color:var(--text-muted)">Last scan: ${lastScan}</small>
</div>
<div style="display:flex; gap:0.5rem">
<button class="btn-outline" style="padding:0.3rem 0.6rem" onclick="editProfile(${p.id}, '${p.name}', ${p.price_ratio})">Edit</button>
<button class="btn-danger" style="padding:0.3rem 0.6rem" onclick="deleteProfile(${p.id})">Delete</button>
</div>
</div>
`;}).join('');
}
function editProfile(id, name, ratio) {
editingProfileId = id;
document.getElementById('new-profile-name').value = name;
document.getElementById('new-profile-ratio').value = ratio;
document.getElementById('btn-save-profile').innerText = 'Update Profile';
}
async function saveProfile() {
const name = document.getElementById('new-profile-name').value;
const ratio = document.getElementById('new-profile-ratio').value;
if (!name) return;
const method = editingProfileId ? 'PUT' : 'POST';
const url = editingProfileId ? `/api/profiles/${editingProfileId}` : '/api/profiles';
await fetch(url, {
method,
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ name, price_ratio: parseFloat(ratio) })
});
editingProfileId = null;
document.getElementById('new-profile-name').value = '';
document.getElementById('btn-save-profile').innerText = 'Add Profile';
await fetchProfiles();
renderProfileList();
}
async function deleteProfile(id) {
if (!confirm('Delete this profile and ALL associated data?')) return;
await fetch(`/api/profiles/${id}`, { method: 'DELETE' });
await fetchProfiles();
if (currentProfileId == id) currentProfileId = profiles[0]?.id || null;
renderProfileList();
renderTabs();
}
// Keywords Management
async function openKeywordsModal() {
if (!currentProfileId) return;
editingKeywordId = null;
resetKeywordForm();
const profile = profiles.find(p => p.id == currentProfileId);
document.getElementById('kw-modal-title').innerText = `Search Targets for: ${profile.name}`;
document.getElementById('modal-keywords').classList.add('active');
await fetchKeywords();
}
function resetKeywordForm() {
editingKeywordId = null;
document.getElementById('kw-part').value = '';
document.getElementById('kw-list').value = '';
document.getElementById('kw-price').value = '';
document.getElementById('btn-add-kw').innerText = 'Add Target to Profile';
document.getElementById('btn-add-kw').className = 'btn-success';
}
let currentKeywordsData = [];
async function fetchKeywords() {
const res = await fetch(`/api/profiles/${currentProfileId}/keywords`);
currentKeywordsData = await res.json();
const tbody = document.getElementById('kw-tbody');
tbody.innerHTML = currentKeywordsData.map(kw => `
<tr>
<td>${kw.part_number || '-'}</td>
<td>${kw.keywords.map(k => `<span class="keyword-pill">${k}</span>`).join('')}</td>
<td>${fmat(kw.target_price)}</td>
<td>
<div style="display:flex; gap:0.5rem">
<button class="btn-outline" style="padding:0.2rem 0.5rem" onclick="editKeyword(${kw.id})">Edit</button>
<button class="btn-danger" style="padding:0.2rem 0.5rem" onclick="deleteKeyword(${kw.id})">Remove</button>
</div>
</td>
</tr>
`).join('');
}
function editKeyword(id) {
const kw = currentKeywordsData.find(k => k.id == id);
if (!kw) return;
editingKeywordId = id;
document.getElementById('kw-part').value = kw.part_number;
document.getElementById('kw-list').value = kw.keywords.join(', ');
document.getElementById('kw-price').value = kw.target_price;
document.getElementById('btn-add-kw').innerText = 'Update Target';
document.getElementById('btn-add-kw').className = 'btn-primary';
document.getElementById('single-import-form').scrollIntoView({ behavior: 'smooth' });
}
async function addKeyword() {
const part = document.getElementById('kw-part').value;
const list = document.getElementById('kw-list').value;
const price = document.getElementById('kw-price').value;
if (!list || !price) return;
const keywords = list.split(',').map(s => s.trim()).filter(Boolean);
const method = editingKeywordId ? 'PUT' : 'POST';
const url = editingKeywordId ? `/api/keywords/${editingKeywordId}` : `/api/profiles/${currentProfileId}/keywords`;
await fetch(url, {
method,
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ part_number: part, keywords, target_price: parseFloat(price) })
});
resetKeywordForm();
await fetchKeywords();
}
async function deleteKeyword(id) {
await fetch(`/api/keywords/${id}`, { method: 'DELETE' });
await fetchKeywords();
}
function toggleBulkImport() {
const single = document.getElementById('single-import-form');
const bulk = document.getElementById('bulk-import-form');
if (single.style.display === 'none') {
single.style.display = 'block';
bulk.style.display = 'none';
} else {
single.style.display = 'none';
bulk.style.display = 'block';
}
}
async function processBulkKeywords() {
const text = document.getElementById('bulk-text').value;
const lines = text.split('\n').filter(l => l.trim().includes('|'));
const items = lines.map(line => {
const parts = line.split('|').map(s => s.trim());
if (parts.length < 3) return null;
let kwPart = parts[1];
// Only split by comma. Keep quotes as part of the query string.
let keywords = kwPart.split(',').map(s => s.trim()).filter(Boolean);
return {
part_number: (parts[0] === 'NONE' || parts[0] === '' || parts[0] === '""') ? '' : parts[0].replace(/"/g, ''),
keywords,
target_price: parseFloat(parts[2].replace(/[$,]/g, ''))
};
}).filter(Boolean);
if (!items.length) {
alert('No valid lines found. Format: PN | K1, K2 | Price');
return;
}
await fetch(`/api/profiles/${currentProfileId}/keywords/bulk`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ items })
});
document.getElementById('bulk-text').value = '';
toggleBulkImport();
await fetchKeywords();
}
// Detail View
function openDetail(id) {
const item = items.find(i => i.id == id);
if (!item) return;
const detail = item.detail_response;
let descHtml = 'No description available.';
if (detail) descHtml = detail.shortDescription || detail.description || descHtml;
const body = document.getElementById('detail-body');
body.innerHTML = `
<div class="m-grid">
<div style="display:flex; flex-direction:column; gap:1rem">
<div style="width:100%; height:500px; display:flex; justify-content:center; align-items:center; background:#000; border-radius:12px; overflow:hidden">
<img src="${item.images[0] || ''}" style="max-width:100%; max-height:100%; object-fit:contain">
</div>
<div style="display:flex; gap:0.5rem; overflow-x:auto">
${item.images.slice(1,5).map(img => `<img src="${img}" style="width:60px; height:60px; object-fit:cover; border-radius:4px">`).join('')}
</div>
<div style="margin-top:0.5rem">
<h3 style="margin-bottom:0.5rem">Description</h3>
<div style="font-size:0.85rem; color:var(--text-muted); line-height:1.6; max-height:300px; overflow-y:auto; padding-right:1rem">
${descHtml}
</div>
</div>
</div>
<div>
<h2 style="margin-bottom:0.5rem">${item.title}</h2>
<div style="color:var(--text-muted); font-size:0.9rem; margin-bottom:1.5rem">ID: ${item.id}</div>
<div class="ai-box">
<b>🤖 AI Analysis:</b><br>${item.ai_suggestion || 'Not analyzed yet'}
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:1rem; margin-bottom:2rem">
<div class="card" style="padding:1rem">
<div style="font-size:0.8rem; color:var(--text-muted)">Avg Price (Total / Qty)</div>
<div class="price-lg">${fmat(item.avgPrice)}</div>
<div style="font-size:0.75rem; color:var(--text-muted)">${fmat(item.total)} / ${item.qty}</div>
</div>
<div class="card" style="padding:1rem">
<div style="font-size:0.8rem; color:var(--text-muted)">Est. Profit</div>
<div class="price-lg" style="color:var(--success)">+${fmat(item.profit)}</div>
</div>
</div>
<div style="font-size:0.9rem">
<p><b>Seller:</b> ${item.seller_username || 'N/A'} (${item.seller_feedback_percent}%)</p>
<p><b>Available:</b> ${item.available} units</p>
<p><b>Target Price:</b> ${fmat(item.targetPrice)}</p>
<p><b>Threshold:</b> ${fmat(item.threshold)}</p>
</div>
<div style="margin-top:2.5rem; display:flex; gap:1rem">
<a href="${item.url}" target="_blank" class="btn-primary" style="flex:1; text-align:center; text-decoration:none; padding:0.8rem">Open on eBay</a>
<button class="btn-outline" style="flex:1" onclick="updateStatus('${item.id}', 'skip')">Skip</button>
<button class="btn-success" style="flex:1" onclick="updateStatus('${item.id}', 'done')">Done</button>
</div>
</div>
</div>
`;
document.getElementById('modal-detail').classList.add('active');
}
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeModals(); });
init();
</script>
</body>
</html>

607
scanner.js Normal file
View File

@ -0,0 +1,607 @@
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);
});
}

224
server.js Normal file
View File

@ -0,0 +1,224 @@
const express = require('express');
const cors = require('cors');
const path = require('path');
const fs = require('fs');
const db = require('./db');
const scanner = require('./scanner');
const ai = require('./ai');
const app = express();
const PORT = process.env.PORT || 4000;
// Middleware
app.use(cors());
app.use(express.json());
// Serve static files from public directory
app.use(express.static(path.join(__dirname, 'public')));
// Create public directory if it doesn't exist
const publicDir = path.join(__dirname, 'public');
if (!fs.existsSync(publicDir)) {
fs.mkdirSync(publicDir, { recursive: true });
}
// Track scanning state
let lastRunTime = null;
let scanProgress = { current: 0, total: 0, profileName: '' };
// API Routes
// --- PROFILES ---
app.get('/api/profiles', (req, res) => {
try {
let profiles = db.getProfiles();
if (profiles.length === 0) {
// Create a default profile if none exists
db.addProfile('Default Profile', 0.85);
profiles = db.getProfiles();
}
res.json(profiles);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/api/profiles', (req, res) => {
try {
const { name, price_ratio } = req.body;
db.addProfile(name, price_ratio || 0.85);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.put('/api/profiles/:id', (req, res) => {
try {
const { name, price_ratio } = req.body;
db.updateProfile(req.params.id, name, price_ratio);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.delete('/api/profiles/:id', (req, res) => {
try {
db.deleteProfile(req.params.id);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// --- KEYWORDS ---
app.get('/api/profiles/:id/keywords', (req, res) => {
try {
const keywords = db.getKeywords(req.params.id);
res.json(keywords);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/api/profiles/:id/keywords', (req, res) => {
try {
const { part_number, keywords, target_price } = req.body;
db.addKeyword(req.params.id, part_number, keywords, target_price);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.put('/api/keywords/:id', (req, res) => {
try {
const { part_number, keywords, target_price } = req.body;
db.updateKeyword(req.params.id, part_number, keywords, target_price);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/api/profiles/:id/keywords/bulk', (req, res) => {
try {
const { items } = req.body; // Array of {part_number, keywords, target_price}
for (const item of items) {
db.addKeyword(req.params.id, item.part_number, item.keywords, item.target_price);
}
res.json({ success: true, count: items.length });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.delete('/api/keywords/:id', (req, res) => {
try {
db.deleteKeyword(req.params.id);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// --- ITEMS ---
// Get scan status
app.get('/api/status', (req, res) => {
res.json({ isScanning, lastRunTime, scanProgress });
});
// Get all "waiting" PASS items for a profile
app.get('/api/items', (req, res) => {
try {
const { profile_id } = req.query;
const items = db.getWaitingPassItems(profile_id);
res.json(items);
} catch (err) {
console.error('Error fetching items:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// Update item review status
app.put('/api/items/:id/status', (req, res) => {
try {
const { id } = req.params;
const { status } = req.body;
if (!['waiting', 'done', 'skip'].includes(status)) {
return res.status(400).json({ error: 'Invalid status' });
}
db.updateReviewStatus(id, status);
res.json({ success: true, message: `Status updated to ${status}` });
} catch (err) {
console.error(`Error updating status for item ${req.params.id}:`, err);
res.status(500).json({ error: 'Internal server error' });
}
});
// Trigger a new scan
app.post('/api/scan', async (req, res) => {
if (isScanning) {
return res.status(400).json({ error: 'Scan is already running' });
}
const { profile_id } = req.body;
if (!profile_id) {
return res.status(400).json({ error: 'profile_id is required' });
}
isScanning = true;
res.json({ success: true, message: 'Scan started in background' });
try {
console.log(`--- STARTING BACKGROUND SCAN FOR PROFILE ${profile_id} ---`);
scanProgress = { current: 0, total: 0, profileName: '' };
await scanner.runScannerCore(profile_id, (current, total, profileName) => {
scanProgress = { current, total, profileName };
});
console.log('--- BACKGROUND SCAN FINISHED ---');
} catch (err) {
console.error('Error running scan:', err);
} finally {
isScanning = false;
lastRunTime = new Date().toISOString();
scanProgress = { current: 0, total: 0, profileName: '' };
}
});
// Trigger AI on specific item
app.post('/api/items/:id/ai', async (req, res) => {
try {
const { id } = req.params;
const item = db.getItem(id);
if (!item) {
return res.status(404).json({ error: 'Item not found' });
}
// Convert JSON strings back to objects for AI prompt
item.detail_response = item.detail_response ? JSON.parse(item.detail_response) : null;
const suggestion = await ai.getAiSuggestion(item);
db.updateAiSuggestion(id, suggestion);
res.json({ success: true, ai_suggestion: suggestion });
} catch (err) {
console.error(`Error requesting AI for item ${req.params.id}:`, err);
res.status(500).json({ error: 'Internal server error' });
}
});
// Default catch-all to index.html for SPA feel
app.use((req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// Start server
app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});

58
test_access.js Normal file
View File

@ -0,0 +1,58 @@
require('dotenv').config();
const axios = require('axios');
async function testOpenAI() {
const API_KEY = process.env.OPENAI_API_KEY;
const MODEL = process.env.OPENAI_MODEL || 'gpt-4o-mini';
console.log('=== OpenAI API Access Test ===');
console.log(`Model : ${MODEL}`);
console.log(`API Key : ${API_KEY ? API_KEY.slice(0, 7) + '...' + API_KEY.slice(-4) : '(not set)'}`);
console.log('');
if (!API_KEY) {
console.log('❌ OPENAI_API_KEY not set in .env');
process.exit(1);
}
try {
const res = await axios.post(
'https://api.openai.com/v1/chat/completions',
{
model: MODEL,
max_tokens: 16,
messages: [{ role: 'user', content: 'Reply with: OK' }],
},
{
headers: {
Authorization: `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
timeout: 15000,
}
);
const reply = res.data.choices?.[0]?.message?.content?.trim();
const usage = res.data.usage;
const model = res.data.model;
console.log(`✅ Connected`);
console.log(` Model : ${model}`);
console.log(` Response : ${reply}`);
console.log(` Tokens used : ${usage?.total_tokens} (prompt ${usage?.prompt_tokens} + completion ${usage?.completion_tokens})`);
console.log(` Est. cost : $${((usage?.prompt_tokens / 1_000_000) * 0.15 + (usage?.completion_tokens / 1_000_000) * 0.60).toFixed(6)}`);
} catch (err) {
const status = err.response?.status;
const msg = err.response?.data?.error?.message || err.message;
const code = err.response?.data?.error?.code || '';
console.log(`❌ FAILED — HTTP ${status} ${code}: ${msg}`);
}
console.log('\n=== Done ===');
}
testOpenAI().catch(err => {
console.error('Fatal:', err.message);
process.exit(1);
});