update
This commit is contained in:
parent
abc194cd2f
commit
63c0c8590b
|
|
@ -0,0 +1,57 @@
|
|||
# Hướng Dẫn Sử Dụng - eBay Deep Scan
|
||||
|
||||
Công cụ này giúp bạn tìm kiếm, lọc và phân tích các mặt hàng trên eBay dựa trên các bộ từ khóa và cấu hình linh hoạt.
|
||||
|
||||
## 1. Cấu Hình Ban Đầu
|
||||
|
||||
Trước khi bắt đầu, bạn cần thiết lập các thông số môi trường trong file `.env`:
|
||||
|
||||
1. Copy `.env.example` thành `.env`.
|
||||
2. Điền các mã API của eBay (`EBAY_CLIENT_ID`, `EBAY_CLIENT_SECRET`).
|
||||
3. Điền `OPENAI_API_KEY` để sử dụng tính năng phân tích bằng AI.
|
||||
|
||||
## 2. Quản Lý Hồ Sơ Thu Quét (Scan Profiles)
|
||||
|
||||
Mỗi "Profile" đại diện cho một nhóm mặt hàng (ví dụ: SSD, RAM, GPU).
|
||||
|
||||
1. Truy cập Dashboard (thông qua `http://localhost:4000`).
|
||||
2. Nhấn **Manage Profiles**.
|
||||
3. **Thêm Profile mới**: Điền tên và **Price Ratio** (tỷ lệ giá mục tiêu so với giá thị trường, ví dụ `0.85`).
|
||||
4. **Common Keywords (Keyword Chung)**: Đây là những từ khóa sẽ được nối vào cuối mọi truy vấn tìm kiếm trong profile đó (ví dụ `-cable -adapter` để loại bỏ dây cáp và đầu chuyển).
|
||||
|
||||
## 3. Nhập Từ Khóa (Import Keywords)
|
||||
|
||||
Bạn có thể nhập từ khóa theo phương pháp Bulk Import (nhập hàng loạt).
|
||||
|
||||
1. Chọn Profile bạn muốn làm việc.
|
||||
2. Nhấn **Bulk Import**.
|
||||
3. Định dạng nhập: `Mã Sản Phẩm | Từ Khóa 1, Từ Khóa 2 | Giá Mục Tiêu`
|
||||
- Ví dụ: `"SSDSC2BX200" | "200GB", "2.5" | 14.00`
|
||||
- **Lưu ý**: Nếu bạn để mã sản phẩm trong ngoặc kép `"Mã"`, hệ thống sẽ tìm kiếm chính xác mã đó trên eBay.
|
||||
|
||||
## 4. Thực Hiện Quét (Scanning)
|
||||
|
||||
1. Nhấn nút **Deep Scan** ở góc trên bên phải Dashboard.
|
||||
2. Một thanh tiến trình (Progress Bar) sẽ hiện ra cho biết hệ thống đang tìm kiếm từ khóa nào và hoàn thành được bao nhiêu phần trăm.
|
||||
3. Kết quả sẽ được tự động lưu vào database và hiển thị trên bảng.
|
||||
|
||||
## 5. Xem Kết Quả và Phân Tích AI
|
||||
|
||||
- **Bảng Kết Quả**: Các mặt hàng có giá tốt (vượt qua bộ lọc giá) sẽ hiện lên trên dashboard.
|
||||
- **AI Analysis**: Hệ thống tự động gửi thông tin chi tiết của các mặt hàng "PASS" lên AI để phân tích xem sản phẩm đó có đúng là món hời hay có rủi ro gì không.
|
||||
- **Popup Chi Tiết**: Nhấn vào tên sản phẩm để xem ảnh phóng to, mô tả đầy đủ, thông tin người bán và các chỉ số giá (Avg Price/Unit).
|
||||
|
||||
## 6. Các Phím Tắt và Tiện Ích
|
||||
|
||||
- **ESC**: Đóng các cửa sổ popup.
|
||||
- **Dọn dẹp database**: Nếu muốn xóa sạch danh sách mặt hàng đã quét, hãy chạy lệnh:
|
||||
```bash
|
||||
node truncate_items.js
|
||||
```
|
||||
|
||||
## 7. Lưu Ý
|
||||
|
||||
- Hệ thống tự động bỏ qua các mặt hàng từ Trung Quốc (CN) và các sản phẩm bị lỗi/hỏng (defective).
|
||||
- Các mặt hàng được đấu giá (Auction) cũng được hỗ trợ nhưng sẽ được phân tích dựa trên giá hiện tại cộng phí ship.
|
||||
|
||||
Chúc bạn săn được những món hời nhất trên eBay!
|
||||
326
RAM.kw
326
RAM.kw
|
|
@ -1,165 +1,165 @@
|
|||
M321R8GA0BB0-CQK | "Samsung" "64GB" "2Rx4" "4800" "RDIMM"| 1425
|
||||
HMCG94AGBQA181N | "Hynix" "64GB" "2Rx4" "5600" "RDIMM"| 1425
|
||||
MTC40F2046S1RC56BG1 | "Micron" "64GB" "2Rx4" "5600" "RDIMM"| 1325
|
||||
HMCG94AGBRA181N | "Hynix" "64GB" "2Rx4" "5600" "RDIMM"| 1425
|
||||
M321R8GA0EB2-CCP | "Samsung" "64GB" "2Rx4" "6400" "RDIMM"| 1570
|
||||
HMCG84MEBRA17N | "Hynix" "32GB" "1Rx4" "4800" "RDIMM"| 640
|
||||
M321R4GA3BB0-CQK | "Samsung" "32GB" "2Rx8" "4800" "RDIMM"| 985
|
||||
M321R4GA3BB6-CQK | "Samsung" "32GB" "2Rx8" "4800" "RDIMM"| 640
|
||||
MTC20F2085S1RC52BAW | "Micron" "32GB" "2Rx8" "5200" "RDIMM"| 570
|
||||
M321R4GA3PB0-CWM | "Samsung" "32GB" "2Rx8" "5600" "RDIMM"| 740
|
||||
HMCG88AGBRA191N-BB | "Hynix" "32GB" "2Rx8" "5600" "RDIMM"| 715
|
||||
HMCG88AGBRA190N | "Hynix" "32GB" "2Rx8" "5600" "RDIMM"| 715
|
||||
M321R4GA0EB2-CCP | "Samsung" "32GB" "1Rx4" "6400" "RDIMM"| 715
|
||||
M321R2GA3BB0-CQK | "Samsung" "16GB" "1Rx8" "4800" "RDIMM"| 315
|
||||
MTC10F1084S1RC48BA1 | "Micron" "16GB" "1Rx8" "4800" "RDIMM"| 285
|
||||
M386AAK40B40-CWD | "Samsung" "128GB" "2S4Rx4" "2666" "LRDIMM"| 530
|
||||
M393AAG40M3B-CYF | "Samsung" "128GB" "2S2Rx4" "2933" "LRDIMM"| 580
|
||||
HMABAGL7ABR4N-XN | "Hynix" "128GB" "4DRx4" "3200" "LRDIMM"| 470
|
||||
M386A8K40BM1-CRC | "Samsung" "64GB" "4DRx4" "2400" "LRDIMM"| 287.36
|
||||
M386A8K40BMB-CRC | "Samsung" "64GB" "4DRx4" "2400" "LRDIMM"| 287.36
|
||||
HMAA8GL7MMR4N-UH | "Hynix" "64GB" "4DRx4" "2400" "LRDIMM"| 284.9
|
||||
MTA72ASS8G72LZ-2G3 | "Micron" "64GB" "4DRx4" "2400" "LRDIMM"| 240
|
||||
M386A8K40DM2-CTD | "Samsung" "64GB" "4DRx4" "2666" "LRDIMM"| 428
|
||||
M386A8K40BM2-CTD | "Samsung" "64GB" "4DRx4" "2666" "LRDIMM"| 420.5
|
||||
M393A8K40B22-CWD | "Samsung" "64GB" "2S2Rx4" "2666" "LRDIMM"| 400
|
||||
M386A8K40CM2-CTD | "Samsung" "64GB" "4DRx4" "2666" "LRDIMM"| 428
|
||||
HMAA8GL7MMR4N-VK | "Hynix" "64GB" "4DRx4" "2666" "LRDIMM"| 334.3
|
||||
HMAA8GL7CPR4N-VK | "Hynix" "64GB" "4DRx4" "2666" "LRDIMM"| 334.3
|
||||
M386A8K40DM2-CVF | "Samsung" "64GB" "4DRx4" "2933" "LRDIMM"| 552.33
|
||||
HMAA8GL7CPR4N-WM | "Hynix" "64GB" "4DRx4" "2933" "LRDIMM"| 558
|
||||
MTA72ASS8G72LZ-2G9 | "Micron" "64GB" "4DRx4" "2933" "LRDIMM"| 539
|
||||
M393A8G40AB2-CVF | "Samsung" "64GB" "2Rx4" "2933" "RDIMM"| 605
|
||||
M393A8G40MB2-CVF | "Samsung" "64GB" "2Rx4" "2933" "RDIMM"| 512
|
||||
HMAA8GR7AJR4N-WM | "Hynix" "64GB" "2Rx4" "2933" "RDIMM"| 750.5
|
||||
HMAA8GR7MJR4N-WM | "Hynix" "64GB" "2Rx4" "2933" "RDIMM"| 536
|
||||
MTA36ASF8G72PZ-2G9 | "Micron" "64GB" "2Rx4" "2933" "RDIMM"| 595
|
||||
M393A8G40AB2-CWE | "Samsung" "64GB" "2Rx4" "3200" "RDIMM"| 872
|
||||
M393A8G40MB2-CWE | "Samsung" "64GB" "2Rx4" "3200" "RDIMM"| 760
|
||||
M393A8G40CB4-CWE | "Samsung" "64GB" "2Rx4" "3200" "RDIMM"| 740
|
||||
HMAA8GR7AJR4N-XN | "Hynix" "64GB" "2Rx4" "3200" "RDIMM"| 600
|
||||
HMAA8GR7CJR4N-XN | "Hynix" "64GB" "2Rx4" "3200" "RDIMM"| 836
|
||||
M386B4G70DM0-YH9 | "Samsung" "32GB" "4Rx4" "1333" "LRDIMM"| 28.37
|
||||
M386B4G70DM0-YK | "Samsung" "32GB" "4DRx4" "1600" "LRDIMM"| 28.37
|
||||
HMA84GL7AMR4N-TF | "Hynix" "32GB" "4DRx4" "2133" "LRDIMM"| 123.5
|
||||
MTA72ASS4G72LZ-2G1 | "Micron" "32GB" "4DRx4" "2133" "LRDIMM"| 92.59
|
||||
M386A4G40DM0-CPB | "Samsung" "32GB" "4DRx4" "2133" "LRDIMM"| 152
|
||||
M386A4G40DM1-CRC | "Samsung" "32GB" "4DRx4" "2400" "LRDIMM"| 156.75
|
||||
M393A4K40BB0-CPB | "Samsung" "32GB" "2Rx4" "2133" "RDIMM"| 207
|
||||
HMA84GR7MFR4N-TF | "Hynix" "32GB" "2Rx4" "2133" "RDIMM"| 174.17
|
||||
MTA36ASF4G72PZ-2G1 | "Micron" "32GB" "2Rx4" "2133" "RDIMM"| 133
|
||||
M386A4K40BB0-CRC | "Samsung" "32GB" "2Rx4" "2400" "LRDIMM"| 172.56
|
||||
M393A4K40BB1-CRC | "Samsung" "32GB" "2Rx4" "2400" "RDIMM"| 251
|
||||
M393A4K40CB1-CRC | "Samsung" "32GB" "2Rx4" "2400" "RDIMM"| 251
|
||||
HMA84GR7MFR4N-UH | "Hynix" "32GB" "2Rx4" "2400" "RDIMM"| 207
|
||||
HMA84GR7AFR4N-UH | "Hynix" "32GB" "2Rx4" "2400" "RDIMM"| 181.16
|
||||
HMA84GL7MFR4N-UH | "Hynix" "32GB" "2Rx4" "2400" "RDIMM"| 181.16
|
||||
MTA36ASF4G72PZ-2G3 | "Micron" "32GB" "2Rx4" "2400" "RDIMM"| 192.85
|
||||
M393A4K40BB2-CTD | "Samsung" "32GB" "2Rx4" "2666" "RDIMM"| 280
|
||||
M393A4K40CB2-CTD | "Samsung" "32GB" "2Rx4" "2666" "RDIMM"| 266
|
||||
HMA84GR7CJR4N-VK | "Hynix" "32GB" "2Rx4" "2666" "RDIMM"| 248
|
||||
HMA84GR7AFR4N-VK | "Hynix" "32GB" "2Rx4" "2666" "RDIMM"| 248
|
||||
HMA84GR7JJR4N-VK | "Hynix" "32GB" "2Rx4" "2666" "RDIMM"| 248
|
||||
MTA36ASF4G72PZ-2G6 | "Micron" "32GB" "2Rx4" "2666" "RDIMM"| 213
|
||||
MTA36ASF4G72LZ-2G6 | "Micron" "32GB" "2Rx4" "2666" "RDIMM"| 213
|
||||
M393A4K40DB2-CVF | "Samsung" "32GB" "2Rx4" "2933" "RDIMM"| 228
|
||||
M393A4K40CB2-CVF | "Samsung" "32GB" "2Rx4" "2933" "RDIMM"| 380
|
||||
HMA84GR7JJR4N-WM | "Hynix" "32GB" "2Rx4" "2933" "RDIMM"| 413
|
||||
HMA84GR7DJR4N-WM | "Hynix" "32GB" "2Rx4" "2933" "RDIMM"| 413
|
||||
HMA84GR7CJR4N-WM | "Hynix" "32GB" "2Rx4" "2933" "RDIMM"| 413
|
||||
MTA36ASF4G72PZ-2G9 | "Micron" "32GB" "2Rx4" "2933" "RDIMM"| 302.82
|
||||
M393A4G40BB3-CWE | "Samsung" "32GB" "1Rx4" "3200" "RDIMM"| 436.05
|
||||
M393A4K40EB3-CWE | "Samsung" "32GB" "2Rx4" "3200" "RDIMM"| 503
|
||||
M393A4K40DB3-CWE | "Samsung" "32GB" "2Rx4" "3200" "RDIMM"| 503
|
||||
HMA84GR7CJR4N-XN | "Hynix" "32GB" "2Rx4" "3200" "RDIMM"| 475
|
||||
HMA84GR7DJR4N-XN | "Hynix" "32GB" "2Rx4" "3200" "RDIMM"| 475
|
||||
MTA36ASF4G72PZ-3G2 | "Micron" "32GB" "2Rx4" "3200" "RDIMM"| 361
|
||||
M393A4G43AB3-CWE | "Samsung" "32GB" "2Rx8" "3200" "RDIMM"| 425
|
||||
HMAA4GR7CJR8N-XN | "Hynix" "32GB" "2Rx8" "3200" "RDIMM"| 362.32
|
||||
HMT42GR7BMR4C-G7 | "Hynix" "16GB" "4Rx4" "1066" "RDIMM"| 21.13
|
||||
MT36KSF2G72PZ-1G4 | "Micron" "16GB" "2Rx4" "1333" "RDIMM"| 16.88
|
||||
M393B2G70EB0-YK0 | "Samsung" "16GB" "2Rx4" "1600" "RDIMM"| 19.69
|
||||
M393B2G70DB0-YK0 | "Samsung" "16GB" "2Rx4" "1600" "RDIMM"| 19.69
|
||||
M393B2G70BH0-YK0 | "Samsung" "16GB" "2Rx4" "1600" "RDIMM"| 19.69
|
||||
MT36KSF2G72PZ-1G6 | "Micron" "16GB" "2Rx4" "1600" "RDIMM"| 21.4
|
||||
HMT42GR7BFR4A-PB | "Hynix" "16GB" "2Rx4" "1600" "RDIMM"| 19.69
|
||||
HMT42GR7MFR4C-PB | "Hynix" "16GB" "2Rx4" "1600" "RDIMM"| 18.99
|
||||
HMT42GR7AFR4C-RD | "Hynix" "16GB" "2Rx4" "1866" "RDIMM"| 17.02
|
||||
M393A2G40EB1-CPB | "Samsung" "16GB" "2Rx4" "2133" "RDIMM"| 110.4
|
||||
M393A2G40DB0-CPB | "Samsung" "16GB" "2Rx4" "2133" "RDIMM"| 110.4
|
||||
HMA42GR7AFR4N-TF | "Hynix" "16GB" "2Rx4" "2133" "RDIMM"| 97.7
|
||||
HMA42GR7MFR4N-TF | "Hynix" "16GB" "2Rx4" "2133" "RDIMM"| 60
|
||||
MTA36ASF2G72PZ-2G1 | "Micron" "16GB" "2Rx4" "2133" "RDIMM"| 64.4
|
||||
M393A2K40CB1-CRC | "Samsung" "16GB" "1Rx4" "2400" "RDIMM"| 10
|
||||
M393A2K40BB1-CRC | "Samsung" "16GB" "1Rx4" "2400" "RDIMM"| 110
|
||||
MTA18ASF2G72PZ-2G3 | "Micron" "16GB" "1Rx4" "2400" "RDIMM"| 95
|
||||
M393A2G40EB1-CRC | "Samsung" "16GB" "2Rx4" "2400" "RDIMM"| 110.2
|
||||
HMA42GR7AFR4N-UH | "Hynix" "16GB" "2Rx4" "2400" "RDIMM"| 108
|
||||
HMA42GR7BJR4N-UH | "Hynix" "16GB" "2Rx4" "2400" "RDIMM"| 108
|
||||
MTA36ASF2G72PZ-2G3 | "Micron" "16GB" "2Rx4" "2400" "RDIMM"| 85
|
||||
HMA82GR7AFR4N-VK | "Hynix" "16GB" "1Rx4" "2666" "RDIMM"| 110
|
||||
HMA82GR7CJR4N-VK | "Hynix" "16GB" "1Rx4" "2666" "RDIMM"| 110
|
||||
M393A2K43BB1-CTD | "Samsung" "16GB" "2Rx8" "2666" "RDIMM"| 114.29
|
||||
HMA82GR7AFR8N-VK | "Hynix" "16GB" "2Rx8" "2666" "RDIMM"| 107
|
||||
HMA82GR7CJR8N-VK | "Hynix" "16GB" "2Rx8" "2666" "RDIMM"| 107
|
||||
HMA82GR7JJR8N-VK | "Hynix" "16GB" "2Rx8" "2666" "RDIMM"| 107
|
||||
MTA18ASF2G72PDZ-2G6 | "Micron" "16GB" "2Rx8" "2666" "RDIMM"| 83.33
|
||||
M393A2K40CB2-CVF | "Samsung" "16GB" "1Rx4" "2933" "RDIMM"| 118.75
|
||||
M393A2K40DB2-CVF | "Samsung" "16GB" "1Rx4" "2933" "RDIMM"| 118.75
|
||||
M393A2K43DB2-CVF | "Samsung" "16GB" "2Rx8" "2933" "RDIMM"| 100.57
|
||||
HMA82GR7CJR8N-WM | "Hynix" "16GB" "2Rx8" "2933" "RDIMM"| 78.65
|
||||
HMA82GR7JJR8N-WM | "Hynix" "16GB" "2Rx8" "2933" "RDIMM"| 102.56
|
||||
M393A2K43DB3-CWE | "Samsung" "16GB" "2Rx8" "3200" "RDIMM"| 171
|
||||
HMA82GR7DJR8N-XN | "Hynix" "16GB" "2Rx8" "3200" "RDIMM"| 114.29
|
||||
HMA82GR7CJR8N-XN | "Hynix" "16GB" "2Rx8" "3200" "RDIMM"| 114.29
|
||||
MTA18ASF2G72PDZ-3G2 | "Micron" "16GB" "2Rx8" "3200" "RDIMM"| 85.47
|
||||
M393B1K70DH0-YH9 | "Samsung" "8GB" "2Rx4" "1333" "RDIMM"| 6.05
|
||||
M393B1K70CH0-CH9 | "Samsung" "8GB" "2Rx4" "1333" "RDIMM"| 6.05
|
||||
MTA18ASF1G72PZ-2G1 | "Micron" "8GB" "1Rx4" "2133" "RDIMM"| 23.98
|
||||
HMA41GR7AFR8N-TF | "Hynix" "8GB" "1Rx8" "2133" "RDIMM"| 30.24
|
||||
M393A1G40DB0-CPB | "Samsung" "8GB" "1Rx8" "2133" "RDIMM"| 45
|
||||
MTA18ASF1G72PZ-2G1 | "Micron" "8GB" "1Rx8" "2133" "RDIMM"| 23.91
|
||||
HMA41GR7AFR8N-TF | "Hynix" "8GB" "2Rx8" "2133" "RDIMM"| 28.13
|
||||
M393A1G43DB0-CPB | "Samsung" "8GB" "2Rx8" "2133" "RDIMM"| 28.13
|
||||
MTA18ASF1G72PZ-2G1 | "Micron" "8GB" "2Rx8" "2133" "RDIMM"| 23.91
|
||||
HMA41GR7AFR4N-UH | "Hynix" "8GB" "1Rx4" "2400" "RDIMM"| 28
|
||||
HMA81GR7MFR8N-UH | "Hynix" "8GB" "1Rx8" "2400" "RDIMM"| 30.24
|
||||
M393A1G40DB1-CRC | "Samsung" "8GB" "1Rx8" "2400" "RDIMM"| 30.24
|
||||
MTA9ASF1G72PZ-2G3 | "Micron" "8GB" "1Rx8" "2400" "RDIMM"| 30.24
|
||||
HMA41GR7AFR8N-UH | "Hynix" "8GB" "2Rx8" "2400" "RDIMM"| 39.6
|
||||
M393A1G40DB1-CRC | "Samsung" "8GB" "2Rx8" "2400" "RDIMM"| 28.13
|
||||
M393A1G43DB1-CRC | "Samsung" "8GB" "2Rx8" "2400" "RDIMM"| 39.6
|
||||
MTA18ASF1G72PDZ-2G3 | "Micron" "8GB" "2Rx8" "2400" "RDIMM"| 28.13
|
||||
HMA81GR7AFR8N-VK | "Hynix" "8GB" "1Rx8" "2666" "RDIMM"| 28.6
|
||||
MTA9ASF1G72PZ-2G6 | "Micron" "8GB" "1Rx8" "2666" "RDIMM"| 28.6
|
||||
HMA81GR7AFR8N-VK | "Hynix" "8GB" "2Rx8" "2666" "RDIMM"| 28.13
|
||||
MTA9ASF1G72PZ-2G6 | "Micron" "8GB" "2Rx8" "2666" "RDIMM"| 28.13
|
||||
HMA81GR7CJR8N-WM | "Hynix" "8GB" "1Rx8" "2933" "RDIMM"| 39.33
|
||||
HMA81GR7CJR8N-WM | "Hynix" "8GB" "2Rx8" "2933" "RDIMM"| 41.49
|
||||
M393A1K43DB1-CVF | "Samsung" "8GB" "2Rx8" "2933" "RDIMM"| 41.49
|
||||
MTA9ASF1G72PZ-2G9 | "Micron" "8GB" "2Rx8" "2933" "RDIMM"| 30.24
|
||||
HMA81GR7CJR8N-XN | "Hynix" "8GB" "1Rx8" "3200" "RDIMM"| 61.6
|
||||
MTA9ASF1G72PZ-3G2 | "Micron" "8GB" "1Rx8" "3200" "RDIMM"| 30.24
|
||||
HMA81GR7CJR8N-XN | "Hynix" "8GB" "2Rx8" "3200" "RDIMM"| 41.49
|
||||
"M321R8GA0BB0-CQK" | "Samsung" "64GB" "2Rx4" "4800" "RDIMM"| 1425
|
||||
"HMCG94AGBQA181N" | "Hynix" "64GB" "2Rx4" "5600" "RDIMM"| 1425
|
||||
"MTC40F2046S1RC56BG1" | "Micron" "64GB" "2Rx4" "5600" "RDIMM"| 1325
|
||||
"HMCG94AGBRA181N" | "Hynix" "64GB" "2Rx4" "5600" "RDIMM"| 1425
|
||||
"M321R8GA0EB2-CCP" | "Samsung" "64GB" "2Rx4" "6400" "RDIMM"| 1570
|
||||
"HMCG84MEBRA17N" | "Hynix" "32GB" "1Rx4" "4800" "RDIMM"| 640
|
||||
"M321R4GA3BB0-CQK" | "Samsung" "32GB" "2Rx8" "4800" "RDIMM"| 985
|
||||
"M321R4GA3BB6-CQK" | "Samsung" "32GB" "2Rx8" "4800" "RDIMM"| 640
|
||||
"MTC20F2085S1RC52BAW" | "Micron" "32GB" "2Rx8" "5200" "RDIMM"| 570
|
||||
"M321R4GA3PB0-CWM" | "Samsung" "32GB" "2Rx8" "5600" "RDIMM"| 740
|
||||
"HMCG88AGBRA191N-BB" | "Hynix" "32GB" "2Rx8" "5600" "RDIMM"| 715
|
||||
"HMCG88AGBRA190N" | "Hynix" "32GB" "2Rx8" "5600" "RDIMM"| 715
|
||||
"M321R4GA0EB2-CCP" | "Samsung" "32GB" "1Rx4" "6400" "RDIMM"| 715
|
||||
"M321R2GA3BB0-CQK" | "Samsung" "16GB" "1Rx8" "4800" "RDIMM"| 315
|
||||
"MTC10F1084S1RC48BA1" | "Micron" "16GB" "1Rx8" "4800" "RDIMM"| 285
|
||||
"M386AAK40B40-CWD" | "Samsung" "128GB" "2S4Rx4" "2666" "LRDIMM"| 530
|
||||
"M393AAG40M3B-CYF" | "Samsung" "128GB" "2S2Rx4" "2933" "LRDIMM"| 580
|
||||
"HMABAGL7ABR4N-XN" | "Hynix" "128GB" "4DRx4" "3200" "LRDIMM"| 470
|
||||
"M386A8K40BM1-CRC" | "Samsung" "64GB" "4DRx4" "2400" "LRDIMM"| 287.36
|
||||
"M386A8K40BMB-CRC" | "Samsung" "64GB" "4DRx4" "2400" "LRDIMM"| 287.36
|
||||
"HMAA8GL7MMR4N-UH" | "Hynix" "64GB" "4DRx4" "2400" "LRDIMM"| 284.9
|
||||
"MTA72ASS8G72LZ-2G3" | "Micron" "64GB" "4DRx4" "2400" "LRDIMM"| 240
|
||||
"M386A8K40DM2-CTD" | "Samsung" "64GB" "4DRx4" "2666" "LRDIMM"| 428
|
||||
"M386A8K40BM2-CTD" | "Samsung" "64GB" "4DRx4" "2666" "LRDIMM"| 420.5
|
||||
"M393A8K40B22-CWD" | "Samsung" "64GB" "2S2Rx4" "2666" "LRDIMM"| 400
|
||||
"M386A8K40CM2-CTD" | "Samsung" "64GB" "4DRx4" "2666" "LRDIMM"| 428
|
||||
"HMAA8GL7MMR4N-VK" | "Hynix" "64GB" "4DRx4" "2666" "LRDIMM"| 334.3
|
||||
"HMAA8GL7CPR4N-VK" | "Hynix" "64GB" "4DRx4" "2666" "LRDIMM"| 334.3
|
||||
"M386A8K40DM2-CVF" | "Samsung" "64GB" "4DRx4" "2933" "LRDIMM"| 552.33
|
||||
"HMAA8GL7CPR4N-WM" | "Hynix" "64GB" "4DRx4" "2933" "LRDIMM"| 558
|
||||
"MTA72ASS8G72LZ-2G9" | "Micron" "64GB" "4DRx4" "2933" "LRDIMM"| 539
|
||||
"M393A8G40AB2-CVF" | "Samsung" "64GB" "2Rx4" "2933" "RDIMM"| 605
|
||||
"M393A8G40MB2-CVF" | "Samsung" "64GB" "2Rx4" "2933" "RDIMM"| 512
|
||||
"HMAA8GR7AJR4N-WM" | "Hynix" "64GB" "2Rx4" "2933" "RDIMM"| 750.5
|
||||
"HMAA8GR7MJR4N-WM" | "Hynix" "64GB" "2Rx4" "2933" "RDIMM"| 536
|
||||
"MTA36ASF8G72PZ-2G9" | "Micron" "64GB" "2Rx4" "2933" "RDIMM"| 595
|
||||
"M393A8G40AB2-CWE" | "Samsung" "64GB" "2Rx4" "3200" "RDIMM"| 872
|
||||
"M393A8G40MB2-CWE" | "Samsung" "64GB" "2Rx4" "3200" "RDIMM"| 760
|
||||
"M393A8G40CB4-CWE" | "Samsung" "64GB" "2Rx4" "3200" "RDIMM"| 740
|
||||
"HMAA8GR7AJR4N-XN" | "Hynix" "64GB" "2Rx4" "3200" "RDIMM"| 600
|
||||
"HMAA8GR7CJR4N-XN" | "Hynix" "64GB" "2Rx4" "3200" "RDIMM"| 836
|
||||
"M386B4G70DM0-YH9" | "Samsung" "32GB" "4Rx4" "1333" "LRDIMM"| 28.37
|
||||
"M386B4G70DM0-YK" | "Samsung" "32GB" "4DRx4" "1600" "LRDIMM"| 28.37
|
||||
"HMA84GL7AMR4N-TF" | "Hynix" "32GB" "4DRx4" "2133" "LRDIMM"| 123.5
|
||||
"MTA72ASS4G72LZ-2G1" | "Micron" "32GB" "4DRx4" "2133" "LRDIMM"| 92.59
|
||||
"M386A4G40DM0-CPB" | "Samsung" "32GB" "4DRx4" "2133" "LRDIMM"| 152
|
||||
"M386A4G40DM1-CRC" | "Samsung" "32GB" "4DRx4" "2400" "LRDIMM"| 156.75
|
||||
"M393A4K40BB0-CPB" | "Samsung" "32GB" "2Rx4" "2133" "RDIMM"| 207
|
||||
"HMA84GR7MFR4N-TF" | "Hynix" "32GB" "2Rx4" "2133" "RDIMM"| 174.17
|
||||
"MTA36ASF4G72PZ-2G1" | "Micron" "32GB" "2Rx4" "2133" "RDIMM"| 133
|
||||
"M386A4K40BB0-CRC" | "Samsung" "32GB" "2Rx4" "2400" "LRDIMM"| 172.56
|
||||
"M393A4K40BB1-CRC" | "Samsung" "32GB" "2Rx4" "2400" "RDIMM"| 251
|
||||
"M393A4K40CB1-CRC" | "Samsung" "32GB" "2Rx4" "2400" "RDIMM"| 251
|
||||
"HMA84GR7MFR4N-UH" | "Hynix" "32GB" "2Rx4" "2400" "RDIMM"| 207
|
||||
"HMA84GR7AFR4N-UH" | "Hynix" "32GB" "2Rx4" "2400" "RDIMM"| 181.16
|
||||
"HMA84GL7MFR4N-UH" | "Hynix" "32GB" "2Rx4" "2400" "RDIMM"| 181.16
|
||||
"MTA36ASF4G72PZ-2G3" | "Micron" "32GB" "2Rx4" "2400" "RDIMM"| 192.85
|
||||
"M393A4K40BB2-CTD" | "Samsung" "32GB" "2Rx4" "2666" "RDIMM"| 280
|
||||
"M393A4K40CB2-CTD" | "Samsung" "32GB" "2Rx4" "2666" "RDIMM"| 266
|
||||
"HMA84GR7CJR4N-VK" | "Hynix" "32GB" "2Rx4" "2666" "RDIMM"| 248
|
||||
"HMA84GR7AFR4N-VK" | "Hynix" "32GB" "2Rx4" "2666" "RDIMM"| 248
|
||||
"HMA84GR7JJR4N-VK" | "Hynix" "32GB" "2Rx4" "2666" "RDIMM"| 248
|
||||
"MTA36ASF4G72PZ-2G6" | "Micron" "32GB" "2Rx4" "2666" "RDIMM"| 213
|
||||
"MTA36ASF4G72LZ-2G6" | "Micron" "32GB" "2Rx4" "2666" "RDIMM"| 213
|
||||
"M393A4K40DB2-CVF" | "Samsung" "32GB" "2Rx4" "2933" "RDIMM"| 228
|
||||
"M393A4K40CB2-CVF" | "Samsung" "32GB" "2Rx4" "2933" "RDIMM"| 380
|
||||
"HMA84GR7JJR4N-WM" | "Hynix" "32GB" "2Rx4" "2933" "RDIMM"| 413
|
||||
"HMA84GR7DJR4N-WM" | "Hynix" "32GB" "2Rx4" "2933" "RDIMM"| 413
|
||||
"HMA84GR7CJR4N-WM" | "Hynix" "32GB" "2Rx4" "2933" "RDIMM"| 413
|
||||
"MTA36ASF4G72PZ-2G9" | "Micron" "32GB" "2Rx4" "2933" "RDIMM"| 302.82
|
||||
"M393A4G40BB3-CWE" | "Samsung" "32GB" "1Rx4" "3200" "RDIMM"| 436.05
|
||||
"M393A4K40EB3-CWE" | "Samsung" "32GB" "2Rx4" "3200" "RDIMM"| 503
|
||||
"M393A4K40DB3-CWE" | "Samsung" "32GB" "2Rx4" "3200" "RDIMM"| 503
|
||||
"HMA84GR7CJR4N-XN" | "Hynix" "32GB" "2Rx4" "3200" "RDIMM"| 475
|
||||
"HMA84GR7DJR4N-XN" | "Hynix" "32GB" "2Rx4" "3200" "RDIMM"| 475
|
||||
"MTA36ASF4G72PZ-3G2" | "Micron" "32GB" "2Rx4" "3200" "RDIMM"| 361
|
||||
"M393A4G43AB3-CWE" | "Samsung" "32GB" "2Rx8" "3200" "RDIMM"| 425
|
||||
"HMAA4GR7CJR8N-XN" | "Hynix" "32GB" "2Rx8" "3200" "RDIMM"| 362.32
|
||||
"HMT42GR7BMR4C-G7" | "Hynix" "16GB" "4Rx4" "1066" "RDIMM"| 21.13
|
||||
"MT36KSF2G72PZ-1G4" | "Micron" "16GB" "2Rx4" "1333" "RDIMM"| 16.88
|
||||
"M393B2G70EB0-YK0" | "Samsung" "16GB" "2Rx4" "1600" "RDIMM"| 19.69
|
||||
"M393B2G70DB0-YK0" | "Samsung" "16GB" "2Rx4" "1600" "RDIMM"| 19.69
|
||||
"M393B2G70BH0-YK0" | "Samsung" "16GB" "2Rx4" "1600" "RDIMM"| 19.69
|
||||
"MT36KSF2G72PZ-1G6" | "Micron" "16GB" "2Rx4" "1600" "RDIMM"| 21.4
|
||||
"HMT42GR7BFR4A-PB" | "Hynix" "16GB" "2Rx4" "1600" "RDIMM"| 19.69
|
||||
"HMT42GR7MFR4C-PB" | "Hynix" "16GB" "2Rx4" "1600" "RDIMM"| 18.99
|
||||
"HMT42GR7AFR4C-RD" | "Hynix" "16GB" "2Rx4" "1866" "RDIMM"| 17.02
|
||||
"M393A2G40EB1-CPB" | "Samsung" "16GB" "2Rx4" "2133" "RDIMM"| 110.4
|
||||
"M393A2G40DB0-CPB" | "Samsung" "16GB" "2Rx4" "2133" "RDIMM"| 110.4
|
||||
"HMA42GR7AFR4N-TF" | "Hynix" "16GB" "2Rx4" "2133" "RDIMM"| 97.7
|
||||
"HMA42GR7MFR4N-TF" | "Hynix" "16GB" "2Rx4" "2133" "RDIMM"| 60
|
||||
"MTA36ASF2G72PZ-2G1" | "Micron" "16GB" "2Rx4" "2133" "RDIMM"| 64.4
|
||||
"M393A2K40CB1-CRC" | "Samsung" "16GB" "1Rx4" "2400" "RDIMM"| 10
|
||||
"M393A2K40BB1-CRC" | "Samsung" "16GB" "1Rx4" "2400" "RDIMM"| 110
|
||||
"MTA18ASF2G72PZ-2G3" | "Micron" "16GB" "1Rx4" "2400" "RDIMM"| 95
|
||||
"M393A2G40EB1-CRC" | "Samsung" "16GB" "2Rx4" "2400" "RDIMM"| 110.2
|
||||
"HMA42GR7AFR4N-UH" | "Hynix" "16GB" "2Rx4" "2400" "RDIMM"| 108
|
||||
"HMA42GR7BJR4N-UH" | "Hynix" "16GB" "2Rx4" "2400" "RDIMM"| 108
|
||||
"MTA36ASF2G72PZ-2G3" | "Micron" "16GB" "2Rx4" "2400" "RDIMM"| 85
|
||||
"HMA82GR7AFR4N-VK" | "Hynix" "16GB" "1Rx4" "2666" "RDIMM"| 110
|
||||
"HMA82GR7CJR4N-VK" | "Hynix" "16GB" "1Rx4" "2666" "RDIMM"| 110
|
||||
"M393A2K43BB1-CTD" | "Samsung" "16GB" "2Rx8" "2666" "RDIMM"| 114.29
|
||||
"HMA82GR7AFR8N-VK" | "Hynix" "16GB" "2Rx8" "2666" "RDIMM"| 107
|
||||
"HMA82GR7CJR8N-VK" | "Hynix" "16GB" "2Rx8" "2666" "RDIMM"| 107
|
||||
"HMA82GR7JJR8N-VK" | "Hynix" "16GB" "2Rx8" "2666" "RDIMM"| 107
|
||||
"MTA18ASF2G72PDZ-2G6" | "Micron" "16GB" "2Rx8" "2666" "RDIMM"| 83.33
|
||||
"M393A2K40CB2-CVF" | "Samsung" "16GB" "1Rx4" "2933" "RDIMM"| 118.75
|
||||
"M393A2K40DB2-CVF" | "Samsung" "16GB" "1Rx4" "2933" "RDIMM"| 118.75
|
||||
"M393A2K43DB2-CVF" | "Samsung" "16GB" "2Rx8" "2933" "RDIMM"| 100.57
|
||||
"HMA82GR7CJR8N-WM" | "Hynix" "16GB" "2Rx8" "2933" "RDIMM"| 78.65
|
||||
"HMA82GR7JJR8N-WM" | "Hynix" "16GB" "2Rx8" "2933" "RDIMM"| 102.56
|
||||
"M393A2K43DB3-CWE" | "Samsung" "16GB" "2Rx8" "3200" "RDIMM"| 171
|
||||
"HMA82GR7DJR8N-XN" | "Hynix" "16GB" "2Rx8" "3200" "RDIMM"| 114.29
|
||||
"HMA82GR7CJR8N-XN" | "Hynix" "16GB" "2Rx8" "3200" "RDIMM"| 114.29
|
||||
"MTA18ASF2G72PDZ-3G2" | "Micron" "16GB" "2Rx8" "3200" "RDIMM"| 85.47
|
||||
"M393B1K70DH0-YH9" | "Samsung" "8GB" "2Rx4" "1333" "RDIMM"| 6.05
|
||||
"M393B1K70CH0-CH9" | "Samsung" "8GB" "2Rx4" "1333" "RDIMM"| 6.05
|
||||
"MTA18ASF1G72PZ-2G1" | "Micron" "8GB" "1Rx4" "2133" "RDIMM"| 23.98
|
||||
"HMA41GR7AFR8N-TF" | "Hynix" "8GB" "1Rx8" "2133" "RDIMM"| 30.24
|
||||
"M393A1G40DB0-CPB" | "Samsung" "8GB" "1Rx8" "2133" "RDIMM"| 45
|
||||
"MTA18ASF1G72PZ-2G1" | "Micron" "8GB" "1Rx8" "2133" "RDIMM"| 23.91
|
||||
"HMA41GR7AFR8N-TF" | "Hynix" "8GB" "2Rx8" "2133" "RDIMM"| 28.13
|
||||
"M393A1G43DB0-CPB" | "Samsung" "8GB" "2Rx8" "2133" "RDIMM"| 28.13
|
||||
"MTA18ASF1G72PZ-2G1" | "Micron" "8GB" "2Rx8" "2133" "RDIMM"| 23.91
|
||||
"HMA41GR7AFR4N-UH" | "Hynix" "8GB" "1Rx4" "2400" "RDIMM"| 28
|
||||
"HMA81GR7MFR8N-UH" | "Hynix" "8GB" "1Rx8" "2400" "RDIMM"| 30.24
|
||||
"M393A1G40DB1-CRC" | "Samsung" "8GB" "1Rx8" "2400" "RDIMM"| 30.24
|
||||
"MTA9ASF1G72PZ-2G3" | "Micron" "8GB" "1Rx8" "2400" "RDIMM"| 30.24
|
||||
"HMA41GR7AFR8N-UH" | "Hynix" "8GB" "2Rx8" "2400" "RDIMM"| 39.6
|
||||
"M393A1G40DB1-CRC" | "Samsung" "8GB" "2Rx8" "2400" "RDIMM"| 28.13
|
||||
"M393A1G43DB1-CRC" | "Samsung" "8GB" "2Rx8" "2400" "RDIMM"| 39.6
|
||||
"MTA18ASF1G72PDZ-2G3" | "Micron" "8GB" "2Rx8" "2400" "RDIMM"| 28.13
|
||||
"HMA81GR7AFR8N-VK" | "Hynix" "8GB" "1Rx8" "2666" "RDIMM"| 28.6
|
||||
"MTA9ASF1G72PZ-2G6" | "Micron" "8GB" "1Rx8" "2666" "RDIMM"| 28.6
|
||||
"HMA81GR7AFR8N-VK" | "Hynix" "8GB" "2Rx8" "2666" "RDIMM"| 28.13
|
||||
"MTA9ASF1G72PZ-2G6" | "Micron" "8GB" "2Rx8" "2666" "RDIMM"| 28.13
|
||||
"HMA81GR7CJR8N-WM" | "Hynix" "8GB" "1Rx8" "2933" "RDIMM"| 39.33
|
||||
"HMA81GR7CJR8N-WM" | "Hynix" "8GB" "2Rx8" "2933" "RDIMM"| 41.49
|
||||
"M393A1K43DB1-CVF" | "Samsung" "8GB" "2Rx8" "2933" "RDIMM"| 41.49
|
||||
"MTA9ASF1G72PZ-2G9" | "Micron" "8GB" "2Rx8" "2933" "RDIMM"| 30.24
|
||||
"HMA81GR7CJR8N-XN" | "Hynix" "8GB" "1Rx8" "3200" "RDIMM"| 61.6
|
||||
"MTA9ASF1G72PZ-3G2" | "Micron" "8GB" "1Rx8" "3200" "RDIMM"| 30.24
|
||||
"HMA81GR7CJR8N-XN" | "Hynix" "8GB" "2Rx8" "3200" "RDIMM"| 41.49
|
||||
"" | "Samsung" "32GB" "2Rx8" "2133" "UDIMM"| 127.5
|
||||
"" | "Samsung" "32GB" "2Rx8" "2400" "UDIMM"| 140
|
||||
M378A4G43MB1-CTD | "Samsung" "32GB" "2Rx8" "2666" "UDIMM"| 164
|
||||
M378A4G43AB2-CVF | "Samsung" "32GB" "2Rx8" "2933" "UDIMM"| 137.88
|
||||
M378A4G43AB2-CWE | "Samsung" "32GB" "2Rx8" "3200" "UDIMM"| 180
|
||||
HMAA4GU6AJR8N-XN | "Hynix" "32GB" "2Rx8" "3200" "UDIMM"| 242.25
|
||||
M378A2K43BB1-CPB | "Samsung" "16GB" "2Rx8" "2133" "UDIMM"| 56.5
|
||||
M378A2K43CB1-CRC | "Samsung" "16GB" "2Rx8" "2400" "UDIMM"| 110
|
||||
MTA18ASF2G72AZ-2G3 | "Micron" "16GB" "2Rx8" "2400" "UDIMM"| 90.25
|
||||
HMA82GU6JJR8N-VK | "Hynix" "16GB" "2Rx8" "2666" "UDIMM"| 90
|
||||
M378A2K43DB1-CVF | "Samsung" "16GB" "2Rx8" "2933" "UDIMM"| 56.5
|
||||
KVR32N22S8/16 | "Kingston" "16GB" "1RX8" "3200" "UDIMM"| 48.5
|
||||
M378A1K43BB1-CPB | "Samsung" "8GB" "1RX8" "2133" "UDIMM"| 25
|
||||
M378A1K43CB2-CRC | "Samsung" "8GB" "1RX8" "2400" "UDIMM"| 30
|
||||
M474A2K43BB1-CRC | "Samsung" "16GB" "2Rx8" "2400" "SODIMM"| 90
|
||||
HMAA2GS6CJR8N-XN | "Hynix" "16GB" "1Rx8" "3200" "SODIMM"| 72.67
|
||||
M474A2K43EB1-CWE | "Samsung" "16GB" "2Rx8" "3200" "SODIMM"| 80
|
||||
M471A4G43MB1-CTD | "Samsung" "32GB" "2Rx8" "2666" "SODIMM"| 125
|
||||
M471A4G43BB1-CWE | "Samsung" "32GB" "2Rx8" "3200" "SODIMM"| 139.4
|
||||
HMAA4GS6CJR8N | "Hynix" "32GB" "2Rx8" "3200" "SODIMM"| 123.55
|
||||
M425R2GA3BB0-CWM | "Samsung" "16GB" "1Rx8" "5600" "SODIMM"| 130.62
|
||||
M425R4GA3PB0-CWM | "Samsung" "32GB" "2Rx8" "5600" "SODIMM"| 246.73
|
||||
"M378A4G43MB1-CTD" | "Samsung" "32GB" "2Rx8" "2666" "UDIMM"| 164
|
||||
"M378A4G43AB2-CVF" | "Samsung" "32GB" "2Rx8" "2933" "UDIMM"| 137.88
|
||||
"M378A4G43AB2-CWE" | "Samsung" "32GB" "2Rx8" "3200" "UDIMM"| 180
|
||||
"HMAA4GU6AJR8N-XN" | "Hynix" "32GB" "2Rx8" "3200" "UDIMM"| 242.25
|
||||
"M378A2K43BB1-CPB" | "Samsung" "16GB" "2Rx8" "2133" "UDIMM"| 56.5
|
||||
"M378A2K43CB1-CRC" | "Samsung" "16GB" "2Rx8" "2400" "UDIMM"| 110
|
||||
"MTA18ASF2G72AZ-2G3" | "Micron" "16GB" "2Rx8" "2400" "UDIMM"| 90.25
|
||||
"HMA82GU6JJR8N-VK" | "Hynix" "16GB" "2Rx8" "2666" "UDIMM"| 90
|
||||
"M378A2K43DB1-CVF" | "Samsung" "16GB" "2Rx8" "2933" "UDIMM"| 56.5
|
||||
"KVR32N22S8/16" | "Kingston" "16GB" "1RX8" "3200" "UDIMM"| 48.5
|
||||
"M378A1K43BB1-CPB" | "Samsung" "8GB" "1RX8" "2133" "UDIMM"| 25
|
||||
"M378A1K43CB2-CRC" | "Samsung" "8GB" "1RX8" "2400" "UDIMM"| 30
|
||||
"M474A2K43BB1-CRC" | "Samsung" "16GB" "2Rx8" "2400" "SODIMM"| 90
|
||||
"HMAA2GS6CJR8N-XN" | "Hynix" "16GB" "1Rx8" "3200" "SODIMM"| 72.67
|
||||
"M474A2K43EB1-CWE" | "Samsung" "16GB" "2Rx8" "3200" "SODIMM"| 80
|
||||
"M471A4G43MB1-CTD" | "Samsung" "32GB" "2Rx8" "2666" "SODIMM"| 125
|
||||
"M471A4G43BB1-CWE" | "Samsung" "32GB" "2Rx8" "3200" "SODIMM"| 139.4
|
||||
"HMAA4GS6CJR8N" | "Hynix" "32GB" "2Rx8" "3200" "SODIMM"| 123.55
|
||||
"M425R2GA3BB0-CWM" | "Samsung" "16GB" "1Rx8" "5600" "SODIMM"| 130.62
|
||||
"M425R4GA3PB0-CWM" | "Samsung" "32GB" "2Rx8" "5600" "SODIMM"| 246.73
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# Hướng Dẫn Sử Dụng Dashboard - eBay Deep Scan (Dành Cho Người Dùng)
|
||||
|
||||
Công cụ này giúp bạn tự động tìm kiếm và lọc ra những món hàng có giá hời trên eBay. Dưới đây là cách sử dụng dashboard đơn giản nhất.
|
||||
|
||||
---
|
||||
|
||||
### 1. Truy Cập Dashboard
|
||||
Mở trình duyệt web và truy cập địa chỉ: `http://localhost:4000` (hoặc link server bạn được cung cấp).
|
||||
|
||||
### 2. Quản Lý Nhóm Sản Phẩm (Profiles)
|
||||
Mỗi **Hồ Sơ (Profile)** giúp bạn quản lý một loại hàng riêng biệt (ví dụ: một cái cho SSD, một cái cho RAM).
|
||||
|
||||
1. Nhấn nút **Manage Profiles** trên thanh công cụ.
|
||||
2. **Thêm mới**: Nhập tên nhóm hàng và **Tỷ lệ giá mục tiêu** (ví dụ: `0.85` tức là bạn muốn mua rẻ hơn thị trường 15%).
|
||||
3. **Từ khóa chung (Common Keywords)**: Nếu bạn luôn muốn tránh một loại hàng nào đó, hãy nhập vào đây. Ví dụ: `-cable -adapter` để tự động lọc bỏ các loại dây cáp và đầu chuyển khỏi mọi tìm kiếm.
|
||||
|
||||
### 3. Nhập Danh Sách Sản Phẩm (Import Keywords)
|
||||
Bạn có thể nhập danh sách hàng nghìn sản phẩm cùng lúc.
|
||||
|
||||
1. Nhấn **Bulk Import** trong phần quản lý từ khóa của Profile.
|
||||
2. Nhập theo mẫu: `Mã sản phẩm | Tên hoặc thông số | Giá mua mong muốn`
|
||||
- Ví dụ: `"SSDSC2BX200" | "200GB", "2.5" | 14.00`
|
||||
- **Mẹo nhỏ**: Nếu bạn để mã sản phẩm trong dấu ngoặc kép `"Mã"`, eBay sẽ tìm kiếm chính xác mã đó, giúp kết quả chính xác hơn.
|
||||
|
||||
### 4. Bắt Đầu Tìm Kiếm (Deep Scan)
|
||||
- Nhấn nút **Deep Scan** (màu xanh lá) ở góc trên bên phải.
|
||||
- Một thanh tiến trình sẽ hiện ra cho biết hệ thống đang tìm kiếm đến đâu.
|
||||
- **Lưu ý**: Hệ thống sẽ tự động bỏ qua các mặt hàng từ Trung Quốc và các máy bị hỏng/vỡ để bạn không mất thời gian xem.
|
||||
|
||||
### 5. Xem Kết Quả và Chốt Đơn
|
||||
Hệ thống chỉ hiển thị những mặt hàng có giá thấp hơn mức bạn mong muốn.
|
||||
|
||||
- **Đánh giá AI (Robot)**: Xem nội dung phân tích tự động dưới cột **AI Analysis**. Nó sẽ cho bạn biết sản phẩm đó "đáng mua" hay có gì "nghi ngờ".
|
||||
- **Xem Ảnh và Chi Tiết**: Nhấn vào **Tên sản phẩm**. Một cửa sổ lớn hiện ra cho phép bạn xem tất cả ảnh gốc, mô tả đầy đủ và giá trung bình thực tế.
|
||||
- **Link eBay**: Nhấn nút **Open on eBay** để đến thẳng trang mua hàng.
|
||||
- **Sắp xếp**: Sau khi xem xong, bạn có thể chọn trạng thái **Done** (Để đánh dấu đã mua/xem) hoặc **Skip** (Để bỏ qua) giúp bảng kết quả luôn sạch sẽ.
|
||||
|
||||
---
|
||||
**Lưu ý**: Nếu bạn muốn xóa toàn bộ kết quả cũ để bắt đầu lại từ đầu, hãy nhờ kỹ thuật viên chạy file làm sạch dữ liệu.
|
||||
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,6 +390,16 @@
|
|||
<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;
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,735 @@
|
|||
const express = require("express");
|
||||
const OpenAI = require("openai");
|
||||
const cors = require("cors");
|
||||
require("dotenv").config();
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: "10mb" }));
|
||||
|
||||
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||||
|
||||
// ---- API: analyze image ----
|
||||
app.post("/analyze", async (req, res) => {
|
||||
try {
|
||||
const { imageUrl } = req.body;
|
||||
if (!imageUrl) return res.status(400).json({ error: "imageUrl is required" });
|
||||
|
||||
const aiRes = await client.chat.completions.create({
|
||||
model: "gpt-4o-mini", // upgraded: better vision accuracy
|
||||
temperature: 0,
|
||||
response_format: { type: "json_object" },
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: "You are a RAM hardware OCR expert. Extract text from RAM module labels and return only valid JSON, no markdown."
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Read all text on this RAM module label and return JSON:
|
||||
|
||||
{
|
||||
"modules": [
|
||||
{
|
||||
"brand": "<manufacturer name>",
|
||||
"part_number": "<exact part number>",
|
||||
"specs": "<capacity, rank, speed spec line>",
|
||||
"raw_label": "<all visible text on label, space-separated>"
|
||||
}
|
||||
],
|
||||
"total_modules": <integer>
|
||||
}`
|
||||
},
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: { url: imageUrl, detail: "high" } // request high detail
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const raw = aiRes.choices[0].message.content;
|
||||
const data = JSON.parse(raw);
|
||||
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- serve UI ----
|
||||
app.get("/", (_req, res) => {
|
||||
res.send(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>RAM OCR Inspector</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Syne:wght@400;600;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #0a0c10;
|
||||
--surface: #111318;
|
||||
--surface2: #181c24;
|
||||
--border: #252a35;
|
||||
--border-glow: #2a7fff44;
|
||||
--text: #e8eaf0;
|
||||
--text-muted: #6b7280;
|
||||
--accent: #2a7fff;
|
||||
--accent-dim: #1a4f99;
|
||||
--green: #00e5a0;
|
||||
--yellow: #f5c518;
|
||||
--red: #ff4d6a;
|
||||
--mono: 'JetBrains Mono', monospace;
|
||||
--sans: 'Syne', sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--sans);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
header {
|
||||
padding: 18px 32px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.logo-icon {
|
||||
width: 34px; height: 34px;
|
||||
background: var(--accent);
|
||||
border-radius: 8px;
|
||||
display: grid; place-items: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
header h1 {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
header span {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
/* ── Main layout ── */
|
||||
main {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 380px;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Left panel ── */
|
||||
.left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px;
|
||||
gap: 20px;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.input-row input {
|
||||
flex: 1;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
transition: border-color .2s;
|
||||
}
|
||||
.input-row input:focus { border-color: var(--accent); }
|
||||
.input-row input::placeholder { color: var(--text-muted); }
|
||||
|
||||
button#analyzeBtn {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
font-family: var(--sans);
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
padding: 10px 22px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background .2s, transform .1s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
button#analyzeBtn:hover { background: #1a6fe8; }
|
||||
button#analyzeBtn:active { transform: scale(.97); }
|
||||
button#analyzeBtn:disabled { background: var(--accent-dim); cursor: not-allowed; }
|
||||
|
||||
/* ── Canvas wrapper ── */
|
||||
.canvas-wrap {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
line-height: 0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
align-self: flex-start;
|
||||
max-width: 100%;
|
||||
}
|
||||
.canvas-wrap img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.canvas-wrap canvas {
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: 10px;
|
||||
height: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
.placeholder .icon { font-size: 36px; opacity: .4; }
|
||||
|
||||
/* ── Word list ── */
|
||||
.word-list-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.section-label {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--text-muted);
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.word-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.chip {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: background .15s, border-color .15s, color .15s;
|
||||
user-select: none;
|
||||
}
|
||||
.chip:hover { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.chip.low-conf { border-color: var(--yellow); color: var(--yellow); }
|
||||
.chip.dim { opacity: .45; }
|
||||
|
||||
/* ── Tooltip ── */
|
||||
.tooltip {
|
||||
position: fixed;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 5px;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity .15s;
|
||||
z-index: 999;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tooltip.show { opacity: 1; }
|
||||
|
||||
/* ── Right panel ── */
|
||||
.right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
.tab {
|
||||
padding: 12px 20px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color .2s, border-color .2s;
|
||||
user-select: none;
|
||||
}
|
||||
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
.tab:hover:not(.active) { color: var(--text); }
|
||||
|
||||
.tab-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
display: none;
|
||||
}
|
||||
.tab-content.active { display: block; }
|
||||
|
||||
/* ── Module cards ── */
|
||||
.module-card {
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.module-card .brand {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: var(--accent);
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.badge {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
}
|
||||
.kv-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 110px 1fr;
|
||||
gap: 6px 12px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
.kv-grid .k { color: var(--text-muted); }
|
||||
.kv-grid .v { color: var(--text); word-break: break-all; }
|
||||
.kv-grid .v.good { color: var(--green); }
|
||||
|
||||
/* ── JSON panel ── */
|
||||
pre#jsonOut {
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.7;
|
||||
color: #a8b4cc;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ── Status bar ── */
|
||||
.status {
|
||||
padding: 8px 24px;
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
.status .dot {
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--border);
|
||||
display: inline-block;
|
||||
}
|
||||
.status .dot.ready { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||||
.status .dot.loading { background: var(--yellow); animation: blink 1s infinite; }
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: .3; }
|
||||
}
|
||||
|
||||
/* ── Copy toast ── */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 30px; right: 30px;
|
||||
background: var(--green);
|
||||
color: #000;
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px;
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
transition: opacity .25s, transform .25s;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
.toast.show { opacity: 1; transform: translateY(0); }
|
||||
|
||||
/* scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="logo-icon">🔍</div>
|
||||
<h1>RAM OCR Inspector</h1>
|
||||
<span>gpt-4o · high detail</span>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- LEFT -->
|
||||
<div class="left">
|
||||
<div class="input-row">
|
||||
<input id="url" type="url" placeholder="Paste image URL of RAM module…"/>
|
||||
<button id="analyzeBtn" onclick="run()">Analyze</button>
|
||||
</div>
|
||||
|
||||
<div id="imgArea">
|
||||
<div class="placeholder">
|
||||
<span class="icon">🖥️</span>
|
||||
<span>Image will appear here</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="wordListArea"></div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT -->
|
||||
<div class="right">
|
||||
<div class="tabs">
|
||||
<div class="tab active" onclick="switchTab('modules')">Modules</div>
|
||||
<div class="tab" onclick="switchTab('json')">Raw JSON</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-modules" class="tab-content active">
|
||||
<p style="color:var(--text-muted); font-family:var(--mono); font-size:13px;">
|
||||
Module info will appear after analysis.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="tab-json" class="tab-content">
|
||||
<pre id="jsonOut">// No data yet</pre>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div class="status">
|
||||
<span class="dot" id="statusDot"></span>
|
||||
<span id="statusText">Ready</span>
|
||||
<span id="wordCount"></span>
|
||||
</div>
|
||||
|
||||
<div class="tooltip" id="tooltip"></div>
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
let currentWords = [];
|
||||
let hoveredIdx = -1;
|
||||
let imgEl = null;
|
||||
let canvasEl = null;
|
||||
|
||||
function switchTab(name) {
|
||||
document.querySelectorAll('.tab').forEach((t,i) => {
|
||||
t.classList.toggle('active', ['modules','json'][i] === name);
|
||||
});
|
||||
document.querySelectorAll('.tab-content').forEach(c => {
|
||||
c.classList.toggle('active', c.id === 'tab-' + name);
|
||||
});
|
||||
}
|
||||
|
||||
function setStatus(state, msg) {
|
||||
const dot = document.getElementById('statusDot');
|
||||
dot.className = 'dot ' + (state === 'loading' ? 'loading' : state === 'ready' ? 'ready' : '');
|
||||
document.getElementById('statusText').textContent = msg;
|
||||
}
|
||||
|
||||
function showToast(msg) {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg;
|
||||
t.classList.add('show');
|
||||
setTimeout(() => t.classList.remove('show'), 1800);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const url = document.getElementById('url').value.trim();
|
||||
if (!url) return;
|
||||
|
||||
const btn = document.getElementById('analyzeBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Analyzing…';
|
||||
setStatus('loading', 'Sending to GPT-4o…');
|
||||
|
||||
document.getElementById('tab-modules').innerHTML = \`<p style="color:var(--text-muted);font-family:var(--mono);font-size:13px">Analyzing…</p>\`;
|
||||
document.getElementById('jsonOut').textContent = '// Waiting…';
|
||||
|
||||
try {
|
||||
const res = await fetch('/analyze', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ imageUrl: url })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error) throw new Error(data.error);
|
||||
|
||||
currentWords = data.words || [];
|
||||
|
||||
document.getElementById('jsonOut').textContent = JSON.stringify(data, null, 2);
|
||||
renderModules(data);
|
||||
renderWordChips(currentWords);
|
||||
drawImage(url, currentWords);
|
||||
|
||||
document.getElementById('wordCount').textContent =
|
||||
\`\${currentWords.length} words · \${data.total_modules || 0} module(s)\`;
|
||||
setStatus('ready', 'Done');
|
||||
} catch (err) {
|
||||
setStatus('', 'Error: ' + err.message);
|
||||
document.getElementById('tab-modules').innerHTML =
|
||||
\`<p style="color:var(--red);font-family:var(--mono);font-size:13px">\${err.message}</p>\`;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Analyze';
|
||||
}
|
||||
}
|
||||
|
||||
function renderModules(data) {
|
||||
const panel = document.getElementById('tab-modules');
|
||||
if (!data.modules || !data.modules.length) {
|
||||
panel.innerHTML = '<p style="color:var(--text-muted);font-family:var(--mono);font-size:13px">No modules detected.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
panel.innerHTML = data.modules.map((m, i) => \`
|
||||
<div class="module-card">
|
||||
<div class="brand">
|
||||
\${m.brand || 'Unknown'}
|
||||
<span class="badge">Module \${i + 1}</span>
|
||||
</div>
|
||||
<div class="kv-grid">
|
||||
\${row('Part №', m.part_number)}
|
||||
\${row('Type', m.type, 'good')}
|
||||
\${row('Capacity', m.capacity, 'good')}
|
||||
\${row('Speed', m.speed)}
|
||||
\${row('Voltage', m.voltage)}
|
||||
\${row('Specs', m.specs)}
|
||||
\${row('Label', m.raw_label)}
|
||||
</div>
|
||||
</div>
|
||||
\`).join('');
|
||||
}
|
||||
|
||||
function row(k, v, cls = '') {
|
||||
if (!v) return '';
|
||||
return \`<span class="k">\${k}</span><span class="v \${cls}">\${v}</span>\`;
|
||||
}
|
||||
|
||||
function renderWordChips(words) {
|
||||
const area = document.getElementById('wordListArea');
|
||||
if (!words.length) { area.innerHTML = ''; return; }
|
||||
|
||||
area.innerHTML = \`
|
||||
<div class="word-list-wrap">
|
||||
<div class="section-label">Detected tokens — click to highlight · click chip to copy</div>
|
||||
<div class="word-chips">
|
||||
\${words.map((w, i) => {
|
||||
const conf = w.confidence ?? 1;
|
||||
const cls = conf < 0.7 ? 'chip low-conf' : 'chip';
|
||||
return \`<div class="\${cls}" id="chip-\${i}" onmouseenter="highlightWord(\${i})" onmouseleave="clearHighlight()" onclick="copyWord('\${w.text.replace(/'/g, "\\\\'")}', \${i})">\${w.text}</div>\`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
\`;
|
||||
}
|
||||
|
||||
function drawImage(url, words) {
|
||||
const area = document.getElementById('imgArea');
|
||||
area.innerHTML = \`<div class="canvas-wrap"><img id="theImg" crossorigin="anonymous"/><canvas id="theCanvas"></canvas></div>\`;
|
||||
|
||||
imgEl = document.getElementById('theImg');
|
||||
canvasEl = document.getElementById('theCanvas');
|
||||
|
||||
imgEl.onload = () => {
|
||||
const W = imgEl.naturalWidth;
|
||||
const H = imgEl.naturalHeight;
|
||||
canvasEl.width = W;
|
||||
canvasEl.height = H;
|
||||
drawBoxes(words, -1);
|
||||
setupCanvasEvents(words);
|
||||
};
|
||||
|
||||
imgEl.src = url;
|
||||
}
|
||||
|
||||
function drawBoxes(words, activeIdx) {
|
||||
if (!canvasEl || !imgEl) return;
|
||||
const ctx = canvasEl.getContext('2d');
|
||||
const W = canvasEl.width;
|
||||
const H = canvasEl.height;
|
||||
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
words.forEach((w, i) => {
|
||||
const [nx, ny, nw, nh] = w.bbox;
|
||||
const x = nx * W, y = ny * H, bw = nw * W, bh = nh * H;
|
||||
const conf = w.confidence ?? 1;
|
||||
|
||||
const isActive = i === activeIdx;
|
||||
|
||||
if (isActive) {
|
||||
// Glow highlight
|
||||
ctx.shadowColor = '#2a7fff';
|
||||
ctx.shadowBlur = 14;
|
||||
ctx.fillStyle = 'rgba(42,127,255,0.20)';
|
||||
ctx.fillRect(x, y, bw, bh);
|
||||
ctx.strokeStyle = '#2a7fff';
|
||||
ctx.lineWidth = 2;
|
||||
} else {
|
||||
ctx.shadowBlur = 0;
|
||||
if (conf < 0.7) {
|
||||
ctx.strokeStyle = 'rgba(245,197,24,0.7)';
|
||||
} else {
|
||||
ctx.strokeStyle = 'rgba(0,229,160,0.65)';
|
||||
}
|
||||
ctx.lineWidth = 1;
|
||||
}
|
||||
|
||||
ctx.strokeRect(x + .5, y + .5, bw, bh);
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Label above box
|
||||
if (isActive || (nw * W > 20 && nh * H > 10)) {
|
||||
const label = w.text;
|
||||
const fs = Math.max(9, Math.min(13, bh * 0.55));
|
||||
ctx.font = \`600 \${fs}px JetBrains Mono, monospace\`;
|
||||
const tw = ctx.measureText(label).width;
|
||||
|
||||
const lx = Math.min(x, W - tw - 4);
|
||||
const ly = y > fs + 4 ? y - 3 : y + bh + fs + 2;
|
||||
|
||||
ctx.fillStyle = isActive ? '#2a7fff' : (conf < 0.7 ? '#f5c518' : '#00e5a0');
|
||||
ctx.globalAlpha = isActive ? 1 : 0.75;
|
||||
ctx.fillText(label, lx, ly);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setupCanvasEvents(words) {
|
||||
const tooltip = document.getElementById('tooltip');
|
||||
|
||||
canvasEl.addEventListener('mousemove', (e) => {
|
||||
const rect = canvasEl.getBoundingClientRect();
|
||||
const scaleX = canvasEl.width / rect.width;
|
||||
const scaleY = canvasEl.height / rect.height;
|
||||
const cx = (e.clientX - rect.left) * scaleX;
|
||||
const cy = (e.clientY - rect.top) * scaleY;
|
||||
const W = canvasEl.width, H = canvasEl.height;
|
||||
|
||||
let found = -1;
|
||||
words.forEach((w, i) => {
|
||||
const [nx, ny, nw, nh] = w.bbox;
|
||||
if (cx >= nx*W && cx <= (nx+nw)*W && cy >= ny*H && cy <= (ny+nh)*H) found = i;
|
||||
});
|
||||
|
||||
if (found !== hoveredIdx) {
|
||||
hoveredIdx = found;
|
||||
drawBoxes(words, hoveredIdx);
|
||||
}
|
||||
|
||||
if (found >= 0) {
|
||||
const w = words[found];
|
||||
const conf = ((w.confidence ?? 1) * 100).toFixed(0);
|
||||
tooltip.textContent = \`\${w.text} · conf \${conf}%\`;
|
||||
tooltip.style.left = (e.clientX + 14) + 'px';
|
||||
tooltip.style.top = (e.clientY - 24) + 'px';
|
||||
tooltip.classList.add('show');
|
||||
} else {
|
||||
tooltip.classList.remove('show');
|
||||
}
|
||||
});
|
||||
|
||||
canvasEl.addEventListener('mouseleave', () => {
|
||||
hoveredIdx = -1;
|
||||
drawBoxes(words, -1);
|
||||
tooltip.classList.remove('show');
|
||||
});
|
||||
|
||||
canvasEl.addEventListener('click', (e) => {
|
||||
const rect = canvasEl.getBoundingClientRect();
|
||||
const scaleX = canvasEl.width / rect.width;
|
||||
const scaleY = canvasEl.height / rect.height;
|
||||
const cx = (e.clientX - rect.left) * scaleX;
|
||||
const cy = (e.clientY - rect.top) * scaleY;
|
||||
const W = canvasEl.width, H = canvasEl.height;
|
||||
|
||||
words.forEach((w) => {
|
||||
const [nx, ny, nw, nh] = w.bbox;
|
||||
if (cx >= nx*W && cx <= (nx+nw)*W && cy >= ny*H && cy <= (ny+nh)*H) {
|
||||
navigator.clipboard.writeText(w.text).then(() => showToast('Copied: ' + w.text));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function highlightWord(i) {
|
||||
hoveredIdx = i;
|
||||
drawBoxes(currentWords, i);
|
||||
}
|
||||
|
||||
function clearHighlight() {
|
||||
hoveredIdx = -1;
|
||||
drawBoxes(currentWords, -1);
|
||||
}
|
||||
|
||||
function copyWord(text, i) {
|
||||
navigator.clipboard.writeText(text).then(() => showToast('Copied: ' + text));
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>`);
|
||||
});
|
||||
|
||||
app.listen(3000, () => console.log("Server running at http://localhost:3000"));
|
||||
Loading…
Reference in New Issue