update du thu

This commit is contained in:
Joseph Le 2026-03-25 20:53:14 +11:00
parent abc194cd2f
commit 01dc64b0e7
4 changed files with 244 additions and 40 deletions

3
.gitignore vendored
View File

@ -1,3 +1,4 @@
node_modules node_modules
.env .env
*/*.db */*.db
data/ebay_items.db

100
ai.js
View File

@ -8,37 +8,93 @@ const openai = new OpenAI({
async function getAiSuggestion(item) { async function getAiSuggestion(item) {
try { try {
const imageUrl = item.detail_response.image ? item.detail_response.image.imageUrl : null;
const prompt = ` const prompt = `
Bạn một chuyên gia thẩm định hàng hoá điện tử trên eBay. ROLE:
Hãy kiểm tra các thông tin dưới đây để trả lời 3 câu hỏi: Bạn chuyên gia kiểm định listing eBay dựa trên dữ liệu + hình ảnh sản phẩm.
1. Item bị ảo / lừa đảo (fake) không?
2. Seller uy tín không? (so sánh feedback score, percent)
3. Dữ liệu hiện tại đã đúng sản phẩm chưa? (Part Number, Specs)
THÔNG TIN TÌM KIẾM: MỤC TIÊU:
- Part Number: ${item.partNumber} Đánh giá độ tin cậy của listing dựa trên:
- Specs mục tiêu: ${item.specs} - Độ khớp giữa HÌNH ẢNH THÔNG TIN
- Độ uy tín của seller
- Độ hợp tổng thể (price / data consistency)
THÔNG TIN EBAY (Tìm được): INPUT:
- Tiêu đề: ${item.title}
- Seller: ${item.seller_username} (Score: ${item.seller_feedback_score}, Positive: ${item.seller_feedback_percent}%) [SEARCH TARGET]
- Part Number: ${item.partNumber || "N/A"}
- Expected Specs: ${item.specs || "N/A"}
[EBAY DATA]
- Title: ${item.title}
- Price: ${item.price} - Price: ${item.price}
- Phân tích JSON chi tiết từ API: - Seller: ${item.detail_response?.seller?.username}
${item.detail_response ? JSON.stringify(item.detail_response).substring(0, 1500) : 'Không có dữ liệu chi tiết'} - Feedback Score: ${item.detail_response?.seller?.feedbackScore}
- Positive %: ${item.detail_response?.seller?.feedbackPercent}
YÊU CẦU ĐẦU RA (Quan trọng!): [DETAIL DATA]
Chỉ đưa ra kết luận DUY NHẤT 1 câu ngắn gọn. ( dụ: "Hãy mua ngay, seller uy tín và đúng chuẩn sản phẩm." HOẶC "Cẩn thận, seller ít feedback và tiêu đề không rõ ràng.") - Brand: ${item.detail_response?.brand || "N/A"}
Khong giải thích dài dòng! - Aspects: ${JSON.stringify(item.detail_response?.localizedAspects || {})}
TASK (QUAN TRỌNG):
Thực hiện các bước sau (ngầm, không output):
1. Nếu ảnh:
- OCR label / text chính (model, part number, specs)
- So sánh với Title + Aspects
2. Check mismatch:
- Sai model / sai part number / sai specs
- Ảnh không liên quan (stock image / generic)
3. Đánh giá seller:
- Feedback < 95% hoặc score thấp => rủi ro
4. Đánh giá tổng thể:
- Consistency + seller + price
OUTPUT RULE (BẮT BUỘC):
- Chỉ 1 câu duy nhất kèm số điểm đánh giá từ 1-10 với độ khớp
- Không markdown, không xuống dòng
- Không giải thích dài
- Format:
Nếu tốt:
"Hãy mua, dữ liệu và hình ảnh khớp, seller uy tín. (Điểm: {{điểm}})"
Nếu rủi ro:
"Cẩn thận, {{lý do chính ngắn gọn}}. (Điểm: {{điểm}})"
Nếu rất tệ:
"Không nên mua, {{lý do rõ ràng}}. (Điểm: {{điểm}})"
dụ do:
- label không khớp title
- sai part number
- ảnh generic / không phải sản phẩm thật
- seller feedback thấp
OUTPUT:
`; `;
console.log(prompt);
const messages = [
{ role: "system", content: "Bạn là trợ lý AI chuyên thẩm định eBay bằng hình ảnh và dữ liệu. Chỉ trả về 1 câu kết luận ngắn gọn." }
];
if (imageUrl) {
messages.push({
role: "user",
content: [
{ type: "text", text: prompt },
{ type: "image_url", image_url: { url: imageUrl, detail: "high" } }
]
});
} else {
messages.push({ role: "user", content: prompt });
}
const response = await openai.chat.completions.create({ const response = await openai.chat.completions.create({
model: "gpt-4o-mini", model: "gpt-4o-mini",
messages: [ messages: 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." }, max_tokens: 150,
{ role: "user", content: prompt } temperature: 0.2
],
max_tokens: 100,
temperature: 0.3
}); });
return { return {

View File

@ -140,6 +140,37 @@
} }
.modal-padd { padding: 2.5rem; } .modal-padd { padding: 2.5rem; }
.thumb-img {
width: 60px; height: 60px; object-fit: cover;
border-radius: 6px; cursor: pointer; border: 2px solid transparent;
transition: all 0.2s;
}
.thumb-img:hover { border-color: var(--primary); transform: translateY(-2px); }
.thumb-img.active { border-color: var(--primary); }
#detail-main-img {
max-width: 100%; max-height: 100%; object-fit: contain; cursor: zoom-in;
}
.row-highlight {
background: rgba(34, 197, 94, 0.1) !important;
border-left: 4px solid var(--success) !important;
}
.toolbar-group {
display: flex; gap: 0.5rem; align-items: center;
background: var(--surface); border: 1px solid var(--border);
padding: 0.3rem 0.6rem; border-radius: 8px;
}
.toolbar-group label { margin-bottom: 0; font-size: 0.75rem; white-space: nowrap; color: var(--text-muted); }
.toolbar-group input { border: none; width: 60px; padding: 0.2rem; background: transparent; color: var(--text); outline: none; }
.badge-offer {
background: #f97316; /* Orange */
color: white; padding: 0.1rem 0.4rem; border-radius: 4px;
font-size: 0.7rem; font-weight: 700; margin-left: 0.5rem;
display: inline-block; vertical-align: middle; line-height: 1;
}
form div { margin-bottom: 1rem; } form div { margin-bottom: 1rem; }
label { display: block; margin-bottom: 0.4rem; font-size: 0.9rem; color: var(--text-muted); } label { display: block; margin-bottom: 0.4rem; font-size: 0.9rem; color: var(--text-muted); }
input[type="text"], input[type="number"], textarea { input[type="text"], input[type="number"], textarea {
@ -185,6 +216,23 @@
height: 100%; background: var(--primary); width: 0%; transition: width 0.3s; height: 100%; background: var(--primary); width: 0%; transition: width 0.3s;
box-shadow: 0 0 10px var(--primary-glow); box-shadow: 0 0 10px var(--primary-glow);
} }
/* Bulk Bar */
.bulk-bar {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
background: var(--primary); color: white; padding: 0.8rem 2rem;
border-radius: 50px; display: flex; align-items: center; gap: 1.5rem;
box-shadow: 0 10px 30px rgba(0,0,0,0.5); z-index: 1001;
opacity: 0; pointer-events: none; transition: all 0.3s;
}
.bulk-bar.active { opacity: 1; pointer-events: all; bottom: 40px; }
.bulk-bar button {
background: rgba(255,255,255,0.15); color: white;
border: 1px solid rgba(255,255,255,0.3); padding: 0.5rem 1.2rem;
}
.bulk-bar button:hover { background: rgba(255,255,255,0.25); }
.item-checkbox { width: 18px; height: 18px; cursor: pointer; accent-color: var(--primary); }
</style> </style>
</head> </head>
<body> <body>
@ -220,6 +268,14 @@
<main> <main>
<div class="toolbar"> <div class="toolbar">
<input type="text" id="searchInput" placeholder="Filter by title, part number, seller..." oninput="renderItems()"> <input type="text" id="searchInput" placeholder="Filter by title, part number, seller..." oninput="renderItems()">
<div class="toolbar-group">
<label>Profit Min ($):</label>
<input type="number" id="minProfit" value="0" oninput="renderItems()">
</div>
<div class="toolbar-group">
<label>Max:</label>
<input type="number" id="maxProfit" placeholder="Any" oninput="renderItems()">
</div>
<div id="item-count" style="font-size: 0.85rem; color: var(--text-muted)"></div> <div id="item-count" style="font-size: 0.85rem; color: var(--text-muted)"></div>
</div> </div>
@ -227,6 +283,7 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th width="40"><input type="checkbox" id="selectAll" onclick="toggleSelectAll(this.checked)"></th>
<th width="80">Img</th> <th width="80">Img</th>
<th>Product Info</th> <th>Product Info</th>
<th>Market Price</th> <th>Market Price</th>
@ -333,13 +390,23 @@
<img id="zoom-img"> <img id="zoom-img">
</div> </div>
<!-- Bulk Action Bar -->
<div class="bulk-bar" id="bulk-bar">
<span id="selected-count" style="font-weight: 600; font-family: var(--mono); font-size: 0.9rem"></span>
<div style="display: flex; gap: 0.5rem">
<button onclick="bulkUpdateStatus('done')">Mark Done</button>
<button onclick="bulkUpdateStatus('skip')">Mark Skip</button>
<button class="btn-outline" style="background:none; border:none; padding:0; text-decoration:underline; font-size:0.8rem; margin-left:0.5rem" onclick="toggleSelectAll(false)">Cancel</button>
</div>
</div>
<script> <script>
let profiles = []; let profiles = [];
let currentProfileId = null; let currentProfileId = null;
let items = []; let items = [];
let isScanning = false; let isScanning = false;
const API_URL = "http://localhost:4000" // const API_URL = "http://localhost:4000"
// const API_URL = "https://logs1.danielvu.com/ebay-price-check" const API_URL = "https://logs1.danielvu.com/ebay-price-check"
const fmat = (v) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(v || 0); const fmat = (v) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(v || 0);
async function init() { async function init() {
@ -370,7 +437,7 @@
renderTabs(); renderTabs();
const p = profiles.find(x => x.id == id); const p = profiles.find(x => x.id == id);
if (p) { if (p) {
document.getElementById('last-run-time').innerText = p.last_scan_time ? new Date(p.last_scan_time).toLocaleString() : 'Never'; document.getElementById('last-run-time').innerText = p.last_scan_time ? new Date(p.last_scan_time + 'Z').toLocaleString() : 'Never';
} }
await fetchItems(); await fetchItems();
} }
@ -384,21 +451,31 @@
function renderItems() { function renderItems() {
const q = document.getElementById('searchInput').value.toLowerCase(); const q = document.getElementById('searchInput').value.toLowerCase();
const filtered = items.filter(i => const minP = parseFloat(document.getElementById('minProfit').value || -999999);
i.title.toLowerCase().includes(q) || const maxP = parseFloat(document.getElementById('maxProfit').value || 999999);
(i.partNumber && i.partNumber.toLowerCase().includes(q)) ||
(i.seller_username && i.seller_username.toLowerCase().includes(q)) const filtered = items.filter(i => {
); const matchesQuery = i.title.toLowerCase().includes(q) ||
(i.partNumber && i.partNumber.toLowerCase().includes(q)) ||
(i.seller_username && i.seller_username.toLowerCase().includes(q));
const matchesProfit = i.profit >= minP && i.profit <= maxP;
return matchesQuery && matchesProfit;
});
document.getElementById('item-count').innerText = `Found ${filtered.length} items`; document.getElementById('item-count').innerText = `Found ${filtered.length} items`;
const tbody = document.getElementById('item-tbody'); const tbody = document.getElementById('item-tbody');
tbody.innerHTML = filtered.length ? filtered.map(item => { tbody.innerHTML = filtered.length ? filtered.map(item => {
const img = item.images && item.images.length > 0 ? item.images[0] : 'https://via.placeholder.com/64'; const img = item.images && item.images.length > 0 ? item.images[0] : 'https://via.placeholder.com/64';
const isHot = item.ai_suggestion && item.ai_suggestion.includes('Hãy mua ngay');
const hasOffer = item.detail_response?.buyingOptions?.includes('BEST_OFFER');
const offerBadge = hasOffer ? `<span class="badge-offer">OFFER</span>` : '';
return ` return `
<tr> <tr class="${isHot ? 'row-highlight' : ''}">
<td><input type="checkbox" class="item-checkbox" value="${item.id}" onchange="updateBulkBar()"></td>
<td><img src="${img}" class="td-img" onclick="openZoom('${img}')"></td> <td><img src="${img}" class="td-img" onclick="openZoom('${img}')"></td>
<td> <td>
<div class="td-title" onclick="openDetail('${item.id}')">${item.title}</div> <div class="td-title" onclick="openDetail('${item.id}')">${item.title}${offerBadge}</div>
<div class="td-sub"> <div class="td-sub">
ID: ${item.id} | PN: ${item.partNumber || 'N/A'} | <b>${item.manufacturer || 'Generic'}</b> ID: ${item.id} | PN: ${item.partNumber || 'N/A'} | <b>${item.manufacturer || 'Generic'}</b>
${item.specs ? `<br><span style="color:var(--warning); font-size:0.75rem">⚠️ ${item.specs}</span>` : ''} ${item.specs ? `<br><span style="color:var(--warning); font-size:0.75rem">⚠️ ${item.specs}</span>` : ''}
@ -476,6 +553,49 @@
function closeModals() { function closeModals() {
document.querySelectorAll('.modal-overlay').forEach(m => m.classList.remove('active')); document.querySelectorAll('.modal-overlay').forEach(m => m.classList.remove('active'));
} }
// Bulk Management
function toggleSelectAll(checked) {
document.querySelectorAll('.item-checkbox').forEach(cb => {
cb.checked = checked;
});
updateBulkBar();
}
function updateBulkBar() {
const selected = document.querySelectorAll('.item-checkbox:checked');
const bar = document.getElementById('bulk-bar');
const count = document.getElementById('selected-count');
if (selected.length > 0) {
count.innerText = `${selected.length} items selected`;
bar.classList.add('active');
} else {
bar.classList.remove('active');
document.getElementById('selectAll').checked = false;
}
}
async function bulkUpdateStatus(status) {
const selected = Array.from(document.querySelectorAll('.item-checkbox:checked')).map(cb => cb.value);
if (selected.length === 0) return;
if (!confirm(`Mark ${selected.length} items as ${status}?`)) return;
const res = await fetch(API_URL + '/api/items/bulk-status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: selected, status })
});
const data = await res.json();
if (data.success) {
items = items.filter(i => !selected.includes(i.id));
renderItems();
document.getElementById('bulk-bar').classList.remove('active');
document.getElementById('selectAll').checked = false;
}
}
function openZoom(url) { function openZoom(url) {
document.getElementById('zoom-img').src = url; document.getElementById('zoom-img').src = url;
document.getElementById('zoom-overlay').classList.add('active'); document.getElementById('zoom-overlay').classList.add('active');
@ -496,7 +616,7 @@
} }
function renderProfileList() { function renderProfileList() {
document.getElementById('profile-list').innerHTML = profiles.map(p => { document.getElementById('profile-list').innerHTML = profiles.map(p => {
const lastScan = p.last_scan_time ? new Date(p.last_scan_time).toLocaleString() : 'Never'; const lastScan = p.last_scan_time ? new Date(p.last_scan_time + 'Z').toLocaleString() : 'Never';
return ` return `
<div style="display:flex; justify-content:space-between; align-items:center; padding:0.75rem; border-bottom:1px solid var(--border)"> <div style="display:flex; justify-content:space-between; align-items:center; padding:0.75rem; border-bottom:1px solid var(--border)">
<div> <div>
@ -687,10 +807,14 @@
<div class="m-grid"> <div class="m-grid">
<div style="display:flex; flex-direction:column; gap:1rem"> <div style="display:flex; flex-direction:column; gap:1rem">
<div style="width:100%; height:500px; display:flex; justify-content:center; align-items:center; background:#000; border-radius:12px; overflow:hidden"> <div style="width:100%; height:500px; display:flex; justify-content:center; align-items:center; background:#000; border-radius:12px; overflow:hidden">
<img src="${item.images[0] || ''}" style="max-width:100%; max-height:100%; object-fit:contain"> <img id="detail-main-img" src="${item.images[0] || ''}" onclick="openZoom(this.src)">
</div> </div>
<div style="display:flex; gap:0.5rem; overflow-x:auto"> <div style="display:flex; gap:0.5rem; overflow-x:auto; padding-bottom: 0.5rem">
${item.images.slice(1,5).map(img => `<img src="${img}" style="width:60px; height:60px; object-fit:cover; border-radius:4px">`).join('')} ${item.images.map((img, idx) => `
<img class="thumb-img ${idx === 0 ? 'active' : ''}"
src="${img}"
onclick="document.getElementById('detail-main-img').src='${img}'; document.querySelectorAll('.thumb-img').forEach(el=>el.classList.remove('active')); this.classList.add('active')">
`).join('')}
</div> </div>
<div style="margin-top:0.5rem"> <div style="margin-top:0.5rem">
<h3 style="margin-bottom:0.5rem">Description</h3> <h3 style="margin-bottom:0.5rem">Description</h3>
@ -700,7 +824,7 @@
</div> </div>
</div> </div>
<div> <div>
<h2 style="margin-bottom:0.5rem">${item.title}</h2> <h2 style="margin-bottom:0.5rem">${item.title}${item.detail_response?.buyingOptions?.includes('BEST_OFFER') ? '<span class="badge-offer">OFFER</span>' : ''}</h2>
<div style="color:var(--text-muted); font-size:0.9rem; margin-bottom:1.5rem">ID: ${item.id}</div> <div style="color:var(--text-muted); font-size:0.9rem; margin-bottom:1.5rem">ID: ${item.id}</div>
<div class="ai-box"> <div class="ai-box">
@ -728,8 +852,8 @@
<div style="margin-top:2.5rem; display:flex; gap:1rem"> <div style="margin-top:2.5rem; display:flex; gap:1rem">
<a href="${item.url}" target="_blank" class="btn-primary" style="flex:1; text-align:center; text-decoration:none; padding:0.8rem">Open on eBay</a> <a href="${item.url}" target="_blank" class="btn-primary" style="flex:1; text-align:center; text-decoration:none; padding:0.8rem">Open on eBay</a>
<button class="btn-outline" style="flex:1" onclick="updateStatus('${item.id}', 'skip')">Skip</button> <button class="btn-outline" style="flex:1" onclick="updateStatus('${item.id}', 'skip'); closeModals()">Skip</button>
<button class="btn-success" style="flex:1" onclick="updateStatus('${item.id}', 'done')">Done</button> <button class="btn-success" style="flex:1" onclick="updateStatus('${item.id}', 'done'); closeModals()">Done</button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -160,6 +160,29 @@ app.put('/api/items/:id/status', (req, res) => {
} }
}); });
// --- BULK STATUS UPDATE ---
app.post('/api/items/bulk-status', (req, res) => {
try {
const { ids, status } = req.body;
if (!ids || !Array.isArray(ids) || !status) {
return res.status(400).json({ error: 'ids (array) and status are required' });
}
if (!['waiting', 'done', 'skip'].includes(status)) {
return res.status(400).json({ error: 'Invalid status' });
}
ids.forEach(id => {
db.updateReviewStatus(id, status);
});
res.json({ success: true, message: `Updated ${ids.length} items to ${status}` });
} catch (err) {
console.error(`Error bulk updating status:`, err);
res.status(500).json({ error: 'Internal server error' });
}
});
// Trigger a new scan // Trigger a new scan
app.post('/api/scan', async (req, res) => { app.post('/api/scan', async (req, res) => {
if (isScanning) { if (isScanning) {