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
.env
*/*.db
*/*.db
data/ebay_items.db

100
ai.js
View File

@ -8,37 +8,93 @@ const openai = new OpenAI({
async function getAiSuggestion(item) {
try {
const imageUrl = item.detail_response.image ? item.detail_response.image.imageUrl : null;
const prompt = `
Bạn một chuyên gia thẩm định hàng hoá điện tử trên eBay.
Hãy kiểm tra các thông tin dưới đây để trả lời 3 câu hỏi:
1. Item bị ảo / lừa đảo (fake) không?
2. Seller uy tín không? (so sánh feedback score, percent)
3. Dữ liệu hiện tại đã đúng sản phẩm chưa? (Part Number, Specs)
ROLE:
Bạn chuyên gia kiểm định listing eBay dựa trên dữ liệu + hình ảnh sản phẩm.
THÔNG TIN TÌM KIẾM:
- Part Number: ${item.partNumber}
- Specs mục tiêu: ${item.specs}
MỤC TIÊU:
Đánh giá độ tin cậy của listing dựa trên:
- Độ 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):
- Tiêu đề: ${item.title}
- Seller: ${item.seller_username} (Score: ${item.seller_feedback_score}, Positive: ${item.seller_feedback_percent}%)
INPUT:
[SEARCH TARGET]
- Part Number: ${item.partNumber || "N/A"}
- Expected Specs: ${item.specs || "N/A"}
[EBAY DATA]
- Title: ${item.title}
- 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'}
- Seller: ${item.detail_response?.seller?.username}
- Feedback Score: ${item.detail_response?.seller?.feedbackScore}
- Positive %: ${item.detail_response?.seller?.feedbackPercent}
YÊU CẦU ĐẦU RA (Quan trọng!):
Chỉ đưa ra kết luận DUY NHẤT 1 câu ngắn gọn. ( dụ: "Hãy mua ngay, seller uy tín và đúng chuẩn sản phẩm." HOẶC "Cẩn thận, seller ít feedback và tiêu đề không rõ ràng.")
Khong giải thích dài dòng!
[DETAIL DATA]
- Brand: ${item.detail_response?.brand || "N/A"}
- 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({
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
messages: messages,
max_tokens: 150,
temperature: 0.2
});
return {

View File

@ -140,6 +140,37 @@
}
.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; }
label { display: block; margin-bottom: 0.4rem; font-size: 0.9rem; color: var(--text-muted); }
input[type="text"], input[type="number"], textarea {
@ -185,6 +216,23 @@
height: 100%; background: var(--primary); width: 0%; transition: width 0.3s;
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>
</head>
<body>
@ -220,6 +268,14 @@
<main>
<div class="toolbar">
<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>
@ -227,6 +283,7 @@
<table>
<thead>
<tr>
<th width="40"><input type="checkbox" id="selectAll" onclick="toggleSelectAll(this.checked)"></th>
<th width="80">Img</th>
<th>Product Info</th>
<th>Market Price</th>
@ -333,13 +390,23 @@
<img id="zoom-img">
</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>
let profiles = [];
let currentProfileId = null;
let items = [];
let isScanning = false;
const API_URL = "http://localhost:4000"
// const API_URL = "https://logs1.danielvu.com/ebay-price-check"
// const API_URL = "http://localhost:4000"
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);
async function init() {
@ -370,7 +437,7 @@
renderTabs();
const p = profiles.find(x => x.id == id);
if (p) {
document.getElementById('last-run-time').innerText = p.last_scan_time ? new Date(p.last_scan_time).toLocaleString() : 'Never';
document.getElementById('last-run-time').innerText = p.last_scan_time ? new Date(p.last_scan_time + 'Z').toLocaleString() : 'Never';
}
await fetchItems();
}
@ -384,21 +451,31 @@
function renderItems() {
const q = document.getElementById('searchInput').value.toLowerCase();
const filtered = items.filter(i =>
i.title.toLowerCase().includes(q) ||
(i.partNumber && i.partNumber.toLowerCase().includes(q)) ||
(i.seller_username && i.seller_username.toLowerCase().includes(q))
);
const minP = parseFloat(document.getElementById('minProfit').value || -999999);
const maxP = parseFloat(document.getElementById('maxProfit').value || 999999);
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`;
const tbody = document.getElementById('item-tbody');
tbody.innerHTML = filtered.length ? filtered.map(item => {
const img = item.images && item.images.length > 0 ? item.images[0] : 'https://via.placeholder.com/64';
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 `
<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>
<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">
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>` : ''}
@ -476,6 +553,49 @@
function closeModals() {
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) {
document.getElementById('zoom-img').src = url;
document.getElementById('zoom-overlay').classList.add('active');
@ -496,7 +616,7 @@
}
function renderProfileList() {
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 `
<div style="display:flex; justify-content:space-between; align-items:center; padding:0.75rem; border-bottom:1px solid var(--border)">
<div>
@ -687,10 +807,14 @@
<div class="m-grid">
<div style="display:flex; flex-direction:column; gap:1rem">
<div style="width:100%; height:500px; display:flex; justify-content:center; align-items:center; background:#000; border-radius:12px; overflow:hidden">
<img src="${item.images[0] || ''}" style="max-width:100%; max-height:100%; object-fit:contain">
<img id="detail-main-img" src="${item.images[0] || ''}" onclick="openZoom(this.src)">
</div>
<div style="display:flex; gap:0.5rem; overflow-x:auto">
${item.images.slice(1,5).map(img => `<img src="${img}" style="width:60px; height:60px; object-fit:cover; border-radius:4px">`).join('')}
<div style="display:flex; gap:0.5rem; overflow-x:auto; padding-bottom: 0.5rem">
${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 style="margin-top:0.5rem">
<h3 style="margin-bottom:0.5rem">Description</h3>
@ -700,7 +824,7 @@
</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 class="ai-box">
@ -728,8 +852,8 @@
<div style="margin-top:2.5rem; display:flex; gap:1rem">
<a href="${item.url}" target="_blank" class="btn-primary" style="flex:1; text-align:center; text-decoration:none; padding:0.8rem">Open on eBay</a>
<button class="btn-outline" style="flex:1" onclick="updateStatus('${item.id}', 'skip')">Skip</button>
<button class="btn-success" style="flex:1" onclick="updateStatus('${item.id}', 'done')">Done</button>
<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'); closeModals()">Done</button>
</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
app.post('/api/scan', async (req, res) => {
if (isScanning) {