first commit
This commit is contained in:
commit
ec9e80d36f
|
|
@ -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
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
node_modules
|
||||
.env
|
||||
Binary file not shown.
|
|
@ -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
|
||||
|
|
@ -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 là 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 có bị ảo / lừa đảo (fake) không?
|
||||
2. Seller có 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. (Ví 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);
|
||||
}
|
||||
Binary file not shown.
|
|
@ -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
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue