commit ec9e80d36fbebd312008ef7f4fa48b58de01667b Author: Joseph Date: Tue Mar 24 10:01:07 2026 +0700 first commit diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..b58b51e Binary files /dev/null and b/.DS_Store differ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ce16746 --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1dcef2d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +.env \ No newline at end of file diff --git a/Archive.zip b/Archive.zip new file mode 100644 index 0000000..b48795c Binary files /dev/null and b/Archive.zip differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..306cb87 --- /dev/null +++ b/README.md @@ -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_.xlsx` — full results with PASS/FAIL +- `results_.json` — same data as JSON +- `run_.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_.txt` +2. Create new `system_prompt_.txt` explaining the product domain +3. Update `.env` to point to new files +4. Run — AI cache is per-itemId so it auto-separates diff --git a/ai.js b/ai.js new file mode 100644 index 0000000..cb4f6df --- /dev/null +++ b/ai.js @@ -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); +} diff --git a/data/ebay_items.db b/data/ebay_items.db new file mode 100644 index 0000000..e8ff1d8 Binary files /dev/null and b/data/ebay_items.db differ diff --git a/db.js b/db.js new file mode 100644 index 0000000..9d84466 --- /dev/null +++ b/db.js @@ -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 + })); + } +}; diff --git a/index.js b/index.js new file mode 100644 index 0000000..ba1c665 --- /dev/null +++ b/index.js @@ -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); +}); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a2b8315 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1544 @@ +{ + "name": "ebaydeepscan", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ebaydeepscan", + "version": "1.0.0", + "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" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/better-sqlite3": { + "version": "12.8.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz", + "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", + "hasInstallScript": true, + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs": { + "version": "0.0.1-security", + "resolved": "https://registry.npmjs.org/fs/-/fs-0.0.1-security.tgz", + "integrity": "sha512-3XY9e1pP0CVEUCdj5BmfIZxRBTSDycnbqhIOGec9QYtmVH2fbLpj86CFWkrNOkt/Fvty4KZG5lTglL9j/gJ87w==" + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-errors/node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openai": { + "version": "6.32.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.32.0.tgz", + "integrity": "sha512-j3k+BjydAf8yQlcOI7WUQMQTbbF5GEIMAE2iZYCOzwwB3S2pCheaWYp+XZRNAch4jWVc52PMDGRRjutao3lLCg==", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path": { + "version": "0.12.7", + "resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz", + "integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==", + "dependencies": { + "process": "^0.11.1", + "util": "^0.10.3" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", + "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", + "dependencies": { + "inherits": "2.0.3" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2791daf --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..6bb4745 --- /dev/null +++ b/public/index.html @@ -0,0 +1,729 @@ + + + + + + eBay Scanner Dashboard + + + + + +
+

eBay Deep Scan Dashboard

+
+ + + +
+
+ + + +
+
+ + Ready +
+
+
+
+ +
+ Last scan: - + +
+
+ +
+
+ +
+
+ +
+ + + + + + + + + + + + +
ImgProduct InfoMarket PriceProfit (Est)AI AnalysisReview
+
+
+ + + + + + + + + + + +
+ +
+ + + + diff --git a/scanner.js b/scanner.js new file mode 100644 index 0000000..6acfc12 --- /dev/null +++ b/scanner.js @@ -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); + }); +} \ No newline at end of file diff --git a/server.js b/server.js new file mode 100644 index 0000000..9bd51f5 --- /dev/null +++ b/server.js @@ -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}`); +}); diff --git a/test_access.js b/test_access.js new file mode 100644 index 0000000..c6135c3 --- /dev/null +++ b/test_access.js @@ -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); +}); \ No newline at end of file