bep40 commited on
Commit
d6d0d4f
·
verified ·
1 Parent(s): 46ac322

Upload add-product-fix.js

Browse files
Files changed (1) hide show
  1. add-product-fix.js +1 -684
add-product-fix.js CHANGED
@@ -1,684 +1 @@
1
- /**
2
- * V.AI STUDIO — Add Product Modal Fix (v2)
3
- * =====================================================================
4
- * FIXES:
5
- * 1. MODAL: closeAddProductModal() / submitAddProductUrl() /
6
- * openAddProductModal() were never defined -> modal couldn't close or add.
7
- * 2. SECRET: on the live static Space the VAISTUDIO env secret is served
8
- * REDACTED (HF_TOKEN_REDACTED), so the client could never validate it and
9
- * the "Mã truy cập không đúng" error appeared even with the correct code.
10
- * -> We now validate against the injected env secret when it is real, and
11
- * against a documented local override (VAISTUDIO2024) otherwise; the
12
- * accepted code is shown as a hint so the owner is never locked out.
13
- * 3. SCRAPING: the old flow only accepted same-site /san-pham/<slug>/ URLs.
14
- * Now it accepts ANY product URL (external stores like imundex.vn too),
15
- * fetched through public CORS proxies (browser fetch to foreign origins is
16
- * CORS-blocked), and parsed with a robust extractor (JSON-LD, og:*, price
17
- * patterns, Mã số/Tên sản phẩm tables, galleries, spec tables).
18
- *
19
- * The new product is persisted to localStorage and injected live into the
20
- * catalog (grid/search/detail). A best-effort push to products_url_added.json
21
- * is attempted.
22
- */
23
- (function () {
24
- 'use strict';
25
- if (window.__VAI_ADD_PROD_FIX2__) return;
26
- window.__VAI_ADD_PROD_FIX2__ = true;
27
-
28
- /* ------------------------- CONFIG ------------------------- */
29
- var ENV_SECRET = (window.huggingface && window.huggingface.variables && window.huggingface.variables.VAISTUDIO) || '';
30
- var LOCAL_SECRET = 'VAISTUDIO2024'; // documented default used when env is redacted
31
- var SITE = (location.origin && location.origin.indexOf('hf.space') >= 0)
32
- ? location.origin : 'https://bep40-v-aistudio.static.hf.space';
33
-
34
- var PROXIES = [
35
- function (u) { return 'https://api.allorigins.win/raw?url=' + encodeURIComponent(u); },
36
- function (u) { return 'https://corsproxy.io/?url=' + encodeURIComponent(u); },
37
- function (u) { return 'https://api.codetabs.com/v1/proxy?quest=' + encodeURIComponent(u); }
38
- ];
39
-
40
- // Dedicated server-side scraper (reliable, no CORS/proxy flakiness). Called first.
41
- var SCRAPER = 'https://bep40-vai-product-scraper.hf.space/api/scrape';
42
- var PERSIST_API = 'https://bep40-vai-product-scraper.hf.space/api/persist';
43
- var OCR_API = 'https://bep40-vai-product-scraper.hf.space/api/ocr';
44
- var SCRAPER_TOKEN = LOCAL_SECRET;
45
-
46
- // OCR-selected images (each is a data-URL string). Used for both OCR + product gallery.
47
- var OCR_IMAGES = [];
48
-
49
- /* ------------------------- IMAGE UPLOAD / PASTE ------------------------- */
50
- // Reads files and APPENDS them into OCR_IMAGES (fixed: previous version kept
51
- // them only in a local array so file-picked images were never OCR'd).
52
- function readFilesAsDataURL(fileList, cbDone) {
53
- var files = Array.prototype.slice.call(fileList || []);
54
- if (!files.length) { cbDone && cbDone(0); return; }
55
- var pending = files.length, added = 0;
56
- files.forEach(function (f) {
57
- if (!f || !/^image\//.test(f.type || '')) { pending--; if (pending === 0) cbDone && cbDone(added); return; }
58
- var reader = new FileReader();
59
- reader.onload = function (e) {
60
- OCR_IMAGES.push(e.target.result);
61
- added++;
62
- pending--;
63
- if (pending === 0) { renderImgPreview(); cbDone && cbDone(added); }
64
- };
65
- reader.onerror = function () { pending--; if (pending === 0) { renderImgPreview(); cbDone && cbDone(added); } };
66
- reader.readAsDataURL(f);
67
- });
68
- }
69
- function renderImgPreview() {
70
- var box = document.getElementById('addImgPreview');
71
- if (!box) return;
72
- if (!OCR_IMAGES.length) { box.innerHTML = ''; return; }
73
- box.innerHTML = OCR_IMAGES.map(function (src, i) {
74
- return '<div class="add-img-thumb"><img src="' + esc(src) + '"><button class="rm" onclick="removeAddImage(' + i + ')" title="Xóa">×</button></div>';
75
- }).join('');
76
- }
77
- function addImageDataURL(url) {
78
- OCR_IMAGES.push(url);
79
- renderImgPreview();
80
- }
81
- window.removeAddImage = function (i) {
82
- OCR_IMAGES.splice(i, 1);
83
- renderImgPreview();
84
- var pv = document.getElementById('addPvGallery');
85
- if (pv) renderPreviewImages();
86
- };
87
- function bindImgZone() {
88
- var zone = document.getElementById('addImgZone');
89
- var input = document.getElementById('addImgInput');
90
- if (!zone || !input) return;
91
- zone.addEventListener('click', function () { input.click(); });
92
- input.addEventListener('change', function () {
93
- readFilesAsDataURL(input.files, function () { input.value = ''; });
94
- });
95
- zone.addEventListener('dragover', function (e) { e.preventDefault(); zone.classList.add('drag'); });
96
- zone.addEventListener('dragleave', function () { zone.classList.remove('drag'); });
97
- zone.addEventListener('drop', function (e) {
98
- e.preventDefault(); zone.classList.remove('drag');
99
- readFilesAsDataURL(e.dataTransfer.files);
100
- });
101
-
102
- // Unified image-paste handling: works whether the focus is on the chat-style
103
- // box or anywhere else in the modal. Two hooks: a direct 'paste' on the
104
- // textarea (mobile often fires it there) plus a document-level fallback.
105
- function handlePaste(e, clip) {
106
- var hasImg = false;
107
- var items = clip && clip.items;
108
- if (!items) return false;
109
- for (var i = 0; i < items.length; i++) {
110
- if (items[i].type && /^image\//.test(items[i].type)) {
111
- hasImg = true;
112
- var blob = items[i].getAsFile();
113
- if (!blob) continue;
114
- var reader = new FileReader();
115
- reader.onload = function (ev) { addImageDataURL(ev.target.result); };
116
- reader.readAsDataURL(blob);
117
- }
118
- }
119
- return hasImg;
120
- }
121
- var ta = document.getElementById('addPasteInput');
122
- if (ta) {
123
- ta.addEventListener('paste', function (e) {
124
- var m = document.getElementById('addProductOverlay');
125
- if (!m || !m.classList.contains('open')) return;
126
- if (handlePaste(e, e.clipboardData)) {
127
- e.preventDefault(); e.stopPropagation();
128
- ta.value = '';
129
- }
130
- });
131
- }
132
- document.addEventListener('paste', function (e) {
133
- var m = document.getElementById('addProductOverlay');
134
- if (!m || !m.classList.contains('open')) return;
135
- // Only handle here if the event target is NOT the textarea (already handled above)
136
- if (e.target && e.target.id === 'addPasteInput') return;
137
- if (handlePaste(e, e.clipboardData)) {
138
- e.preventDefault();
139
- if (ta) ta.value = '';
140
- }
141
- });
142
- }
143
- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bindImgZone);
144
- else bindImgZone();
145
-
146
- /* ------------------------- OCR ------------------------- */
147
- window.runOCRAddProduct = async function () {
148
- var st = document.getElementById('addProductStatus');
149
- function status(msg, cls) { if (st) { st.textContent = msg; st.className = 'add-product-status ' + (cls || ''); } }
150
- var secret = (document.getElementById('addProductSecret') && document.getElementById('addProductSecret').value || '').trim();
151
- if (!isValidSecret(secret)) { status('❌ Mã truy cập không đúng. Nhập: ' + LOCAL_SECRET, 'err'); return; }
152
- if (!OCR_IMAGES.length) { status('❌ Chưa có ảnh nào. Hãy tải lên hoặc dán (Ctrl+V) ảnh sản phẩm.', 'err'); return; }
153
- var btn = document.getElementById('addImgScan');
154
- if (btn) { btn.disabled = true; btn.textContent = '⏳ Đang OCR…'; }
155
- status('⏳ Đang trích xuất thông tin từ ảnh (OCR)…');
156
- try {
157
- var r = await fetch(OCR_API, {
158
- method: 'POST', headers: { 'Content-Type': 'application/json' },
159
- body: JSON.stringify({ token: SCRAPER_TOKEN, images: OCR_IMAGES })
160
- });
161
- if (!r.ok) throw new Error((await r.text()) || ('HTTP ' + r.status));
162
- var d = await r.json();
163
- if (document.getElementById('addOcrName')) document.getElementById('addOcrName').value = d.name || '';
164
- if (document.getElementById('addOcrSku')) document.getElementById('addOcrSku').value = d.sku || '';
165
- if (document.getElementById('addOcrPrice')) document.getElementById('addOcrPrice').value = d.pn || '';
166
- if (document.getElementById('addOcrBrand')) document.getElementById('addOcrBrand').value = d.brand || '';
167
- var fields = document.getElementById('addOcrFields');
168
- if (fields) fields.style.display = 'block';
169
- var found = (d.name ? 1 : 0) + (d.sku ? 1 : 0) + (d.pn ? 1 : 0) + (d.brand ? 1 : 0);
170
-
171
- // Prepare pending product from the OCR fields + images, show preview,
172
- // and switch the button to "Xác nhận thêm SP" so OCR is immediately usable.
173
- var slug2 = slugify(((document.getElementById('addOcrSku')||{}).value ? (document.getElementById('addOcrSku')||{}).value + ' ' : '') + (d.name || ''));
174
- __pendingAdd = {
175
- r: {
176
- name: d.name || '', sku: d.sku || '', price: d.pn ? money(d.pn) : '', pn: d.pn || 0,
177
- img: OCR_IMAGES[0], gallery: OCR_IMAGES.slice(), desc: '',
178
- brand: d.brand || '', specs: {}
179
- },
180
- slug: slug2, pageUrl: '', usingOCR: true
181
- };
182
- __pendingAdd.r.imgs_data = OCR_IMAGES.slice();
183
- enterPreviewMode();
184
-
185
- status('✅ OCR xong: nhận được ' + found + ' trường thông tin. Preview đã hiển thị — bạn có thể chỉnh sửa, rồi bấm "Xác nhận thêm SP".', 'ok');
186
- } catch (e) {
187
- status('❌ Lỗi OCR: ' + (e && e.message || e), 'err');
188
- } finally {
189
- if (btn) { btn.disabled = false; btn.textContent = '<i class="fas fa-magnifying-glass"></i> Trích xuất thông tin từ ảnh (OCR)'; }
190
- }
191
- };
192
-
193
- /* ------------------------- PREVIEW ------------------------- */
194
- // Renders the current product info + images into the preview panel so the
195
- // shop owner can review/edit/delete/upload/paste before adding to the Space.
196
- function currentAddPreviewData() {
197
- var name = (document.getElementById('addOcrName') || {}).value || '';
198
- var sku = (document.getElementById('addOcrSku') || {}).value || '';
199
- var price = (document.getElementById('addOcrPrice') || {}).value || '';
200
- var brand = (document.getElementById('addOcrBrand') || {}).value || '';
201
- var cat = (document.getElementById('addOcrCat') || {}).value || '';
202
- var desc = (document.getElementById('addOcrDesc') || {}).value || '';
203
- var p = { name: name, sku: sku, price: price, pn: parseInt(price, 10) || 0, brand: brand, cat: cat, desc: desc, imgs: OCR_IMAGES.slice() };
204
- return p;
205
- }
206
- function renderPreviewImages() {
207
- var gal = document.getElementById('addPvGallery');
208
- if (!gal) return;
209
- var main = OCR_IMAGES[0] || '';
210
- var parts = '';
211
- if (main) {
212
- parts += '<img src="' + esc(main) + '" alt="Ảnh chính">';
213
- } else {
214
- parts += '<div style="width:100%;aspect-ratio:1;border-radius:10px;background:#f1f5f9;display:flex;align-items:center;justify-content:center;color:#94a3b8;font-size:.72rem;text-align:center;padding:8px;box-sizing:border-box">Chưa có ảnh.<br>Thêm ảnh bên trên.</div>';
215
- }
216
- if (OCR_IMAGES.length > 1) {
217
- parts += '<div class="add-pv-thumbs">' + OCR_IMAGES.slice(1).map(function (src, i) {
218
- return '<img src="' + esc(src) + '" title="Ảnh ' + (i + 2) + '" onclick="removeAddImage(' + (i + 1) + ')">';
219
- }).join('') + '</div>';
220
- }
221
- gal.innerHTML = parts;
222
- }
223
- function buildAddPreview() {
224
- var wrap = document.getElementById('addPreviewWrap');
225
- if (!wrap) return;
226
- var p = currentAddPreviewData();
227
- var el = document.getElementById('addPvName'); if (el) el.textContent = p.name || 'Chưa có tên sản phẩm';
228
- var el = document.getElementById('addPvSku'); if (el) el.textContent = p.sku ? ('Mã: ' + p.sku) : '';
229
- var el = document.getElementById('addPvPrice'); if (el) el.textContent = p.pn ? (p.pn.toLocaleString('vi-VN') + 'đ') : 'Liên hệ';
230
- var el = document.getElementById('addPvBrand'); if (el) el.textContent = (p.cat ? (p.cat + ' ') : '') + (p.brand ? ('• Thương hiệu: ' + p.brand) : '');
231
- var el = document.getElementById('addPvDesc'); if (el) el.textContent = p.desc || '';
232
- renderPreviewImages();
233
- wrap.style.display = 'block';
234
- }
235
- window.refreshAddPreview = function () { buildAddPreview(); };
236
-
237
- function bindPreviewRefresh() {
238
- ['addOcrName', 'addOcrSku', 'addOcrPrice', 'addOcrBrand', 'addOcrCat', 'addOcrDesc'].forEach(function (id) {
239
- var el = document.getElementById(id);
240
- if (el) el.addEventListener('input', function () { if (document.getElementById('addPreviewWrap') && document.getElementById('addPreviewWrap').style.display !== 'none') buildAddPreview(); });
241
- });
242
- }
243
- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bindPreviewRefresh);
244
- else bindPreviewRefresh();
245
-
246
-
247
- function isValidSecret(s) {
248
- s = String(s || '').trim();
249
- if (!s) return false;
250
- if (ENV_SECRET && ENV_SECRET !== 'HF_TOKEN_REDACTED' && s === ENV_SECRET) return true;
251
- if (s === LOCAL_SECRET) return true;
252
- return false;
253
- }
254
-
255
- function money(n) { n = Number(n) || 0; return n ? n.toLocaleString('vi-VN') + 'đ' : 'LH'; }
256
- function norm(s) { return String(s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[Đđ]/g, 'd').toLowerCase(); }
257
- function slugify(s) { return norm(s).replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 150) || 'san-pham-url'; }
258
- function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]; }); }
259
-
260
- /* ------------------------- MODAL OPEN/CLOSE ------------------------- */
261
- window.openAddProductModal = function () {
262
- var m = document.getElementById('addProductOverlay');
263
- if (!m) return;
264
- m.classList.add('open');
265
- m.style.display = 'flex';
266
- var hint = document.getElementById('addProductHintText');
267
- if (hint) hint.textContent = 'Mã truy cập mặc định: ' + LOCAL_SECRET + ' (dùng cho chủ cửa hàng)';
268
- var st = document.getElementById('addProductStatus');
269
- if (st) st.textContent = '';
270
- // reset OCR state for a fresh add
271
- OCR_IMAGES = [];
272
- var prv = document.getElementById('addImgPreview');
273
- if (prv) prv.innerHTML = '';
274
- var ta = document.getElementById('addPasteInput');
275
- if (ta) ta.value = '';
276
- var fields = document.getElementById('addOcrFields');
277
- if (fields) fields.style.display = 'none';
278
- ['addOcrName','addOcrSku','addOcrPrice','addOcrBrand','addOcrCat','addOcrDesc'].forEach(function (id) {
279
- var el = document.getElementById(id); if (el) el.value = '';
280
- });
281
- var subBtn = document.getElementById('addProductSubmit');
282
- if (subBtn) { subBtn.textContent='Thêm SP'; subBtn.removeAttribute('data-confirm'); subBtn.disabled=false; }
283
- __pendingAdd = null;
284
- };
285
- window.closeAddProductModal = function () {
286
- var m = document.getElementById('addProductOverlay');
287
- if (!m) return;
288
- m.classList.remove('open');
289
- m.style.display = 'none';
290
- };
291
- function bindOverlay() {
292
- var m = document.getElementById('addProductOverlay');
293
- if (!m || m.getAttribute('data-vai-close-bound')) return;
294
- m.setAttribute('data-vai-close-bound', '1');
295
- m.addEventListener('click', function (e) { if (e.target === m) window.closeAddProductModal(); });
296
- }
297
- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bindOverlay);
298
- else bindOverlay();
299
-
300
- /* ------------------------- PERSISTENCE ------------------------- */
301
- function persistUrlAdded(list) { try { localStorage.setItem('vai_url_persist', JSON.stringify(list)); } catch (e) {} }
302
- function loadUrlAdded() {
303
- try { var v = JSON.parse(localStorage.getItem('vai_url_persist') || '[]'); return Array.isArray(v) ? v : []; }
304
- catch (e) { return []; }
305
- }
306
-
307
- /* ------------------------- FETCH (scraper API + CORS proxies) ------------------------- */
308
- async function scrapeViaApi(url) {
309
- // Server-side scraper: returns parsed JSON. No CORS, reliable.
310
- try {
311
- var r = await fetch(SCRAPER + '?url=' + encodeURIComponent(url) + '&token=' + encodeURIComponent(SCRAPER_TOKEN), { cache: 'no-store' });
312
- if (r.ok) {
313
- var data = await r.json();
314
- if (data && (data.name || data.sku || data.gallery || data.price)) {
315
- // normalize server result to the same shape as parseProduct()
316
- return {
317
- name: data.name || '', sku: data.sku || '', img: data.img || '',
318
- gallery: Array.isArray(data.gallery) ? data.gallery.filter(Boolean) : [],
319
- desc: data.desc || data.summary || '', price: data.price || '',
320
- pn: Number(data.pn) || 0, specs: data.specs || {}, brand: data.brand || ''
321
- };
322
- }
323
- }
324
- } catch (e) {}
325
- return null;
326
- }
327
-
328
- async function fetchHtml(url) {
329
- // Try direct first (works for same-site); then proxies.
330
- var attempts = [url];
331
- for (var i = 0; i < PROXIES.length; i++) attempts.push(PROXIES[i](url));
332
- var lastErr = null;
333
- for (var j = 0; j < attempts.length; j++) {
334
- try {
335
- var r = await fetch(attempts[j], { cache: 'no-store' });
336
- if (r.ok) {
337
- var t = await r.text();
338
- if (t && t.length > 100) return t;
339
- }
340
- } catch (e) { lastErr = e; }
341
- }
342
- throw lastErr || new Error('Không tải được trang (CORS/network).');
343
- }
344
-
345
- /* ------------------------- PARSER ------------------------- */
346
- function cleanUrl(u, base) {
347
- if (!u) return '';
348
- u = String(u).trim().replace(/[\u00a0]/g, ' ');
349
- if (!u) return '';
350
- // doubled scheme: 'https://www.x.vnhttps://x.vn/...'
351
- var m = u.match(/^(https?:\/\/[^/]+)(https?:\/\/\S+)$/);
352
- if (m) u = m[2];
353
- // absolute
354
- if (/^https?:\/\//i.test(u)) return u;
355
- // protocol-relative
356
- if (/^\/\//.test(u)) return (base && base.match(/^https?/i)[0] || 'https') + ':' + u;
357
- // relative
358
- if (base) {
359
- try { return new URL(u, base).href; } catch (e) {}
360
- }
361
- return u;
362
- }
363
-
364
- function getMeta(doc, property) {
365
- var el = doc.querySelector('meta[property="' + property + '"]') ||
366
- doc.querySelector('meta[name="' + property + '"]');
367
- return el ? (el.getAttribute('content') || '') : '';
368
- }
369
-
370
- function extractJsonLd(html) {
371
- var re = /<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi;
372
- var m, best = null;
373
- while ((m = re.exec(html))) {
374
- try {
375
- var data = JSON.parse(m[1]);
376
- var arr = Array.isArray(data) ? data : [data];
377
- for (var k = 0; k < arr.length; k++) {
378
- var item = arr[k];
379
- if (!item) continue;
380
- var type = String(item['@type'] || '').toLowerCase();
381
- var isProduct = type.indexOf('product') >= 0 || (item.name && (item.offers || item.image));
382
- if (isProduct) {
383
- var price = item.offers && (item.offers.price != null ? item.offers.price : (item.offers.lowPrice != null ? item.offers.lowPrice : null));
384
- best = {
385
- name: item.name, sku: item.sku,
386
- brand: (item.brand && (item.brand.name || item.brand)) || '',
387
- image: Array.isArray(item.image) ? item.image[0] : item.image,
388
- price: price, priceCurrency: item.offers && item.offers.priceCurrency,
389
- description: item.description
390
- };
391
- break;
392
- }
393
- }
394
- if (best) break;
395
- } catch (e) {}
396
- }
397
- return best;
398
- }
399
-
400
- function extractPrice(html) {
401
- var price = '', pn = 0;
402
- var m = html.match(/(?:property|itemprop)="product:price:amount"\s+content="([^"]+)"/i);
403
- if (m) pn = parseInt(String(m[1]).replace(/[^\d]/g, ''), 10);
404
- if (!pn) {
405
- var pm = html.match(/Giá:?\s*<\/span>?\s*([\d.,\s]+)\s*(VNĐ|VND|đ|₫|Đ)/i);
406
- if (pm) pn = parseInt(pm[1].replace(/[^\d]/g, ''), 10);
407
- }
408
- if (!pn) {
409
- var am = html.match(/(\d{1,3}(?:[.,]\d{3}){1,3})\s*(VNĐ|VND|đ|₫)/i);
410
- if (am) pn = parseInt(am[1].replace(/[^\d]/g, ''), 10);
411
- }
412
- if (pn) price = money(pn);
413
- return { price: price, pn: pn };
414
- }
415
-
416
- function extractSpecs(html) {
417
- var specs = {};
418
- var lineRe = /<div class="row[^"]*pd-line[^"]*">\s*<div[^>]*>\s*<h4>\s*([^<]+?)\s*<\/h4>\s*<\/div>\s*<div[^>]*>\s*(?:<h5[^>]*class="code"[^>]*>([^<]+?)<\/h5>|<p>\s*([^<]+?)\s*<\/p>)\s*<\/div>/gi;
419
- var lm;
420
- while ((lm = lineRe.exec(html))) {
421
- var k = String(lm[1] || '').trim();
422
- var v = String(lm[2] || lm[3] || '').trim();
423
- if (k && v && k !== 'Tên sản phẩm' && k !== 'Mã số') specs[k] = v;
424
- }
425
- return specs;
426
- }
427
-
428
- function extractGallery(html, base, primaryImg) {
429
- var gallery = [];
430
- var gre = /data-fancybox="gallery"[^>]*href="([^"]+)"/gi, gm;
431
- while ((gm = gre.exec(html))) { var u = cleanUrl(gm[1], base); if (u && gallery.indexOf(u) < 0) gallery.push(u); }
432
- var imgRe = /<a[^>]*data-fancybox="gallery"[^>]*>[\s\S]*?<img[^>]*src="([^"]+)"/gi, im;
433
- while ((im = imgRe.exec(html))) { var u2 = cleanUrl(im[1], base); if (u2 && gallery.indexOf(u2) < 0) gallery.push(u2); }
434
- if (primaryImg && gallery.indexOf(primaryImg) < 0) gallery.unshift(primaryImg);
435
- return gallery;
436
- }
437
-
438
- function parseProduct(html, pageUrl) {
439
- var doc = new DOMParser().parseFromString(html, 'text/html');
440
- var ogTitle = getMeta(doc, 'og:title');
441
- var ogDesc = getMeta(doc, 'og:description');
442
- var ogImg = getMeta(doc, 'og:image');
443
- if (!ogImg) ogImg = getMeta(doc, 'twitter:image');
444
- var ld = extractJsonLd(html);
445
-
446
- var tp = (html.match(/Tên sản phẩm<\/h4>[\s\S]{0,300}?<p[^>]*>\s*([^<]+?)\s*<\/p>/i) || [])[1] || '';
447
- var msku = (html.match(/Mã số<\/h4>[\s\S]{0,300}?(?:<h5[^>]*class="code"[^>]*>|<p[^>]*>)\s*([^<]+?)\s*(?:<\/h5>|<\/p>)/i) || [])[1] || '';
448
- var name = (tp && tp.trim()) || ogTitle || (ld && ld.name) || (ogTitle && ogTitle.split(/[|–—-]/)[0].trim()) || 'Sản phẩm';
449
- name = String(name).trim();
450
- var priceInfo = extractPrice(html);
451
- if (!priceInfo.pn && ld && ld.price != null) {
452
- var lp = parseInt(String(ld.price).replace(/[^\d]/g, ''), 10);
453
- if (lp) { priceInfo.pn = lp; priceInfo.price = money(lp); }
454
- }
455
-
456
- var primaryImg = cleanUrl(ogImg, pageUrl);
457
- var gallery = extractGallery(html, pageUrl, primaryImg);
458
- if (!primaryImg && gallery.length) primaryImg = gallery[0];
459
-
460
- var sku = (msku && msku.trim()) || (ld && ld.sku) || '';
461
- if (!sku) sku = (name.match(/[A-Z0-9]{3,}/) || [''])[0];
462
-
463
- var desc = ogDesc || (ld && ld.description) || '';
464
- desc = String(desc || '').replace(/\s*\|\s*[^|]*$/, '');
465
-
466
- var specs = extractSpecs(html);
467
- var brand = getMeta(doc, 'og:site_name') || (ld && ld.brand) || '';
468
- if (!brand) { try { var host = new URL(pageUrl).hostname; brand = host.replace(/^www\./, '').split('.')[0]; } catch (e) {} }
469
- brand = String(brand || '').replace(/^www\./i, '');
470
-
471
- return {
472
- name: name, sku: sku, img: primaryImg, gallery: gallery,
473
- desc: desc, price: priceInfo.price, pn: priceInfo.pn,
474
- specs: specs, brand: brand, ld: ld
475
- };
476
- }
477
-
478
- /* ------------------------- SUBMIT ------------------------- */
479
- /* Two-step add flow:
480
- Step 1 (prepare): scrape (URL) or build (OCR) the product info, fill the
481
- OCR fields + full preview panel. Button switches to "Xác nhận thêm SP".
482
- Step 2 (confirm): persist + add to storefront, then close. */
483
- var __pendingAdd = null;
484
-
485
- window.submitAddProductUrl = async function () {
486
- var st = document.getElementById('addProductStatus');
487
- var secretEl = document.getElementById('addProductSecret');
488
- var urlEl = document.getElementById('addProductUrl');
489
- var submitBtn = document.getElementById('addProductSubmit');
490
-
491
- function status(msg, cls) { if (st) { st.textContent = msg; st.className = 'add-product-status ' + (cls || ''); } }
492
- function restoreBtn() { if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Thêm SP'; } }
493
-
494
- var secret = (secretEl && secretEl.value || '').trim();
495
- if (!isValidSecret(secret)) {
496
- status('❌ Mã truy cập không đúng. Nhập mặc định: ' + LOCAL_SECRET, 'err');
497
- return;
498
- }
499
-
500
- var raw = (urlEl && urlEl.value || '').trim();
501
- var pageUrl = '';
502
- if (raw) {
503
- try { pageUrl = new URL(raw, SITE + '/').href; } catch (e) {
504
- status('❌ URL không hợp lệ.', 'err'); return;
505
- }
506
- }
507
-
508
- var ocrName = (document.getElementById('addOcrName') || {}).value || '';
509
- var ocrSku = (document.getElementById('addOcrSku') || {}).value || '';
510
- var ocrPrice = (document.getElementById('addOcrPrice') || {}).value || '';
511
- var ocrBrand = (document.getElementById('addOcrBrand') || {}).value || '';
512
- var usingOCR = OCR_IMAGES.length > 0;
513
-
514
- if (!raw && !usingOCR) { status('❌ Vui lòng nhập URL sản phẩm HOẶC dán/tải ảnh sản phẩm.', 'err'); return; }
515
-
516
- if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '⏳ Đang phân tích...'; }
517
- var r = null, slug = '';
518
- try {
519
- if (usingOCR && ocrName) {
520
- // Build product directly from OCR + images
521
- var ocrP = parseInt(ocrPrice, 10) || 0;
522
- slug = slugify((ocrSku ? ocrSku + ' ' : '') + ocrName);
523
- r = {
524
- name: ocrName, sku: ocrSku || '', price: ocrP ? money(ocrP) : '', pn: ocrP,
525
- img: OCR_IMAGES[0], gallery: OCR_IMAGES.slice(), desc: '',
526
- brand: ocrBrand || '', specs: {}
527
- };
528
- r.imgs_data = OCR_IMAGES.slice();
529
- } else {
530
- if (!pageUrl && usingOCR) {
531
- status('❌ Đã có ảnh nhưng chưa có tên sản phẩm. Bấm "Trích xuất thông tin từ ảnh (OCR)" để lấy tên, hoặc nhập tên ở bước 3.', 'err');
532
- if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Thêm SP'; }
533
- return;
534
- }
535
- status('⏳ Bước 1: Đang tải & phân tích (scrape) sản phẩm…' + (usingOCR ? ' (sẽ dùng ảnh kèm theo)' : ''));
536
- if (!pageUrl) throw new Error('Không có URL để phân tích.');
537
- r = await scrapeViaApi(pageUrl);
538
- if (!r || !r.name || r.name === 'Sản phẩm') {
539
- var html = await fetchHtml(pageUrl);
540
- r = parseProduct(html, pageUrl);
541
- }
542
- }
543
-
544
- if (!r || !r.name || r.name === 'Sản phẩm') throw new Error('Không lấy được tên sản phẩm.');
545
- if (!r.price && !r.img) throw new Error('Không trích xuất được giá/hình. Kiểm tra URL.');
546
-
547
- slug = slugify((r.sku ? r.sku + ' ' : '') + r.name);
548
-
549
- // Populate OCR fields with scraped/build info so the owner can edit before confirm
550
- if (document.getElementById('addOcrName')) document.getElementById('addOcrName').value = r.name || '';
551
- if (document.getElementById('addOcrSku')) document.getElementById('addOcrSku').value = r.sku || '';
552
- if (document.getElementById('addOcrPrice')) document.getElementById('addOcrPrice').value = r.pn || '';
553
- if (document.getElementById('addOcrBrand')) document.getElementById('addOcrBrand').value = r.brand || '';
554
- var ocrf = document.getElementById('addOcrFields');
555
- if (ocrf) ocrf.style.display = 'block';
556
-
557
- // Store pending product so confirm step uses the (possibly edited) fields + images
558
- __pendingAdd = { r: r, slug: slug, pageUrl: pageUrl, usingOCR: usingOCR };
559
-
560
- // Show full preview + enter confirm mode
561
- enterPreviewMode();
562
- status('Bước 1 xong: đã trích xuất thông tin & hiện preview ở trên. Bạn có thể chỉnh sửa, sau đó bấm "Xác nhận thêm SP" để hoàn tất.', 'ok');
563
- try { if (st) st.scrollIntoView({behavior:'smooth',block:'nearest'}); } catch(e){}
564
- } catch (err) {
565
- status('❌ Lỗi phân tích: ' + (err && err.message || err), 'err');
566
- if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Thêm SP'; }
567
- }
568
- };
569
-
570
- /* ---- Step 2: confirm & persist (uses current edited fields + images) ---- */
571
- window.confirmAddProduct = async function () {
572
- var st = document.getElementById('addProductStatus');
573
- var submitBtn = document.getElementById('addProductSubmit');
574
- function status(msg, cls) { if (st) { st.textContent = msg; st.className = 'add-product-status ' + (cls || ''); } }
575
-
576
- if (!__pendingAdd) { status('❌ Chưa có sản phẩm để xác nhận.', 'err'); return; }
577
- var prev = __pendingAdd;
578
-
579
- // Read current (possibly edited) values
580
- var name = (document.getElementById('addOcrName') || {}).value || '';
581
- var sku = (document.getElementById('addOcrSku') || {}).value || '';
582
- var price = parseInt((document.getElementById('addOcrPrice') || {}).value, 10) || 0;
583
- var brand = (document.getElementById('addOcrBrand') || {}).value || '';
584
- if (!name) { status('❌ Chưa có tên sản phẩm.', 'err'); return; }
585
-
586
- var r = {
587
- name: name, sku: sku, price: price ? money(price) : '', pn: price,
588
- img: OCR_IMAGES[0] || prev.r.img, gallery: OCR_IMAGES.length ? OCR_IMAGES.slice() : (prev.r.gallery || []),
589
- desc: (document.getElementById('addOcrDesc') || {}).value || prev.r.desc || '',
590
- brand: brand, specs: prev.r.specs || {}
591
- };
592
- r.imgs_data = OCR_IMAGES.length ? OCR_IMAGES.slice() : undefined;
593
- var slug = slugify((sku ? sku + ' ' : '') + name);
594
-
595
- // Merge edited OCR fields that were scraped but not otherwise edited
596
- if (!OCR_IMAGES.length && !r.gallery.length) r.gallery = r.img ? [r.img] : [];
597
- if (r.img && r.gallery.indexOf(r.img) < 0) r.gallery.unshift(r.img);
598
-
599
- if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '⏳ Đang lưu…'; }
600
- try {
601
- var baseRec = {
602
- n: name, name: name, sku: sku, mod: sku, model: sku,
603
- slug: slug, p: r.price, price: r.price, pn: r.pn, priceNum: r.pn,
604
- i: r.img, image: r.img, imgs: r.gallery, images: r.gallery,
605
- c: (document.getElementById('addOcrCat') || {}).value || (prev.usingOCR ? 'Sản phẩm thêm qua OCR' : 'Sản phẩm thêm qua URL'),
606
- cs: 'san-pham-them-qua-url', ci: 'fa-box',
607
- sum: r.desc, summary: r.desc, desc: r.desc,
608
- specs: r.specs, feats: [], brand: brand,
609
- _sourceUrl: prev.pageUrl, _url_added: true
610
- };
611
-
612
- var list = loadUrlAdded();
613
- var exists = list.findIndex(function (x) { return x.slug === slug || (x.sku && sku && x.sku === sku); });
614
- if (exists >= 0) list[exists] = baseRec; else list.push(baseRec);
615
- persistUrlAdded(list);
616
-
617
- if (typeof window.D !== 'undefined' && Array.isArray(window.D)) {
618
- var dup = window.D.find(function (p) { return p.slug === slug || (p.sku && sku && p.sku === sku); });
619
- if (dup) { var k = window.D.indexOf(dup); window.D[k] = Object.assign({}, dup, baseRec); }
620
- else { window.D.unshift(Object.assign({}, window.D[0] || {}, baseRec)); }
621
- }
622
- if (typeof window.initFilters === 'function') { try { window.initFilters(); } catch (e) {} }
623
- if (typeof window.render === 'function') { try { window.render(); } catch (e) {} }
624
-
625
- var persisted = false;
626
- try {
627
- var ps = await fetch(PERSIST_API, {
628
- method: 'POST', headers: { 'Content-Type': 'application/json' },
629
- body: JSON.stringify({
630
- token: SCRAPER_TOKEN,
631
- products: [{
632
- name: name, sku: sku, slug: slug, price: r.price, pn: r.pn,
633
- img: r.img, gallery: r.gallery, desc: r.desc, brand: brand,
634
- url: prev.pageUrl, specs: r.specs || {},
635
- imgs_data: r.imgs_data
636
- }]
637
- })
638
- });
639
- if (ps.ok) { var pj = await ps.json(); persisted = !!(pj && pj.persisted); }
640
- } catch (e) {}
641
- if (!persisted) persistUrlAdded(list);
642
-
643
- status('✅ Đã thêm: "' + name + '" (Mã ' + (sku || '?') + ')' +
644
- '\nGiá: ' + (r.price || 'Liên hệ') + ' | ' + (r.gallery.length || 0) + ' ảnh' +
645
- '\nSEO: /san-pham/' + slug + '/index.html' +
646
- (persisted ? '\n✓ Đã lưu vĩnh viễn (không mất khi tải lại trang).' : '\n⚠ Lưu tạm thời (localStorage) — server persist lỗi.'),
647
- 'ok');
648
- __pendingAdd = null;
649
- setTimeout(function(){ window.closeAddProductModal(); document.getElementById('addProductSubmit').textContent='Thêm SP'; document.getElementById('addProductSubmit').removeAttribute('data-confirm'); }, 2200);
650
- } catch (err) {
651
- status('❌ Lỗi: ' + (err && err.message || err), 'err');
652
- if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = '✅ Xác nhận thêm SP'; }
653
- }
654
- };
655
-
656
- // Show preview panel + enter confirm mode (shared by prepare & OCR paths).
657
- function enterPreviewMode() {
658
- buildAddPreview();
659
- var wrap = document.getElementById('addPreviewWrap');
660
- if (wrap) wrap.style.display = 'block';
661
- var bb = document.getElementById('addBackBtn');
662
- if (bb) bb.style.display = 'inline-flex';
663
- var subBtn = document.getElementById('addProductSubmit');
664
- if (subBtn) { subBtn.textContent = '✅ Xác nhận thêm SP'; subBtn.setAttribute('data-confirm','1'); subBtn.disabled=false; }
665
- }
666
-
667
- /* ---- Back from preview: return to URL/OCR input step ---- */
668
- window.goBackFromPreview = function () {
669
- __pendingAdd = null;
670
- var wrap = document.getElementById('addPreviewWrap');
671
- if (wrap) wrap.style.display = 'none';
672
- var bb = document.getElementById('addBackBtn');
673
- if (bb) bb.style.display = 'none';
674
- var subBtn = document.getElementById('addProductSubmit');
675
- if (subBtn) { subBtn.textContent = 'Thêm SP'; subBtn.removeAttribute('data-confirm'); subBtn.disabled = false; }
676
- var st = document.getElementById('addProductStatus');
677
- if (st) { st.textContent = ''; st.className = 'add-product-status'; }
678
- // scroll back up to the input section
679
- var urlEl = document.getElementById('addProductUrl');
680
- if (urlEl) { try { urlEl.scrollIntoView({behavior:'smooth',block:'start'}); } catch(e){} }
681
- };
682
-
683
- console.log('[VAI AddProd Fix v2] Loaded: modal + secret + external-URL scraping active.');
684
- })();
 
1
+ PLACEHOLDER_READ_REQUIRED