update du thu
This commit is contained in:
parent
abc194cd2f
commit
01dc64b0e7
|
|
@ -1,3 +1,4 @@
|
|||
node_modules
|
||||
.env
|
||||
*/*.db
|
||||
*/*.db
|
||||
data/ebay_items.db
|
||||
|
|
|
|||
100
ai.js
100
ai.js
|
|
@ -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 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)
|
||||
ROLE:
|
||||
Bạn là 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 và THÔNG TIN
|
||||
- Độ uy tín của seller
|
||||
- Độ hợp lý 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. (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!
|
||||
[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 có ả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 có 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}})"
|
||||
|
||||
Ví dụ lý 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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
23
server.js
23
server.js
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue